├── .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 |

8 |

9 |
10 |
11 |
12 |
13 | Additional screenshots
14 |
15 | Sidenav search
16 | Search for tracks, artists, albums, playlists, genres
17 |
18 |
19 |
20 |
21 | Search results
22 | View additional search results in a dedicated window
23 |
24 |
25 |
26 |
27 | Artists
28 | Features most played songs, albums, and other collaborations
29 |
30 |
31 |
32 |
33 | Playlists
34 | Playlist view, with it's own numbered tracklist
35 |
36 |
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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/public/clear.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/public/music.notes.svg:
--------------------------------------------------------------------------------
1 |
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 |
--------------------------------------------------------------------------------
/public/pause.fill.svg:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/public/pause.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/public/play.circle.fill.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
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 |
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 |
{
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 |
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 |
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 |
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 |
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 |
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 |
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 |
87 |
88 |
89 |
90 |
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 |
155 |
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 |
18 | {error &&
{error}
}
19 |
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 |
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 |
--------------------------------------------------------------------------------