├── .dockerignore ├── .gitignore ├── .nvmrc ├── .prettierrc ├── Dockerfile ├── LICENSE ├── README.md ├── copy404.js ├── eslint.config.js ├── index.html ├── package.json ├── public ├── album.fill.svg ├── apple-touch-icon.png ├── artist-dark-variant.webp ├── artist-light-variant.webp ├── artist.svg ├── clear.svg ├── dark-light-variant-tiny-split.webp ├── dark-variant-tiny.webp ├── dark-variant.webp ├── default-thumbnail.original.png ├── default-thumbnail.png ├── favicon-96x96.png ├── favicon.ico ├── light-dark-variant-tiny-split.webp ├── light-variant-tiny.webp ├── light-variant.webp ├── logo.png ├── logo.webp ├── music.note.svg ├── music.notes.svg ├── noscript.css ├── pause.circle.fill.svg ├── pause.fill.svg ├── pause.svg ├── play.circle.fill.svg ├── play.fill.svg ├── play.svg ├── player-next.svg ├── player-repeat-one.svg ├── player-repeat.svg ├── player-shuffle.svg ├── playlist-dark-variant.webp ├── playlist-light-variant.webp ├── playlist.svg ├── robots.txt ├── search-dark-variant.png ├── search-light-variant.png ├── search-results-dark-variant.webp ├── search-results-light-variant.webp ├── search.svg ├── web-app-manifest-192x192.png └── web-app-manifest-512x512.png ├── src ├── App.css ├── App.tsx ├── api │ └── jellyfin.ts ├── components │ ├── AuthForm.tsx │ ├── Dropdown.css │ ├── Dropdown.tsx │ ├── InlineLoader.css │ ├── InlineLoader.tsx │ ├── JellyImg.tsx │ ├── Loader.css │ ├── Loader.tsx │ ├── Main.tsx │ ├── MediaList.css │ ├── MediaList.tsx │ ├── PlaybackManager.ts │ ├── PlaylistTrackList.css │ ├── PlaylistTrackList.tsx │ ├── Sidenav.css │ ├── Sidenav.tsx │ ├── Skeleton.css │ ├── Skeleton.tsx │ ├── SvgIcons.tsx │ ├── TrackList.css │ └── TrackList.tsx ├── context │ ├── DropdownContext │ │ ├── DropdownContext.ts │ │ └── DropdownContextProvider.tsx │ ├── FilterContext │ │ ├── FilterContext.ts │ │ └── FilterContextProvider.tsx │ ├── HistoryContext │ │ ├── HistoryContext.ts │ │ └── HistoryContextProvider.tsx │ ├── JellyfinContext │ │ ├── JellyfinContext.ts │ │ └── JellyfinContextProvider.tsx │ ├── PageTitleContext │ │ ├── PageTitleContext.ts │ │ └── PageTitleProvider.tsx │ ├── PlaybackContext │ │ ├── PlaybackContext.ts │ │ └── PlaybackContextProvider.tsx │ ├── ScrollContext │ │ ├── ScrollContext.ts │ │ └── ScrollContextProvider.tsx │ ├── SidenavContext │ │ ├── SidenavContext.ts │ │ └── SidenavContextProvider.tsx │ └── ThemeContext │ │ ├── ThemeContext.ts │ │ └── ThemeContextProvider.tsx ├── fontsource.d.ts ├── hooks │ ├── Jellyfin │ │ ├── Infinite │ │ │ ├── useJellyfinAlbumsData.ts │ │ │ ├── useJellyfinArtistTracksData.ts │ │ │ ├── useJellyfinFavoritesData.ts │ │ │ ├── useJellyfinFrequentlyPlayedData.ts │ │ │ ├── useJellyfinGenreTracks.ts │ │ │ ├── useJellyfinInfiniteData.ts │ │ │ ├── useJellyfinPlaylistData.ts │ │ │ ├── useJellyfinRecentlyPlayedData.ts │ │ │ └── useJellyfinTracksData.ts │ │ ├── useJellyfinAlbumData.ts │ │ ├── useJellyfinArtistData.ts │ │ ├── useJellyfinHomeData.ts │ │ ├── useJellyfinPlaylistsFeaturingArtist.ts │ │ └── useJellyfinPlaylistsList.ts │ ├── useDependencyDebug.ts │ ├── useDisplayItems.ts │ ├── useDocumentTitle.ts │ ├── useFavorites.ts │ ├── usePatchQueries.ts │ └── usePlaylists.ts ├── main.tsx ├── pages │ ├── Album.css │ ├── Album.tsx │ ├── Albums.tsx │ ├── Artist.css │ ├── Artist.tsx │ ├── ArtistTracks.tsx │ ├── Favorites.tsx │ ├── FrequentlyPlayed.tsx │ ├── Genre.tsx │ ├── Home.tsx │ ├── Login.tsx │ ├── Playlist.css │ ├── Playlist.tsx │ ├── Queue.css │ ├── Queue.tsx │ ├── RecentlyPlayed.tsx │ ├── SearchResults.css │ ├── SearchResults.tsx │ ├── Settings.css │ ├── Settings.tsx │ └── Tracks.tsx ├── types.d.ts ├── utils │ ├── formatDate.ts │ ├── formatDuration.ts │ ├── formatDurationReadable.ts │ ├── getAllTracks.ts │ └── titleUtils.ts └── vite-env.d.ts ├── tsconfig.app.json ├── tsconfig.json ├── tsconfig.node.json ├── vite.config.ts └── yarn.lock /.dockerignore: -------------------------------------------------------------------------------- 1 | # Local packages and built application 2 | node_modules 3 | dist 4 | 5 | # Documentation 6 | LICENSE 7 | README.md 8 | 9 | # Docker files 10 | Dockerfile -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | 26 | deploy.sh -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | v22.14.0 -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "arrowParens": "avoid", 3 | "bracketSpacing": true, 4 | "semi": false, 5 | "singleQuote": true, 6 | "tabWidth": 4, 7 | "trailingComma": "es5", 8 | "printWidth": 120 9 | } 10 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # Build the program 2 | FROM node:22 AS builder 3 | 4 | WORKDIR /app 5 | 6 | # Install packages 7 | COPY package.json . 8 | COPY *.lock . 9 | 10 | RUN yarn install 11 | 12 | # Copy source 13 | COPY . . 14 | 15 | # Variables 16 | ARG NODE_ENV=production 17 | ARG VITE_LOCK_JELLYFIN_URL="" 18 | ARG VITE_DEFAULT_JELLYFIN_URL="" 19 | 20 | ENV NODE_ENV=$NODE_ENV 21 | ENV VITE_LOCK_JELLYFIN_URL=$VITE_LOCK_JELLYFIN_URL 22 | ENV VITE_DEFAULT_JELLYFIN_URL=$VITE_DEFAULT_JELLYFIN_URL 23 | 24 | # Build application 25 | RUN yarn build 26 | 27 | # Serves with nginx 28 | FROM nginx:mainline-alpine AS server 29 | 30 | COPY --from=builder /app/dist /usr/share/nginx/html -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) Jelly Music App and Developers 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## Jelly Music App (JMA) 2 | 3 | A lightweight & elegant music interface for Jellyfin. Made to be intuitive and minimal with great attention to detail, a clutter-free web app centered on music playback. Using the Jellyfin API, it provides seamless access to your personal music library. [Demo](https://stannnnn.github.io/jelly-app/login?demo=1) 4 |
5 | 6 |
7 | Light variant 8 | Dark variant 9 |
10 |
11 | 12 |
13 | Additional screenshots 14 |
15 | Sidenav search 16 |

Search for tracks, artists, albums, playlists, genres

17 | Sidenav search light variant 18 | Sidenav search dark variant 19 |
20 |
21 | Search results 22 |

View additional search results in a dedicated window

23 | Search results light variant 24 | Search results dark variant 25 |
26 |
27 | Artists 28 |

Features most played songs, albums, and other collaborations

29 | Artist light variant 30 | Artist dark variant 31 |
32 |
33 | Playlists 34 |

Playlist view, with it's own numbered tracklist

35 | Playlist light variant 36 | Playlist dark variant 37 |
38 | 39 | ### Features 40 | 41 | - **Elegant & Simple Design:** A clean, clutter-free interface that makes music playback effortless and enjoyable. 42 | - **Seamless Library Access:** Connect to your Jellyfin server to explore your personal music collection with ease. 43 | - **Discover Your Favorites:** 44 | - **Home:** Jump back in with recently played tracks, your most-played favorites, and newly added media. 45 | - **Artists:** Browse top tracks, albums, and collaborations for any artist in your library. 46 | - **Playlists:** View playlists with a clear, numbered tracklist for quick navigation. 47 | - **Quick Search:** Find tracks, artists, albums, playlists, or genres effortlessly with a sidenav search or dedicated results page. 48 | - **Device Friendly:** Enjoy a smooth, app-like experience on mobile and desktop alike, installable as a PWA for instant access. 49 | - **Smooth Performance:** Built with modern tools like React for a snappy, reliable experience. 50 | - **Smart Fetching:** Caches your music efficiently for instant, smooth playback. 51 | - **Personalized Settings:** Easily configure your theme and audio quality for a tailored experience. 52 | 53 | ### Installation 54 | 55 | Jelly Music App is available as a production build, ready to deploy on an existing web server. Download the latest release from our project's [GitHub release page](https://github.com/Stannnnn/jelly-app/releases) and place the contents of the archived folder in a web-accessible directory. 56 |
57 |
58 | 59 | [Yarn](https://classic.yarnpkg.com/lang/en/docs/install) (`npm i -g yarn`) is required if you wish to build the project or run the development server yourself. 60 | 61 | #### Build from Source 62 | 63 | 1. Clone the repository: 64 | ```bash 65 | git clone https://github.com/Stannnnn/jelly-app.git 66 | ``` 67 | 2. Install dependencies: 68 | ```bash 69 | yarn 70 | ``` 71 | 3. Build the production files: 72 | ```bash 73 | yarn build 74 | ``` 75 | 4. Deploy the contents of the `dist` folder to a web-accessible directory. 76 |
77 | 78 | Alternatively, you can run the development server directly: `yarn dev` or `yarn dev:nocache` 79 | -------------------------------------------------------------------------------- /copy404.js: -------------------------------------------------------------------------------- 1 | import fs from 'fs' 2 | 3 | fs.copyFileSync('dist/index.html', 'dist/404.html') -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | import js from '@eslint/js' 2 | import reactHooks from 'eslint-plugin-react-hooks' 3 | import reactRefresh from 'eslint-plugin-react-refresh' 4 | import globals from 'globals' 5 | import tseslint from 'typescript-eslint' 6 | 7 | export default tseslint.config( 8 | { ignores: ['dist'] }, 9 | { 10 | extends: [js.configs.recommended, ...tseslint.configs.recommended], 11 | files: ['**/*.{ts,tsx}'], 12 | languageOptions: { 13 | ecmaVersion: 2020, 14 | globals: globals.browser, 15 | }, 16 | plugins: { 17 | 'react-hooks': reactHooks, 18 | 'react-refresh': reactRefresh, 19 | }, 20 | rules: { 21 | ...reactHooks.configs.recommended.rules, 22 | 'react-refresh/only-export-components': ['warn', { allowConstantExport: true }], 23 | '@typescript-eslint/no-unused-vars': [ 24 | 'warn', 25 | { 26 | varsIgnorePattern: '^_', 27 | argsIgnorePattern: '^_', 28 | }, 29 | ], 30 | }, 31 | } 32 | ) 33 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 15 | 16 | 17 | 18 | 19 | 20 | Jelly Music App 21 | 22 | 23 |
24 | 69 |
70 | 71 | 72 | 73 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "jellyfin-music-client", 3 | "private": true, 4 | "version": "0.1.0", 5 | "type": "module", 6 | "packageManager": "yarn@1.22.22", 7 | "scripts": { 8 | "dev": "vite", 9 | "dev:nocache": "vite", 10 | "build": "tsc -b && vite build", 11 | "lint": "eslint .", 12 | "preview": "vite preview", 13 | "deploy": "tsc -b && vite build && node copy404.js && gh-pages -d dist" 14 | }, 15 | "dependencies": { 16 | "@fontsource-variable/inter": "^5.2.5", 17 | "@jellyfin/sdk": "^0.11.0", 18 | "@primer/octicons-react": "^19.15.2", 19 | "@tanstack/query-sync-storage-persister": "^5.77.0", 20 | "@tanstack/react-query": "^5.77.0", 21 | "@tanstack/react-query-persist-client": "^5.77.0", 22 | "@types/node": "^22.15.21", 23 | "axios": "^1.9.0", 24 | "hls.js": "^1.6.2", 25 | "react": "^19.1.0", 26 | "react-dom": "^19.1.0", 27 | "react-router-dom": "7.6.0", 28 | "react-virtuoso": "^4.12.7" 29 | }, 30 | "devDependencies": { 31 | "@eslint/js": "^9.27.0", 32 | "@types/react": "^19.1.5", 33 | "@types/react-dom": "^19.1.5", 34 | "@types/react-router-dom": "^5.3.3", 35 | "@vitejs/plugin-react": "^4.5.0", 36 | "eslint": "^9.27.0", 37 | "eslint-plugin-react-hooks": "^5.1.0", 38 | "eslint-plugin-react-refresh": "^0.4.20", 39 | "gh-pages": "^6.3.0", 40 | "globals": "^16.1.0", 41 | "typescript": "~5.8.3", 42 | "typescript-eslint": "^8.32.1", 43 | "vite": "^6.3.5", 44 | "vite-plugin-pwa": "^1.0.0" 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /public/album.fill.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /public/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Stannnnn/jelly-app/a0edc54ac2b978289584f0f837cfef25b9e67133/public/apple-touch-icon.png -------------------------------------------------------------------------------- /public/artist-dark-variant.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Stannnnn/jelly-app/a0edc54ac2b978289584f0f837cfef25b9e67133/public/artist-dark-variant.webp -------------------------------------------------------------------------------- /public/artist-light-variant.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Stannnnn/jelly-app/a0edc54ac2b978289584f0f837cfef25b9e67133/public/artist-light-variant.webp -------------------------------------------------------------------------------- /public/artist.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /public/clear.svg: -------------------------------------------------------------------------------- 1 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /public/dark-light-variant-tiny-split.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Stannnnn/jelly-app/a0edc54ac2b978289584f0f837cfef25b9e67133/public/dark-light-variant-tiny-split.webp -------------------------------------------------------------------------------- /public/dark-variant-tiny.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Stannnnn/jelly-app/a0edc54ac2b978289584f0f837cfef25b9e67133/public/dark-variant-tiny.webp -------------------------------------------------------------------------------- /public/dark-variant.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Stannnnn/jelly-app/a0edc54ac2b978289584f0f837cfef25b9e67133/public/dark-variant.webp -------------------------------------------------------------------------------- /public/default-thumbnail.original.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Stannnnn/jelly-app/a0edc54ac2b978289584f0f837cfef25b9e67133/public/default-thumbnail.original.png -------------------------------------------------------------------------------- /public/default-thumbnail.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Stannnnn/jelly-app/a0edc54ac2b978289584f0f837cfef25b9e67133/public/default-thumbnail.png -------------------------------------------------------------------------------- /public/favicon-96x96.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Stannnnn/jelly-app/a0edc54ac2b978289584f0f837cfef25b9e67133/public/favicon-96x96.png -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Stannnnn/jelly-app/a0edc54ac2b978289584f0f837cfef25b9e67133/public/favicon.ico -------------------------------------------------------------------------------- /public/light-dark-variant-tiny-split.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Stannnnn/jelly-app/a0edc54ac2b978289584f0f837cfef25b9e67133/public/light-dark-variant-tiny-split.webp -------------------------------------------------------------------------------- /public/light-variant-tiny.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Stannnnn/jelly-app/a0edc54ac2b978289584f0f837cfef25b9e67133/public/light-variant-tiny.webp -------------------------------------------------------------------------------- /public/light-variant.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Stannnnn/jelly-app/a0edc54ac2b978289584f0f837cfef25b9e67133/public/light-variant.webp -------------------------------------------------------------------------------- /public/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Stannnnn/jelly-app/a0edc54ac2b978289584f0f837cfef25b9e67133/public/logo.png -------------------------------------------------------------------------------- /public/logo.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Stannnnn/jelly-app/a0edc54ac2b978289584f0f837cfef25b9e67133/public/logo.webp -------------------------------------------------------------------------------- /public/music.note.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /public/music.notes.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /public/noscript.css: -------------------------------------------------------------------------------- 1 | a.textlink { 2 | color: var(--link-color); 3 | outline: none; 4 | text-decoration: none; 5 | } 6 | 7 | a.textlink:hover { 8 | text-decoration: underline; 9 | } 10 | 11 | :root { 12 | --system-fonts: -apple-system, system-ui, BlinkMacSystemFont, 'Segoe UI', 'Helvetica Neue', Arial, sans-serif; 13 | --inter-font: 'Inter', sans-serif; 14 | color-scheme: light; 15 | --bg-color-secondary: #f8f8f8; 16 | --font-color: #303030; 17 | --font-color-tertiary: #767676; 18 | --border-color: #ececec; 19 | --link-color: #a386e7; 20 | } 21 | 22 | html, 23 | body { 24 | font-family: var(--system-fonts); 25 | font-family: var(--inter-font); 26 | font-optical-sizing: auto; 27 | font-weight: 400; 28 | font-size: 16px; 29 | color: var(--font-color); 30 | text-rendering: optimizeLegibility; 31 | -moz-osx-font-smoothing: grayscale; 32 | -webkit-font-smoothing: antialiased; 33 | -webkit-text-size-adjust: 100%; 34 | -webkit-tap-highlight-color: rgba(0, 0, 0, 0); 35 | margin: 0; 36 | padding: 0; 37 | width: 100%; 38 | height: 100%; 39 | background-color: var(--bg-color-secondary); 40 | } 41 | 42 | body { 43 | overflow: auto; 44 | overflow-x: hidden; 45 | -webkit-overflow-scrolling: touch; 46 | } 47 | 48 | #root { 49 | height: 100%; 50 | } 51 | 52 | .drawback_interface { 53 | position: relative; 54 | height: 100%; 55 | max-width: 1020px; 56 | margin: 0 auto; 57 | display: flex; 58 | align-items: center; 59 | flex-direction: column; 60 | gap: 100px; 61 | } 62 | 63 | .drawback_interface > .container { 64 | display: flex; 65 | align-items: center; 66 | gap: 60px; 67 | } 68 | 69 | .drawback_interface > .container > .drawback { 70 | display: flex; 71 | flex-direction: column; 72 | align-items: center; 73 | position: relative; 74 | width: 100%; 75 | max-width: 360px; 76 | flex-shrink: 0; 77 | } 78 | 79 | .drawback_interface > .container > .drawback > .drawback_header { 80 | padding: 60px; 81 | margin-bottom: 40px; 82 | } 83 | 84 | .drawback_interface > .container > .drawback > .drawback_header > .container { 85 | display: flex; 86 | align-items: center; 87 | justify-content: center; 88 | cursor: pointer; 89 | } 90 | 91 | .drawback_interface > .container > .drawback > .drawback_header > .container > .logo { 92 | position: relative; 93 | } 94 | 95 | .drawback_interface > .container > .drawback > .drawback_header > .container > .logo > .image { 96 | display: block; 97 | width: 80px; 98 | height: 80px; 99 | background-size: contain; 100 | background-repeat: no-repeat; 101 | background-image: url(./logo.webp); 102 | } 103 | 104 | .drawback_interface > .container > .drawback > .drawback_content { 105 | display: flex; 106 | flex-direction: column; 107 | align-items: center; 108 | margin-bottom: 80px; 109 | } 110 | 111 | .drawback_interface > .container > .drawback > .drawback_content > .greeting { 112 | margin-bottom: 20px; 113 | } 114 | 115 | .drawback_interface > .container > .drawback > .drawback_content > .greeting > .title { 116 | font-size: 1.75rem; 117 | font-weight: 800; 118 | letter-spacing: 0.5px; 119 | text-align: center; 120 | } 121 | 122 | .drawback_interface > .container > .drawback > .drawback_content > .description { 123 | display: flex; 124 | flex-direction: column; 125 | align-items: center; 126 | line-height: 1.8; 127 | } 128 | 129 | .drawback_interface > .container > .drawback > .drawback_content > .description > blockquote { 130 | margin: 0; 131 | } 132 | 133 | .drawback_interface > .container > .drawback > .drawback_content > .description > blockquote > p { 134 | font-size: 0.9rem; 135 | font-weight: 500; 136 | font-style: italic; 137 | text-indent: -16px; 138 | margin: 10px 20px; 139 | } 140 | 141 | .drawback_interface > .container > .drawback > .drawback_content > .description > blockquote > p::before { 142 | content: '\201C'; 143 | font-size: 1rem; 144 | font-weight: 700; 145 | line-height: normal; 146 | margin-right: 4px; 147 | } 148 | 149 | .drawback_interface > .container > .drawback > .drawback_content > .description > blockquote > p::after { 150 | content: '\201D'; 151 | font-size: 1rem; 152 | font-weight: 700; 153 | line-height: normal; 154 | } 155 | 156 | .drawback_interface > .container > .drawback > .drawback_content > .description > .conclusion { 157 | font-size: 0.725rem; 158 | color: var(--font-color-tertiary); 159 | margin-top: 10px; 160 | padding-top: 10px; 161 | border-top: 1px solid var(--border-color); 162 | } 163 | 164 | .drawback_interface > .container > .drawback > .drawback_content > .description > .conclusion > p { 165 | margin: 0; 166 | margin-top: 10px; 167 | margin-left: 10px; 168 | } 169 | 170 | .drawback_interface > .container > .preview { 171 | position: relative; 172 | display: block; 173 | width: calc(100% - 100px); 174 | height: calc(100% - 100px); 175 | } 176 | 177 | .drawback_interface > .container > .preview > .thumbnail { 178 | max-width: 100%; 179 | max-height: 100%; 180 | border-radius: 2.6%; 181 | box-shadow: 0 0 40px rgba(0, 0, 0, 0.14); 182 | } 183 | 184 | .drawback_interface > .disclaimer { 185 | margin-top: auto; 186 | padding: 12px 20px; 187 | font-size: 0.725rem; 188 | font-weight: 300; 189 | color: var(--font-color-tertiary); 190 | text-align: center; 191 | } 192 | 193 | /* Responsive */ 194 | @media only screen and (max-width: 980px) { 195 | .drawback_interface { 196 | max-width: calc(100% - 40px); 197 | } 198 | 199 | .drawback_interface > .container { 200 | flex-direction: column; 201 | gap: 0; 202 | } 203 | 204 | .drawback_interface > .container > .drawback > .drawback_header { 205 | padding: 40px; 206 | } 207 | } 208 | 209 | @media only screen and (max-width: 480px) { 210 | .drawback_interface { 211 | max-width: calc(100% - 20px); 212 | } 213 | 214 | .drawback_interface > .container > .preview { 215 | width: 100%; 216 | } 217 | } 218 | -------------------------------------------------------------------------------- /public/pause.circle.fill.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /public/pause.fill.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /public/pause.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/play.circle.fill.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /public/play.fill.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /public/play.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/player-next.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /public/player-repeat-one.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/player-repeat.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /public/player-shuffle.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /public/playlist-dark-variant.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Stannnnn/jelly-app/a0edc54ac2b978289584f0f837cfef25b9e67133/public/playlist-dark-variant.webp -------------------------------------------------------------------------------- /public/playlist-light-variant.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Stannnnn/jelly-app/a0edc54ac2b978289584f0f837cfef25b9e67133/public/playlist-light-variant.webp -------------------------------------------------------------------------------- /public/playlist.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /public/robots.txt: -------------------------------------------------------------------------------- 1 | User-agent: * 2 | Disallow: / -------------------------------------------------------------------------------- /public/search-dark-variant.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Stannnnn/jelly-app/a0edc54ac2b978289584f0f837cfef25b9e67133/public/search-dark-variant.png -------------------------------------------------------------------------------- /public/search-light-variant.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Stannnnn/jelly-app/a0edc54ac2b978289584f0f837cfef25b9e67133/public/search-light-variant.png -------------------------------------------------------------------------------- /public/search-results-dark-variant.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Stannnnn/jelly-app/a0edc54ac2b978289584f0f837cfef25b9e67133/public/search-results-dark-variant.webp -------------------------------------------------------------------------------- /public/search-results-light-variant.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Stannnnn/jelly-app/a0edc54ac2b978289584f0f837cfef25b9e67133/public/search-results-light-variant.webp -------------------------------------------------------------------------------- /public/search.svg: -------------------------------------------------------------------------------- 1 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /public/web-app-manifest-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Stannnnn/jelly-app/a0edc54ac2b978289584f0f837cfef25b9e67133/public/web-app-manifest-192x192.png -------------------------------------------------------------------------------- /public/web-app-manifest-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Stannnnn/jelly-app/a0edc54ac2b978289584f0f837cfef25b9e67133/public/web-app-manifest-512x512.png -------------------------------------------------------------------------------- /src/App.tsx: -------------------------------------------------------------------------------- 1 | import '@fontsource-variable/inter' 2 | import { createSyncStoragePersister } from '@tanstack/query-sync-storage-persister' 3 | import { QueryClient, QueryClientProvider, useQueryClient } from '@tanstack/react-query' 4 | import { PersistQueryClientProvider } from '@tanstack/react-query-persist-client' 5 | import { useCallback, useEffect, useState } from 'react' 6 | import { Navigate, Route, BrowserRouter as Router, Routes } from 'react-router-dom' 7 | import './App.css' 8 | import { Dropdown } from './components/Dropdown' 9 | import { Main } from './components/Main' 10 | import './components/MediaList.css' 11 | import { Sidenav } from './components/Sidenav' 12 | import { useDropdownContext } from './context/DropdownContext/DropdownContext' 13 | import { DropdownContextProvider } from './context/DropdownContext/DropdownContextProvider' 14 | import { HistoryContextProvider } from './context/HistoryContext/HistoryContextProvider' 15 | import { JellyfinContextProvider } from './context/JellyfinContext/JellyfinContextProvider' 16 | import { PageTitleProvider } from './context/PageTitleContext/PageTitleProvider' 17 | import { PlaybackContextProvider } from './context/PlaybackContext/PlaybackContextProvider' 18 | import { ScrollContextProvider } from './context/ScrollContext/ScrollContextProvider' 19 | import { useSidenavContext } from './context/SidenavContext/SidenavContext' 20 | import { SidenavContextProvider } from './context/SidenavContext/SidenavContextProvider' 21 | import { ThemeContextProvider } from './context/ThemeContext/ThemeContextProvider' 22 | import { useDocumentTitle } from './hooks/useDocumentTitle' 23 | import { Album } from './pages/Album' 24 | import { Albums } from './pages/Albums' 25 | import { Artist } from './pages/Artist' 26 | import { ArtistTracks } from './pages/ArtistTracks' 27 | import { Favorites } from './pages/Favorites' 28 | import { FrequentlyPlayed } from './pages/FrequentlyPlayed' 29 | import { Genre } from './pages/Genre' 30 | import { Home } from './pages/Home' 31 | import { Login } from './pages/Login' 32 | import { Playlist } from './pages/Playlist' 33 | import { Queue } from './pages/Queue' 34 | import { RecentlyPlayed } from './pages/RecentlyPlayed' 35 | import { SearchResults } from './pages/SearchResults' 36 | import { Settings } from './pages/Settings' 37 | import { Tracks } from './pages/Tracks' 38 | 39 | const queryClient = new QueryClient({ 40 | defaultOptions: { 41 | queries: { 42 | staleTime: 1000 * 60 * 5, // 5 minutes 43 | }, 44 | }, 45 | }) 46 | 47 | const persister = createSyncStoragePersister({ 48 | storage: window.localStorage, 49 | }) 50 | 51 | export const App = () => { 52 | return ( 53 | <> 54 | {window.__NPM_LIFECYCLE_EVENT__ === 'dev:nocache' ? ( 55 | 56 | 57 | 58 | ) : ( 59 | 60 | 61 | 62 | )} 63 | 64 | ) 65 | } 66 | 67 | const RoutedApp = () => { 68 | const [auth, setAuth] = useState(() => { 69 | const savedAuth = localStorage.getItem('auth') 70 | return savedAuth ? JSON.parse(savedAuth) : null 71 | }) 72 | const [isLoggingOut, setIsLoggingOut] = useState(false) 73 | const queryClient = useQueryClient() 74 | 75 | const handleLogin = (authData: AuthData) => { 76 | setAuth(authData) 77 | localStorage.setItem('auth', JSON.stringify(authData)) 78 | } 79 | 80 | const handleLogout = () => { 81 | setIsLoggingOut(true) 82 | localStorage.removeItem('repeatMode') 83 | setAuth(null) 84 | localStorage.removeItem('auth') 85 | setIsLoggingOut(false) 86 | queryClient.clear() 87 | } 88 | 89 | useEffect(() => { 90 | if (!auth) { 91 | localStorage.removeItem('auth') 92 | } 93 | }, [auth]) 94 | 95 | useEffect(() => { 96 | const isWindows = /Win/.test(navigator.userAgent) 97 | const isChromium = /Chrome/.test(navigator.userAgent) && /Google Inc/.test(navigator.vendor) 98 | const isEdge = /Edg/.test(navigator.userAgent) && /Microsoft Corporation/.test(navigator.vendor) 99 | if (isWindows && (isChromium || isEdge)) { 100 | document.getElementsByTagName('html')[0].classList.add('winOS') 101 | } else { 102 | document.getElementsByTagName('html')[0].classList.add('otherOS') 103 | } 104 | }, []) 105 | 106 | const actualApp = ( 107 |
108 | 109 | : } /> 110 | 115 | 116 | 117 | 118 | 119 | 120 | 121 | 122 | 123 | 124 | ) : ( 125 | 126 | ) 127 | } 128 | /> 129 | 130 |
131 | ) 132 | 133 | return ( 134 | 135 | 136 | 137 | 138 | {actualApp} 139 | 140 | 141 | 142 | 143 | ) 144 | } 145 | 146 | interface AuthData { 147 | serverUrl: string 148 | token: string 149 | userId: string 150 | username: string 151 | } 152 | 153 | const MainLayout = ({ auth, handleLogout }: { auth: AuthData; handleLogout: () => void }) => { 154 | useDocumentTitle() 155 | 156 | const { showSidenav, toggleSidenav } = useSidenavContext() 157 | const dropdownContext = useDropdownContext() 158 | const isDropdownOpen = dropdownContext?.isOpen || false 159 | const isTouchDevice = dropdownContext?.isTouchDevice || false 160 | 161 | const memoSettings = useCallback(() => { 162 | return 163 | }, [handleLogout]) 164 | 165 | return ( 166 |
167 |
171 | 172 | 173 | 174 | 175 | } /> 176 | } /> 177 | } /> 178 | } /> 179 | } /> 180 | } /> 181 | } /> 182 | } /> 183 | } /> 184 | } /> 185 | } /> 186 | } /> 187 | } /> 188 | } /> 189 | } /> 190 | 191 |
192 | ) 193 | } 194 | -------------------------------------------------------------------------------- /src/components/AuthForm.tsx: -------------------------------------------------------------------------------- 1 | import { FormEvent, useState } from 'react' 2 | import { ApiError, loginToJellyfin } from '../api/jellyfin' 3 | 4 | export const AuthForm = ({ 5 | onLogin, 6 | }: { 7 | onLogin: (authData: { serverUrl: string; token: string; userId: string; username: string }) => void 8 | }) => { 9 | const queryParams = new URLSearchParams(window.location.search) 10 | const isDemo = queryParams.get('demo') === '1' 11 | 12 | // If the URL is locked, we just use the default 13 | const lockedURL = import.meta.env.VITE_LOCK_JELLYFIN_URL === 'true'; 14 | let loadedURL = import.meta.env.VITE_DEFAULT_JELLYFIN_URL 15 | if (!lockedURL) { 16 | loadedURL = isDemo ? 'https://demo.jellyfin.org/stable' : localStorage.getItem('lastServerUrl') || import.meta.env.VITE_DEFAULT_JELLYFIN_URL || '' 17 | } 18 | 19 | const [serverUrl, setServerUrl] = useState(loadedURL) 20 | const [username, setUsername] = useState(isDemo ? 'demo' : '') 21 | const [password, setPassword] = useState('') 22 | const [loading, setLoading] = useState(false) 23 | const [error, setError] = useState('') 24 | 25 | const handleSubmit = async (e: FormEvent) => { 26 | e.preventDefault() 27 | setLoading(true) 28 | setError('') 29 | 30 | // Pre-validate serverUrl 31 | if (!serverUrl) { 32 | setError('Please enter a server URL.') 33 | setLoading(false) 34 | return 35 | } 36 | 37 | let trimmedServerUrl = serverUrl.toLowerCase().replace(new RegExp('/+$'), '') 38 | 39 | // Basic URL format check 40 | const urlPattern = /^https?:\/\/.+/ 41 | if (!urlPattern.test(trimmedServerUrl)) { 42 | setError('Invalid URL format. Use http:// or https:// followed by a valid address.') 43 | setLoading(false) 44 | return 45 | } 46 | 47 | try { 48 | let result 49 | 50 | try { 51 | result = await loginToJellyfin(trimmedServerUrl, username, password) 52 | } catch (firstErr) { 53 | const formattedServerUrl = trimmedServerUrl.split('/').slice(0, 3).join('/') 54 | 55 | if (trimmedServerUrl !== formattedServerUrl) { 56 | trimmedServerUrl = formattedServerUrl 57 | result = await loginToJellyfin(trimmedServerUrl, username, password) 58 | } else { 59 | throw firstErr 60 | } 61 | } 62 | 63 | const { token, userId, username: fetchedUsername } = result! 64 | 65 | // Save the serverUrl to localStorage on successful login 66 | localStorage.setItem('lastServerUrl', trimmedServerUrl) 67 | onLogin({ serverUrl: trimmedServerUrl, token, userId, username: fetchedUsername }) 68 | } catch (err) { 69 | if (err instanceof ApiError) { 70 | if (err.response) { 71 | // HTTP status errors 72 | if (err.response.status === 401) { 73 | setError('Invalid credentials entered.') 74 | } else if (err.response.status === 404) { 75 | setError('Server not found. Please check the URL.') 76 | } else if (err.response.status === 400) { 77 | setError('Invalid request. Please check your input.') 78 | } else { 79 | setError(`Login failed: Server returned status ${err.response.status}.`) 80 | } 81 | } else { 82 | // Setup errors (e.g., bad config) 83 | setError('Login failed: Request setup error.') 84 | } 85 | } else { 86 | setError('An unexpected error occurred. Please try again.') 87 | } 88 | } finally { 89 | setLoading(false) 90 | } 91 | } 92 | 93 | return ( 94 |
95 |
{error &&
{error}
}
96 |
Welcome back
97 | {!lockedURL && // We do not render if the URL is locked 98 |
99 | setServerUrl(e.target.value)} 104 | disabled={loading} 105 | /> 106 |
107 | } 108 |
109 | setUsername(e.target.value)} 114 | disabled={loading} 115 | /> 116 |
117 |
118 | setPassword(e.target.value)} 123 | disabled={loading} 124 | /> 125 |
126 | 129 |
130 | ) 131 | } 132 | -------------------------------------------------------------------------------- /src/components/Dropdown.css: -------------------------------------------------------------------------------- 1 | .dropdown, 2 | .sub-dropdown { 3 | position: absolute; 4 | z-index: 2; 5 | } 6 | 7 | .dropdown { 8 | transform: scale(0.92); 9 | opacity: 0; 10 | visibility: hidden; 11 | transition: opacity 0.2s ease-out, visibility 0.2s ease-out, transform 0.2s cubic-bezier(0.4, 0, 0.2, 1); 12 | will-change: transform; 13 | } 14 | 15 | .dropdown.active { 16 | transform: scale(1); 17 | opacity: 1; 18 | visibility: visible; 19 | } 20 | 21 | .sub-dropdown { 22 | top: -4px !important; 23 | left: 100% !important; 24 | } 25 | 26 | .sub-dropdown.flip-x { 27 | left: auto !important; 28 | right: 100% !important; 29 | } 30 | 31 | .sub-dropdown.flip-y { 32 | top: unset !important; 33 | bottom: -4px !important; 34 | } 35 | 36 | .dropdown > .dropdown-menu { 37 | min-width: 170px; 38 | max-width: 210px; 39 | } 40 | 41 | .dropdown > .dropdown-menu, 42 | .sub-dropdown > .dropdown-menu { 43 | position: relative; 44 | display: flex; 45 | flex-direction: column; 46 | gap: 2px; 47 | cursor: initial; 48 | padding: 4px; 49 | border-radius: 8px; 50 | border: 1px solid var(--border-color); 51 | background-color: var(--bg-color); 52 | box-shadow: var(--dropdown-shadow); 53 | } 54 | 55 | .sub-dropdown > .dropdown-menu { 56 | width: max-content; 57 | max-width: 210px; 58 | max-height: 42dvh; 59 | overflow: auto; 60 | overflow-x: hidden; 61 | overscroll-behavior: contain; 62 | -webkit-overflow-scrolling: touch; 63 | scrollbar-width: thin; 64 | } 65 | 66 | .dropdown > .dropdown-menu > .dropdown-separator, 67 | .sub-dropdown > .dropdown-menu > .dropdown-separator { 68 | flex-shrink: 0; 69 | width: calc(100% - 16px); 70 | height: 1px; 71 | margin: 2px 8px; 72 | background-color: var(--border-color); 73 | } 74 | 75 | .sub-dropdown > .dropdown-menu > .dropdown-item:has(.playlist-input.has-text) ~ .dropdown-separator { 76 | width: calc(100% - 10px); 77 | } 78 | 79 | .dropdown > .dropdown-menu > .dropdown-item, 80 | .sub-dropdown > .dropdown-menu > .dropdown-item { 81 | position: relative; 82 | font-size: 0.75rem; 83 | font-weight: 500; 84 | cursor: pointer; 85 | padding: 4px 10px; 86 | border-radius: 4px; 87 | display: flex; 88 | justify-content: space-between; 89 | align-items: center; 90 | } 91 | 92 | .dropdown > .dropdown-menu > .dropdown-item:hover, 93 | .sub-dropdown > .dropdown-menu > .dropdown-item:hover { 94 | background-color: var(--bg-color-tertiary); 95 | } 96 | 97 | .dropdown > .dropdown-menu > .dropdown-item.active { 98 | background-color: var(--bg-color-tertiary); 99 | } 100 | 101 | .dropdown > .dropdown-menu > .dropdown-item.has-removable { 102 | color: var(--font-color-error-light); 103 | } 104 | 105 | .dropdown > .dropdown-menu > .dropdown-item.add-favorite { 106 | color: var(--brand-color); 107 | } 108 | 109 | .dropdown > .dropdown-menu > .dropdown-item > .icon { 110 | margin-right: -4px; 111 | } 112 | 113 | .sub-dropdown > .dropdown-menu > .dropdown-item { 114 | transition: opacity 0.2s ease-out; 115 | } 116 | 117 | .sub-dropdown > .dropdown-menu > .dropdown-item:has(.playlist-input.has-text) ~ .dropdown-item { 118 | opacity: 0.3; 119 | pointer-events: none; 120 | } 121 | 122 | .sub-dropdown > .dropdown-menu > .dropdown-item:has(.playlist-input-container) { 123 | padding: unset; 124 | background-color: unset !important; 125 | } 126 | 127 | .sub-dropdown > .dropdown-menu > .dropdown-item > .playlist-input-container { 128 | display: flex; 129 | align-items: center; 130 | width: 100%; 131 | } 132 | 133 | .sub-dropdown > .dropdown-menu > .dropdown-item > .playlist-input-container > .playlist-input { 134 | font: inherit; 135 | position: relative; 136 | flex: 1; 137 | padding: 4px 10px; 138 | background: transparent; 139 | } 140 | 141 | .sub-dropdown > .dropdown-menu > .dropdown-item > .playlist-input-container > .playlist-input::placeholder { 142 | font: inherit; 143 | } 144 | 145 | .sub-dropdown > .dropdown-menu > .dropdown-item > .playlist-input-container > .playlist-input.has-text { 146 | max-width: calc(100% - 52px); 147 | } 148 | 149 | .sub-dropdown > .dropdown-menu > .dropdown-item > .playlist-input-container > .playlist-input.has-text ~ .create-btn { 150 | opacity: 1; 151 | visibility: visible; 152 | } 153 | 154 | .sub-dropdown > .dropdown-menu > .dropdown-item > .playlist-input-container > .create-btn { 155 | position: absolute; 156 | right: 2px; 157 | opacity: 0; 158 | visibility: hidden; 159 | font-size: 0.725rem; 160 | font-weight: 500; 161 | cursor: pointer; 162 | padding: 3px 7px; 163 | border: none; 164 | border-radius: 4px; 165 | background: var(--bg-color-tertiary); 166 | color: var(--font-color); 167 | transition: background-color 0.2s ease-out, opacity 0.2s ease-out, visibility 0.2s ease-out; 168 | } 169 | 170 | .sub-dropdown > .dropdown-menu > .dropdown-item > .playlist-input-container > .create-btn:hover { 171 | background: var(--bg-color-quaternary); 172 | } 173 | 174 | /* Responsive */ 175 | @media only screen and (max-width: 480px), (pointer: coarse) { 176 | .dropdown { 177 | position: fixed; 178 | top: unset !important; 179 | left: 16px !important; 180 | bottom: 16px !important; 181 | width: calc(100% - 32px); 182 | z-index: 3; 183 | opacity: 1; 184 | visibility: visible; 185 | transform: scale(1) translateY(calc(100% + 16px)); 186 | transition: transform 0.2s cubic-bezier(0.4, 0, 0.2, 1); 187 | } 188 | 189 | .dropdown.active { 190 | transform: scale(1) translateY(0); 191 | } 192 | 193 | .dropdown > .dropdown-menu { 194 | min-width: 100%; 195 | max-width: 100%; 196 | border-radius: 12px; 197 | padding: 7px; 198 | gap: 4px; 199 | box-shadow: none; 200 | background-color: var(--bg-color-secondary); 201 | max-height: 60dvh; 202 | overflow: auto; 203 | overflow-x: hidden; 204 | overscroll-behavior: contain; 205 | -webkit-overflow-scrolling: touch; 206 | } 207 | 208 | .dropdown > .dropdown-menu > .dropdown-separator, 209 | .sub-dropdown > .dropdown-menu > .dropdown-separator { 210 | display: none; 211 | } 212 | 213 | .dropdown > .dropdown-menu > .dropdown-item { 214 | font-size: 0.825rem; 215 | padding: 7px 12px; 216 | border-radius: 6px; 217 | transition: opacity 0.2s ease-out; 218 | } 219 | 220 | .dropdown > .dropdown-menu > .dropdown-item.return-item { 221 | justify-content: unset; 222 | gap: 10px; 223 | margin-bottom: 9px; 224 | } 225 | 226 | .dropdown > .dropdown-menu > .dropdown-item.return-item::after { 227 | content: ''; 228 | position: absolute; 229 | left: 10px; 230 | bottom: -7px; 231 | height: 1px; 232 | width: calc(100% - 20px); 233 | background-color: var(--border-color); 234 | pointer-events: none; 235 | } 236 | 237 | .dropdown > .dropdown-menu > .dropdown-item.return-item > .return-icon { 238 | margin-left: -2px; 239 | } 240 | 241 | .dropdown > .dropdown-menu > .dropdown-item:has(.playlist-input.has-text) ~ .dropdown-item { 242 | opacity: 0.3; 243 | pointer-events: none; 244 | } 245 | 246 | .dropdown > .dropdown-menu > .dropdown-item:has(.playlist-input-container) { 247 | padding: unset; 248 | background-color: unset !important; 249 | } 250 | 251 | .dropdown > .dropdown-menu > .dropdown-item > .playlist-input-container { 252 | display: flex; 253 | align-items: center; 254 | width: 100%; 255 | } 256 | 257 | .dropdown > .dropdown-menu > .dropdown-item > .playlist-input-container > .playlist-input { 258 | font: inherit; 259 | position: relative; 260 | flex: 1; 261 | padding: 7px 12px; 262 | background: transparent; 263 | } 264 | 265 | .dropdown > .dropdown-menu > .dropdown-item > .playlist-input-container > .playlist-input::placeholder { 266 | font: inherit; 267 | } 268 | 269 | .dropdown > .dropdown-menu > .dropdown-item > .playlist-input-container > .playlist-input.has-text { 270 | max-width: calc(100% - 52px); 271 | } 272 | 273 | .dropdown > .dropdown-menu > .dropdown-item > .playlist-input-container > .playlist-input.has-text ~ .create-btn { 274 | opacity: 1; 275 | visibility: visible; 276 | } 277 | 278 | .dropdown > .dropdown-menu > .dropdown-item > .playlist-input-container > .create-btn { 279 | position: absolute; 280 | right: 2px; 281 | opacity: 0; 282 | visibility: hidden; 283 | font-size: 0.725rem; 284 | font-weight: 500; 285 | cursor: pointer; 286 | padding: 5px 9px; 287 | border: none; 288 | border-radius: 5px; 289 | background: var(--bg-color-tertiary); 290 | color: var(--font-color); 291 | transition: background-color 0.2s ease-out, opacity 0.2s ease-out, visibility 0.2s ease-out; 292 | } 293 | 294 | .dropdown > .dropdown-menu > .dropdown-item > .playlist-input-container > .create-btn:hover { 295 | background: var(--bg-color-quaternary); 296 | } 297 | } 298 | -------------------------------------------------------------------------------- /src/components/Dropdown.tsx: -------------------------------------------------------------------------------- 1 | import { useDropdownContext } from '../context/DropdownContext/DropdownContext' 2 | import './Dropdown.css' 3 | 4 | export const Dropdown = () => { 5 | const dropdown = useDropdownContext() 6 | 7 | return dropdown.dropdownNode 8 | } 9 | -------------------------------------------------------------------------------- /src/components/InlineLoader.css: -------------------------------------------------------------------------------- 1 | .inline-loader { 2 | position: relative; 3 | } 4 | 5 | .inline-loader > .loading { 6 | overflow: visible; 7 | width: 12px; 8 | height: 12px; 9 | } 10 | 11 | .inline-loader > .loading > circle { 12 | position: absolute; 13 | fill: none; 14 | stroke-width: 2; 15 | stroke-dasharray: 104; 16 | stroke-linecap: round; 17 | transform: translate(0px, 0px); 18 | transform: rotate(-90deg); 19 | transform-origin: 50% 50%; 20 | } 21 | 22 | .inline-loader > .loading > circle:nth-child(1) { 23 | stroke-dashoffset: 0; 24 | stroke: var(--bg-color-tertiary); 25 | } 26 | 27 | .inline-loader > .loading > circle:nth-child(2) { 28 | stroke-dashoffset: 94; 29 | stroke: var(--brand-color); 30 | animation: inlineLoaderForward 0.8s linear infinite; 31 | transition: stroke-dashoffset 0.8s linear infinite; 32 | } 33 | 34 | @keyframes inlineLoaderForward { 35 | 0% { 36 | transform: rotate(0deg); 37 | } 38 | 100% { 39 | transform: rotate(360deg); 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/components/InlineLoader.tsx: -------------------------------------------------------------------------------- 1 | import './InlineLoader.css' 2 | 3 | export const InlineLoader = () => ( 4 |
5 | 6 | 7 | 8 | 9 |
10 | ) 11 | -------------------------------------------------------------------------------- /src/components/JellyImg.tsx: -------------------------------------------------------------------------------- 1 | import { MediaItem } from '../api/jellyfin' 2 | import { useJellyfinContext } from '../context/JellyfinContext/JellyfinContext' 3 | 4 | export const JellyImg = ({ 5 | item, 6 | type, 7 | width, 8 | height, 9 | imageProps, 10 | }: { 11 | item: MediaItem 12 | type: 'Primary' | 'Backdrop' 13 | width: number 14 | height: number 15 | imageProps?: React.DetailedHTMLProps, HTMLImageElement> 16 | }) => { 17 | const api = useJellyfinContext() 18 | 19 | return ( 20 | {item.Name} { 27 | ;(e.target as HTMLImageElement).src = import.meta.env.BASE_URL + 'default-thumbnail.png' 28 | }} 29 | /> 30 | ) 31 | } 32 | -------------------------------------------------------------------------------- /src/components/Loader.css: -------------------------------------------------------------------------------- 1 | .loading_container { 2 | position: relative; 3 | height: calc(100vh - 220px); 4 | display: flex; 5 | align-items: center; 6 | justify-content: center; 7 | } 8 | 9 | .loading_container > .loading { 10 | overflow: visible; 11 | width: 28px; 12 | height: 28px; 13 | } 14 | 15 | .loading_container > .loading > circle { 16 | position: absolute; 17 | fill: none; 18 | stroke-width: 3; 19 | stroke-dasharray: 124; 20 | stroke-linecap: round; 21 | transform: translate(0px, 0px); 22 | transform: rotate(-90deg); 23 | transform-origin: 50% 50%; 24 | } 25 | 26 | .loading_container > .loading > circle:nth-child(1) { 27 | stroke-dashoffset: 0; 28 | stroke: var(--bg-color-tertiary); 29 | } 30 | 31 | .loading_container > .loading > circle:nth-child(2) { 32 | stroke-dashoffset: 94; 33 | stroke: var(--brand-color); 34 | animation: loaderForward 0.8s linear infinite; 35 | transition: stroke-dashoffset 0.8s linear infinite; 36 | } 37 | 38 | @keyframes loaderForward { 39 | 0% { 40 | transform: rotate(0deg); 41 | } 42 | 100% { 43 | transform: rotate(360deg); 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/components/Loader.tsx: -------------------------------------------------------------------------------- 1 | import './Loader.css' 2 | 3 | export const Loader = () => ( 4 |
5 | 6 | 7 | 8 | 9 |
10 | ) 11 | -------------------------------------------------------------------------------- /src/components/MediaList.css: -------------------------------------------------------------------------------- 1 | .media-list { 2 | list-style: none; 3 | padding: 0; 4 | margin-bottom: -4px; 5 | } 6 | 7 | .media-list .media-item { 8 | cursor: pointer; 9 | display: flex; 10 | align-items: center; 11 | padding: 8px; 12 | margin: 0 -8px; 13 | border-radius: 8px; 14 | transition: background-color 0.2s ease-out; 15 | } 16 | 17 | .media-list .media-item:hover { 18 | background-color: var(--bg-color-secondary); 19 | } 20 | 21 | .media-list .media-item.active { 22 | background-color: var(--bg-color-secondary); 23 | } 24 | 25 | .media-list .media-item > .media-state { 26 | position: relative; 27 | flex-shrink: 0; 28 | width: 46px; 29 | height: 46px; 30 | margin-right: 12px; 31 | border-radius: 6px; 32 | } 33 | 34 | .media-list .media-item.artist-item > .media-state { 35 | width: 36px; 36 | height: 36px; 37 | border-radius: 20px; 38 | } 39 | 40 | .media-list .media-item > .media-state > .thumbnail { 41 | display: block; 42 | width: 46px; 43 | height: 46px; 44 | object-fit: cover; 45 | border-radius: 6px; 46 | background-color: var(--bg-color-secondary); 47 | } 48 | 49 | .media-list .media-item.artist-item > .media-state > .thumbnail { 50 | width: 36px; 51 | height: 36px; 52 | border-radius: 20px; 53 | } 54 | 55 | /* Hide thumbnail overlay by default */ 56 | .media-list .media-item > .media-state > .overlay { 57 | position: absolute; 58 | opacity: 0; 59 | visibility: hidden; 60 | display: flex; 61 | align-items: center; 62 | justify-content: center; 63 | top: 0; 64 | left: 0; 65 | width: 100%; 66 | height: 100%; 67 | border-radius: 6px; 68 | background-color: var(--overlay-bg); 69 | transition: opacity 0.2s ease-out, visibility 0.2s ease-out; 70 | } 71 | 72 | /* Show thumbnail overlay when hovering a track/playlist/item */ 73 | .media-list .media-item:hover > .media-state > .overlay { 74 | opacity: 1; 75 | visibility: visible; 76 | } 77 | 78 | /* Show thumbnail overlay (and it's children divs, important!) when a track is playing or paused */ 79 | .media-list .media-item.playing > .media-state > .overlay, 80 | .media-list .media-item.paused > .media-state > .overlay { 81 | opacity: 1; 82 | visibility: visible; 83 | } 84 | 85 | .media-list .media-item > .media-state > .overlay > .container { 86 | display: flex; 87 | align-items: center; 88 | justify-content: center; 89 | position: relative; 90 | width: 100%; 91 | height: 100%; 92 | } 93 | 94 | .media-list .media-item > .media-state > .overlay > .container > .play { 95 | position: absolute; 96 | cursor: pointer; 97 | transition: opacity 0.2s ease-out, visibility 0.2s ease-out; 98 | } 99 | 100 | /* Hide play button when a track is playing or paused */ 101 | .media-list .media-item.playing > .media-state > .overlay > .container > .play, 102 | .media-list .media-item.paused > .media-state > .overlay > .container > .play { 103 | opacity: 0; 104 | visibility: hidden; 105 | } 106 | 107 | /* Show play button when a track is paused and you hover the track/playlist/item */ 108 | .media-list .media-item.paused:hover > .media-state > .overlay > .container > .play { 109 | opacity: 1; 110 | visibility: visible; 111 | } 112 | 113 | .media-list .media-item > .media-state > .overlay > .container > .play > .play-icon { 114 | display: block; 115 | width: 26px; 116 | height: 23px; 117 | background-color: var(--font-color-inverted); 118 | mask-repeat: no-repeat; 119 | mask-image: url(/play.svg); 120 | } 121 | 122 | /* Hide pause button by default */ 123 | .media-list .media-item > .media-state > .overlay > .container > .pause { 124 | position: absolute; 125 | opacity: 0; 126 | visibility: hidden; 127 | cursor: pointer; 128 | transition: opacity 0.2s ease-out, visibility 0.2s ease-out; 129 | } 130 | 131 | /* Hide pause button when a track is paused */ 132 | .media-list .media-item.paused > .media-state > .overlay > .container > .pause { 133 | opacity: 0; 134 | visibility: hidden; 135 | } 136 | 137 | /* Show pause button when a track is playing and you hover the track/playlist/item */ 138 | .media-list .media-item.playing:hover > .media-state > .overlay > .container > .pause { 139 | opacity: 1; 140 | visibility: visible; 141 | } 142 | 143 | .media-list .media-item > .media-state > .overlay > .container > .pause > .pause-icon { 144 | display: block; 145 | width: 26px; 146 | height: 23px; 147 | background-color: var(--font-color-inverted); 148 | mask-repeat: no-repeat; 149 | mask-image: url(/pause.svg); 150 | } 151 | 152 | /* Hide play-state animation by default */ 153 | .media-list .media-item > .media-state > .overlay > .play-state-animation { 154 | margin-left: 1px; 155 | position: absolute; 156 | opacity: 0; 157 | visibility: hidden; 158 | transition: opacity 0.2s ease-out, visibility 0.2s ease-out; 159 | } 160 | 161 | /* Show play-state animation when a track is playing or paused */ 162 | .media-list .media-item.playing > .media-state > .overlay > .play-state-animation, 163 | .media-list .media-item.paused > .media-state > .overlay > .play-state-animation { 164 | opacity: 1; 165 | visibility: visible; 166 | } 167 | 168 | /* Hide play-state animation when you hover the track/playlist/item */ 169 | .media-list .media-item:hover > .media-state > .overlay > .play-state-animation { 170 | opacity: 0; 171 | visibility: hidden; 172 | } 173 | 174 | /* Play-state animation */ 175 | .media-list .media-item > .media-state > .overlay > .play-state-animation > .sound-bars { 176 | animation-play-state: paused; 177 | } 178 | 179 | /* Animation is active when a track is playing */ 180 | .media-list .media-item.playing > .media-state > .overlay > .play-state-animation > .sound-bars { 181 | animation-play-state: running; 182 | } 183 | 184 | /* Animation is paused when a track is stopped */ 185 | .media-list .media-item.paused > .media-state > .overlay > .play-state-animation > .sound-bars { 186 | animation-play-state: paused; 187 | } 188 | 189 | /* The individual sound-bars will inherit the play state from parent */ 190 | .media-list .media-item > .media-state > .overlay > .play-state-animation > .sound-bars > .bar { 191 | animation-play-state: inherit !important; 192 | transform-origin: bottom; 193 | fill: var(--font-color-inverted); 194 | } 195 | 196 | .media-list .media-item > .media-state > .overlay > .play-state-animation > .sound-bars > .bar1 { 197 | animation: MediaListbounce 0.5s infinite ease-in-out; 198 | } 199 | 200 | .media-list .media-item > .media-state > .overlay > .play-state-animation > .sound-bars > .bar2 { 201 | animation: MediaListbounce 0.55s infinite ease-in-out; 202 | } 203 | 204 | .media-list .media-item > .media-state > .overlay > .play-state-animation > .sound-bars > .bar3 { 205 | animation: MediaListbounce 0.45s infinite ease-in-out; 206 | } 207 | 208 | .media-list .media-item > .media-state > .overlay > .play-state-animation > .sound-bars > .bar4 { 209 | animation: MediaListbounce 0.6s infinite ease-in-out; 210 | } 211 | 212 | .media-list .media-item > .media-state > .overlay > .play-state-animation > .sound-bars > .bar5 { 213 | animation: MediaListbounce 0.5s infinite ease-in-out; 214 | } 215 | 216 | .media-list .media-item > .media-state > .overlay > .play-state-animation > .sound-bars > .bar6 { 217 | animation: MediaListbounce 0.55s infinite ease-in-out; 218 | } 219 | 220 | @keyframes MediaListbounce { 221 | 0%, 222 | 100% { 223 | transform: scaleY(1); 224 | } 225 | 50% { 226 | transform: scaleY(1.8); 227 | } 228 | } 229 | 230 | .media-list .media-item > .media-details { 231 | display: flex; 232 | flex-direction: column; 233 | flex-grow: 1; 234 | } 235 | 236 | .media-list .media-item > .media-details > .container { 237 | line-height: 1; 238 | } 239 | 240 | .media-list .media-item > .media-details > .song-name { 241 | font-size: 0.85rem; 242 | font-weight: 600; 243 | /* Help with CJK letter spacing */ 244 | line-height: 1rem; 245 | margin-bottom: 1px; 246 | transition: color 0.2s ease-out; 247 | } 248 | 249 | .media-list .media-item.artist-item > .media-details > .song-name { 250 | margin-bottom: 0; 251 | } 252 | 253 | .media-list .media-item.playing > .media-details > .song-name, 254 | .media-list .media-item.paused > .media-details > .song-name { 255 | color: var(--font-color-active); 256 | } 257 | 258 | .media-list .media-item > .media-details > .container > .artist { 259 | display: inline; 260 | font-size: 0.725rem; 261 | font-weight: 600; 262 | color: var(--font-color-tertiary); 263 | transition: color 0.2s ease-out; 264 | } 265 | 266 | .media-list .media-item.playing > .media-details > .container > .artist, 267 | .media-list .media-item.paused > .media-details > .container > .artist { 268 | color: var(--font-color-secondary); 269 | } 270 | 271 | .media-list .media-item > .media-details > .container > .divider { 272 | display: inline-block; 273 | vertical-align: middle; 274 | width: 2px; 275 | height: 2px; 276 | margin: 0 4px; 277 | margin-top: 2px; 278 | border-radius: 50%; 279 | background-color: var(--font-color-tertiary); 280 | transition: background-color 0.2s ease-out; 281 | } 282 | 283 | .media-list .media-item.playing > .media-details > .container > .divider, 284 | .media-list .media-item.paused > .media-details > .container > .divider { 285 | background-color: var(--font-color-secondary); 286 | } 287 | 288 | .media-list .media-item > .media-details > .container > .album { 289 | display: inline; 290 | font-size: 0.725rem; 291 | font-weight: 500; 292 | color: var(--font-color-tertiary); 293 | transition: color 0.2s ease-out; 294 | } 295 | 296 | .media-list .media-item.playing > .media-details > .container > .album, 297 | .media-list .media-item.paused > .media-details > .container > .album { 298 | color: var(--font-color-secondary); 299 | } 300 | 301 | .media-list .media-item > .favorited { 302 | padding: 6px; 303 | margin-left: 8px; 304 | color: var(--font-color-tertiary); 305 | transition: color 0.2s ease-out; 306 | } 307 | 308 | .media-list .media-item.playing > .favorited, 309 | .media-list .media-item.paused > .favorited { 310 | color: var(--font-color-secondary); 311 | } 312 | -------------------------------------------------------------------------------- /src/components/MediaList.tsx: -------------------------------------------------------------------------------- 1 | import { HeartFillIcon } from '@primer/octicons-react' 2 | import { useLocation, useNavigate } from 'react-router-dom' 3 | import { Virtuoso } from 'react-virtuoso' 4 | import { MediaItem } from '../api/jellyfin' 5 | import { useDropdownContext } from '../context/DropdownContext/DropdownContext' 6 | import { IMenuItems } from '../context/DropdownContext/DropdownContextProvider' 7 | import { usePlaybackContext } from '../context/PlaybackContext/PlaybackContext' 8 | import { useDisplayItems } from '../hooks/useDisplayItems' 9 | import { JellyImg } from './JellyImg' 10 | import { Loader } from './Loader' 11 | import { IReviver } from './PlaybackManager' 12 | import { Skeleton } from './Skeleton' 13 | import { PlaystateAnimationMedalist } from './SvgIcons' 14 | 15 | export const MediaList = ({ 16 | items = [], 17 | isLoading, 18 | type, 19 | title, 20 | reviver, 21 | loadMore, 22 | hidden = {}, 23 | }: { 24 | items: MediaItem[] | undefined 25 | isLoading: boolean 26 | type: 'song' | 'album' | 'artist' 27 | title: string 28 | reviver?: IReviver 29 | loadMore?: () => void 30 | hidden?: IMenuItems 31 | }) => { 32 | const playback = usePlaybackContext() 33 | const navigate = useNavigate() 34 | const location = useLocation() 35 | const { displayItems, setRowRefs } = useDisplayItems(items, isLoading) 36 | 37 | const dropdown = useDropdownContext() 38 | 39 | const handleSongClick = (item: MediaItem, index: number) => { 40 | if (type === 'song') { 41 | if (playback.currentTrack?.Id === item.Id) { 42 | playback.togglePlayPause() 43 | } else { 44 | playback.setCurrentPlaylist({ playlist: items, title, reviver }) 45 | playback.playTrack(index) 46 | } 47 | } 48 | } 49 | 50 | const renderItem = (index: number, item: MediaItem | { isPlaceholder: true }) => { 51 | if ('isPlaceholder' in item) { 52 | if (type === 'album') { 53 | return ( 54 |
setRowRefs(index, el)}> 55 | 56 |
57 | ) 58 | } else if (type === 'artist') { 59 | return ( 60 |
setRowRefs(index, el)}> 61 | 62 |
63 | ) 64 | } else { 65 | return ( 66 |
  • setRowRefs(index, el)}> 67 | 68 |
  • 69 | ) 70 | } 71 | } 72 | 73 | const isActive = dropdown.selectedItem?.Id === item.Id && dropdown.isOpen 74 | 75 | const itemClass = [ 76 | type === 'song' && playback.currentTrack?.Id === item.Id ? (playback.isPlaying ? 'playing' : 'paused') : '', 77 | isActive ? 'active' : '', 78 | ] 79 | .filter(Boolean) 80 | .join(' ') 81 | 82 | if (type === 'album') { 83 | return ( 84 |
    navigate(`/album/${item.Id}`)} 88 | ref={el => setRowRefs(index, el)} 89 | onContextMenu={e => dropdown.onContextMenu(e, { item })} 90 | onTouchStart={e => dropdown.onTouchStart(e, { item })} 91 | onTouchMove={dropdown.onTouchClear} 92 | onTouchEnd={dropdown.onTouchClear} 93 | > 94 |
    95 | 96 |
    97 |
    98 | {item.Name} 99 |
    100 |
    {item.AlbumArtist || 'Unknown Artist'}
    101 |
    102 |
    103 | {item.UserData?.IsFavorite && location.pathname !== '/favorites' && ( 104 |
    105 | 106 |
    107 | )} 108 |
    109 | ) 110 | } else if (type === 'artist') { 111 | return ( 112 |
    navigate(`/artist/${item.Id}`)} 116 | ref={el => setRowRefs(index, el)} 117 | onContextMenu={e => dropdown.onContextMenu(e, { item })} 118 | onTouchStart={e => dropdown.onTouchStart(e, { item })} 119 | onTouchMove={dropdown.onTouchClear} 120 | onTouchEnd={dropdown.onTouchClear} 121 | > 122 |
    123 | 124 |
    125 |
    126 |
    {item.Name || 'Unknown Artist'}
    127 |
    128 | {item.UserData?.IsFavorite && location.pathname !== '/favorites' && ( 129 |
    130 | 131 |
    132 | )} 133 |
    134 | ) 135 | } else { 136 | return ( 137 |
  • handleSongClick(item, index)} 140 | key={item.Id} 141 | ref={el => setRowRefs(index, el)} 142 | onContextMenu={e => dropdown.onContextMenu(e, { item }, false, hidden)} 143 | onTouchStart={e => dropdown.onTouchStart(e, { item }, false, hidden)} 144 | onTouchMove={dropdown.onTouchClear} 145 | onTouchEnd={dropdown.onTouchClear} 146 | > 147 |
    148 | 149 | 150 |
    151 |
    152 |
    153 |
    154 |
    155 |
    156 |
    157 |
    158 |
    159 |
    160 | 161 |
    162 |
    163 |
    164 |
    165 | {item.Name} 166 |
    167 |
    168 | {item.Artists && item.Artists.length > 0 ? item.Artists.join(', ') : 'Unknown Artist'} 169 |
    170 | <> 171 |
    172 |
    {item.Album || 'Unknown Album'}
    173 | 174 |
    175 |
    176 | {item.UserData?.IsFavorite && location.pathname !== '/favorites' && ( 177 |
    178 | 179 |
    180 | )} 181 |
  • 182 | ) 183 | } 184 | } 185 | 186 | if (isLoading && items.length === 0) { 187 | return 188 | } 189 | 190 | if (items.length === 0 && !isLoading) { 191 | return ( 192 |
    193 | {type === 'song' 194 | ? 'No tracks were found' 195 | : type === 'album' 196 | ? 'No albums were found' 197 | : 'No artists were found'} 198 |
    199 | ) 200 | } 201 | 202 | return ( 203 |
      204 | 211 |
    212 | ) 213 | } 214 | -------------------------------------------------------------------------------- /src/components/PlaylistTrackList.tsx: -------------------------------------------------------------------------------- 1 | import { HeartFillIcon } from '@primer/octicons-react' 2 | import { useCallback } from 'react' 3 | import { useLocation } from 'react-router-dom' 4 | import { Virtuoso } from 'react-virtuoso' 5 | import { MediaItem } from '../api/jellyfin' 6 | import { useDropdownContext } from '../context/DropdownContext/DropdownContext' 7 | import { usePlaybackContext } from '../context/PlaybackContext/PlaybackContext' 8 | import { useDisplayItems } from '../hooks/useDisplayItems' 9 | import { formatDuration } from '../utils/formatDuration' 10 | import { JellyImg } from './JellyImg' 11 | import { Loader } from './Loader' 12 | import { IReviver } from './PlaybackManager' 13 | import './PlaylistTrackList.css' 14 | import { Skeleton } from './Skeleton' 15 | import { PlaystateAnimationTracklist } from './SvgIcons' 16 | 17 | export const PlaylistTrackList = ({ 18 | tracks, 19 | isLoading, 20 | playlistId, 21 | showType, 22 | title, 23 | reviver, 24 | loadMore, 25 | }: { 26 | tracks: MediaItem[] 27 | isLoading: boolean 28 | playlistId?: string 29 | showType?: 'artist' | 'album' 30 | title: string 31 | reviver?: IReviver 32 | loadMore?: () => void 33 | }) => { 34 | const playback = usePlaybackContext() 35 | const location = useLocation() 36 | const { displayItems, setRowRefs } = useDisplayItems(tracks, isLoading) 37 | 38 | const dropdown = useDropdownContext() 39 | 40 | const handleTrackClick = useCallback( 41 | (track: MediaItem, index: number) => { 42 | if (playback.currentTrack?.Id === track.Id) { 43 | playback.togglePlayPause() 44 | } else { 45 | playback.setCurrentPlaylist({ playlist: tracks, title, reviver }) 46 | playback.playTrack(index) 47 | } 48 | }, 49 | [playback, reviver, title, tracks] 50 | ) 51 | 52 | const renderTrack = (index: number, item: MediaItem | { isPlaceholder: true }) => { 53 | if ('isPlaceholder' in item) { 54 | return ( 55 |
  • setRowRefs(index, el)}> 56 | 57 |
  • 58 | ) 59 | } 60 | 61 | const track = item 62 | const isActive = dropdown.selectedItem?.Id === item.Id && dropdown.isOpen 63 | 64 | const trackClass = [ 65 | playback.currentTrack?.Id === track.Id ? (playback.isPlaying ? 'playing' : 'paused') : '', 66 | isActive ? 'active' : '', 67 | ] 68 | .filter(Boolean) 69 | .join(' ') 70 | 71 | const isFavorite = track.UserData?.IsFavorite && location.pathname !== '/favorites' 72 | 73 | return ( 74 |
  • handleTrackClick(track, index)} 77 | key={track.Id} 78 | ref={el => setRowRefs(index, el)} 79 | onContextMenu={e => dropdown.onContextMenu(e, { item, playlistId })} 80 | onTouchStart={e => dropdown.onTouchStart(e, { item, playlistId })} 81 | onTouchMove={dropdown.onTouchClear} 82 | onTouchEnd={dropdown.onTouchClear} 83 | > 84 |
    85 | 86 | 87 |
    88 |
    89 |
    90 |
    91 |
    92 |
    93 |
    94 |
    95 |
    96 |
    97 | 98 |
    99 |
    100 |
    101 |
    102 | 103 | {index + 1}. 104 | {track.Name} 105 | 106 |
    107 | {showType === 'artist' ? ( 108 |
    109 | {track.Artists && track.Artists.length > 0 110 | ? track.Artists.join(', ') 111 | : 'Unknown Artist'} 112 |
    113 | ) : showType === 'album' ? ( 114 |
    {track.Album || 'Unknown Album'}
    115 | ) : ( 116 | <> 117 |
    118 | {track.Artists && track.Artists.length > 0 119 | ? track.Artists.join(', ') 120 | : 'Unknown Artist'} 121 |
    122 |
    123 |
    {track.Album || 'Unknown Album'}
    124 | 125 | )} 126 |
    127 |
    128 |
    129 | {isFavorite && ( 130 |
    131 | 132 |
    133 | )} 134 |
    {formatDuration(track.RunTimeTicks || 0)}
    135 |
    136 |
  • 137 | ) 138 | } 139 | 140 | if (isLoading && tracks.length === 0) { 141 | return 142 | } 143 | 144 | if (!isLoading && tracks.length === 0) { 145 | return
    No tracks were found
    146 | } 147 | 148 | return ( 149 |
      150 | 158 |
    159 | ) 160 | } 161 | -------------------------------------------------------------------------------- /src/components/Skeleton.css: -------------------------------------------------------------------------------- 1 | .skeleton-loading { 2 | display: flex; 3 | align-items: center; 4 | flex: 1; 5 | } 6 | 7 | .skeleton-effect { 8 | background-image: linear-gradient( 9 | 90deg, 10 | var(--bg-color-tertiary) 0%, 11 | var(--bg-color-quaternary) 20%, 12 | var(--bg-color-tertiary) 40% 13 | ); 14 | background-size: 200%; 15 | background-color: var(--bg-color-tertiary); 16 | animation: skeleton-shine 1.6s infinite ease-out; 17 | } 18 | 19 | .skeleton-loading > .skeleton-effect.thumbnail { 20 | width: 46px; 21 | height: 46px; 22 | margin-right: 12px; 23 | border-radius: 6px; 24 | } 25 | 26 | /* Playlist thumbnail */ 27 | .skeleton-loading > .skeleton-effect.thumbnail.playlist { 28 | width: 40px; 29 | height: 40px; 30 | } 31 | 32 | /* Artist thumbnail */ 33 | .skeleton-loading > .skeleton-effect.thumbnail.artist { 34 | width: 36px; 35 | height: 36px; 36 | border-radius: 20px; 37 | } 38 | 39 | .skeleton-loading > .skeleton-details { 40 | flex: 1; 41 | display: flex; 42 | flex-direction: column; 43 | } 44 | 45 | .skeleton-loading > .skeleton-details > .skeleton-effect.title { 46 | height: 16px; 47 | margin-bottom: 8px; 48 | border-radius: 4px; 49 | } 50 | 51 | .skeleton-loading > .skeleton-details > .skeleton-effect.artist { 52 | height: 14px; 53 | border-radius: 4px; 54 | } 55 | 56 | /* Albums */ 57 | .skeleton-loading > .skeleton-details > .skeleton-effect.album.title { 58 | width: 50%; 59 | } 60 | 61 | .skeleton-loading > .skeleton-details > .skeleton-effect.album.artist { 62 | width: 20%; 63 | } 64 | 65 | /* Artists */ 66 | .skeleton-loading > .skeleton-details > .skeleton-effect.artist.title { 67 | width: 35%; 68 | margin-bottom: 0; 69 | } 70 | 71 | /* Tracks */ 72 | .skeleton-loading > .skeleton-details > .skeleton-effect.track.title { 73 | width: 30%; 74 | } 75 | 76 | .skeleton-loading > .skeleton-details > .skeleton-effect.track.artist { 77 | width: 45%; 78 | } 79 | 80 | /* Playlists */ 81 | .skeleton-loading > .skeleton-details > .skeleton-effect.playlist.title { 82 | width: 50%; 83 | } 84 | 85 | .skeleton-loading > .skeleton-details > .skeleton-effect.playlist.artist { 86 | width: 25%; 87 | } 88 | 89 | .skeleton-loading > .skeleton-indicators { 90 | display: flex; 91 | align-items: center; 92 | gap: 8px; 93 | margin-left: 12px; 94 | margin-bottom: 24px; 95 | } 96 | 97 | .skeleton-loading > .skeleton-indicators > .skeleton-effect.duration { 98 | width: 40px; 99 | height: 14px; 100 | border-radius: 4px; 101 | } 102 | 103 | @keyframes skeleton-shine { 104 | 0% { 105 | background-position: 100%; 106 | } 107 | 40%, 108 | 100% { 109 | background-position: -100%; 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /src/components/Skeleton.tsx: -------------------------------------------------------------------------------- 1 | import './Skeleton.css' 2 | 3 | export const Skeleton = ({ type = 'album' }: { type?: 'song' | 'album' | 'artist' | 'playlist' }) => ( 4 |
    5 |
    10 |
    11 | {type === 'album' && ( 12 | <> 13 |
    14 |
    15 | 16 | )} 17 | {type === 'artist' && ( 18 | <> 19 |
    20 | 21 | )} 22 | {type === 'song' && ( 23 | <> 24 |
    25 |
    26 | 27 | )} 28 | {type === 'playlist' && ( 29 | <> 30 |
    31 |
    32 | 33 | )} 34 |
    35 | {type === 'playlist' && ( 36 |
    37 |
    38 |
    39 | )} 40 |
    41 | ) 42 | -------------------------------------------------------------------------------- /src/components/TrackList.tsx: -------------------------------------------------------------------------------- 1 | import { HeartFillIcon } from '@primer/octicons-react' 2 | import { MediaItem } from '../api/jellyfin' 3 | import { useDropdownContext } from '../context/DropdownContext/DropdownContext' 4 | import { IMenuItems } from '../context/DropdownContext/DropdownContextProvider' 5 | import { usePlaybackContext } from '../context/PlaybackContext/PlaybackContext' 6 | import { formatDuration } from '../utils/formatDuration' 7 | import { IReviver } from './PlaybackManager' 8 | import { PlaystateAnimationTracklist } from './SvgIcons' 9 | import './TrackList.css' 10 | 11 | export const TrackList = ({ 12 | tracks, 13 | playlist, 14 | showAlbum = false, 15 | title, 16 | reviver, 17 | hidden = {}, 18 | }: { 19 | tracks: MediaItem[] 20 | playlist?: MediaItem[] 21 | showAlbum?: boolean 22 | title: string 23 | reviver?: IReviver 24 | hidden?: IMenuItems 25 | }) => { 26 | const playback = usePlaybackContext() 27 | 28 | const MIN_PLAY_COUNT = 5 29 | const mostPlayedTracks = tracks 30 | .map(track => ({ 31 | ...track, 32 | playCount: track.UserData?.PlayCount || 0, 33 | })) 34 | .filter(track => track.playCount >= MIN_PLAY_COUNT) 35 | .sort((a, b) => b.playCount - a.playCount) 36 | .slice(0, 3) 37 | .map(track => track.Id) 38 | 39 | const dropdown = useDropdownContext() 40 | 41 | return ( 42 |
      43 | {tracks.map((track, index) => { 44 | const isCurrentTrack = playback.currentTrack?.Id === track.Id 45 | const isMostPlayed = mostPlayedTracks.includes(track.Id) 46 | const isActive = dropdown.selectedItem?.Id === track.Id && dropdown.isOpen 47 | const itemClass = [ 48 | isCurrentTrack ? (playback.isPlaying ? 'playing' : 'paused') : '', 49 | isMostPlayed ? 'most-played' : '', 50 | isActive ? 'active' : '', 51 | ] 52 | .filter(Boolean) 53 | .join(' ') 54 | 55 | return ( 56 |
    • { 60 | if (isCurrentTrack) { 61 | playback.togglePlayPause() 62 | } else { 63 | const tracksToPlay = playlist || tracks 64 | playback.setCurrentPlaylist({ playlist: tracksToPlay, title, reviver }) 65 | const playIndex = playlist ? playlist.findIndex(t => t.Id === track.Id) : index 66 | playback.playTrack(playIndex) 67 | } 68 | }} 69 | onContextMenu={e => dropdown.onContextMenu(e, { item: track }, false, hidden)} 70 | onTouchStart={e => dropdown.onTouchStart(e, { item: track }, false, hidden)} 71 | onTouchMove={dropdown.onTouchClear} 72 | onTouchEnd={dropdown.onTouchClear} 73 | > 74 |
      75 |
      {index + 1}
      76 |
      77 |
      78 |
      79 |
      80 |
      81 |
      82 |
      83 |
      84 | 85 |
      86 |
      87 |
      88 |
      89 |
      90 |
      91 |
      {track.Name}
      92 |
      93 | {showAlbum ? ( 94 |
      95 |
      {track.Album || 'Unknown Album'}
      96 |
      97 | ) : ( 98 |
      99 | {track.Artists && track.Artists.length > 0 100 | ? track.Artists.join(', ') 101 | : 'Unknown Artist'} 102 |
      103 | )} 104 |
      105 | {track.UserData?.IsFavorite && ( 106 |
      107 | 108 |
      109 | )} 110 |
      {formatDuration(track.RunTimeTicks)}
      111 |
      112 |
    • 113 | ) 114 | })} 115 |
    116 | ) 117 | } 118 | -------------------------------------------------------------------------------- /src/context/DropdownContext/DropdownContext.ts: -------------------------------------------------------------------------------- 1 | import { createContext, useContext } from 'react' 2 | import { IDropdownContext } from './DropdownContextProvider' 3 | 4 | export const DropdownContext = createContext({} as IDropdownContext) 5 | 6 | export const useDropdownContext = () => useContext(DropdownContext) 7 | -------------------------------------------------------------------------------- /src/context/FilterContext/FilterContext.ts: -------------------------------------------------------------------------------- 1 | import { createContext, useContext } from 'react' 2 | import { IFilterContext } from './FilterContextProvider' 3 | 4 | export const FilterContext = createContext({} as IFilterContext) 5 | 6 | export const useFilterContext = () => useContext(FilterContext) 7 | -------------------------------------------------------------------------------- /src/context/FilterContext/FilterContextProvider.tsx: -------------------------------------------------------------------------------- 1 | import { BaseItemKind, ItemSortBy, SortOrder } from '@jellyfin/sdk/lib/generated-client' 2 | import { ReactNode, useMemo, useState } from 'react' 3 | import { FilterContext } from './FilterContext' 4 | 5 | export type IFilterContext = ReturnType 6 | 7 | const useInitialState = () => { 8 | const [sort, setSort] = useState( 9 | location.pathname === '/tracks' || location.pathname === '/albums' || location.pathname === '/genre' 10 | ? 'Added' 11 | : location.pathname === '/favorites' 12 | ? 'Tracks' 13 | : '' 14 | ) 15 | 16 | const jellySort = useMemo(() => { 17 | let newSortBy: ItemSortBy[] 18 | let newSortOrder: SortOrder[] = [SortOrder.Ascending] 19 | 20 | switch (sort) { 21 | case 'Added': 22 | newSortBy = [ItemSortBy.DateCreated] 23 | newSortOrder = [SortOrder.Descending] 24 | break 25 | case 'Released': 26 | newSortBy = [ItemSortBy.PremiereDate] 27 | break 28 | case 'Runtime': 29 | newSortBy = [ItemSortBy.Runtime] 30 | break 31 | case 'Random': 32 | newSortBy = [ItemSortBy.Random] 33 | break 34 | default: 35 | newSortBy = [ItemSortBy.DateCreated] 36 | newSortOrder = [SortOrder.Descending] 37 | } 38 | 39 | return { sortBy: newSortBy, sortOrder: newSortOrder } 40 | }, [sort]) 41 | 42 | const jellyItemKind = useMemo(() => { 43 | switch (sort) { 44 | case 'Artists': 45 | return BaseItemKind.MusicArtist 46 | case 'Albums': 47 | return BaseItemKind.MusicAlbum 48 | default: 49 | return BaseItemKind.Audio 50 | } 51 | }, [sort]) 52 | 53 | return { 54 | sort, 55 | setSort, 56 | jellySort, 57 | jellyItemKind, 58 | } 59 | } 60 | 61 | export const FilterContextProvider = ({ children }: { children: ReactNode }) => { 62 | const initialState = useInitialState() 63 | 64 | return {children} 65 | } 66 | -------------------------------------------------------------------------------- /src/context/HistoryContext/HistoryContext.ts: -------------------------------------------------------------------------------- 1 | import { createContext, useContext } from 'react' 2 | import { IHistoryContext } from './HistoryContextProvider' 3 | 4 | export const HistoryContext = createContext({} as IHistoryContext) 5 | 6 | export const useHistoryContext = () => useContext(HistoryContext) 7 | -------------------------------------------------------------------------------- /src/context/HistoryContext/HistoryContextProvider.tsx: -------------------------------------------------------------------------------- 1 | import { ReactNode, useEffect, useState } from 'react' 2 | import { useLocation, useNavigate } from 'react-router-dom' 3 | import { HistoryContext } from './HistoryContext' 4 | 5 | export type IHistoryContext = ReturnType 6 | 7 | const useInitialState = () => { 8 | const [historyStack, setHistoryStack] = useState([]) 9 | const navigate = useNavigate() 10 | const { pathname } = useLocation() 11 | 12 | useEffect(() => { 13 | if (historyStack[historyStack.length - 1] === pathname) return 14 | 15 | setHistoryStack(prev => [...prev, pathname]) 16 | }, [pathname]) // eslint-disable-line react-hooks/exhaustive-deps 17 | 18 | const goBack = () => { 19 | if (historyStack.length <= 1) { 20 | navigate('/') 21 | return 22 | } 23 | 24 | setHistoryStack(prev => { 25 | const newStack = prev.slice(0, -1) 26 | navigate(newStack[newStack.length - 1]) 27 | return newStack 28 | }) 29 | } 30 | 31 | return { goBack } 32 | } 33 | 34 | export const HistoryContextProvider = ({ children }: { children: ReactNode }) => { 35 | const initialState = useInitialState() 36 | 37 | return {children} 38 | } 39 | -------------------------------------------------------------------------------- /src/context/JellyfinContext/JellyfinContext.ts: -------------------------------------------------------------------------------- 1 | import { createContext, useContext } from 'react' 2 | import { IJellyfinContext } from './JellyfinContextProvider' 3 | 4 | export const JellyfinContext = createContext({} as IJellyfinContext) 5 | 6 | export const useJellyfinContext = () => useContext(JellyfinContext) 7 | -------------------------------------------------------------------------------- /src/context/JellyfinContext/JellyfinContextProvider.tsx: -------------------------------------------------------------------------------- 1 | import { ReactNode, useState } from 'react' 2 | import { IJellyfinAuth, initJellyfinApi } from '../../api/jellyfin' 3 | import { JellyfinContext } from './JellyfinContext' 4 | 5 | export type IJellyfinContext = ReturnType 6 | 7 | const useInitialState = (auth: IJellyfinAuth) => { 8 | const [api] = useState(initJellyfinApi(auth)) 9 | 10 | return { ...api, auth } // Preferably we dont return the auth object here but we need it for legacy functions 11 | } 12 | 13 | export const JellyfinContextProvider = ({ children, auth }: { children: ReactNode; auth: IJellyfinAuth }) => { 14 | const initialState = useInitialState(auth) 15 | 16 | return {children} 17 | } 18 | -------------------------------------------------------------------------------- /src/context/PageTitleContext/PageTitleContext.ts: -------------------------------------------------------------------------------- 1 | import { createContext, useContext } from 'react' 2 | import { IPageTitleContext } from './PageTitleProvider' 3 | 4 | export const PageTitleContext = createContext({} as IPageTitleContext) 5 | 6 | export const usePageTitle = () => useContext(PageTitleContext) 7 | -------------------------------------------------------------------------------- /src/context/PageTitleContext/PageTitleProvider.tsx: -------------------------------------------------------------------------------- 1 | import { ReactNode, useState } from 'react' 2 | import { PageTitleContext } from './PageTitleContext' 3 | 4 | export type IPageTitleContext = ReturnType 5 | 6 | const useInitialState = () => { 7 | const [pageTitle, setPageTitle] = useState('') 8 | 9 | return { 10 | pageTitle, 11 | setPageTitle, 12 | } 13 | } 14 | 15 | export const PageTitleProvider = ({ children }: { children: ReactNode }) => { 16 | const initialState = useInitialState() 17 | 18 | return {children} 19 | } 20 | -------------------------------------------------------------------------------- /src/context/PlaybackContext/PlaybackContext.ts: -------------------------------------------------------------------------------- 1 | import { createContext, useContext } from 'react' 2 | import { IPlaybackContext } from './PlaybackContextProvider' 3 | 4 | export const PlaybackContext = createContext({} as IPlaybackContext) 5 | 6 | export const usePlaybackContext = () => useContext(PlaybackContext) 7 | -------------------------------------------------------------------------------- /src/context/PlaybackContext/PlaybackContextProvider.tsx: -------------------------------------------------------------------------------- 1 | import { ReactNode } from 'react' 2 | import { PlaybackManagerProps, usePlaybackManager } from '../../components/PlaybackManager' 3 | import { PlaybackContext } from './PlaybackContext' 4 | 5 | export type IPlaybackContext = ReturnType 6 | 7 | const useInitialState = (props: PlaybackManagerProps) => { 8 | const playbackManager = usePlaybackManager(props) 9 | 10 | return playbackManager 11 | } 12 | 13 | export const PlaybackContextProvider = (props: { children: ReactNode } & PlaybackManagerProps) => { 14 | const { children, ...rest } = props 15 | const initialState = useInitialState(rest) 16 | 17 | return {children} 18 | } 19 | -------------------------------------------------------------------------------- /src/context/ScrollContext/ScrollContext.ts: -------------------------------------------------------------------------------- 1 | import { createContext, useContext } from 'react' 2 | import { IScrollContext } from './ScrollContextProvider' 3 | 4 | export const ScrollContext = createContext({} as IScrollContext) 5 | 6 | export const useScrollContext = () => useContext(ScrollContext) 7 | -------------------------------------------------------------------------------- /src/context/ScrollContext/ScrollContextProvider.tsx: -------------------------------------------------------------------------------- 1 | import { ReactNode, useEffect, useState } from 'react' 2 | import { ScrollContext } from './ScrollContext' 3 | 4 | export type IScrollContext = ReturnType 5 | 6 | const useInitialState = () => { 7 | const [disabled, setDisabled] = useState(false) 8 | 9 | useEffect(() => { 10 | const body = document.getElementsByTagName('body')[0] 11 | 12 | if (disabled) { 13 | body.classList.add('lockscroll') 14 | } else { 15 | body.classList.remove('lockscroll') 16 | } 17 | 18 | return () => { 19 | body.classList.remove('lockscroll') 20 | } 21 | }, [disabled]) 22 | 23 | return { 24 | disabled, 25 | setDisabled, 26 | } 27 | } 28 | 29 | export const ScrollContextProvider = ({ children }: { children: ReactNode }) => { 30 | const initialState = useInitialState() 31 | 32 | return {children} 33 | } 34 | -------------------------------------------------------------------------------- /src/context/SidenavContext/SidenavContext.ts: -------------------------------------------------------------------------------- 1 | import { createContext, useContext } from 'react' 2 | import { ISidenavContext } from './SidenavContextProvider' 3 | 4 | export const SidenavContext = createContext({} as ISidenavContext) 5 | 6 | export const useSidenavContext = () => useContext(SidenavContext) 7 | -------------------------------------------------------------------------------- /src/context/SidenavContext/SidenavContextProvider.tsx: -------------------------------------------------------------------------------- 1 | import { ReactNode, useEffect, useState } from 'react' 2 | import { useLocation } from 'react-router-dom' 3 | import { SidenavContext } from './SidenavContext' 4 | 5 | export type ISidenavContext = ReturnType 6 | 7 | const useInitialState = () => { 8 | const [showSidenav, setShowSidenav] = useState(false) 9 | const location = useLocation() 10 | 11 | // Close sidenav on route change if location is provided 12 | useEffect(() => { 13 | if (location?.pathname) { 14 | setShowSidenav(false) 15 | } 16 | }, [location?.pathname]) 17 | 18 | // Toggle lockscroll on body 19 | useEffect(() => { 20 | if (showSidenav) { 21 | document.body.classList.add('lockscroll') 22 | } else { 23 | document.body.classList.remove('lockscroll') 24 | } 25 | 26 | return () => { 27 | document.body.classList.remove('lockscroll') 28 | } 29 | }, [showSidenav]) 30 | 31 | const toggleSidenav = () => { 32 | setShowSidenav(current => !current) 33 | } 34 | 35 | const closeSidenav = () => { 36 | setShowSidenav(false) 37 | } 38 | 39 | return { showSidenav, toggleSidenav, closeSidenav } 40 | } 41 | 42 | export const SidenavContextProvider = ({ children }: { children: ReactNode }) => { 43 | const initialState = useInitialState() 44 | 45 | return {children} 46 | } 47 | -------------------------------------------------------------------------------- /src/context/ThemeContext/ThemeContext.ts: -------------------------------------------------------------------------------- 1 | import { createContext, useContext } from 'react' 2 | import { IThemeContext } from './ThemeContextProvider' 3 | 4 | export const ThemeContext = createContext({} as IThemeContext) 5 | 6 | export const useThemeContext = () => useContext(ThemeContext) 7 | -------------------------------------------------------------------------------- /src/context/ThemeContext/ThemeContextProvider.tsx: -------------------------------------------------------------------------------- 1 | import { ReactNode, useEffect, useState } from 'react' 2 | import { ThemeContext } from './ThemeContext' 3 | 4 | type Theme = 'light' | 'dark' | 'system' 5 | export type IThemeContext = ReturnType 6 | 7 | const useInitialState = () => { 8 | const [theme, setTheme] = useState(() => { 9 | const savedTheme = localStorage.getItem('theme') as Theme 10 | return savedTheme || 'light' 11 | }) 12 | 13 | useEffect(() => { 14 | const html = document.documentElement 15 | const systemDarkQuery = window.matchMedia('(prefers-color-scheme: dark)') 16 | 17 | // Apply theme only on first load if needed 18 | const applyInitialTheme = (currentTheme: Theme) => { 19 | const currentClass = html.classList.contains('dark') ? 'dark' : 'light' 20 | const expectedClass = 21 | currentTheme === 'system' ? (systemDarkQuery.matches ? 'dark' : 'light') : currentTheme 22 | if (currentClass !== expectedClass) { 23 | html.classList.remove('light', 'dark') 24 | if (currentTheme === 'system') { 25 | const isDark = systemDarkQuery.matches 26 | html.classList.add(isDark ? 'dark' : 'light') 27 | } else { 28 | html.classList.add(currentTheme) 29 | } 30 | } 31 | } 32 | 33 | applyInitialTheme(theme) 34 | 35 | // Handle system changes only when theme is 'system' 36 | const handleSystemChange = (e: MediaQueryListEvent) => { 37 | if (theme === 'system') { 38 | html.classList.remove('light', 'dark') 39 | html.classList.add(e.matches ? 'dark' : 'light') 40 | } 41 | } 42 | systemDarkQuery.addEventListener('change', handleSystemChange) 43 | return () => systemDarkQuery.removeEventListener('change', handleSystemChange) 44 | }, []) // eslint-disable-line react-hooks/exhaustive-deps 45 | 46 | const toggleTheme = (newTheme: Theme) => { 47 | setTheme(newTheme) 48 | const html = document.documentElement 49 | setTimeout(() => { 50 | html.classList.remove('light', 'dark') 51 | if (newTheme === 'system') { 52 | const isDark = window.matchMedia('(prefers-color-scheme: dark)').matches 53 | html.classList.add(isDark ? 'dark' : 'light') 54 | } else { 55 | html.classList.add(newTheme) 56 | } 57 | localStorage.setItem('theme', newTheme) 58 | }, 200) // Delayed class switch 59 | } 60 | 61 | return { theme, toggleTheme } 62 | } 63 | 64 | export const ThemeContextProvider = ({ children }: { children: ReactNode }) => { 65 | const initialState = useInitialState() 66 | return {children} 67 | } 68 | -------------------------------------------------------------------------------- /src/fontsource.d.ts: -------------------------------------------------------------------------------- 1 | declare module '@fontsource-variable/inter' { 2 | export default any 3 | } 4 | -------------------------------------------------------------------------------- /src/hooks/Jellyfin/Infinite/useJellyfinAlbumsData.ts: -------------------------------------------------------------------------------- 1 | import { ___PAGE_PARAM_INDEX___ } from '../../../components/PlaybackManager' 2 | import { useFilterContext } from '../../../context/FilterContext/FilterContext' 3 | import { useJellyfinContext } from '../../../context/JellyfinContext/JellyfinContext' 4 | import { useJellyfinInfiniteData } from './useJellyfinInfiniteData' 5 | 6 | export const useJellyfinAlbumsData = () => { 7 | const api = useJellyfinContext() 8 | const itemsPerPage = 40 9 | const { jellySort } = useFilterContext() 10 | 11 | return useJellyfinInfiniteData({ 12 | queryKey: ['albums', jellySort.sortBy, jellySort.sortOrder], 13 | queryFn: async ({ pageParam = 0 }) => { 14 | const startIndex = (pageParam as number) * itemsPerPage 15 | return await api.getAllAlbums(startIndex, itemsPerPage, jellySort.sortBy, jellySort.sortOrder) 16 | }, 17 | queryFnReviver: { 18 | fn: 'getAllAlbums', 19 | params: [___PAGE_PARAM_INDEX___, itemsPerPage, jellySort.sortBy, jellySort.sortOrder], 20 | }, 21 | }) 22 | } 23 | -------------------------------------------------------------------------------- /src/hooks/Jellyfin/Infinite/useJellyfinArtistTracksData.ts: -------------------------------------------------------------------------------- 1 | import { ___PAGE_PARAM_INDEX___ } from '../../../components/PlaybackManager' 2 | import { useJellyfinContext } from '../../../context/JellyfinContext/JellyfinContext' 3 | import { useJellyfinInfiniteData } from './useJellyfinInfiniteData' 4 | 5 | export const useJellyfinArtistTracksData = (artistId: string) => { 6 | const api = useJellyfinContext() 7 | const itemsPerPage = 40 8 | 9 | return useJellyfinInfiniteData({ 10 | queryKey: ['artistTracks', artistId], 11 | queryFn: async ({ pageParam = 0 }) => { 12 | const startIndex = (pageParam as number) * itemsPerPage 13 | const { Items } = await api.getArtistTracks(artistId, startIndex, itemsPerPage) 14 | return Items 15 | }, 16 | queryFnReviver: { 17 | fn: 'getArtistTracks', 18 | params: [artistId, ___PAGE_PARAM_INDEX___, itemsPerPage], 19 | }, 20 | }) 21 | } 22 | -------------------------------------------------------------------------------- /src/hooks/Jellyfin/Infinite/useJellyfinFavoritesData.ts: -------------------------------------------------------------------------------- 1 | import { ___PAGE_PARAM_INDEX___ } from '../../../components/PlaybackManager' 2 | import { useFilterContext } from '../../../context/FilterContext/FilterContext' 3 | import { useJellyfinContext } from '../../../context/JellyfinContext/JellyfinContext' 4 | import { useJellyfinInfiniteData } from './useJellyfinInfiniteData' 5 | 6 | export const useJellyfinFavoritesData = () => { 7 | const api = useJellyfinContext() 8 | const itemsPerPage = 40 9 | const { jellySort, jellyItemKind } = useFilterContext() 10 | 11 | return useJellyfinInfiniteData({ 12 | queryKey: ['favorites', jellyItemKind], 13 | queryFn: async ({ pageParam = 0 }) => { 14 | const startIndex = (pageParam as number) * itemsPerPage 15 | return await api.getFavoriteTracks( 16 | startIndex, 17 | itemsPerPage, 18 | jellySort.sortBy, 19 | jellySort.sortOrder, 20 | jellyItemKind 21 | ) 22 | }, 23 | queryFnReviver: { 24 | fn: 'getFavoriteTracks', 25 | params: [___PAGE_PARAM_INDEX___, itemsPerPage, jellyItemKind], 26 | }, 27 | }) 28 | } 29 | -------------------------------------------------------------------------------- /src/hooks/Jellyfin/Infinite/useJellyfinFrequentlyPlayedData.ts: -------------------------------------------------------------------------------- 1 | import { ___PAGE_PARAM_INDEX___ } from '../../../components/PlaybackManager' 2 | import { useJellyfinContext } from '../../../context/JellyfinContext/JellyfinContext' 3 | import { useJellyfinInfiniteData } from './useJellyfinInfiniteData' 4 | 5 | export const useJellyfinFrequentlyPlayedData = () => { 6 | const api = useJellyfinContext() 7 | const itemsPerPage = 40 8 | 9 | return useJellyfinInfiniteData({ 10 | queryKey: ['frequentlyPlayed'], 11 | queryFn: async ({ pageParam = 0 }) => { 12 | const startIndex = (pageParam as number) * itemsPerPage 13 | return await api.fetchFrequentlyPlayed(startIndex, itemsPerPage) 14 | }, 15 | queryFnReviver: { 16 | fn: 'fetchFrequentlyPlayed', 17 | params: [___PAGE_PARAM_INDEX___, itemsPerPage], 18 | }, 19 | }) 20 | } 21 | -------------------------------------------------------------------------------- /src/hooks/Jellyfin/Infinite/useJellyfinGenreTracks.ts: -------------------------------------------------------------------------------- 1 | import { ___PAGE_PARAM_INDEX___ } from '../../../components/PlaybackManager' 2 | import { useFilterContext } from '../../../context/FilterContext/FilterContext' 3 | import { useJellyfinContext } from '../../../context/JellyfinContext/JellyfinContext' 4 | import { useJellyfinInfiniteData } from './useJellyfinInfiniteData' 5 | 6 | export const useJellyfinGenreTracks = (genre: string) => { 7 | const api = useJellyfinContext() 8 | const itemsPerPage = 40 9 | const { jellySort } = useFilterContext() 10 | 11 | return useJellyfinInfiniteData({ 12 | queryKey: ['genreTracks', genre, jellySort.sortBy, jellySort.sortOrder], 13 | queryFn: async ({ pageParam = 0 }) => { 14 | const startIndex = (pageParam as number) * itemsPerPage 15 | return await api.getGenreTracks(genre, startIndex, itemsPerPage, jellySort.sortBy, jellySort.sortOrder) 16 | }, 17 | queryFnReviver: { 18 | fn: 'getGenreTracks', 19 | params: [genre, ___PAGE_PARAM_INDEX___, itemsPerPage, jellySort.sortBy, jellySort.sortOrder], 20 | }, 21 | }) 22 | } 23 | -------------------------------------------------------------------------------- /src/hooks/Jellyfin/Infinite/useJellyfinInfiniteData.ts: -------------------------------------------------------------------------------- 1 | import { QueryFunction, useInfiniteQuery } from '@tanstack/react-query' 2 | import { useCallback, useEffect, useMemo } from 'react' 3 | import { ApiError, MediaItem } from '../../../api/jellyfin' 4 | import { IReviver } from '../../../components/PlaybackManager' 5 | import { getAllTracks } from '../../../utils/getAllTracks' 6 | 7 | export type IJellyfinInfiniteProps = Parameters[0] 8 | 9 | export const useJellyfinInfiniteData = ({ 10 | queryKey, 11 | queryFn, 12 | initialPageParam = 0, 13 | queryFnReviver, 14 | }: { 15 | queryKey: unknown[] 16 | queryFn: QueryFunction 17 | initialPageParam?: number 18 | queryFnReviver: IReviver['queryFn'] 19 | }) => { 20 | const itemsPerPage = 40 21 | 22 | const { data, isFetching, isPending, error, fetchNextPage, hasNextPage, isFetchingNextPage } = useInfiniteQuery< 23 | MediaItem[], 24 | ApiError 25 | >({ 26 | queryKey, 27 | queryFn, 28 | getNextPageParam: (lastPage, pages) => (lastPage.length === itemsPerPage ? pages.length : undefined), 29 | initialPageParam, 30 | staleTime: Infinity, 31 | }) 32 | 33 | useEffect(() => { 34 | if (error instanceof ApiError) { 35 | if (error.response?.status === 401) { 36 | localStorage.removeItem('auth') 37 | window.location.href = '/login' 38 | } 39 | } 40 | }, [error]) 41 | 42 | const allTracks = useMemo(() => { 43 | return getAllTracks(data) 44 | }, [data]) 45 | 46 | const loadMore = useCallback(async () => { 47 | if (hasNextPage && !isFetchingNextPage) { 48 | return getAllTracks((await fetchNextPage()).data) 49 | } 50 | }, [hasNextPage, isFetchingNextPage, fetchNextPage]) 51 | 52 | return { 53 | items: allTracks, 54 | isLoading: isFetching || isPending, 55 | error: error ? error.message : null, 56 | hasNextPage, 57 | loadMore, 58 | reviver: { 59 | queryKey, 60 | queryFn: queryFnReviver, 61 | }, 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /src/hooks/Jellyfin/Infinite/useJellyfinPlaylistData.ts: -------------------------------------------------------------------------------- 1 | import { useQuery } from '@tanstack/react-query' 2 | import { useEffect } from 'react' 3 | import { ApiError, MediaItem } from '../../../api/jellyfin' 4 | import { ___PAGE_PARAM_INDEX___ } from '../../../components/PlaybackManager' 5 | import { useJellyfinContext } from '../../../context/JellyfinContext/JellyfinContext' 6 | import { useJellyfinInfiniteData } from './useJellyfinInfiniteData' 7 | 8 | export const useJellyfinPlaylistData = (playlistId: string) => { 9 | const api = useJellyfinContext() 10 | const itemsPerPage = 40 11 | 12 | const { data: playlist, error: playlistError } = useQuery({ 13 | queryKey: ['playlist', playlistId], 14 | queryFn: () => api.getPlaylist(playlistId), 15 | }) 16 | 17 | const { data: totals, error: totalsError } = useQuery< 18 | { 19 | totalTrackCount: number 20 | totalPlaytime: number 21 | }, 22 | ApiError 23 | >({ 24 | queryKey: ['playlistTotals', playlistId], 25 | queryFn: () => api.getPlaylistTotals(playlistId), 26 | }) 27 | 28 | useEffect(() => { 29 | if ( 30 | (playlistError || totalsError) instanceof ApiError && 31 | (playlistError || totalsError)?.response?.status === 401 32 | ) { 33 | localStorage.removeItem('auth') 34 | window.location.href = '/login' 35 | } 36 | }, [playlistError, totalsError]) 37 | 38 | const infiniteData = useJellyfinInfiniteData({ 39 | queryKey: ['playlistTracks', playlistId], 40 | queryFn: async ({ pageParam = 0 }) => { 41 | const startIndex = (pageParam as number) * itemsPerPage 42 | return await api.getPlaylistTracks(playlistId, startIndex, itemsPerPage) 43 | }, 44 | queryFnReviver: { 45 | fn: 'getPlaylistTracks', 46 | params: [playlistId, ___PAGE_PARAM_INDEX___, itemsPerPage], 47 | }, 48 | }) 49 | 50 | const totalPlaytime = totals?.totalPlaytime || 0 51 | const totalTrackCount = totals?.totalTrackCount || 0 52 | const totalPlays = infiniteData.items.reduce((sum, track) => sum + (track.UserData?.PlayCount || 0), 0) 53 | 54 | return { 55 | ...infiniteData, 56 | playlist, 57 | totalPlaytime, 58 | totalTrackCount, 59 | totalPlays, 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/hooks/Jellyfin/Infinite/useJellyfinRecentlyPlayedData.ts: -------------------------------------------------------------------------------- 1 | import { ___PAGE_PARAM_INDEX___ } from '../../../components/PlaybackManager' 2 | import { useJellyfinContext } from '../../../context/JellyfinContext/JellyfinContext' 3 | import { useJellyfinInfiniteData } from './useJellyfinInfiniteData' 4 | 5 | export const useJellyfinRecentlyPlayedData = () => { 6 | const api = useJellyfinContext() 7 | const itemsPerPage = 40 8 | 9 | return useJellyfinInfiniteData({ 10 | queryKey: ['recentlyPlayed'], 11 | queryFn: async ({ pageParam = 0 }) => { 12 | const startIndex = (pageParam as number) * itemsPerPage 13 | return await api.fetchRecentlyPlayed(startIndex, itemsPerPage) 14 | }, 15 | queryFnReviver: { 16 | fn: 'fetchRecentlyPlayed', 17 | params: [___PAGE_PARAM_INDEX___, itemsPerPage], 18 | }, 19 | }) 20 | } 21 | -------------------------------------------------------------------------------- /src/hooks/Jellyfin/Infinite/useJellyfinTracksData.ts: -------------------------------------------------------------------------------- 1 | import { ___PAGE_PARAM_INDEX___ } from '../../../components/PlaybackManager' 2 | import { useFilterContext } from '../../../context/FilterContext/FilterContext' 3 | import { useJellyfinContext } from '../../../context/JellyfinContext/JellyfinContext' 4 | import { useJellyfinInfiniteData } from './useJellyfinInfiniteData' 5 | 6 | export const useJellyfinTracksData = () => { 7 | const api = useJellyfinContext() 8 | const itemsPerPage = 40 9 | const { jellySort } = useFilterContext() 10 | 11 | return useJellyfinInfiniteData({ 12 | queryKey: ['jellyfinTracks', jellySort.sortBy, jellySort.sortOrder], 13 | queryFn: async ({ pageParam = 0 }) => { 14 | const startIndex = (pageParam as number) * itemsPerPage 15 | return await api.getAllTracks(startIndex, itemsPerPage, jellySort.sortBy, jellySort.sortOrder) 16 | }, 17 | queryFnReviver: { 18 | fn: 'getAllTracks', 19 | params: [___PAGE_PARAM_INDEX___, itemsPerPage, jellySort.sortBy, jellySort.sortOrder], 20 | }, 21 | }) 22 | } 23 | -------------------------------------------------------------------------------- /src/hooks/Jellyfin/useJellyfinAlbumData.ts: -------------------------------------------------------------------------------- 1 | import { useQuery } from '@tanstack/react-query' 2 | import { MediaItem } from '../../api/jellyfin' 3 | import { useJellyfinContext } from '../../context/JellyfinContext/JellyfinContext' 4 | 5 | interface JellyfinAlbumData { 6 | album: MediaItem | null 7 | tracks: MediaItem[] 8 | discCount: number 9 | loading: boolean 10 | error: string | null 11 | } 12 | 13 | export const useJellyfinAlbumData = (albumId: string) => { 14 | const api = useJellyfinContext() 15 | 16 | const { data, isFetching, isPending, error } = useQuery({ 17 | queryKey: ['albumData', albumId], 18 | queryFn: async () => { 19 | const { album, tracks } = await api.getAlbumDetails(albumId) 20 | 21 | const discNumbers = new Set(tracks.map(track => track.ParentIndexNumber || 1)) 22 | const discCount = discNumbers.size 23 | 24 | return { 25 | album, 26 | tracks, 27 | discCount, 28 | loading: false, 29 | error: null, 30 | } 31 | }, 32 | }) 33 | 34 | return { 35 | ...data, 36 | loading: isFetching || isPending, 37 | error: error ? error.message : null, 38 | tracks: data?.tracks || [], 39 | discCount: data?.discCount || 1, 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/hooks/Jellyfin/useJellyfinArtistData.ts: -------------------------------------------------------------------------------- 1 | import { useQuery } from '@tanstack/react-query' 2 | import { MediaItem } from '../../api/jellyfin' 3 | import { useJellyfinContext } from '../../context/JellyfinContext/JellyfinContext' 4 | 5 | interface JellyfinArtistData { 6 | artist: MediaItem | null 7 | tracks: MediaItem[] 8 | albums: MediaItem[] 9 | appearsInAlbums: MediaItem[] 10 | totalTrackCount: number 11 | totalPlaytime: number 12 | totalPlays: number 13 | loading: boolean 14 | error: string | null 15 | } 16 | 17 | export const useJellyfinArtistData = (artistId: string, trackLimit = 5) => { 18 | const api = useJellyfinContext() 19 | 20 | const { data, isFetching, isPending, error } = useQuery({ 21 | queryKey: ['artistData', artistId, trackLimit], 22 | queryFn: async () => { 23 | const [artistDetailsResponse, allTracks] = await Promise.all([ 24 | api.getArtistDetails(artistId, trackLimit), 25 | api.fetchAllTracks(artistId), 26 | ]) 27 | 28 | const { artist, tracks, albums, appearsInAlbums, totalTrackCount } = artistDetailsResponse 29 | 30 | const totalPlaytime = allTracks.reduce( 31 | (sum: number, track: MediaItem) => sum + (track.RunTimeTicks || 0), 32 | 0 33 | ) 34 | const totalPlays = allTracks.reduce( 35 | (sum: number, track: MediaItem) => sum + (track.UserData?.PlayCount || 0), 36 | 0 37 | ) 38 | return { 39 | artist, 40 | tracks, 41 | albums, 42 | appearsInAlbums, 43 | totalTrackCount, 44 | totalPlaytime, 45 | totalPlays, 46 | loading: false, 47 | error: null, 48 | } 49 | }, 50 | }) 51 | 52 | return { 53 | ...data, 54 | loading: isFetching || isPending, 55 | error: error ? error.message : null, 56 | appearsInAlbums: data?.appearsInAlbums || [], 57 | albums: data?.albums || [], 58 | tracks: data?.tracks || [], 59 | totalPlays: data?.totalPlays || 0, 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/hooks/Jellyfin/useJellyfinHomeData.ts: -------------------------------------------------------------------------------- 1 | import { useQuery } from '@tanstack/react-query' 2 | import { MediaItem } from '../../api/jellyfin' 3 | import { useJellyfinContext } from '../../context/JellyfinContext/JellyfinContext' 4 | 5 | interface JellyfinHomeData { 6 | recentlyPlayed: MediaItem[] 7 | frequentlyPlayed: MediaItem[] 8 | recentlyAdded: MediaItem[] 9 | loading: boolean 10 | error: string | null 11 | } 12 | 13 | export const useJellyfinHomeData = () => { 14 | const api = useJellyfinContext() 15 | 16 | const { data, isLoading, error } = useQuery({ 17 | queryKey: ['homeData'], 18 | queryFn: async () => { 19 | const [recentlyPlayed, frequentlyPlayed, recentlyAdded] = await Promise.all([ 20 | api.getRecentlyPlayed(), 21 | api.getFrequentlyPlayed(), 22 | api.getRecentlyAdded(), 23 | ]) 24 | return { 25 | recentlyPlayed: recentlyPlayed.filter(item => item.Type === 'Audio'), 26 | frequentlyPlayed: frequentlyPlayed.filter(item => item.Type === 'Audio'), 27 | recentlyAdded: recentlyAdded.filter(item => item.Type === 'MusicAlbum'), 28 | loading: false, 29 | error: null, 30 | } 31 | }, 32 | }) 33 | 34 | return { 35 | ...data, 36 | isLoading, 37 | error: error ? error.message : null, 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/hooks/Jellyfin/useJellyfinPlaylistsFeaturingArtist.ts: -------------------------------------------------------------------------------- 1 | import { useQuery } from '@tanstack/react-query' 2 | import { MediaItem } from '../../api/jellyfin' 3 | import { useJellyfinContext } from '../../context/JellyfinContext/JellyfinContext' 4 | 5 | export const useJellyfinPlaylistsFeaturingArtist = (artistId: string) => { 6 | const api = useJellyfinContext() 7 | 8 | const { data, isFetching, isPending, error } = useQuery({ 9 | queryKey: ['playlistsFeaturingArtist', artistId], 10 | queryFn: async () => { 11 | return await api.getPlaylistsFeaturingArtist(artistId) 12 | }, 13 | }) 14 | 15 | return { 16 | playlists: data || [], 17 | loading: isFetching || isPending, 18 | error: error ? error.message : null, 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/hooks/Jellyfin/useJellyfinPlaylistsList.ts: -------------------------------------------------------------------------------- 1 | import { useQuery } from '@tanstack/react-query' 2 | import { MediaItem } from '../../api/jellyfin' 3 | import { useJellyfinContext } from '../../context/JellyfinContext/JellyfinContext' 4 | 5 | export const useJellyfinPlaylistsList = () => { 6 | const api = useJellyfinContext() 7 | 8 | const { data, isFetching, isPending, error } = useQuery({ 9 | queryKey: ['playlists'], 10 | queryFn: async () => { 11 | return await api.getAllPlaylists() 12 | }, 13 | }) 14 | 15 | return { 16 | playlists: data || [], 17 | loading: isFetching || isPending, 18 | error: error ? error.message : null, 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/hooks/useDependencyDebug.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable react-hooks/exhaustive-deps */ 2 | /* eslint-disable @typescript-eslint/no-explicit-any */ 3 | import { useEffect, useRef } from 'react' 4 | 5 | export const useDependencyDebug = (deps: any[], name = 'deps') => { 6 | const prev = useRef([]) 7 | 8 | useEffect(() => { 9 | deps.forEach((dep, i) => { 10 | if (dep !== prev.current[i]) { 11 | console.info(`[${name}] Dependency[${i}] changed`, { 12 | prev: prev.current[i], 13 | next: dep, 14 | }) 15 | } 16 | }) 17 | prev.current = deps 18 | }, deps) 19 | } 20 | -------------------------------------------------------------------------------- /src/hooks/useDisplayItems.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef, useState } from 'react' 2 | import { MediaItem } from '../api/jellyfin' 3 | 4 | export const useDisplayItems = (tracks: MediaItem[], isLoading: boolean) => { 5 | const [displayItems, setDisplayItems] = useState<(MediaItem | { isPlaceholder: true })[]>(tracks) 6 | const sizeMap = useRef<{ [index: number]: number }>({}) 7 | const rowRefs = useRef<(HTMLElement | null)[]>([]) 8 | const resizeObservers = useRef([]) 9 | 10 | useEffect(() => { 11 | if (isLoading) { 12 | setDisplayItems([...tracks, ...Array(4).fill({ isPlaceholder: true })]) 13 | } else { 14 | setDisplayItems(tracks) 15 | } 16 | }, [tracks, isLoading]) 17 | 18 | const setSize = (index: number, height: number) => { 19 | sizeMap.current = { ...sizeMap.current, [index]: height } 20 | } 21 | 22 | useEffect(() => { 23 | const handleResize = () => { 24 | measureInitialHeights() 25 | } 26 | 27 | const setupResizeObservers = () => { 28 | resizeObservers.current = rowRefs.current.map((ref, index) => { 29 | const observer = new ResizeObserver(() => { 30 | if (ref) { 31 | const originalHeight = ref.style.height 32 | ref.style.height = 'auto' 33 | const height = ref.getBoundingClientRect().height 34 | ref.style.height = originalHeight || `${height}px` 35 | if (height !== sizeMap.current[index]) { 36 | setSize(index, height) 37 | } 38 | } 39 | }) 40 | if (ref) observer.observe(ref) 41 | return observer 42 | }) 43 | } 44 | 45 | const cleanupResizeObservers = () => { 46 | resizeObservers.current.forEach(observer => observer.disconnect()) 47 | resizeObservers.current = [] 48 | } 49 | 50 | const measureInitialHeights = () => { 51 | rowRefs.current.forEach((ref, index) => { 52 | if (ref) { 53 | const originalHeight = ref.style.height 54 | ref.style.height = 'auto' 55 | const height = ref.getBoundingClientRect().height 56 | ref.style.height = originalHeight || `${height}px` 57 | if (height !== sizeMap.current[index]) { 58 | setSize(index, height) 59 | } 60 | } 61 | }) 62 | } 63 | 64 | rowRefs.current = displayItems.map(() => null) 65 | cleanupResizeObservers() 66 | measureInitialHeights() 67 | setupResizeObservers() 68 | document.body.style.overflowY = 'auto' 69 | window.addEventListener('resize', handleResize) 70 | 71 | return () => { 72 | cleanupResizeObservers() 73 | window.removeEventListener('resize', handleResize) 74 | } 75 | }, [displayItems]) 76 | 77 | return { 78 | displayItems, 79 | setDisplayItems, 80 | setRowRefs: (index: number, el: HTMLElement | null) => { 81 | rowRefs.current[index] = el 82 | }, 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /src/hooks/useDocumentTitle.ts: -------------------------------------------------------------------------------- 1 | import { useEffect } from 'react' 2 | import { useLocation } from 'react-router-dom' 3 | import { usePageTitle } from '../context/PageTitleContext/PageTitleContext' 4 | import { usePlaybackContext } from '../context/PlaybackContext/PlaybackContext' 5 | import { getPageTitle } from '../utils/titleUtils' 6 | 7 | export const useDocumentTitle = () => { 8 | const { currentTrack, isPlaying } = usePlaybackContext() 9 | const { pageTitle } = usePageTitle() 10 | const location = useLocation() 11 | 12 | useEffect(() => { 13 | if (currentTrack && isPlaying) { 14 | const artist = currentTrack.AlbumArtist || currentTrack.Artists?.[0] || 'Unknown Artist' 15 | document.title = `${currentTrack.Name} - ${artist}` 16 | } else { 17 | document.title = `${getPageTitle(pageTitle, location)} - Jelly Music App` 18 | } 19 | }, [currentTrack, isPlaying, pageTitle, location.pathname, location]) 20 | } 21 | -------------------------------------------------------------------------------- /src/hooks/useFavorites.ts: -------------------------------------------------------------------------------- 1 | import { MediaItem } from '../api/jellyfin' 2 | import { useJellyfinContext } from '../context/JellyfinContext/JellyfinContext' 3 | import { usePatchQueries } from './usePatchQueries' 4 | 5 | export const useFavorites = () => { 6 | const api = useJellyfinContext() 7 | const { patchMediaItem, prependItemToQueryData, removeItemFromQueryData } = usePatchQueries() 8 | 9 | return { 10 | addToFavorites: async (item: MediaItem) => { 11 | const res = await api.addToFavorites(item.Id) 12 | 13 | prependItemToQueryData(['favorites', item.Type || ''], item) 14 | 15 | patchMediaItem(item.Id, item => { 16 | return { ...item, UserData: res.data } 17 | }) 18 | }, 19 | removeFromFavorites: async (item: MediaItem) => { 20 | const res = await api.removeFromFavorites(item.Id) 21 | 22 | removeItemFromQueryData(['favorites', item.Type || ''], item.Id) 23 | 24 | patchMediaItem(item.Id, item => { 25 | return { ...item, UserData: res.data } 26 | }) 27 | }, 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/hooks/usePatchQueries.ts: -------------------------------------------------------------------------------- 1 | import { InfiniteData, useQueryClient } from '@tanstack/react-query' 2 | import { MediaItem } from '../api/jellyfin' 3 | 4 | const isPages = (data: object): data is InfiniteData => { 5 | return 'pages' in data && !!data.pages 6 | } 7 | 8 | const isMediaItem = (data: object): data is MediaItem => { 9 | return 'Id' in data && !!data.Id 10 | } 11 | 12 | const isObject = (data: unknown): data is { [x: string]: unknown } => { 13 | return typeof data === 'object' 14 | } 15 | 16 | const patchData = (data: unknown, itemId: string, patch: IPatch): unknown => { 17 | if (!data) return data 18 | 19 | if (Array.isArray(data)) { 20 | return data.map(item => patchData(item, itemId, patch)) 21 | } 22 | 23 | if (isObject(data)) { 24 | if (isMediaItem(data) && data.Id === itemId) { 25 | return patch(data) 26 | } 27 | 28 | if (isPages(data)) { 29 | return { 30 | ...data, 31 | pages: data.pages.map(page => patchData(page, itemId, patch)), 32 | } 33 | } 34 | 35 | // Fallback for objects that are not MediaItem or InfiniteData, e.g. album data 36 | const newData: { [x: string]: unknown } = {} 37 | 38 | for (const key in data) { 39 | newData[key] = patchData(data[key], itemId, patch) 40 | } 41 | 42 | return newData 43 | } 44 | 45 | return data 46 | } 47 | 48 | type IPatch = (item: MediaItem) => MediaItem 49 | 50 | export const usePatchQueries = () => { 51 | const queryClient = useQueryClient() 52 | 53 | return { 54 | patchMediaItem: (mediaItemId: string, patch: IPatch) => { 55 | const allQueries = queryClient.getQueryCache().findAll() 56 | 57 | for (const query of allQueries) { 58 | const data = query.state.data 59 | 60 | if (!data) continue 61 | 62 | queryClient.setQueryData(query.queryKey, patchData(data, mediaItemId, patch)) 63 | } 64 | }, 65 | prependItemToQueryData: (queryKey: string[], item: MediaItem) => { 66 | const allQueries = queryClient.getQueryCache().findAll() 67 | 68 | for (const query of allQueries) { 69 | const data = query.state.data 70 | 71 | if (!data) continue 72 | 73 | // check if the query.queryKey starts with the queryKey 74 | if (query.queryKey.slice(0, queryKey.length).join(',') !== queryKey.join(',')) continue 75 | 76 | if (isPages(data)) { 77 | const [first, ...pages] = data.pages 78 | 79 | query.setData({ 80 | ...data, 81 | pages: [[item, ...first], ...pages], 82 | }) 83 | } else { 84 | query.setData([item, ...(data as MediaItem[])]) 85 | } 86 | } 87 | }, 88 | removeItemFromQueryData: (queryKey: string[], itemId: string) => { 89 | const allQueries = queryClient.getQueryCache().findAll() 90 | 91 | for (const query of allQueries) { 92 | const data = query.state.data 93 | 94 | if (!data) continue 95 | 96 | // check if the query.queryKey starts with the queryKey 97 | if (query.queryKey.slice(0, queryKey.length).join(',') !== queryKey.join(',')) continue 98 | 99 | if (isPages(data)) { 100 | query.setData({ 101 | ...data, 102 | pages: data.pages.map(page => page.filter((item: MediaItem) => item.Id !== itemId)), 103 | }) 104 | } else { 105 | query.setData((data as MediaItem[]).filter(item => item.Id !== itemId)) 106 | } 107 | } 108 | }, 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /src/hooks/usePlaylists.ts: -------------------------------------------------------------------------------- 1 | import { MediaItem } from '../api/jellyfin' 2 | import { useJellyfinContext } from '../context/JellyfinContext/JellyfinContext' 3 | import { usePatchQueries } from './usePatchQueries' 4 | 5 | export const usePlaylists = () => { 6 | const api = useJellyfinContext() 7 | const { prependItemToQueryData, removeItemFromQueryData } = usePatchQueries() 8 | 9 | return { 10 | addToPlaylist: async (item: MediaItem, playlistId: string) => { 11 | await api.addToPlaylist(playlistId, item.Id) 12 | prependItemToQueryData(['playlistTracks', playlistId], item) 13 | }, 14 | removeFromPlaylist: async (item: MediaItem, playlistId: string) => { 15 | await api.removeFromPlaylist(playlistId, item.Id) 16 | removeItemFromQueryData(['playlistTracks', playlistId], item.Id) 17 | }, 18 | createPlaylist: async (name: string) => { 19 | const res = await api.createPlaylist(name) 20 | const playlist = await api.getPlaylist(res.Id!) 21 | prependItemToQueryData(['playlists'], playlist) 22 | return playlist 23 | }, 24 | deletePlaylist: async (playlistId: string) => { 25 | await api.deletePlaylist(playlistId) 26 | removeItemFromQueryData(['playlists'], playlistId) 27 | }, 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/main.tsx: -------------------------------------------------------------------------------- 1 | import { StrictMode } from 'react' 2 | import { createRoot } from 'react-dom/client' 3 | import { App } from './App.tsx' 4 | 5 | const savedTheme = localStorage.getItem('theme') || 'light' 6 | const isSystemDark = window.matchMedia('(prefers-color-scheme: dark)').matches 7 | const themeClass = savedTheme === 'system' ? (isSystemDark ? 'dark' : 'light') : savedTheme 8 | document.documentElement.classList.add(themeClass) 9 | 10 | createRoot(document.getElementById('root')!).render( 11 | 12 | 13 | 14 | ) 15 | -------------------------------------------------------------------------------- /src/pages/Album.css: -------------------------------------------------------------------------------- 1 | .album-page { 2 | margin-top: 4px; 3 | } 4 | 5 | .album-page > .album-header { 6 | display: flex; 7 | align-items: center; 8 | margin-left: 28px; 9 | margin-bottom: 14px; 10 | } 11 | 12 | .album-page > .album-header > .thumbnail { 13 | display: block; 14 | width: 100px; 15 | height: 100px; 16 | object-fit: cover; 17 | border-radius: 8px; 18 | margin-right: 20px; 19 | background-color: var(--bg-color-secondary); 20 | } 21 | 22 | .album-page > .album-header > .album-details { 23 | flex: 1; 24 | } 25 | 26 | .album-page > .album-header > .album-details > .artist { 27 | font-size: 0.9rem; 28 | font-weight: 600; 29 | margin-bottom: 4px; 30 | } 31 | 32 | .album-page > .album-header > .album-details > .date { 33 | font-size: 0.75rem; 34 | font-weight: 500; 35 | color: var(--font-color-tertiary); 36 | margin-bottom: 4px; 37 | } 38 | 39 | .album-page > .album-header > .album-details > .stats { 40 | font-size: 0.75rem; 41 | font-weight: 500; 42 | color: var(--font-color-tertiary); 43 | display: flex; 44 | align-items: center; 45 | flex-wrap: wrap; 46 | } 47 | 48 | .album-page > .album-header > .album-details > .stats > .track-amount > .number { 49 | font-weight: 600; 50 | letter-spacing: 0.5px; 51 | } 52 | 53 | .album-page > .album-header > .album-details > .stats > .divider { 54 | width: 2px; 55 | height: 2px; 56 | margin: 0 4px; 57 | margin-top: 2px; 58 | border-radius: 50%; 59 | background-color: var(--font-color-tertiary); 60 | } 61 | 62 | .album-page > .album-header > .album-details > .stats > .length > .number { 63 | font-weight: 600; 64 | letter-spacing: 0.5px; 65 | } 66 | 67 | .album-page > .album-header > .album-details > .stats > .plays > .number { 68 | font-weight: 600; 69 | letter-spacing: 0.5px; 70 | } 71 | 72 | .album-page > .album-header > .album-details > .actions { 73 | display: flex; 74 | align-items: center; 75 | justify-content: space-between; 76 | gap: 12px; 77 | margin-top: 12px; 78 | } 79 | 80 | .album-page > .album-header > .album-details > .actions > .primary { 81 | display: flex; 82 | align-items: center; 83 | gap: 6px; 84 | } 85 | 86 | .album-page > .album-header > .album-details > .actions > .primary > .play-album { 87 | display: flex; 88 | align-items: center; 89 | cursor: pointer; 90 | padding: 6px 11px; 91 | border-radius: 6px; 92 | background-color: var(--bg-color-secondary); 93 | transition: background-color 0.2s ease-out; 94 | } 95 | 96 | .album-page > .album-header > .album-details > .actions > .primary > .play-album:hover { 97 | background-color: var(--bg-color-tertiary); 98 | } 99 | 100 | .album-page > .album-header > .album-details > .actions > .primary > .play-album > .play-icon { 101 | display: block; 102 | width: 12px; 103 | height: 12px; 104 | margin-right: 6px; 105 | background-color: var(--brand-color); 106 | mask-repeat: no-repeat; 107 | mask-image: url(/play.fill.svg); 108 | } 109 | 110 | .album-page > .album-header > .album-details > .actions > .primary > .play-album > .text { 111 | font-size: 0.75rem; 112 | font-weight: 600; 113 | color: var(--brand-color); 114 | } 115 | 116 | .album-page > .album-header > .album-details > .actions > .primary > .favorite-state { 117 | cursor: pointer; 118 | padding: 6px; 119 | border-radius: 20px; 120 | transition: background-color 0.2s ease-out; 121 | } 122 | 123 | .album-page > .album-header > .album-details > .actions > .primary > .favorite-state:hover { 124 | background-color: var(--bg-color-tertiary); 125 | } 126 | 127 | .album-page > .album-header > .album-details > .actions > .primary > .favorite-state > svg { 128 | color: var(--brand-color); 129 | } 130 | 131 | .album-page > .album-header > .album-details > .actions > .more { 132 | cursor: pointer; 133 | padding: 7px; 134 | border-radius: 20px; 135 | background-color: var(--bg-color-secondary); 136 | transition: background-color 0.2s ease-out; 137 | } 138 | 139 | .album-page > .album-header > .album-details > .actions > .more:hover { 140 | background-color: var(--bg-color-tertiary); 141 | } 142 | 143 | .album-page > .album-header > .album-details > .actions > .more.active { 144 | background-color: var(--bg-color-tertiary); 145 | } 146 | 147 | .album-page > .album-header > .album-details > .actions > .more > svg { 148 | fill: var(--brand-color); 149 | } 150 | 151 | .album-page > .album-content > .disc { 152 | font-size: 0.725rem; 153 | font-weight: 600; 154 | color: var(--font-color-tertiary); 155 | margin: 32px 0 6px 28px; 156 | } 157 | 158 | .album-page > .album-content > .disc.first { 159 | margin: 22px 0 6px 28px; 160 | } 161 | 162 | /* Responsive */ 163 | @media only screen and (max-width: 390px) { 164 | .album-page > .album-header { 165 | margin-left: 0; 166 | } 167 | } 168 | 169 | @media only screen and (max-width: 320px) { 170 | .album-page > .album-header { 171 | flex-direction: column; 172 | gap: 14px; 173 | margin-bottom: 18px; 174 | } 175 | 176 | .album-page > .album-header > .thumbnail { 177 | margin-right: 0; 178 | } 179 | 180 | .album-page > .album-header > .album-details { 181 | text-align: center; 182 | } 183 | 184 | .album-page > .album-header > .album-details > .stats { 185 | justify-content: center; 186 | } 187 | 188 | .album-page > .album-header > .album-details > .actions { 189 | justify-content: center; 190 | } 191 | } 192 | -------------------------------------------------------------------------------- /src/pages/Album.tsx: -------------------------------------------------------------------------------- 1 | import { HeartFillIcon, HeartIcon } from '@primer/octicons-react' 2 | import { useEffect } from 'react' 3 | import { Link, useParams } from 'react-router-dom' 4 | import { MediaItem } from '../api/jellyfin' 5 | import { JellyImg } from '../components/JellyImg' 6 | import { Loader } from '../components/Loader' 7 | import { MoreIcon } from '../components/SvgIcons' 8 | import { TrackList } from '../components/TrackList' 9 | import { useDropdownContext } from '../context/DropdownContext/DropdownContext' 10 | import { usePageTitle } from '../context/PageTitleContext/PageTitleContext' 11 | import { usePlaybackContext } from '../context/PlaybackContext/PlaybackContext' 12 | import { useJellyfinAlbumData } from '../hooks/Jellyfin/useJellyfinAlbumData' 13 | import { useFavorites } from '../hooks/useFavorites' 14 | import { formatDate } from '../utils/formatDate' 15 | import { formatDurationReadable } from '../utils/formatDurationReadable' 16 | import './Album.css' 17 | 18 | export const Album = () => { 19 | const playback = usePlaybackContext() 20 | const { albumId } = useParams<{ albumId: string }>() 21 | const { album, tracks, discCount, loading, error } = useJellyfinAlbumData(albumId!) 22 | const { setPageTitle } = usePageTitle() 23 | const { isOpen, selectedItem, onContextMenu } = useDropdownContext() 24 | const { addToFavorites, removeFromFavorites } = useFavorites() 25 | 26 | useEffect(() => { 27 | if (album) { 28 | setPageTitle(album.Name) 29 | } 30 | return () => { 31 | setPageTitle('') 32 | } 33 | }, [album, setPageTitle]) 34 | 35 | const totalPlaytime = tracks.reduce((total, track) => total + (track.RunTimeTicks || 0), 0) 36 | 37 | if (loading) { 38 | return 39 | } 40 | 41 | if (error || !album) { 42 | return
    {error || 'Album not found'}
    43 | } 44 | 45 | const totalPlays = tracks.reduce((total, track) => total + (track.UserData?.PlayCount || 0), 0) 46 | const trackCount = album.ChildCount || tracks.length 47 | 48 | const tracksByDisc = tracks.reduce((acc, track) => { 49 | const discNumber = track.ParentIndexNumber || 1 50 | if (!acc[discNumber]) { 51 | acc[discNumber] = [] 52 | } 53 | acc[discNumber].push(track) 54 | return acc 55 | }, {} as Record) 56 | 57 | const sortedTracks = Object.keys(tracksByDisc) 58 | .sort((a, b) => Number(a) - Number(b)) 59 | .flatMap(discNumber => 60 | tracksByDisc[Number(discNumber)].sort((a, b) => (a.IndexNumber || 0) - (b.IndexNumber || 0)) 61 | ) 62 | 63 | const handleMoreClick = (e: React.MouseEvent) => { 64 | e.stopPropagation() 65 | onContextMenu(e, { item: album }, true, { add_to_favorite: true, remove_from_favorite: true }) 66 | } 67 | 68 | return ( 69 |
    70 |
    71 | 72 |
    73 |
    74 | {album.AlbumArtists && album.AlbumArtists.length > 0 ? ( 75 | 76 | {album.AlbumArtist || 'Unknown Artist'} 77 | 78 | ) : ( 79 | album.AlbumArtist || 'Unknown Artist' 80 | )} 81 |
    82 |
    {formatDate(album.PremiereDate)}
    83 |
    84 |
    85 | {trackCount}{' '} 86 | {trackCount === 1 ? 'Track' : 'Tracks'} 87 |
    88 |
    89 |
    90 | {formatDurationReadable(totalPlaytime)} Total 91 |
    92 | {totalPlays > 0 && ( 93 | <> 94 |
    95 |
    96 | {totalPlays} {totalPlays === 1 ? 'Play' : 'Plays'} 97 |
    98 | 99 | )} 100 |
    101 |
    102 |
    103 |
    { 106 | playback.setCurrentPlaylist({ playlist: sortedTracks, title: album.Name }) 107 | playback.playTrack(0) 108 | }} 109 | > 110 |
    111 |
    Play
    112 |
    113 |
    { 117 | if (album?.Id) { 118 | try { 119 | if (album.UserData?.IsFavorite) { 120 | await removeFromFavorites(album) 121 | } else { 122 | await addToFavorites(album) 123 | } 124 | } catch (error) { 125 | console.error('Failed to update favorite status:', error) 126 | } 127 | } 128 | }} 129 | > 130 | {album.UserData?.IsFavorite ? : } 131 |
    132 |
    133 |
    138 | 139 |
    140 |
    141 |
    142 |
    143 | 144 | {Object.keys(tracksByDisc) 145 | .sort((a, b) => Number(a) - Number(b)) 146 | .map((discNumber, index) => ( 147 |
    148 | {discCount > 1 &&
    Disc {discNumber}
    } 149 |
    156 | ))} 157 |
    158 | ) 159 | } 160 | -------------------------------------------------------------------------------- /src/pages/Albums.tsx: -------------------------------------------------------------------------------- 1 | import { MediaList } from '../components/MediaList' 2 | import { useJellyfinAlbumsData } from '../hooks/Jellyfin/Infinite/useJellyfinAlbumsData' 3 | 4 | export const Albums = () => { 5 | const { items, isLoading, error, reviver, loadMore } = useJellyfinAlbumsData() 6 | 7 | return ( 8 |
    9 |
    20 | ) 21 | } 22 | -------------------------------------------------------------------------------- /src/pages/Artist.css: -------------------------------------------------------------------------------- 1 | .artist-page { 2 | margin-top: 4px; 3 | } 4 | 5 | .artist-page > .artist-header { 6 | display: flex; 7 | align-items: center; 8 | margin-left: 28px; 9 | margin-bottom: 14px; 10 | } 11 | 12 | .artist-page > .artist-header > .thumbnail { 13 | display: block; 14 | width: 100px; 15 | height: 100px; 16 | object-fit: cover; 17 | border-radius: 8px; 18 | margin-right: 20px; 19 | background-color: var(--bg-color-secondary); 20 | } 21 | 22 | .artist-page > .artist-header > .artist-details { 23 | flex: 1; 24 | } 25 | 26 | .artist-page > .artist-header > .artist-details > .artist { 27 | font-size: 0.9rem; 28 | font-weight: 600; 29 | margin-bottom: 4px; 30 | } 31 | 32 | .artist-page > .artist-header > .artist-details > .genres { 33 | font-size: 0.75rem; 34 | font-weight: 500; 35 | color: var(--font-color-tertiary); 36 | margin-bottom: 4px; 37 | } 38 | 39 | .artist-page > .artist-header > .artist-details > .stats { 40 | font-size: 0.75rem; 41 | font-weight: 500; 42 | color: var(--font-color-tertiary); 43 | display: flex; 44 | align-items: center; 45 | flex-wrap: wrap; 46 | } 47 | 48 | .artist-page > .artist-header > .artist-details > .stats > .track-amount > .number, 49 | .artist-page > .artist-header > .artist-details > .stats > .album-amount > .number { 50 | font-weight: 600; 51 | letter-spacing: 0.5px; 52 | } 53 | 54 | .artist-page > .artist-header > .artist-details > .stats > .divider { 55 | width: 2px; 56 | height: 2px; 57 | margin: 0 4px; 58 | margin-top: 2px; 59 | border-radius: 50%; 60 | background-color: var(--font-color-tertiary); 61 | } 62 | 63 | .artist-page > .artist-header > .artist-details > .stats > .length > .number { 64 | font-weight: 600; 65 | letter-spacing: 0.5px; 66 | } 67 | 68 | .artist-page > .artist-header > .artist-details > .stats > .plays > .number { 69 | font-weight: 600; 70 | letter-spacing: 0.5px; 71 | } 72 | 73 | .artist-page > .artist-header > .artist-details > .actions { 74 | display: flex; 75 | align-items: center; 76 | justify-content: space-between; 77 | gap: 12px; 78 | margin-top: 12px; 79 | } 80 | 81 | .artist-page > .artist-header > .artist-details > .actions > .primary { 82 | display: flex; 83 | align-items: center; 84 | gap: 6px; 85 | } 86 | 87 | .artist-page > .artist-header > .artist-details > .actions > .primary > .play-artist { 88 | display: flex; 89 | align-items: center; 90 | cursor: pointer; 91 | padding: 6px 11px; 92 | border-radius: 6px; 93 | background-color: var(--bg-color-secondary); 94 | transition: background-color 0.2s ease-out; 95 | } 96 | 97 | .artist-page > .artist-header > .artist-details > .actions > .primary > .play-artist:hover { 98 | background-color: var(--bg-color-tertiary); 99 | } 100 | 101 | .artist-page > .artist-header > .artist-details > .actions > .primary > .play-artist > .play-icon { 102 | display: block; 103 | width: 12px; 104 | height: 12px; 105 | margin-right: 6px; 106 | background-color: var(--brand-color); 107 | mask-repeat: no-repeat; 108 | mask-image: url(/play.fill.svg); 109 | } 110 | 111 | .artist-page > .artist-header > .artist-details > .actions > .primary > .play-artist > .text { 112 | font-size: 0.75rem; 113 | font-weight: 600; 114 | color: var(--brand-color); 115 | } 116 | 117 | .artist-page > .artist-header > .artist-details > .actions > .primary > .favorite-state { 118 | cursor: pointer; 119 | padding: 6px; 120 | border-radius: 20px; 121 | transition: background-color 0.2s ease-out; 122 | } 123 | 124 | .artist-page > .artist-header > .artist-details > .actions > .primary > .favorite-state:hover { 125 | background-color: var(--bg-color-tertiary); 126 | } 127 | 128 | .artist-page > .artist-header > .artist-details > .actions > .primary > .favorite-state > svg { 129 | color: var(--brand-color); 130 | } 131 | 132 | .artist-page > .artist-header > .artist-details > .actions > .more { 133 | cursor: pointer; 134 | padding: 7px; 135 | border-radius: 20px; 136 | background-color: var(--bg-color-secondary); 137 | transition: background-color 0.2s ease-out; 138 | } 139 | 140 | .artist-page > .artist-header > .artist-details > .actions > .more:hover { 141 | background-color: var(--bg-color-tertiary); 142 | } 143 | 144 | .artist-page > .artist-header > .artist-details > .actions > .more.active { 145 | background-color: var(--bg-color-tertiary); 146 | } 147 | 148 | .artist-page > .artist-header > .artist-details > .actions > .more > svg { 149 | fill: var(--brand-color); 150 | } 151 | 152 | .artist-page > .artist-content > .section { 153 | margin-bottom: 40px; 154 | } 155 | 156 | .artist-page > .artist-content > .section:last-of-type { 157 | margin-bottom: 0; 158 | } 159 | 160 | .artist-page > .artist-content > .section > .title { 161 | font-size: 0.95rem; 162 | font-weight: 700; 163 | margin-bottom: 2px; 164 | } 165 | 166 | .artist-page > .artist-content > .section.top-songs > .all-tracks { 167 | font-size: 0.725rem; 168 | font-weight: 500; 169 | text-align: right; 170 | margin-top: 10px; 171 | } 172 | 173 | .all-tracks > a.textlink { 174 | color: var(--font-color-tertiary); 175 | } 176 | 177 | .artist-page > .artist-content > .section > .desc { 178 | font-size: 0.75rem; 179 | font-weight: 500; 180 | color: var(--font-color-tertiary); 181 | margin-bottom: 6px; 182 | } 183 | 184 | .artist-page > .artist-content > .section > .section-list > .section-item { 185 | cursor: pointer; 186 | display: flex; 187 | align-items: center; 188 | padding: 8px; 189 | margin: 0 -8px; 190 | border-radius: 8px; 191 | transition: background-color 0.2s ease-out; 192 | } 193 | 194 | .artist-page > .artist-content > .section > .section-list > .section-item:hover { 195 | background-color: var(--bg-color-secondary); 196 | } 197 | 198 | .artist-page > .artist-content > .section > .section-list > .section-item > .thumbnail { 199 | display: block; 200 | width: 46px; 201 | height: 46px; 202 | object-fit: cover; 203 | border-radius: 6px; 204 | margin-right: 12px; 205 | background-color: var(--bg-color-secondary); 206 | } 207 | 208 | .artist-page > .artist-content > .section > .section-list > .section-item > .section-info { 209 | display: flex; 210 | flex-direction: column; 211 | flex-grow: 1; 212 | } 213 | 214 | .artist-page > .artist-content > .section > .section-list > .section-item > .section-info > .name { 215 | font-size: 0.85rem; 216 | font-weight: 600; 217 | /* Help with CJK letter spacing */ 218 | line-height: 1rem; 219 | margin-bottom: 4px; 220 | } 221 | 222 | .artist-page > .artist-content > .section > .section-list > .section-item > .section-info > .date, 223 | .artist-page > .artist-content > .section > .section-list > .section-item > .section-info > .track-amount { 224 | font-size: 0.725rem; 225 | font-weight: 500; 226 | color: var(--font-color-tertiary); 227 | } 228 | 229 | .artist-page > .artist-content > .section.appears-in > .section-list > .section-item > .section-info > .name { 230 | margin-bottom: 2px; 231 | } 232 | 233 | .artist-page > .artist-content > .section > .section-list > .section-item > .section-info > .container { 234 | line-height: 1; 235 | } 236 | 237 | .artist-page > .artist-content > .section > .section-list > .section-item > .section-info > .container > .year { 238 | display: inline; 239 | font-size: 0.725rem; 240 | font-weight: 500; 241 | color: var(--font-color-tertiary); 242 | } 243 | 244 | .artist-page > .artist-content > .section > .section-list > .section-item > .section-info > .container > .divider { 245 | display: inline-block; 246 | vertical-align: middle; 247 | width: 2px; 248 | height: 2px; 249 | margin: 0 4px; 250 | margin-top: 2px; 251 | border-radius: 50%; 252 | background-color: var(--font-color-tertiary); 253 | } 254 | 255 | .artist-page > .artist-content > .section > .section-list > .section-item > .section-info > .container > .artist { 256 | display: inline; 257 | font-size: 0.725rem; 258 | font-weight: 500; 259 | color: var(--font-color-tertiary); 260 | } 261 | 262 | .artist-page > .artist-content > .section > .section-list > .section-item > .favorited { 263 | padding: 6px; 264 | color: var(--font-color-tertiary); 265 | } 266 | 267 | /* Responsive */ 268 | @media only screen and (max-width: 500px) { 269 | .artist-page > .artist-header { 270 | margin-left: 0; 271 | } 272 | } 273 | 274 | @media only screen and (max-width: 360px) { 275 | .artist-page > .artist-header { 276 | flex-direction: column; 277 | gap: 14px; 278 | margin-bottom: 18px; 279 | } 280 | 281 | .artist-page > .artist-header > .thumbnail { 282 | margin-right: 0; 283 | } 284 | 285 | .artist-page > .artist-header > .artist-details { 286 | text-align: center; 287 | } 288 | 289 | .artist-page > .artist-header > .artist-details > .stats { 290 | justify-content: center; 291 | } 292 | 293 | .artist-page > .artist-header > .artist-details > .actions { 294 | justify-content: center; 295 | } 296 | } 297 | -------------------------------------------------------------------------------- /src/pages/ArtistTracks.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect } from 'react' 2 | import { useParams } from 'react-router-dom' 3 | import { Loader } from '../components/Loader' 4 | import { PlaylistTrackList } from '../components/PlaylistTrackList' 5 | import { usePageTitle } from '../context/PageTitleContext/PageTitleContext' 6 | import { useJellyfinArtistTracksData } from '../hooks/Jellyfin/Infinite/useJellyfinArtistTracksData' 7 | import { useJellyfinArtistData } from '../hooks/Jellyfin/useJellyfinArtistData' 8 | 9 | export const ArtistTracks = () => { 10 | const { artistId } = useParams<{ artistId: string }>() 11 | const { artist } = useJellyfinArtistData(artistId!) 12 | const { items: allTracks, isLoading, error, reviver, loadMore } = useJellyfinArtistTracksData(artistId!) 13 | const { setPageTitle } = usePageTitle() 14 | 15 | useEffect(() => { 16 | if (artist) { 17 | setPageTitle(`${artist.Name}'s Tracks`) 18 | } 19 | return () => { 20 | setPageTitle('') 21 | } 22 | }, [artist, setPageTitle]) 23 | 24 | if (isLoading && allTracks.length === 0) { 25 | return 26 | } 27 | 28 | return ( 29 |
    30 | {error &&
    {error}
    } 31 | 39 |
    40 | ) 41 | } 42 | -------------------------------------------------------------------------------- /src/pages/Favorites.tsx: -------------------------------------------------------------------------------- 1 | import { MediaList } from '../components/MediaList' 2 | import { useFilterContext } from '../context/FilterContext/FilterContext' 3 | import { useJellyfinFavoritesData } from '../hooks/Jellyfin/Infinite/useJellyfinFavoritesData' 4 | 5 | export const Favorites = () => { 6 | const { items, isLoading, error, reviver, loadMore } = useJellyfinFavoritesData() 7 | const { jellyItemKind } = useFilterContext() 8 | 9 | return ( 10 |
    11 | {error &&
    {error}
    } 12 | 20 |
    21 | ) 22 | } 23 | -------------------------------------------------------------------------------- /src/pages/FrequentlyPlayed.tsx: -------------------------------------------------------------------------------- 1 | import { MediaList } from '../components/MediaList' 2 | import { useJellyfinFrequentlyPlayedData } from '../hooks/Jellyfin/Infinite/useJellyfinFrequentlyPlayedData' 3 | 4 | export const FrequentlyPlayed = () => { 5 | const { items, isLoading, error, reviver, loadMore } = useJellyfinFrequentlyPlayedData() 6 | 7 | return ( 8 |
    9 | {error &&
    {error}
    } 10 | 18 |
    19 | ) 20 | } 21 | -------------------------------------------------------------------------------- /src/pages/Genre.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect } from 'react' 2 | import { useParams } from 'react-router-dom' 3 | import { MediaList } from '../components/MediaList' 4 | import { usePageTitle } from '../context/PageTitleContext/PageTitleContext' 5 | import { useJellyfinGenreTracks } from '../hooks/Jellyfin/Infinite/useJellyfinGenreTracks' 6 | 7 | export const Genre = () => { 8 | const { genre } = useParams<{ genre: string }>() 9 | const { items, isLoading, error, reviver, loadMore } = useJellyfinGenreTracks(genre!) 10 | const { setPageTitle } = usePageTitle() 11 | 12 | useEffect(() => { 13 | if (genre) { 14 | setPageTitle(decodeURIComponent(genre)) 15 | } 16 | return () => { 17 | setPageTitle('') 18 | } 19 | }, [genre, setPageTitle]) 20 | 21 | return ( 22 |
    23 | {error &&
    {error}
    } 24 | 32 |
    33 | ) 34 | } 35 | -------------------------------------------------------------------------------- /src/pages/Home.tsx: -------------------------------------------------------------------------------- 1 | import { Link } from 'react-router-dom' 2 | import { Loader } from '../components/Loader' 3 | import { MediaList } from '../components/MediaList' 4 | import { useJellyfinHomeData } from '../hooks/Jellyfin/useJellyfinHomeData' 5 | 6 | export const Home = () => { 7 | const { recentlyPlayed, frequentlyPlayed, recentlyAdded, isLoading, error } = useJellyfinHomeData() 8 | 9 | if (isLoading) { 10 | return 11 | } 12 | 13 | if (error) { 14 | return
    {error}
    15 | } 16 | 17 | return ( 18 |
    19 |
    20 |
    21 |
    22 |
    Recently Played
    23 |
    Songs you queued up lately
    24 |
    25 | 26 | See more 27 | 28 |
    29 | 30 |
    31 |
    32 |
    33 |
    34 |
    Frequently Played
    35 |
    Songs you listen to often
    36 |
    37 | 38 | See more 39 | 40 |
    41 | 47 |
    48 |
    49 |
    50 |
    51 |
    Recently Added
    52 |
    Albums recently added to the Library
    53 |
    54 |
    55 | 56 |
    57 |
    58 | ) 59 | } 60 | -------------------------------------------------------------------------------- /src/pages/Login.tsx: -------------------------------------------------------------------------------- 1 | import { AuthForm } from '../components/AuthForm' 2 | 3 | export const Login = ({ 4 | onLogin, 5 | }: { 6 | onLogin: (authData: { serverUrl: string; token: string; userId: string; username: string }) => void 7 | }) => { 8 | return ( 9 |
    10 |
    11 |
    12 |
    13 | 14 |
    Jelly Music App - Version {__VERSION__}
    15 |
    16 | ) 17 | } 18 | -------------------------------------------------------------------------------- /src/pages/Playlist.css: -------------------------------------------------------------------------------- 1 | .playlist-page { 2 | margin-top: 4px; 3 | } 4 | 5 | .playlist-page > .playlist-header { 6 | display: flex; 7 | align-items: center; 8 | margin-bottom: 14px; 9 | } 10 | 11 | .playlist-page > .playlist-header > .thumbnail { 12 | display: block; 13 | width: 100px; 14 | height: 100px; 15 | object-fit: cover; 16 | border-radius: 8px; 17 | margin-right: 20px; 18 | background-color: var(--bg-color-secondary); 19 | } 20 | 21 | .playlist-page > .playlist-header > .playlist-details { 22 | flex: 1; 23 | } 24 | 25 | .playlist-page > .playlist-header > .playlist-details > .title { 26 | font-size: 0.9rem; 27 | font-weight: 600; 28 | margin-bottom: 4px; 29 | } 30 | 31 | .playlist-page > .playlist-header > .playlist-details > .date { 32 | font-size: 0.75rem; 33 | font-weight: 500; 34 | color: var(--font-color-tertiary); 35 | margin-bottom: 4px; 36 | } 37 | 38 | .playlist-page > .playlist-header > .playlist-details > .stats { 39 | font-size: 0.75rem; 40 | font-weight: 500; 41 | color: var(--font-color-tertiary); 42 | display: flex; 43 | align-items: center; 44 | flex-wrap: wrap; 45 | } 46 | 47 | .playlist-page > .playlist-header > .playlist-details > .stats > .track-amount > .number, 48 | .playlist-page > .playlist-header > .playlist-details > .stats > .album-amount > .number { 49 | font-weight: 600; 50 | letter-spacing: 0.5px; 51 | } 52 | 53 | .playlist-page > .playlist-header > .playlist-details > .stats > .divider { 54 | width: 2px; 55 | height: 2px; 56 | margin: 0 4px; 57 | margin-top: 2px; 58 | border-radius: 50%; 59 | background-color: var(--font-color-tertiary); 60 | } 61 | 62 | .playlist-page > .playlist-header > .playlist-details > .stats > .length > .number { 63 | font-weight: 600; 64 | letter-spacing: 0.5px; 65 | } 66 | 67 | .playlist-page > .playlist-header > .playlist-details > .stats > .plays > .number { 68 | font-weight: 600; 69 | letter-spacing: 0.5px; 70 | } 71 | 72 | .playlist-page > .playlist-header > .playlist-details > .actions { 73 | display: flex; 74 | align-items: center; 75 | justify-content: space-between; 76 | gap: 12px; 77 | margin-top: 12px; 78 | } 79 | 80 | .playlist-page > .playlist-header > .playlist-details > .actions > .primary { 81 | display: flex; 82 | align-items: center; 83 | gap: 6px; 84 | } 85 | 86 | .playlist-page > .playlist-header > .playlist-details > .actions > .primary > .play-playlist { 87 | display: flex; 88 | align-items: center; 89 | cursor: pointer; 90 | padding: 6px 11px; 91 | border-radius: 6px; 92 | background-color: var(--bg-color-secondary); 93 | transition: background-color 0.2s ease-out; 94 | } 95 | 96 | .playlist-page > .playlist-header > .playlist-details > .actions > .primary > .play-playlist:hover { 97 | background-color: var(--bg-color-tertiary); 98 | } 99 | 100 | .playlist-page > .playlist-header > .playlist-details > .actions > .primary > .play-playlist > .play-icon { 101 | display: block; 102 | width: 12px; 103 | height: 12px; 104 | margin-right: 6px; 105 | background-color: var(--brand-color); 106 | mask-repeat: no-repeat; 107 | mask-image: url(/play.fill.svg); 108 | } 109 | 110 | .playlist-page > .playlist-header > .playlist-details > .actions > .primary > .play-playlist > .text { 111 | font-size: 0.75rem; 112 | font-weight: 600; 113 | color: var(--brand-color); 114 | } 115 | 116 | .playlist-page > .playlist-header > .playlist-details > .actions > .primary > .favorite-state { 117 | cursor: pointer; 118 | padding: 6px; 119 | border-radius: 20px; 120 | transition: background-color 0.2s ease-out; 121 | } 122 | 123 | .playlist-page > .playlist-header > .playlist-details > .actions > .primary > .favorite-state:hover { 124 | background-color: var(--bg-color-tertiary); 125 | } 126 | 127 | .playlist-page > .playlist-header > .playlist-details > .actions > .primary > .favorite-state > svg { 128 | color: var(--brand-color); 129 | } 130 | 131 | .playlist-page > .playlist-header > .playlist-details > .actions > .more { 132 | cursor: pointer; 133 | padding: 7px; 134 | border-radius: 20px; 135 | background-color: var(--bg-color-secondary); 136 | transition: background-color 0.2s ease-out; 137 | } 138 | 139 | .playlist-page > .playlist-header > .playlist-details > .actions > .more:hover { 140 | background-color: var(--bg-color-tertiary); 141 | } 142 | 143 | .playlist-page > .playlist-header > .playlist-details > .actions > .more.active { 144 | background-color: var(--bg-color-tertiary); 145 | } 146 | 147 | .playlist-page > .playlist-header > .playlist-details > .actions > .more > svg { 148 | fill: var(--brand-color); 149 | } 150 | 151 | @media only screen and (max-width: 320px) { 152 | .playlist-page > .playlist-header { 153 | flex-direction: column; 154 | gap: 14px; 155 | margin-bottom: 18px; 156 | } 157 | 158 | .playlist-page > .playlist-header > .thumbnail { 159 | margin-right: 0; 160 | } 161 | 162 | .playlist-page > .playlist-header > .playlist-details { 163 | text-align: center; 164 | } 165 | 166 | .playlist-page > .playlist-header > .playlist-details > .stats { 167 | justify-content: center; 168 | } 169 | 170 | .playlist-page > .playlist-header > .playlist-details > .actions { 171 | justify-content: center; 172 | } 173 | } 174 | -------------------------------------------------------------------------------- /src/pages/Playlist.tsx: -------------------------------------------------------------------------------- 1 | import { HeartFillIcon, HeartIcon } from '@primer/octicons-react' 2 | import { useEffect } from 'react' 3 | import { useParams } from 'react-router-dom' 4 | import { JellyImg } from '../components/JellyImg' 5 | import { Loader } from '../components/Loader' 6 | import { PlaylistTrackList } from '../components/PlaylistTrackList' 7 | import { MoreIcon } from '../components/SvgIcons' 8 | import { useDropdownContext } from '../context/DropdownContext/DropdownContext' 9 | import { usePageTitle } from '../context/PageTitleContext/PageTitleContext' 10 | import { usePlaybackContext } from '../context/PlaybackContext/PlaybackContext' 11 | import { useJellyfinPlaylistData } from '../hooks/Jellyfin/Infinite/useJellyfinPlaylistData' 12 | import { useFavorites } from '../hooks/useFavorites' 13 | import { formatDate } from '../utils/formatDate' 14 | import { formatDurationReadable } from '../utils/formatDurationReadable' 15 | import './Playlist.css' 16 | 17 | export const Playlist = () => { 18 | const playback = usePlaybackContext() 19 | const { addToFavorites, removeFromFavorites } = useFavorites() 20 | 21 | const { playlistId } = useParams<{ playlistId: string }>() 22 | const { 23 | playlist, 24 | items: tracks, 25 | isLoading, 26 | error, 27 | totalPlaytime, 28 | totalTrackCount, 29 | totalPlays, 30 | reviver, 31 | loadMore, 32 | } = useJellyfinPlaylistData(playlistId!) 33 | 34 | const { setPageTitle } = usePageTitle() 35 | const { isOpen, selectedItem, onContextMenu } = useDropdownContext() 36 | 37 | useEffect(() => { 38 | if (playlist) { 39 | setPageTitle(playlist.Name) 40 | } 41 | return () => { 42 | setPageTitle('') 43 | } 44 | }, [playlist, setPageTitle]) 45 | 46 | if (isLoading && tracks.length === 0) { 47 | return 48 | } 49 | 50 | if (error || !playlist) { 51 | return
    {error || 'Playlist not found'}
    52 | } 53 | 54 | const handleMoreClick = (e: React.MouseEvent) => { 55 | e.stopPropagation() 56 | onContextMenu(e, { item: playlist }, true, { add_to_favorite: true, remove_from_favorite: true }) 57 | } 58 | 59 | return ( 60 |
    61 |
    62 | 63 | 64 |
    65 |
    {playlist.Name}
    66 |
    {formatDate(playlist.DateCreated)}
    67 |
    68 |
    69 | {totalTrackCount}{' '} 70 | {totalTrackCount === 1 ? 'Track' : 'Tracks'} 71 |
    72 |
    73 |
    74 | {formatDurationReadable(totalPlaytime)} Total 75 |
    76 | {totalPlays > 0 && ( 77 | <> 78 |
    79 |
    80 | {totalPlays} {totalPlays === 1 ? 'Play' : 'Plays'} 81 |
    82 | 83 | )} 84 |
    85 |
    86 |
    87 |
    { 90 | playback.setCurrentPlaylist({ playlist: tracks, title: playlist.Name }) 91 | playback.playTrack(0) 92 | }} 93 | > 94 |
    95 |
    Play
    96 |
    97 |
    { 101 | if (playlist?.Id) { 102 | try { 103 | if (playlist.UserData?.IsFavorite) { 104 | await removeFromFavorites(playlist) 105 | } else { 106 | await addToFavorites(playlist) 107 | } 108 | } catch (error) { 109 | console.error('Failed to update favorite status:', error) 110 | } 111 | } 112 | }} 113 | > 114 | {playlist.UserData?.IsFavorite ? : } 115 |
    116 |
    117 |
    122 | 123 |
    124 |
    125 |
    126 |
    127 | 128 | 137 |
    138 | ) 139 | } 140 | -------------------------------------------------------------------------------- /src/pages/Queue.css: -------------------------------------------------------------------------------- 1 | .queue-page { 2 | margin-top: -4px; 3 | } 4 | 5 | .queue-page > .queue-header { 6 | margin-bottom: 20px; 7 | } 8 | 9 | .queue-page > .queue-title { 10 | font-size: 0.95rem; 11 | font-weight: 700; 12 | margin-bottom: 2px; 13 | } 14 | 15 | .queue-page > .queue-desc { 16 | font-size: 0.75rem; 17 | font-weight: 500; 18 | color: var(--font-color-tertiary); 19 | margin-bottom: 6px; 20 | } 21 | 22 | .queue-page > .queue-desc > .text > .highlight { 23 | font-weight: 600; 24 | } 25 | -------------------------------------------------------------------------------- /src/pages/Queue.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect } from 'react' 2 | import { MediaList } from '../components/MediaList' 3 | import { usePageTitle } from '../context/PageTitleContext/PageTitleContext' 4 | import { usePlaybackContext } from '../context/PlaybackContext/PlaybackContext' 5 | import './Queue.css' 6 | 7 | export const Queue = () => { 8 | const { setPageTitle } = usePageTitle() 9 | const { currentTrack, currentPlaylist, currentTrackIndex, playlistTitle } = usePlaybackContext() 10 | 11 | useEffect(() => { 12 | setPageTitle('Queue') 13 | return () => setPageTitle('') 14 | }, [setPageTitle]) 15 | 16 | if (!currentTrack || currentPlaylist.length === 0) { 17 | return
    Queue is currently empty
    18 | } 19 | 20 | const queueTracks = currentPlaylist.slice(currentTrackIndex + 1) 21 | 22 | return ( 23 |
    24 |
    25 | 26 |
    27 | {queueTracks.length > 0 && ( 28 | <> 29 |
    Playing Next
    30 |
    31 | 32 | From {playlistTitle} 33 | 34 |
    35 | 36 | 37 | )} 38 |
    39 | ) 40 | } 41 | -------------------------------------------------------------------------------- /src/pages/RecentlyPlayed.tsx: -------------------------------------------------------------------------------- 1 | import { MediaList } from '../components/MediaList' 2 | import { useJellyfinRecentlyPlayedData } from '../hooks/Jellyfin/Infinite/useJellyfinRecentlyPlayedData' 3 | 4 | export const RecentlyPlayed = () => { 5 | const { items, isLoading, error, reviver, loadMore } = useJellyfinRecentlyPlayedData() 6 | 7 | return ( 8 |
    9 | {error &&
    {error}
    } 10 | 18 |
    19 | ) 20 | } 21 | -------------------------------------------------------------------------------- /src/pages/SearchResults.css: -------------------------------------------------------------------------------- 1 | .search-results-page > .search-content > .section { 2 | margin-bottom: 40px; 3 | } 4 | 5 | .search-results-page > .search-content > .section.songs { 6 | margin-top: -4px; 7 | } 8 | 9 | .search-results-page > .search-content > .section:last-of-type { 10 | margin-bottom: 0; 11 | } 12 | 13 | .search-results-page > .search-content > .section > .title { 14 | font-size: 0.95rem; 15 | font-weight: 700; 16 | margin-bottom: 6px; 17 | } 18 | 19 | .search-results-page > .search-content > .section > .section-list > .section-item { 20 | cursor: pointer; 21 | display: flex; 22 | align-items: center; 23 | padding: 8px; 24 | margin: 0 -8px; 25 | border-radius: 8px; 26 | transition: background-color 0.2s ease-out; 27 | } 28 | 29 | .search-results-page > .search-content > .section > .section-list > .section-item:hover { 30 | background-color: var(--bg-color-secondary); 31 | } 32 | 33 | .search-results-page > .search-content > .section.genres > .section-list > .section-item { 34 | padding: 10px; 35 | margin: 0 -10px; 36 | } 37 | 38 | .search-results-page > .search-content > .section > .section-list > .section-item > .thumbnail { 39 | display: block; 40 | width: 46px; 41 | height: 46px; 42 | object-fit: cover; 43 | border-radius: 6px; 44 | margin-right: 12px; 45 | background-color: var(--bg-color-secondary); 46 | } 47 | 48 | .search-results-page > .search-content > .section.artists > .section-list > .section-item > .thumbnail { 49 | width: 36px; 50 | height: 36px; 51 | border-radius: 20px; 52 | } 53 | 54 | .search-results-page > .search-content > .section.genres > .section-list > .section-item > .icon { 55 | margin-right: 8px; 56 | } 57 | 58 | .search-results-page > .search-content > .section > .section-list > .section-item > .section-info { 59 | display: flex; 60 | flex-direction: column; 61 | flex-grow: 1; 62 | } 63 | 64 | .search-results-page > .search-content > .section > .section-list > .section-item > .section-info > .name { 65 | font-size: 0.85rem; 66 | font-weight: 600; 67 | margin-bottom: 4px; 68 | } 69 | 70 | .search-results-page > .search-content > .section.artists > .section-list > .section-item > .section-info > .name { 71 | margin-bottom: 0; 72 | } 73 | 74 | .search-results-page > .search-content > .section.genres > .section-list > .section-item > .section-info > .name { 75 | margin-bottom: 0; 76 | } 77 | 78 | .search-results-page > .search-content > .section > .section-list > .section-item > .section-info > .desc { 79 | font-size: 0.725rem; 80 | font-weight: 600; 81 | color: var(--font-color-tertiary); 82 | } 83 | 84 | .search-results-page > .search-content > .section > .section-list > .section-item > .section-info > .desc.track-amount { 85 | font-weight: 500; 86 | } 87 | 88 | .search-results-page > .search-content > .section > .section-list > .section-item > .favorited { 89 | padding: 6px; 90 | color: var(--font-color-tertiary); 91 | } 92 | -------------------------------------------------------------------------------- /src/pages/Settings.css: -------------------------------------------------------------------------------- 1 | .settings-page { 2 | margin-bottom: 20px; 3 | } 4 | 5 | .settings-page > .section { 6 | margin-bottom: 20px; 7 | padding-bottom: 20px; 8 | border-bottom: 1px solid var(--border-color); 9 | } 10 | 11 | .settings-page > .section:last-of-type { 12 | margin-bottom: 0; 13 | padding-bottom: 0; 14 | border-bottom: none; 15 | } 16 | 17 | .settings-page > .section > .title { 18 | font-size: 0.85rem; 19 | font-weight: 600; 20 | margin-bottom: 8px; 21 | } 22 | 23 | /* Appearance */ 24 | .settings-page > .section.appearance { 25 | display: flex; 26 | align-items: flex-start; 27 | justify-content: space-between; 28 | flex-wrap: wrap; 29 | gap: 14px; 30 | row-gap: 4px; 31 | } 32 | 33 | .settings-page > .section.appearance > .options { 34 | display: flex; 35 | align-items: center; 36 | flex-wrap: wrap; 37 | justify-content: center; 38 | gap: 20px; 39 | margin-top: 4px; 40 | } 41 | 42 | .settings-page > .section.appearance > .options > .option { 43 | cursor: pointer; 44 | } 45 | 46 | .settings-page > .section.appearance > .options > .option > .visual { 47 | position: relative; 48 | width: 98px; 49 | height: 62px; 50 | border-radius: 10px; 51 | outline: 3px solid rgba(0, 0, 0, 0); 52 | background-size: cover; 53 | background-repeat: no-repeat; 54 | background-color: var(--bg-color-secondary); 55 | transition: outline-color 0.2s ease-out; 56 | } 57 | 58 | .settings-page > .section.appearance > .options > .option.active > .visual { 59 | outline-color: var(--brand-color); 60 | } 61 | 62 | .settings-page > .section.appearance > .options > .option.light > .visual { 63 | background-image: url(/light-variant-tiny.webp); 64 | } 65 | 66 | .settings-page > .section.appearance > .options > .option.dark > .visual { 67 | background-image: url(/dark-variant-tiny.webp); 68 | } 69 | 70 | .settings-page > .section.appearance > .options > .option.system > .visual { 71 | background-image: url(/dark-light-variant-tiny-split.webp); 72 | background-image: url(/light-dark-variant-tiny-split.webp); 73 | } 74 | 75 | .settings-page > .section.appearance > .options > .option > .desc { 76 | font-size: 0.75rem; 77 | font-weight: 600; 78 | text-align: center; 79 | color: var(--font-color-tertiary); 80 | margin-top: 10px; 81 | transition: color 0.2s ease-out; 82 | } 83 | 84 | .settings-page > .section.appearance > .options > .option.active > .desc { 85 | color: var(--font-color); 86 | } 87 | 88 | /* Playback */ 89 | .settings-page > .section.playback > .inner { 90 | margin-bottom: 16px; 91 | padding-bottom: 16px; 92 | border-bottom: 1px solid var(--border-color); 93 | } 94 | 95 | .settings-page > .section.playback > .inner:last-of-type { 96 | margin-bottom: 0; 97 | padding-bottom: 0; 98 | border-bottom: none; 99 | } 100 | 101 | .settings-page > .section.playback > .inner > .container { 102 | display: flex; 103 | flex-direction: column; 104 | align-items: flex-start; 105 | justify-content: space-between; 106 | gap: 8px; 107 | } 108 | 109 | .settings-page > .section.playback > .inner.row > .container { 110 | flex-direction: row; 111 | } 112 | 113 | .settings-page > .section.playback > .inner > .container > .info > .subtitle { 114 | font-size: 0.75rem; 115 | font-weight: 500; 116 | color: var(--font-color-secondary); 117 | margin-bottom: 4px; 118 | } 119 | 120 | .settings-page > .section.playback > .inner > .container > .info > .subdesc { 121 | font-size: 0.725rem; 122 | line-height: 1.4; 123 | color: var(--font-color-tertiary); 124 | } 125 | 126 | .settings-page > .section.playback > .inner > .container > .options { 127 | display: flex; 128 | flex-direction: column; 129 | gap: 4px; 130 | width: 100%; 131 | } 132 | 133 | .settings-page > .section.playback > .inner > .container > .options > .option { 134 | display: flex; 135 | align-items: flex-start; 136 | cursor: pointer; 137 | padding: 6px 12px; 138 | margin: 0 -10px; 139 | border-radius: 8px; 140 | transition: background-color 0.2s ease-out; 141 | } 142 | 143 | .settings-page > .section.playback > .inner > .container > .options > .option:hover { 144 | background-color: var(--bg-color-secondary); 145 | } 146 | 147 | .settings-page > .section.playback > .inner > .container > .options > .option.active:hover { 148 | cursor: initial; 149 | background-color: unset; 150 | } 151 | 152 | .settings-page > .section.playback > .inner > .container > .options > .option > .details > .title { 153 | font-size: 0.75rem; 154 | font-weight: 600; 155 | margin-bottom: 2px; 156 | color: var(--font-color-secondary); 157 | transition: color 0.2s ease-out; 158 | } 159 | 160 | .settings-page > .section.playback > .inner > .container > .options > .option.active > .details > .title { 161 | color: var(--font-color-active); 162 | } 163 | 164 | .settings-page > .section.playback > .inner > .container > .options > .option > .details > .title > .bitrate { 165 | font-size: 0.625rem; 166 | color: var(--font-color-tertiary); 167 | margin-left: 2px; 168 | } 169 | 170 | .settings-page > .section.playback > .inner > .container > .options > .option > .details > .desc { 171 | font-size: 0.725rem; 172 | line-height: 1.4; 173 | color: var(--font-color-tertiary); 174 | transition: color 0.2s ease-out; 175 | } 176 | 177 | .settings-page > .section.playback > .inner > .container > .options > .option.active > .details > .desc { 178 | color: var(--font-color-secondary); 179 | } 180 | 181 | .settings-page > .section.playback > .inner > .container > .options > .option > .status { 182 | color: var(--brand-color); 183 | margin-top: 2px; 184 | margin-right: 10px; 185 | opacity: 0; 186 | visibility: hidden; 187 | transition: opacity 0.2s ease-out, visibility 0.2s ease-out; 188 | } 189 | 190 | .settings-page > .section.playback > .inner > .container > .options > .option.active > .status { 191 | opacity: 1; 192 | visibility: visible; 193 | } 194 | 195 | /* Switch - not used */ 196 | .switch { 197 | position: relative; 198 | display: block; 199 | width: 28px; 200 | height: 15px; 201 | cursor: pointer; 202 | flex-shrink: 0; 203 | } 204 | 205 | .switch > input { 206 | display: none; 207 | } 208 | 209 | .switch > .slider { 210 | position: absolute; 211 | top: 0; 212 | left: 0; 213 | right: 0; 214 | bottom: 0; 215 | border-radius: 20px; 216 | outline: 1px solid var(--border-color-secondary); 217 | background-color: var(--bg-color-tertiary); 218 | transition: outline-color 0.2s ease-out, background-color 0.2s ease-out; 219 | } 220 | 221 | .switch > input:checked + .slider { 222 | background-color: var(--brand-color); 223 | outline-color: var(--brand-color); 224 | } 225 | 226 | .switch > .slider:before { 227 | position: absolute; 228 | content: ''; 229 | height: 11px; 230 | width: 11px; 231 | left: 2px; 232 | bottom: 2px; 233 | border-radius: 50%; 234 | background-color: var(--font-color-tertiary); 235 | transition: transform 0.2s ease-out, background-color 0.2s ease-out; 236 | } 237 | 238 | .switch > input:checked + .slider:before { 239 | transform: translateX(13px); 240 | background-color: var(--font-color-inverted); 241 | } 242 | 243 | /* About and Session */ 244 | .settings-page > .section > .desc { 245 | font-size: 0.75rem; 246 | line-height: 1.5; 247 | color: var(--font-color-secondary); 248 | } 249 | 250 | .settings-page > .section > .desc > .subtitle { 251 | font-weight: 500; 252 | margin-bottom: 4px; 253 | } 254 | 255 | .settings-page > .section > .desc > .subfooter { 256 | margin-top: 14px; 257 | } 258 | 259 | .settings-page > .section > .desc > p > .mantra { 260 | font-style: italic; 261 | } 262 | 263 | .settings-page > .section > .actions { 264 | margin-top: 14px; 265 | } 266 | 267 | .settings-page > .section > .actions > .logout-button { 268 | font-size: 0.725rem; 269 | font-weight: 500; 270 | color: var(--font-color-inverted); 271 | cursor: pointer; 272 | padding: 7px 13px; 273 | border-radius: 6px; 274 | background-color: var(--btn-brand-color); 275 | transition: background-color 0.2s ease-out; 276 | } 277 | 278 | .settings-page > .section > .actions > .logout-button:hover { 279 | background-color: var(--btn-brand-color-hover); 280 | } 281 | -------------------------------------------------------------------------------- /src/pages/Tracks.tsx: -------------------------------------------------------------------------------- 1 | import { MediaList } from '../components/MediaList' 2 | import { useJellyfinTracksData } from '../hooks/Jellyfin/Infinite/useJellyfinTracksData' 3 | 4 | export const Tracks = () => { 5 | const { items, isLoading, error, reviver, loadMore } = useJellyfinTracksData() 6 | 7 | return ( 8 |
    9 | {error &&
    {error}
    } 10 | 18 |
    19 | ) 20 | } 21 | -------------------------------------------------------------------------------- /src/types.d.ts: -------------------------------------------------------------------------------- 1 | declare global { 2 | interface Window { 3 | __NPM_LIFECYCLE_EVENT__: string 4 | } 5 | 6 | const __VERSION__: string 7 | } 8 | 9 | export {} 10 | -------------------------------------------------------------------------------- /src/utils/formatDate.ts: -------------------------------------------------------------------------------- 1 | export const formatDate = (date?: string | null) => { 2 | if (!date) return 'Unknown Date' 3 | return new Date(date).toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' }) 4 | } 5 | 6 | export const formatDateYear = (date?: string | null) => { 7 | if (!date) return 'Unknown Date' 8 | return new Date(date).toLocaleDateString('en-US', { year: 'numeric' }) 9 | } 10 | -------------------------------------------------------------------------------- /src/utils/formatDuration.ts: -------------------------------------------------------------------------------- 1 | export const formatDuration = (ticks?: number | null) => { 2 | if (!ticks) return '0:00' 3 | const seconds = Math.floor(ticks / 10000000) 4 | const hours = Math.floor(seconds / 3600) 5 | const minutes = Math.floor((seconds % 3600) / 60) 6 | const remainingSeconds = seconds % 60 7 | 8 | if (hours > 0) { 9 | return `${hours}:${minutes.toString().padStart(2, '0')}:${remainingSeconds.toString().padStart(2, '0')}` 10 | } 11 | return `${minutes}:${remainingSeconds.toString().padStart(2, '0')}` 12 | } 13 | -------------------------------------------------------------------------------- /src/utils/formatDurationReadable.ts: -------------------------------------------------------------------------------- 1 | export const formatDurationReadable = (ticks?: number) => { 2 | if (!ticks || ticks <= 0) return '0m' 3 | 4 | const seconds = Math.floor(ticks / 10_000_000) 5 | const hours = Math.floor(seconds / 3600) 6 | const minutes = Math.floor((seconds % 3600) / 60) 7 | const remainingSeconds = seconds % 60 8 | 9 | const parts: string[] = [] 10 | 11 | if (hours > 0) { 12 | parts.push(`${hours}h`) 13 | } 14 | 15 | if (minutes > 0 || hours > 0) { 16 | parts.push(`${minutes}m`) 17 | } 18 | 19 | if (hours === 0 && minutes === 0 && remainingSeconds > 0) { 20 | parts.push(`${remainingSeconds}s`) 21 | } 22 | 23 | return parts.join(' ') || '0m' 24 | } 25 | -------------------------------------------------------------------------------- /src/utils/getAllTracks.ts: -------------------------------------------------------------------------------- 1 | import { InfiniteData } from '@tanstack/react-query' 2 | import { MediaItem } from '../api/jellyfin' 3 | 4 | export const getAllTracks = (data: InfiniteData | undefined) => { 5 | const seenIds = new Set() 6 | const allTracks: MediaItem[] = data 7 | ? data.pages 8 | .map((page, pageIndex) => 9 | page.map(track => ({ 10 | ...track, 11 | pageIndex, 12 | })) 13 | ) 14 | .flat() 15 | .filter(track => { 16 | if (seenIds.has(track.Id)) { 17 | return false 18 | } 19 | seenIds.add(track.Id) 20 | return true 21 | }) 22 | : [] 23 | return allTracks 24 | } 25 | -------------------------------------------------------------------------------- /src/utils/titleUtils.ts: -------------------------------------------------------------------------------- 1 | import { Location } from 'react-router-dom' 2 | 3 | export const getPageTitle = (pageTitle: string, location: Location): string => { 4 | // Return pageTitle if set (e.g., by SearchResults), otherwise fallback to defaults 5 | if (pageTitle) return pageTitle 6 | 7 | if (location.pathname.startsWith('/album/')) return 'Album' 8 | if (location.pathname.startsWith('/artist/')) { 9 | if (location.pathname.includes('/tracks')) return 'Tracks' 10 | return 'Artist' 11 | } 12 | if (location.pathname.startsWith('/genre/')) return 'Genre' 13 | if (location.pathname.startsWith('/playlist/')) return 'Playlist' 14 | if (location.pathname.startsWith('/search/')) { 15 | const query = decodeURIComponent(location.pathname.split('/search/')[1]) 16 | return `Search results for '${query}'` 17 | } 18 | 19 | switch (location.pathname) { 20 | case '/': 21 | return 'Home' 22 | case '/tracks': 23 | return 'Tracks' 24 | case '/albums': 25 | return 'Albums' 26 | case '/favorites': 27 | return 'Favorites' 28 | case '/settings': 29 | return 'Settings' 30 | case '/recently': 31 | return 'Recently Played' 32 | case '/frequently': 33 | return 'Frequently Played' 34 | default: 35 | return 'Home' 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /tsconfig.app.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", 4 | "target": "ES2020", 5 | "useDefineForClassFields": true, 6 | "lib": ["ES2020", "DOM", "DOM.Iterable"], 7 | "module": "ESNext", 8 | "skipLibCheck": true, 9 | 10 | /* Bundler mode */ 11 | "moduleResolution": "bundler", 12 | "allowImportingTsExtensions": true, 13 | "isolatedModules": true, 14 | "moduleDetection": "force", 15 | "noEmit": true, 16 | "jsx": "react-jsx", 17 | 18 | /* Linting */ 19 | "strict": true, 20 | "noUnusedLocals": true, 21 | "noUnusedParameters": true, 22 | "noFallthroughCasesInSwitch": true, 23 | "noUncheckedSideEffectImports": true 24 | }, 25 | "include": ["src"] 26 | } 27 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "files": [], 3 | "references": [{ "path": "./tsconfig.app.json" }, { "path": "./tsconfig.node.json" }] 4 | } 5 | -------------------------------------------------------------------------------- /tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", 4 | "target": "ES2022", 5 | "lib": ["ES2023"], 6 | "module": "ESNext", 7 | "skipLibCheck": true, 8 | 9 | /* Bundler mode */ 10 | "moduleResolution": "bundler", 11 | "allowImportingTsExtensions": true, 12 | "isolatedModules": true, 13 | "moduleDetection": "force", 14 | "noEmit": true, 15 | 16 | /* Linting */ 17 | "strict": true, 18 | "noUnusedLocals": true, 19 | "noUnusedParameters": true, 20 | "noFallthroughCasesInSwitch": true, 21 | "noUncheckedSideEffectImports": true 22 | }, 23 | "include": ["vite.config.ts"] 24 | } 25 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import react from '@vitejs/plugin-react' 2 | import { defineConfig } from 'vite' 3 | import { VitePWA } from 'vite-plugin-pwa' 4 | 5 | const version = (process.env.npm_package_version || 'unknown').split('.').slice(0, 2).join('.') 6 | 7 | export default defineConfig({ 8 | base: process.env.npm_lifecycle_event === 'deploy' ? '/jelly-app/' : '/', 9 | plugins: [ 10 | react(), 11 | VitePWA({ 12 | registerType: 'autoUpdate', 13 | workbox: { 14 | globPatterns: ['**/*.{js,css,html,ico,png,webp,svg}'], 15 | }, 16 | manifest: { 17 | name: 'Jelly Music App', 18 | short_name: 'JMA', 19 | description: 'A lightweight & elegant music interface for Jellyfin', 20 | start_url: '/', 21 | scope: '/', 22 | display: 'standalone', 23 | orientation: 'portrait', 24 | theme_color: '#f8f8f8', 25 | background_color: '#f8f8f8', 26 | icons: [ 27 | { 28 | src: './web-app-manifest-192x192.png', 29 | sizes: '192x192', 30 | type: 'image/png', 31 | purpose: 'maskable', 32 | }, 33 | { 34 | src: './logo.png', 35 | sizes: '256x256', 36 | type: 'image/png', 37 | }, 38 | { 39 | src: './web-app-manifest-512x512.png', 40 | sizes: '512x512', 41 | type: 'image/png', 42 | purpose: 'maskable', 43 | }, 44 | ], 45 | }, 46 | }), 47 | { 48 | name: 'html-version-injector', 49 | transformIndexHtml(html) { 50 | return html.replace('__VERSION__', version) 51 | }, 52 | }, 53 | ], 54 | define: { 55 | __NPM_LIFECYCLE_EVENT__: JSON.stringify(process.env.npm_lifecycle_event), 56 | __VERSION__: JSON.stringify(version), 57 | }, 58 | }) 59 | --------------------------------------------------------------------------------