mirror of
https://github.com/jeffvli/feishin.git
synced 2026-05-07 04:20:12 +02:00
Compare commits
75 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6858485e41 | |||
| ebd97c253b | |||
| 2c834cd3a8 | |||
| dff6d27c23 | |||
| 3aed97c139 | |||
| 8fe93b4b2e | |||
| c634a07c5d | |||
| 0235a569a0 | |||
| cbe1c878e7 | |||
| 4afb893ce5 | |||
| 645697367d | |||
| 683bb0222c | |||
| ce0c07ebdb | |||
| 785f0ef77f | |||
| 7bfdbb5d92 | |||
| abdb2fee85 | |||
| d1bcd2b2fb | |||
| 297d6f0d2e | |||
| 78ac5af178 | |||
| 9cd8807a75 | |||
| 620cca9ce3 | |||
| 89688455e0 | |||
| 5259f2401b | |||
| c36f0a055d | |||
| ef87a8c2a7 | |||
| 5da68d4243 | |||
| dc95a3c66b | |||
| 087ea44737 | |||
| cb2597d2c8 | |||
| 0d03b66fe5 | |||
| ba531505af | |||
| 595eba152a | |||
| ebd2f07447 | |||
| 5d6503c1f4 | |||
| d03a3a11eb | |||
| 04b4d92f69 | |||
| ec69cc22f9 | |||
| 19a88fea86 | |||
| 729538d885 | |||
| 9f86a8179f | |||
| 3976f5e5bf | |||
| 90d3fb219d | |||
| cabd69772e | |||
| 9339c08777 | |||
| f5e047c7f5 | |||
| f79f9cc79e | |||
| c3fcb7487c | |||
| 15c6ef382a | |||
| 14086ebc9c | |||
| 2257e439a4 | |||
| 6824a5db7a | |||
| c0110eff82 | |||
| 2c17458fdf | |||
| c1345802aa | |||
| 197497df05 | |||
| 7bebe286d5 | |||
| 24394fa858 | |||
| f7c6088cca | |||
| 65eca32de3 | |||
| ae167e63fd | |||
| ab17ba8add | |||
| 2854a91700 | |||
| 6bc778fa53 | |||
| 44fcc33825 | |||
| e0e967385f | |||
| 8900d8126c | |||
| 65b045df03 | |||
| 918842e3a5 | |||
| a3573d4f9a | |||
| 46fdacad81 | |||
| 67b8c7f1c0 | |||
| 43f28317f6 | |||
| f61cf8c331 | |||
| 340344b791 | |||
| ba1a2d5495 |
+1
-1
@@ -2,7 +2,7 @@ root = true
|
||||
|
||||
[*]
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
indent_size = 4
|
||||
end_of_line = lf
|
||||
charset = utf-8
|
||||
trim_trailing_whitespace = true
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
---
|
||||
name: Bug report
|
||||
about: You're having technical issues. 🐞
|
||||
labels: 'bug'
|
||||
---
|
||||
|
||||
## Expected Behavior
|
||||
|
||||
<!--- What should have happened? -->
|
||||
|
||||
## Current Behavior
|
||||
|
||||
<!--- What went wrong? -->
|
||||
<!-- Add screenshots to help explain your problem -->
|
||||
<!-- (Open the browser dev tools in the menu or using CTRL + SHIFT + I) -->
|
||||
|
||||
## Steps to Reproduce
|
||||
|
||||
<!-- Add relevant code and/or a live example -->
|
||||
<!-- Add stack traces -->
|
||||
|
||||
1.
|
||||
|
||||
2.
|
||||
|
||||
3.
|
||||
|
||||
4.
|
||||
|
||||
## Possible Solution (Not obligatory)
|
||||
|
||||
<!--- Suggest a reason for the bug or how to fix it. -->
|
||||
|
||||
## Context
|
||||
|
||||
<!--- How has this issue affected you? What are you trying to accomplish? -->
|
||||
|
||||
## Your Environment
|
||||
|
||||
<!--- Include as many relevant details about the environment you experienced the bug in -->
|
||||
|
||||
- Application version (e.g. v0.1.0) :
|
||||
- Operating System and version (e.g. Windows 10) :
|
||||
- Server and version (e.g. Navidrome v0.48.0) :
|
||||
- Node version (if developing locally) :
|
||||
@@ -1,9 +0,0 @@
|
||||
---
|
||||
name: Question
|
||||
about: Ask a question.❓
|
||||
labels: 'question'
|
||||
---
|
||||
|
||||
<!-- Question issues will be closed. -->
|
||||
<!-- Ask questions in the discussions tab: Please use discussions https://github.com/jeffvli/feishin/discussions -->
|
||||
<!-- Or join the Discord/Matrix servers: https://discord.gg/FVKpcMDy5f https://matrix.to/#/#sonixd:matrix.org -->
|
||||
@@ -1,11 +0,0 @@
|
||||
---
|
||||
name: Feature request
|
||||
about: Request a feature to be added to Feishin 🎉
|
||||
labels: 'enhancement'
|
||||
---
|
||||
|
||||
## What do you want to be added?
|
||||
|
||||
## Additional context
|
||||
|
||||
<!-- Is this a server-specific feature? (e.g. Jellyfin only). -->
|
||||
@@ -0,0 +1,63 @@
|
||||
name: Bug report
|
||||
description: You're having technical issues. 🐞
|
||||
labels: ['bug']
|
||||
body:
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Expected Behavior
|
||||
description: What should have happened?
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Current Behavior
|
||||
description: What went wrong? Add screenshots to help explain your problem. (Open the browser dev tools in the menu or using CTRL + SHIFT + I)
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Steps to Reproduce
|
||||
placeholder: |
|
||||
<!-- Add relevant code and/or a live example -->
|
||||
<!-- Add stack traces -->
|
||||
1.
|
||||
2.
|
||||
3.
|
||||
4.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Possible Solution
|
||||
description: Suggest a reason for the bug or how to fix it.
|
||||
validations:
|
||||
required: false
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Context
|
||||
description: How has this issue affected you? What are you trying to accomplish?
|
||||
validations:
|
||||
required: false
|
||||
- type: input
|
||||
attributes:
|
||||
label: Application version
|
||||
placeholder: (e.g. v0.1.0)
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
attributes:
|
||||
label: Operating System and version
|
||||
placeholder: (e.g. Windows 11 desktop, Webapp in Firefox)
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
attributes:
|
||||
label: Server and Version
|
||||
placeholder: (e.g. Navidrome v0.48.0)
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
attributes:
|
||||
label: Node Version (if developing locally)
|
||||
validations:
|
||||
required: false
|
||||
@@ -0,0 +1,5 @@
|
||||
blank_issues_enabled: true
|
||||
contact_links:
|
||||
- name: Question
|
||||
url: https://github.com/jeffvli/feishin/discussions
|
||||
about: Please ask and answer questions here.
|
||||
@@ -0,0 +1,22 @@
|
||||
name: Feature request
|
||||
description: Request a feature to be added to Feishin 🎉
|
||||
labels: ['enhancement']
|
||||
body:
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: What do you want to be added?
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Additional context
|
||||
validations:
|
||||
required: false
|
||||
- type: checkboxes
|
||||
attributes:
|
||||
label: Is this a server-specific feature? (e.g. Jellyfin only)
|
||||
options:
|
||||
- label: 'Yes'
|
||||
required: false
|
||||
validations:
|
||||
required: false
|
||||
@@ -93,11 +93,20 @@ First thing to do is check that your MPV binary path is correct. Navigate to the
|
||||
|
||||
Feishin supports any music server that implements a [Navidrome](https://www.navidrome.org/) or [Jellyfin](https://jellyfin.org/) API. **Subsonic API is not currently supported**. This will likely be added in [later when the new Subsonic API is decided on](https://support.symfonium.app/t/subsonic-servers-participation/1233).
|
||||
|
||||
- [Navidrome](https://github.com/navidrome/navidrome) version 0.48.0 and newer
|
||||
- [Navidrome](https://github.com/navidrome/navidrome)
|
||||
- [Jellyfin](https://github.com/jellyfin/jellyfin)
|
||||
- [Funkwhale](https://funkwhale.audio/) - TBD
|
||||
- Subsonic-compatible servers - TBD
|
||||
|
||||
### I have the issue "The SUID sandbox helper binary was found, but is not configured correctly" on Linux
|
||||
|
||||
This happens when you have user (unprivileged) namespaces disabled (`sysctl kernel.unprivileged_userns_clone` returns 0). You can fix this by either enabling unprivileged namespaces, or by making the `chrome-sandbox` Setuid.
|
||||
|
||||
```bash
|
||||
chmod 4755 chrome-sandbox
|
||||
sudo chown root:root chrome-sandbox
|
||||
```
|
||||
|
||||
## Development
|
||||
|
||||
Built and tested using Node `v16.15.0`.
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@ version: '3.5'
|
||||
services:
|
||||
feishin:
|
||||
container_name: feishin
|
||||
image: jeffvli/feishin
|
||||
image: ghcr.io/jeffvli/feishin:latest
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- 9180:9180
|
||||
|
||||
Generated
+1680
-302
File diff suppressed because it is too large
Load Diff
+5
-3
@@ -2,7 +2,7 @@
|
||||
"name": "feishin",
|
||||
"productName": "Feishin",
|
||||
"description": "Feishin music server",
|
||||
"version": "0.6.1",
|
||||
"version": "0.7.0",
|
||||
"scripts": {
|
||||
"build": "concurrently \"npm run build:main\" \"npm run build:renderer\" \"npm run build:remote\"",
|
||||
"build:main": "cross-env NODE_ENV=production TS_NODE_TRANSPILE_ONLY=true webpack --config ./.erb/configs/webpack.config.main.prod.ts",
|
||||
@@ -216,6 +216,7 @@
|
||||
"@types/react-virtualized-auto-sizer": "^1.0.1",
|
||||
"@types/react-window": "^1.8.5",
|
||||
"@types/react-window-infinite-loader": "^1.0.6",
|
||||
"@types/sanitize-html": "^2.11.0",
|
||||
"@types/styled-components": "^5.1.26",
|
||||
"@types/terser-webpack-plugin": "^5.0.4",
|
||||
"@types/webpack-bundle-analyzer": "^4.4.1",
|
||||
@@ -231,7 +232,7 @@
|
||||
"css-minimizer-webpack-plugin": "^3.4.1",
|
||||
"detect-port": "^1.3.0",
|
||||
"electron": "^26.6.10",
|
||||
"electron-builder": "^24.9.0",
|
||||
"electron-builder": "^24.13.3",
|
||||
"electron-devtools-installer": "^3.2.0",
|
||||
"electron-notarize": "^1.2.1",
|
||||
"electronmon": "^2.0.2",
|
||||
@@ -318,7 +319,7 @@
|
||||
"electron-updater": "^4.6.5",
|
||||
"fast-average-color": "^9.3.0",
|
||||
"format-duration": "^2.0.0",
|
||||
"framer-motion": "^10.13.0",
|
||||
"framer-motion": "^11.0.0",
|
||||
"fuse.js": "^6.6.2",
|
||||
"history": "^5.3.0",
|
||||
"i18next": "^21.10.0",
|
||||
@@ -345,6 +346,7 @@
|
||||
"react-virtualized-auto-sizer": "^1.0.17",
|
||||
"react-window": "^1.8.9",
|
||||
"react-window-infinite-loader": "^1.0.9",
|
||||
"sanitize-html": "^2.13.0",
|
||||
"semver": "^7.5.4",
|
||||
"styled-components": "^6.0.8",
|
||||
"swiper": "^9.3.1",
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "feishin",
|
||||
"version": "0.6.1",
|
||||
"version": "0.7.0",
|
||||
"lockfileVersion": 2,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "feishin",
|
||||
"version": "0.6.1",
|
||||
"version": "0.7.0",
|
||||
"hasInstallScript": true,
|
||||
"license": "GPL-3.0",
|
||||
"dependencies": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "feishin",
|
||||
"version": "0.6.1",
|
||||
"version": "0.7.0",
|
||||
"description": "",
|
||||
"main": "./dist/main/main.js",
|
||||
"author": {
|
||||
|
||||
+17
-3
@@ -16,6 +16,8 @@ import sv from './locales/sv.json';
|
||||
import cs from './locales/cs.json';
|
||||
import nbNO from './locales/nb-NO.json';
|
||||
import nl from './locales/nl.json';
|
||||
import zhHant from './locales/zh-Hant.json';
|
||||
import fa from './locales/fa.json';
|
||||
|
||||
const resources = {
|
||||
en: { translation: en },
|
||||
@@ -24,10 +26,12 @@ const resources = {
|
||||
it: { translation: it },
|
||||
ru: { translation: ru },
|
||||
'pt-BR': { translation: ptBr },
|
||||
fa: { translation: fa },
|
||||
fr: { translation: fr },
|
||||
ja: { translation: ja },
|
||||
pl: { translation: pl },
|
||||
'zh-Hans': { translation: zhHans },
|
||||
'zh-Hant': { translation: zhHant },
|
||||
sr: { translation: sr },
|
||||
sv: { translation: sv },
|
||||
cs: { translation: cs },
|
||||
@@ -72,7 +76,10 @@ export const languages = [
|
||||
label: 'Norsk (Bokmål)',
|
||||
value: 'nb-NO',
|
||||
},
|
||||
|
||||
{
|
||||
label: 'فارسی',
|
||||
value: 'fa',
|
||||
},
|
||||
{
|
||||
label: 'Português (Brasil)',
|
||||
value: 'pt-BR',
|
||||
@@ -97,6 +104,10 @@ export const languages = [
|
||||
label: '简体中文',
|
||||
value: 'zh-Hans',
|
||||
},
|
||||
{
|
||||
label: '繁體中文',
|
||||
value: 'zh-Hant',
|
||||
},
|
||||
];
|
||||
|
||||
const lowerCasePostProcessor: PostProcessorModule = {
|
||||
@@ -125,7 +136,7 @@ const titleCasePostProcessor: PostProcessorModule = {
|
||||
},
|
||||
};
|
||||
|
||||
const ignoreSentenceCaseLanguages = ['de']
|
||||
const ignoreSentenceCaseLanguages = ['de'];
|
||||
|
||||
const sentenceCasePostProcessor: PostProcessorModule = {
|
||||
type: 'postProcessor',
|
||||
@@ -136,7 +147,10 @@ const sentenceCasePostProcessor: PostProcessorModule = {
|
||||
return sentences
|
||||
.map((sentence) => {
|
||||
return (
|
||||
sentence.charAt(0).toLocaleUpperCase() + (!ignoreSentenceCaseLanguages.includes(translator.language) ? sentence.slice(1).toLocaleLowerCase() : sentence.slice(1))
|
||||
sentence.charAt(0).toLocaleUpperCase() +
|
||||
(!ignoreSentenceCaseLanguages.includes(translator.language)
|
||||
? sentence.slice(1).toLocaleLowerCase()
|
||||
: sentence.slice(1))
|
||||
);
|
||||
})
|
||||
.join('. ');
|
||||
|
||||
+84
-13
@@ -196,7 +196,21 @@
|
||||
"clearCache": "vymazat mezipaměť prohlížeče",
|
||||
"clearCache_description": "„tvrdé pročištění“ aplikace feishin. kromě mezipaměti aplikace feishin vymaže i mezipaměť prohlížeče (uložené obrázky a další zdroje). přihlašovací údaje k serveru a nastavení nebudou ovlivněny",
|
||||
"clearQueryCache": "vymazat mezipaměť aplikace feishin",
|
||||
"clearQueryCache_description": "„lehké pročištění“ aplikace feishin. tímto obnovíte seznamy skladeb, metadata skladeb a resetujete uložené texty. nastavení, přihlašovací údaje k serveru a obrázky v mezipaměti nebudou ovlivněny"
|
||||
"clearQueryCache_description": "„lehké pročištění“ aplikace feishin. tímto obnovíte seznamy skladeb, metadata skladeb a resetujete uložené texty. nastavení, přihlašovací údaje k serveru a obrázky v mezipaměti nebudou ovlivněny",
|
||||
"startMinimized": "spustit minimalizované",
|
||||
"homeConfiguration_description": "nastavte, které položky a v jakém pořadí mají být zobrazeny na domovské stránce",
|
||||
"passwordStore": "ukládání hesel / tajných klíčů",
|
||||
"mpvExtraParameters_help": "jeden na řádek",
|
||||
"homeConfiguration": "nastavení domovské stránky",
|
||||
"playerAlbumArtResolution_description": "rozlišení náhledu obalu alba ve velkém přehrávači. větší hodnota znamená kvalitnější obrázek, ale může se déle načítat. výchozí hodnota je 0, což znamená automatické rozlišení",
|
||||
"playerAlbumArtResolution": "rozlišení obalu alba v přehrávači",
|
||||
"genreBehavior": "výchozí chování stránky žánrů",
|
||||
"externalLinks_description": "zapne zobrazování externích odkazů (Last.fm, MusicBrainz) na stránce umělce/alba",
|
||||
"genreBehavior_description": "určuje, zda kliknutí na žánr otevře seznam skladeb nebo alb",
|
||||
"clearCacheSuccess": "mezipaměť úspěšně vymazána",
|
||||
"externalLinks": "zobrazit externí odkazy",
|
||||
"startMinimized_description": "spustit aplikaci do systémové lišty",
|
||||
"passwordStore_description": "který způsob ukládání hesel / tajných klíčů použít. změňte tuto možnost, pokud máte problémy s ukládáním hesel."
|
||||
},
|
||||
"action": {
|
||||
"editPlaylist": "upravit $t(entity.playlist_one)",
|
||||
@@ -215,7 +229,11 @@
|
||||
"moveToBottom": "přesunout dolů",
|
||||
"setRating": "nastavit hodnocení",
|
||||
"toggleSmartPlaylistEditor": "přepnout editor $t(entity.smartPlaylist)",
|
||||
"removeFromFavorites": "odebrat z $t(entity.favorite_other)"
|
||||
"removeFromFavorites": "odebrat z $t(entity.favorite_other)",
|
||||
"openIn": {
|
||||
"lastfm": "Otevřít v Last.fm",
|
||||
"musicbrainz": "Otevřít v MusicBrainz"
|
||||
}
|
||||
},
|
||||
"common": {
|
||||
"backward": "zpátky",
|
||||
@@ -298,7 +316,17 @@
|
||||
"random": "náhodně",
|
||||
"size": "velikost",
|
||||
"biography": "biografie",
|
||||
"note": "poznámka"
|
||||
"note": "poznámka",
|
||||
"albumGain": "zisk (gain) alba",
|
||||
"albumPeak": "vrchol alba",
|
||||
"close": "zavřít",
|
||||
"mbid": "ID MusicBrainz",
|
||||
"trackGain": "zisk (gain) skladby",
|
||||
"reload": "znovu načíst",
|
||||
"share": "sdílet",
|
||||
"codec": "kodek",
|
||||
"trackPeak": "vrchol skladby",
|
||||
"preview": "náhled"
|
||||
},
|
||||
"table": {
|
||||
"config": {
|
||||
@@ -312,7 +340,9 @@
|
||||
"gap": "$t(common.gap)",
|
||||
"tableColumns": "sloupce tabulky",
|
||||
"autoFitColumns": "automaticky přizpůsobit sloupce",
|
||||
"size": "$t(common.size)"
|
||||
"size": "$t(common.size)",
|
||||
"itemGap": "mezera mezi položkami (px)",
|
||||
"itemSize": "velikost položek (px)"
|
||||
},
|
||||
"label": {
|
||||
"releaseDate": "datum vydání",
|
||||
@@ -340,7 +370,8 @@
|
||||
"discNumber": "číslo disku",
|
||||
"favorite": "$t(common.favorite)",
|
||||
"year": "$t(common.year)",
|
||||
"albumArtist": "$t(entity.albumArtist_one)"
|
||||
"albumArtist": "$t(entity.albumArtist_one)",
|
||||
"codec": "$t(common.codec)"
|
||||
}
|
||||
},
|
||||
"column": {
|
||||
@@ -365,7 +396,9 @@
|
||||
"albumArtist": "umělec alba",
|
||||
"path": "cesta",
|
||||
"discNumber": "disk",
|
||||
"channels": "$t(common.channel_other)"
|
||||
"channels": "$t(common.channel_other)",
|
||||
"size": "$t(common.size)",
|
||||
"codec": "$t(common.codec)"
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
@@ -387,7 +420,10 @@
|
||||
"mpvRequired": "vyžadován přehrávač MPV",
|
||||
"audioDeviceFetchError": "při pokusu o přístup ke zvukovým zařízením se vyskytla chyba",
|
||||
"invalidServer": "neplatný server",
|
||||
"loginRateError": "příliš mnoho pokusů o přihlášení, zkuste to znovu za pár vteřin"
|
||||
"loginRateError": "příliš mnoho pokusů o přihlášení, zkuste to znovu za pár vteřin",
|
||||
"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"
|
||||
},
|
||||
"filter": {
|
||||
"mostPlayed": "nejvíce přehráváno",
|
||||
@@ -445,7 +481,8 @@
|
||||
"settings": "$t(common.setting_other)",
|
||||
"home": "$t(common.home)",
|
||||
"artists": "$t(entity.artist_other)",
|
||||
"albumArtists": "$t(entity.albumArtist_other)"
|
||||
"albumArtists": "$t(entity.albumArtist_other)",
|
||||
"shared": "$t(entity.playlist_other) sdíleny"
|
||||
},
|
||||
"fullscreenPlayer": {
|
||||
"config": {
|
||||
@@ -459,7 +496,9 @@
|
||||
"unsynchronized": "nesynchronizováno",
|
||||
"lyricAlignment": "zarovnání textů",
|
||||
"useImageAspectRatio": "použít poměr stran obrázku",
|
||||
"lyricGap": "mezera textů"
|
||||
"lyricGap": "mezera textů",
|
||||
"dynamicImageBlur": "velikost rozostření obrázku",
|
||||
"dynamicIsImage": "povolit obrázek na pozadí"
|
||||
},
|
||||
"upNext": "další",
|
||||
"lyrics": "texty",
|
||||
@@ -493,7 +532,9 @@
|
||||
"addFavorite": "$t(action.addToFavorites)",
|
||||
"play": "$t(player.play)",
|
||||
"numberSelected": "vybráno {{count}}",
|
||||
"removeFromQueue": "$t(action.removeFromQueue)"
|
||||
"removeFromQueue": "$t(action.removeFromQueue)",
|
||||
"showDetails": "získat informace",
|
||||
"shareItem": "sdílet položku"
|
||||
},
|
||||
"home": {
|
||||
"mostPlayed": "nejpřehrávanější",
|
||||
@@ -516,10 +557,14 @@
|
||||
"title": "$t(entity.albumArtist_other)"
|
||||
},
|
||||
"genreList": {
|
||||
"title": "$t(entity.genre_other)"
|
||||
"title": "$t(entity.genre_other)",
|
||||
"showTracks": "zobrazit $t(entity.track_other) s žánrem",
|
||||
"showAlbums": "zobrazit $t(entity.album_other) s žánrem"
|
||||
},
|
||||
"trackList": {
|
||||
"title": "$t(entity.track_other)"
|
||||
"title": "$t(entity.track_other)",
|
||||
"artistTracks": "Skladby od umělce {{artist}}",
|
||||
"genreTracks": "$t(entity.track_other) s žánrem „{{genre}}“"
|
||||
},
|
||||
"globalSearch": {
|
||||
"commands": {
|
||||
@@ -533,7 +578,25 @@
|
||||
"title": "$t(entity.playlist_other)"
|
||||
},
|
||||
"albumList": {
|
||||
"title": "$t(entity.album_other)"
|
||||
"title": "$t(entity.album_other)",
|
||||
"artistAlbums": "Alba od umělce {{artist}}",
|
||||
"genreAlbums": "$t(entity.album_other) s žánrem „{{genre}}“"
|
||||
},
|
||||
"albumArtistDetail": {
|
||||
"recentReleases": "nedávno vydáno",
|
||||
"viewDiscography": "zobrazit diskografii",
|
||||
"about": "O umělci {{artist}}",
|
||||
"appearsOn": "také v",
|
||||
"topSongs": "nejlepší skladby",
|
||||
"topSongsFrom": "Nejlepší skladby od umělce {{title}}",
|
||||
"relatedArtists": "podobní $t(entity.artist_other)",
|
||||
"viewAllTracks": "zobrazit všechny $t(entity.track_other)",
|
||||
"viewAll": "zobrazit vše"
|
||||
},
|
||||
"itemDetail": {
|
||||
"copiedPath": "cesta úspěšně zkopírována",
|
||||
"copyPath": "kopírovat cestu do schránky",
|
||||
"openFile": "zobrazit skladbu ve správci souborů"
|
||||
}
|
||||
},
|
||||
"form": {
|
||||
@@ -584,6 +647,14 @@
|
||||
},
|
||||
"editPlaylist": {
|
||||
"title": "upravit $t(entity.playlist_one)"
|
||||
},
|
||||
"shareItem": {
|
||||
"allowDownloading": "umožnit stahování",
|
||||
"success": "odkaz ke sdílení zkopírován do schránky (klikněte sem pro otevření)",
|
||||
"description": "popis",
|
||||
"expireInvalid": "čas vypršení musí být v budoucnosti",
|
||||
"setExpiration": "nastavit vypršení",
|
||||
"createFailed": "nepodařilo se vytvořit sdílení (je sdílení povoleno?)"
|
||||
}
|
||||
},
|
||||
"entity": {
|
||||
|
||||
@@ -310,7 +310,8 @@
|
||||
"discNumber": "Disk",
|
||||
"genre": "$t(entity.genre_one)",
|
||||
"songCount": "$t(entity.track_other)",
|
||||
"trackNumber": "Nr."
|
||||
"trackNumber": "Nr.",
|
||||
"size": "$t(common.size)"
|
||||
}
|
||||
},
|
||||
"page": {
|
||||
|
||||
@@ -26,6 +26,8 @@
|
||||
"action_one": "action",
|
||||
"action_other": "actions",
|
||||
"add": "add",
|
||||
"albumGain": "album gain",
|
||||
"albumPeak": "album peak",
|
||||
"areYouSure": "are you sure?",
|
||||
"ascending": "ascending",
|
||||
"backward": "backward",
|
||||
@@ -72,6 +74,7 @@
|
||||
"menu": "menu",
|
||||
"minimize": "minimize",
|
||||
"modified": "modified",
|
||||
"mbid": "MusicBrainz ID",
|
||||
"name": "name",
|
||||
"no": "no",
|
||||
"none": "none",
|
||||
@@ -81,6 +84,7 @@
|
||||
"owner": "owner",
|
||||
"path": "path",
|
||||
"playerMustBePaused": "player must be paused",
|
||||
"preview": "preview",
|
||||
"previousSong": "previous $t(entity.track_one)",
|
||||
"quit": "quit",
|
||||
"random": "random",
|
||||
@@ -98,10 +102,13 @@
|
||||
"setting": "setting",
|
||||
"setting_one": "setting",
|
||||
"setting_other": "settings",
|
||||
"share": "share",
|
||||
"size": "size",
|
||||
"sortOrder": "order",
|
||||
"title": "title",
|
||||
"trackNumber": "track",
|
||||
"trackGain": "track gain",
|
||||
"trackPeak": "track peak",
|
||||
"unknown": "unknown",
|
||||
"version": "version",
|
||||
"year": "year",
|
||||
@@ -144,6 +151,7 @@
|
||||
"apiRouteError": "unable to route request",
|
||||
"audioDeviceFetchError": "an error occurred when trying to get audio devices",
|
||||
"authenticationFailed": "authentication failed",
|
||||
"badAlbum": "you are seeing this page because this song is not part of an album. you are most likely seeing this issue if you have a song at the top level of your music folder. jellyfin only groups tracks if they are in a folder.",
|
||||
"credentialsRequired": "credentials required",
|
||||
"endpointNotImplementedError": "endpoint {{endpoint}} is not implemented for {{serverType}}",
|
||||
"genericError": "an error occurred",
|
||||
@@ -152,6 +160,7 @@
|
||||
"loginRateError": "too many login attempts, please try again in a few seconds",
|
||||
"mpvRequired": "MPV required",
|
||||
"networkError": "a network error occurred",
|
||||
"openError": "could not open file",
|
||||
"playbackError": "an error occurred when trying to play the media",
|
||||
"remoteDisableError": "an error occurred when trying to $t(common.disable) the remote server",
|
||||
"remoteEnableError": "an error occurred when trying to $t(common.enable) the remote server",
|
||||
@@ -251,6 +260,14 @@
|
||||
"input_optionMatchAll": "match all",
|
||||
"input_optionMatchAny": "match any"
|
||||
},
|
||||
"shareItem": {
|
||||
"allowDownloading": "allow downloading",
|
||||
"description": "description",
|
||||
"setExpiration": "set expiration",
|
||||
"success": "share link copied to clipboard (or click here to open)",
|
||||
"expireInvalid": "expiration must be in the future",
|
||||
"createFailed": "failed to create share (is sharing enabled?)"
|
||||
},
|
||||
"updateServer": {
|
||||
"success": "server updated successfully",
|
||||
"title": "update server"
|
||||
@@ -276,6 +293,8 @@
|
||||
"moreFromGeneric": "more from {{item}}"
|
||||
},
|
||||
"albumList": {
|
||||
"artistAlbums": "Albums by {{artist}}",
|
||||
"genreAlbums": "\"{{genre}}\" $t(entity.album_other)",
|
||||
"title": "$t(entity.album_other)"
|
||||
},
|
||||
"appMenu": {
|
||||
@@ -306,7 +325,9 @@
|
||||
"removeFromFavorites": "$t(action.removeFromFavorites)",
|
||||
"removeFromPlaylist": "$t(action.removeFromPlaylist)",
|
||||
"removeFromQueue": "$t(action.removeFromQueue)",
|
||||
"setRating": "$t(action.setRating)"
|
||||
"setRating": "$t(action.setRating)",
|
||||
"shareItem": "share item",
|
||||
"showDetails": "get info"
|
||||
},
|
||||
"fullscreenPlayer": {
|
||||
"config": {
|
||||
@@ -329,6 +350,8 @@
|
||||
"upNext": "up next"
|
||||
},
|
||||
"genreList": {
|
||||
"showAlbums": "show $t(entity.genre_one) $t(entity.album_other)",
|
||||
"showTracks": "show $t(entity.genre_one) $t(entity.track_other)",
|
||||
"title": "$t(entity.genre_other)"
|
||||
},
|
||||
"globalSearch": {
|
||||
@@ -346,6 +369,11 @@
|
||||
"recentlyPlayed": "recently played",
|
||||
"title": "$t(common.home)"
|
||||
},
|
||||
"itemDetail": {
|
||||
"copyPath": "copy path to clipboard",
|
||||
"copiedPath": "path copied successfully",
|
||||
"openFile": "show track in file manager"
|
||||
},
|
||||
"playlistList": {
|
||||
"title": "$t(entity.playlist_other)"
|
||||
},
|
||||
@@ -462,6 +490,8 @@
|
||||
"gaplessAudio": "gapless audio",
|
||||
"gaplessAudio_description": "sets the gapless audio setting for mpv",
|
||||
"gaplessAudio_optionWeak": "weak (recommended)",
|
||||
"genreBehavior": "genre page default behavior",
|
||||
"genreBehavior_description": "determines whether clicking on a genre opens by default in track or album list",
|
||||
"globalMediaHotkeys": "global media hotkeys",
|
||||
"globalMediaHotkeys_description": "enable or disable the usage of your system media hotkeys to control playback",
|
||||
"homeConfiguration": "home page configuration",
|
||||
@@ -610,6 +640,7 @@
|
||||
"rating": "rating",
|
||||
"releaseDate": "release date",
|
||||
"releaseYear": "year",
|
||||
"size": "$t(common.size)",
|
||||
"songCount": "$t(entity.track_other)",
|
||||
"title": "title",
|
||||
"trackNumber": "track"
|
||||
|
||||
+84
-13
@@ -196,7 +196,21 @@
|
||||
"clearQueryCache_description": "una 'limpieza suave' de feishin. esto refrescará las listas de reproducción, metadatos de pistas y restablece las letras guardadas. se mantienen los ajustes, credenciales del servidor y las imágenes en caché",
|
||||
"buttonSize": "tamaño del botón de la barra de reproducción",
|
||||
"clearCache_description": "una 'limpieza fuerte' de feishin. para limpiar la caché de feishin, vacía la caché del navegador (imágenes guardadas y otros elementos). se mantienen las credenciales y ajustes del servidor",
|
||||
"buttonSize_description": "el tamaño de los botones de la barra de reproducción"
|
||||
"buttonSize_description": "el tamaño de los botones de la barra de reproducción",
|
||||
"passwordStore_description": "qué método de almacenamiento de contraseñas/claves secretas utilizar. cambie esta opción si tiene problemas para guardar contraseñas.",
|
||||
"startMinimized_description": "iniciar la aplicación en la bandeja del sistema",
|
||||
"startMinimized": "iniciar minimizado",
|
||||
"passwordStore": "contraseñas/almacenamiento secreto",
|
||||
"playerAlbumArtResolution_description": "la resolución para la vista previa de la carátula del álbum del reproductor grande. más grande hace que parezca más nítido, pero puede ralentizar la carga. El valor predeterminado es 0, lo que significa automático",
|
||||
"playerAlbumArtResolution": "resolución de la carátula del álbum del reproductor",
|
||||
"homeConfiguration": "Configuración de la página de inicio",
|
||||
"mpvExtraParameters_help": "Uno por línea",
|
||||
"genreBehavior": "Comportamiento predeterminado de la página de géneros",
|
||||
"externalLinks_description": "Permite mostrar enlaces externos (Last.fm, MusicBrainz) en páginas de artista/álbum",
|
||||
"genreBehavior_description": "Determina si al pulsar en un género se abre por defecto la lista de pistas o de álbumes",
|
||||
"homeConfiguration_description": "Configura qué elementos son mostrados y en qué orden en la página de inicio",
|
||||
"clearCacheSuccess": "Caché limpiada correctamente",
|
||||
"externalLinks": "Mostrar enlaces externos"
|
||||
},
|
||||
"action": {
|
||||
"editPlaylist": "editar $t(entity.playlist_one)",
|
||||
@@ -215,7 +229,11 @@
|
||||
"moveToBottom": "mover al fondo",
|
||||
"setRating": "establecer calificación",
|
||||
"toggleSmartPlaylistEditor": "cambiar editor $t(entity.smartPlaylist)",
|
||||
"removeFromFavorites": "eliminar de $t(entity.favorite_other)"
|
||||
"removeFromFavorites": "eliminar de $t(entity.favorite_other)",
|
||||
"openIn": {
|
||||
"lastfm": "Abrir en Last.fm",
|
||||
"musicbrainz": "Abrir en MusicBrainz"
|
||||
}
|
||||
},
|
||||
"common": {
|
||||
"backward": "hacia atrás",
|
||||
@@ -298,7 +316,17 @@
|
||||
"action_other": "acciones",
|
||||
"channel_one": "Canal",
|
||||
"channel_many": "Canales",
|
||||
"channel_other": "Canales"
|
||||
"channel_other": "Canales",
|
||||
"trackPeak": "la más alta de la canción",
|
||||
"albumPeak": "lo más destacado del álbum",
|
||||
"albumGain": "Ganancia de álbum",
|
||||
"mbid": "ID de MusicBrainz",
|
||||
"codec": "Códec",
|
||||
"close": "Cerrar",
|
||||
"reload": "Recargar",
|
||||
"share": "Compartir",
|
||||
"trackGain": "Ganancia de pista",
|
||||
"preview": "Vista previa"
|
||||
},
|
||||
"error": {
|
||||
"remotePortWarning": "reiniciar el servidor para aplicar el nuevo puerto",
|
||||
@@ -319,7 +347,10 @@
|
||||
"mpvRequired": "MPV requerido",
|
||||
"audioDeviceFetchError": "un error ocurrió cuando se intentó obtener los dispositivos de audio",
|
||||
"invalidServer": "servidor inválido",
|
||||
"loginRateError": "demasiados intentos de inicio de sesión, por favor inténtalo en unos segundos"
|
||||
"loginRateError": "demasiados intentos de inicio de sesión, por favor inténtalo en unos segundos",
|
||||
"badAlbum": "Estás viendo esta página porque esta canción no forma parte de un álbum. Este problema puede ocurrir si tiene una canción en el nivel superior de su 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"
|
||||
},
|
||||
"filter": {
|
||||
"mostPlayed": "más reproducido",
|
||||
@@ -377,7 +408,8 @@
|
||||
"settings": "$t(common.setting_other)",
|
||||
"home": "$t(common.home)",
|
||||
"artists": "$t(entity.artist_other)",
|
||||
"albumArtists": "$t(entity.albumArtist_other)"
|
||||
"albumArtists": "$t(entity.albumArtist_other)",
|
||||
"shared": "compartido $t(entity.playlist_other)"
|
||||
},
|
||||
"appMenu": {
|
||||
"selectServer": "seleccionar servidor",
|
||||
@@ -407,7 +439,9 @@
|
||||
"addFavorite": "$t(action.addToFavorites)",
|
||||
"play": "$t(player.play)",
|
||||
"numberSelected": "{{count}} seleccionado",
|
||||
"removeFromQueue": "$t(action.removeFromQueue)"
|
||||
"removeFromQueue": "$t(action.removeFromQueue)",
|
||||
"shareItem": "Compartir elemento",
|
||||
"showDetails": "Obtener información"
|
||||
},
|
||||
"home": {
|
||||
"mostPlayed": "más reproducidos",
|
||||
@@ -429,7 +463,9 @@
|
||||
"lyricAlignment": "alineación de letra",
|
||||
"useImageAspectRatio": "usar ratio de aspecto de imagen",
|
||||
"showLyricMatch": "mostrar coincidencia de letras",
|
||||
"lyricGap": "desfase de letra"
|
||||
"lyricGap": "desfase de letra",
|
||||
"dynamicImageBlur": "tamaño de desenfoque de imagen",
|
||||
"dynamicIsImage": "habilitar imagen de fondo"
|
||||
},
|
||||
"lyrics": "letras",
|
||||
"related": "relacionado"
|
||||
@@ -448,10 +484,14 @@
|
||||
"title": "$t(entity.albumArtist_other)"
|
||||
},
|
||||
"genreList": {
|
||||
"title": "$t(entity.genre_other)"
|
||||
"title": "$t(entity.genre_other)",
|
||||
"showAlbums": "Mostrar $t(entity.genre_one) $t(entity.album_other)",
|
||||
"showTracks": "Mostrar $t(entity.genre_one) $t(entity.track_other)"
|
||||
},
|
||||
"trackList": {
|
||||
"title": "$t(entity.track_other)"
|
||||
"title": "$t(entity.track_other)",
|
||||
"genreTracks": "\"{{genre}}\" $t(entity.track_other)",
|
||||
"artistTracks": "Pistas de {{artist}}"
|
||||
},
|
||||
"globalSearch": {
|
||||
"commands": {
|
||||
@@ -465,7 +505,25 @@
|
||||
"title": "$t(entity.playlist_other)"
|
||||
},
|
||||
"albumList": {
|
||||
"title": "$t(entity.album_other)"
|
||||
"title": "$t(entity.album_other)",
|
||||
"genreAlbums": "\"{{genre}}\" $t(entity.album_other)",
|
||||
"artistAlbums": "Álbumes de {{artist}}"
|
||||
},
|
||||
"albumArtistDetail": {
|
||||
"viewAllTracks": "ver todo de $t(entity.track_other)",
|
||||
"relatedArtists": "similar a $t(entity.artist_other)",
|
||||
"topSongs": "mejores canciones",
|
||||
"topSongsFrom": "Las mejores canciones de {{title}}",
|
||||
"viewAll": "Ver todo",
|
||||
"recentReleases": "Lanzamientos recientes",
|
||||
"viewDiscography": "Ver discografía",
|
||||
"about": "Sobre {{artist}}",
|
||||
"appearsOn": "Aparece en"
|
||||
},
|
||||
"itemDetail": {
|
||||
"copiedPath": "Ruta copiada correctamente",
|
||||
"openFile": "Mostrar pista en el gestor de archivos",
|
||||
"copyPath": "Copiar ruta al portapapeles"
|
||||
}
|
||||
},
|
||||
"form": {
|
||||
@@ -516,6 +574,14 @@
|
||||
"queryEditor": {
|
||||
"input_optionMatchAll": "coincidir todos",
|
||||
"input_optionMatchAny": "coincidir cualquiera"
|
||||
},
|
||||
"shareItem": {
|
||||
"createFailed": "No se pudo crear el recurso compartido (¿está habilitado el uso compartido?)",
|
||||
"allowDownloading": "Permitir la descarga",
|
||||
"description": "Descripción",
|
||||
"setExpiration": "Establecer expiración",
|
||||
"success": "Enlace de compartición copiado al portapapeles (o pulsa aquí para abrir)",
|
||||
"expireInvalid": "La expiración debe ser en el futuro"
|
||||
}
|
||||
},
|
||||
"table": {
|
||||
@@ -541,7 +607,9 @@
|
||||
"albumArtist": "artista de álbum",
|
||||
"path": "ruta",
|
||||
"discNumber": "disco",
|
||||
"channels": "$t(common.channel_other)"
|
||||
"channels": "$t(common.channel_other)",
|
||||
"size": "$t(common.size)",
|
||||
"codec": "$t(common.codec)"
|
||||
},
|
||||
"config": {
|
||||
"label": {
|
||||
@@ -570,14 +638,17 @@
|
||||
"playCount": "número de reproducción",
|
||||
"genre": "$t(entity.genre_one)",
|
||||
"favorite": "$t(common.favorite)",
|
||||
"year": "$t(common.year)"
|
||||
"year": "$t(common.year)",
|
||||
"codec": "$t(common.codec)"
|
||||
},
|
||||
"general": {
|
||||
"gap": "$t(common.gap)",
|
||||
"tableColumns": "columnas de la tabla",
|
||||
"autoFitColumns": "ajuste automático de columnas",
|
||||
"size": "$t(common.size)",
|
||||
"displayType": "tipo de visualización"
|
||||
"displayType": "tipo de visualización",
|
||||
"itemGap": "espacio entre elementos (px)",
|
||||
"itemSize": "tamaño del elemento (px)"
|
||||
},
|
||||
"view": {
|
||||
"card": "tarjeta",
|
||||
|
||||
@@ -253,7 +253,7 @@
|
||||
"moreFromGeneric": "plus de {{item}}"
|
||||
},
|
||||
"setting": {
|
||||
"generalTab": "générale",
|
||||
"generalTab": "général",
|
||||
"hotkeysTab": "raccourci",
|
||||
"windowTab": "fenêtre",
|
||||
"playbackTab": "lecteur"
|
||||
@@ -328,10 +328,10 @@
|
||||
"scrobble": "scrobble",
|
||||
"enableRemote_description": "activer le serveur de contrôle à distance, qui permet à d'autres appareils de contrôler l'application",
|
||||
"fontType_optionSystem": "police système",
|
||||
"mpvExecutablePath_description": "définit le chemin vers l'exécutable mpv",
|
||||
"mpvExecutablePath_description": "définit le chemin vers l'exécutable mpv, si vide, le chemin par défaut sera utilisé",
|
||||
"hotkey_favoriteCurrentSong": "favori $t(common.currentSong)",
|
||||
"sampleRate": "taux d'échantillonnage",
|
||||
"sampleRate_description": "sélectionnez le taux d'échantillonnage de sortie utilisé si la fréquence d'échantillonnage sélectionnée est différente de celle du média actuel",
|
||||
"sampleRate_description": "sélectionner le taux d'échantillonnage de sortie utilisé si la fréquence d'échantillonnage sélectionnée est différente de celle du média actuel. une valeur en inférieur à 8000 utilisera la fréquence par défaut",
|
||||
"hotkey_zoomIn": "zoom avant",
|
||||
"scrobble_description": "scrobble les lectures à votre serveur multimédia",
|
||||
"hotkey_browserForward": "avancer",
|
||||
@@ -399,7 +399,7 @@
|
||||
"lyricFetchProvider_description": "sélectionnez le fournisseur auprès desquels récupérer les paroles. l'ordre des fournisseurs et l'ordre dans lequel ils seront interrogés",
|
||||
"globalMediaHotkeys_description": "active ou désactive l'utilisation des raccourcis clavier multimédia système pour contrôler la lecture",
|
||||
"followLyric": "suivre les paroles actuelles",
|
||||
"discordIdleStatus": "afficher l'état d'inactivité dans le status de l'activité",
|
||||
"discordIdleStatus": "afficher l'état d'inactivité dans le statut de l'activité",
|
||||
"hotkey_zoomOut": "zoom arrière",
|
||||
"hotkey_unfavoriteCurrentSong": "retirer des favoris la $t(common.currentSong)",
|
||||
"hotkey_rate0": "supprimer la note",
|
||||
@@ -437,7 +437,7 @@
|
||||
"skipPlaylistPage_description": "lors de la navigation dans une playlist, aller directement vers le liste des morceaux, au lieu de la page par défaut",
|
||||
"volumeWheelStep": "valeur du pas de volume",
|
||||
"windowBarStyle": "style de la barre de la fenêtre",
|
||||
"useSystemTheme_description": "suivre les préférence du système (sombre ou clair)",
|
||||
"useSystemTheme_description": "suivre les préférences du système (sombre ou clair)",
|
||||
"skipPlaylistPage": "sauter la page de playlist",
|
||||
"themeDark": "thème (sombre)",
|
||||
"windowBarStyle_description": "sélectionner le style de la barre de la fenêtre",
|
||||
@@ -460,7 +460,13 @@
|
||||
"replayGainClipping": "{{ReplayGain}} clipping",
|
||||
"replayGainMode": "mode de {{ReplayGain}}",
|
||||
"replayGainFallback_description": "gain en dB à appliquer si le fichier n'a pas de tag {{ReplayGain}}",
|
||||
"replayGainPreamp_description": "ajuste le gain de préampli appliqué a la valeur de {{ReplayGain}}"
|
||||
"replayGainPreamp_description": "ajuste le gain de préampli appliqué a la valeur de {{ReplayGain}}",
|
||||
"clearQueryCache": "vide le cache de feishin",
|
||||
"clearCache": "Vider le cache navigateur",
|
||||
"buttonSize_description": "la taille des boutons de la barre de lecture",
|
||||
"clearQueryCache_description": "un 'soft clear' de feishin. cela actualisera les playlists, les métadonnées des pistes, et réinitialisera les paroles enregistrées. les paramètres, identifiants serveurs et les images mises en cache sont conservés",
|
||||
"clearCache_description": "un 'hard clear' de feishin. en plus de vider le cache de feishin, vide le cache du navigateur (images sauvegardées et autres ressources). les identifiants serveurs et paramètres sont conservés",
|
||||
"buttonSize": "taille des boutons de la barre de lecture"
|
||||
},
|
||||
"form": {
|
||||
"deletePlaylist": {
|
||||
@@ -482,7 +488,7 @@
|
||||
"error_savePassword": "une erreur s’est produite lors de la tentative de sauvegarde du mot de passe"
|
||||
},
|
||||
"addToPlaylist": {
|
||||
"success": "{{message}} $t(entity.track_other) ajouté à {{numOfPlaylists}} $t(entity.playlist_other)",
|
||||
"success": "$t(entity.trackWithCount, {\"count\": {{message}} }) ajouté à $t(entity.playlistWithCount, {\"count\": {{numOfPlaylists}} })",
|
||||
"title": "ajouter à $t(entity.playlist_one)",
|
||||
"input_skipDuplicates": "sauter les doublons",
|
||||
"input_playlists": "$t(entity.playlist_other)"
|
||||
@@ -625,7 +631,8 @@
|
||||
"artist": "$t(entity.artist_one)",
|
||||
"genre": "$t(entity.genre_one)",
|
||||
"songCount": "$t(entity.track_other)",
|
||||
"channels": "$t(common.channel_other)"
|
||||
"channels": "$t(common.channel_other)",
|
||||
"size": "$t(common.size)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -288,7 +288,12 @@
|
||||
"replayGainFallback_description": "gain in db da applicare se il file non possiede tag {{ReplayGain}}",
|
||||
"replayGainPreamp_description": "aggiusta la preamplificazione del gain applicato sui valori {{ReplayGain}}",
|
||||
"skipPlaylistPage": "Salta la pagina playlist",
|
||||
"sidebarCollapsedNavigation": "navigazione con barra laterale (collassata)"
|
||||
"sidebarCollapsedNavigation": "navigazione con barra laterale (collassata)",
|
||||
"clearCache_description": "pulitura \"forzata\" di feishin. Oltre a pulire la cache di feishin, elimina la cache del browser(immagini salvate e altri elementi). credenziali e impostazioni del server saranno mantenute",
|
||||
"clearQueryCache": "pulisci cache di feishin",
|
||||
"buttonSize_description": "Dimensione bottoni nella barra di riproduzione",
|
||||
"clearCache": "pulisci la cache del browser",
|
||||
"clearQueryCache_description": "\"leggera\" pulizia di feishin. verranno aggiornate le playlist, metadata delle tracce e i testi salvati. impostazioni, credenziali del server e le immagini salvate saranno mantenute"
|
||||
},
|
||||
"error": {
|
||||
"remotePortWarning": "riavvia il server per applicare la nuova porta",
|
||||
@@ -572,7 +577,8 @@
|
||||
"albumArtist": "artista album",
|
||||
"path": "percorso",
|
||||
"discNumber": "disco",
|
||||
"channels": "$t(common.channel_other)"
|
||||
"channels": "$t(common.channel_other)",
|
||||
"size": "$t(common.size)"
|
||||
}
|
||||
},
|
||||
"entity": {
|
||||
|
||||
@@ -353,7 +353,8 @@
|
||||
"albumArtist": "アルバムアーティスト",
|
||||
"path": "パス",
|
||||
"discNumber": "ディスク",
|
||||
"channels": "$t(common.channel_other)"
|
||||
"channels": "$t(common.channel_other)",
|
||||
"size": "$t(common.size)"
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
|
||||
@@ -249,7 +249,8 @@
|
||||
},
|
||||
"table": {
|
||||
"column": {
|
||||
"rating": "rating"
|
||||
"rating": "rating",
|
||||
"size": "$t(common.size)"
|
||||
},
|
||||
"config": {
|
||||
"label": {
|
||||
|
||||
@@ -489,7 +489,7 @@
|
||||
"font_description": "ustaw czcionkę dla aplikacji",
|
||||
"playButtonBehavior_optionPlay": "$t(player.play)",
|
||||
"minimumScrobblePercentage": "minimalny czas trwania scrobble (procentowy)",
|
||||
"mpvExecutablePath_description": "ustaw ścieżkę dla plików wykonywalnych mpv",
|
||||
"mpvExecutablePath_description": "ustaw ścieżkę dla plików wykonywalnych mpv. gdy puste, zostanie użyta domyślna ścieżka",
|
||||
"playButtonBehavior_optionAddLast": "$t(player.addLast)",
|
||||
"minimizeToTray_description": "zminimalizuj aplikację do zasobnika systemowego",
|
||||
"remotePassword": "hasło dla serwera zdalnej kontroli",
|
||||
@@ -518,7 +518,7 @@
|
||||
"sampleRate": "częstotliwość próbkowania",
|
||||
"sidePlayQueueStyle_optionAttached": "przyłączony",
|
||||
"sidebarConfiguration": "konfiguracja paska bocznego",
|
||||
"sampleRate_description": "wybierz wyjściową częstotliwość próbkowania, która ma być używana, jeśli wybrana częstotliwość próbkowania różni się od częstotliwości bieżącego utworu",
|
||||
"sampleRate_description": "wybierz wyjściową częstotliwość próbkowania, która ma być używana, jeśli wybrana częstotliwość próbkowania różni się od częstotliwości bieżącego utworu. wartość mniejsza niż 8000 spowoduje użycie częstotliwości domyślnej",
|
||||
"replayGainMode_optionNone": "$t(common.none)",
|
||||
"replayGainClipping": "wzmocnienie {{ReplayGain}}",
|
||||
"scrobble_description": "odtwarzanie scrobble na serwerze multimediów",
|
||||
@@ -558,7 +558,13 @@
|
||||
"skipPlaylistPage": "pomiń stronę list odtwarzania",
|
||||
"themeDark": "motyw (ciemny)",
|
||||
"windowBarStyle_description": "wybierz styl paska okna",
|
||||
"useSystemTheme": "użyj motywu systemowego"
|
||||
"useSystemTheme": "użyj motywu systemowego",
|
||||
"buttonSize": "Rozmiar przycisku paska odtwarzacza",
|
||||
"clearQueryCache": "wyczyść pamięć podręczną feishin",
|
||||
"clearCache_description": "\"twarde wyczyszczenie\" feishin. oprócz wyczyszczenia pamięci podręcznej feishin, opróżnij pamięć podręczną przeglądarki (zapisane obrazy i inne zasoby). dane i ustawienia serwera zostaną zachowane",
|
||||
"clearQueryCache_description": "\"miękkie wyczyszczenie\" feishin. spowoduje to odświeżenie list odtwarzania, metadanych utworów i zresetowanie zapisanych tekstów. ustawienia, dane uwierzytelniające serwera i obrazy w pamięci podręcznej zostaną zachowane",
|
||||
"buttonSize_description": "rozmiar przycisków paska odtwarzacza",
|
||||
"clearCache": "wyczyść pamięć podręczną przeglądarki"
|
||||
},
|
||||
"table": {
|
||||
"config": {
|
||||
@@ -625,7 +631,8 @@
|
||||
"albumArtist": "artysta albumu",
|
||||
"path": "ścieżka",
|
||||
"discNumber": "płyta",
|
||||
"channels": "$t(common.channel_other)"
|
||||
"channels": "$t(common.channel_other)",
|
||||
"size": "$t(common.size)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -80,7 +80,13 @@
|
||||
"yes": "sim",
|
||||
"random": "aleatório",
|
||||
"size": "tamanho",
|
||||
"note": "observação"
|
||||
"note": "observação",
|
||||
"mbid": "ID no MusicBrainz",
|
||||
"reload": "recarregar",
|
||||
"codec": "codec",
|
||||
"preview": "pré-visualizar",
|
||||
"share": "compartilhar",
|
||||
"close": "fechar"
|
||||
},
|
||||
"action": {
|
||||
"goToPage": "vá para página",
|
||||
@@ -98,17 +104,34 @@
|
||||
"removeFromPlaylist": "remover da $t(entity.playlist_one)",
|
||||
"deletePlaylist": "deletar $t(entity.playlist_one)",
|
||||
"deselectAll": "desmarcar todos",
|
||||
"removeFromFavorites": "remover de $t(entity.favorite_other)"
|
||||
"removeFromFavorites": "remover de $t(entity.favorite_other)",
|
||||
"openIn": {
|
||||
"lastfm": "Abrir em Last.fm",
|
||||
"musicbrainz": "Abrir em MusicBrainz"
|
||||
}
|
||||
},
|
||||
"form": {
|
||||
"deletePlaylist": {
|
||||
"title": "deletar $t(entity.playlist_one)"
|
||||
"title": "deletar $t(entity.playlist_one)",
|
||||
"input_confirm": "escreva o nome da $t(entity.playlist_one) para confirmar"
|
||||
},
|
||||
"addServer": {
|
||||
"title": "adicionar servidor"
|
||||
"title": "adicionar servidor",
|
||||
"input_password": "senha",
|
||||
"input_legacyAuthentication": "habilitar autenticação legada",
|
||||
"error_savePassword": "um erro ocorreu ao tentar salvar a senha",
|
||||
"ignoreSsl": "ignorar ssl ($t(common.restartRequired))",
|
||||
"input_savePassword": "salvar senha",
|
||||
"input_url": "url",
|
||||
"success": "servidor adicionado com sucesso",
|
||||
"input_name": "nome do servidor",
|
||||
"input_username": "nome de usuário"
|
||||
},
|
||||
"createPlaylist": {
|
||||
"title": "criar $t(entity.playlist_one)"
|
||||
"title": "criar $t(entity.playlist_one)",
|
||||
"input_public": "público",
|
||||
"input_description": "$t(common.description)",
|
||||
"success": "$t(entity.playlist_one) criada com sucesso"
|
||||
},
|
||||
"updateServer": {
|
||||
"title": "atualizar servidor"
|
||||
@@ -117,7 +140,10 @@
|
||||
"title": "editar $t(entity.playlist_one)"
|
||||
},
|
||||
"addToPlaylist": {
|
||||
"title": "adicionar à $t(entity.playlist_one)"
|
||||
"title": "adicionar à $t(entity.playlist_one)",
|
||||
"input_playlists": "$t(entity.playlist_other)",
|
||||
"input_skipDuplicates": "pular duplicadas",
|
||||
"success": "adicionado $t(entity.trackWithCount, {\"count\": {{message}} }) para $t(entity.playlistWithCount, {\"count\": {{numOfPlaylists}} })"
|
||||
},
|
||||
"lyricSearch": {
|
||||
"title": "pesquisa de letras"
|
||||
@@ -139,7 +165,8 @@
|
||||
},
|
||||
"column": {
|
||||
"title": "titulo",
|
||||
"discNumber": "disco"
|
||||
"discNumber": "disco",
|
||||
"size": "$t(common.size)"
|
||||
}
|
||||
},
|
||||
"page": {
|
||||
@@ -175,7 +202,17 @@
|
||||
"filter": {
|
||||
"title": "titulo",
|
||||
"disc": "disco",
|
||||
"mostPlayed": "mais tocado"
|
||||
"mostPlayed": "mais tocado",
|
||||
"album": "$t(entity.album_one)",
|
||||
"name": "nome",
|
||||
"biography": "bibliografia",
|
||||
"duration": "duração",
|
||||
"favorited": "favoritado",
|
||||
"fromYear": "a partir do ano",
|
||||
"songCount": "contador de músicas",
|
||||
"toYear": "até o ano",
|
||||
"random": "aleatório",
|
||||
"search": "buscar"
|
||||
},
|
||||
"player": {
|
||||
"playbackFetchNoResults": "nenhuma música encontrada",
|
||||
@@ -241,6 +278,9 @@
|
||||
"mpvRequired": "MPV necessário",
|
||||
"audioDeviceFetchError": "ocorreu um erro ao tentar obter dispositivos de áudio",
|
||||
"invalidServer": "servidor inválido",
|
||||
"loginRateError": "muitas tentativas de login, tente novamente em alguns segundos"
|
||||
"loginRateError": "muitas tentativas de login, tente novamente em alguns segundos",
|
||||
"badAlbum": "você está vendo este erro por que está música não é parte de algum album. um motivo comum para você estar vendo este erro é se a sua música estiver na raiz da sua pasta de músicas. o jellyfin apenas agrupa as músicas se elas estiveram na mesma pasta.",
|
||||
"networkError": "ocorreu um erro na internet",
|
||||
"openError": "não foi possível abrir o arquivo"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -204,7 +204,8 @@
|
||||
"trackNumber": "трек",
|
||||
"genre": "$t(entity.genre_one)",
|
||||
"path": "путь",
|
||||
"discNumber": "диск"
|
||||
"discNumber": "диск",
|
||||
"size": "$t(common.size)"
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
|
||||
@@ -359,7 +359,8 @@
|
||||
"albumArtist": "album artist",
|
||||
"path": "putanja",
|
||||
"discNumber": "disk",
|
||||
"channels": "$t(common.channel_other)"
|
||||
"channels": "$t(common.channel_other)",
|
||||
"size": "$t(common.size)"
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
|
||||
@@ -16,7 +16,11 @@
|
||||
"setRating": "评分",
|
||||
"toggleSmartPlaylistEditor": "切换$t(entity.smartPlaylist)编辑器",
|
||||
"removeFromFavorites": "从$t(entity.favorite_other)移除",
|
||||
"goToPage": "转到页面"
|
||||
"goToPage": "转到页面",
|
||||
"openIn": {
|
||||
"lastfm": "在 Last.fm 中打开",
|
||||
"musicbrainz": "在 MusicBrainz 中打开"
|
||||
}
|
||||
},
|
||||
"common": {
|
||||
"increase": "增高",
|
||||
@@ -93,7 +97,17 @@
|
||||
"yes": "是",
|
||||
"size": "大小",
|
||||
"areYouSure": "是否继续?",
|
||||
"note": "注释"
|
||||
"note": "注释",
|
||||
"close": "关闭",
|
||||
"albumPeak": "专辑峰值",
|
||||
"mbid": "MusicBrainz ID",
|
||||
"reload": "重新加载",
|
||||
"trackGain": "音轨增益",
|
||||
"trackPeak": "音轨峰值",
|
||||
"albumGain": "专辑增益",
|
||||
"codec": "编解码器",
|
||||
"share": "分享",
|
||||
"preview": "预览"
|
||||
},
|
||||
"entity": {
|
||||
"albumArtist_other": "专辑艺术家",
|
||||
@@ -310,7 +324,21 @@
|
||||
"buttonSize_description": "播放器栏按钮大小",
|
||||
"clearCache_description": "feishin的“硬清除”。除了清除feishin的缓存,清空浏览器缓存(保存的图像和其他资源)。会保留服务器凭据和设置",
|
||||
"clearQueryCache_description": "feishin的“软清除”。这将会刷新播放列表、元数据并重置保存的歌词。会保留设置、服务器凭据和缓存图像",
|
||||
"clearQueryCache": "清除feishin缓存"
|
||||
"clearQueryCache": "清除feishin缓存",
|
||||
"externalLinks": "显示外部链接",
|
||||
"externalLinks_description": "允许在艺术家/专辑页面上显示外部链接(Last.fm、MusicBrainz)",
|
||||
"mpvExtraParameters_help": "每行一个",
|
||||
"startMinimized": "启动最小化",
|
||||
"startMinimized_description": "在系统托盘中启动应用程序",
|
||||
"passwordStore_description": "使用什么密码/秘密存储。如果您在存储密码时遇到问题,请更改此设置。",
|
||||
"clearCacheSuccess": "缓存清除成功",
|
||||
"playerAlbumArtResolution": "播放器专辑封面分辨率",
|
||||
"playerAlbumArtResolution_description": "大型播放器专辑封面预览的分辨率。较大使其看起来更清晰,但可能会减慢加载速度。默认为0,表示自动",
|
||||
"genreBehavior": "类型页面默认行为",
|
||||
"genreBehavior_description": "确定单击流派是否默认在曲目或专辑列表中打开",
|
||||
"homeConfiguration": "主页配置",
|
||||
"homeConfiguration_description": "配置主页上显示的项目以及显示顺序",
|
||||
"passwordStore": "密码/秘密存储"
|
||||
},
|
||||
"error": {
|
||||
"remotePortWarning": "重启服务器使新端口生效",
|
||||
@@ -331,7 +359,10 @@
|
||||
"mpvRequired": "需要 MPV",
|
||||
"audioDeviceFetchError": "无法获取音频设备",
|
||||
"invalidServer": "无效的服务器",
|
||||
"loginRateError": "登录请求尝试次数过多,请稍后再试"
|
||||
"loginRateError": "登录请求尝试次数过多,请稍后再试",
|
||||
"badAlbum": "您看到此页面是因为这首歌不是专辑的一部分。如果您的音乐文件夹顶层有一首歌曲,您很可能会遇到此问题。jellyfin 仅对位于文件夹中的曲目进行分组。",
|
||||
"networkError": "发生网络错误",
|
||||
"openError": "无法打开文件"
|
||||
},
|
||||
"filter": {
|
||||
"mostPlayed": "播放最多",
|
||||
@@ -389,7 +420,8 @@
|
||||
"settings": "$t(common.setting_other)",
|
||||
"home": "$t(common.home)",
|
||||
"artists": "$t(entity.artist_other)",
|
||||
"albumArtists": "$t(entity.albumArtist_other)"
|
||||
"albumArtists": "$t(entity.albumArtist_other)",
|
||||
"shared": "共享 $t(entity.playlist_other)"
|
||||
},
|
||||
"fullscreenPlayer": {
|
||||
"config": {
|
||||
@@ -403,7 +435,9 @@
|
||||
"lyricAlignment": "歌词对齐",
|
||||
"useImageAspectRatio": "使用图片纵横比",
|
||||
"lyricGap": "歌词间距",
|
||||
"followCurrentLyric": "跟随当前歌词"
|
||||
"followCurrentLyric": "跟随当前歌词",
|
||||
"dynamicImageBlur": "图像模糊大小",
|
||||
"dynamicIsImage": "启用背景图像"
|
||||
},
|
||||
"lyrics": "歌词",
|
||||
"related": "相关",
|
||||
@@ -462,22 +496,46 @@
|
||||
"addNext": "$t(player.addNext)",
|
||||
"deselectAll": "$t(action.deselectAll)",
|
||||
"addLast": "$t(player.addLast)",
|
||||
"addFavorite": "$t(action.addToFavorites)"
|
||||
"addFavorite": "$t(action.addToFavorites)",
|
||||
"showDetails": "获取信息",
|
||||
"shareItem": "分享项目"
|
||||
},
|
||||
"trackList": {
|
||||
"title": "$t(entity.track_other)"
|
||||
"title": "$t(entity.track_other)",
|
||||
"genreTracks": "\"{{genre}}\" $t(entity.track_other)",
|
||||
"artistTracks": "{{artist}} 的曲目"
|
||||
},
|
||||
"albumArtistList": {
|
||||
"title": "$t(entity.albumArtist_other)"
|
||||
},
|
||||
"albumList": {
|
||||
"title": "$t(entity.album_other)"
|
||||
"title": "$t(entity.album_other)",
|
||||
"artistAlbums": "{{artist}} 的专辑",
|
||||
"genreAlbums": "\"{{genre}}\" $t(entity.album_other)"
|
||||
},
|
||||
"genreList": {
|
||||
"title": "$t(entity.genre_other)"
|
||||
"title": "$t(entity.genre_other)",
|
||||
"showAlbums": "显示 $t(entity.genre_one) $t(entity.album_other)",
|
||||
"showTracks": "显示 $t(entity.genre_one) $t(entity.track_other)"
|
||||
},
|
||||
"playlistList": {
|
||||
"title": "$t(entity.playlist_other)"
|
||||
},
|
||||
"albumArtistDetail": {
|
||||
"recentReleases": "最近发布",
|
||||
"viewDiscography": "查看唱片目录",
|
||||
"relatedArtists": "相关 $t(entity.artist_other)",
|
||||
"topSongs": "热门歌曲",
|
||||
"topSongsFrom": "{{title}} 的热门歌曲",
|
||||
"viewAllTracks": "查看所有 $t(entity.track_other)",
|
||||
"about": "关于 {{artist}}",
|
||||
"appearsOn": "出现在",
|
||||
"viewAll": "查看全部"
|
||||
},
|
||||
"itemDetail": {
|
||||
"copyPath": "将路径复制到剪贴板",
|
||||
"copiedPath": "路径复制成功",
|
||||
"openFile": "在文件管理器中显示曲目"
|
||||
}
|
||||
},
|
||||
"form": {
|
||||
@@ -528,6 +586,14 @@
|
||||
"title": "搜索歌词",
|
||||
"input_name": "$t(common.name)",
|
||||
"input_artist": "$t(entity.artist_one)"
|
||||
},
|
||||
"shareItem": {
|
||||
"expireInvalid": "过期时间必须是将来的时间",
|
||||
"createFailed": "创建共享失败(是否启用共享?)",
|
||||
"allowDownloading": "允许下载",
|
||||
"description": "描述",
|
||||
"setExpiration": "设置过期时间",
|
||||
"success": "共享链接已复制到剪贴板(或单击此处打开)"
|
||||
}
|
||||
},
|
||||
"table": {
|
||||
@@ -537,7 +603,9 @@
|
||||
"gap": "$t(common.gap)",
|
||||
"tableColumns": "列",
|
||||
"autoFitColumns": "列宽自适应",
|
||||
"size": "$t(common.size)"
|
||||
"size": "$t(common.size)",
|
||||
"itemGap": "项目间隙(px)",
|
||||
"itemSize": "项目大小 (px)"
|
||||
},
|
||||
"view": {
|
||||
"table": "表格",
|
||||
@@ -570,7 +638,8 @@
|
||||
"favorite": "$t(common.favorite)",
|
||||
"year": "$t(common.year)",
|
||||
"albumArtist": "$t(entity.albumArtist_one)",
|
||||
"titleCombined": "$t(common.title)(合并)"
|
||||
"titleCombined": "$t(common.title)(合并)",
|
||||
"codec": "$t(common.codec)"
|
||||
}
|
||||
},
|
||||
"column": {
|
||||
@@ -595,7 +664,9 @@
|
||||
"albumArtist": "专辑艺术家",
|
||||
"path": "路径",
|
||||
"channels": "$t(common.channel_other)",
|
||||
"discNumber": "盘"
|
||||
"discNumber": "盘",
|
||||
"size": "$t(common.size)",
|
||||
"codec": "$t(common.codec)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -463,7 +463,8 @@
|
||||
"bpm": "bpm",
|
||||
"songCount": "$t(entity.track_other)",
|
||||
"title": "標題",
|
||||
"trackNumber": "音軌編號"
|
||||
"trackNumber": "音軌編號",
|
||||
"size": "$t(common.size)"
|
||||
}
|
||||
},
|
||||
"action": {
|
||||
|
||||
@@ -72,7 +72,7 @@ const getRemoteLyrics = async (song: QueueSong) => {
|
||||
const params = {
|
||||
album: song.album || song.name,
|
||||
artist: song.artistName,
|
||||
duration: song.duration,
|
||||
duration: song.duration / 1000.0,
|
||||
name: song.name,
|
||||
};
|
||||
const response = await FETCHERS[source](params);
|
||||
|
||||
@@ -180,7 +180,11 @@ ipcMain.handle(
|
||||
|
||||
// Clean up previous mpv instance
|
||||
getMpvInstance()?.stop();
|
||||
getMpvInstance()?.quit();
|
||||
getMpvInstance()
|
||||
?.quit()
|
||||
.catch((error) => {
|
||||
mpvLog({ action: 'Failed to quit existing MPV' }, error);
|
||||
});
|
||||
mpvInstance = null;
|
||||
|
||||
mpvInstance = await createMpv(data);
|
||||
@@ -211,11 +215,12 @@ ipcMain.handle(
|
||||
|
||||
ipcMain.on('player-quit', async () => {
|
||||
try {
|
||||
getMpvInstance()?.stop();
|
||||
getMpvInstance()?.quit();
|
||||
mpvInstance = null;
|
||||
await getMpvInstance()?.stop();
|
||||
await getMpvInstance()?.quit();
|
||||
} catch (err: NodeMpvError | any) {
|
||||
mpvLog({ action: 'Failed to quit mpv' }, err);
|
||||
} finally {
|
||||
mpvInstance = null;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -301,7 +306,7 @@ ipcMain.on('player-seek-to', async (_event, time: number) => {
|
||||
|
||||
// Sets the queue in position 0 and 1 to the given data. Used when manually starting a song or using the next/prev buttons
|
||||
ipcMain.on('player-set-queue', async (_event, data: PlayerData, pause?: boolean) => {
|
||||
if (!data.queue.current && !data.queue.next) {
|
||||
if (!data.queue.current?.id && !data.queue.next?.id) {
|
||||
try {
|
||||
await getMpvInstance()?.clearPlaylist();
|
||||
await getMpvInstance()?.pause();
|
||||
@@ -312,14 +317,14 @@ ipcMain.on('player-set-queue', async (_event, data: PlayerData, pause?: boolean)
|
||||
}
|
||||
|
||||
try {
|
||||
if (data.queue.current) {
|
||||
if (data.queue.current?.streamUrl) {
|
||||
await getMpvInstance()
|
||||
?.load(data.queue.current.streamUrl, 'replace')
|
||||
.catch(() => {
|
||||
getMpvInstance()?.play();
|
||||
});
|
||||
|
||||
if (data.queue.next) {
|
||||
if (data.queue.next?.streamUrl) {
|
||||
await getMpvInstance()?.load(data.queue.next.streamUrl, 'append');
|
||||
}
|
||||
}
|
||||
@@ -348,7 +353,7 @@ ipcMain.on('player-set-queue-next', async (_event, data: PlayerData) => {
|
||||
await getMpvInstance()?.playlistRemove(1);
|
||||
}
|
||||
|
||||
if (data.queue.next) {
|
||||
if (data.queue.next?.streamUrl) {
|
||||
await getMpvInstance()?.load(data.queue.next.streamUrl, 'append');
|
||||
}
|
||||
} catch (err: NodeMpvError | any) {
|
||||
@@ -368,7 +373,7 @@ ipcMain.on('player-auto-next', async (_event, data: PlayerData) => {
|
||||
getMpvInstance()?.pause();
|
||||
});
|
||||
|
||||
if (data.queue.next) {
|
||||
if (data.queue.next?.streamUrl) {
|
||||
await getMpvInstance()?.load(data.queue.next.streamUrl, 'append');
|
||||
}
|
||||
} catch (err: NodeMpvError | any) {
|
||||
@@ -407,11 +412,19 @@ ipcMain.handle('player-get-time', async (): Promise<number | undefined> => {
|
||||
}
|
||||
});
|
||||
|
||||
app.on('before-quit', () => {
|
||||
getMpvInstance()?.stop();
|
||||
getMpvInstance()?.quit();
|
||||
app.on('before-quit', async () => {
|
||||
try {
|
||||
await getMpvInstance()?.stop();
|
||||
await getMpvInstance()?.quit();
|
||||
} catch (err: NodeMpvError | any) {
|
||||
mpvLog({ action: `Failed to cleanly before-quit` }, err);
|
||||
}
|
||||
});
|
||||
|
||||
app.on('window-all-closed', () => {
|
||||
getMpvInstance()?.quit();
|
||||
app.on('window-all-closed', async () => {
|
||||
try {
|
||||
await getMpvInstance()?.quit();
|
||||
} catch (err: NodeMpvError | any) {
|
||||
mpvLog({ action: `Failed to cleanly exit` }, err);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -6,18 +6,20 @@ import { store } from '../settings';
|
||||
export const enableMediaKeys = (window: BrowserWindow | null) => {
|
||||
if (isMacOS()) {
|
||||
const shouldPrompt = store.get('should_prompt_accessibility', true) as boolean;
|
||||
const shownWarning = store.get('shown_accessibility_warning', false) as boolean;
|
||||
const trusted = systemPreferences.isTrustedAccessibilityClient(shouldPrompt);
|
||||
|
||||
if (shouldPrompt) {
|
||||
store.set('should_prompt_accessibility', false);
|
||||
}
|
||||
|
||||
if (!trusted) {
|
||||
if (!trusted && !shownWarning) {
|
||||
window?.webContents.send('toast-from-main', {
|
||||
message:
|
||||
'Feishin is not a trusted accessibility client. Media keys will not work until this setting is changed',
|
||||
type: 'warning',
|
||||
});
|
||||
store.set('shown_accessibility_warning', true);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -18,22 +18,29 @@ mprisPlayer.on('quit', () => {
|
||||
process.exit();
|
||||
});
|
||||
|
||||
const hasData = (): boolean => {
|
||||
return mprisPlayer.metadata && !!mprisPlayer.metadata['mpris:length'];
|
||||
};
|
||||
|
||||
mprisPlayer.on('stop', () => {
|
||||
getMainWindow()?.webContents.send('renderer-player-stop');
|
||||
mprisPlayer.playbackStatus = 'Paused';
|
||||
});
|
||||
|
||||
mprisPlayer.on('pause', () => {
|
||||
if (!hasData()) return;
|
||||
getMainWindow()?.webContents.send('renderer-player-pause');
|
||||
mprisPlayer.playbackStatus = 'Paused';
|
||||
});
|
||||
|
||||
mprisPlayer.on('play', () => {
|
||||
if (!hasData()) return;
|
||||
getMainWindow()?.webContents.send('renderer-player-play');
|
||||
mprisPlayer.playbackStatus = 'Playing';
|
||||
});
|
||||
|
||||
mprisPlayer.on('playpause', () => {
|
||||
if (!hasData()) return;
|
||||
getMainWindow()?.webContents.send('renderer-player-play-pause');
|
||||
if (mprisPlayer.playbackStatus !== 'Playing') {
|
||||
mprisPlayer.playbackStatus = 'Playing';
|
||||
@@ -43,6 +50,7 @@ mprisPlayer.on('playpause', () => {
|
||||
});
|
||||
|
||||
mprisPlayer.on('next', () => {
|
||||
if (!hasData()) return;
|
||||
getMainWindow()?.webContents.send('renderer-player-next');
|
||||
|
||||
if (mprisPlayer.playbackStatus !== 'Playing') {
|
||||
@@ -51,6 +59,7 @@ mprisPlayer.on('next', () => {
|
||||
});
|
||||
|
||||
mprisPlayer.on('previous', () => {
|
||||
if (!hasData()) return;
|
||||
getMainWindow()?.webContents.send('renderer-player-previous');
|
||||
|
||||
if (mprisPlayer.playbackStatus !== 'Playing') {
|
||||
@@ -136,7 +145,10 @@ ipcMain.on('update-song', (_event, args: SongUpdate) => {
|
||||
mprisPlayer.shuffle = shuffle;
|
||||
}
|
||||
|
||||
if (!song) return;
|
||||
if (!song) {
|
||||
mprisPlayer.metadata = {};
|
||||
return;
|
||||
}
|
||||
|
||||
const upsizedImageUrl = song.imageUrl
|
||||
? song.imageUrl
|
||||
|
||||
@@ -24,6 +24,8 @@ import {
|
||||
BrowserWindowConstructorOptions,
|
||||
protocol,
|
||||
net,
|
||||
Rectangle,
|
||||
screen,
|
||||
} from 'electron';
|
||||
import electronLocalShortcut from 'electron-localshortcut';
|
||||
import log from 'electron-log/main';
|
||||
@@ -256,6 +258,26 @@ const createWindow = async (first = true) => {
|
||||
...(nativeFrame && isWindows() && nativeFrameConfig.windows),
|
||||
});
|
||||
|
||||
// From https://github.com/electron/electron/issues/526#issuecomment-1663959513
|
||||
const bounds = store.get('bounds') as Rectangle | undefined;
|
||||
if (bounds) {
|
||||
const screenArea = screen.getDisplayMatching(bounds).workArea;
|
||||
if (
|
||||
bounds.x > screenArea.x + screenArea.width ||
|
||||
bounds.x < screenArea.x ||
|
||||
bounds.y < screenArea.y ||
|
||||
bounds.y > screenArea.y + screenArea.height
|
||||
) {
|
||||
if (bounds.width < screenArea.width && bounds.height < screenArea.height) {
|
||||
mainWindow.setBounds({ height: bounds.height, width: bounds.width });
|
||||
} else {
|
||||
mainWindow.setBounds({ height: 900, width: 1440 });
|
||||
}
|
||||
} else {
|
||||
mainWindow.setBounds(bounds);
|
||||
}
|
||||
}
|
||||
|
||||
electronLocalShortcut.register(mainWindow, 'Ctrl+Shift+I', () => {
|
||||
mainWindow?.webContents.openDevTools();
|
||||
});
|
||||
@@ -342,6 +364,20 @@ const createWindow = async (first = true) => {
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle('open-item', async (_event, path: string) => {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
access(path, constants.F_OK, (error) => {
|
||||
if (error) {
|
||||
reject(error);
|
||||
return;
|
||||
}
|
||||
|
||||
shell.showItemInFolder(path);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
const globalMediaKeysEnabled = store.get('global_media_hotkeys', true) as boolean;
|
||||
|
||||
if (globalMediaKeysEnabled) {
|
||||
@@ -358,6 +394,16 @@ const createWindow = async (first = true) => {
|
||||
}
|
||||
|
||||
if (!first || !startWindowMinimized) {
|
||||
const maximized = store.get('maximized');
|
||||
const fullScreen = store.get('fullscreen');
|
||||
|
||||
if (maximized) {
|
||||
mainWindow.maximize();
|
||||
}
|
||||
if (fullScreen) {
|
||||
mainWindow.setFullScreen(true);
|
||||
}
|
||||
|
||||
mainWindow.show();
|
||||
createWinThumbarButtons();
|
||||
}
|
||||
@@ -371,6 +417,10 @@ const createWindow = async (first = true) => {
|
||||
let saved = false;
|
||||
|
||||
mainWindow.on('close', (event) => {
|
||||
store.set('bounds', mainWindow?.getNormalBounds());
|
||||
store.set('maximized', mainWindow?.isMaximized());
|
||||
store.set('fullscreen', mainWindow?.isFullScreen());
|
||||
|
||||
if (!exitFromTray && store.get('window_exit_to_tray')) {
|
||||
if (isMacOS() && !forceQuit) {
|
||||
exitFromTray = true;
|
||||
|
||||
@@ -64,6 +64,7 @@ const env = {
|
||||
SERVER_NAME: process.env.SERVER_NAME ?? '',
|
||||
SERVER_TYPE,
|
||||
SERVER_URL: process.env.SERVER_URL ?? 'http://',
|
||||
START_MAXIMIZED: store.get('maximized'),
|
||||
};
|
||||
|
||||
export const localSettings = {
|
||||
|
||||
@@ -10,6 +10,10 @@ const restoreQueue = () => {
|
||||
ipcRenderer.send('player-restore-queue');
|
||||
};
|
||||
|
||||
const openItem = async (path: string) => {
|
||||
return ipcRenderer.invoke('open-item', path);
|
||||
};
|
||||
|
||||
const onSaveQueue = (cb: (event: IpcRendererEvent) => void) => {
|
||||
ipcRenderer.on('renderer-save-queue', cb);
|
||||
};
|
||||
@@ -51,6 +55,7 @@ export const utils = {
|
||||
mainMessageListener,
|
||||
onRestoreQueue,
|
||||
onSaveQueue,
|
||||
openItem,
|
||||
playerErrorListener,
|
||||
restoreQueue,
|
||||
saveQueue,
|
||||
|
||||
@@ -61,6 +61,7 @@ export const RemoteContainer = () => {
|
||||
spacing={0}
|
||||
>
|
||||
<RemoteButton
|
||||
disabled={!song}
|
||||
tooltip="Previous track"
|
||||
variant="default"
|
||||
onClick={() => send({ event: 'previous' })}
|
||||
@@ -68,7 +69,8 @@ export const RemoteContainer = () => {
|
||||
<RiSkipBackFill size={25} />
|
||||
</RemoteButton>
|
||||
<RemoteButton
|
||||
tooltip={status === PlayerStatus.PLAYING ? 'Pause' : 'Play'}
|
||||
disabled={!song}
|
||||
tooltip={song && status === PlayerStatus.PLAYING ? 'Pause' : 'Play'}
|
||||
variant="default"
|
||||
onClick={() => {
|
||||
if (status === PlayerStatus.PLAYING) {
|
||||
@@ -78,13 +80,14 @@ export const RemoteContainer = () => {
|
||||
}
|
||||
}}
|
||||
>
|
||||
{status === PlayerStatus.PLAYING ? (
|
||||
{song && status === PlayerStatus.PLAYING ? (
|
||||
<RiPauseFill size={25} />
|
||||
) : (
|
||||
<RiPlayFill size={25} />
|
||||
)}
|
||||
</RemoteButton>
|
||||
<RemoteButton
|
||||
disabled={!song}
|
||||
tooltip="Next track"
|
||||
variant="default"
|
||||
onClick={() => send({ event: 'next' })}
|
||||
|
||||
@@ -8,6 +8,7 @@ import type {
|
||||
AlbumArtistDetailArgs,
|
||||
AlbumArtistListArgs,
|
||||
SetRatingArgs,
|
||||
ShareItemArgs,
|
||||
GenreListArgs,
|
||||
CreatePlaylistArgs,
|
||||
DeletePlaylistArgs,
|
||||
@@ -55,6 +56,7 @@ import type {
|
||||
SimilarSongsArgs,
|
||||
Song,
|
||||
ServerType,
|
||||
ShareItemResponse,
|
||||
} from '/@/renderer/api/types';
|
||||
import { DeletePlaylistResponse, RandomSongListArgs } from './types';
|
||||
import { ndController } from '/@/renderer/api/navidrome/navidrome-controller';
|
||||
@@ -102,6 +104,7 @@ export type ControllerEndpoint = Partial<{
|
||||
scrobble: (args: ScrobbleArgs) => Promise<ScrobbleResponse>;
|
||||
search: (args: SearchArgs) => Promise<SearchResponse>;
|
||||
setRating: (args: SetRatingArgs) => Promise<RatingResponse>;
|
||||
shareItem: (args: ShareItemArgs) => Promise<ShareItemResponse>;
|
||||
updatePlaylist: (args: UpdatePlaylistArgs) => Promise<UpdatePlaylistResponse>;
|
||||
}>;
|
||||
|
||||
@@ -149,6 +152,7 @@ const endpoints: ApiController = {
|
||||
scrobble: jfController.scrobble,
|
||||
search: jfController.search,
|
||||
setRating: undefined,
|
||||
shareItem: undefined,
|
||||
updatePlaylist: jfController.updatePlaylist,
|
||||
},
|
||||
navidrome: {
|
||||
@@ -178,7 +182,7 @@ const endpoints: ApiController = {
|
||||
getPlaylistSongList: ndController.getPlaylistSongList,
|
||||
getRandomSongList: ssController.getRandomSongList,
|
||||
getServerInfo: ndController.getServerInfo,
|
||||
getSimilarSongs: ssController.getSimilarSongs,
|
||||
getSimilarSongs: ndController.getSimilarSongs,
|
||||
getSongDetail: ndController.getSongDetail,
|
||||
getSongList: ndController.getSongList,
|
||||
getStructuredLyrics: ssController.getStructuredLyrics,
|
||||
@@ -188,6 +192,7 @@ const endpoints: ApiController = {
|
||||
scrobble: ssController.scrobble,
|
||||
search: ssController.search3,
|
||||
setRating: ssController.setRating,
|
||||
shareItem: ndController.shareItem,
|
||||
updatePlaylist: ndController.updatePlaylist,
|
||||
},
|
||||
subsonic: {
|
||||
@@ -223,6 +228,7 @@ const endpoints: ApiController = {
|
||||
scrobble: ssController.scrobble,
|
||||
search: ssController.search3,
|
||||
setRating: undefined,
|
||||
shareItem: undefined,
|
||||
updatePlaylist: undefined,
|
||||
},
|
||||
};
|
||||
@@ -457,6 +463,15 @@ const updateRating = async (args: SetRatingArgs) => {
|
||||
)?.(args);
|
||||
};
|
||||
|
||||
const shareItem = async (args: ShareItemArgs) => {
|
||||
return (
|
||||
apiController(
|
||||
'shareItem',
|
||||
args.apiClientProps.server?.type,
|
||||
) as ControllerEndpoint['shareItem']
|
||||
)?.(args);
|
||||
};
|
||||
|
||||
const getTopSongList = async (args: TopSongListArgs) => {
|
||||
return (
|
||||
apiController(
|
||||
@@ -555,6 +570,7 @@ export const controller = {
|
||||
removeFromPlaylist,
|
||||
scrobble,
|
||||
search,
|
||||
shareItem,
|
||||
updatePlaylist,
|
||||
updateRating,
|
||||
};
|
||||
|
||||
@@ -4,6 +4,7 @@ export enum ServerFeature {
|
||||
LYRICS_MULTIPLE_STRUCTURED = 'lyricsMultipleStructured',
|
||||
LYRICS_SINGLE_STRUCTURED = 'lyricsSingleStructured',
|
||||
PLAYLISTS_SMART = 'playlistsSmart',
|
||||
SHARING_ALBUM_SONG = 'sharingAlbumSong',
|
||||
}
|
||||
|
||||
export type ServerFeatures = Partial<Record<ServerFeature, boolean>>;
|
||||
|
||||
@@ -574,7 +574,7 @@ export enum JFSongListSort {
|
||||
ARTIST = 'Artist,Album,SortName',
|
||||
COMMUNITY_RATING = 'CommunityRating,SortName',
|
||||
DURATION = 'Runtime,AlbumArtist,Album,SortName',
|
||||
NAME = 'Name,SortName',
|
||||
NAME = 'SortName,Name',
|
||||
PLAY_COUNT = 'PlayCount,SortName',
|
||||
RANDOM = 'Random,SortName',
|
||||
RECENTLY_ADDED = 'DateCreated,SortName',
|
||||
@@ -601,7 +601,7 @@ export type JFSongListParams = {
|
||||
export enum JFAlbumArtistListSort {
|
||||
ALBUM = 'Album,SortName',
|
||||
DURATION = 'Runtime,AlbumArtist,Album,SortName',
|
||||
NAME = 'Name,SortName',
|
||||
NAME = 'SortName,Name',
|
||||
RANDOM = 'Random,SortName',
|
||||
RECENTLY_ADDED = 'DateCreated,SortName',
|
||||
RELEASE_DATE = 'PremiereDate,AlbumArtist,Album,SortName',
|
||||
@@ -618,7 +618,7 @@ export type JFAlbumArtistListParams = {
|
||||
export enum JFArtistListSort {
|
||||
ALBUM = 'Album,SortName',
|
||||
DURATION = 'Runtime,AlbumArtist,Album,SortName',
|
||||
NAME = 'Name,SortName',
|
||||
NAME = 'SortName,Name',
|
||||
RANDOM = 'Random,SortName',
|
||||
RECENTLY_ADDED = 'DateCreated,SortName',
|
||||
RELEASE_DATE = 'PremiereDate,AlbumArtist,Album,SortName',
|
||||
|
||||
@@ -115,6 +115,15 @@ export const contract = c.router({
|
||||
400: jfType._response.error,
|
||||
},
|
||||
},
|
||||
getInstantMix: {
|
||||
method: 'GET',
|
||||
path: 'songs/:itemId/InstantMix',
|
||||
query: jfType._parameters.similarSongs,
|
||||
responses: {
|
||||
200: jfType._response.songList,
|
||||
400: jfType._response.error,
|
||||
},
|
||||
},
|
||||
getMusicFolderList: {
|
||||
method: 'GET',
|
||||
path: 'users/:userId/items',
|
||||
@@ -176,6 +185,15 @@ export const contract = c.router({
|
||||
400: jfType._response.error,
|
||||
},
|
||||
},
|
||||
getSongData: {
|
||||
method: 'GET',
|
||||
path: 'users/:userId/items/:id',
|
||||
query: jfType._parameters.songDetail,
|
||||
responses: {
|
||||
200: jfType._response.song,
|
||||
400: jfType._response.error,
|
||||
},
|
||||
},
|
||||
getSongDetail: {
|
||||
method: 'GET',
|
||||
path: 'users/:userId/items/:id',
|
||||
@@ -195,7 +213,7 @@ export const contract = c.router({
|
||||
},
|
||||
getSongLyrics: {
|
||||
method: 'GET',
|
||||
path: 'users/:userId/Items/:id/Lyrics',
|
||||
path: 'audio/:id/Lyrics',
|
||||
responses: {
|
||||
200: jfType._response.lyrics,
|
||||
404: jfType._response.error,
|
||||
|
||||
@@ -61,7 +61,9 @@ import packageJson from '../../../../package.json';
|
||||
import { z } from 'zod';
|
||||
import { JFSongListSort, JFSortOrder } from '/@/renderer/api/jellyfin.types';
|
||||
import isElectron from 'is-electron';
|
||||
import { ServerFeatures } from '/@/renderer/api/features-types';
|
||||
import { ServerFeature } from '/@/renderer/api/features-types';
|
||||
import { VersionInfo, getFeatures } from '/@/renderer/api/utils';
|
||||
import chunk from 'lodash/chunk';
|
||||
|
||||
const formatCommaDelimitedString = (value: string[]) => {
|
||||
return value.join(',');
|
||||
@@ -231,7 +233,7 @@ const getAlbumArtistList = async (args: AlbumArtistListArgs): Promise<AlbumArtis
|
||||
ParentId: query.musicFolderId,
|
||||
Recursive: true,
|
||||
SearchTerm: query.searchTerm,
|
||||
SortBy: albumArtistListSortMap.jellyfin[query.sortBy] || 'Name,SortName',
|
||||
SortBy: albumArtistListSortMap.jellyfin[query.sortBy] || 'SortName,Name',
|
||||
SortOrder: sortOrderMap.jellyfin[query.sortOrder],
|
||||
StartIndex: query.startIndex,
|
||||
UserId: apiClientProps.server?.userId || undefined,
|
||||
@@ -257,7 +259,7 @@ const getArtistList = async (args: ArtistListArgs): Promise<AlbumArtistListRespo
|
||||
Limit: query.limit,
|
||||
ParentId: query.musicFolderId,
|
||||
Recursive: true,
|
||||
SortBy: artistListSortMap.jellyfin[query.sortBy] || 'Name,SortName',
|
||||
SortBy: artistListSortMap.jellyfin[query.sortBy] || 'SortName,Name',
|
||||
SortOrder: sortOrderMap.jellyfin[query.sortOrder],
|
||||
StartIndex: query.startIndex,
|
||||
},
|
||||
@@ -445,8 +447,26 @@ const getSongList = async (args: SongListArgs): Promise<SongListResponse> => {
|
||||
throw new Error('Failed to get song list');
|
||||
}
|
||||
|
||||
let items: z.infer<typeof jfType._response.song>[];
|
||||
|
||||
// Jellyfin Bodge because of code from https://github.com/jellyfin/jellyfin/blob/c566ccb63bf61f9c36743ddb2108a57c65a2519b/Emby.Server.Implementations/Data/SqliteItemRepository.cs#L3622
|
||||
// If the Album ID filter is passed, Jellyfin will search for
|
||||
// 1. the matching album id
|
||||
// 2. An album with the name of the album.
|
||||
// It is this second condition causing issues,
|
||||
if (query.albumIds) {
|
||||
const albumIdSet = new Set(query.albumIds);
|
||||
items = res.body.Items.filter((item) => albumIdSet.has(item.AlbumId));
|
||||
|
||||
if (items.length < res.body.Items.length) {
|
||||
res.body.TotalRecordCount -= res.body.Items.length - items.length;
|
||||
}
|
||||
} else {
|
||||
items = res.body.Items;
|
||||
}
|
||||
|
||||
return {
|
||||
items: res.body.Items.map((item) =>
|
||||
items: items.map((item) =>
|
||||
jfNormalize.song(item, apiClientProps.server, '', query.imageSize),
|
||||
),
|
||||
startIndex: query.startIndex,
|
||||
@@ -454,6 +474,11 @@ const getSongList = async (args: SongListArgs): Promise<SongListResponse> => {
|
||||
};
|
||||
};
|
||||
|
||||
// Limit the query to 50 at a time to be *extremely* conservative on the
|
||||
// length of the full URL, since the ids are part of the query string and
|
||||
// not the POST body
|
||||
const MAX_ITEMS_PER_PLAYLIST_ADD = 50;
|
||||
|
||||
const addToPlaylist = async (args: AddToPlaylistArgs): Promise<AddToPlaylistResponse> => {
|
||||
const { query, body, apiClientProps } = args;
|
||||
|
||||
@@ -461,19 +486,23 @@ const addToPlaylist = async (args: AddToPlaylistArgs): Promise<AddToPlaylistResp
|
||||
throw new Error('No userId found');
|
||||
}
|
||||
|
||||
const res = await jfApiClient(apiClientProps).addToPlaylist({
|
||||
body: null,
|
||||
params: {
|
||||
id: query.id,
|
||||
},
|
||||
query: {
|
||||
Ids: body.songId,
|
||||
UserId: apiClientProps.server?.userId,
|
||||
},
|
||||
});
|
||||
const chunks = chunk(body.songId, MAX_ITEMS_PER_PLAYLIST_ADD);
|
||||
|
||||
if (res.status !== 204) {
|
||||
throw new Error('Failed to add to playlist');
|
||||
for (const chunk of chunks) {
|
||||
const res = await jfApiClient(apiClientProps).addToPlaylist({
|
||||
body: null,
|
||||
params: {
|
||||
id: query.id,
|
||||
},
|
||||
query: {
|
||||
Ids: chunk.join(','),
|
||||
UserId: apiClientProps.server?.userId,
|
||||
},
|
||||
});
|
||||
|
||||
if (res.status !== 204) {
|
||||
throw new Error('Failed to add to playlist');
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
@@ -484,18 +513,22 @@ const removeFromPlaylist = async (
|
||||
): Promise<RemoveFromPlaylistResponse> => {
|
||||
const { query, apiClientProps } = args;
|
||||
|
||||
const res = await jfApiClient(apiClientProps).removeFromPlaylist({
|
||||
body: null,
|
||||
params: {
|
||||
id: query.id,
|
||||
},
|
||||
query: {
|
||||
EntryIds: query.songId,
|
||||
},
|
||||
});
|
||||
const chunks = chunk(query.songId, MAX_ITEMS_PER_PLAYLIST_ADD);
|
||||
|
||||
if (res.status !== 204) {
|
||||
throw new Error('Failed to remove from playlist');
|
||||
for (const chunk of chunks) {
|
||||
const res = await jfApiClient(apiClientProps).removeFromPlaylist({
|
||||
body: null,
|
||||
params: {
|
||||
id: query.id,
|
||||
},
|
||||
query: {
|
||||
EntryIds: chunk.join(','),
|
||||
},
|
||||
});
|
||||
|
||||
if (res.status !== 204) {
|
||||
throw new Error('Failed to remove from playlist');
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
@@ -919,7 +952,6 @@ const getLyrics = async (args: LyricsArgs): Promise<LyricsResponse> => {
|
||||
const res = await jfApiClient(apiClientProps).getSongLyrics({
|
||||
params: {
|
||||
id: query.songId,
|
||||
userId: apiClientProps.server?.userId,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -951,6 +983,8 @@ const getSongDetail = async (args: SongDetailArgs): Promise<SongDetailResponse>
|
||||
return jfNormalize.song(res.body, apiClientProps.server, '');
|
||||
};
|
||||
|
||||
const VERSION_INFO: VersionInfo = [['10.9.0', { [ServerFeature.LYRICS_SINGLE_STRUCTURED]: [1] }]];
|
||||
|
||||
const getServerInfo = async (args: ServerInfoArgs): Promise<ServerInfo> => {
|
||||
const { apiClientProps } = args;
|
||||
|
||||
@@ -960,9 +994,7 @@ const getServerInfo = async (args: ServerInfoArgs): Promise<ServerInfo> => {
|
||||
throw new Error('Failed to get server info');
|
||||
}
|
||||
|
||||
const features: ServerFeatures = {
|
||||
lyricsSingleStructured: true,
|
||||
};
|
||||
const features = getFeatures(VERSION_INFO, res.body.Version);
|
||||
|
||||
return {
|
||||
features,
|
||||
@@ -974,6 +1006,8 @@ const getServerInfo = async (args: ServerInfoArgs): Promise<ServerInfo> => {
|
||||
const getSimilarSongs = async (args: SimilarSongsArgs): Promise<Song[]> => {
|
||||
const { apiClientProps, query } = args;
|
||||
|
||||
// Prefer getSimilarSongs, where possible. Fallback to InstantMix
|
||||
// where no similar songs were found.
|
||||
const res = await jfApiClient(apiClientProps).getSimilarSongs({
|
||||
params: {
|
||||
itemId: query.songId,
|
||||
@@ -985,11 +1019,36 @@ const getSimilarSongs = async (args: SimilarSongsArgs): Promise<Song[]> => {
|
||||
},
|
||||
});
|
||||
|
||||
if (res.status !== 200) {
|
||||
if (res.status === 200 && res.body.Items.length) {
|
||||
const results = res.body.Items.reduce<Song[]>((acc, song) => {
|
||||
if (song.Id !== query.songId) {
|
||||
acc.push(jfNormalize.song(song, apiClientProps.server, ''));
|
||||
}
|
||||
|
||||
return acc;
|
||||
}, []);
|
||||
|
||||
if (results.length > 0) {
|
||||
return results;
|
||||
}
|
||||
}
|
||||
|
||||
const mix = await jfApiClient(apiClientProps).getInstantMix({
|
||||
params: {
|
||||
itemId: query.songId,
|
||||
},
|
||||
query: {
|
||||
Fields: 'Genres, DateCreated, MediaSources, ParentId',
|
||||
Limit: query.count,
|
||||
UserId: apiClientProps.server?.userId || undefined,
|
||||
},
|
||||
});
|
||||
|
||||
if (mix.status !== 200) {
|
||||
throw new Error('Failed to get similar songs');
|
||||
}
|
||||
|
||||
return res.body.Items.reduce<Song[]>((acc, song) => {
|
||||
return mix.body.Items.reduce<Song[]>((acc, song) => {
|
||||
if (song.Id !== query.songId) {
|
||||
acc.push(jfNormalize.song(song, apiClientProps.server, ''));
|
||||
}
|
||||
|
||||
@@ -134,7 +134,7 @@ const normalizeSong = (
|
||||
imageUrl: null,
|
||||
name: entry.Name,
|
||||
})),
|
||||
albumId: item.AlbumId,
|
||||
albumId: item.AlbumId || `dummy/${item.Id}`,
|
||||
artistName: item?.ArtistItems?.[0]?.Name,
|
||||
artists: item?.ArtistItems?.map((entry) => ({
|
||||
id: entry.Id,
|
||||
|
||||
@@ -387,11 +387,13 @@ const genericItem = z.object({
|
||||
Name: z.string(),
|
||||
});
|
||||
|
||||
const songDetailParameters = baseParameters;
|
||||
|
||||
const song = z.object({
|
||||
Album: z.string(),
|
||||
AlbumArtist: z.string(),
|
||||
AlbumArtists: z.array(genericItem),
|
||||
AlbumId: z.string(),
|
||||
AlbumId: z.string().optional(),
|
||||
AlbumPrimaryImageTag: z.string(),
|
||||
ArtistItems: z.array(genericItem),
|
||||
Artists: z.array(z.string()),
|
||||
@@ -512,7 +514,7 @@ const albumList = pagination.extend({
|
||||
const albumArtistListSort = {
|
||||
ALBUM: 'Album,SortName',
|
||||
DURATION: 'Runtime,AlbumArtist,Album,SortName',
|
||||
NAME: 'Name,SortName',
|
||||
NAME: 'SortName,Name',
|
||||
RANDOM: 'Random,SortName',
|
||||
RECENTLY_ADDED: 'DateCreated,SortName',
|
||||
RELEASE_DATE: 'PremiereDate,AlbumArtist,Album,SortName',
|
||||
@@ -542,7 +544,7 @@ const songListSort = {
|
||||
ARTIST: 'Artist,Album,SortName',
|
||||
COMMUNITY_RATING: 'CommunityRating,SortName',
|
||||
DURATION: 'Runtime,AlbumArtist,Album,SortName',
|
||||
NAME: 'Name,SortName',
|
||||
NAME: 'SortName,Name',
|
||||
PLAY_COUNT: 'PlayCount,SortName',
|
||||
RANDOM: 'Random,SortName',
|
||||
RECENTLY_ADDED: 'DateCreated,SortName',
|
||||
@@ -607,14 +609,14 @@ const addToPlaylist = z.object({
|
||||
});
|
||||
|
||||
const addToPlaylistParameters = z.object({
|
||||
Ids: z.array(z.string()),
|
||||
Ids: z.string(),
|
||||
UserId: z.string(),
|
||||
});
|
||||
|
||||
const removeFromPlaylist = z.null();
|
||||
|
||||
const removeFromPlaylistParameters = z.object({
|
||||
EntryIds: z.array(z.string()),
|
||||
EntryIds: z.string(),
|
||||
});
|
||||
|
||||
const deletePlaylist = z.null();
|
||||
@@ -709,6 +711,7 @@ export const jfType = {
|
||||
search: searchParameters,
|
||||
similarArtistList: similarArtistListParameters,
|
||||
similarSongs: similarSongsParameters,
|
||||
songDetail: songDetailParameters,
|
||||
songList: songListParameters,
|
||||
updatePlaylist: updatePlaylistParameters,
|
||||
},
|
||||
|
||||
@@ -242,6 +242,7 @@ export enum NDSongListSort {
|
||||
ID = 'id',
|
||||
PLAY_COUNT = 'playCount',
|
||||
PLAY_DATE = 'playDate',
|
||||
RANDOM = 'random',
|
||||
RATING = 'rating',
|
||||
RECENTLY_ADDED = 'createdAt',
|
||||
TITLE = 'title',
|
||||
@@ -399,6 +400,7 @@ export const NDSongQueryFields = [
|
||||
{ label: 'File Type', type: 'string', value: 'filetype' },
|
||||
{ label: 'Genre', type: 'string', value: 'genre' },
|
||||
{ label: 'Has CoverArt', type: 'boolean', value: 'hascoverart' },
|
||||
{ label: 'Playlist', type: 'playlist', value: 'id' },
|
||||
{ label: 'Is Compilation', type: 'boolean', value: 'compilation' },
|
||||
{ label: 'Is Favorite', type: 'boolean', value: 'loved' },
|
||||
{ label: 'Lyrics', type: 'string', value: 'lyrics' },
|
||||
@@ -414,6 +416,11 @@ export const NDSongQueryFields = [
|
||||
{ label: 'Year', type: 'number', value: 'year' },
|
||||
];
|
||||
|
||||
export const NDSongQueryPlaylistOperators = [
|
||||
{ label: 'is in', value: 'inPlaylist' },
|
||||
{ label: 'is not in', value: 'notInPlaylist' },
|
||||
];
|
||||
|
||||
export const NDSongQueryDateOperators = [
|
||||
{ label: 'is', value: 'is' },
|
||||
{ label: 'is not', value: 'isNot' },
|
||||
|
||||
@@ -157,6 +157,16 @@ export const contract = c.router({
|
||||
500: resultWithHeaders(ndType._response.error),
|
||||
},
|
||||
},
|
||||
shareItem: {
|
||||
body: ndType._parameters.shareItem,
|
||||
method: 'POST',
|
||||
path: 'share',
|
||||
responses: {
|
||||
200: resultWithHeaders(ndType._response.shareItem),
|
||||
404: resultWithHeaders(ndType._response.error),
|
||||
500: resultWithHeaders(ndType._response.error),
|
||||
},
|
||||
},
|
||||
updatePlaylist: {
|
||||
body: ndType._parameters.updatePlaylist,
|
||||
method: 'PUT',
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import { ndApiClient } from '/@/renderer/api/navidrome/navidrome-api';
|
||||
import { ndNormalize } from '/@/renderer/api/navidrome/navidrome-normalize';
|
||||
import { NavidromeExtensions, ndType } from '/@/renderer/api/navidrome/navidrome-types';
|
||||
import { ndType } from '/@/renderer/api/navidrome/navidrome-types';
|
||||
import { ssApiClient } from '/@/renderer/api/subsonic/subsonic-api';
|
||||
import semverCoerce from 'semver/functions/coerce';
|
||||
import semverGte from 'semver/functions/gte';
|
||||
import {
|
||||
AlbumArtistDetailArgs,
|
||||
AlbumArtistDetailResponse,
|
||||
@@ -47,10 +45,16 @@ import {
|
||||
genreListSortMap,
|
||||
ServerInfo,
|
||||
ServerInfoArgs,
|
||||
ShareItemArgs,
|
||||
ShareItemResponse,
|
||||
SimilarSongsArgs,
|
||||
Song,
|
||||
} from '../types';
|
||||
import { hasFeature } from '/@/renderer/api/utils';
|
||||
import { VersionInfo, getFeatures, hasFeature } from '/@/renderer/api/utils';
|
||||
import { ServerFeature, ServerFeatures } from '/@/renderer/api/features-types';
|
||||
import { SubsonicExtensions } from '/@/renderer/api/subsonic/subsonic-types';
|
||||
import { NDSongListSort } from '/@/renderer/api/navidrome.types';
|
||||
import { ssNormalize } from '/@/renderer/api/subsonic/subsonic-normalize';
|
||||
|
||||
const authenticate = async (
|
||||
url: string,
|
||||
@@ -151,20 +155,18 @@ const getAlbumArtistDetail = async (
|
||||
throw new Error('Server is required');
|
||||
}
|
||||
|
||||
// Prefer images from getArtistInfo first (which should be proxied)
|
||||
// Prioritize large > medium > small
|
||||
return ndNormalize.albumArtist(
|
||||
{
|
||||
...res.body.data,
|
||||
...(artistInfoRes.status === 200 && {
|
||||
largeImageUrl:
|
||||
artistInfoRes.body.artistInfo.largeImageUrl ||
|
||||
artistInfoRes.body.artistInfo.mediumImageUrl ||
|
||||
artistInfoRes.body.artistInfo.smallImageUrl ||
|
||||
res.body.data.largeImageUrl,
|
||||
similarArtists: artistInfoRes.body.artistInfo.similarArtist,
|
||||
...(!res.body.data.largeImageUrl && {
|
||||
largeImageUrl: artistInfoRes.body.artistInfo.largeImageUrl,
|
||||
}),
|
||||
...(!res.body.data.mediumImageUrl && {
|
||||
largeImageUrl: artistInfoRes.body.artistInfo.mediumImageUrl,
|
||||
}),
|
||||
...(!res.body.data.smallImageUrl && {
|
||||
largeImageUrl: artistInfoRes.body.artistInfo.smallImageUrl,
|
||||
}),
|
||||
}),
|
||||
},
|
||||
apiClientProps.server,
|
||||
@@ -482,34 +484,11 @@ const removeFromPlaylist = async (
|
||||
return null;
|
||||
};
|
||||
|
||||
const VERSION_INFO: Array<[string, Record<string, number[]>]> = [
|
||||
const VERSION_INFO: VersionInfo = [
|
||||
['0.49.3', { [ServerFeature.SHARING_ALBUM_SONG]: [1] }],
|
||||
['0.48.0', { [ServerFeature.PLAYLISTS_SMART]: [1] }],
|
||||
];
|
||||
|
||||
const getFeatures = (version: string): Record<string, number[]> => {
|
||||
const cleanVersion = semverCoerce(version);
|
||||
const features: Record<string, number[]> = {};
|
||||
let matched = cleanVersion === null;
|
||||
|
||||
for (const [version, supportedFeatures] of VERSION_INFO) {
|
||||
if (!matched) {
|
||||
matched = semverGte(cleanVersion!, version);
|
||||
}
|
||||
|
||||
if (matched) {
|
||||
for (const [feature, feat] of Object.entries(supportedFeatures)) {
|
||||
if (feature in features) {
|
||||
features[feature].push(...feat);
|
||||
} else {
|
||||
features[feature] = feat;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return features;
|
||||
};
|
||||
|
||||
const getServerInfo = async (args: ServerInfoArgs): Promise<ServerInfo> => {
|
||||
const { apiClientProps } = args;
|
||||
|
||||
@@ -520,7 +499,10 @@ const getServerInfo = async (args: ServerInfoArgs): Promise<ServerInfo> => {
|
||||
throw new Error('Failed to ping server');
|
||||
}
|
||||
|
||||
const navidromeFeatures: Record<string, number[]> = getFeatures(ping.body.serverVersion!);
|
||||
const navidromeFeatures: Record<string, number[]> = getFeatures(
|
||||
VERSION_INFO,
|
||||
ping.body.serverVersion!,
|
||||
);
|
||||
|
||||
if (ping.body.openSubsonic) {
|
||||
const res = await ssApiClient(apiClientProps).getServerInfo();
|
||||
@@ -541,12 +523,87 @@ const getServerInfo = async (args: ServerInfoArgs): Promise<ServerInfo> => {
|
||||
|
||||
const features: ServerFeatures = {
|
||||
lyricsMultipleStructured: !!navidromeFeatures[SubsonicExtensions.SONG_LYRICS],
|
||||
playlistsSmart: !!navidromeFeatures[NavidromeExtensions.SMART_PLAYLISTS],
|
||||
playlistsSmart: !!navidromeFeatures[ServerFeature.PLAYLISTS_SMART],
|
||||
sharingAlbumSong: !!navidromeFeatures[ServerFeature.SHARING_ALBUM_SONG],
|
||||
};
|
||||
|
||||
return { features, id: apiClientProps.server?.id, version: ping.body.serverVersion! };
|
||||
};
|
||||
|
||||
const shareItem = async (args: ShareItemArgs): Promise<ShareItemResponse> => {
|
||||
const { body, apiClientProps } = args;
|
||||
|
||||
const res = await ndApiClient(apiClientProps).shareItem({
|
||||
body: {
|
||||
description: body.description,
|
||||
downloadable: body.downloadable,
|
||||
expires: body.expires,
|
||||
resourceIds: body.resourceIds,
|
||||
resourceType: body.resourceType,
|
||||
},
|
||||
});
|
||||
|
||||
if (res.status !== 200) {
|
||||
throw new Error('Failed to share item');
|
||||
}
|
||||
|
||||
return {
|
||||
id: res.body.data.id,
|
||||
};
|
||||
};
|
||||
|
||||
const getSimilarSongs = async (args: SimilarSongsArgs): Promise<Song[]> => {
|
||||
const { apiClientProps, query } = args;
|
||||
|
||||
// Prefer getSimilarSongs (which queries last.fm) where available
|
||||
// otherwise find other tracks by the same album artist
|
||||
const res = await ssApiClient({
|
||||
...apiClientProps,
|
||||
silent: true,
|
||||
}).getSimilarSongs({
|
||||
query: {
|
||||
count: query.count,
|
||||
id: query.songId,
|
||||
},
|
||||
});
|
||||
|
||||
if (res.status === 200 && res.body.similarSongs?.song) {
|
||||
const similar = res.body.similarSongs.song.reduce<Song[]>((acc, song) => {
|
||||
if (song.id !== query.songId) {
|
||||
acc.push(ssNormalize.song(song, apiClientProps.server, ''));
|
||||
}
|
||||
|
||||
return acc;
|
||||
}, []);
|
||||
|
||||
if (similar.length > 0) {
|
||||
return similar;
|
||||
}
|
||||
}
|
||||
|
||||
const fallback = await ndApiClient(apiClientProps).getSongList({
|
||||
query: {
|
||||
_end: 50,
|
||||
_order: 'ASC',
|
||||
_sort: NDSongListSort.RANDOM,
|
||||
_start: 0,
|
||||
album_artist_id: query.albumArtistIds,
|
||||
},
|
||||
});
|
||||
|
||||
if (fallback.status !== 200) {
|
||||
throw new Error('Failed to get similar songs');
|
||||
}
|
||||
|
||||
return fallback.body.data.reduce<Song[]>((acc, song) => {
|
||||
if (song.id !== query.songId) {
|
||||
acc.push(ndNormalize.song(song, apiClientProps.server, ''));
|
||||
}
|
||||
|
||||
return acc;
|
||||
}, []);
|
||||
};
|
||||
|
||||
export const ndController = {
|
||||
addToPlaylist,
|
||||
authenticate,
|
||||
@@ -561,9 +618,11 @@ export const ndController = {
|
||||
getPlaylistList,
|
||||
getPlaylistSongList,
|
||||
getServerInfo,
|
||||
getSimilarSongs,
|
||||
getSongDetail,
|
||||
getSongList,
|
||||
getUserList,
|
||||
removeFromPlaylist,
|
||||
shareItem,
|
||||
updatePlaylist,
|
||||
};
|
||||
|
||||
@@ -81,7 +81,7 @@ const normalizeSong = (
|
||||
const imagePlaceholderUrl = null;
|
||||
return {
|
||||
album: item.album,
|
||||
albumArtists: [{ id: item.artistId, imageUrl: null, name: item.artist }],
|
||||
albumArtists: [{ id: item.albumArtistId, imageUrl: null, name: item.albumArtist }],
|
||||
albumId: item.albumId,
|
||||
artistName: item.artist,
|
||||
artists: [{ id: item.artistId, imageUrl: null, name: item.artist }],
|
||||
|
||||
@@ -343,9 +343,17 @@ const removeFromPlaylistParameters = z.object({
|
||||
id: z.array(z.string()),
|
||||
});
|
||||
|
||||
export enum NavidromeExtensions {
|
||||
SMART_PLAYLISTS = 'smartPlaylists',
|
||||
}
|
||||
const shareItem = z.object({
|
||||
id: z.string(),
|
||||
});
|
||||
|
||||
const shareItemParameters = z.object({
|
||||
description: z.string(),
|
||||
downloadable: z.boolean(),
|
||||
expires: z.number(),
|
||||
resourceIds: z.string(),
|
||||
resourceType: z.string(),
|
||||
});
|
||||
|
||||
export const ndType = {
|
||||
_enum: {
|
||||
@@ -365,6 +373,7 @@ export const ndType = {
|
||||
genreList: genreListParameters,
|
||||
playlistList: playlistListParameters,
|
||||
removeFromPlaylist: removeFromPlaylistParameters,
|
||||
shareItem: shareItemParameters,
|
||||
songList: songListParameters,
|
||||
updatePlaylist: updatePlaylistParameters,
|
||||
userList: userListParameters,
|
||||
@@ -386,6 +395,7 @@ export const ndType = {
|
||||
playlistSong,
|
||||
playlistSongList,
|
||||
removeFromPlaylist,
|
||||
shareItem,
|
||||
song,
|
||||
songList,
|
||||
updatePlaylist,
|
||||
|
||||
@@ -131,7 +131,6 @@ axiosClient.defaults.paramsSerializer = (params) => {
|
||||
axiosClient.interceptors.response.use(
|
||||
(response) => {
|
||||
const data = response.data;
|
||||
|
||||
if (data['subsonic-response'].status !== 'ok') {
|
||||
// Suppress code related to non-linked lastfm or spotify from Navidrome
|
||||
if (data['subsonic-response'].error.code !== 0) {
|
||||
@@ -161,12 +160,24 @@ const parsePath = (fullPath: string) => {
|
||||
};
|
||||
};
|
||||
|
||||
const silentlyTransformResponse = (data: any) => {
|
||||
const jsonBody = JSON.parse(data);
|
||||
const status = jsonBody ? jsonBody['subsonic-response']?.status : undefined;
|
||||
|
||||
if (status && status !== 'ok') {
|
||||
jsonBody['subsonic-response'].error.code = 0;
|
||||
}
|
||||
|
||||
return jsonBody;
|
||||
};
|
||||
|
||||
export const ssApiClient = (args: {
|
||||
server: ServerListItem | null;
|
||||
signal?: AbortSignal;
|
||||
silent?: boolean;
|
||||
url?: string;
|
||||
}) => {
|
||||
const { server, url, signal } = args;
|
||||
const { server, url, signal, silent } = args;
|
||||
|
||||
return initClient(contract, {
|
||||
api: async ({ path, method, headers, body }) => {
|
||||
@@ -206,6 +217,8 @@ export const ssApiClient = (args: {
|
||||
...params,
|
||||
},
|
||||
signal,
|
||||
// In cases where we have a fallback, don't notify the error
|
||||
transformResponse: silent ? silentlyTransformResponse : undefined,
|
||||
url: `${baseUrl}/${api}`,
|
||||
});
|
||||
|
||||
|
||||
@@ -469,7 +469,7 @@ const getSimilarSongs = async (args: SimilarSongsArgs): Promise<Song[]> => {
|
||||
throw new Error('Failed to get similar songs');
|
||||
}
|
||||
|
||||
if (!res.body.similarSongs) {
|
||||
if (!res.body.similarSongs?.song) {
|
||||
return [];
|
||||
}
|
||||
|
||||
|
||||
@@ -75,7 +75,13 @@ const normalizeSong = (
|
||||
discNumber: item.discNumber || 1,
|
||||
discSubtitle: null,
|
||||
duration: item.duration ? item.duration * 1000 : 0,
|
||||
gain: null,
|
||||
gain:
|
||||
item.replayGain && (item.replayGain.albumGain || item.replayGain.trackGain)
|
||||
? {
|
||||
album: item.replayGain.albumGain,
|
||||
track: item.replayGain.trackGain,
|
||||
}
|
||||
: null,
|
||||
genres: item.genre
|
||||
? [
|
||||
{
|
||||
@@ -94,7 +100,13 @@ const normalizeSong = (
|
||||
lyrics: null,
|
||||
name: item.title,
|
||||
path: item.path,
|
||||
peak: null,
|
||||
peak:
|
||||
item.replayGain && (item.replayGain.albumPeak || item.replayGain.trackPeak)
|
||||
? {
|
||||
album: item.replayGain.albumPeak,
|
||||
track: item.replayGain.trackPeak,
|
||||
}
|
||||
: null,
|
||||
playCount: item?.playCount || 0,
|
||||
releaseDate: null,
|
||||
releaseYear: item.year ? String(item.year) : null,
|
||||
|
||||
@@ -53,6 +53,13 @@ const musicFolderList = z.object({
|
||||
}),
|
||||
});
|
||||
|
||||
const songGain = z.object({
|
||||
albumGain: z.number().optional(),
|
||||
albumPeak: z.number().optional(),
|
||||
trackGain: z.number().optional(),
|
||||
trackPeak: z.number().optional(),
|
||||
});
|
||||
|
||||
const song = z.object({
|
||||
album: z.string().optional(),
|
||||
albumId: z.string().optional(),
|
||||
@@ -72,6 +79,7 @@ const song = z.object({
|
||||
parent: z.string(),
|
||||
path: z.string(),
|
||||
playCount: z.number().optional(),
|
||||
replayGain: songGain.optional(),
|
||||
size: z.number(),
|
||||
starred: z.boolean().optional(),
|
||||
suffix: z.string(),
|
||||
|
||||
@@ -541,7 +541,7 @@ export const songListSortMap: SongListSortMap = {
|
||||
id: NDSongListSort.ID,
|
||||
name: NDSongListSort.TITLE,
|
||||
playCount: NDSongListSort.PLAY_COUNT,
|
||||
random: undefined,
|
||||
random: NDSongListSort.RANDOM,
|
||||
rating: NDSongListSort.RATING,
|
||||
recentlyAdded: NDSongListSort.RECENTLY_ADDED,
|
||||
recentlyPlayed: NDSongListSort.PLAY_DATE,
|
||||
@@ -766,6 +766,19 @@ export type RatingQuery = {
|
||||
|
||||
export type SetRatingArgs = { query: RatingQuery; serverId?: string } & BaseEndpointArgs;
|
||||
|
||||
// Sharing
|
||||
export type ShareItemResponse = { id: string } | undefined;
|
||||
|
||||
export type ShareItemBody = {
|
||||
description: string;
|
||||
downloadable: boolean;
|
||||
expires: number;
|
||||
resourceIds: string;
|
||||
resourceType: string;
|
||||
};
|
||||
|
||||
export type ShareItemArgs = { body: ShareItemBody; serverId?: string } & BaseEndpointArgs;
|
||||
|
||||
// Add to playlist
|
||||
export type AddToPlaylistResponse = null | undefined;
|
||||
|
||||
@@ -1170,6 +1183,7 @@ export type StructuredLyric = {
|
||||
} & (StructuredUnsyncedLyric | StructuredSyncedLyric);
|
||||
|
||||
export type SimilarSongsQuery = {
|
||||
albumArtistIds: string[];
|
||||
count?: number;
|
||||
songId: string;
|
||||
};
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { AxiosHeaders } from 'axios';
|
||||
import semverCoerce from 'semver/functions/coerce';
|
||||
import semverGte from 'semver/functions/gte';
|
||||
import { z } from 'zod';
|
||||
import { toast } from '/@/renderer/components';
|
||||
import { useAuthStore } from '/@/renderer/store';
|
||||
@@ -47,3 +49,54 @@ export const hasFeature = (server: ServerListItem | null, feature: ServerFeature
|
||||
|
||||
return server.features[feature] ?? false;
|
||||
};
|
||||
|
||||
export type VersionInfo = ReadonlyArray<[string, Record<string, readonly number[]>]>;
|
||||
|
||||
/**
|
||||
* Returns the available server features given the version string.
|
||||
* @param versionInfo a list, in DECREASING VERSION order, of the features supported by the server.
|
||||
* The first version match will automatically consider the rest matched.
|
||||
* @example
|
||||
* ```
|
||||
* // The CORRECT way to order
|
||||
* const VERSION_INFO: VersionInfo = [
|
||||
* ['0.49.3', { [ServerFeature.SHARING_ALBUM_SONG]: [1] }],
|
||||
* ['0.48.0', { [ServerFeature.PLAYLISTS_SMART]: [1] }],
|
||||
* ];
|
||||
* // INCORRECT way to order
|
||||
* const VERSION_INFO: VersionInfo = [
|
||||
* ['0.48.0', { [ServerFeature.PLAYLISTS_SMART]: [1] }],
|
||||
* ['0.49.3', { [ServerFeature.SHARING_ALBUM_SONG]: [1] }],
|
||||
* ];
|
||||
* ```
|
||||
* @param version the version string (SemVer)
|
||||
* @returns a Record containing the matched features (if any) and their versions
|
||||
*/
|
||||
export const getFeatures = (
|
||||
versionInfo: VersionInfo,
|
||||
version: string,
|
||||
): Record<string, number[]> => {
|
||||
const cleanVersion = semverCoerce(version);
|
||||
const features: Record<string, number[]> = {};
|
||||
let matched = cleanVersion === null;
|
||||
|
||||
for (const [version, supportedFeatures] of versionInfo) {
|
||||
if (!matched) {
|
||||
matched = semverGte(cleanVersion!, version);
|
||||
}
|
||||
|
||||
if (matched) {
|
||||
for (const [feature, feat] of Object.entries(supportedFeatures)) {
|
||||
if (feature in features) {
|
||||
features[feature].push(...feat);
|
||||
} else {
|
||||
features[feature] = [...feat];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return features;
|
||||
};
|
||||
|
||||
export const SEPARATOR_STRING = ' · ';
|
||||
|
||||
+6
-24
@@ -3,10 +3,9 @@ import { ClientSideRowModelModule } from '@ag-grid-community/client-side-row-mod
|
||||
import { ModuleRegistry } from '@ag-grid-community/core';
|
||||
import { InfiniteRowModelModule } from '@ag-grid-community/infinite-row-model';
|
||||
import { MantineProvider } from '@mantine/core';
|
||||
import { ModalsProvider } from '@mantine/modals';
|
||||
import isElectron from 'is-electron';
|
||||
import { initSimpleImg } from 'react-simple-img';
|
||||
import { BaseContextModal, toast } from './components';
|
||||
import { toast } from './components';
|
||||
import { useTheme } from './hooks';
|
||||
import { IsUpdatedDialog } from './is-updated-dialog';
|
||||
import { AppRouter } from './router/app-router';
|
||||
@@ -20,7 +19,6 @@ import './styles/global.scss';
|
||||
import { ContextMenuProvider } from '/@/renderer/features/context-menu';
|
||||
import { useHandlePlayQueueAdd } from '/@/renderer/features/player/hooks/use-handle-playqueue-add';
|
||||
import { PlayQueueHandlerContext } from '/@/renderer/features/player';
|
||||
import { AddToPlaylistContextModal } from '/@/renderer/features/playlists';
|
||||
import { getMpvProperties } from '/@/renderer/features/settings/components/playback/mpv-settings';
|
||||
import { PlayerState, usePlayerStore, useQueueControls } from '/@/renderer/store';
|
||||
import { FontType, PlaybackType, PlayerStatus } from '/@/renderer/types';
|
||||
@@ -246,27 +244,11 @@ export const App = () => {
|
||||
},
|
||||
}}
|
||||
>
|
||||
<ModalsProvider
|
||||
modalProps={{
|
||||
centered: true,
|
||||
styles: {
|
||||
body: { position: 'relative' },
|
||||
content: { overflow: 'auto' },
|
||||
},
|
||||
transitionProps: {
|
||||
duration: 300,
|
||||
exitDuration: 300,
|
||||
transition: 'fade',
|
||||
},
|
||||
}}
|
||||
modals={{ addToPlaylist: AddToPlaylistContextModal, base: BaseContextModal }}
|
||||
>
|
||||
<PlayQueueHandlerContext.Provider value={providerValue}>
|
||||
<ContextMenuProvider>
|
||||
<AppRouter />
|
||||
</ContextMenuProvider>
|
||||
</PlayQueueHandlerContext.Provider>
|
||||
</ModalsProvider>
|
||||
<PlayQueueHandlerContext.Provider value={providerValue}>
|
||||
<ContextMenuProvider>
|
||||
<AppRouter />
|
||||
</ContextMenuProvider>
|
||||
</PlayQueueHandlerContext.Provider>
|
||||
<IsUpdatedDialog />
|
||||
</MantineProvider>
|
||||
);
|
||||
|
||||
@@ -14,6 +14,7 @@ import { Badge } from '/@/renderer/components/badge';
|
||||
import { AppRoute } from '/@/renderer/router/routes';
|
||||
import { usePlayQueueAdd } from '/@/renderer/features/player/hooks/use-playqueue-add';
|
||||
import { Play } from '/@/renderer/types';
|
||||
import { usePlayButtonBehavior } from '/@/renderer/store';
|
||||
|
||||
const Carousel = styled(motion.div)`
|
||||
position: relative;
|
||||
@@ -114,6 +115,7 @@ export const FeatureCarousel = ({ data }: FeatureCarouselProps) => {
|
||||
const handlePlayQueueAdd = usePlayQueueAdd();
|
||||
const [itemIndex, setItemIndex] = useState(0);
|
||||
const [direction, setDirection] = useState(0);
|
||||
const playType = usePlayButtonBehavior();
|
||||
|
||||
const currentItem = data?.[itemIndex];
|
||||
|
||||
@@ -222,11 +224,18 @@ export const FeatureCarousel = ({ data }: FeatureCarouselProps) => {
|
||||
id: [currentItem.id],
|
||||
type: LibraryItem.ALBUM,
|
||||
},
|
||||
playType: Play.NOW,
|
||||
playType,
|
||||
});
|
||||
}}
|
||||
>
|
||||
{t('player.play', { postProcess: 'titleCase' })}
|
||||
{t(
|
||||
playType === Play.NOW
|
||||
? 'player.play'
|
||||
: playType === Play.NEXT
|
||||
? 'player.addNext'
|
||||
: 'player.addLast',
|
||||
{ postProcess: 'titleCase' },
|
||||
)}
|
||||
</Button>
|
||||
<Group spacing="sm">
|
||||
<Button
|
||||
|
||||
@@ -54,8 +54,10 @@ interface QueryBuilderProps {
|
||||
boolean: { label: string; value: string }[];
|
||||
date: { label: string; value: string }[];
|
||||
number: { label: string; value: string }[];
|
||||
playlist: { label: string; value: string }[];
|
||||
string: { label: string; value: string }[];
|
||||
};
|
||||
playlists?: { label: string; value: string }[];
|
||||
uniqueId: string;
|
||||
}
|
||||
|
||||
@@ -73,6 +75,7 @@ export const QueryBuilder = ({
|
||||
onChangeValue,
|
||||
onClearFilters,
|
||||
onResetFilters,
|
||||
playlists,
|
||||
groupIndex,
|
||||
uniqueId,
|
||||
filters,
|
||||
@@ -180,6 +183,7 @@ export const QueryBuilder = ({
|
||||
level={level}
|
||||
noRemove={data?.rules?.length === 1}
|
||||
operators={operators}
|
||||
selectData={playlists}
|
||||
onChangeField={onChangeField}
|
||||
onChangeOperator={onChangeOperator}
|
||||
onChangeValue={onChangeValue}
|
||||
@@ -204,6 +208,7 @@ export const QueryBuilder = ({
|
||||
groupIndex={[...(groupIndex || []), index]}
|
||||
level={level + 1}
|
||||
operators={operators}
|
||||
playlists={playlists}
|
||||
uniqueId={group.uniqueId}
|
||||
onAddRule={onAddRule}
|
||||
onAddRuleGroup={onAddRuleGroup}
|
||||
|
||||
@@ -28,9 +28,10 @@ interface QueryOptionProps {
|
||||
number: { label: string; value: string }[];
|
||||
string: { label: string; value: string }[];
|
||||
};
|
||||
selectData?: { label: string; value: string }[];
|
||||
}
|
||||
|
||||
const QueryValueInput = ({ onChange, type, ...props }: any) => {
|
||||
const QueryValueInput = ({ onChange, type, data, ...props }: any) => {
|
||||
const [numberRange, setNumberRange] = useState([0, 0]);
|
||||
|
||||
switch (type) {
|
||||
@@ -59,7 +60,6 @@ const QueryValueInput = ({ onChange, type, ...props }: any) => {
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
case 'dateRange':
|
||||
return (
|
||||
<>
|
||||
@@ -87,7 +87,6 @@ const QueryValueInput = ({ onChange, type, ...props }: any) => {
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
case 'boolean':
|
||||
return (
|
||||
<Select
|
||||
@@ -99,6 +98,14 @@ const QueryValueInput = ({ onChange, type, ...props }: any) => {
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
case 'playlist':
|
||||
return (
|
||||
<Select
|
||||
data={data}
|
||||
onChange={onChange}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
default:
|
||||
return <></>;
|
||||
@@ -116,6 +123,7 @@ export const QueryBuilderOption = ({
|
||||
onChangeField,
|
||||
onChangeOperator,
|
||||
onChangeValue,
|
||||
selectData,
|
||||
}: QueryOptionProps) => {
|
||||
const { field, operator, uniqueId, value } = data;
|
||||
|
||||
@@ -133,10 +141,7 @@ export const QueryBuilderOption = ({
|
||||
|
||||
const handleChangeValue = (e: any) => {
|
||||
const isDirectValue =
|
||||
typeof e === 'string' ||
|
||||
typeof e === 'number' ||
|
||||
typeof e === 'undefined' ||
|
||||
typeof e === null;
|
||||
typeof e === 'string' || typeof e === 'number' || typeof e === 'undefined';
|
||||
|
||||
if (isDirectValue) {
|
||||
return onChangeValue({
|
||||
@@ -207,6 +212,7 @@ export const QueryBuilderOption = ({
|
||||
/>
|
||||
{field ? (
|
||||
<QueryValueInput
|
||||
data={selectData || []}
|
||||
defaultValue={value}
|
||||
maxWidth={170}
|
||||
size="sm"
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import { SEPARATOR_STRING } from '/@/renderer/api/utils';
|
||||
import { Text } from '/@/renderer/components/text';
|
||||
|
||||
export const Separator = () => {
|
||||
return (
|
||||
<Text
|
||||
$noSelect
|
||||
$secondary
|
||||
size="md"
|
||||
style={{ display: 'inline-block', padding: '0px 3px' }}
|
||||
>
|
||||
{SEPARATOR_STRING}
|
||||
</Text>
|
||||
);
|
||||
};
|
||||
@@ -19,6 +19,7 @@ export type VirtualInfiniteGridRef = {
|
||||
resetLoadMoreItemsCache: () => void;
|
||||
scrollTo: (index: number) => void;
|
||||
setItemData: (data: any[]) => void;
|
||||
updateItemData: (rule: (item: any) => any) => void;
|
||||
};
|
||||
|
||||
interface VirtualGridProps
|
||||
@@ -107,17 +108,19 @@ export const VirtualInfiniteGrid = forwardRef(
|
||||
take: end - start,
|
||||
});
|
||||
|
||||
const newData: any[] = [...itemData];
|
||||
setItemData((itemData) => {
|
||||
const newData: any[] = [...itemData];
|
||||
|
||||
let itemIndex = 0;
|
||||
for (let rowIndex = start; rowIndex < end; rowIndex += 1) {
|
||||
newData[rowIndex] = data.items[itemIndex];
|
||||
itemIndex += 1;
|
||||
}
|
||||
let itemIndex = 0;
|
||||
for (let rowIndex = start; rowIndex < itemCount; rowIndex += 1) {
|
||||
newData[rowIndex] = data.items[itemIndex];
|
||||
itemIndex += 1;
|
||||
}
|
||||
|
||||
setItemData(newData);
|
||||
return newData;
|
||||
});
|
||||
},
|
||||
[columnCount, fetchFn, itemData, setItemData],
|
||||
[columnCount, fetchFn, itemCount],
|
||||
);
|
||||
|
||||
const debouncedLoadMoreItems = debounce(loadMoreItems, 500);
|
||||
@@ -135,6 +138,9 @@ export const VirtualInfiniteGrid = forwardRef(
|
||||
setItemData: (data: any[]) => {
|
||||
setItemData(data);
|
||||
},
|
||||
updateItemData: (rule) => {
|
||||
setItemData((data) => data.map(rule));
|
||||
},
|
||||
}));
|
||||
|
||||
if (loading) return null;
|
||||
|
||||
@@ -7,6 +7,7 @@ import { Text } from '/@/renderer/components/text';
|
||||
import { CellContainer } from '/@/renderer/components/virtual-table/cells/generic-cell';
|
||||
import { AppRoute } from '/@/renderer/router/routes';
|
||||
import { Skeleton } from '/@/renderer/components/skeleton';
|
||||
import { Separator } from '/@/renderer/components/separator';
|
||||
|
||||
export const AlbumArtistCell = ({ value, data }: ICellRendererParams) => {
|
||||
if (value === undefined) {
|
||||
@@ -29,15 +30,7 @@ export const AlbumArtistCell = ({ value, data }: ICellRendererParams) => {
|
||||
>
|
||||
{value?.map((item: Artist | AlbumArtist, index: number) => (
|
||||
<React.Fragment key={`row-${item.id}-${data.uniqueId}`}>
|
||||
{index > 0 && (
|
||||
<Text
|
||||
$secondary
|
||||
size="md"
|
||||
style={{ display: 'inline-block' }}
|
||||
>
|
||||
,
|
||||
</Text>
|
||||
)}{' '}
|
||||
{index > 0 && <Separator />}
|
||||
{item.id ? (
|
||||
<Text
|
||||
$link
|
||||
|
||||
@@ -7,6 +7,7 @@ import { Text } from '/@/renderer/components/text';
|
||||
import { CellContainer } from '/@/renderer/components/virtual-table/cells/generic-cell';
|
||||
import { AppRoute } from '/@/renderer/router/routes';
|
||||
import { Skeleton } from '/@/renderer/components/skeleton';
|
||||
import { Separator } from '/@/renderer/components/separator';
|
||||
|
||||
export const ArtistCell = ({ value, data }: ICellRendererParams) => {
|
||||
if (value === undefined) {
|
||||
@@ -29,15 +30,7 @@ export const ArtistCell = ({ value, data }: ICellRendererParams) => {
|
||||
>
|
||||
{value?.map((item: Artist | AlbumArtist, index: number) => (
|
||||
<React.Fragment key={`row-${item.id}-${data.uniqueId}`}>
|
||||
{index > 0 && (
|
||||
<Text
|
||||
$secondary
|
||||
size="md"
|
||||
style={{ display: 'inline-block' }}
|
||||
>
|
||||
,
|
||||
</Text>
|
||||
)}{' '}
|
||||
{index > 0 && <Separator />}
|
||||
{item.id ? (
|
||||
<Text
|
||||
$link
|
||||
|
||||
@@ -10,8 +10,8 @@ import styled from 'styled-components';
|
||||
import type { AlbumArtist, Artist } from '/@/renderer/api/types';
|
||||
import { Text } from '/@/renderer/components/text';
|
||||
import { AppRoute } from '/@/renderer/router/routes';
|
||||
import { ServerType } from '/@/renderer/api/types';
|
||||
import { Skeleton } from '/@/renderer/components/skeleton';
|
||||
import { SEPARATOR_STRING } from '/@/renderer/api/utils';
|
||||
|
||||
const CellContainer = styled(motion.div)<{ height: number }>`
|
||||
display: grid;
|
||||
@@ -51,7 +51,7 @@ const StyledImage = styled(SimpleImg)`
|
||||
export const CombinedTitleCell = ({ value, rowIndex, node }: ICellRendererParams) => {
|
||||
const artists = useMemo(() => {
|
||||
if (!value) return null;
|
||||
return value?.type === ServerType.JELLYFIN ? value.artists : value.albumArtists;
|
||||
return value.artists?.length ? value.artists : value.albumArtists;
|
||||
}, [value]);
|
||||
|
||||
if (value === undefined) {
|
||||
@@ -119,7 +119,7 @@ export const CombinedTitleCell = ({ value, rowIndex, node }: ICellRendererParams
|
||||
{artists?.length ? (
|
||||
artists.map((artist: Artist | AlbumArtist, index: number) => (
|
||||
<React.Fragment key={`queue-${rowIndex}-artist-${artist.id}`}>
|
||||
{index > 0 ? ', ' : null}
|
||||
{index > 0 ? SEPARATOR_STRING : null}
|
||||
{artist.id ? (
|
||||
<Text
|
||||
$link
|
||||
|
||||
@@ -4,9 +4,11 @@ import { generatePath, Link } from 'react-router-dom';
|
||||
import type { AlbumArtist, Artist } from '/@/renderer/api/types';
|
||||
import { Text } from '/@/renderer/components/text';
|
||||
import { CellContainer } from '/@/renderer/components/virtual-table/cells/generic-cell';
|
||||
import { AppRoute } from '/@/renderer/router/routes';
|
||||
import { Separator } from '/@/renderer/components/separator';
|
||||
import { useGenreRoute } from '/@/renderer/hooks/use-genre-route';
|
||||
|
||||
export const GenreCell = ({ value, data }: ICellRendererParams) => {
|
||||
const genrePath = useGenreRoute();
|
||||
return (
|
||||
<CellContainer $position="left">
|
||||
<Text
|
||||
@@ -16,24 +18,14 @@ export const GenreCell = ({ value, data }: ICellRendererParams) => {
|
||||
>
|
||||
{value?.map((item: Artist | AlbumArtist, index: number) => (
|
||||
<React.Fragment key={`row-${item.id}-${data.uniqueId}`}>
|
||||
{index > 0 && (
|
||||
<Text
|
||||
$secondary
|
||||
size="md"
|
||||
style={{ display: 'inline-block' }}
|
||||
>
|
||||
,
|
||||
</Text>
|
||||
)}{' '}
|
||||
{index > 0 && <Separator />}
|
||||
<Text
|
||||
$link
|
||||
$secondary
|
||||
component={Link}
|
||||
overflow="hidden"
|
||||
size="md"
|
||||
to={generatePath(AppRoute.LIBRARY_GENRES_SONGS, {
|
||||
genreId: item.id,
|
||||
})}
|
||||
to={generatePath(genrePath, { genreId: item.id })}
|
||||
>
|
||||
{item.name || '—'}
|
||||
</Text>
|
||||
|
||||
@@ -43,6 +43,7 @@ import { useFixedTableHeader } from '/@/renderer/components/virtual-table/hooks/
|
||||
import { NoteCell } from '/@/renderer/components/virtual-table/cells/note-cell';
|
||||
import { RowIndexCell } from '/@/renderer/components/virtual-table/cells/row-index-cell';
|
||||
import i18n from '/@/i18n/i18n';
|
||||
import { formatSizeString } from '/@/renderer/utils/format-size-string';
|
||||
|
||||
export * from './table-config-dropdown';
|
||||
export * from './table-pagination';
|
||||
@@ -320,6 +321,16 @@ const tableColumns: { [key: string]: ColDef } = {
|
||||
},
|
||||
width: 65,
|
||||
},
|
||||
size: {
|
||||
cellRenderer: (params: ICellRendererParams) => GenericCell(params, { position: 'center' }),
|
||||
colId: TableColumn.SIZE,
|
||||
headerComponent: (params: IHeaderParams) =>
|
||||
GenericTableHeader(params, { position: 'center' }),
|
||||
headerName: i18n.t('table.column.size'),
|
||||
valueGetter: (params: ValueGetterParams) =>
|
||||
params.data ? formatSizeString(params.data.size) : undefined,
|
||||
width: 80,
|
||||
},
|
||||
songCount: {
|
||||
cellRenderer: (params: ICellRendererParams) => GenericCell(params, { position: 'center' }),
|
||||
colId: TableColumn.SONG_COUNT,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Center, Group, Stack } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { RiCheckFill } from 'react-icons/ri';
|
||||
import { RiCheckFill, RiEdit2Line, RiHome4Line } from 'react-icons/ri';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Button, PageHeader, Text } from '/@/renderer/components';
|
||||
import { ActionRequiredContainer } from '/@/renderer/features/action-required/components/action-required-container';
|
||||
@@ -9,6 +9,8 @@ import { ServerRequired } from '/@/renderer/features/action-required/components/
|
||||
import { AnimatedPage } from '/@/renderer/features/shared';
|
||||
import { AppRoute } from '/@/renderer/router/routes';
|
||||
import { useCurrentServer } from '/@/renderer/store';
|
||||
import { openModal } from '@mantine/modals';
|
||||
import { ServerList } from '/@/renderer/features/servers';
|
||||
|
||||
const ActionRequiredRoute = () => {
|
||||
const { t } = useTranslation();
|
||||
@@ -32,6 +34,13 @@ const ActionRequiredRoute = () => {
|
||||
const canReturnHome = checks.every((c) => c.valid);
|
||||
const displayedCheck = checks.find((c) => !c.valid);
|
||||
|
||||
const handleManageServersModal = () => {
|
||||
openModal({
|
||||
children: <ServerList />,
|
||||
title: 'Manage Servers',
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<AnimatedPage>
|
||||
<PageHeader />
|
||||
@@ -63,6 +72,7 @@ const ActionRequiredRoute = () => {
|
||||
<Button
|
||||
component={Link}
|
||||
disabled={!canReturnHome}
|
||||
leftIcon={<RiHome4Line />}
|
||||
to={AppRoute.HOME}
|
||||
variant="filled"
|
||||
>
|
||||
@@ -70,6 +80,23 @@ const ActionRequiredRoute = () => {
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{!displayedCheck && (
|
||||
<Group
|
||||
noWrap
|
||||
position="center"
|
||||
>
|
||||
<Button
|
||||
fullWidth
|
||||
leftIcon={<RiEdit2Line />}
|
||||
variant="filled"
|
||||
onClick={handleManageServersModal}
|
||||
>
|
||||
{t('page.appMenu.manageServers', {
|
||||
postProcess: 'sentenceCase',
|
||||
})}
|
||||
</Button>
|
||||
</Group>
|
||||
)}
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Center>
|
||||
|
||||
@@ -45,6 +45,7 @@ import {
|
||||
} from '/@/renderer/store/settings.store';
|
||||
import { Play } from '/@/renderer/types';
|
||||
import { replaceURLWithHTMLLinks } from '/@/renderer/utils/linkify';
|
||||
import { useGenreRoute } from '/@/renderer/hooks/use-genre-route';
|
||||
|
||||
const isFullWidthRow = (node: RowNode) => {
|
||||
return node.id?.startsWith('disc-');
|
||||
@@ -81,6 +82,7 @@ export const AlbumDetailContent = ({ tableRef, background }: AlbumDetailContentP
|
||||
const isFocused = useAppFocus();
|
||||
const currentSong = useCurrentSong();
|
||||
const { externalLinks } = useGeneralSettings();
|
||||
const genreRoute = useGenreRoute();
|
||||
|
||||
const columnDefs = useMemo(
|
||||
() => getColumnDefs(tableConfig.columns, false, 'albumDetail'),
|
||||
@@ -389,7 +391,7 @@ export const AlbumDetailContent = ({ tableRef, background }: AlbumDetailContentP
|
||||
component={Link}
|
||||
radius={0}
|
||||
size="md"
|
||||
to={generatePath(AppRoute.LIBRARY_GENRES_SONGS, {
|
||||
to={generatePath(genreRoute, {
|
||||
genreId: genre.id,
|
||||
})}
|
||||
variant="outline"
|
||||
|
||||
@@ -19,10 +19,10 @@ import {
|
||||
} from '/@/renderer/components/virtual-grid';
|
||||
import { useListContext } from '/@/renderer/context/list-context';
|
||||
import { usePlayQueueAdd } from '/@/renderer/features/player';
|
||||
import { useCreateFavorite, useDeleteFavorite } from '/@/renderer/features/shared';
|
||||
import { AppRoute } from '/@/renderer/router/routes';
|
||||
import { useCurrentServer, useListStoreActions, useListStoreByKey } from '/@/renderer/store';
|
||||
import { CardRow, ListDisplayType } from '/@/renderer/types';
|
||||
import { useHandleFavorite } from '/@/renderer/features/shared/hooks/use-handle-favorite';
|
||||
|
||||
export const AlbumListGridView = ({ gridRef, itemCount }: any) => {
|
||||
const queryClient = useQueryClient();
|
||||
@@ -36,33 +36,7 @@ export const AlbumListGridView = ({ gridRef, itemCount }: any) => {
|
||||
const scrollOffset = searchParams.get('scrollOffset');
|
||||
const initialScrollOffset = Number(id ? scrollOffset : grid?.scrollOffset) || 0;
|
||||
|
||||
const createFavoriteMutation = useCreateFavorite({});
|
||||
const deleteFavoriteMutation = useDeleteFavorite({});
|
||||
|
||||
const handleFavorite = (options: {
|
||||
id: string[];
|
||||
isFavorite: boolean;
|
||||
itemType: LibraryItem;
|
||||
}) => {
|
||||
const { id, itemType, isFavorite } = options;
|
||||
if (isFavorite) {
|
||||
deleteFavoriteMutation.mutate({
|
||||
query: {
|
||||
id,
|
||||
type: itemType,
|
||||
},
|
||||
serverId: server?.id,
|
||||
});
|
||||
} else {
|
||||
createFavoriteMutation.mutate({
|
||||
query: {
|
||||
id,
|
||||
type: itemType,
|
||||
},
|
||||
serverId: server?.id,
|
||||
});
|
||||
}
|
||||
};
|
||||
const handleFavorite = useHandleFavorite({ gridRef, server });
|
||||
|
||||
const cardRows = useMemo(() => {
|
||||
const rows: CardRow<Album>[] = [ALBUM_CARD_ROWS.name];
|
||||
|
||||
@@ -545,7 +545,7 @@ export const AlbumListHeaderFilters = ({ gridRef, tableRef }: AlbumListHeaderFil
|
||||
<Slider
|
||||
defaultValue={isGrid ? grid?.itemSize || 0 : table.rowHeight}
|
||||
max={isGrid ? 300 : 100}
|
||||
min={isGrid ? 150 : 25}
|
||||
min={isGrid ? 100 : 25}
|
||||
onChangeEnd={handleItemSize}
|
||||
/>
|
||||
</DropdownMenu.Item>
|
||||
|
||||
@@ -1,63 +1,59 @@
|
||||
import type { ChangeEvent, MutableRefObject } from 'react';
|
||||
import { useEffect, useRef, type ChangeEvent, type MutableRefObject } from 'react';
|
||||
import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/lib/agGridReact';
|
||||
import { Flex, Group, Stack } from '@mantine/core';
|
||||
import debounce from 'lodash/debounce';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useListFilterRefresh } from '../../../hooks/use-list-filter-refresh';
|
||||
import { LibraryItem } from '/@/renderer/api/types';
|
||||
import { PageHeader, SearchInput } from '/@/renderer/components';
|
||||
import { VirtualInfiniteGridRef } from '/@/renderer/components/virtual-grid';
|
||||
import { useListContext } from '/@/renderer/context/list-context';
|
||||
import { AlbumListHeaderFilters } from '/@/renderer/features/albums/components/album-list-header-filters';
|
||||
import { FilterBar, LibraryHeaderBar } from '/@/renderer/features/shared';
|
||||
import { useContainerQuery } from '/@/renderer/hooks';
|
||||
import {
|
||||
AlbumListFilter,
|
||||
useCurrentServer,
|
||||
useListStoreActions,
|
||||
useListStoreByKey,
|
||||
usePlayButtonBehavior,
|
||||
} from '/@/renderer/store';
|
||||
import { ListDisplayType } from '/@/renderer/types';
|
||||
import { AlbumListFilter, useCurrentServer, usePlayButtonBehavior } from '/@/renderer/store';
|
||||
import { titleCase } from '/@/renderer/utils';
|
||||
import { useDisplayRefresh } from '/@/renderer/hooks/use-display-refresh';
|
||||
|
||||
interface AlbumListHeaderProps {
|
||||
genreId?: string;
|
||||
gridRef: MutableRefObject<VirtualInfiniteGridRef | null>;
|
||||
itemCount?: number;
|
||||
tableRef: MutableRefObject<AgGridReactType | null>;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
export const AlbumListHeader = ({ itemCount, gridRef, tableRef, title }: AlbumListHeaderProps) => {
|
||||
export const AlbumListHeader = ({
|
||||
genreId,
|
||||
itemCount,
|
||||
gridRef,
|
||||
tableRef,
|
||||
title,
|
||||
}: AlbumListHeaderProps) => {
|
||||
const { t } = useTranslation();
|
||||
const server = useCurrentServer();
|
||||
const { setFilter, setTablePagination } = useListStoreActions();
|
||||
const cq = useContainerQuery();
|
||||
const { pageKey, handlePlay } = useListContext();
|
||||
const { display, filter } = useListStoreByKey({ key: pageKey });
|
||||
const playButtonBehavior = usePlayButtonBehavior();
|
||||
|
||||
const { handleRefreshGrid, handleRefreshTable } = useListFilterRefresh({
|
||||
const genreRef = useRef<string>();
|
||||
const { filter, handlePlay, refresh, search } = useDisplayRefresh({
|
||||
gridRef,
|
||||
itemType: LibraryItem.ALBUM,
|
||||
server,
|
||||
tableRef,
|
||||
});
|
||||
|
||||
const handleSearch = debounce((e: ChangeEvent<HTMLInputElement>) => {
|
||||
const searchTerm = e.target.value === '' ? undefined : e.target.value;
|
||||
const updatedFilters = setFilter({
|
||||
data: { searchTerm },
|
||||
itemType: LibraryItem.ALBUM,
|
||||
key: pageKey,
|
||||
}) as AlbumListFilter;
|
||||
const updatedFilters = search(e) as AlbumListFilter;
|
||||
|
||||
if (display === ListDisplayType.TABLE || display === ListDisplayType.TABLE_PAGINATED) {
|
||||
handleRefreshTable(tableRef, updatedFilters);
|
||||
setTablePagination({ data: { currentPage: 0 }, key: pageKey });
|
||||
} else {
|
||||
handleRefreshGrid(gridRef, updatedFilters);
|
||||
}
|
||||
refresh(updatedFilters);
|
||||
}, 500);
|
||||
|
||||
useEffect(() => {
|
||||
if (genreRef.current && genreRef.current !== genreId) {
|
||||
refresh(filter);
|
||||
}
|
||||
|
||||
genreRef.current = genreId;
|
||||
}, [filter, genreId, refresh, tableRef]);
|
||||
|
||||
return (
|
||||
<Stack
|
||||
ref={cq.ref}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { useCallback, useMemo, useRef } from 'react';
|
||||
import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/lib/agGridReact';
|
||||
import isEmpty from 'lodash/isEmpty';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useParams, useSearchParams } from 'react-router-dom';
|
||||
import { api } from '/@/renderer/api';
|
||||
import { queryKeys } from '/@/renderer/api/query-keys';
|
||||
import { LibraryItem } from '/@/renderer/api/types';
|
||||
import { GenreListSort, LibraryItem, SortOrder } from '/@/renderer/api/types';
|
||||
import { VirtualInfiniteGridRef } from '/@/renderer/components/virtual-grid';
|
||||
import { ListContext } from '/@/renderer/context/list-context';
|
||||
import { AlbumListContent } from '/@/renderer/features/albums/components/album-list-content';
|
||||
@@ -15,19 +16,32 @@ import { AnimatedPage } from '/@/renderer/features/shared';
|
||||
import { queryClient } from '/@/renderer/lib/react-query';
|
||||
import { useCurrentServer, useListFilterByKey } from '/@/renderer/store';
|
||||
import { Play } from '/@/renderer/types';
|
||||
import { useGenreList } from '/@/renderer/features/genres';
|
||||
import { titleCase } from '/@/renderer/utils';
|
||||
|
||||
const AlbumListRoute = () => {
|
||||
const { t } = useTranslation();
|
||||
const gridRef = useRef<VirtualInfiniteGridRef | null>(null);
|
||||
const tableRef = useRef<AgGridReactType | null>(null);
|
||||
const server = useCurrentServer();
|
||||
const [searchParams] = useSearchParams();
|
||||
const { albumArtistId } = useParams();
|
||||
const { albumArtistId, genreId } = useParams();
|
||||
const pageKey = albumArtistId ? `albumArtistAlbum` : 'album';
|
||||
const handlePlayQueueAdd = usePlayQueueAdd();
|
||||
|
||||
const customFilters = useMemo(() => {
|
||||
const value = {
|
||||
...(albumArtistId && { artistIds: [albumArtistId] }),
|
||||
...(genreId && {
|
||||
_custom: {
|
||||
jellyfin: {
|
||||
GenreIds: genreId,
|
||||
},
|
||||
navidrome: {
|
||||
genre_id: genreId,
|
||||
},
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
if (isEmpty(value)) {
|
||||
@@ -35,13 +49,35 @@ const AlbumListRoute = () => {
|
||||
}
|
||||
|
||||
return value;
|
||||
}, [albumArtistId]);
|
||||
}, [albumArtistId, genreId]);
|
||||
|
||||
const albumListFilter = useListFilterByKey({
|
||||
filter: customFilters,
|
||||
key: pageKey,
|
||||
});
|
||||
|
||||
const genreList = useGenreList({
|
||||
options: {
|
||||
cacheTime: 1000 * 60 * 60,
|
||||
enabled: !!genreId,
|
||||
},
|
||||
query: {
|
||||
sortBy: GenreListSort.NAME,
|
||||
sortOrder: SortOrder.ASC,
|
||||
startIndex: 0,
|
||||
},
|
||||
serverId: server?.id,
|
||||
});
|
||||
|
||||
const genreTitle = useMemo(() => {
|
||||
if (!genreList.data) return '';
|
||||
const genre = genreList.data.items.find((g) => g.id === genreId);
|
||||
|
||||
if (!genre) return 'Unknown';
|
||||
|
||||
return genre?.name;
|
||||
}, [genreId, genreList.data]);
|
||||
|
||||
const itemCountCheck = useAlbumList({
|
||||
options: {
|
||||
cacheTime: 1000 * 60,
|
||||
@@ -98,19 +134,27 @@ const AlbumListRoute = () => {
|
||||
return {
|
||||
customFilters,
|
||||
handlePlay,
|
||||
id: albumArtistId ?? undefined,
|
||||
id: albumArtistId ?? genreId,
|
||||
pageKey,
|
||||
};
|
||||
}, [albumArtistId, customFilters, handlePlay, pageKey]);
|
||||
}, [albumArtistId, customFilters, genreId, handlePlay, pageKey]);
|
||||
|
||||
const artist = searchParams.get('artistName');
|
||||
const title = artist
|
||||
? t('page.albumList.artistAlbums', { artist })
|
||||
: genreId
|
||||
? t('page.albumList.genreAlbums', { genre: titleCase(genreTitle) })
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<AnimatedPage>
|
||||
<ListContext.Provider value={providerValue}>
|
||||
<AlbumListHeader
|
||||
genreId={genreId}
|
||||
gridRef={gridRef}
|
||||
itemCount={itemCount}
|
||||
tableRef={tableRef}
|
||||
title={searchParams.get('artistName') || undefined}
|
||||
title={title}
|
||||
/>
|
||||
<AlbumListContent
|
||||
gridRef={gridRef}
|
||||
|
||||
@@ -0,0 +1,260 @@
|
||||
import { Button, Spinner, Spoiler, Text } from '/@/renderer/components';
|
||||
import {
|
||||
AnimatedPage,
|
||||
LibraryHeader,
|
||||
PlayButton,
|
||||
useCreateFavorite,
|
||||
useDeleteFavorite,
|
||||
} from '/@/renderer/features/shared';
|
||||
import { Fragment } from 'react';
|
||||
import { generatePath, useParams } from 'react-router';
|
||||
import { useContainerQuery, useFastAverageColor } from '/@/renderer/hooks';
|
||||
import { usePlayQueueAdd } from '/@/renderer/features/player';
|
||||
import { usePlayButtonBehavior } from '/@/renderer/store/settings.store';
|
||||
import { LibraryItem, SongDetailResponse } from '/@/renderer/api/types';
|
||||
import { useCurrentServer } from '/@/renderer/store';
|
||||
import { Stack, Group, Box, Center } from '@mantine/core';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { AppRoute } from '/@/renderer/router/routes';
|
||||
import { formatDurationString } from '/@/renderer/utils';
|
||||
import { RiErrorWarningLine, RiHeartFill, RiHeartLine, RiMoreFill } from 'react-icons/ri';
|
||||
import { replaceURLWithHTMLLinks } from '/@/renderer/utils/linkify';
|
||||
import { SONG_ALBUM_PAGE } from '/@/renderer/features/context-menu/context-menu-items';
|
||||
import { useHandleGeneralContextMenu } from '/@/renderer/features/context-menu';
|
||||
import { styled } from 'styled-components';
|
||||
import { queryClient } from '/@/renderer/lib/react-query';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { api } from '/@/renderer/api';
|
||||
import { queryKeys } from '/@/renderer/api/query-keys';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
const DetailContainer = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2rem;
|
||||
padding: 1rem 2rem 5rem;
|
||||
overflow: hidden;
|
||||
`;
|
||||
|
||||
const DummyAlbumDetailRoute = () => {
|
||||
const cq = useContainerQuery();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const { albumId } = useParams() as { albumId: string };
|
||||
const server = useCurrentServer();
|
||||
const queryKey = queryKeys.songs.detail(server?.id || '', albumId);
|
||||
const detailQuery = useQuery({
|
||||
queryFn: ({ signal }) => {
|
||||
if (!server) throw new Error('Server not found');
|
||||
return api.controller.getSongDetail({
|
||||
apiClientProps: { server, signal },
|
||||
query: { id: albumId },
|
||||
});
|
||||
},
|
||||
queryKey,
|
||||
});
|
||||
|
||||
const { color: background, colorId } = useFastAverageColor({
|
||||
id: albumId,
|
||||
src: detailQuery.data?.imageUrl,
|
||||
srcLoaded: !detailQuery.isLoading,
|
||||
});
|
||||
const handlePlayQueueAdd = usePlayQueueAdd();
|
||||
const playButtonBehavior = usePlayButtonBehavior();
|
||||
|
||||
const createFavoriteMutation = useCreateFavorite({});
|
||||
const deleteFavoriteMutation = useDeleteFavorite({});
|
||||
|
||||
const handleFavorite = async () => {
|
||||
if (!detailQuery?.data) return;
|
||||
|
||||
const wasFavorite = detailQuery.data.userFavorite;
|
||||
|
||||
try {
|
||||
if (wasFavorite) {
|
||||
await deleteFavoriteMutation.mutateAsync({
|
||||
query: {
|
||||
id: [detailQuery.data.id],
|
||||
type: LibraryItem.SONG,
|
||||
},
|
||||
serverId: detailQuery.data.serverId,
|
||||
});
|
||||
} else {
|
||||
await createFavoriteMutation.mutateAsync({
|
||||
query: {
|
||||
id: [detailQuery.data.id],
|
||||
type: LibraryItem.SONG,
|
||||
},
|
||||
serverId: detailQuery.data.serverId,
|
||||
});
|
||||
}
|
||||
|
||||
queryClient.setQueryData<SongDetailResponse>(queryKey, {
|
||||
...detailQuery.data,
|
||||
userFavorite: !wasFavorite,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
};
|
||||
|
||||
const showGenres = detailQuery?.data?.genres ? detailQuery?.data?.genres.length !== 0 : false;
|
||||
const comment = detailQuery?.data?.comment;
|
||||
|
||||
const handleGeneralContextMenu = useHandleGeneralContextMenu(LibraryItem.SONG, SONG_ALBUM_PAGE);
|
||||
|
||||
const handlePlay = () => {
|
||||
handlePlayQueueAdd?.({
|
||||
byItemType: {
|
||||
id: [albumId],
|
||||
type: LibraryItem.SONG,
|
||||
},
|
||||
playType: playButtonBehavior,
|
||||
});
|
||||
};
|
||||
|
||||
if (!background || colorId !== albumId) {
|
||||
return <Spinner container />;
|
||||
}
|
||||
|
||||
const metadataItems = [
|
||||
{
|
||||
id: 'releaseYear',
|
||||
secondary: false,
|
||||
value: detailQuery?.data?.releaseYear,
|
||||
},
|
||||
{
|
||||
id: 'duration',
|
||||
secondary: false,
|
||||
value: detailQuery?.data?.duration && formatDurationString(detailQuery.data.duration),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<AnimatedPage key={`dummy-album-detail-${albumId}`}>
|
||||
<Stack ref={cq.ref}>
|
||||
<LibraryHeader
|
||||
background={background}
|
||||
imageUrl={detailQuery?.data?.imageUrl}
|
||||
item={{ route: AppRoute.LIBRARY_SONGS, type: LibraryItem.SONG }}
|
||||
title={detailQuery?.data?.name || ''}
|
||||
>
|
||||
<Stack spacing="sm">
|
||||
<Group spacing="sm">
|
||||
{metadataItems.map((item, index) => (
|
||||
<Fragment key={`item-${item.id}-${index}`}>
|
||||
{index > 0 && <Text $noSelect>•</Text>}
|
||||
<Text $secondary={item.secondary}>{item.value}</Text>
|
||||
</Fragment>
|
||||
))}
|
||||
</Group>
|
||||
<Group
|
||||
mah="4rem"
|
||||
spacing="md"
|
||||
sx={{
|
||||
WebkitBoxOrient: 'vertical',
|
||||
WebkitLineClamp: 2,
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
{detailQuery?.data?.albumArtists.map((artist) => (
|
||||
<Text
|
||||
key={`artist-${artist.id}`}
|
||||
$link
|
||||
component={Link}
|
||||
fw={600}
|
||||
size="md"
|
||||
to={generatePath(AppRoute.LIBRARY_ALBUM_ARTISTS_DETAIL, {
|
||||
albumArtistId: artist.id,
|
||||
})}
|
||||
variant="subtle"
|
||||
>
|
||||
{artist.name}
|
||||
</Text>
|
||||
))}
|
||||
</Group>
|
||||
</Stack>
|
||||
</LibraryHeader>
|
||||
</Stack>
|
||||
<DetailContainer>
|
||||
<Box component="section">
|
||||
<Group
|
||||
position="apart"
|
||||
spacing="sm"
|
||||
>
|
||||
<Group>
|
||||
<PlayButton onClick={() => handlePlay()} />
|
||||
<Button
|
||||
compact
|
||||
loading={
|
||||
createFavoriteMutation.isLoading ||
|
||||
deleteFavoriteMutation.isLoading
|
||||
}
|
||||
variant="subtle"
|
||||
onClick={handleFavorite}
|
||||
>
|
||||
{detailQuery?.data?.userFavorite ? (
|
||||
<RiHeartFill
|
||||
color="red"
|
||||
size={20}
|
||||
/>
|
||||
) : (
|
||||
<RiHeartLine size={20} />
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
compact
|
||||
variant="subtle"
|
||||
onClick={(e) => {
|
||||
if (!detailQuery?.data) return;
|
||||
handleGeneralContextMenu(e, [detailQuery.data!]);
|
||||
}}
|
||||
>
|
||||
<RiMoreFill size={20} />
|
||||
</Button>
|
||||
</Group>
|
||||
</Group>
|
||||
</Box>
|
||||
{showGenres && (
|
||||
<Box component="section">
|
||||
<Group spacing="sm">
|
||||
{detailQuery?.data?.genres?.map((genre) => (
|
||||
<Button
|
||||
key={`genre-${genre.id}`}
|
||||
compact
|
||||
component={Link}
|
||||
radius={0}
|
||||
size="md"
|
||||
to={generatePath(AppRoute.LIBRARY_GENRES_SONGS, {
|
||||
genreId: genre.id,
|
||||
})}
|
||||
variant="outline"
|
||||
>
|
||||
{genre.name}
|
||||
</Button>
|
||||
))}
|
||||
</Group>
|
||||
</Box>
|
||||
)}
|
||||
{comment && (
|
||||
<Box component="section">
|
||||
<Spoiler maxHeight={75}>{replaceURLWithHTMLLinks(comment)}</Spoiler>
|
||||
</Box>
|
||||
)}
|
||||
<Box component="section">
|
||||
<Center>
|
||||
<Group mr={5}>
|
||||
<RiErrorWarningLine
|
||||
color="var(--danger-color)"
|
||||
size={30}
|
||||
/>
|
||||
</Group>
|
||||
<h2>{t('error.badAlbum', { postProcess: 'sentenceCase' })}</h2>
|
||||
</Center>
|
||||
</Box>
|
||||
</DetailContainer>
|
||||
</AnimatedPage>
|
||||
);
|
||||
};
|
||||
|
||||
export default DummyAlbumDetailRoute;
|
||||
@@ -39,6 +39,8 @@ import { AppRoute } from '/@/renderer/router/routes';
|
||||
import { useCurrentServer } from '/@/renderer/store';
|
||||
import { useGeneralSettings, usePlayButtonBehavior } from '/@/renderer/store/settings.store';
|
||||
import { CardRow, Play, TableColumn } from '/@/renderer/types';
|
||||
import { sanitize } from '/@/renderer/utils/sanitize';
|
||||
import { useGenreRoute } from '/@/renderer/hooks/use-genre-route';
|
||||
|
||||
const ContentContainer = styled.div`
|
||||
position: relative;
|
||||
@@ -68,6 +70,7 @@ export const AlbumArtistDetailContent = ({ background }: AlbumArtistDetailConten
|
||||
const cq = useContainerQuery();
|
||||
const handlePlayQueueAdd = usePlayQueueAdd();
|
||||
const server = useCurrentServer();
|
||||
const genrePath = useGenreRoute();
|
||||
|
||||
const detailQuery = useAlbumArtistDetail({
|
||||
query: { id: albumArtistId },
|
||||
@@ -330,8 +333,13 @@ export const AlbumArtistDetailContent = ({ background }: AlbumArtistDetailConten
|
||||
|
||||
const topSongs = topSongsQuery?.data?.items?.slice(0, 10);
|
||||
|
||||
const showBiography =
|
||||
detailQuery?.data?.biography !== undefined && detailQuery?.data?.biography !== null;
|
||||
const biography = useMemo(() => {
|
||||
const bio = detailQuery?.data?.biography;
|
||||
|
||||
if (!bio) return null;
|
||||
return sanitize(bio);
|
||||
}, [detailQuery?.data?.biography]);
|
||||
|
||||
const showTopSongs = topSongsQuery?.data?.items?.length;
|
||||
const showGenres = detailQuery?.data?.genres ? detailQuery?.data?.genres.length !== 0 : false;
|
||||
const mbzId = detailQuery?.data?.mbz;
|
||||
@@ -408,7 +416,7 @@ export const AlbumArtistDetailContent = ({ background }: AlbumArtistDetailConten
|
||||
component={Link}
|
||||
radius="md"
|
||||
size="md"
|
||||
to={generatePath(AppRoute.LIBRARY_GENRES_SONGS, {
|
||||
to={generatePath(genrePath, {
|
||||
genreId: genre.id,
|
||||
})}
|
||||
variant="outline"
|
||||
@@ -459,7 +467,7 @@ export const AlbumArtistDetailContent = ({ background }: AlbumArtistDetailConten
|
||||
</Group>
|
||||
</Box>
|
||||
) : null}
|
||||
{showBiography ? (
|
||||
{biography ? (
|
||||
<Box
|
||||
component="section"
|
||||
maw="1280px"
|
||||
@@ -472,9 +480,7 @@ export const AlbumArtistDetailContent = ({ background }: AlbumArtistDetailConten
|
||||
artist: detailQuery?.data?.name,
|
||||
})}
|
||||
</TextTitle>
|
||||
<Spoiler
|
||||
dangerouslySetInnerHTML={{ __html: detailQuery?.data?.biography || '' }}
|
||||
/>
|
||||
<Spoiler dangerouslySetInnerHTML={{ __html: biography }} />
|
||||
</Box>
|
||||
) : null}
|
||||
{showTopSongs ? (
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
AlbumArtistListQuery,
|
||||
AlbumArtistListResponse,
|
||||
AlbumArtistListSort,
|
||||
ArtistListQuery,
|
||||
LibraryItem,
|
||||
} from '/@/renderer/api/types';
|
||||
import { ALBUMARTIST_CARD_ROWS } from '/@/renderer/components';
|
||||
@@ -20,6 +21,7 @@ import { usePlayQueueAdd } from '/@/renderer/features/player';
|
||||
import { AppRoute } from '/@/renderer/router/routes';
|
||||
import { useCurrentServer, useListStoreActions } from '/@/renderer/store';
|
||||
import { CardRow, ListDisplayType } from '/@/renderer/types';
|
||||
import { useHandleFavorite } from '/@/renderer/features/shared/hooks/use-handle-favorite';
|
||||
|
||||
interface AlbumArtistListGridViewProps {
|
||||
gridRef: MutableRefObject<VirtualInfiniteGridRef | null>;
|
||||
@@ -34,6 +36,7 @@ export const AlbumArtistListGridView = ({ itemCount, gridRef }: AlbumArtistListG
|
||||
const { pageKey } = useListContext();
|
||||
const { grid, display, filter } = useListStoreByKey({ key: pageKey });
|
||||
const { setGrid } = useListStoreActions();
|
||||
const handleFavorite = useHandleFavorite({ gridRef, server });
|
||||
|
||||
const fetchInitialData = useCallback(() => {
|
||||
const query: Omit<AlbumArtistListQuery, 'startIndex' | 'limit'> = {
|
||||
@@ -70,16 +73,13 @@ export const AlbumArtistListGridView = ({ itemCount, gridRef }: AlbumArtistListG
|
||||
|
||||
const fetch = useCallback(
|
||||
async ({ skip: startIndex, take: limit }: { skip: number; take: number }) => {
|
||||
const queryKey = queryKeys.albumArtists.list(
|
||||
server?.id || '',
|
||||
{
|
||||
...filter,
|
||||
},
|
||||
{
|
||||
limit,
|
||||
startIndex,
|
||||
},
|
||||
);
|
||||
const query: ArtistListQuery = {
|
||||
...filter,
|
||||
limit,
|
||||
startIndex,
|
||||
};
|
||||
|
||||
const queryKey = queryKeys.albumArtists.list(server?.id || '', query);
|
||||
|
||||
const albumArtistsRes = await queryClient.fetchQuery(
|
||||
queryKey,
|
||||
@@ -154,6 +154,7 @@ export const AlbumArtistListGridView = ({ itemCount, gridRef }: AlbumArtistListG
|
||||
display={display || ListDisplayType.CARD}
|
||||
fetchFn={fetch}
|
||||
fetchInitialData={fetchInitialData}
|
||||
handleFavorite={handleFavorite}
|
||||
handlePlayQueueAdd={handlePlayQueueAdd}
|
||||
height={height}
|
||||
initialScrollOffset={grid?.scrollOffset || 0}
|
||||
|
||||
@@ -3,8 +3,6 @@ import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/li
|
||||
import { Flex, Group, Stack } from '@mantine/core';
|
||||
import debounce from 'lodash/debounce';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useListContext } from '../../../context/list-context';
|
||||
import { useListStoreByKey } from '../../../store/list.store';
|
||||
import { FilterBar } from '../../shared/components/filter-bar';
|
||||
import { LibraryItem } from '/@/renderer/api/types';
|
||||
import { PageHeader, SearchInput } from '/@/renderer/components';
|
||||
@@ -12,9 +10,8 @@ import { VirtualInfiniteGridRef } from '/@/renderer/components/virtual-grid';
|
||||
import { AlbumArtistListHeaderFilters } from '/@/renderer/features/artists/components/album-artist-list-header-filters';
|
||||
import { LibraryHeaderBar } from '/@/renderer/features/shared';
|
||||
import { useContainerQuery } from '/@/renderer/hooks';
|
||||
import { useListFilterRefresh } from '/@/renderer/hooks/use-list-filter-refresh';
|
||||
import { AlbumArtistListFilter, useCurrentServer, useListStoreActions } from '/@/renderer/store';
|
||||
import { ListDisplayType } from '/@/renderer/types';
|
||||
import { AlbumArtistListFilter, useCurrentServer } from '/@/renderer/store';
|
||||
import { useDisplayRefresh } from '/@/renderer/hooks/use-display-refresh';
|
||||
|
||||
interface AlbumArtistListHeaderProps {
|
||||
gridRef: MutableRefObject<VirtualInfiniteGridRef | null>;
|
||||
@@ -29,30 +26,18 @@ export const AlbumArtistListHeader = ({
|
||||
}: AlbumArtistListHeaderProps) => {
|
||||
const { t } = useTranslation();
|
||||
const server = useCurrentServer();
|
||||
const { pageKey } = useListContext();
|
||||
const { display, filter } = useListStoreByKey({ key: pageKey });
|
||||
const { setFilter, setTablePagination } = useListStoreActions();
|
||||
const cq = useContainerQuery();
|
||||
|
||||
const { handleRefreshGrid, handleRefreshTable } = useListFilterRefresh({
|
||||
const { filter, refresh, search } = useDisplayRefresh({
|
||||
gridRef,
|
||||
itemType: LibraryItem.ALBUM_ARTIST,
|
||||
server,
|
||||
tableRef,
|
||||
});
|
||||
|
||||
const handleSearch = debounce((e: ChangeEvent<HTMLInputElement>) => {
|
||||
const searchTerm = e.target.value === '' ? undefined : e.target.value;
|
||||
const updatedFilters = setFilter({
|
||||
data: { searchTerm },
|
||||
itemType: LibraryItem.ALBUM_ARTIST,
|
||||
key: pageKey,
|
||||
}) as AlbumArtistListFilter;
|
||||
|
||||
if (display === ListDisplayType.TABLE || display === ListDisplayType.TABLE_PAGINATED) {
|
||||
handleRefreshTable(tableRef, updatedFilters);
|
||||
setTablePagination({ data: { currentPage: 0 }, key: pageKey });
|
||||
} else {
|
||||
handleRefreshGrid(gridRef, updatedFilters);
|
||||
}
|
||||
const updatedFilters = search(e) as AlbumArtistListFilter;
|
||||
refresh(updatedFilters);
|
||||
}, 500);
|
||||
|
||||
return (
|
||||
|
||||
@@ -9,6 +9,7 @@ export const QUEUE_CONTEXT_MENU_ITEMS: SetContextMenuItems = [
|
||||
{ divider: true, id: 'removeFromFavorites' },
|
||||
{ children: true, disabled: false, id: 'setRating' },
|
||||
{ disabled: false, id: 'deselectAll' },
|
||||
{ divider: true, id: 'showDetails' },
|
||||
];
|
||||
|
||||
export const SONG_CONTEXT_MENU_ITEMS: SetContextMenuItems = [
|
||||
@@ -18,7 +19,16 @@ export const SONG_CONTEXT_MENU_ITEMS: SetContextMenuItems = [
|
||||
{ divider: true, id: 'addToPlaylist' },
|
||||
{ id: 'addToFavorites' },
|
||||
{ divider: true, id: 'removeFromFavorites' },
|
||||
{ children: true, disabled: false, id: 'setRating' },
|
||||
{ children: true, disabled: false, divider: true, id: 'setRating' },
|
||||
{ divider: true, id: 'shareItem' },
|
||||
{ divider: true, id: 'showDetails' },
|
||||
];
|
||||
|
||||
export const SONG_ALBUM_PAGE: SetContextMenuItems = [
|
||||
{ id: 'play' },
|
||||
{ id: 'playLast' },
|
||||
{ divider: true, id: 'playNext' },
|
||||
{ divider: true, id: 'addToPlaylist' },
|
||||
];
|
||||
|
||||
export const PLAYLIST_SONG_CONTEXT_MENU_ITEMS: SetContextMenuItems = [
|
||||
@@ -30,6 +40,7 @@ export const PLAYLIST_SONG_CONTEXT_MENU_ITEMS: SetContextMenuItems = [
|
||||
{ id: 'addToFavorites' },
|
||||
{ divider: true, id: 'removeFromFavorites' },
|
||||
{ children: true, disabled: false, id: 'setRating' },
|
||||
{ divider: true, id: 'showDetails' },
|
||||
];
|
||||
|
||||
export const SMART_PLAYLIST_SONG_CONTEXT_MENU_ITEMS: SetContextMenuItems = [
|
||||
@@ -40,6 +51,7 @@ export const SMART_PLAYLIST_SONG_CONTEXT_MENU_ITEMS: SetContextMenuItems = [
|
||||
{ id: 'addToFavorites' },
|
||||
{ divider: true, id: 'removeFromFavorites' },
|
||||
{ children: true, disabled: false, id: 'setRating' },
|
||||
{ divider: true, id: 'showDetails' },
|
||||
];
|
||||
|
||||
export const ALBUM_CONTEXT_MENU_ITEMS: SetContextMenuItems = [
|
||||
@@ -49,7 +61,9 @@ export const ALBUM_CONTEXT_MENU_ITEMS: SetContextMenuItems = [
|
||||
{ divider: true, id: 'addToPlaylist' },
|
||||
{ id: 'addToFavorites' },
|
||||
{ id: 'removeFromFavorites' },
|
||||
{ children: true, disabled: false, id: 'setRating' },
|
||||
{ children: true, disabled: false, divider: true, id: 'setRating' },
|
||||
{ divider: true, id: 'shareItem' },
|
||||
{ divider: true, id: 'showDetails' },
|
||||
];
|
||||
|
||||
export const GENRE_CONTEXT_MENU_ITEMS: SetContextMenuItems = [
|
||||
@@ -67,6 +81,7 @@ export const ARTIST_CONTEXT_MENU_ITEMS: SetContextMenuItems = [
|
||||
{ id: 'addToFavorites' },
|
||||
{ divider: true, id: 'removeFromFavorites' },
|
||||
{ children: true, disabled: false, id: 'setRating' },
|
||||
{ divider: true, id: 'showDetails' },
|
||||
];
|
||||
|
||||
export const PLAYLIST_CONTEXT_MENU_ITEMS: SetContextMenuItems = [
|
||||
|
||||
@@ -11,6 +11,8 @@ import {
|
||||
import { closeAllModals, openContextModal, openModal } from '@mantine/modals';
|
||||
import { AnimatePresence } from 'framer-motion';
|
||||
import isElectron from 'is-electron';
|
||||
import { ServerFeature } from '/@/renderer/api/features-types';
|
||||
import { hasFeature } from '/@/renderer/api/utils';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
RiAddBoxFill,
|
||||
@@ -25,6 +27,8 @@ import {
|
||||
RiPlayListAddFill,
|
||||
RiStarFill,
|
||||
RiCloseCircleLine,
|
||||
RiShareForwardFill,
|
||||
RiInformationFill,
|
||||
} from 'react-icons/ri';
|
||||
import { AnyLibraryItems, LibraryItem, ServerType, AnyLibraryItem } from '/@/renderer/api/types';
|
||||
import {
|
||||
@@ -53,6 +57,7 @@ import {
|
||||
} from '/@/renderer/store';
|
||||
import { usePlaybackType } from '/@/renderer/store/settings.store';
|
||||
import { Play, PlaybackType } from '/@/renderer/types';
|
||||
import { ItemDetailsModal } from '/@/renderer/features/item-details/components/item-details-modal';
|
||||
|
||||
type ContextMenuContextProps = {
|
||||
closeContextMenu: () => void;
|
||||
@@ -76,7 +81,7 @@ const ContextMenuContext = createContext<ContextMenuContextProps>({
|
||||
},
|
||||
});
|
||||
|
||||
const JELLYFIN_IGNORED_MENU_ITEMS: ContextMenuItemType[] = ['setRating'];
|
||||
const JELLYFIN_IGNORED_MENU_ITEMS: ContextMenuItemType[] = ['setRating', 'shareItem'];
|
||||
// const NAVIDROME_IGNORED_MENU_ITEMS: ContextMenuItemType[] = [];
|
||||
// const SUBSONIC_IGNORED_MENU_ITEMS: ContextMenuItemType[] = [];
|
||||
|
||||
@@ -600,6 +605,22 @@ export const ContextMenuProvider = ({ children }: ContextMenuProviderProps) => {
|
||||
}
|
||||
}, [ctx.dataNodes, moveToTopOfQueue, playbackType]);
|
||||
|
||||
const handleShareItem = useCallback(() => {
|
||||
if (!ctx.dataNodes && !ctx.data) return;
|
||||
|
||||
const uniqueIds = ctx.data.map((node) => node.id);
|
||||
|
||||
openContextModal({
|
||||
innerProps: {
|
||||
itemIds: uniqueIds,
|
||||
resourceType: ctx.data[0].itemType,
|
||||
},
|
||||
modal: 'shareItem',
|
||||
size: 'md',
|
||||
title: t('page.contextMenu.shareItem', { postProcess: 'sentenceCase' }),
|
||||
});
|
||||
}, [ctx.data, ctx.dataNodes, t]);
|
||||
|
||||
const handleRemoveSelected = useCallback(() => {
|
||||
const uniqueIds = ctx.dataNodes?.map((row) => row.data.uniqueId);
|
||||
if (!uniqueIds?.length) return;
|
||||
@@ -627,6 +648,16 @@ export const ContextMenuProvider = ({ children }: ContextMenuProviderProps) => {
|
||||
ctx.tableApi?.deselectAll();
|
||||
}, [ctx.tableApi]);
|
||||
|
||||
const handleOpenItemDetails = useCallback(() => {
|
||||
const item = ctx.data[0];
|
||||
|
||||
openModal({
|
||||
children: <ItemDetailsModal item={item} />,
|
||||
size: 'xl',
|
||||
title: t('page.contextMenu.showDetails', { postProcess: 'titleCase' }),
|
||||
});
|
||||
}, [ctx.data, t]);
|
||||
|
||||
const contextMenuItems: Record<ContextMenuItemType, ContextMenuItem> = useMemo(() => {
|
||||
return {
|
||||
addToFavorites: {
|
||||
@@ -775,20 +806,38 @@ export const ContextMenuProvider = ({ children }: ContextMenuProviderProps) => {
|
||||
onClick: () => {},
|
||||
rightIcon: <RiArrowRightSFill size="1.2rem" />,
|
||||
},
|
||||
shareItem: {
|
||||
disabled: !hasFeature(server, ServerFeature.SHARING_ALBUM_SONG),
|
||||
id: 'shareItem',
|
||||
label: t('page.contextMenu.shareItem', { postProcess: 'sentenceCase' }),
|
||||
leftIcon: <RiShareForwardFill size="1.1rem" />,
|
||||
onClick: handleShareItem,
|
||||
},
|
||||
showDetails: {
|
||||
disabled: ctx.data?.length !== 1 || !ctx.data[0].itemType,
|
||||
id: 'showDetails',
|
||||
label: t('page.contextMenu.showDetails', { postProcess: 'sentenceCase' }),
|
||||
leftIcon: <RiInformationFill />,
|
||||
onClick: handleOpenItemDetails,
|
||||
},
|
||||
};
|
||||
}, [
|
||||
t,
|
||||
handleAddToFavorites,
|
||||
handleAddToPlaylist,
|
||||
openDeletePlaylistModal,
|
||||
handleDeselectAll,
|
||||
handleMoveToBottom,
|
||||
handleMoveToTop,
|
||||
handlePlay,
|
||||
handleRemoveFromFavorites,
|
||||
handleRemoveFromPlaylist,
|
||||
handleRemoveSelected,
|
||||
ctx.data,
|
||||
handleOpenItemDetails,
|
||||
handlePlay,
|
||||
handleUpdateRating,
|
||||
openDeletePlaylistModal,
|
||||
t,
|
||||
handleShareItem,
|
||||
server,
|
||||
]);
|
||||
|
||||
const mergedRef = useMergedRef(ref, clickOutsideRef);
|
||||
@@ -819,72 +868,80 @@ export const ContextMenuProvider = ({ children }: ContextMenuProviderProps) => {
|
||||
>
|
||||
{ctx.menuItems?.map((item) => {
|
||||
return (
|
||||
<Fragment key={`context-menu-${item.id}`}>
|
||||
{item.children ? (
|
||||
<HoverCard
|
||||
offset={5}
|
||||
position="right"
|
||||
>
|
||||
<HoverCard.Target>
|
||||
<ContextMenuButton
|
||||
disabled={item.disabled}
|
||||
leftIcon={
|
||||
contextMenuItems[item.id]
|
||||
.leftIcon
|
||||
}
|
||||
rightIcon={
|
||||
contextMenuItems[item.id]
|
||||
.rightIcon
|
||||
}
|
||||
onClick={
|
||||
contextMenuItems[item.id]
|
||||
.onClick
|
||||
}
|
||||
>
|
||||
{contextMenuItems[item.id].label}
|
||||
</ContextMenuButton>
|
||||
</HoverCard.Target>
|
||||
<HoverCard.Dropdown>
|
||||
<Stack spacing={0}>
|
||||
{contextMenuItems[
|
||||
item.id
|
||||
].children?.map((child) => (
|
||||
<ContextMenuButton
|
||||
key={`sub-${child.id}`}
|
||||
disabled={child.disabled}
|
||||
leftIcon={child.leftIcon}
|
||||
rightIcon={child.rightIcon}
|
||||
onClick={child.onClick}
|
||||
>
|
||||
{child.label}
|
||||
</ContextMenuButton>
|
||||
))}
|
||||
</Stack>
|
||||
</HoverCard.Dropdown>
|
||||
</HoverCard>
|
||||
) : (
|
||||
<ContextMenuButton
|
||||
disabled={item.disabled}
|
||||
leftIcon={
|
||||
contextMenuItems[item.id].leftIcon
|
||||
}
|
||||
rightIcon={
|
||||
contextMenuItems[item.id].rightIcon
|
||||
}
|
||||
onClick={contextMenuItems[item.id].onClick}
|
||||
>
|
||||
{contextMenuItems[item.id].label}
|
||||
</ContextMenuButton>
|
||||
)}
|
||||
!contextMenuItems[item.id].disabled && (
|
||||
<Fragment key={`context-menu-${item.id}`}>
|
||||
{item.children ? (
|
||||
<HoverCard
|
||||
offset={5}
|
||||
position="right"
|
||||
>
|
||||
<HoverCard.Target>
|
||||
<ContextMenuButton
|
||||
leftIcon={
|
||||
contextMenuItems[item.id]
|
||||
.leftIcon
|
||||
}
|
||||
rightIcon={
|
||||
contextMenuItems[item.id]
|
||||
.rightIcon
|
||||
}
|
||||
onClick={
|
||||
contextMenuItems[item.id]
|
||||
.onClick
|
||||
}
|
||||
>
|
||||
{
|
||||
contextMenuItems[item.id]
|
||||
.label
|
||||
}
|
||||
</ContextMenuButton>
|
||||
</HoverCard.Target>
|
||||
<HoverCard.Dropdown>
|
||||
<Stack spacing={0}>
|
||||
{contextMenuItems[
|
||||
item.id
|
||||
].children?.map((child) => (
|
||||
<ContextMenuButton
|
||||
key={`sub-${child.id}`}
|
||||
leftIcon={
|
||||
child.leftIcon
|
||||
}
|
||||
rightIcon={
|
||||
child.rightIcon
|
||||
}
|
||||
onClick={child.onClick}
|
||||
>
|
||||
{child.label}
|
||||
</ContextMenuButton>
|
||||
))}
|
||||
</Stack>
|
||||
</HoverCard.Dropdown>
|
||||
</HoverCard>
|
||||
) : (
|
||||
<ContextMenuButton
|
||||
leftIcon={
|
||||
contextMenuItems[item.id].leftIcon
|
||||
}
|
||||
rightIcon={
|
||||
contextMenuItems[item.id].rightIcon
|
||||
}
|
||||
onClick={
|
||||
contextMenuItems[item.id].onClick
|
||||
}
|
||||
>
|
||||
{contextMenuItems[item.id].label}
|
||||
</ContextMenuButton>
|
||||
)}
|
||||
|
||||
{item.divider && (
|
||||
<Divider
|
||||
key={`context-menu-divider-${item.id}`}
|
||||
color="rgb(62, 62, 62)"
|
||||
size="sm"
|
||||
/>
|
||||
)}
|
||||
</Fragment>
|
||||
{item.divider && (
|
||||
<Divider
|
||||
key={`context-menu-divider-${item.id}`}
|
||||
color="rgb(62, 62, 62)"
|
||||
size="sm"
|
||||
/>
|
||||
)}
|
||||
</Fragment>
|
||||
)
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
|
||||
@@ -28,12 +28,14 @@ export type ContextMenuItemType =
|
||||
| 'addToFavorites'
|
||||
| 'removeFromFavorites'
|
||||
| 'setRating'
|
||||
| 'shareItem'
|
||||
| 'deletePlaylist'
|
||||
| 'createPlaylist'
|
||||
| 'moveToBottomOfQueue'
|
||||
| 'moveToTopOfQueue'
|
||||
| 'removeFromQueue'
|
||||
| 'deselectAll';
|
||||
| 'deselectAll'
|
||||
| 'showDetails';
|
||||
|
||||
export type SetContextMenuItems = {
|
||||
children?: boolean;
|
||||
|
||||
@@ -13,9 +13,9 @@ import {
|
||||
} from '/@/renderer/components/virtual-grid';
|
||||
import { useListContext } from '/@/renderer/context/list-context';
|
||||
import { usePlayQueueAdd } from '/@/renderer/features/player';
|
||||
import { AppRoute } from '/@/renderer/router/routes';
|
||||
import { useCurrentServer, useListStoreActions, useListStoreByKey } from '/@/renderer/store';
|
||||
import { CardRow, ListDisplayType } from '/@/renderer/types';
|
||||
import { useGenreRoute } from '/@/renderer/hooks/use-genre-route';
|
||||
|
||||
export const GenreListGridView = ({ gridRef, itemCount }: any) => {
|
||||
const queryClient = useQueryClient();
|
||||
@@ -24,6 +24,7 @@ export const GenreListGridView = ({ gridRef, itemCount }: any) => {
|
||||
const { pageKey, id } = useListContext();
|
||||
const { grid, display, filter } = useListStoreByKey({ key: pageKey });
|
||||
const { setGrid } = useListStoreActions();
|
||||
const genrePath = useGenreRoute();
|
||||
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const scrollOffset = searchParams.get('scrollOffset');
|
||||
@@ -137,7 +138,7 @@ export const GenreListGridView = ({ gridRef, itemCount }: any) => {
|
||||
loading={itemCount === undefined || itemCount === null}
|
||||
minimumBatchSize={40}
|
||||
route={{
|
||||
route: AppRoute.LIBRARY_GENRES_SONGS,
|
||||
route: genrePath,
|
||||
slugs: [{ idProperty: 'id', slugProperty: 'genreId' }],
|
||||
}}
|
||||
width={width}
|
||||
|
||||
@@ -3,7 +3,14 @@ import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/li
|
||||
import { Divider, Flex, Group, Stack } from '@mantine/core';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { RiFolder2Fill, RiMoreFill, RiRefreshLine, RiSettings3Fill } from 'react-icons/ri';
|
||||
import {
|
||||
RiAlbumLine,
|
||||
RiFolder2Fill,
|
||||
RiMoreFill,
|
||||
RiMusic2Line,
|
||||
RiRefreshLine,
|
||||
RiSettings3Fill,
|
||||
} from 'react-icons/ri';
|
||||
import { queryKeys } from '/@/renderer/api/query-keys';
|
||||
import { GenreListSort, LibraryItem, ServerType, SortOrder } from '/@/renderer/api/types';
|
||||
import { Button, DropdownMenu, MultiSelect, Slider, Switch, Text } from '/@/renderer/components';
|
||||
@@ -15,9 +22,12 @@ import { useContainerQuery } from '/@/renderer/hooks';
|
||||
import { useListFilterRefresh } from '/@/renderer/hooks/use-list-filter-refresh';
|
||||
import {
|
||||
GenreListFilter,
|
||||
GenreTarget,
|
||||
useCurrentServer,
|
||||
useGeneralSettings,
|
||||
useListStoreActions,
|
||||
useListStoreByKey,
|
||||
useSettingsStoreActions,
|
||||
} from '/@/renderer/store';
|
||||
import { ListDisplayType, TableColumn } from '/@/renderer/types';
|
||||
import i18n from '/@/i18n/i18n';
|
||||
@@ -52,6 +62,8 @@ export const GenreListHeaderFilters = ({ gridRef, tableRef }: GenreListHeaderFil
|
||||
const { setFilter, setTable, setGrid, setDisplayType } = useListStoreActions();
|
||||
const { display, filter, table, grid } = useListStoreByKey({ key: pageKey });
|
||||
const cq = useContainerQuery();
|
||||
const { genreTarget } = useGeneralSettings();
|
||||
const { setGenreBehavior } = useSettingsStoreActions();
|
||||
|
||||
const { handleRefreshTable, handleRefreshGrid } = useListFilterRefresh({
|
||||
itemType: LibraryItem.GENRE,
|
||||
@@ -208,6 +220,11 @@ export const GenreListHeaderFilters = ({ gridRef, tableRef }: GenreListHeaderFil
|
||||
return filter.musicFolderId !== undefined;
|
||||
}, [filter.musicFolderId]);
|
||||
|
||||
const handleGenreToggle = useCallback(() => {
|
||||
const newState = genreTarget === GenreTarget.ALBUM ? GenreTarget.TRACK : GenreTarget.ALBUM;
|
||||
setGenreBehavior(newState);
|
||||
}, [genreTarget, setGenreBehavior]);
|
||||
|
||||
return (
|
||||
<Flex justify="space-between">
|
||||
<Group
|
||||
@@ -309,6 +326,23 @@ export const GenreListHeaderFilters = ({ gridRef, tableRef }: GenreListHeaderFil
|
||||
{t('common.refresh', { postProcess: 'titleCase' })}
|
||||
</DropdownMenu.Item>
|
||||
</DropdownMenu.Dropdown>
|
||||
<Divider orientation="vertical" />
|
||||
<Button
|
||||
compact
|
||||
size="md"
|
||||
tooltip={{
|
||||
label: t(
|
||||
genreTarget === GenreTarget.ALBUM
|
||||
? 'page.genreList.showAlbums'
|
||||
: 'page.genreList.showTracks',
|
||||
{ postProcess: 'sentenceCase' },
|
||||
),
|
||||
}}
|
||||
variant="subtle"
|
||||
onClick={handleGenreToggle}
|
||||
>
|
||||
{genreTarget === GenreTarget.ALBUM ? <RiAlbumLine /> : <RiMusic2Line />}
|
||||
</Button>
|
||||
</DropdownMenu>
|
||||
</Group>
|
||||
<Group
|
||||
@@ -364,7 +398,7 @@ export const GenreListHeaderFilters = ({ gridRef, tableRef }: GenreListHeaderFil
|
||||
<Slider
|
||||
defaultValue={isGrid ? grid?.itemSize || 0 : table.rowHeight}
|
||||
max={isGrid ? 300 : 100}
|
||||
min={isGrid ? 150 : 25}
|
||||
min={isGrid ? 100 : 25}
|
||||
onChangeEnd={handleItemSize}
|
||||
/>
|
||||
</DropdownMenu.Item>
|
||||
|
||||
@@ -5,19 +5,12 @@ import debounce from 'lodash/debounce';
|
||||
import { LibraryItem } from '/@/renderer/api/types';
|
||||
import { PageHeader, SearchInput } from '/@/renderer/components';
|
||||
import { VirtualInfiniteGridRef } from '/@/renderer/components/virtual-grid';
|
||||
import { useListContext } from '/@/renderer/context/list-context';
|
||||
import { GenreListHeaderFilters } from '/@/renderer/features/genres/components/genre-list-header-filters';
|
||||
import { FilterBar, LibraryHeaderBar } from '/@/renderer/features/shared';
|
||||
import { useContainerQuery } from '/@/renderer/hooks';
|
||||
import { useListFilterRefresh } from '/@/renderer/hooks/use-list-filter-refresh';
|
||||
import {
|
||||
GenreListFilter,
|
||||
useCurrentServer,
|
||||
useListStoreActions,
|
||||
useListStoreByKey,
|
||||
} from '/@/renderer/store';
|
||||
import { ListDisplayType } from '/@/renderer/types';
|
||||
import { GenreListFilter, useCurrentServer } from '/@/renderer/store';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useDisplayRefresh } from '/@/renderer/hooks/use-display-refresh';
|
||||
|
||||
interface GenreListHeaderProps {
|
||||
gridRef: MutableRefObject<VirtualInfiniteGridRef | null>;
|
||||
@@ -29,34 +22,18 @@ export const GenreListHeader = ({ itemCount, gridRef, tableRef }: GenreListHeade
|
||||
const { t } = useTranslation();
|
||||
const cq = useContainerQuery();
|
||||
const server = useCurrentServer();
|
||||
const { pageKey } = useListContext();
|
||||
const { display, filter } = useListStoreByKey({ key: pageKey });
|
||||
const { setFilter, setTablePagination } = useListStoreActions();
|
||||
|
||||
const { handleRefreshGrid, handleRefreshTable } = useListFilterRefresh({
|
||||
const { filter, refresh, search } = useDisplayRefresh({
|
||||
gridRef,
|
||||
itemType: LibraryItem.GENRE,
|
||||
server,
|
||||
tableRef,
|
||||
});
|
||||
|
||||
const handleSearch = debounce((e: ChangeEvent<HTMLInputElement>) => {
|
||||
const searchTerm = e.target.value === '' ? undefined : e.target.value;
|
||||
const updatedFilters = setFilter({
|
||||
data: { searchTerm },
|
||||
itemType: LibraryItem.GENRE,
|
||||
key: pageKey,
|
||||
}) as GenreListFilter;
|
||||
|
||||
const filterWithCustom = {
|
||||
...updatedFilters,
|
||||
};
|
||||
|
||||
if (display === ListDisplayType.TABLE || display === ListDisplayType.TABLE_PAGINATED) {
|
||||
handleRefreshTable(tableRef, filterWithCustom);
|
||||
setTablePagination({ data: { currentPage: 0 }, key: pageKey });
|
||||
} else {
|
||||
handleRefreshGrid(gridRef, filterWithCustom);
|
||||
}
|
||||
const updatedFilters = search(e) as GenreListFilter;
|
||||
refresh(updatedFilters);
|
||||
}, 500);
|
||||
|
||||
return (
|
||||
<Stack
|
||||
ref={cq.ref}
|
||||
|
||||
@@ -9,7 +9,7 @@ import { useCurrentServer } from '/@/renderer/store';
|
||||
import { MutableRefObject, useCallback } from 'react';
|
||||
import { RowDoubleClickedEvent } from '@ag-grid-community/core';
|
||||
import { generatePath, useNavigate } from 'react-router';
|
||||
import { AppRoute } from '/@/renderer/router/routes';
|
||||
import { useGenreRoute } from '/@/renderer/hooks/use-genre-route';
|
||||
|
||||
interface GenreListTableViewProps {
|
||||
itemCount?: number;
|
||||
@@ -20,6 +20,7 @@ export const GenreListTableView = ({ tableRef, itemCount }: GenreListTableViewPr
|
||||
const server = useCurrentServer();
|
||||
const { pageKey, customFilters } = useListContext();
|
||||
const navigate = useNavigate();
|
||||
const genrePath = useGenreRoute();
|
||||
|
||||
const tableProps = useVirtualTable({
|
||||
contextMenu: GENRE_CONTEXT_MENU_ITEMS,
|
||||
@@ -36,9 +37,9 @@ export const GenreListTableView = ({ tableRef, itemCount }: GenreListTableViewPr
|
||||
const { data } = e;
|
||||
if (!data) return;
|
||||
|
||||
navigate(generatePath(AppRoute.LIBRARY_GENRES_SONGS, { genreId: data.id }));
|
||||
navigate(generatePath(genrePath, { genreId: data.id }));
|
||||
},
|
||||
[navigate],
|
||||
[genrePath, navigate],
|
||||
);
|
||||
|
||||
return (
|
||||
|
||||
@@ -0,0 +1,314 @@
|
||||
import { Group, Table } from '@mantine/core';
|
||||
import dayjs from 'dayjs';
|
||||
import { RiCheckFill, RiCloseFill } from 'react-icons/ri';
|
||||
import { TFunction, useTranslation } from 'react-i18next';
|
||||
import { ReactNode } from 'react';
|
||||
import { Album, AlbumArtist, AnyLibraryItem, LibraryItem, Song } from '/@/renderer/api/types';
|
||||
import { formatDurationString } from '/@/renderer/utils';
|
||||
import { formatSizeString } from '/@/renderer/utils/format-size-string';
|
||||
import { replaceURLWithHTMLLinks } from '/@/renderer/utils/linkify';
|
||||
import { Rating, Spoiler, Text } from '/@/renderer/components';
|
||||
import { sanitize } from '/@/renderer/utils/sanitize';
|
||||
import { SongPath } from '/@/renderer/features/item-details/components/song-path';
|
||||
import { generatePath } from 'react-router';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { AppRoute } from '/@/renderer/router/routes';
|
||||
import { Separator } from '/@/renderer/components/separator';
|
||||
import { useGenreRoute } from '/@/renderer/hooks/use-genre-route';
|
||||
|
||||
export type ItemDetailsModalProps = {
|
||||
item: Album | AlbumArtist | Song;
|
||||
};
|
||||
|
||||
type ItemDetailRow<T> = {
|
||||
key?: keyof T;
|
||||
label: string;
|
||||
postprocess?: string[];
|
||||
render?: (item: T) => ReactNode;
|
||||
};
|
||||
|
||||
const handleRow = <T extends AnyLibraryItem>(t: TFunction, item: T, rule: ItemDetailRow<T>) => {
|
||||
let value: ReactNode;
|
||||
|
||||
if (rule.render) {
|
||||
value = rule.render(item);
|
||||
} else {
|
||||
const prop = item[rule.key!];
|
||||
value = prop !== undefined && prop !== null ? String(prop) : null;
|
||||
}
|
||||
|
||||
if (!value) return null;
|
||||
|
||||
return (
|
||||
<tr key={rule.label}>
|
||||
<td>{t(rule.label, { postProcess: rule.postprocess || 'sentenceCase' })}</td>
|
||||
<td>{value}</td>
|
||||
</tr>
|
||||
);
|
||||
};
|
||||
|
||||
const formatArtists = (isAlbumArtist: boolean) => (item: Album | Song) =>
|
||||
(isAlbumArtist ? item.albumArtists : item.artists)?.map((artist, index) => (
|
||||
<span key={artist.id || artist.name}>
|
||||
{index > 0 && <Separator />}
|
||||
{artist.id ? (
|
||||
<Text
|
||||
$link
|
||||
component={Link}
|
||||
overflow="visible"
|
||||
size="md"
|
||||
to={
|
||||
artist.id
|
||||
? generatePath(AppRoute.LIBRARY_ALBUM_ARTISTS_DETAIL, {
|
||||
albumArtistId: artist.id,
|
||||
})
|
||||
: ''
|
||||
}
|
||||
weight={500}
|
||||
>
|
||||
{artist.name || '—'}
|
||||
</Text>
|
||||
) : (
|
||||
<Text
|
||||
overflow="visible"
|
||||
size="md"
|
||||
>
|
||||
{artist.name || '-'}
|
||||
</Text>
|
||||
)}
|
||||
</span>
|
||||
));
|
||||
|
||||
const formatComment = (item: Album | Song) =>
|
||||
item.comment ? <Spoiler maxHeight={50}>{replaceURLWithHTMLLinks(item.comment)}</Spoiler> : null;
|
||||
|
||||
const formatDate = (key: string | null) => (key ? dayjs(key).fromNow() : '');
|
||||
|
||||
const FormatGenre = (item: Album | AlbumArtist | Song) => {
|
||||
const genreRoute = useGenreRoute();
|
||||
|
||||
return item.genres?.map((genre, index) => (
|
||||
<span key={genre.id}>
|
||||
{index > 0 && <Separator />}
|
||||
<Text
|
||||
$link
|
||||
component={Link}
|
||||
overflow="visible"
|
||||
size="md"
|
||||
to={genre.id ? generatePath(genreRoute, { genreId: genre.id }) : ''}
|
||||
weight={500}
|
||||
>
|
||||
{genre.name || '—'}
|
||||
</Text>
|
||||
</span>
|
||||
));
|
||||
};
|
||||
|
||||
const formatRating = (item: Album | AlbumArtist | Song) =>
|
||||
item.userRating !== null ? (
|
||||
<Rating
|
||||
readOnly
|
||||
value={item.userRating}
|
||||
/>
|
||||
) : null;
|
||||
|
||||
const BoolField = (key: boolean) =>
|
||||
key ? <RiCheckFill size="1.1rem" /> : <RiCloseFill size="1.1rem" />;
|
||||
|
||||
const AlbumPropertyMapping: ItemDetailRow<Album>[] = [
|
||||
{ key: 'name', label: 'common.title' },
|
||||
{ label: 'entity.albumArtist_one', render: formatArtists(true) },
|
||||
{ label: 'entity.genre_other', render: FormatGenre },
|
||||
{
|
||||
label: 'common.duration',
|
||||
render: (album) => album.duration && formatDurationString(album.duration),
|
||||
},
|
||||
{ key: 'releaseYear', label: 'filter.releaseYear' },
|
||||
{ key: 'songCount', label: 'filter.songCount' },
|
||||
{ label: 'filter.isCompilation', render: (album) => BoolField(album.isCompilation || false) },
|
||||
{
|
||||
key: 'size',
|
||||
label: 'common.size',
|
||||
render: (album) => album.size && formatSizeString(album.size),
|
||||
},
|
||||
{
|
||||
label: 'common.favorite',
|
||||
render: (album) => BoolField(album.userFavorite),
|
||||
},
|
||||
{ label: 'common.rating', render: formatRating },
|
||||
{ key: 'playCount', label: 'filter.playCount' },
|
||||
{
|
||||
label: 'filter.lastPlayed',
|
||||
render: (song) => formatDate(song.lastPlayedAt),
|
||||
},
|
||||
{
|
||||
label: 'common.modified',
|
||||
render: (song) => formatDate(song.updatedAt),
|
||||
},
|
||||
{ label: 'filter.comment', render: formatComment },
|
||||
{
|
||||
label: 'common.mbid',
|
||||
postprocess: [],
|
||||
render: (album) =>
|
||||
album.mbzId ? (
|
||||
<a
|
||||
href={`https://musicbrainz.org/release/${album.mbzId}`}
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
{album.mbzId}
|
||||
</a>
|
||||
) : null,
|
||||
},
|
||||
];
|
||||
|
||||
const AlbumArtistPropertyMapping: ItemDetailRow<AlbumArtist>[] = [
|
||||
{ key: 'name', label: 'common.name' },
|
||||
{ label: 'entity.genre_other', render: FormatGenre },
|
||||
{
|
||||
label: 'common.duration',
|
||||
render: (artist) => artist.duration && formatDurationString(artist.duration),
|
||||
},
|
||||
{ key: 'songCount', label: 'filter.songCount' },
|
||||
{
|
||||
label: 'common.favorite',
|
||||
render: (artist) => BoolField(artist.userFavorite),
|
||||
},
|
||||
{ label: 'common.rating', render: formatRating },
|
||||
{ key: 'playCount', label: 'filter.playCount' },
|
||||
{
|
||||
label: 'filter.lastPlayed',
|
||||
render: (song) => formatDate(song.lastPlayedAt),
|
||||
},
|
||||
{
|
||||
label: 'common.mbid',
|
||||
postprocess: [],
|
||||
render: (artist) =>
|
||||
artist.mbz ? (
|
||||
<a
|
||||
href={`https://musicbrainz.org/artist/${artist.mbz}`}
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
{artist.mbz}
|
||||
</a>
|
||||
) : null,
|
||||
},
|
||||
{
|
||||
label: 'common.biography',
|
||||
render: (artist) =>
|
||||
artist.biography ? (
|
||||
<Spoiler
|
||||
dangerouslySetInnerHTML={{ __html: sanitize(artist.biography) }}
|
||||
maxHeight={50}
|
||||
/>
|
||||
) : null,
|
||||
},
|
||||
];
|
||||
|
||||
const SongPropertyMapping: ItemDetailRow<Song>[] = [
|
||||
{ key: 'name', label: 'common.title' },
|
||||
{ key: 'path', label: 'common.path', render: SongPath },
|
||||
{ label: 'entity.albumArtist_one', render: formatArtists(true) },
|
||||
{ key: 'artists', label: 'entity.artist_other', render: formatArtists(false) },
|
||||
{
|
||||
key: 'album',
|
||||
label: 'entity.album_one',
|
||||
render: (song) =>
|
||||
song.albumId &&
|
||||
song.album && (
|
||||
<Text
|
||||
$link
|
||||
component={Link}
|
||||
overflow="visible"
|
||||
size="md"
|
||||
to={
|
||||
song.albumId
|
||||
? generatePath(AppRoute.LIBRARY_ALBUMS_DETAIL, {
|
||||
albumId: song.albumId,
|
||||
})
|
||||
: ''
|
||||
}
|
||||
weight={500}
|
||||
>
|
||||
{song.album}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{ key: 'discNumber', label: 'common.disc' },
|
||||
{ key: 'trackNumber', label: 'common.trackNumber' },
|
||||
{ key: 'releaseYear', label: 'filter.releaseYear' },
|
||||
{ label: 'entity.genre_other', render: FormatGenre },
|
||||
{
|
||||
label: 'common.duration',
|
||||
render: (song) => formatDurationString(song.duration),
|
||||
},
|
||||
{ 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: 'channels', label: 'common.channel_other' },
|
||||
{ key: 'size', label: 'common.size', render: (song) => formatSizeString(song.size) },
|
||||
{
|
||||
label: 'common.favorite',
|
||||
render: (song) => BoolField(song.userFavorite),
|
||||
},
|
||||
{ label: 'common.rating', render: formatRating },
|
||||
{ key: 'playCount', label: 'filter.playCount' },
|
||||
{
|
||||
label: 'filter.lastPlayed',
|
||||
render: (song) => formatDate(song.lastPlayedAt),
|
||||
},
|
||||
{
|
||||
label: 'common.modified',
|
||||
render: (song) => formatDate(song.updatedAt),
|
||||
},
|
||||
{
|
||||
label: 'common.albumGain',
|
||||
render: (song) => (song.gain?.album !== undefined ? `${song.gain.album} dB` : null),
|
||||
},
|
||||
{
|
||||
label: 'common.trackGain',
|
||||
render: (song) => (song.gain?.track !== undefined ? `${song.gain.track} dB` : null),
|
||||
},
|
||||
{
|
||||
label: 'common.albumPeak',
|
||||
render: (song) => (song.peak?.album !== undefined ? `${song.peak.album}` : null),
|
||||
},
|
||||
{
|
||||
label: 'common.trackPeak',
|
||||
render: (song) => (song.peak?.track !== undefined ? `${song.peak.track}` : null),
|
||||
},
|
||||
{ label: 'filter.comment', render: formatComment },
|
||||
];
|
||||
|
||||
export const ItemDetailsModal = ({ item }: ItemDetailsModalProps) => {
|
||||
const { t } = useTranslation();
|
||||
let body: ReactNode;
|
||||
|
||||
switch (item.itemType) {
|
||||
case LibraryItem.ALBUM:
|
||||
body = AlbumPropertyMapping.map((rule) => handleRow(t, item, rule));
|
||||
break;
|
||||
case LibraryItem.ALBUM_ARTIST:
|
||||
body = AlbumArtistPropertyMapping.map((rule) => handleRow(t, item, rule));
|
||||
break;
|
||||
case LibraryItem.SONG:
|
||||
body = SongPropertyMapping.map((rule) => handleRow(t, item, rule));
|
||||
break;
|
||||
default:
|
||||
body = null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Group>
|
||||
<Table
|
||||
highlightOnHover
|
||||
horizontalSpacing="sm"
|
||||
sx={{ userSelect: 'text' }}
|
||||
verticalSpacing="sm"
|
||||
>
|
||||
<tbody>{body}</tbody>
|
||||
</Table>
|
||||
</Group>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,67 @@
|
||||
import { ActionIcon, CopyButton, Group } from '@mantine/core';
|
||||
import isElectron from 'is-electron';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { RiCheckFill, RiClipboardFill, RiExternalLinkFill } from 'react-icons/ri';
|
||||
import { Tooltip, toast } from '/@/renderer/components';
|
||||
import styled from 'styled-components';
|
||||
|
||||
const util = isElectron() ? window.electron.utils : null;
|
||||
|
||||
export type SongPathProps = {
|
||||
path: string | null;
|
||||
};
|
||||
|
||||
const PathText = styled.div`
|
||||
user-select: all;
|
||||
`;
|
||||
|
||||
export const SongPath = ({ path }: SongPathProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (!path) return null;
|
||||
|
||||
return (
|
||||
<Group>
|
||||
<CopyButton
|
||||
timeout={2000}
|
||||
value={path}
|
||||
>
|
||||
{({ copied, copy }) => (
|
||||
<Tooltip
|
||||
withinPortal
|
||||
label={t(
|
||||
copied ? 'page.itemDetail.copiedPath' : 'page.itemDetail.copyPath',
|
||||
{ postProcess: 'sentenceCase' },
|
||||
)}
|
||||
>
|
||||
<ActionIcon onClick={copy}>
|
||||
{copied ? <RiCheckFill /> : <RiClipboardFill />}
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
)}
|
||||
</CopyButton>
|
||||
{util && (
|
||||
<Tooltip
|
||||
withinPortal
|
||||
label={t('page.itemDetail.openFile', { postProcess: 'sentenceCase' })}
|
||||
>
|
||||
<ActionIcon>
|
||||
<RiExternalLinkFill
|
||||
onClick={() => {
|
||||
util.openItem(path).catch((error) => {
|
||||
toast.error({
|
||||
message: (error as Error).message,
|
||||
title: t('error.openError', {
|
||||
postProcess: 'sentenceCase',
|
||||
}),
|
||||
});
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
)}
|
||||
<PathText>{path}</PathText>
|
||||
</Group>
|
||||
);
|
||||
};
|
||||
@@ -104,7 +104,7 @@ export const useSongLyricsBySong = (
|
||||
})
|
||||
.catch(console.error);
|
||||
|
||||
if (subsonicLyrics) {
|
||||
if (subsonicLyrics?.length) {
|
||||
return subsonicLyrics;
|
||||
}
|
||||
} else if (hasFeature(server, ServerFeature.LYRICS_SINGLE_STRUCTURED)) {
|
||||
|
||||
@@ -41,7 +41,7 @@ const SynchronizedLyricsContainer = styled.div<{ $gap: number }>`
|
||||
transparent 95%
|
||||
);
|
||||
|
||||
@media screen and (width <= 768px) {
|
||||
@media screen and (orientation: portrait) {
|
||||
padding: 5vh 0;
|
||||
}
|
||||
`;
|
||||
@@ -271,7 +271,12 @@ export const SynchronizedLyrics = ({
|
||||
|
||||
return;
|
||||
}
|
||||
if (!seeked) {
|
||||
|
||||
// If the time goes back to 0 and we are still playing, this suggests that
|
||||
// we may be playing the same track (repeat one). In this case, we also
|
||||
// need to restart playback
|
||||
const restarted = status === PlayerStatus.PLAYING && now === 0;
|
||||
if (!seeked && !restarted) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -34,7 +34,7 @@ const UnsynchronizedLyricsContainer = styled.div<{ $gap: number }>`
|
||||
transparent 95%
|
||||
);
|
||||
|
||||
@media screen and (width <= 768px) {
|
||||
@media screen and (orientation: portrait) {
|
||||
padding: 5vh 0;
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
} from 'react-icons/ri';
|
||||
import { Song } from '/@/renderer/api/types';
|
||||
import { usePlayerControls, useQueueControls } from '/@/renderer/store';
|
||||
import { PlaybackType, TableType } from '/@/renderer/types';
|
||||
import { PlaybackType, PlayerStatus, TableType } from '/@/renderer/types';
|
||||
import { usePlaybackType } from '/@/renderer/store/settings.store';
|
||||
import { usePlayerStore, useSetCurrentTime } from '../../../store/player.store';
|
||||
import { TableConfigDropdown } from '/@/renderer/components/virtual-table';
|
||||
@@ -91,7 +91,7 @@ export const PlayQueueListControls = ({ type, tableRef }: PlayQueueListOptionsPr
|
||||
mpvPlayer!.pause();
|
||||
}
|
||||
|
||||
remote?.updateSong({ song: undefined });
|
||||
remote?.updateSong({ song: undefined, status: PlayerStatus.PAUSED });
|
||||
|
||||
setCurrentTime(0);
|
||||
pause();
|
||||
|
||||
@@ -36,6 +36,7 @@ import { useHandleTableContextMenu } from '/@/renderer/features/context-menu';
|
||||
import { QUEUE_CONTEXT_MENU_ITEMS } from '/@/renderer/features/context-menu/context-menu-items';
|
||||
import { VirtualGridAutoSizerContainer } from '/@/renderer/components/virtual-grid';
|
||||
import { useAppFocus } from '/@/renderer/hooks';
|
||||
import { PlayersRef } from '/@/renderer/features/player/ref/players-ref';
|
||||
|
||||
const mpvPlayer = isElectron() ? window.electron.mpvPlayer : null;
|
||||
const remote = isElectron() ? window.electron.remote : null;
|
||||
@@ -90,6 +91,15 @@ export const PlayQueue = forwardRef(({ type }: QueueProps, ref: Ref<any>) => {
|
||||
if (playbackType === PlaybackType.LOCAL) {
|
||||
mpvPlayer!.volume(volume);
|
||||
mpvPlayer!.setQueue(playerData, false);
|
||||
} else {
|
||||
const player =
|
||||
playerData.current.player === 1
|
||||
? PlayersRef.current?.player1
|
||||
: PlayersRef.current?.player2;
|
||||
const underlying = player?.getInternalPlayer();
|
||||
if (underlying) {
|
||||
underlying.currentTime = 0;
|
||||
}
|
||||
}
|
||||
|
||||
play();
|
||||
|
||||
@@ -324,8 +324,13 @@ export const CenterControls = ({ playersRef }: CenterControlsProps) => {
|
||||
setSeekValue(e);
|
||||
}}
|
||||
onChangeEnd={(e) => {
|
||||
handleSeekSlider(e);
|
||||
setIsSeeking(false);
|
||||
// There is a timing bug in Mantine in which the onChangeEnd
|
||||
// event fires before onChange. Add a small delay to force
|
||||
// onChangeEnd to happen after onCHange
|
||||
setTimeout(() => {
|
||||
handleSeekSlider(e);
|
||||
setIsSeeking(false);
|
||||
}, 50);
|
||||
}}
|
||||
/>
|
||||
</SliderWrapper>
|
||||
|
||||
@@ -206,10 +206,7 @@ export const FullScreenPlayerImage = () => {
|
||||
justify="flex-start"
|
||||
p="1rem"
|
||||
>
|
||||
<ImageContainer
|
||||
ref={mainImageRef}
|
||||
onLoad={updateImageSize}
|
||||
>
|
||||
<ImageContainer ref={mainImageRef}>
|
||||
<AnimatePresence
|
||||
initial={false}
|
||||
mode="sync"
|
||||
|
||||
@@ -39,7 +39,7 @@ const Container = styled(motion.div)`
|
||||
justify-content: center;
|
||||
padding: 2rem;
|
||||
|
||||
@media screen and (width <= 768px) {
|
||||
@media screen and (orientation: portrait) {
|
||||
padding: 2rem 2rem 1rem;
|
||||
}
|
||||
`;
|
||||
@@ -53,7 +53,7 @@ const ResponsiveContainer = styled.div`
|
||||
max-width: 2560px;
|
||||
margin-top: 5rem;
|
||||
|
||||
@media screen and (width <= 768px) {
|
||||
@media screen and (orientation: portrait) {
|
||||
grid-template-rows: minmax(0, 1fr) minmax(0, 1fr);
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
margin-top: 0;
|
||||
@@ -472,12 +472,10 @@ export const FullScreenPlayer = () => {
|
||||
srcLoaded: true,
|
||||
});
|
||||
|
||||
const imageUrl = currentSong?.imageUrl;
|
||||
const imageUrl = currentSong?.imageUrl && currentSong.imageUrl.replace(/size=\d+/g, 'size=500');
|
||||
const backgroundImage =
|
||||
imageUrl && dynamicIsImage
|
||||
? `url("${imageUrl
|
||||
.replace(/size=\d+/g, 'size=500')
|
||||
.replace(currentSong.id, currentSong.albumId)}`
|
||||
? `url("${imageUrl.replace(currentSong.id, currentSong.albumId)}"), url("${imageUrl}")`
|
||||
: mainBackground;
|
||||
|
||||
return (
|
||||
|
||||
@@ -20,6 +20,7 @@ import { fadeIn } from '/@/renderer/styles';
|
||||
import { LibraryItem } from '/@/renderer/api/types';
|
||||
import { SONG_CONTEXT_MENU_ITEMS } from '/@/renderer/features/context-menu/context-menu-items';
|
||||
import { useHandleGeneralContextMenu } from '/@/renderer/features/context-menu/hooks/use-handle-context-menu';
|
||||
import { Separator } from '/@/renderer/components/separator';
|
||||
|
||||
const ImageWrapper = styled.div`
|
||||
position: relative;
|
||||
@@ -236,16 +237,7 @@ export const LeftControls = () => {
|
||||
<LineItem $secondary>
|
||||
{artists?.map((artist, index) => (
|
||||
<React.Fragment key={`bar-${artist.id}`}>
|
||||
{index > 0 && (
|
||||
<Text
|
||||
$link
|
||||
$secondary
|
||||
size="md"
|
||||
style={{ display: 'inline-block' }}
|
||||
>
|
||||
,
|
||||
</Text>
|
||||
)}{' '}
|
||||
{index > 0 && <Separator />}
|
||||
<Text
|
||||
$link
|
||||
component={Link}
|
||||
|
||||
@@ -309,13 +309,12 @@ export const useCenterControls = (args: { playersRef: any }) => {
|
||||
const handleRepeatAll = {
|
||||
local: () => {
|
||||
const playerData = next();
|
||||
mprisUpdateSong({ song: playerData.current.song, status: PlayerStatus.PLAYING });
|
||||
mprisUpdateSong({ song: playerData.current.song });
|
||||
mpvPlayer!.setQueue(playerData);
|
||||
mpvPlayer!.next();
|
||||
},
|
||||
web: () => {
|
||||
const playerData = next();
|
||||
mprisUpdateSong({ song: playerData.current.song, status: PlayerStatus.PLAYING });
|
||||
mprisUpdateSong({ song: playerData.current.song });
|
||||
},
|
||||
};
|
||||
|
||||
@@ -324,17 +323,12 @@ export const useCenterControls = (args: { playersRef: any }) => {
|
||||
if (isLastTrack) {
|
||||
const playerData = setCurrentIndex(0);
|
||||
mprisUpdateSong({ song: playerData.current.song, status: PlayerStatus.PAUSED });
|
||||
mpvPlayer!.setQueue(playerData);
|
||||
mpvPlayer!.pause();
|
||||
mpvPlayer!.setQueue(playerData, true);
|
||||
pause();
|
||||
} else {
|
||||
const playerData = next();
|
||||
mprisUpdateSong({
|
||||
song: playerData.current.song,
|
||||
status: PlayerStatus.PLAYING,
|
||||
});
|
||||
mprisUpdateSong({ song: playerData.current.song });
|
||||
mpvPlayer!.setQueue(playerData);
|
||||
mpvPlayer!.next();
|
||||
}
|
||||
},
|
||||
web: () => {
|
||||
@@ -342,16 +336,13 @@ export const useCenterControls = (args: { playersRef: any }) => {
|
||||
const playerData = setCurrentIndex(0);
|
||||
mprisUpdateSong({
|
||||
song: playerData.current.song,
|
||||
status: PlayerStatus.PLAYING,
|
||||
status: PlayerStatus.PAUSED,
|
||||
});
|
||||
resetPlayers();
|
||||
pause();
|
||||
} else {
|
||||
const playerData = next();
|
||||
mprisUpdateSong({
|
||||
song: playerData.current.song,
|
||||
status: PlayerStatus.PLAYING,
|
||||
});
|
||||
mprisUpdateSong({ song: playerData.current.song });
|
||||
resetPlayers();
|
||||
}
|
||||
},
|
||||
@@ -359,18 +350,16 @@ export const useCenterControls = (args: { playersRef: any }) => {
|
||||
|
||||
const handleRepeatOne = {
|
||||
local: () => {
|
||||
const playerData = next();
|
||||
mprisUpdateSong({ song: playerData.current.song, status: PlayerStatus.PLAYING });
|
||||
mpvPlayer!.setQueue(playerData);
|
||||
mpvPlayer!.next();
|
||||
if (!isLastTrack) {
|
||||
const playerData = next();
|
||||
mprisUpdateSong({ song: playerData.current.song });
|
||||
mpvPlayer!.setQueue(playerData);
|
||||
}
|
||||
},
|
||||
web: () => {
|
||||
if (!isLastTrack) {
|
||||
const playerData = next();
|
||||
mprisUpdateSong({
|
||||
song: playerData.current.song,
|
||||
status: PlayerStatus.PLAYING,
|
||||
});
|
||||
mprisUpdateSong({ song: playerData.current.song });
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -424,36 +413,22 @@ export const useCenterControls = (args: { playersRef: any }) => {
|
||||
local: () => {
|
||||
if (!isFirstTrack) {
|
||||
const playerData = previous();
|
||||
mprisUpdateSong({
|
||||
song: playerData.current.song,
|
||||
status: PlayerStatus.PLAYING,
|
||||
});
|
||||
mprisUpdateSong({ song: playerData.current.song });
|
||||
mpvPlayer!.setQueue(playerData);
|
||||
mpvPlayer!.previous();
|
||||
} else {
|
||||
const playerData = setCurrentIndex(queue.length - 1);
|
||||
mprisUpdateSong({
|
||||
song: playerData.current.song,
|
||||
status: PlayerStatus.PLAYING,
|
||||
});
|
||||
mprisUpdateSong({ song: playerData.current.song });
|
||||
mpvPlayer!.setQueue(playerData);
|
||||
mpvPlayer!.previous();
|
||||
}
|
||||
},
|
||||
web: () => {
|
||||
if (isFirstTrack) {
|
||||
const playerData = setCurrentIndex(queue.length - 1);
|
||||
mprisUpdateSong({
|
||||
song: playerData.current.song,
|
||||
status: PlayerStatus.PLAYING,
|
||||
});
|
||||
mprisUpdateSong({ song: playerData.current.song });
|
||||
resetPlayers();
|
||||
} else {
|
||||
const playerData = previous();
|
||||
mprisUpdateSong({
|
||||
song: playerData.current.song,
|
||||
status: PlayerStatus.PLAYING,
|
||||
});
|
||||
mprisUpdateSong({ song: playerData.current.song });
|
||||
resetPlayers();
|
||||
}
|
||||
},
|
||||
@@ -461,13 +436,19 @@ export const useCenterControls = (args: { playersRef: any }) => {
|
||||
|
||||
const handleRepeatNone = {
|
||||
local: () => {
|
||||
const playerData = previous();
|
||||
remote?.updateSong({
|
||||
currentTime: usePlayerStore.getState().current.time,
|
||||
song: playerData.current.song,
|
||||
});
|
||||
mpvPlayer!.setQueue(playerData);
|
||||
mpvPlayer!.previous();
|
||||
if (isFirstTrack) {
|
||||
const playerData = setCurrentIndex(0);
|
||||
mprisUpdateSong({ song: playerData.current.song, status: PlayerStatus.PAUSED });
|
||||
mpvPlayer!.setQueue(playerData, true);
|
||||
pause();
|
||||
} else {
|
||||
const playerData = previous();
|
||||
mprisUpdateSong({
|
||||
currentTime: usePlayerStore.getState().current.time,
|
||||
song: playerData.current.song,
|
||||
});
|
||||
mpvPlayer!.setQueue(playerData);
|
||||
}
|
||||
},
|
||||
web: () => {
|
||||
if (isFirstTrack) {
|
||||
@@ -476,10 +457,7 @@ export const useCenterControls = (args: { playersRef: any }) => {
|
||||
pause();
|
||||
} else {
|
||||
const playerData = previous();
|
||||
mprisUpdateSong({
|
||||
song: playerData.current.song,
|
||||
status: PlayerStatus.PLAYING,
|
||||
});
|
||||
mprisUpdateSong({ song: playerData.current.song });
|
||||
resetPlayers();
|
||||
}
|
||||
},
|
||||
@@ -487,21 +465,13 @@ export const useCenterControls = (args: { playersRef: any }) => {
|
||||
|
||||
const handleRepeatOne = {
|
||||
local: () => {
|
||||
if (!isFirstTrack) {
|
||||
const playerData = previous();
|
||||
mprisUpdateSong({
|
||||
song: playerData.current.song,
|
||||
status: PlayerStatus.PLAYING,
|
||||
});
|
||||
mpvPlayer!.setQueue(playerData);
|
||||
mpvPlayer!.previous();
|
||||
} else {
|
||||
mpvPlayer!.stop();
|
||||
}
|
||||
const playerData = previous();
|
||||
mprisUpdateSong({ song: playerData.current.song });
|
||||
mpvPlayer!.setQueue(playerData);
|
||||
},
|
||||
web: () => {
|
||||
const playerData = previous();
|
||||
mprisUpdateSong({ song: playerData.current.song, status: PlayerStatus.PLAYING });
|
||||
mprisUpdateSong({ song: playerData.current.song });
|
||||
resetPlayers();
|
||||
},
|
||||
};
|
||||
@@ -538,7 +508,7 @@ export const useCenterControls = (args: { playersRef: any }) => {
|
||||
]);
|
||||
|
||||
const handlePlayPause = useCallback(() => {
|
||||
if (queue) {
|
||||
if (queue.length > 0) {
|
||||
if (playerStatus === PlayerStatus.PAUSED) {
|
||||
return handlePlay();
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useCallback, useState, useRef } from 'react';
|
||||
import { QueueSong, ServerType } from '/@/renderer/api/types';
|
||||
import { useSendScrobble } from '/@/renderer/features/player/mutations/scrobble-mutation';
|
||||
import { useCurrentStatus, usePlayerStore } from '/@/renderer/store';
|
||||
import { usePlayerStore } from '/@/renderer/store';
|
||||
import { usePlaybackSettings } from '/@/renderer/store/settings.store';
|
||||
import { PlayerStatus } from '/@/renderer/types';
|
||||
|
||||
@@ -52,7 +52,6 @@ const checkScrobbleConditions = (args: {
|
||||
};
|
||||
|
||||
export const useScrobble = () => {
|
||||
const status = useCurrentStatus();
|
||||
const scrobbleSettings = usePlaybackSettings().scrobble;
|
||||
const isScrobbleEnabled = scrobbleSettings?.enabled;
|
||||
const sendScrobble = useSendScrobble();
|
||||
@@ -94,6 +93,7 @@ export const useScrobble = () => {
|
||||
|
||||
if (progressIntervalId.current) {
|
||||
clearInterval(progressIntervalId.current);
|
||||
progressIntervalId.current = null;
|
||||
}
|
||||
|
||||
// const currentSong = current[0] as QueueSong | undefined;
|
||||
@@ -135,9 +135,13 @@ export const useScrobble = () => {
|
||||
clearTimeout(songChangeTimeoutId.current as ReturnType<typeof setTimeout>);
|
||||
songChangeTimeoutId.current = setTimeout(() => {
|
||||
const currentSong = current[0] as QueueSong | undefined;
|
||||
// Get the current status from the state, not variable. This is because
|
||||
// of a timing issue where, when playing the first track, the first
|
||||
// event is song, and then the event is play
|
||||
const currentStatus = usePlayerStore.getState().current.status;
|
||||
|
||||
// Send start scrobble when song changes and the new song is playing
|
||||
if (status === PlayerStatus.PLAYING && currentSong?.id) {
|
||||
if (currentStatus === PlayerStatus.PLAYING && currentSong?.id) {
|
||||
sendScrobble.mutate({
|
||||
query: {
|
||||
event: 'start',
|
||||
@@ -149,6 +153,12 @@ export const useScrobble = () => {
|
||||
});
|
||||
|
||||
if (currentSong?.serverType === ServerType.JELLYFIN) {
|
||||
// It is possible that another function sets an interval.
|
||||
// We only want one running, so clear the existing interval
|
||||
if (progressIntervalId.current) {
|
||||
clearInterval(progressIntervalId.current);
|
||||
}
|
||||
|
||||
progressIntervalId.current = setInterval(() => {
|
||||
const currentTime = usePlayerStore.getState().current.time;
|
||||
handleScrobbleFromSeek(currentTime);
|
||||
@@ -163,7 +173,6 @@ export const useScrobble = () => {
|
||||
scrobbleSettings?.scrobbleAtPercentage,
|
||||
isCurrentSongScrobbled,
|
||||
sendScrobble,
|
||||
status,
|
||||
handleScrobbleFromSeek,
|
||||
],
|
||||
);
|
||||
@@ -200,8 +209,14 @@ export const useScrobble = () => {
|
||||
});
|
||||
|
||||
if (currentSong?.serverType === ServerType.JELLYFIN) {
|
||||
// It is possible that another function sets an interval.
|
||||
// We only want one running, so clear the existing interval
|
||||
if (progressIntervalId.current) {
|
||||
clearInterval(progressIntervalId.current);
|
||||
}
|
||||
|
||||
progressIntervalId.current = setInterval(() => {
|
||||
const currentTime = currentTimeSec;
|
||||
const currentTime = usePlayerStore.getState().current.time;
|
||||
handleScrobbleFromSeek(currentTime);
|
||||
}, 10000);
|
||||
}
|
||||
@@ -220,6 +235,7 @@ export const useScrobble = () => {
|
||||
|
||||
if (progressIntervalId.current) {
|
||||
clearInterval(progressIntervalId.current as ReturnType<typeof setInterval>);
|
||||
progressIntervalId.current = null;
|
||||
}
|
||||
} else {
|
||||
const isLastTrackInQueue = usePlayerStore.getState().actions.checkIsLastTrack();
|
||||
|
||||
@@ -20,10 +20,10 @@ import {
|
||||
VirtualInfiniteGridRef,
|
||||
} from '/@/renderer/components/virtual-grid';
|
||||
import { usePlayQueueAdd } from '/@/renderer/features/player';
|
||||
import { useCreateFavorite, useDeleteFavorite } from '/@/renderer/features/shared';
|
||||
import { AppRoute } from '/@/renderer/router/routes';
|
||||
import { useCurrentServer, useGeneralSettings, useListStoreByKey } from '/@/renderer/store';
|
||||
import { CardRow, ListDisplayType } from '/@/renderer/types';
|
||||
import { useHandleFavorite } from '/@/renderer/features/shared/hooks/use-handle-favorite';
|
||||
|
||||
interface PlaylistListGridViewProps {
|
||||
gridRef: MutableRefObject<VirtualInfiniteGridRef | null>;
|
||||
@@ -38,34 +38,7 @@ export const PlaylistListGridView = ({ gridRef, itemCount }: PlaylistListGridVie
|
||||
const { display, grid, filter } = useListStoreByKey({ key: pageKey });
|
||||
const { setGrid } = useListStoreActions();
|
||||
const { defaultFullPlaylist } = useGeneralSettings();
|
||||
|
||||
const createFavoriteMutation = useCreateFavorite({});
|
||||
const deleteFavoriteMutation = useDeleteFavorite({});
|
||||
|
||||
const handleFavorite = (options: {
|
||||
id: string[];
|
||||
isFavorite: boolean;
|
||||
itemType: LibraryItem;
|
||||
}) => {
|
||||
const { id, itemType, isFavorite } = options;
|
||||
if (isFavorite) {
|
||||
deleteFavoriteMutation.mutate({
|
||||
query: {
|
||||
id,
|
||||
type: itemType,
|
||||
},
|
||||
serverId: server?.id,
|
||||
});
|
||||
} else {
|
||||
createFavoriteMutation.mutate({
|
||||
query: {
|
||||
id,
|
||||
type: itemType,
|
||||
},
|
||||
serverId: server?.id,
|
||||
});
|
||||
}
|
||||
};
|
||||
const handleFavorite = useHandleFavorite({ gridRef, server });
|
||||
|
||||
const cardRows = useMemo(() => {
|
||||
const rows: CardRow<Playlist>[] = defaultFullPlaylist
|
||||
|
||||
@@ -406,7 +406,7 @@ export const PlaylistListHeaderFilters = ({
|
||||
<Slider
|
||||
defaultValue={isGrid ? grid?.itemSize || 0 : table.rowHeight}
|
||||
max={isGrid ? 300 : 100}
|
||||
min={isGrid ? 150 : 25}
|
||||
min={isGrid ? 100 : 25}
|
||||
onChangeEnd={handleItemSize}
|
||||
/>
|
||||
</DropdownMenu.Item>
|
||||
|
||||
@@ -8,15 +8,12 @@ import { CreatePlaylistForm } from '/@/renderer/features/playlists/components/cr
|
||||
import { PlaylistListHeaderFilters } from '/@/renderer/features/playlists/components/playlist-list-header-filters';
|
||||
import { LibraryHeaderBar } from '/@/renderer/features/shared';
|
||||
import { useContainerQuery } from '/@/renderer/hooks';
|
||||
import { PlaylistListFilter, useCurrentServer, useListStoreActions } from '/@/renderer/store';
|
||||
import { ListDisplayType } from '/@/renderer/types';
|
||||
import { PlaylistListFilter, useCurrentServer } from '/@/renderer/store';
|
||||
import debounce from 'lodash/debounce';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { RiFileAddFill } from 'react-icons/ri';
|
||||
import { LibraryItem, ServerType } from '/@/renderer/api/types';
|
||||
import { useListFilterRefresh } from '../../../hooks/use-list-filter-refresh';
|
||||
import { useListContext } from '/@/renderer/context/list-context';
|
||||
import { useListStoreByKey } from '../../../store/list.store';
|
||||
import { useDisplayRefresh } from '/@/renderer/hooks/use-display-refresh';
|
||||
|
||||
interface PlaylistListHeaderProps {
|
||||
gridRef: MutableRefObject<VirtualInfiniteGridRef | null>;
|
||||
@@ -26,11 +23,8 @@ interface PlaylistListHeaderProps {
|
||||
|
||||
export const PlaylistListHeader = ({ itemCount, tableRef, gridRef }: PlaylistListHeaderProps) => {
|
||||
const { t } = useTranslation();
|
||||
const { pageKey } = useListContext();
|
||||
const cq = useContainerQuery();
|
||||
const server = useCurrentServer();
|
||||
const { setFilter, setTablePagination } = useListStoreActions();
|
||||
const { display, filter } = useListStoreByKey({ key: pageKey });
|
||||
|
||||
const handleCreatePlaylistModal = () => {
|
||||
openModal({
|
||||
@@ -43,25 +37,16 @@ export const PlaylistListHeader = ({ itemCount, tableRef, gridRef }: PlaylistLis
|
||||
});
|
||||
};
|
||||
|
||||
const { handleRefreshGrid, handleRefreshTable } = useListFilterRefresh({
|
||||
const { filter, refresh, search } = useDisplayRefresh({
|
||||
gridRef,
|
||||
itemType: LibraryItem.PLAYLIST,
|
||||
server,
|
||||
tableRef,
|
||||
});
|
||||
|
||||
const handleSearch = debounce((e: ChangeEvent<HTMLInputElement>) => {
|
||||
const searchTerm = e.target.value === '' ? undefined : e.target.value;
|
||||
const updatedFilters = setFilter({
|
||||
data: { searchTerm },
|
||||
itemType: LibraryItem.PLAYLIST,
|
||||
key: pageKey,
|
||||
}) as PlaylistListFilter;
|
||||
|
||||
if (display === ListDisplayType.TABLE || display === ListDisplayType.TABLE_PAGINATED) {
|
||||
handleRefreshTable(tableRef, updatedFilters);
|
||||
setTablePagination({ data: { currentPage: 0 }, key: pageKey });
|
||||
} else {
|
||||
handleRefreshGrid(gridRef, updatedFilters);
|
||||
}
|
||||
const updatedFilters = search(e) as PlaylistListFilter;
|
||||
refresh(updatedFilters);
|
||||
}, 500);
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { forwardRef, Ref, useImperativeHandle, useState } from 'react';
|
||||
import { forwardRef, Ref, useImperativeHandle, useMemo, useState } from 'react';
|
||||
import { Group } from '@mantine/core';
|
||||
import { useForm } from '@mantine/form';
|
||||
import { openModal } from '@mantine/modals';
|
||||
import clone from 'lodash/clone';
|
||||
import get from 'lodash/get';
|
||||
import setWith from 'lodash/setWith';
|
||||
@@ -21,14 +22,18 @@ import {
|
||||
import { QueryBuilderGroup, QueryBuilderRule } from '/@/renderer/types';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { RiMore2Fill, RiSaveLine } from 'react-icons/ri';
|
||||
import { SongListSort } from '/@/renderer/api/types';
|
||||
import { PlaylistListSort, SongListSort, SortOrder } from '/@/renderer/api/types';
|
||||
import {
|
||||
NDSongQueryBooleanOperators,
|
||||
NDSongQueryDateOperators,
|
||||
NDSongQueryFields,
|
||||
NDSongQueryNumberOperators,
|
||||
NDSongQueryPlaylistOperators,
|
||||
NDSongQueryStringOperators,
|
||||
} from '/@/renderer/api/navidrome.types';
|
||||
import { usePlaylistList } from '/@/renderer/features/playlists/queries/playlist-list-query';
|
||||
import { useCurrentServer } from '/@/renderer/store';
|
||||
import { JsonPreview } from '/@/renderer/features/shared/components/json-preview';
|
||||
|
||||
type AddArgs = {
|
||||
groupIndex: number[];
|
||||
@@ -52,6 +57,7 @@ interface PlaylistQueryBuilderProps {
|
||||
parsedFilter: any,
|
||||
extraFilters: { limit?: number; sortBy?: string; sortOrder?: string },
|
||||
) => void;
|
||||
playlistId?: string;
|
||||
query: any;
|
||||
sortBy: SongListSort;
|
||||
sortOrder: 'asc' | 'desc';
|
||||
@@ -84,14 +90,43 @@ export type PlaylistQueryBuilderRef = {
|
||||
|
||||
export const PlaylistQueryBuilder = forwardRef(
|
||||
(
|
||||
{ sortOrder, sortBy, limit, isSaving, query, onSave, onSaveAs }: PlaylistQueryBuilderProps,
|
||||
{
|
||||
sortOrder,
|
||||
sortBy,
|
||||
limit,
|
||||
isSaving,
|
||||
query,
|
||||
onSave,
|
||||
onSaveAs,
|
||||
playlistId,
|
||||
}: PlaylistQueryBuilderProps,
|
||||
ref: Ref<PlaylistQueryBuilderRef>,
|
||||
) => {
|
||||
const { t } = useTranslation();
|
||||
const server = useCurrentServer();
|
||||
const [filters, setFilters] = useState<QueryBuilderGroup>(
|
||||
query ? convertNDQueryToQueryGroup(query) : DEFAULT_QUERY,
|
||||
);
|
||||
|
||||
const { data: playlists } = usePlaylistList({
|
||||
query: { sortBy: PlaylistListSort.NAME, sortOrder: SortOrder.ASC, startIndex: 0 },
|
||||
serverId: server?.id,
|
||||
});
|
||||
|
||||
const playlistData = useMemo(() => {
|
||||
if (!playlists) return [];
|
||||
|
||||
return playlists.items
|
||||
.filter((p) => {
|
||||
if (!playlistId) return true;
|
||||
return p.id !== playlistId;
|
||||
})
|
||||
.map((p) => ({
|
||||
label: p.name,
|
||||
value: p.id,
|
||||
}));
|
||||
}, [playlistId, playlists]);
|
||||
|
||||
const extraFiltersForm = useForm({
|
||||
initialValues: {
|
||||
limit,
|
||||
@@ -131,6 +166,16 @@ export const PlaylistQueryBuilder = forwardRef(
|
||||
onSaveAs?.(convertQueryGroupToNDQuery(filters), extraFiltersForm.values);
|
||||
};
|
||||
|
||||
const openPreviewModal = () => {
|
||||
const previewValue = convertQueryGroupToNDQuery(filters);
|
||||
|
||||
openModal({
|
||||
children: <JsonPreview value={previewValue} />,
|
||||
size: 'xl',
|
||||
title: t('common.preview', { postProcess: 'titleCase' }),
|
||||
});
|
||||
};
|
||||
|
||||
const handleAddRuleGroup = (args: AddArgs) => {
|
||||
const { level, groupIndex } = args;
|
||||
const filtersCopy = clone(filters);
|
||||
@@ -367,7 +412,7 @@ export const PlaylistQueryBuilder = forwardRef(
|
||||
return (
|
||||
<MotionFlex
|
||||
direction="column"
|
||||
h="calc(100% - 2.5rem)"
|
||||
h="calc(100% - 3.5rem)"
|
||||
justify="space-between"
|
||||
>
|
||||
<ScrollArea
|
||||
@@ -383,8 +428,10 @@ export const PlaylistQueryBuilder = forwardRef(
|
||||
boolean: NDSongQueryBooleanOperators,
|
||||
date: NDSongQueryDateOperators,
|
||||
number: NDSongQueryNumberOperators,
|
||||
playlist: NDSongQueryPlaylistOperators,
|
||||
string: NDSongQueryStringOperators,
|
||||
}}
|
||||
playlists={playlistData}
|
||||
uniqueId={filters.uniqueId}
|
||||
onAddRule={handleAddRule}
|
||||
onAddRuleGroup={handleAddRuleGroup}
|
||||
@@ -428,7 +475,7 @@ export const PlaylistQueryBuilder = forwardRef(
|
||||
value: 'desc',
|
||||
},
|
||||
]}
|
||||
label={t('common.order', { postProcess: 'titleCase' })}
|
||||
label={t('common.sortOrder', { postProcess: 'titleCase' })}
|
||||
maxWidth="20%"
|
||||
width={125}
|
||||
{...extraFiltersForm.getInputProps('sortOrder')}
|
||||
@@ -452,6 +499,13 @@ export const PlaylistQueryBuilder = forwardRef(
|
||||
>
|
||||
{t('common.saveAs', { postProcess: 'titleCase' })}
|
||||
</Button>
|
||||
<Button
|
||||
p="0.5em"
|
||||
variant="default"
|
||||
onClick={openPreviewModal}
|
||||
>
|
||||
{t('common.preview', { postProcess: 'titleCase' })}
|
||||
</Button>
|
||||
<DropdownMenu position="bottom-end">
|
||||
<DropdownMenu.Target>
|
||||
<Button
|
||||
|
||||
@@ -76,7 +76,7 @@ export const UpdatePlaylistForm = ({ users, query, body, onCancel }: UpdatePlayl
|
||||
});
|
||||
|
||||
const isPublicDisplayed = server?.type === ServerType.NAVIDROME;
|
||||
const isOwnerDisplayed = server?.type === ServerType.NAVIDROME;
|
||||
const isOwnerDisplayed = server?.type === ServerType.NAVIDROME && userList;
|
||||
const isSubmitDisabled = !form.values.name || mutation.isLoading;
|
||||
|
||||
return (
|
||||
@@ -154,11 +154,17 @@ export const openUpdatePlaylistModal = async (args: {
|
||||
|
||||
const users =
|
||||
server?.type === ServerType.NAVIDROME
|
||||
? await queryClient.fetchQuery({
|
||||
queryFn: ({ signal }) =>
|
||||
api.controller.getUserList({ apiClientProps: { server, signal }, query }),
|
||||
queryKey: queryKeys.users.list(server?.id || '', query),
|
||||
})
|
||||
? await queryClient
|
||||
.fetchQuery({
|
||||
queryFn: ({ signal }) =>
|
||||
api.controller.getUserList({ apiClientProps: { server, signal }, query }),
|
||||
queryKey: queryKeys.users.list(server?.id || '', query),
|
||||
})
|
||||
.catch((error) => {
|
||||
// This eror most likely happens if the user is not an admin
|
||||
console.error(error);
|
||||
return null;
|
||||
})
|
||||
: null;
|
||||
|
||||
openModal({
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user