Compare commits

..

28 Commits

Author SHA1 Message Date
jeffvli 5ab0eba23e update to 0.19.0 2025-07-30 19:53:07 -07:00
jeffvli 08fc307516 attempt to catch network errors to prevent credential invalidation 2025-07-30 19:51:46 -07:00
Hosted Weblate e5adc0caa9 Translated using Weblate (Norwegian Bokmål)
Currently translated at 53.4% (365 of 683 strings)

Translated using Weblate (Norwegian Bokmål)

Currently translated at 53.4% (365 of 683 strings)

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: klodrik <klodrik@zoominn.no>
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/nb_NO/
Translation: feishin/Translation
2025-07-31 04:11:35 +02:00
Hosted Weblate 7f0bdf20fc Translated using Weblate (Chinese (Simplified Han script))
Currently translated at 100.0% (685 of 685 strings)

Translated using Weblate (Chinese (Simplified Han script))

Currently translated at 100.0% (683 of 683 strings)

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: 無情天 <kofzhanganguo@126.com>
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/zh_Hans/
Translation: feishin/Translation
2025-07-31 04:11:34 +02:00
Hosted Weblate deb89ef87d Translated using Weblate (French)
Currently translated at 100.0% (685 of 685 strings)

Translated using Weblate (French)

Currently translated at 100.0% (683 of 683 strings)

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: KosmoMoustache <kosmomoustache@users.noreply.hosted.weblate.org>
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/fr/
Translation: feishin/Translation
2025-07-31 04:11:34 +02:00
Hosted Weblate 9751e22db4 Translated using Weblate (Spanish)
Currently translated at 100.0% (685 of 685 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (683 of 683 strings)

Co-authored-by: Fordas <fordas15@gmail.com>
Co-authored-by: Hosted Weblate <hosted@weblate.org>
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/es/
Translation: feishin/Translation
2025-07-31 04:11:33 +02:00
Hosted Weblate 6128470a47 Translated using Weblate (Czech)
Currently translated at 100.0% (685 of 685 strings)

Translated using Weblate (Czech)

Currently translated at 100.0% (683 of 683 strings)

Co-authored-by: Fjuro <fjuro@alius.cz>
Co-authored-by: Fjuro <git@alius.cz>
Co-authored-by: Hosted Weblate <hosted@weblate.org>
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/cs/
Translation: feishin/Translation
2025-07-31 04:11:32 +02:00
Jeff ea79885ef5 Merge pull request #1030 from jeffvli/dependabot/npm_and_yarn/npm_and_yarn-373e2693b3
Bump @eslint/plugin-kit from 0.3.3 to 0.3.4 in the npm_and_yarn group across 1 directory
2025-07-30 21:11:24 -05:00
dependabot[bot] 84fd6e482d Bump @eslint/plugin-kit in the npm_and_yarn group across 1 directory
Bumps the npm_and_yarn group with 1 update in the / directory: [@eslint/plugin-kit](https://github.com/eslint/rewrite/tree/HEAD/packages/plugin-kit).


Updates `@eslint/plugin-kit` from 0.3.3 to 0.3.4
- [Release notes](https://github.com/eslint/rewrite/releases)
- [Changelog](https://github.com/eslint/rewrite/blob/main/packages/plugin-kit/CHANGELOG.md)
- [Commits](https://github.com/eslint/rewrite/commits/plugin-kit-v0.3.4/packages/plugin-kit)

---
updated-dependencies:
- dependency-name: "@eslint/plugin-kit"
  dependency-version: 0.3.4
  dependency-type: indirect
  dependency-group: npm_and_yarn
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-07-30 02:24:51 +00:00
Jeff 904f05ff61 Merge pull request #1021 from jeffvli/dependabot/npm_and_yarn/npm_and_yarn-e04d5d616f
Bump form-data from 4.0.2 to 4.0.4 in the npm_and_yarn group across 1 directory
2025-07-29 21:23:23 -05:00
Jeff e78ec7688a Merge pull request #1024 from Der-Penz/development
Support tab navigation on ActionIcons in command palette
2025-07-29 21:22:47 -05:00
jeffvli f43de7f23c remove version from linux artifactName (#1020) 2025-07-29 19:18:27 -07:00
Jeff 07532ca55a Merge pull request #1018 from Lyall-A/fix-discord-disappearing
fix discord status clearing when song loops
2025-07-29 21:15:37 -05:00
Jeff 040a805f5f Merge pull request #1023 from chenqimiao/development
Add qm-music to an OpenSubsonic compatible server list
2025-07-29 21:04:39 -05:00
DerPenz 33af5e625b support tab navigation on ActionIcons in command palette 2025-07-26 14:01:05 +02:00
Qimiao Chen bdedcb883d introduce qm-music in readme 2025-07-25 18:22:34 +08:00
dependabot[bot] e842a75722 Bump form-data in the npm_and_yarn group across 1 directory
Bumps the npm_and_yarn group with 1 update in the / directory: [form-data](https://github.com/form-data/form-data).


Updates `form-data` from 4.0.2 to 4.0.4
- [Release notes](https://github.com/form-data/form-data/releases)
- [Changelog](https://github.com/form-data/form-data/blob/master/CHANGELOG.md)
- [Commits](https://github.com/form-data/form-data/compare/v4.0.2...v4.0.4)

---
updated-dependencies:
- dependency-name: form-data
  dependency-version: 4.0.4
  dependency-type: indirect
  dependency-group: npm_and_yarn
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-07-25 03:38:33 +00:00
dependabot[bot] d28054cc7f Bump @eslint/plugin-kit in the npm_and_yarn group across 1 directory (#1014)
Bumps the npm_and_yarn group with 1 update in the / directory: [@eslint/plugin-kit](https://github.com/eslint/rewrite/tree/HEAD/packages/plugin-kit).


Updates `@eslint/plugin-kit` from 0.3.1 to 0.3.3
- [Release notes](https://github.com/eslint/rewrite/releases)
- [Changelog](https://github.com/eslint/rewrite/blob/main/packages/plugin-kit/CHANGELOG.md)
- [Commits](https://github.com/eslint/rewrite/commits/plugin-kit-v0.3.3/packages/plugin-kit)

---
updated-dependencies:
- dependency-name: "@eslint/plugin-kit"
  dependency-version: 0.3.3
  dependency-type: indirect
  dependency-group: npm_and_yarn
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-07-25 03:37:07 +00:00
Lyall 41f8c34f6e fix discord status clearing when song loops 2025-07-22 10:23:02 +01:00
Kendall Garner 4b4df28641 add bit depth, sample rate 2025-07-13 07:12:13 -07:00
jeffvli 8b141d652c disable single attribute per line 2025-07-12 11:17:54 -07:00
Kendall Garner 92ed8e20c9 fix custom path 2025-07-12 07:21:34 -07:00
Kendall Garner 746ab8c2d9 large notification z-index 2025-07-11 19:21:52 -07:00
Kendall Garner 69341f4492 More typechecks on scrobble, use timeout on notification (#1004) 2025-07-10 13:53:40 +00:00
Kendall Garner 56130d8503 fix subsonic login error: use status instead 2025-07-08 16:42:30 -07:00
Kendall Garner 4407c8d424 convert query="" to query= for subsonic 2025-07-08 14:42:49 -07:00
Kendall Garner 58bb8e716e undo that change, remove magic numbers 2025-07-08 11:26:07 -07:00
Kendall Garner 0becfd4b59 subsonic limit to 500 2025-07-08 08:30:31 -07:00
177 changed files with 843 additions and 2012 deletions
+1 -1
View File
@@ -8,7 +8,7 @@ arrowParens: always
proseWrap: never
htmlWhitespaceSensitivity: strict
endOfLine: lf
singleAttributePerLine: true
singleAttributePerLine: false
bracketSpacing: true
plugins:
- prettier-plugin-packagejson
+1
View File
@@ -129,6 +129,7 @@ Feishin supports any music server that implements a [Navidrome](https://www.navi
- [LMS](https://github.com/epoupon/lms)
- [Nextcloud Music](https://apps.nextcloud.com/apps/music)
- [Supysonic](https://github.com/spl0k/supysonic)
- [Qm-Music](https://github.com/chenqimiao/qm-music)
- More (?)
### I have the issue "The SUID sandbox helper binary was found, but is not configured correctly" on Linux
+1 -27
View File
@@ -35,39 +35,13 @@ mac:
notarize: false
dmg:
contents: [{ x: 130, y: 220 }, { x: 410, y: 220, type: link, path: /Applications }]
deb:
depends:
- libgssapi_krb5.so.2
- libavahi-common.so.3
- libavahi-client.so.3
- libkrb5.so.3
- libkrb5support.so.0
- libkeyutils.so.1
- libcups.so.2
rpm:
depends:
- libgssapi_krb5.so.2
- libavahi-common.so.3
- libavahi-client.so.3
- libkrb5.so.3
- libkrb5support.so.0
- libkeyutils.so.1
- libcups.so.2
freebsd:
depends:
- libgssapi_krb5.so.2
- libavahi-common.so.3
- libavahi-client.so.3
- libkrb5.so.3
- libkrb5support.so.0
- libkeyutils.so.1
- libcups.so.2
linux:
target:
- AppImage
- tar.xz
category: AudioVideo;Audio;Player
icon: assets/icons/icon.png
artifactName: ${productName}-${os}-${arch}.${ext}
npmRebuild: false
publish:
provider: github
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "feishin",
"version": "0.18.0",
"version": "0.19.0",
"description": "A modern self-hosted music player.",
"keywords": [
"subsonic",
+47 -41
View File
@@ -766,6 +766,10 @@ packages:
resolution: {integrity: sha512-qIbV0/JZr7iSDjqAc60IqbLdsj9GDt16xQtWD+B78d/HAlvysGdZZ6rpJHGAc2T0FQx1X6thsSPdnoiGKdNtdg==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
'@eslint/core@0.15.1':
resolution: {integrity: sha512-bkOp+iumZCCbt1K1CmWf0R9pM5yKpDv+ZXtvSyQpudrI9kuFLp+bM2WOPXImuD/ceQuaa8f5pj93Y7zyECIGNA==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
'@eslint/eslintrc@3.3.1':
resolution: {integrity: sha512-gtF186CXhIl1p4pJNGZw8Yc6RlshoePRvE0X91oPGb3vZ8pM3qOS9W9NGPat9LziaBV7XrJWGylNQXkGcnM3IQ==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
@@ -778,8 +782,8 @@ packages:
resolution: {integrity: sha512-RBMg5FRL0I0gs51M/guSAj5/e14VQ4tpZnQNWwuDT66P14I43ItmPfIZRhO9fUVIPOAQXU47atlywZ/czoqFPA==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
'@eslint/plugin-kit@0.3.1':
resolution: {integrity: sha512-0J+zgWxHN+xXONWIyPWKFMgVuJoZuGiIFu8yxk7RJjxkzpGmyja5wRFqZIVtjDVOQpV+Rw0iOAjYPE2eQyjr0w==}
'@eslint/plugin-kit@0.3.4':
resolution: {integrity: sha512-Ul5l+lHEcw3L5+k8POx6r74mxEYKG5kOb6Xpy2gCRW6zweT6TEhAf8vhxGgjhqrd/VO/Dirhsb+1hNpD1ue9hw==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
'@floating-ui/core@1.7.0':
@@ -834,26 +838,24 @@ packages:
resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==}
engines: {node: '>=12'}
'@jridgewell/gen-mapping@0.3.8':
resolution: {integrity: sha512-imAbBGkb+ebQyxKgzv5Hu2nmROxoDOXHh80evxdoXNOrvAnVx7zimzc1Oo5h9RlfV4vPXaE2iM5pOFbvOCClWA==}
engines: {node: '>=6.0.0'}
'@jridgewell/gen-mapping@0.3.12':
resolution: {integrity: sha512-OuLGC46TjB5BbN1dH8JULVVZY4WTdkF7tV9Ys6wLL1rubZnCMstOhNHueU5bLCrnRuDhKPDM4g6sw4Bel5Gzqg==}
'@jridgewell/resolve-uri@3.1.2':
resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==}
engines: {node: '>=6.0.0'}
'@jridgewell/set-array@1.2.1':
resolution: {integrity: sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==}
engines: {node: '>=6.0.0'}
'@jridgewell/source-map@0.3.6':
resolution: {integrity: sha512-1ZJTZebgqllO79ue2bm3rIGud/bOe0pP5BjSRCRxxYkEZS8STV7zN84UBbiYu7jy+eCKSnVIUgoWWE/tt+shMQ==}
'@jridgewell/source-map@0.3.10':
resolution: {integrity: sha512-0pPkgz9dY+bijgistcTTJ5mR+ocqRXLuhXHYdzoMmmoJ2C9S46RCm2GMUbatPEUK9Yjy26IrAy8D/M00lLkv+Q==}
'@jridgewell/sourcemap-codec@1.5.0':
resolution: {integrity: sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==}
'@jridgewell/trace-mapping@0.3.25':
resolution: {integrity: sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==}
'@jridgewell/sourcemap-codec@1.5.4':
resolution: {integrity: sha512-VT2+G1VQs/9oz078bLrYbecdZKs912zQlkelYpuf+SXF+QvZDYJlbx/LSx+meSAwdDFnF8FVXW92AVjjkVmgFw==}
'@jridgewell/trace-mapping@0.3.29':
resolution: {integrity: sha512-uw6guiW/gcAGPDhLmd77/6lW8QLeiV5RUTsAX46Db6oLhGaVj4lhnPwb184s1bkc8kdVg/+h988dro8GRDpmYQ==}
'@keyv/serialize@1.0.3':
resolution: {integrity: sha512-qnEovoOp5Np2JDGonIDL6Ayihw0RhnRh6vxPuHo4RDn1UOzwEo4AeIfpL6UGIrsceWrCMiVPgwRjbHu4vYFc3g==}
@@ -2370,8 +2372,8 @@ packages:
resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==}
engines: {node: '>=14'}
form-data@4.0.2:
resolution: {integrity: sha512-hGfm/slu0ZabnNt4oaRZ6uREyfCj6P4fT/n6A1rGV+Z0VdGXjfOhVUpkn6qVQONHGIFwmveGXyDs75+nr6FM8w==}
form-data@4.0.4:
resolution: {integrity: sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==}
engines: {node: '>= 6'}
format-duration@2.0.0:
@@ -4128,8 +4130,8 @@ packages:
resolution: {integrity: sha512-Fgl0YPZ902wEsAyiQ+idGd1A7rSFx/ayC1CQVMw5P+EQx2V0SgpGtf6OKFhVjPflPUl9YMmEOnmfjCdMUsygww==}
engines: {node: '>= 10'}
socks@2.8.5:
resolution: {integrity: sha512-iF+tNDQla22geJdTyJB1wM/qrX9DMRwWrciEPwWLPRWAUEM8sQiyxgckLxWT1f7+9VabJS0jTGGr4QgBuvi6Ww==}
socks@2.8.6:
resolution: {integrity: sha512-pe4Y2yzru68lXCb38aAqRf5gvN8YdjP1lok5o0J7BOHljkyCGKVz7H3vpVIXKD27rj2giOJ7DwVyk/GWrPHDWA==}
engines: {node: '>= 10.0.0', npm: '>= 3.0.0'}
sort-keys@5.1.0:
@@ -4786,8 +4788,8 @@ snapshots:
'@ampproject/remapping@2.3.0':
dependencies:
'@jridgewell/gen-mapping': 0.3.8
'@jridgewell/trace-mapping': 0.3.25
'@jridgewell/gen-mapping': 0.3.12
'@jridgewell/trace-mapping': 0.3.29
'@atlaskit/pragmatic-drag-and-drop-auto-scroll@2.1.1':
dependencies:
@@ -4843,8 +4845,8 @@ snapshots:
dependencies:
'@babel/parser': 7.27.2
'@babel/types': 7.27.1
'@jridgewell/gen-mapping': 0.3.8
'@jridgewell/trace-mapping': 0.3.25
'@jridgewell/gen-mapping': 0.3.12
'@jridgewell/trace-mapping': 0.3.29
jsesc: 3.1.0
'@babel/helper-compilation-targets@7.27.2':
@@ -5206,6 +5208,10 @@ snapshots:
dependencies:
'@types/json-schema': 7.0.15
'@eslint/core@0.15.1':
dependencies:
'@types/json-schema': 7.0.15
'@eslint/eslintrc@3.3.1':
dependencies:
ajv: 6.12.6
@@ -5224,9 +5230,9 @@ snapshots:
'@eslint/object-schema@2.1.6': {}
'@eslint/plugin-kit@0.3.1':
'@eslint/plugin-kit@0.3.4':
dependencies:
'@eslint/core': 0.14.0
'@eslint/core': 0.15.1
levn: 0.4.1
'@floating-ui/core@1.7.0':
@@ -5282,28 +5288,27 @@ snapshots:
wrap-ansi: 8.1.0
wrap-ansi-cjs: wrap-ansi@7.0.0
'@jridgewell/gen-mapping@0.3.8':
'@jridgewell/gen-mapping@0.3.12':
dependencies:
'@jridgewell/set-array': 1.2.1
'@jridgewell/sourcemap-codec': 1.5.0
'@jridgewell/trace-mapping': 0.3.25
'@jridgewell/sourcemap-codec': 1.5.4
'@jridgewell/trace-mapping': 0.3.29
'@jridgewell/resolve-uri@3.1.2': {}
'@jridgewell/set-array@1.2.1': {}
'@jridgewell/source-map@0.3.6':
'@jridgewell/source-map@0.3.10':
dependencies:
'@jridgewell/gen-mapping': 0.3.8
'@jridgewell/trace-mapping': 0.3.25
'@jridgewell/gen-mapping': 0.3.12
'@jridgewell/trace-mapping': 0.3.29
optional: true
'@jridgewell/sourcemap-codec@1.5.0': {}
'@jridgewell/trace-mapping@0.3.25':
'@jridgewell/sourcemap-codec@1.5.4': {}
'@jridgewell/trace-mapping@0.3.29':
dependencies:
'@jridgewell/resolve-uri': 3.1.2
'@jridgewell/sourcemap-codec': 1.5.0
'@jridgewell/sourcemap-codec': 1.5.4
'@keyv/serialize@1.0.3':
dependencies:
@@ -6062,7 +6067,7 @@ snapshots:
axios@1.9.0:
dependencies:
follow-redirects: 1.15.9
form-data: 4.0.2
form-data: 4.0.4
proxy-from-env: 1.1.0
transitivePeerDependencies:
- debug
@@ -6708,7 +6713,7 @@ snapshots:
builder-util: 26.0.11
builder-util-runtime: 9.3.1
chalk: 4.1.2
form-data: 4.0.2
form-data: 4.0.4
fs-extra: 10.1.0
lazy-val: 1.0.5
mime: 2.6.0
@@ -7009,7 +7014,7 @@ snapshots:
'@eslint/core': 0.14.0
'@eslint/eslintrc': 3.3.1
'@eslint/js': 9.27.0
'@eslint/plugin-kit': 0.3.1
'@eslint/plugin-kit': 0.3.4
'@humanfs/node': 0.16.6
'@humanwhocodes/module-importer': 1.0.1
'@humanwhocodes/retry': 0.4.3
@@ -7171,11 +7176,12 @@ snapshots:
cross-spawn: 7.0.6
signal-exit: 4.1.0
form-data@4.0.2:
form-data@4.0.4:
dependencies:
asynckit: 0.4.0
combined-stream: 1.0.8
es-set-tostringtag: 2.1.0
hasown: 2.0.2
mime-types: 2.1.35
format-duration@2.0.0: {}
@@ -8940,11 +8946,11 @@ snapshots:
dependencies:
agent-base: 6.0.2
debug: 4.4.1
socks: 2.8.5
socks: 2.8.6
transitivePeerDependencies:
- supports-color
socks@2.8.5:
socks@2.8.6:
dependencies:
ip-address: 9.0.5
smart-buffer: 4.2.0
@@ -9244,7 +9250,7 @@ snapshots:
terser@5.39.2:
dependencies:
'@jridgewell/source-map': 0.3.6
'@jridgewell/source-map': 0.3.10
acorn: 8.15.0
commander: 2.20.3
source-map-support: 0.5.21
+8 -3
View File
@@ -271,7 +271,9 @@
"discordPausedStatus": "zobrazit rich presence při pozastavení",
"discordPausedStatus_description": "pokud je povoleno, bude při pozastavení přehrávače zobrazen stav",
"preservePitch": "zachovat výšku",
"preservePitch_description": "zachová výšku při úpravě rychlosti přehrávání"
"preservePitch_description": "zachová výšku při úpravě rychlosti přehrávání",
"notify": "povolit oznámení o skladbách",
"notify_description": "zobrazit oznámení při změně aktuální skladby"
},
"action": {
"editPlaylist": "upravit $t(entity.playlist_one)",
@@ -393,7 +395,9 @@
"additionalParticipants": "další přispívající",
"tags": "štítky",
"viewReleaseNotes": "zobrazit seznam změn",
"newVersion": "byla nainstalována nová verze ({{version}})"
"newVersion": "byla nainstalována nová verze ({{version}})",
"bitDepth": "bitová hloubka",
"sampleRate": "vzorkovací frekvence"
},
"table": {
"config": {
@@ -495,7 +499,8 @@
"badAlbum": "tuto stránku vidíte, protože tato skladba není součástí alba. tento problém může nastat, pokud máte skladbu na nejvyšší úrovni vaší složky s hudbou. jellyfin seskupuje skladby pouze, pokud se nacházejí ve složce.",
"networkError": "vyskytla se chyba sítě",
"openError": "nepodařilo se otevřít soubor",
"badValue": "neplatná možnost „{{value}}“. tato možnost již neexistuje"
"badValue": "neplatná možnost „{{value}}“. tato možnost již neexistuje",
"notificationDenied": "oprávnění k posílání oznámení byla zamítnuta. toto nastavení nemá žádný vliv"
},
"filter": {
"mostPlayed": "nejvíce přehráváno",
+2
View File
@@ -36,6 +36,7 @@
"ascending": "ascending",
"backward": "backward",
"biography": "biography",
"bitDepth": "bit depth",
"bitrate": "bitrate",
"bpm": "bpm",
"cancel": "cancel",
@@ -99,6 +100,7 @@
"resetToDefault": "reset to default",
"restartRequired": "restart required",
"right": "right",
"sampleRate": "sample rate",
"save": "save",
"saveAndReplace": "save and replace",
"saveAs": "save as",
+8 -3
View File
@@ -271,7 +271,9 @@
"discordPausedStatus": "Mostrar estado de actividad cuando esté en pausa",
"discordPausedStatus_description": "Cuando está activado, el estado mostrará cuando el reproductor esté en pausa",
"preservePitch": "Mantener el tono",
"preservePitch_description": "Mantiene el tono cuando se modifica la velocidad de reproducción"
"preservePitch_description": "Mantiene el tono cuando se modifica la velocidad de reproducción",
"notify": "Activar notificaciones de canciones",
"notify_description": "Muestra notificaciones cuando se cambia la canción actual"
},
"action": {
"editPlaylist": "editar $t(entity.playlist_one)",
@@ -393,7 +395,9 @@
"additionalParticipants": "Participantes adicionales",
"tags": "Etiquetas",
"newVersion": "Una nueva versión ha sido instalada ({{version}})",
"viewReleaseNotes": "Ver notas de lanzamiento"
"viewReleaseNotes": "Ver notas de lanzamiento",
"bitDepth": "Profundidad de bit",
"sampleRate": "Frecuencia de muestreo"
},
"error": {
"remotePortWarning": "reiniciar el servidor para aplicar el nuevo puerto",
@@ -418,7 +422,8 @@
"badAlbum": "Estás viendo esta página porque esta canción no forma parte de un álbum. Este problema puede ocurrir si tienes una canción en el nivel superior de tu carpeta de música. Jellyfin solo agrupa pistas si están en una carpeta.",
"networkError": "Ocurrió un error de red",
"openError": "No se pudo abrir el archivo",
"badValue": "Opción inválida \"{{value}}\". Este valor ya no existe"
"badValue": "Opción inválida \"{{value}}\". Este valor ya no existe",
"notificationDenied": "Se denegaron los permisos para notificaciones. Esta configuración no tiene efecto"
},
"filter": {
"mostPlayed": "más reproducido",
+8 -3
View File
@@ -155,7 +155,9 @@
"additionalParticipants": "participants additionnels",
"tags": "tags",
"newVersion": "une nouvelle version vient d'être installé ({{version}})",
"viewReleaseNotes": "voir la note de version"
"viewReleaseNotes": "voir la note de version",
"sampleRate": "taux d'échantillonnage",
"bitDepth": "bit par échantillon"
},
"error": {
"remotePortWarning": "redémarrer le serveur pour appliquer le nouveau port",
@@ -180,7 +182,8 @@
"openError": "impossible d'ouvrir le fichier",
"networkError": "une erreur de réseau est survenue",
"badAlbum": "vous voyez cette page parce que cette chanson ne fait pas parti d'un album. vous rencontrez probablement cette erreur si vous avez une chanson qui n'est pas dans votre répertoire de musique. jellyfin gère les chansons uniquement si elles sont dans un sous-dossier, qui est lui-même dans un dossier \"Musique(s)\".",
"badValue": "option {{value}} invalide. Cette valeur n'existe plus"
"badValue": "option {{value}} invalide. Cette valeur n'existe plus",
"notificationDenied": "les autorisations pour les notifications ont été refusées. ce paramètre n'a aucun effet"
},
"filter": {
"mostPlayed": "plus joués",
@@ -616,7 +619,9 @@
"discordPausedStatus_description": "quand activé, le status s'affichera lorsque le lecteur est en pause",
"discordPausedStatus": "afficher le status d'activité en pause",
"preservePitch": "préserver la hauteur",
"preservePitch_description": "préserver la hauteur lors du changement de la vitesse de lecture"
"preservePitch_description": "préserver la hauteur lors du changement de la vitesse de lecture",
"notify": "activer les notifications des chansons",
"notify_description": "affiche une notification lors du changement de chanson"
},
"form": {
"deletePlaylist": {
+77 -6
View File
@@ -104,13 +104,14 @@
"year": "år",
"yes": "ja",
"descending": "synkende",
"dismiss": "avkreft",
"dismiss": "lukk",
"delete": "slett",
"description": "beskrivelse",
"manage": "håndtere",
"maximize": "maksimer",
"right": "høyre",
"sortOrder": "rekkefølge"
"sortOrder": "rekkefølge",
"tags": "tagger"
},
"entity": {
"smartPlaylist": "smart $t(entity.playlist_one)",
@@ -233,7 +234,7 @@
"addServer": {
"ignoreCors": "ignorer cors ($t(common.restartRequired))",
"ignoreSsl": "ignorer ssl ($t(common.restartRequired))",
"error_savePassword": "en problem oppstod ved lagring av passord",
"error_savePassword": "et problem oppstod ved lagring av passord",
"input_savePassword": "lagre passord",
"input_url": "lenke",
"input_username": "brukernavn",
@@ -269,6 +270,10 @@
"updateServer": {
"success": "vellykket oppdatering av serveren",
"title": "oppdater server"
},
"queryEditor": {
"input_optionMatchAll": "match alle",
"input_optionMatchAny": "matche hvilken som helst"
}
},
"page": {
@@ -338,7 +343,7 @@
"lyricGap": "sangtekstavstand",
"dynamicImageBlur": "bilduskarphetstørrelse",
"lyricAlignment": "sangtekstjustering",
"lyricOffset": "sangtekstjustering (ms)",
"lyricOffset": "sangtekstforskyvning (ms)",
"lyricSize": "sangtekststørrelse",
"opacity": "absorpsjon",
"showLyricMatch": "vis sangteksttreff",
@@ -405,7 +410,8 @@
"search": "$t(common.search)",
"settings": "$t(common.setting_other)",
"shared": "delt $t(entity.playlist_other)",
"artists": "$t(entity.artist_other)"
"artists": "$t(entity.artist_other)",
"myLibrary": "mitt bibliotek"
},
"setting": {
"generalTab": "generelt",
@@ -416,6 +422,9 @@
},
"playlistList": {
"title": "$t(entity.playlist_other)"
},
"playlist": {
"reorder": "omorganisering kun mulig ved sortering på id"
}
},
"player": {
@@ -439,6 +448,68 @@
"queue_moveToTop": "flytt valgte til bunnen",
"playbackFetchNoResults": "ingen sanger funnet",
"playbackSpeed": "avspillingshastighet",
"playSimilarSongs": "spill lignende sanger"
"playSimilarSongs": "spill lignende sanger",
"skip": "hopp over",
"shuffle": "spill i tilfeldig rekkefølge",
"shuffle_off": "tilfeldig rekkefølge skrudd av",
"skip_back": "hopp bakover",
"skip_forward": "hopp fremover",
"stop": "stopp",
"toggleFullscreenPlayer": "bytt til fullskjermspiller",
"pause": "sett på pause",
"viewQueue": "se kø",
"unfavorite": "fjern fra favoritter"
},
"setting": {
"accentColor": "aksentfarge",
"accentColor_description": "setter aksentfarge i applikasjonen",
"albumBackground": "album bakgrunnsbilde",
"albumBackgroundBlur": "album bakgrunnsbilde uskarphetsstørrelse",
"albumBackgroundBlur_description": "justerer grad av uskarphet lagt til på album bakgrunnsbilde",
"audioDevice": "lydenhet",
"zoom": "zoomprosent",
"zoom_description": "angir zoomprosent for applikasjonen"
},
"table": {
"config": {
"label": {
"playCount": "antall avspillinger",
"releaseDate": "utgivelsesdato",
"trackNumber": "spornummer",
"rowIndex": "radindeks",
"dateAdded": "dato lagt til",
"discNumber": "skivenummer",
"lastPlayed": "sist avspilt"
},
"view": {
"table": "tabell",
"card": "kort",
"grid": "rutenett",
"list": "liste",
"poster": "plakat"
},
"general": {
"autoFitColumns": "automatisk kolonnetilpasning",
"displayType": "visningstype",
"followCurrentSong": "følg gjeldende sang"
}
},
"column": {
"releaseYear": "år",
"comment": "kommentar",
"biography": "biografi",
"album": "album",
"albumArtist": "albumartist",
"dateAdded": "dato lagt til",
"discNumber": "skive",
"favorite": "favoritt",
"lastPlayed": "sist avspilt",
"path": "sti",
"playCount": "avspillinger",
"rating": "vurdering",
"releaseDate": "utgivelsesdato",
"title": "tittel",
"trackNumber": "spor"
}
}
}
+8 -3
View File
@@ -113,7 +113,9 @@
"additionalParticipants": "其他参与者",
"tags": "标签",
"viewReleaseNotes": "查看发行说明",
"newVersion": "已安装新版本 ({{version}})"
"newVersion": "已安装新版本 ({{version}})",
"bitDepth": "位深度",
"sampleRate": "采样率"
},
"entity": {
"albumArtist_other": "专辑艺术家",
@@ -407,7 +409,9 @@
"discordPausedStatus": "暂停时显示rich presence",
"discordPausedStatus_description": "启用后将在播放器暂停时显示状态",
"preservePitch": "保持音高",
"preservePitch_description": "在调整播放速度时保持音高"
"preservePitch_description": "在调整播放速度时保持音高",
"notify": "启用歌曲通知",
"notify_description": "更改当前歌曲时显示通知"
},
"error": {
"remotePortWarning": "重启服务器使新端口生效",
@@ -432,7 +436,8 @@
"badAlbum": "您看到此页面是因为这首歌不是专辑的一部分。如果您的音乐文件夹顶层有一首歌曲,您很可能会遇到此问题。jellyfin 仅对位于文件夹中的曲目进行分组。",
"networkError": "发生网络错误",
"openError": "无法打开文件",
"badValue": "无效的选项 \"{{value}}\". 此值不再存在"
"badValue": "无效的选项 \"{{value}}\". 此值不再存在",
"notificationDenied": "通知权限被拒绝。此设置无效"
},
"filter": {
"mostPlayed": "最多播放过",
+1 -4
View File
@@ -22,10 +22,7 @@ export const App = () => {
const { mode, theme } = useAppTheme(isDark ? AppTheme.DEFAULT_DARK : AppTheme.DEFAULT_LIGHT);
return (
<MantineProvider
defaultColorScheme={mode}
theme={theme}
>
<MantineProvider defaultColorScheme={mode} theme={theme}>
<Shell />
</MantineProvider>
);
+1 -11
View File
@@ -18,17 +18,7 @@ export const ThemeButton = () => {
}}
variant="default"
>
{isDark ? (
<Icon
icon="themeLight"
size={30}
/>
) : (
<Icon
icon="themeDark"
size={30}
/>
)}
{isDark ? <Icon icon="themeLight" size={30} /> : <Icon icon="themeDark" size={30} />}
</ActionIcon>
);
};
+7 -30
View File
@@ -32,17 +32,9 @@ export const RemoteContainer = () => {
const debouncedSetRating = debounce(setRating, 400);
return (
<Stack
gap="md"
h="100dvh"
w="100%"
>
<Stack gap="md" h="100dvh" w="100%">
{showImage && (
<Flex
align="center"
justify="center"
w="100%"
>
<Flex align="center" justify="center" w="100%">
<PlayerImage src={song?.imageUrl} />
</Flex>
)}
@@ -87,10 +79,7 @@ export const RemoteContainer = () => {
</Group>
</Stack>
)}
<Group
gap={0}
grow
>
<Group gap={0} grow>
<ActionIcon
disabled={!id}
icon="favorite"
@@ -109,10 +98,7 @@ export const RemoteContainer = () => {
/>
{(song?.serverType === 'navidrome' || song?.serverType === 'subsonic') && (
<div style={{ margin: 'auto' }}>
<Tooltip
label="Double click to clear"
openDelay={1000}
>
<Tooltip label="Double click to clear" openDelay={1000}>
<Rating
onChange={debouncedSetRating}
onDoubleClick={() => debouncedSetRating(0)}
@@ -123,10 +109,7 @@ export const RemoteContainer = () => {
</div>
)}
</Group>
<Group
gap="xs"
grow
>
<Group gap="xs" grow>
<ActionIcon
disabled={!id}
icon="mediaPrevious"
@@ -174,10 +157,7 @@ export const RemoteContainer = () => {
variant="default"
/>
</Group>
<Group
gap="xs"
grow
>
<Group gap="xs" grow>
<ActionIcon
icon="mediaShuffle"
iconProps={{
@@ -232,10 +212,7 @@ export const RemoteContainer = () => {
max={100}
onChangeEnd={(e) => send({ event: 'volume', volume: e })}
rightLabel={
<Text
fw={600}
size="xs"
>
<Text fw={600} size="xs">
{volume ?? 0}
</Text>
}
+5 -24
View File
@@ -13,16 +13,9 @@ export const Shell = () => {
const connected = useConnected();
return (
<AppShell
h="100vh"
padding="md"
w="100vw"
>
<AppShell h="100vh" padding="md" w="100vw">
<AppShell.Header style={{ background: 'var(--theme-colors-surface)' }}>
<Grid
px="md"
py="sm"
>
<Grid px="md" py="sm">
<Grid.Col span={4}>
<Flex
align="center"
@@ -33,20 +26,11 @@ export const Shell = () => {
justifySelf: 'flex-start',
}}
>
<Image
fit="contain"
height={32}
src="/favicon.ico"
width={32}
/>
<Image fit="contain" height={32} src="/favicon.ico" width={32} />
</Flex>
</Grid.Col>
<Grid.Col span={8}>
<Group
gap="sm"
justify="flex-end"
wrap="nowrap"
>
<Group gap="sm" justify="flex-end" wrap="nowrap">
<ReconnectButton />
<ImageButton />
<ThemeButton />
@@ -58,10 +42,7 @@ export const Shell = () => {
{connected ? (
<RemoteContainer />
) : (
<Center
h="100vh"
w="100vw"
>
<Center h="100vh" w="100vw">
<Spinner />
</Center>
)}
+1 -4
View File
@@ -61,10 +61,7 @@ export const WrappedSlider = ({ leftLabel, rightLabel, value, ...props }: Wrappe
const [seek, setSeek] = useState(0);
return (
<Group
align="center"
wrap="nowrap"
>
<Group align="center" wrap="nowrap">
{leftLabel && <Text size="sm">{leftLabel}</Text>}
<PlayerbarSlider
{...props}
+13 -2
View File
@@ -349,7 +349,14 @@ axiosClient.interceptors.response.use(
.catch((newError: any) => {
if (newError !== TIMEOUT_ERROR) {
console.error('Error when trying to reauthenticate: ', newError);
limitedFail(currentServer);
if (isAxiosError(newError) && newError.code === 'ERR_NETWORK') {
console.log(
'Network error during reauthentication - preserving credentials',
);
} else {
limitedFail(currentServer);
}
}
// make sure to pass the error so axios will error later on
@@ -360,7 +367,11 @@ axiosClient.interceptors.response.use(
});
}
limitedFail(currentServer);
if (isAxiosError(error) && error.code === 'ERR_NETWORK') {
console.log('Network error during authentication - preserving credentials');
} else {
limitedFail(currentServer);
}
}
return Promise.reject(error);
@@ -251,6 +251,9 @@ axiosClient.interceptors.response.use(
message: data['subsonic-response'].error.message,
title: i18n.t('error.genericError', { postProcess: 'sentenceCase' }) as string,
});
// Since we do status === 200, override this value with the error code
response.status = data['subsonic-response'].error.code;
}
}
@@ -46,6 +46,10 @@ const ALBUM_LIST_SORT_MAPPING: Record<AlbumListSort, AlbumListSortType | undefin
[AlbumListSort.YEAR]: AlbumListSortType.BY_YEAR,
};
const MAX_SUBSONIC_ITEMS = 500;
// A trick to skip ahead 10x
const SUBSONIC_FAST_BATCH_SIZE = MAX_SUBSONIC_ITEMS * 10;
export const SubsonicController: ControllerEndpoint = {
addToPlaylist: async ({ apiClientProps, body, query }) => {
const res = await ssApiClient(apiClientProps).updatePlaylist({
@@ -90,7 +94,7 @@ export const SubsonicController: ControllerEndpoint = {
};
}
await ssApiClient({ server: null, url: cleanServerUrl }).authenticate({
const resp = await ssApiClient({ server: null, url: cleanServerUrl }).authenticate({
query: {
c: 'Feishin',
f: 'json',
@@ -99,6 +103,10 @@ export const SubsonicController: ControllerEndpoint = {
},
});
if (resp.status !== 200) {
throw new Error('Failed to log in');
}
return {
credential,
userId: null,
@@ -269,7 +277,7 @@ export const SubsonicController: ControllerEndpoint = {
albumOffset: query.startIndex,
artistCount: 0,
artistOffset: 0,
query: query.searchTerm || '""',
query: query.searchTerm || '',
songCount: 0,
songOffset: 0,
},
@@ -418,11 +426,11 @@ export const SubsonicController: ControllerEndpoint = {
while (fetchNextPage) {
const res = await ssApiClient(apiClientProps).search3({
query: {
albumCount: 500,
albumCount: MAX_SUBSONIC_ITEMS,
albumOffset: startIndex,
artistCount: 0,
artistOffset: 0,
query: query.searchTerm || '""',
query: query.searchTerm || '',
songCount: 0,
songOffset: 0,
},
@@ -437,8 +445,7 @@ export const SubsonicController: ControllerEndpoint = {
totalRecordCount += albumCount;
startIndex += albumCount;
// The max limit size for Subsonic is 500
fetchNextPage = albumCount === 500;
fetchNextPage = albumCount === MAX_SUBSONIC_ITEMS;
}
return totalRecordCount;
@@ -522,7 +529,7 @@ export const SubsonicController: ControllerEndpoint = {
genre: query.genres?.length ? query.genres[0] : undefined,
musicFolderId: query.musicFolderId,
offset: startIndex,
size: 500,
size: MAX_SUBSONIC_ITEMS,
toYear,
type,
},
@@ -546,8 +553,7 @@ export const SubsonicController: ControllerEndpoint = {
totalRecordCount += albumCount;
startIndex += albumCount;
// The max limit size for Subsonic is 500
fetchNextPage = albumCount === 500;
fetchNextPage = albumCount === MAX_SUBSONIC_ITEMS;
}
return totalRecordCount;
@@ -904,7 +910,7 @@ export const SubsonicController: ControllerEndpoint = {
albumOffset: 0,
artistCount: 0,
artistOffset: 0,
query: query.searchTerm || '""',
query: query.searchTerm || '',
songCount: query.limit,
songOffset: query.startIndex,
},
@@ -1046,7 +1052,7 @@ export const SubsonicController: ControllerEndpoint = {
albumOffset: 0,
artistCount: 0,
artistOffset: 0,
query: query.searchTerm || '""',
query: query.searchTerm || '',
songCount: query.limit,
songOffset: query.startIndex,
},
@@ -1086,8 +1092,8 @@ export const SubsonicController: ControllerEndpoint = {
albumOffset: 0,
artistCount: 0,
artistOffset: 0,
query: query.searchTerm || '""',
songCount: 500,
query: query.searchTerm || '',
songCount: MAX_SUBSONIC_ITEMS,
songOffset: startIndex,
},
});
@@ -1101,8 +1107,7 @@ export const SubsonicController: ControllerEndpoint = {
totalRecordCount += songCount;
startIndex += songCount;
// The max limit size for Subsonic is 500
fetchNextPage = songCount === 500;
fetchNextPage = songCount === MAX_SUBSONIC_ITEMS;
}
return totalRecordCount;
@@ -1110,6 +1115,10 @@ export const SubsonicController: ControllerEndpoint = {
if (query.genreIds) {
let totalRecordCount = 0;
// Rather than just do `getSongsByGenre` by groups of 500, instead
// jump the offset 10x, and then backtrack on the last chunk. This improves
// performance for extremely large libraries
while (fetchNextSection) {
const res = await ssApiClient(apiClientProps).getSongsByGenre({
query: {
@@ -1128,17 +1137,17 @@ export const SubsonicController: ControllerEndpoint = {
if (numberOfResults !== 1) {
fetchNextSection = false;
startIndex = sectionIndex === 0 ? 0 : sectionIndex - 5000;
startIndex = sectionIndex === 0 ? 0 : sectionIndex - SUBSONIC_FAST_BATCH_SIZE;
break;
} else {
sectionIndex += 5000;
sectionIndex += SUBSONIC_FAST_BATCH_SIZE;
}
}
while (fetchNextPage) {
const res = await ssApiClient(apiClientProps).getSongsByGenre({
query: {
count: 500,
count: MAX_SUBSONIC_ITEMS,
genre: query.genreIds[0],
musicFolderId: query.musicFolderId,
offset: startIndex,
@@ -1154,7 +1163,7 @@ export const SubsonicController: ControllerEndpoint = {
totalRecordCount = startIndex + numberOfResults;
startIndex += numberOfResults;
fetchNextPage = numberOfResults === 500;
fetchNextPage = numberOfResults === MAX_SUBSONIC_ITEMS;
}
return totalRecordCount;
@@ -1176,6 +1185,9 @@ export const SubsonicController: ControllerEndpoint = {
let totalRecordCount = 0;
// Rather than just do `search3` by groups of 500, instead
// jump the offset 10x, and then backtrack on the last chunk. This improves
// performance for extremely large libraries
while (fetchNextSection) {
const res = await ssApiClient(apiClientProps).search3({
query: {
@@ -1183,7 +1195,7 @@ export const SubsonicController: ControllerEndpoint = {
albumOffset: 0,
artistCount: 0,
artistOffset: 0,
query: query.searchTerm || '""',
query: query.searchTerm || '',
songCount: 1,
songOffset: sectionIndex,
},
@@ -1195,13 +1207,12 @@ export const SubsonicController: ControllerEndpoint = {
const numberOfResults = (res.body.searchResult3?.song || []).length || 0;
// Check each batch of 5000 songs to check for data
sectionIndex += 5000;
fetchNextSection = numberOfResults === 1;
if (!fetchNextSection) {
// fetchNextBlock will be false on the next loop so we need to subtract 5000 * 2
startIndex = sectionIndex - 10000;
if (numberOfResults !== 1) {
fetchNextSection = false;
startIndex = sectionIndex === 0 ? 0 : sectionIndex - SUBSONIC_FAST_BATCH_SIZE;
break;
} else {
sectionIndex += SUBSONIC_FAST_BATCH_SIZE;
}
}
@@ -1212,8 +1223,8 @@ export const SubsonicController: ControllerEndpoint = {
albumOffset: 0,
artistCount: 0,
artistOffset: 0,
query: query.searchTerm || '""',
songCount: 500,
query: query.searchTerm || '',
songCount: MAX_SUBSONIC_ITEMS,
songOffset: startIndex,
},
});
@@ -1227,8 +1238,7 @@ export const SubsonicController: ControllerEndpoint = {
totalRecordCount = startIndex + numberOfResults;
startIndex += numberOfResults;
// The max limit size for Subsonic is 500
fetchNextPage = numberOfResults === 500;
fetchNextPage = numberOfResults === MAX_SUBSONIC_ITEMS;
}
return totalRecordCount;
+2 -9
View File
@@ -190,15 +190,8 @@ export const App = () => {
}, [language]);
return (
<MantineProvider
defaultColorScheme={mode as 'dark' | 'light'}
theme={theme}
>
<Notifications
containerWidth="300px"
position="bottom-center"
zIndex={5}
/>
<MantineProvider defaultColorScheme={mode as 'dark' | 'light'} theme={theme}>
<Notifications containerWidth="300px" position="bottom-center" zIndex={50000} />
<PlayQueueHandlerContext.Provider value={providerValue}>
<ContextMenuProvider>
<WebAudioContext.Provider value={webAudioProvider}>
@@ -47,10 +47,7 @@ export const CardControls = ({
return (
<div className={styles.gridCardControlsContainer}>
<div className={styles.bottomControls}>
<button
className={styles.playButton}
onClick={handlePlay}
>
<button className={styles.playButton} onClick={handlePlay}>
<Icon icon="mediaPlay" />
</button>
<Group gap="xs">
+5 -20
View File
@@ -55,14 +55,8 @@ export const PosterCard = ({
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
>
<Link
className={styles.imageContainer}
to={path}
>
<Image
className={styles.image}
src={data?.imageUrl}
/>
<Link className={styles.imageContainer} to={path}>
<Image className={styles.image} src={data?.imageUrl} />
<GridCardControls
handleFavorite={controls.handleFavorite}
handlePlayQueueAdd={controls.handlePlayQueueAdd}
@@ -72,30 +66,21 @@ export const PosterCard = ({
/>
</Link>
<div className={styles.detailContainer}>
<CardRows
data={data}
rows={controls.cardRows}
/>
<CardRows data={data} rows={controls.cardRows} />
</div>
</div>
);
}
return (
<div
className={styles.container}
key={`placeholder-${uniqueId}-${data.id}`}
>
<div className={styles.container} key={`placeholder-${uniqueId}-${data.id}`}>
<div className={styles.imageContainer}>
<Skeleton className={styles.image} />
</div>
<div className={styles.detailContainer}>
<Stack gap="xs">
{(controls?.cardRows || []).map((row, index) => (
<Skeleton
height={14}
key={`${index}-${row.arrayProperty}`}
/>
<Skeleton height={14} key={`${index}-${row.arrayProperty}`} />
))}
</Stack>
</div>
@@ -35,14 +35,8 @@ export const ContextMenuButton = forwardRef(
onClick={props.onClick}
ref={ref}
>
<Group
justify="space-between"
w="100%"
>
<Group
className={styles.left}
gap="md"
>
<Group justify="space-between" w="100%">
<Group className={styles.left} gap="md">
{leftIcon}
{children}
</Group>
@@ -77,11 +77,7 @@ export const FeatureCarousel = ({ data }: FeatureCarouselProps) => {
className={styles.wrapper}
to={generatePath(AppRoute.LIBRARY_ALBUMS_DETAIL, { albumId: currentItem?.id || '' })}
>
<AnimatePresence
custom={direction}
initial={false}
mode="popLayout"
>
<AnimatePresence custom={direction} initial={false} mode="popLayout">
{data && (
<motion.div
animate="animate"
@@ -101,10 +97,7 @@ export const FeatureCarousel = ({ data }: FeatureCarouselProps) => {
/>
</div>
<div className={styles.infoColumn}>
<Stack
gap="md"
style={{ width: '100%' }}
>
<Stack gap="md" style={{ width: '100%' }}>
<div className={styles.titleWrapper}>
<TextTitle
fw={900}
@@ -117,10 +110,7 @@ export const FeatureCarousel = ({ data }: FeatureCarouselProps) => {
</div>
<div className={styles.titleWrapper}>
{currentItem?.albumArtists.slice(0, 1).map((artist) => (
<Text
fw={600}
key={`carousel-artist-${artist.id}`}
>
<Text fw={600} key={`carousel-artist-${artist.id}`}>
{artist.name}
</Text>
))}
@@ -60,10 +60,7 @@ const Title = ({ handleNext, handlePrev, label, pagination }: TitleProps) => {
{isValidElement(label) ? (
label
) : (
<TextTitle
order={3}
weight={700}
>
<TextTitle order={3} weight={700}>
{label}
</TextTitle>
)}
@@ -280,11 +277,7 @@ export const SwiperGridCarousel = ({
}, []);
return (
<Stack
className="grid-carousel"
gap="md"
ref={containerRef as any}
>
<Stack className="grid-carousel" gap="md" ref={containerRef as any}>
{title ? (
<Title
{...title}
@@ -91,11 +91,7 @@ export const NativeScrollArea = forwardRef(
{...pageHeaderProps}
/>
)}
<div
className={styles.scrollArea}
ref={mergedRef}
{...props}
>
<div className={styles.scrollArea} ref={mergedRef} {...props}>
{children}
</div>
</>
@@ -99,10 +99,7 @@ export const QueryBuilder = ({
};
return (
<Stack
gap="sm"
ml={`${level * 10}px`}
>
<Stack gap="sm" ml={`${level * 10}px`}>
<Group gap="sm">
<Select
data={FILTER_GROUP_OPTIONS_DATA}
@@ -112,12 +109,7 @@ export const QueryBuilder = ({
value={data.type}
width="20%"
/>
<ActionIcon
icon="add"
onClick={handleAddRule}
size="sm"
variant="subtle"
/>
<ActionIcon icon="add" onClick={handleAddRule} size="sm" variant="subtle" />
<DropdownMenu position="bottom-start">
<DropdownMenu.Target>
<ActionIcon
@@ -150,24 +142,14 @@ export const QueryBuilder = ({
<DropdownMenu.Divider />
<DropdownMenu.Item
isDanger
leftSection={
<Icon
color="error"
icon="refresh"
/>
}
leftSection={<Icon color="error" icon="refresh" />}
onClick={onResetFilters}
>
Reset to default
</DropdownMenu.Item>
<DropdownMenu.Item
isDanger
leftSection={
<Icon
color="error"
icon="delete"
/>
}
leftSection={<Icon color="error" icon="delete" />}
onClick={onClearFilters}
>
Clear filters
@@ -48,13 +48,7 @@ const QueryValueInput = ({ data, onChange, type, ...props }: any) => {
/>
);
case 'date':
return (
<TextInput
onChange={onChange}
size="sm"
{...props}
/>
);
return <TextInput onChange={onChange} size="sm" {...props} />;
case 'dateRange':
return (
<>
@@ -92,21 +86,9 @@ const QueryValueInput = ({ data, onChange, type, ...props }: any) => {
/>
);
case 'playlist':
return (
<Select
data={data}
onChange={onChange}
{...props}
/>
);
return <Select data={data} onChange={onChange} {...props} />;
case 'string':
return (
<TextInput
onChange={onChange}
size="sm"
{...props}
/>
);
return <TextInput onChange={onChange} size="sm" {...props} />;
default:
return <></>;
@@ -188,10 +170,7 @@ export const QueryBuilderOption = ({
const ml = (level + 1) * 10;
return (
<Group
gap="sm"
ml={ml}
>
<Group gap="sm" ml={ml}>
<Select
data={filters}
maxWidth={170}
@@ -81,10 +81,7 @@ export const DefaultCard = ({
data?.userFavorite && styles.isFavorite,
)}
>
<Image
className={styles.image}
src={data?.imageUrl}
/>
<Image className={styles.image} src={data?.imageUrl} />
<GridCardControls
handleFavorite={controls.handleFavorite}
handlePlayQueueAdd={controls.handlePlayQueueAdd}
@@ -95,10 +92,7 @@ export const DefaultCard = ({
/>
</div>
<div className={styles.detailContainer}>
<CardRows
data={data}
rows={controls.cardRows}
/>
<CardRows data={data} rows={controls.cardRows} />
</div>
</div>
</div>
@@ -86,10 +86,7 @@ export const GridCardControls = ({
onClick={handlePlay}
variant="filled"
>
<Icon
icon="mediaPlay"
size="xl"
/>
<Icon icon="mediaPlay" size="xl" />
</Button>
<div className={styles.bottomControls}>
{itemType !== LibraryItem.PLAYLIST && (
@@ -73,17 +73,11 @@ export const PosterCard = ({
margin: controls.itemGap,
}}
>
<div
className={styles.linkContainer}
onClick={() => navigate(path)}
>
<div className={styles.linkContainer} onClick={() => navigate(path)}>
<div
className={`${styles.imageContainer} ${data?.userFavorite ? styles.isFavorite : ''}`}
>
<Image
className={styles.image}
src={data?.imageUrl}
/>
<Image className={styles.image} src={data?.imageUrl} />
<GridCardControls
handleFavorite={controls.handleFavorite}
handlePlayQueueAdd={controls.handlePlayQueueAdd}
@@ -95,10 +89,7 @@ export const PosterCard = ({
</div>
</div>
<div className={styles.detailContainer}>
<CardRows
data={data}
rows={controls.cardRows}
/>
<CardRows data={data} rows={controls.cardRows} />
</div>
</div>
);
@@ -15,21 +15,14 @@ export const AlbumArtistCell = ({ data, value }: ICellRendererParams) => {
if (value === undefined) {
return (
<CellContainer position="left">
<Skeleton
height="1rem"
width="80%"
/>
<Skeleton height="1rem" width="80%" />
</CellContainer>
);
}
return (
<CellContainer position="left">
<Text
isMuted
overflow="hidden"
size="md"
>
<Text isMuted overflow="hidden" size="md">
{value?.map((item: AlbumArtist | Artist, index: number) => (
<React.Fragment key={`row-${item.id}-${data.uniqueId}`}>
{index > 0 && <Separator />}
@@ -47,11 +40,7 @@ export const AlbumArtistCell = ({ data, value }: ICellRendererParams) => {
{item.name || '—'}
</Text>
) : (
<Text
isMuted
overflow="hidden"
size="md"
>
<Text isMuted overflow="hidden" size="md">
{item.name || '—'}
</Text>
)}
@@ -15,21 +15,14 @@ export const ArtistCell = ({ data, value }: ICellRendererParams) => {
if (value === undefined) {
return (
<CellContainer position="left">
<Skeleton
height="1rem"
width="80%"
/>
<Skeleton height="1rem" width="80%" />
</CellContainer>
);
}
return (
<CellContainer position="left">
<Text
isMuted
overflow="hidden"
size="md"
>
<Text isMuted overflow="hidden" size="md">
{value?.map((item: AlbumArtist | Artist, index: number) => (
<React.Fragment key={`row-${item.id}-${data.uniqueId}`}>
{index > 0 && <Separator />}
@@ -47,11 +40,7 @@ export const ArtistCell = ({ data, value }: ICellRendererParams) => {
{item.name || '—'}
</Text>
) : (
<Text
isMuted
overflow="hidden"
size="md"
>
<Text isMuted overflow="hidden" size="md">
{item.name || '—'}
</Text>
)}
@@ -41,11 +41,7 @@ export const CombinedTitleCell = ({
>
<Skeleton className={styles.image} />
</div>
<Skeleton
className={styles.skeletonMetadata}
height="1rem"
width="80%"
/>
<Skeleton className={styles.skeletonMetadata} height="1rem" width="80%" />
</div>
);
}
@@ -62,11 +58,7 @@ export const CombinedTitleCell = ({
width: `${(node.rowHeight || 40) - 10}px`,
}}
>
<Image
alt="cover"
className={styles.image}
src={value.imageUrl}
/>
<Image alt="cover" className={styles.image} src={value.imageUrl} />
<ListCoverControls
className={styles.playButton}
@@ -77,18 +69,10 @@ export const CombinedTitleCell = ({
/>
</div>
<div className={styles.metadataWrapper}>
<Text
className="current-song-child"
overflow="hidden"
size="md"
>
<Text className="current-song-child" overflow="hidden" size="md">
{value.name}
</Text>
<Text
isMuted
overflow="hidden"
size="md"
>
<Text isMuted overflow="hidden" size="md">
{artists?.length ? (
artists.map((artist: AlbumArtist | Artist, index: number) => (
<React.Fragment key={`queue-${rowIndex}-artist-${artist.id}`}>
@@ -25,10 +25,7 @@ export const FullWidthDiscCell = ({ api, data, node }: ICellRendererParams) => {
return (
<div className={styles.container}>
<Group
justify="space-between"
w="100%"
>
<Group justify="space-between" w="100%">
<Button
leftSection={isSelected ? <Icon icon="squareCheck" /> : <Icon icon="square" />}
onClick={handleToggleDiscNodes}
@@ -23,10 +23,7 @@ export const GenericCell = ({ value, valueFormatted }: ICellRendererParams, opti
if (value === undefined) {
return (
<CellContainer position={position || 'left'}>
<Skeleton
height="1rem"
width="80%"
/>
<Skeleton height="1rem" width="80%" />
</CellContainer>
);
}
@@ -45,12 +42,7 @@ export const GenericCell = ({ value, valueFormatted }: ICellRendererParams, opti
{isLink ? displayedValue.value : displayedValue}
</Text>
) : (
<Text
isMuted={!primary}
isNoSelect={false}
overflow="hidden"
size="md"
>
<Text isMuted={!primary} isNoSelect={false} overflow="hidden" size="md">
{displayedValue}
</Text>
)}
@@ -13,11 +13,7 @@ export const GenreCell = ({ data, value }: ICellRendererParams) => {
const genrePath = useGenreRoute();
return (
<CellContainer position="left">
<Text
isMuted
overflow="hidden"
size="md"
>
<Text isMuted overflow="hidden" size="md">
{value?.map((item: AlbumArtist | Artist, index: number) => (
<React.Fragment key={`row-${item.id}-${data.uniqueId}`}>
{index > 0 && <Separator />}
@@ -19,20 +19,14 @@ export const NoteCell = ({ value }: ICellRendererParams) => {
if (value === undefined) {
return (
<CellContainer position="left">
<Skeleton
height="1rem"
width="80%"
/>
<Skeleton height="1rem" width="80%" />
</CellContainer>
);
}
return (
<CellContainer position="left">
<Text
isMuted
overflow="hidden"
>
<Text isMuted overflow="hidden">
{formattedValue}
</Text>
</CellContainer>
@@ -26,11 +26,7 @@ export const RatingCell = ({ node, value }: ICellRendererParams) => {
return (
<CellContainer position="center">
<Rating
onChange={handleUpdateRating}
size="xs"
value={value?.userRating}
/>
<Rating onChange={handleUpdateRating} size="xs" value={value?.userRating} />
</CellContainer>
);
};
@@ -144,15 +144,9 @@ export const RowIndexCell = ({ eGridCell, value }: ICellRendererParams) => {
return (
<CellContainer position="right">
{isPlaying && isCurrentSong ? (
<Icon
fill="primary"
icon="mediaPlay"
/>
<Icon fill="primary" icon="mediaPlay" />
) : isCurrentSong ? (
<Icon
fill="primary"
icon="mediaPause"
/>
<Icon fill="primary" icon="mediaPause" />
) : (
<Text
className="current-song-child current-song-index"
@@ -8,21 +8,14 @@ export const TitleCell = ({ value }: ICellRendererParams) => {
if (value === undefined) {
return (
<CellContainer position="left">
<Skeleton
height="1rem"
width="80%"
/>
<Skeleton height="1rem" width="80%" />
</CellContainer>
);
}
return (
<CellContainer position="left">
<Text
className="current-song-child"
overflow="hidden"
size="md"
>
<Text className="current-song-child" overflow="hidden" size="md">
{value}
</Text>
</CellContainer>
@@ -7,10 +7,5 @@ export interface ICustomHeaderParams extends IHeaderParams {
}
export const DurationHeader = () => {
return (
<Icon
icon="duration"
size="sm"
/>
);
return <Icon icon="duration" size="sm" />;
};
@@ -16,36 +16,11 @@ type Options = {
type Presets = 'actions' | 'duration' | 'rowIndex' | 'userFavorite' | 'userRating';
const headerPresets = {
actions: (
<Icon
icon="ellipsisHorizontal"
size="sm"
/>
),
duration: (
<Icon
icon="duration"
size="sm"
/>
),
rowIndex: (
<Icon
icon="hash"
size="sm"
/>
),
userFavorite: (
<Icon
icon="favorite"
size="sm"
/>
),
userRating: (
<Icon
icon="star"
size="sm"
/>
),
actions: <Icon icon="ellipsisHorizontal" size="sm" />,
duration: <Icon icon="duration" size="sm" />,
rowIndex: <Icon icon="hash" size="sm" />,
userFavorite: <Icon icon="favorite" size="sm" />,
userRating: <Icon icon="star" size="sm" />,
};
export const GenericTableHeader = (
@@ -635,15 +635,8 @@ export const VirtualTable = forwardRef(
onNewColumnsLoaded={handleNewColumnsLoaded}
/>
{paginationProps && (
<AnimatePresence
initial={false}
mode="wait"
presenceAffectsLayout
>
<TablePagination
{...paginationProps}
tableRef={tableRef}
/>
<AnimatePresence initial={false} mode="wait" presenceAffectsLayout>
<TablePagination {...paginationProps} tableRef={tableRef} />
</AnimatePresence>
)}
</div>
@@ -76,10 +76,7 @@ export const TablePagination = ({
ref={containerQuery.ref}
style={{ borderTop: '1px solid var(--theme-generic-border-color)' }}
>
<Text
isMuted
size="md"
>
<Text isMuted size="md">
{containerQuery.isMd ? (
<>
Showing <b>{currentPageStartIndex}</b> - <b>{currentPageStopIndex}</b> of{' '}
@@ -97,11 +94,7 @@ export const TablePagination = ({
</>
)}
</Text>
<Group
gap="sm"
ref={containerQuery.ref}
wrap="nowrap"
>
<Group gap="sm" ref={containerQuery.ref} wrap="nowrap">
<Popover
onClose={() => handlers.close()}
opened={isGoToPageOpen}
@@ -127,10 +120,7 @@ export const TablePagination = ({
min={1}
width={70}
/>
<Button
type="submit"
variant="filled"
>
<Button type="submit" variant="filled">
Go
</Button>
</Group>
@@ -13,15 +13,8 @@ interface ActionRequiredContainerProps {
export const ActionRequiredContainer = ({ children, title }: ActionRequiredContainerProps) => (
<Stack style={{ cursor: 'default', maxWidth: '700px' }}>
<Group>
<Icon
fill="warn"
icon="warn"
size="lg"
/>
<Text
size="xl"
style={{ textTransform: 'uppercase' }}
>
<Icon fill="warn" icon="warn" size="lg" />
<Text size="xl" style={{ textTransform: 'uppercase' }}>
{title}
</Text>
</Group>
@@ -21,18 +21,11 @@ export const ErrorFallback = ({ resetErrorBoundary }: FallbackProps) => {
<Center style={{ height: '100vh' }}>
<Stack style={{ maxWidth: '50%' }}>
<Group gap="xs">
<Icon
fill="error"
icon="error"
size="lg"
/>
<Icon fill="error" icon="error" size="lg" />
<Text size="lg">{t('error.genericError')}</Text>
</Group>
<Text>{error?.message}</Text>
<Button
onClick={resetErrorBoundary}
variant="filled"
>
<Button onClick={resetErrorBoundary} variant="filled">
{t('common.reload')}
</Button>
</Stack>
@@ -43,18 +43,11 @@ export const MpvRequired = () => {
<Text>Set your MPV executable location below and restart the application.</Text>
<Text>
MPV is available at the following:{' '}
<a
href="https://mpv.io/installation/"
rel="noreferrer"
target="_blank"
>
<a href="https://mpv.io/installation/" rel="noreferrer" target="_blank">
https://mpv.io/
</a>
</Text>
<FileInput
disabled={disabled}
onChange={handleSetMpvPath}
/>
<FileInput disabled={disabled} onChange={handleSetMpvPath} />
<Text>{t('setting.disable_mpv', { context: 'description' })}</Text>
<Checkbox
label={t('setting.disableMpv')}
@@ -42,19 +42,12 @@ const RouteErrorBoundary = () => {
px={10}
variant="subtle"
/>
<Icon
fill="error"
icon="error"
size="lg"
/>
<Icon fill="error" icon="error" size="lg" />
<Text size="lg">{t('error.genericError')}</Text>
</Group>
<Divider my={5} />
<Text size="sm">{error?.message}</Text>
<Group
gap="sm"
grow
>
<Group gap="sm" grow>
<Button
leftSection={<Icon icon="home" />}
onClick={handleHome}
@@ -81,11 +74,7 @@ const RouteErrorBoundary = () => {
</DropdownMenu>
</Group>
<Group grow>
<Button
onClick={handleReload}
size="md"
variant="filled"
>
<Button onClick={handleReload} size="md" variant="filled">
{t('common.reload')}
</Button>
</Group>
@@ -132,10 +132,7 @@ function ServerSelector() {
}}
variant={server.id === currentServer?.id ? 'filled' : 'default'}
>
<Group
justify="space-between"
w="100%"
>
<Group justify="space-between" w="100%">
<Group>
<img
src={logo}
@@ -144,10 +141,7 @@ function ServerSelector() {
width: 'var(--theme-font-size-2xl)',
}}
/>
<Text
fw={600}
size="lg"
>
<Text fw={600} size="lg">
{server.name}
</Text>
</Group>
@@ -49,10 +49,7 @@ const ActionRequiredRoute = () => {
<AnimatedPage>
<PageHeader />
<Center style={{ height: '100%', width: '100vw' }}>
<Stack
gap="xl"
style={{ maxWidth: '50%' }}
>
<Stack gap="xl" style={{ maxWidth: '50%' }}>
<Group wrap="nowrap">
{displayedCheck && (
<ActionRequiredContainer title={displayedCheck.title}>
@@ -64,10 +61,7 @@ const ActionRequiredRoute = () => {
{canReturnHome && <Navigate to={AppRoute.HOME} />}
{/* This should be displayed if a credential is required */}
{isCredentialRequired && (
<Group
justify="center"
wrap="nowrap"
>
<Group justify="center" wrap="nowrap">
<Button
fullWidth
leftSection={<Icon icon="edit" />}
@@ -18,24 +18,14 @@ const InvalidRoute = () => {
<AnimatedPage>
<Center style={{ height: '100%', width: '100%' }}>
<Stack>
<Group
justify="center"
wrap="nowrap"
>
<Icon
color="warn"
icon="error"
/>
<Group justify="center" wrap="nowrap">
<Icon color="warn" icon="error" />
<Text size="xl">
{t('error.apiRouteError', { postProcess: 'sentenceCase' })}
</Text>
</Group>
<Text>{location.pathname}</Text>
<ActionIcon
icon="arrowLeftS"
onClick={() => navigate(-1)}
variant="filled"
/>
<ActionIcon icon="arrowLeftS" onClick={() => navigate(-1)} variant="filled" />
</Stack>
</Center>
</AnimatedPage>
@@ -319,17 +319,11 @@ export const AlbumDetailContent = ({ background, tableRef }: AlbumDetailContentP
const mbzId = detailQuery?.data?.mbzId;
return (
<div
className={styles.contentContainer}
ref={cq.ref}
>
<div className={styles.contentContainer} ref={cq.ref}>
<LibraryBackgroundOverlay backgroundColor={background} />
<div className={styles.detailContainer}>
<section>
<Group
gap="sm"
justify="space-between"
>
<Group gap="sm" justify="space-between">
<Group>
<PlayButton onClick={() => handlePlay(playButtonBehavior)} />
<Group gap="xs">
@@ -485,11 +479,7 @@ export const AlbumDetailContent = ({ background, tableRef }: AlbumDetailContentP
suppressRowDrag
/>
</div>
<Stack
gap="lg"
mt="3rem"
ref={cq.ref}
>
<Stack gap="lg" mt="3rem" ref={cq.ref}>
{cq.height || cq.width ? (
<>
{carousels
@@ -33,15 +33,9 @@ export const AlbumListContent = ({ gridRef, itemCount, tableRef }: AlbumListCont
return (
<Suspense fallback={<Spinner container />}>
{display === ListDisplayType.CARD || display === ListDisplayType.GRID ? (
<AlbumListGridView
gridRef={gridRef}
itemCount={itemCount}
/>
<AlbumListGridView gridRef={gridRef} itemCount={itemCount} />
) : (
<AlbumListTableView
itemCount={itemCount}
tableRef={tableRef}
/>
<AlbumListTableView itemCount={itemCount} tableRef={tableRef} />
)}
</Suspense>
);
@@ -448,11 +448,7 @@ export const AlbumListHeaderFilters = ({
return (
<Flex justify="space-between">
<Group
gap="sm"
ref={cq.ref}
w="100%"
>
<Group gap="sm" ref={cq.ref} w="100%">
<DropdownMenu position="bottom-start">
<DropdownMenu.Target>
<Button variant="subtle">{sortByLabel}</Button>
@@ -471,10 +467,7 @@ export const AlbumListHeaderFilters = ({
</DropdownMenu.Dropdown>
</DropdownMenu>
<Divider orientation="vertical" />
<OrderToggleButton
onToggle={handleToggleSortOrder}
sortOrder={filter.sortOrder}
/>
<OrderToggleButton onToggle={handleToggleSortOrder} sortOrder={filter.sortOrder} />
{server?.type === ServerType.JELLYFIN && (
<>
<Divider orientation="vertical" />
@@ -497,10 +490,7 @@ export const AlbumListHeaderFilters = ({
</DropdownMenu>
</>
)}
<FilterButton
isActive={!!isFilterApplied}
onClick={handleOpenFiltersModal}
/>
<FilterButton isActive={!!isFilterApplied} onClick={handleOpenFiltersModal} />
<RefreshButton onClick={handleRefresh} />
<DropdownMenu position="bottom-start">
<DropdownMenu.Target>
@@ -535,10 +525,7 @@ export const AlbumListHeaderFilters = ({
</DropdownMenu.Dropdown>
</DropdownMenu>
</Group>
<Group
gap="sm"
wrap="nowrap"
>
<Group gap="sm" wrap="nowrap">
<ListConfigMenu
autoFitColumns={table.autoFit}
disabledViewTypes={[ListDisplayType.LIST]}
@@ -61,15 +61,9 @@ export const AlbumListHeader = ({
}, [filter, genreId, refresh, tableRef]);
return (
<Stack
gap={0}
ref={cq.ref}
>
<Stack gap={0} ref={cq.ref}>
<PageHeader backgroundColor="var(--theme-colors-background)">
<Flex
justify="space-between"
w="100%"
>
<Flex justify="space-between" w="100%">
<LibraryHeaderBar>
<LibraryHeaderBar.PlayButton
onClick={() => handlePlay?.({ playType: playButtonBehavior })}
@@ -85,10 +79,7 @@ export const AlbumListHeader = ({
</LibraryHeaderBar.Badge>
</LibraryHeaderBar>
<Group>
<SearchInput
defaultValue={filter.searchTerm}
onChange={handleSearch}
/>
<SearchInput defaultValue={filter.searchTerm} onChange={handleSearch} />
</Group>
</Flex>
</PageHeader>
@@ -227,16 +227,9 @@ export const JellyfinAlbumFilters = ({
return (
<Stack p="0.8rem">
{yesNoFilter.map((filter) => (
<Group
justify="space-between"
key={`nd-filter-${filter.label}`}
>
<Group justify="space-between" key={`nd-filter-${filter.label}`}>
<Text>{filter.label}</Text>
<YesNoSelect
onChange={filter.onChange}
size="xs"
value={filter.value}
/>
<YesNoSelect onChange={filter.onChange} size="xs" value={filter.value} />
</Group>
))}
<Divider my="0.5rem" />
@@ -248,28 +248,15 @@ export const NavidromeAlbumFilters = ({
return (
<Stack p="0.8rem">
{yesNoUndefinedFilters.map((filter) => (
<Group
justify="space-between"
key={`nd-filter-${filter.label}`}
>
<Group justify="space-between" key={`nd-filter-${filter.label}`}>
<Text>{filter.label}</Text>
<YesNoSelect
onChange={filter.onChange}
size="xs"
value={filter.value}
/>
<YesNoSelect onChange={filter.onChange} size="xs" value={filter.value} />
</Group>
))}
{toggleFilters.map((filter) => (
<Group
justify="space-between"
key={`nd-filter-${filter.label}`}
>
<Group justify="space-between" key={`nd-filter-${filter.label}`}>
<Text>{filter.label}</Text>
<Switch
checked={filter?.value || false}
onChange={filter.onChange}
/>
<Switch checked={filter?.value || false} onChange={filter.onChange} />
</Group>
))}
<Divider my="0.5rem" />
@@ -307,10 +294,7 @@ export const NavidromeAlbumFilters = ({
{tagsQuery.data?.enumTags?.length &&
tagsQuery.data.enumTags.length > 0 &&
tagsQuery.data.enumTags.map((tag) => (
<Group
grow
key={tag.name}
>
<Group grow key={tag.name}>
<SelectWithInvalidData
clearable
data={tag.options}
@@ -148,15 +148,9 @@ export const SubsonicAlbumFilters = ({
return (
<Stack p="0.8rem">
{toggleFilters.map((filter) => (
<Group
justify="space-between"
key={`nd-filter-${filter.label}`}
>
<Group justify="space-between" key={`nd-filter-${filter.label}`}>
<Text>{filter.label}</Text>
<Switch
checked={filter?.value || false}
onChange={filter.onChange}
/>
<Switch checked={filter?.value || false} onChange={filter.onChange} />
</Group>
))}
<Divider my="0.5rem" />
@@ -70,10 +70,7 @@ const AlbumDetailRoute = () => {
}}
ref={headerRef}
/>
<AlbumDetailContent
background={background}
tableRef={tableRef}
/>
<AlbumDetailContent background={background} tableRef={tableRef} />
</NativeScrollArea>
</AnimatedPage>
);
@@ -144,11 +144,7 @@ const AlbumListRoute = () => {
tableRef={tableRef}
title={title}
/>
<AlbumListContent
gridRef={gridRef}
itemCount={itemCount}
tableRef={tableRef}
/>
<AlbumListContent gridRef={gridRef} itemCount={itemCount} tableRef={tableRef} />
</ListContext.Provider>
</AnimatedPage>
);
@@ -174,10 +174,7 @@ const DummyAlbumDetailRoute = () => {
</Stack>
<div className={styles.detailContainer}>
<section>
<Group
gap="sm"
justify="space-between"
>
<Group gap="sm" justify="space-between">
<Group>
<PlayButton onClick={() => handlePlay()} />
<ActionIcon
@@ -231,11 +228,7 @@ const DummyAlbumDetailRoute = () => {
<section>
<Center>
<Group mr={5}>
<Icon
fill="error"
icon="error"
size={30}
/>
<Icon fill="error" icon="error" size={30} />
</Group>
<h2>{t('error.badAlbum', { postProcess: 'sentenceCase' })}</h2>
</Center>
@@ -202,10 +202,7 @@ export const AlbumArtistDetailContent = ({ background }: AlbumArtistDetailConten
order: itemOrder.recentAlbums,
title: (
<Group align="flex-end">
<TextTitle
fw={700}
order={2}
>
<TextTitle fw={700} order={2}>
{t('page.albumArtistDetail.recentReleases', {
postProcess: 'sentenceCase',
})}
@@ -232,10 +229,7 @@ export const AlbumArtistDetailContent = ({ background }: AlbumArtistDetailConten
loading: compilationAlbumsQuery?.isLoading || compilationAlbumsQuery.isFetching,
order: itemOrder.compilations,
title: (
<TextTitle
fw={700}
order={2}
>
<TextTitle fw={700} order={2}>
{t('page.albumArtistDetail.appearsOn', { postProcess: 'sentenceCase' })}
</TextTitle>
),
@@ -247,10 +241,7 @@ export const AlbumArtistDetailContent = ({ background }: AlbumArtistDetailConten
itemType: LibraryItem.ALBUM_ARTIST,
order: itemOrder.similarArtists,
title: (
<TextTitle
fw={700}
order={2}
>
<TextTitle fw={700} order={2}>
{t('page.albumArtistDetail.relatedArtists', {
postProcess: 'sentenceCase',
})}
@@ -355,19 +346,10 @@ export const AlbumArtistDetailContent = ({ background }: AlbumArtistDetailConten
detailQuery?.isLoading ||
(server?.type === ServerType.NAVIDROME && enabledItem.topSongs && topSongsQuery?.isLoading);
if (isLoading)
return (
<div
className={styles.contentContainer}
ref={cq.ref}
/>
);
if (isLoading) return <div className={styles.contentContainer} ref={cq.ref} />;
return (
<div
className={styles.contentContainer}
ref={cq.ref}
>
<div className={styles.contentContainer} ref={cq.ref}>
<LibraryBackgroundOverlay backgroundColor={background} />
<div className={styles.detailContainer}>
<Group gap="md">
@@ -481,15 +463,9 @@ export const AlbumArtistDetailContent = ({ background }: AlbumArtistDetailConten
) : null}
<Grid gutter="xl">
{biography ? (
<Grid.Col
order={itemOrder.biography}
span={12}
>
<Grid.Col order={itemOrder.biography} span={12}>
<section style={{ maxWidth: '1280px' }}>
<TextTitle
fw={700}
order={2}
>
<TextTitle fw={700} order={2}>
{t('page.albumArtistDetail.about', {
artist: detailQuery?.data?.name,
})}
@@ -499,23 +475,11 @@ export const AlbumArtistDetailContent = ({ background }: AlbumArtistDetailConten
</Grid.Col>
) : null}
{showTopSongs ? (
<Grid.Col
order={itemOrder.topSongs}
span={12}
>
<Grid.Col order={itemOrder.topSongs} span={12}>
<section>
<Group
justify="space-between"
wrap="nowrap"
>
<Group
align="flex-end"
wrap="nowrap"
>
<TextTitle
fw={700}
order={2}
>
<Group justify="space-between" wrap="nowrap">
<Group align="flex-end" wrap="nowrap">
<TextTitle fw={700} order={2}>
{t('page.albumArtistDetail.topSongs', {
postProcess: 'sentenceCase',
})}
@@ -42,15 +42,9 @@ export const AlbumArtistListContent = ({
return (
<Suspense fallback={<Spinner container />}>
{isGrid ? (
<AlbumArtistListGridView
gridRef={gridRef}
itemCount={itemCount}
/>
<AlbumArtistListGridView gridRef={gridRef} itemCount={itemCount} />
) : (
<AlbumArtistListTableView
itemCount={itemCount}
tableRef={tableRef}
/>
<AlbumArtistListTableView itemCount={itemCount} tableRef={tableRef} />
)}
</Suspense>
);
@@ -372,11 +372,7 @@ export const AlbumArtistListHeaderFilters = ({
return (
<Flex justify="space-between">
<Group
gap="sm"
ref={cq.ref}
w="100%"
>
<Group gap="sm" ref={cq.ref} w="100%">
<DropdownMenu position="bottom-start">
<DropdownMenu.Target>
<Button variant="subtle">{sortByLabel}</Button>
@@ -395,10 +391,7 @@ export const AlbumArtistListHeaderFilters = ({
</DropdownMenu.Dropdown>
</DropdownMenu>
<Divider orientation="vertical" />
<OrderToggleButton
onToggle={handleToggleSortOrder}
sortOrder={filter.sortOrder}
/>
<OrderToggleButton onToggle={handleToggleSortOrder} sortOrder={filter.sortOrder} />
{server?.type === ServerType.JELLYFIN && (
<>
<DropdownMenu position="bottom-start">
@@ -437,10 +430,7 @@ export const AlbumArtistListHeaderFilters = ({
</DropdownMenu.Dropdown>
</DropdownMenu>
</Group>
<Group
gap="sm"
wrap="nowrap"
>
<Group gap="sm" wrap="nowrap">
<ListConfigMenu
autoFitColumns={table.autoFit}
disabledViewTypes={[ListDisplayType.LIST]}
@@ -46,15 +46,9 @@ export const AlbumArtistListHeader = ({
}, 500);
return (
<Stack
gap={0}
ref={cq.ref}
>
<Stack gap={0} ref={cq.ref}>
<PageHeader>
<Flex
justify="space-between"
w="100%"
>
<Flex justify="space-between" w="100%">
<LibraryHeaderBar>
<LibraryHeaderBar.Title>
{t('page.albumArtistList.title', { postProcess: 'titleCase' })}
@@ -66,18 +60,12 @@ export const AlbumArtistListHeader = ({
</LibraryHeaderBar.Badge>
</LibraryHeaderBar>
<Group>
<SearchInput
defaultValue={filter.searchTerm}
onChange={handleSearch}
/>
<SearchInput defaultValue={filter.searchTerm} onChange={handleSearch} />
</Group>
</Flex>
</PageHeader>
<FilterBar>
<AlbumArtistListHeaderFilters
gridRef={gridRef}
tableRef={tableRef}
/>
<AlbumArtistListHeaderFilters gridRef={gridRef} tableRef={tableRef} />
</FilterBar>
</Stack>
);
@@ -34,15 +34,9 @@ export const ArtistListContent = ({ gridRef, itemCount, tableRef }: ArtistListCo
return (
<Suspense fallback={<Spinner container />}>
{isGrid ? (
<ArtistListGridView
gridRef={gridRef}
itemCount={itemCount}
/>
<ArtistListGridView gridRef={gridRef} itemCount={itemCount} />
) : (
<ArtistListTableView
itemCount={itemCount}
tableRef={tableRef}
/>
<ArtistListTableView itemCount={itemCount} tableRef={tableRef} />
)}
</Suspense>
);
@@ -388,11 +388,7 @@ export const ArtistListHeaderFilters = ({ gridRef, tableRef }: ArtistListHeaderF
return (
<Flex justify="space-between">
<Group
gap="sm"
ref={cq.ref}
w="100%"
>
<Group gap="sm" ref={cq.ref} w="100%">
<DropdownMenu position="bottom-start">
<DropdownMenu.Target>
<Button variant="subtle">{sortByLabel}</Button>
@@ -411,19 +407,13 @@ export const ArtistListHeaderFilters = ({ gridRef, tableRef }: ArtistListHeaderF
</DropdownMenu.Dropdown>
</DropdownMenu>
<Divider orientation="vertical" />
<OrderToggleButton
onToggle={handleToggleSortOrder}
sortOrder={filter.sortOrder}
/>
<OrderToggleButton onToggle={handleToggleSortOrder} sortOrder={filter.sortOrder} />
{server?.type === ServerType.JELLYFIN && (
<>
<Divider orientation="vertical" />
<DropdownMenu position="bottom-start">
<DropdownMenu.Target>
<ActionIcon
icon="folder"
variant="subtle"
/>
<ActionIcon icon="folder" variant="subtle" />
</DropdownMenu.Target>
<DropdownMenu.Dropdown>
{musicFoldersQuery.data?.items.map((folder) => (
@@ -442,11 +432,7 @@ export const ArtistListHeaderFilters = ({ gridRef, tableRef }: ArtistListHeaderF
)}
{roles.data?.length && (
<>
<Select
data={roles.data}
onChange={handleSetRole}
value={filter.role}
/>
<Select data={roles.data} onChange={handleSetRole} value={filter.role} />
</>
)}
<RefreshButton onClick={handleRefresh} />
@@ -466,10 +452,7 @@ export const ArtistListHeaderFilters = ({ gridRef, tableRef }: ArtistListHeaderF
</DropdownMenu.Dropdown>
</DropdownMenu>
</Group>
<Group
gap="xs"
wrap="nowrap"
>
<Group gap="xs" wrap="nowrap">
<ListConfigMenu
autoFitColumns={table.autoFit}
displayType={display}
@@ -42,15 +42,9 @@ export const ArtistListHeader = ({ gridRef, itemCount, tableRef }: ArtistListHea
}, 500);
return (
<Stack
gap={0}
ref={cq.ref}
>
<Stack gap={0} ref={cq.ref}>
<PageHeader>
<Flex
justify="space-between"
w="100%"
>
<Flex justify="space-between" w="100%">
<LibraryHeaderBar>
<LibraryHeaderBar.Title>
{t('entity.artist_other', { postProcess: 'titleCase' })}
@@ -62,18 +56,12 @@ export const ArtistListHeader = ({ gridRef, itemCount, tableRef }: ArtistListHea
</LibraryHeaderBar.Badge>
</LibraryHeaderBar>
<Group>
<SearchInput
defaultValue={filter.searchTerm}
onChange={handleSearch}
/>
<SearchInput defaultValue={filter.searchTerm} onChange={handleSearch} />
</Group>
</Flex>
</PageHeader>
<FilterBar>
<ArtistListHeaderFilters
gridRef={gridRef}
tableRef={tableRef}
/>
<ArtistListHeaderFilters gridRef={gridRef} tableRef={tableRef} />
</FilterBar>
</Stack>
);
@@ -41,16 +41,8 @@ const ArtistListRoute = () => {
return (
<AnimatedPage>
<ListContext.Provider value={providerValue}>
<ArtistListHeader
gridRef={gridRef}
itemCount={itemCount}
tableRef={tableRef}
/>
<ArtistListContent
gridRef={gridRef}
itemCount={itemCount}
tableRef={tableRef}
/>
<ArtistListHeader gridRef={gridRef} itemCount={itemCount} tableRef={tableRef} />
<ArtistListContent gridRef={gridRef} itemCount={itemCount} tableRef={tableRef} />
</ListContext.Provider>
</AnimatedPage>
);
@@ -533,10 +533,7 @@ export const ContextMenuProvider = ({ children }: ContextMenuProviderProps) => {
openModal({
children: (
<ConfirmModal
loading={removeFromPlaylistMutation.isLoading}
onConfirm={confirm}
>
<ConfirmModal loading={removeFromPlaylistMutation.isLoading} onConfirm={confirm}>
{t('common.areYouSure', { postProcess: 'sentenceCase' })}
</ConfirmModal>
),
@@ -922,26 +919,15 @@ export const ContextMenuProvider = ({ children }: ContextMenuProviderProps) => {
<Portal>
<AnimatePresence>
{opened && (
<ContextMenu
minWidth={125}
ref={mergedRef}
xPos={ctx.xPos}
yPos={ctx.yPos}
>
<ContextMenu minWidth={125} ref={mergedRef} xPos={ctx.xPos} yPos={ctx.yPos}>
<Stack gap={0}>
<Stack
gap={0}
onClick={closeContextMenu}
>
<Stack gap={0} onClick={closeContextMenu}>
{ctx.menuItems?.map((item) => {
return (
!contextMenuItems[item.id].disabled && (
<Fragment key={`context-menu-${item.id}`}>
{item.children ? (
<HoverCard
offset={0}
position="right"
>
<HoverCard offset={0} position="right">
<HoverCard.Target>
<ContextMenuButton
leftIcon={
@@ -26,10 +26,8 @@ export const useDiscordRpc = () => {
) => {
if (
!current[0] || // No track
(current[0] &&
current[2] === 'paused' && // Track paused
(discordSettings.showPaused ? current[1] === 0 : true)) || // Beginning of track (only if show paused setting enabled)
(discordSettings.showPaused ? false : current[1] === 0) // Beginning of track (only if show paused setting disabled)
current[1] === 0 || // Start of track
(current[2] === 'paused' && !discordSettings.showPaused) // Track paused with show paused setting disabled
)
return discordRpc?.clearActivity();
@@ -38,11 +36,13 @@ export const useDiscordRpc = () => {
const trackChanged = lastUniqueId !== song.uniqueId;
/*
1. If we jump more then 1.2 seconds from last state, update status to match
2. If the current song id is completely different, update status
3. If the player state changed, update status
1. If the song has just started, update status
2. If we jump more then 1.2 seconds from last state, update status to match
3. If the current song id is completely different, update status
4. If the player state changed, update status
*/
if (
previous[1] === 0 ||
Math.abs((current[1] as number) - (previous[1] as number)) > 1.2 ||
trackChanged ||
current[2] !== previous[2]
@@ -33,15 +33,9 @@ export const GenreListContent = ({ gridRef, itemCount, tableRef }: AlbumListCont
return (
<Suspense fallback={<Spinner container />}>
{display === ListDisplayType.CARD || display === ListDisplayType.GRID ? (
<GenreListGridView
gridRef={gridRef}
itemCount={itemCount}
/>
<GenreListGridView gridRef={gridRef} itemCount={itemCount} />
) : (
<GenreListTableView
itemCount={itemCount}
tableRef={tableRef}
/>
<GenreListTableView itemCount={itemCount} tableRef={tableRef} />
)}
</Suspense>
);
@@ -254,11 +254,7 @@ export const GenreListHeaderFilters = ({
return (
<Flex justify="space-between">
<Group
gap="sm"
ref={cq.ref}
w="100%"
>
<Group gap="sm" ref={cq.ref} w="100%">
<DropdownMenu position="bottom-start">
<DropdownMenu.Target>
<Button variant="subtle">{sortByLabel}</Button>
@@ -277,10 +273,7 @@ export const GenreListHeaderFilters = ({
</DropdownMenu.Dropdown>
</DropdownMenu>
<Divider orientation="vertical" />
<OrderToggleButton
onToggle={handleToggleSortOrder}
sortOrder={filter.sortOrder}
/>
<OrderToggleButton onToggle={handleToggleSortOrder} sortOrder={filter.sortOrder} />
{server?.type === ServerType.JELLYFIN && (
<>
<Divider orientation="vertical" />
@@ -340,10 +333,7 @@ export const GenreListHeaderFilters = ({
</Button>
</DropdownMenu>
</Group>
<Group
gap="sm"
wrap="nowrap"
>
<Group gap="sm" wrap="nowrap">
<ListConfigMenu
autoFitColumns={table.autoFit}
disabledViewTypes={[ListDisplayType.LIST]}
@@ -40,15 +40,9 @@ export const GenreListHeader = ({ gridRef, itemCount, tableRef }: GenreListHeade
}, 500);
return (
<Stack
gap={0}
ref={cq.ref}
>
<Stack gap={0} ref={cq.ref}>
<PageHeader>
<Flex
justify="space-between"
w="100%"
>
<Flex justify="space-between" w="100%">
<LibraryHeaderBar>
<LibraryHeaderBar.Title>
{t('page.genreList.title', { postProcess: 'titleCase' })}
@@ -60,10 +54,7 @@ export const GenreListHeader = ({ gridRef, itemCount, tableRef }: GenreListHeade
</LibraryHeaderBar.Badge>
</LibraryHeaderBar>
<Group>
<SearchInput
defaultValue={filter.searchTerm}
onChange={handleSearch}
/>
<SearchInput defaultValue={filter.searchTerm} onChange={handleSearch} />
</Group>
</Flex>
</PageHeader>
@@ -42,16 +42,8 @@ const GenreListRoute = () => {
return (
<AnimatedPage>
<ListContext.Provider value={providerValue}>
<GenreListHeader
gridRef={gridRef}
itemCount={itemCount}
tableRef={tableRef}
/>
<GenreListContent
gridRef={gridRef}
itemCount={itemCount}
tableRef={tableRef}
/>
<GenreListHeader gridRef={gridRef} itemCount={itemCount} tableRef={tableRef} />
<GenreListContent gridRef={gridRef} itemCount={itemCount} tableRef={tableRef} />
</ListContext.Provider>
</AnimatedPage>
);
@@ -81,10 +81,7 @@ const formatArtists = (artists: null | RelatedArtist[] | undefined) =>
{artist.name || '—'}
</Text>
) : (
<Text
overflow="visible"
size="md"
>
<Text overflow="visible" size="md">
{artist.name || '-'}
</Text>
)}
@@ -119,17 +116,7 @@ const FormatGenre = (item: Album | AlbumArtist | Playlist | Song) => {
};
const BoolField = (key: boolean) =>
key ? (
<Icon
color="success"
icon="check"
/>
) : (
<Icon
color="error"
icon="x"
/>
);
key ? <Icon color="success" icon="check" /> : <Icon color="error" icon="x" />;
const AlbumPropertyMapping: ItemDetailRow<Album>[] = [
{ key: 'name', label: 'common.title' },
@@ -287,6 +274,8 @@ const SongPropertyMapping: ItemDetailRow<Song>[] = [
{ label: 'filter.isCompilation', render: (song) => BoolField(song.compilation || false) },
{ key: 'container', label: 'common.codec' },
{ key: 'bitRate', label: 'common.bitrate', render: (song) => `${song.bitRate} kbps` },
{ key: 'sampleRate', label: 'common.sampleRate' },
{ key: 'bitDepth', label: 'common.bitDepth' },
{ key: 'channels', label: 'common.channel_other' },
{ key: 'size', label: 'common.size', render: (song) => formatSizeString(song.size) },
{
@@ -409,12 +398,7 @@ export const ItemDetailsModal = ({ item }: ItemDetailsModalProps) => {
}
return (
<Table
highlightOnHover
variant="vertical"
withRowBorders={false}
withTableBorder
>
<Table highlightOnHover variant="vertical" withRowBorders={false} withTableBorder>
<Table.Tbody>{body}</Table.Tbody>
</Table>
);
@@ -22,10 +22,7 @@ export const SongPath = ({ path }: SongPathProps) => {
return (
<Group>
<CopyButton
timeout={2000}
value={path}
>
<CopyButton timeout={2000} value={path}>
{({ copied, copy }) => (
<Tooltip
label={t(
@@ -36,10 +33,7 @@ export const SongPath = ({ path }: SongPathProps) => {
)}
withinPortal
>
<ActionIcon
onClick={copy}
variant="transparent"
>
<ActionIcon onClick={copy} variant="transparent">
{copied ? <Icon icon="check" /> : <Icon icon="clipboardCopy" />}
</ActionIcon>
</Tooltip>
@@ -38,33 +38,15 @@ const SearchResult = ({ data, onClick }: SearchResultProps) => {
source === LyricSource.GENIUS ? id.replace(/^((http[s]?|ftp):\/)?\/?([^:/\s]+)/g, '') : id;
return (
<button
className={styles.searchItem}
onClick={onClick}
>
<Group
justify="space-between"
wrap="nowrap"
>
<Stack
gap={0}
maw="65%"
>
<Text
fw={600}
size="md"
>
<button className={styles.searchItem} onClick={onClick}>
<Group justify="space-between" wrap="nowrap">
<Stack gap={0} maw="65%">
<Text fw={600} size="md">
{name}
</Text>
<Text isMuted>{artist}</Text>
<Group
gap="sm"
wrap="nowrap"
>
<Text
isMuted
size="sm"
>
<Group gap="sm" wrap="nowrap">
<Text isMuted size="sm">
{[source, cleanId].join(' — ')}
</Text>
</Group>
@@ -167,11 +149,7 @@ export const LyricsSearchForm = ({ artist, name, onSearchOverride }: LyricSearch
export const openLyricSearchModal = ({ artist, name, onSearchOverride }: LyricSearchFormProps) => {
openModal({
children: (
<LyricsSearchForm
artist={artist}
name={name}
onSearchOverride={onSearchOverride}
/>
<LyricsSearchForm artist={artist} name={name} onSearchOverride={onSearchOverride} />
),
size: 'lg',
title: i18n.t('form.lyricSearch.title', { postProcess: 'titleCase' }) as string,
+1 -4
View File
@@ -151,10 +151,7 @@ export const Lyrics = () => {
<ErrorBoundary FallbackComponent={ErrorFallback}>
<div className={styles.lyricsContainer}>
{isLoadingLyrics ? (
<Spinner
container
size={25}
/>
<Spinner container size={25} />
) : (
<AnimatePresence mode="sync">
{hasNoLyrics ? (
@@ -29,10 +29,7 @@ export const UnsynchronizedLyrics = ({
}, [translatedLyrics]);
return (
<div
className={styles.container}
style={{ gap: `${settings.gapUnsync}px` }}
>
<div className={styles.container} style={{ gap: `${settings.gapUnsync}px` }}>
{settings.showProvider && source && (
<LyricLine
alignment={settings.alignment}
@@ -11,30 +11,17 @@ export const DrawerPlayQueue = () => {
const queueRef = useRef<null | { grid: AgGridReactType<Song> }>(null);
return (
<Flex
direction="column"
h="100%"
>
<Flex direction="column" h="100%">
<div
style={{
backgroundColor: 'var(--theme-colors-background)',
borderRadius: '10px',
}}
>
<PlayQueueListControls
tableRef={queueRef}
type="sideQueue"
/>
<PlayQueueListControls tableRef={queueRef} type="sideQueue" />
</div>
<Flex
bg="var(--theme-colors-background)"
h="100%"
mb="0.6rem"
>
<PlayQueue
ref={queueRef}
type="sideQueue"
/>
<Flex bg="var(--theme-colors-background)" h="100%" mb="0.6rem">
<PlayQueue ref={queueRef} type="sideQueue" />
</Flex>
</Flex>
);
@@ -174,10 +174,7 @@ export const PlayQueueListControls = ({ tableRef, type }: PlayQueueListOptionsPr
/>
</Group>
<Group>
<Popover
position="top-end"
transitionProps={{ transition: 'fade' }}
>
<Popover position="top-end" transitionProps={{ transition: 'fade' }}>
<Popover.Target>
<ActionIcon
icon="settings"
@@ -18,19 +18,10 @@ export const SidebarPlayQueue = () => {
const isWeb = windowBarStyle === Platform.WEB;
return (
<VirtualGridContainer>
<Box
display={!isWeb ? 'flex' : undefined}
h="65px"
>
<PlayQueueListControls
tableRef={queueRef}
type="sideQueue"
/>
<Box display={!isWeb ? 'flex' : undefined} h="65px">
<PlayQueueListControls tableRef={queueRef} type="sideQueue" />
</Box>
<PlayQueue
ref={queueRef}
type="sideQueue"
/>
<PlayQueue ref={queueRef} type="sideQueue" />
</VirtualGridContainer>
);
};
@@ -16,14 +16,8 @@ const NowPlayingRoute = () => {
<AnimatedPage>
<VirtualGridContainer>
<NowPlayingHeader />
<PlayQueueListControls
tableRef={queueRef}
type="nowPlaying"
/>
<PlayQueue
ref={queueRef}
type="nowPlaying"
/>
<PlayQueueListControls tableRef={queueRef} type="nowPlaying" />
<PlayQueue ref={queueRef} type="nowPlaying" />
</VirtualGridContainer>
</AnimatedPage>
);
@@ -115,13 +115,7 @@ export const CenterControls = ({ playersRef }: CenterControlsProps) => {
<div className={styles.controlsContainer}>
<div className={styles.buttonsContainer}>
<PlayerButton
icon={
<Icon
fill="default"
icon="mediaStop"
size={buttonSize - 2}
/>
}
icon={<Icon fill="default" icon="mediaStop" size={buttonSize - 2} />}
onClick={handleStop}
tooltip={{
label: t('player.stop', { postProcess: 'sentenceCase' }),
@@ -152,13 +146,7 @@ export const CenterControls = ({ playersRef }: CenterControlsProps) => {
variant="tertiary"
/>
<PlayerButton
icon={
<Icon
fill="default"
icon="mediaPrevious"
size={buttonSize}
/>
}
icon={<Icon fill="default" icon="mediaPrevious" size={buttonSize} />}
onClick={handlePrevTrack}
tooltip={{
label: t('player.previous', { postProcess: 'sentenceCase' }),
@@ -169,11 +157,7 @@ export const CenterControls = ({ playersRef }: CenterControlsProps) => {
{skip?.enabled && (
<PlayerButton
icon={
<Icon
fill="default"
icon="mediaStepBackward"
size={buttonSize}
/>
<Icon fill="default" icon="mediaStepBackward" size={buttonSize} />
}
onClick={() => handleSkipBackward(skip?.skipBackwardSeconds)}
tooltip={{
@@ -194,13 +178,7 @@ export const CenterControls = ({ playersRef }: CenterControlsProps) => {
/>
{skip?.enabled && (
<PlayerButton
icon={
<Icon
fill="default"
icon="mediaStepForward"
size={buttonSize}
/>
}
icon={<Icon fill="default" icon="mediaStepForward" size={buttonSize} />}
onClick={() => handleSkipForward(skip?.skipForwardSeconds)}
tooltip={{
label: t('player.skip', {
@@ -214,13 +192,7 @@ export const CenterControls = ({ playersRef }: CenterControlsProps) => {
/>
)}
<PlayerButton
icon={
<Icon
fill="default"
icon="mediaNext"
size={buttonSize}
/>
}
icon={<Icon fill="default" icon="mediaNext" size={buttonSize} />}
onClick={handleNextTrack}
tooltip={{
label: t('player.next', { postProcess: 'sentenceCase' }),
@@ -231,11 +203,7 @@ export const CenterControls = ({ playersRef }: CenterControlsProps) => {
<PlayerButton
icon={
repeat === PlayerRepeat.ONE ? (
<Icon
fill="primary"
icon="mediaRepeatOne"
size={buttonSize}
/>
<Icon fill="primary" icon="mediaRepeatOne" size={buttonSize} />
) : (
<Icon
fill={repeat === PlayerRepeat.NONE ? 'default' : 'primary'}
@@ -268,13 +236,7 @@ export const CenterControls = ({ playersRef }: CenterControlsProps) => {
variant="tertiary"
/>
<PlayerButton
icon={
<Icon
fill="default"
icon="mediaRandom"
size={buttonSize}
/>
}
icon={<Icon fill="default" icon="mediaRandom" size={buttonSize} />}
onClick={() =>
openShuffleAllModal({
handlePlayQueueAdd,
@@ -291,12 +253,7 @@ export const CenterControls = ({ playersRef }: CenterControlsProps) => {
</div>
<div className={styles.sliderContainer}>
<div className={styles.sliderValueWrapper}>
<Text
fw={600}
isMuted
isNoSelect
size="xs"
>
<Text fw={600} isMuted isNoSelect size="xs">
{formattedTime}
</Text>
</div>
@@ -324,12 +281,7 @@ export const CenterControls = ({ playersRef }: CenterControlsProps) => {
/>
</div>
<div className={styles.sliderValueWrapper}>
<Text
fw={600}
isMuted
isNoSelect
size="xs"
>
<Text fw={600} isMuted isNoSelect size="xs">
{duration}
</Text>
</div>
@@ -68,11 +68,7 @@ const ImageWithPlaceholder = ({
width: '100%',
}}
>
<Icon
color="muted"
icon="itemAlbum"
size="25%"
/>
<Icon color="muted" icon="itemAlbum" size="25%" />
</Center>
);
}
@@ -167,14 +163,8 @@ export const FullScreenPlayerImage = () => {
justify="flex-start"
p="1rem"
>
<div
className={styles.imageContainer}
ref={mainImageRef}
>
<AnimatePresence
initial={false}
mode="sync"
>
<div className={styles.imageContainer} ref={mainImageRef}>
<AnimatePresence initial={false} mode="sync">
{imageState.current === 0 && (
<ImageWithPlaceholder
animate="open"
@@ -206,18 +196,8 @@ export const FullScreenPlayerImage = () => {
)}
</AnimatePresence>
</div>
<Stack
className={styles.metadataContainer}
gap="md"
maw="100%"
>
<Text
fw={900}
lh="1.2"
overflow="hidden"
size="4xl"
w="100%"
>
<Stack className={styles.metadataContainer} gap="md" maw="100%">
<Text fw={900} lh="1.2" overflow="hidden" size="4xl" w="100%">
{currentSong?.name}
</Text>
<Text
@@ -257,10 +237,7 @@ export const FullScreenPlayerImage = () => {
</Fragment>
))}
</Text>
<Group
justify="center"
mt="sm"
>
<Group justify="center" mt="sm">
{currentSong?.container && (
<Badge variant="transparent">{currentSong?.container}</Badge>
)}
@@ -76,10 +76,7 @@ export const FullScreenPlayerQueue = () => {
justify="center"
>
{headerItems.map((item) => (
<div
className={styles.headerItemWrapper}
key={`tab-${item.label}`}
>
<div className={styles.headerItemWrapper} key={`tab-${item.label}`}>
<Button
flex={1}
fw="600"
@@ -238,10 +238,7 @@ const Controls = ({ isPageHovered }: ControlsProps) => {
})}
</Option.Label>
<Option.Control>
<Group
w="100%"
wrap="nowrap"
>
<Group w="100%" wrap="nowrap">
<Slider
defaultValue={lyricConfig.fontSize}
label={(e) =>
@@ -278,10 +275,7 @@ const Controls = ({ isPageHovered }: ControlsProps) => {
})}
</Option.Label>
<Option.Control>
<Group
w="100%"
wrap="nowrap"
>
<Group w="100%" wrap="nowrap">
<Slider
defaultValue={lyricConfig.gap}
label={(e) => `Synchronized: ${e}px`}
@@ -4,10 +4,5 @@ import { useCurrentSong } from '/@/renderer/store';
export const FullScreenSimilarSongs = () => {
const currentSong = useCurrentSong();
return currentSong?.id ? (
<SimilarSongsList
fullScreen
song={currentSong}
/>
) : null;
return currentSong?.id ? <SimilarSongsList fullScreen song={currentSong} /> : null;
};
@@ -69,10 +69,7 @@ export const LeftControls = () => {
return (
<div className={styles.leftControlsContainer}>
<LayoutGroup>
<AnimatePresence
initial={false}
mode="popLayout"
>
<AnimatePresence initial={false} mode="popLayout">
{!hideImage && (
<div className={styles.imageWrapper}>
<motion.div
@@ -123,19 +120,9 @@ export const LeftControls = () => {
</div>
)}
</AnimatePresence>
<motion.div
className={styles.metadataStack}
layout="position"
>
<div
className={styles.lineItem}
onClick={stopPropagation}
>
<Group
align="center"
gap="xs"
wrap="nowrap"
>
<motion.div className={styles.metadataStack} layout="position">
<div className={styles.lineItem} onClick={stopPropagation}>
<Group align="center" gap="xs" wrap="nowrap">
<Text
component={Link}
fw={500}
@@ -193,13 +193,7 @@ export const RightControls = () => {
}, [addToFavoritesMutation, removeFromFavoritesMutation, updateRatingMutation]);
return (
<Flex
align="flex-end"
direction="column"
h="100%"
px="1rem"
py="0.5rem"
>
<Flex align="flex-end" direction="column" h="100%" px="1rem" py="0.5rem">
<Group h="calc(100% / 3)">
{showRating && (
<Rating
@@ -209,18 +203,8 @@ export const RightControls = () => {
/>
)}
</Group>
<Group
align="center"
gap="xs"
wrap="nowrap"
>
<DropdownMenu
arrowOffset={12}
offset={0}
position="top-end"
width={425}
withArrow
>
<Group align="center" gap="xs" wrap="nowrap">
<DropdownMenu arrowOffset={12} offset={0} position="top-end" width={425} withArrow>
<DropdownMenu.Target>
<ActionIcon
icon="mediaSpeed"
@@ -33,10 +33,5 @@ export const Visualizer = () => {
return () => {};
}, [accent, canvasRef, motion, webAudio]);
return (
<div
className={styles.container}
ref={canvasRef}
/>
);
return <div className={styles.container} ref={canvasRef} />;
};
@@ -34,6 +34,8 @@ Progress Events (Jellyfin only):
- Sends the 'progress' scrobble event on an interval
*/
type PlayerEvent = [PlayerStatus, number];
type SongEvent = [QueueSong | undefined, number, 1 | 2];
const checkScrobbleConditions = (args: {
@@ -86,21 +88,35 @@ export const useScrobble = () => {
);
const progressIntervalId = useRef<null | ReturnType<typeof setInterval>>(null);
const songChangeTimeoutId = useRef<null | ReturnType<typeof setTimeout>>(null);
const songChangeTimeoutId = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
const notifyTimeoutId = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
const handleScrobbleFromSongChange = useCallback(
(current: SongEvent, previous: SongEvent) => {
if (scrobbleSettings?.notify && current[0]) {
if (scrobbleSettings?.notify && current[0]?.id) {
clearTimeout(notifyTimeoutId.current);
const currentSong = current[0];
const artists =
currentSong.artists?.length > 0
? currentSong.artists.map((artist) => artist.name).join(', ')
: currentSong.artistName;
// Set a delay so that quickly (within a second) switching songs doesn't trigger multiple
// notifications
notifyTimeoutId.current = setTimeout(() => {
// Only trigger if the song changed, or the player changed. This should be the case
// anyways, but who knows
if (
currentSong.uniqueId !== previous[0]?.uniqueId ||
current[2] !== previous[2]
) {
const artists =
currentSong.artists?.length > 0
? currentSong.artists.map((artist) => artist.name).join(', ')
: currentSong.artistName;
new Notification(`Now playing ${currentSong.name}`, {
body: `by ${artists} on ${currentSong.album}`,
icon: currentSong.imageUrl || undefined,
});
new Notification(`Now playing ${currentSong.name}`, {
body: `by ${artists} on ${currentSong.album}`,
icon: currentSong.imageUrl || undefined,
});
}
}, 1000);
}
if (!isScrobbleEnabled) return;
@@ -110,7 +126,6 @@ export const useScrobble = () => {
progressIntervalId.current = null;
}
// const currentSong = current[0] as QueueSong | undefined;
const previousSong = previous[0];
const previousSongTimeSec = previous[1];
@@ -146,7 +161,7 @@ export const useScrobble = () => {
setIsCurrentSongScrobbled(false);
// Use a timeout to prevent spamming the server with scrobble events when switching through songs quickly
clearTimeout(songChangeTimeoutId.current as ReturnType<typeof setTimeout>);
clearTimeout(songChangeTimeoutId.current);
songChangeTimeoutId.current = setTimeout(() => {
const currentSong = current[0];
// Get the current status from the state, not variable. This is because
@@ -193,10 +208,7 @@ export const useScrobble = () => {
);
const handleScrobbleFromStatusChange = useCallback(
(
current: (number | PlayerStatus | undefined)[],
previous: (number | PlayerStatus | undefined)[],
) => {
(current: PlayerEvent, previous: PlayerEvent) => {
if (!isScrobbleEnabled) return;
const currentSong = usePlayerStore.getState().current.song;
@@ -208,8 +220,8 @@ export const useScrobble = () => {
? usePlayerStore.getState().current.time * 1e7
: undefined;
const currentStatus = current[0] as PlayerStatus;
const currentTimeSec = current[1] as number;
const currentStatus = current[0];
const currentTimeSec = current[1];
// Whenever the player is restarted, send a 'start' scrobble
if (currentStatus === PlayerStatus.PLAYING) {
@@ -249,12 +261,12 @@ export const useScrobble = () => {
});
if (progressIntervalId.current) {
clearInterval(progressIntervalId.current as ReturnType<typeof setInterval>);
clearInterval(progressIntervalId.current);
progressIntervalId.current = null;
}
} else {
const isLastTrackInQueue = usePlayerStore.getState().actions.checkIsLastTrack();
const previousTimeSec = previous[1] as number;
const previousTimeSec = previous[1];
// If not already scrobbled, send a 'submission' scrobble if conditions are met
const shouldSubmitScrobble = checkScrobbleConditions({
@@ -358,17 +370,17 @@ export const useScrobble = () => {
// multiple times in a row and playback goes normally (no next/previous)
equalityFn: (a, b) =>
// compute whether the song changed
(a[0] as QueueSong)?.uniqueId === (b[0] as QueueSong)?.uniqueId &&
a[0]?.uniqueId === b[0]?.uniqueId &&
// compute whether the same player: relevant for repeat one and repeat all (one track)
a[2] === b[2],
},
);
const unsubStatusChange = usePlayerStore.subscribe(
(state) => [state.current.status, state.current.time],
(state): PlayerEvent => [state.current.status, state.current.time],
handleScrobbleFromStatusChange,
{
equalityFn: (a, b) => (a[0] as PlayerStatus) === (b[0] as PlayerStatus),
equalityFn: (a, b) => a[0] === b[0],
},
);
@@ -155,10 +155,7 @@ export const CreatePlaylistForm = ({ onCancel }: CreatePlaylistFormProps) => {
)}
<Group justify="flex-end">
<Button
onClick={onCancel}
variant="subtle"
>
<Button onClick={onCancel} variant="subtle">
{t('common.cancel', { postProcess: 'titleCase' })}
</Button>
<Button
@@ -331,11 +331,7 @@ export const PlaylistDetailSongListContent = ({ songs, tableRef }: PlaylistDetai
/>
</VirtualGridAutoSizerContainer>
{isPaginationEnabled && (
<AnimatePresence
initial={false}
mode="wait"
presenceAffectsLayout
>
<AnimatePresence initial={false} mode="wait" presenceAffectsLayout>
{page.display === ListDisplayType.TABLE_PAGINATED && (
<TablePagination
pageKey={playlistId}
@@ -469,11 +469,7 @@ export const PlaylistDetailSongListHeaderFilters = ({
return (
<Flex justify="space-between">
<Group
gap="sm"
ref={cq.ref}
w="100%"
>
<Group gap="sm" ref={cq.ref} w="100%">
<DropdownMenu position="bottom-start">
<DropdownMenu.Target>
<Button
@@ -555,10 +551,7 @@ export const PlaylistDetailSongListHeaderFilters = ({
{server?.type === ServerType.NAVIDROME && !isSmartPlaylist && (
<>
<DropdownMenu.Divider />
<DropdownMenu.Item
isDanger
onClick={handleToggleShowQueryBuilder}
>
<DropdownMenu.Item isDanger onClick={handleToggleShowQueryBuilder}>
{t('action.toggleSmartPlaylistEditor', {
postProcess: 'sentenceCase',
})}
@@ -33,15 +33,9 @@ export const PlaylistListContent = ({ gridRef, itemCount, tableRef }: PlaylistLi
return (
<Suspense fallback={<Spinner container />}>
{display === ListDisplayType.CARD || display === ListDisplayType.GRID ? (
<PlaylistListGridView
gridRef={gridRef}
itemCount={itemCount}
/>
<PlaylistListGridView gridRef={gridRef} itemCount={itemCount} />
) : (
<PlaylistListTableView
itemCount={itemCount}
tableRef={tableRef}
/>
<PlaylistListTableView itemCount={itemCount} tableRef={tableRef} />
)}
<div />
</Suspense>
@@ -355,11 +355,7 @@ export const PlaylistListHeaderFilters = ({
return (
<Flex justify="space-between">
<Group
gap="sm"
ref={cq.ref}
w="100%"
>
<Group gap="sm" ref={cq.ref} w="100%">
<DropdownMenu position="bottom-start">
<DropdownMenu.Target>
<Button variant="subtle">{sortByLabel}</Button>
@@ -378,10 +374,7 @@ export const PlaylistListHeaderFilters = ({
</DropdownMenu.Dropdown>
</DropdownMenu>
<Divider orientation="vertical" />
<OrderToggleButton
onToggle={handleToggleSortOrder}
sortOrder={filter.sortOrder}
/>
<OrderToggleButton onToggle={handleToggleSortOrder} sortOrder={filter.sortOrder} />
<RefreshButton onClick={handleRefresh} />
<DropdownMenu position="bottom-start">
<DropdownMenu.Target>
@@ -397,14 +390,8 @@ export const PlaylistListHeaderFilters = ({
</DropdownMenu.Dropdown>
</DropdownMenu>
</Group>
<Group
gap="xs"
wrap="nowrap"
>
<Button
onClick={handleCreatePlaylistModal}
variant="subtle"
>
<Group gap="xs" wrap="nowrap">
<Button onClick={handleCreatePlaylistModal} variant="subtle">
{t('action.createPlaylist', { postProcess: 'sentenceCase' })}
</Button>
<ListConfigMenu
@@ -44,16 +44,9 @@ export const PlaylistListHeader = ({ gridRef, itemCount, tableRef }: PlaylistLis
}, 500);
return (
<Stack
gap={0}
ref={cq.ref}
>
<Stack gap={0} ref={cq.ref}>
<PageHeader>
<Flex
align="center"
justify="space-between"
w="100%"
>
<Flex align="center" justify="space-between" w="100%">
<LibraryHeaderBar>
<LibraryHeaderBar.Title>
{t('page.playlistList.title', { postProcess: 'titleCase' })}
@@ -67,18 +60,12 @@ export const PlaylistListHeader = ({ gridRef, itemCount, tableRef }: PlaylistLis
</Badge>
</LibraryHeaderBar>
<Group>
<SearchInput
defaultValue={filter.searchTerm}
onChange={handleSearch}
/>
<SearchInput defaultValue={filter.searchTerm} onChange={handleSearch} />
</Group>
</Flex>
</PageHeader>
<FilterBar>
<PlaylistListHeaderFilters
gridRef={gridRef}
tableRef={tableRef}
/>
<PlaylistListHeaderFilters gridRef={gridRef} tableRef={tableRef} />
</FilterBar>
</Stack>
);

Some files were not shown because too many files have changed in this diff Show More