Compare commits

...

62 Commits

Author SHA1 Message Date
jeffvli f2beeef0e9 Bump to v0.7.3 2024-07-30 03:26:21 -07:00
jeffvli fd893224b3 Bump electronVersion in build configuration (#686) 2024-07-30 03:17:32 -07:00
Hosted Weblate 8815246221 Translated using Weblate (Chinese (Simplified))
Currently translated at 100.0% (593 of 593 strings)

Co-authored-by: ENDzZ <godzmichael@outlook.com>
Co-authored-by: Hosted Weblate <hosted@weblate.org>
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/zh_Hans/
Translation: feishin/Translation
2024-07-25 19:36:24 +00:00
Hosted Weblate 5e353649a4 Translated using Weblate (Korean)
Currently translated at 1.6% (10 of 593 strings)

Co-authored-by: 박용현 <pyh5523@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/ko/
Translation: feishin/Translation
2024-07-25 19:36:24 +00:00
Jeff 4ca7b4221c Merge pull request #680 from dragonish/metadata
Fix full screen player metadata may not change when switching queue
2024-07-25 12:36:19 -07:00
dragonish 6c61ea898f Fix full screen player metadata may not change when switching queue 2024-07-26 01:52:40 +08:00
Kendall Garner 0b786b025f Merge pull request #676 from dragonish/lyrics
Fix synchronized lyrics that may become unaligned during playback after re-rendering
2024-07-23 04:09:11 +00:00
dragonish e106fb324f Fix synchronized lyrics that may become unaligned during playback after re-rendering 2024-07-22 21:33:01 +08:00
Jeff 3edc6bab04 Merge pull request #668 from yuygfgg/patch-1
fix blank screen when reopening window on macos
2024-07-21 04:16:17 -07:00
yuygfgg 0113ef2582 restore comments 2024-07-14 21:15:58 +08:00
yuygfgg 493b81875a fix blank screen when reopening window on macos 2024-07-14 20:46:04 +08:00
Jeff ed8e5e69ba Merge pull request #664 from sel10ut/bugfix/fix-minimize-setting
Fix minimize to tray setting toggle
2024-07-14 02:53:48 -07:00
jeffvli d27a656568 Bump electron to v31 (again) 2024-07-13 01:30:18 -07:00
sel10ut 582739a091 fix(settings): set proper default minimize to tray check 2024-07-04 13:29:47 +03:00
Jeff fb930f1197 Merge pull request #654 from minicoz/feat/docker-compose-readme
Adding in docker compose instructions to README
2024-07-03 16:30:45 -07:00
Jeff 849aa97e63 Merge pull request #663 from jeffvli/dependabot/npm_and_yarn/release/app/npm_and_yarn-99150a289a
Bump ws from 8.17.1 to 8.18.0 in /release/app in the npm_and_yarn group across 1 directory
2024-07-03 16:29:51 -07:00
dependabot[bot] bc5abe3ec1 Bump ws in /release/app in the npm_and_yarn group across 1 directory
Bumps the npm_and_yarn group with 1 update in the /release/app directory: [ws](https://github.com/websockets/ws).


Updates `ws` from 8.17.1 to 8.18.0
- [Release notes](https://github.com/websockets/ws/releases)
- [Commits](https://github.com/websockets/ws/compare/8.17.1...8.18.0)

---
updated-dependencies:
- dependency-name: ws
  dependency-type: direct:production
  dependency-group: npm_and_yarn
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-07-03 23:21:13 +00:00
Jeff 46cd783fa3 Merge pull request #662 from jeffvli/dependabot/npm_and_yarn/npm_and_yarn-8fcff2be61
Bump the npm_and_yarn group across 2 directories with 2 updates
2024-07-03 16:20:05 -07:00
jeffvli a93173f55a Bump to v0.7.2 2024-07-03 15:57:57 -07:00
jeffvli bd04168209 Add languages 2024-07-03 15:56:48 -07:00
dependabot[bot] 76ffdb6627 Bump the npm_and_yarn group across 2 directories with 2 updates
Bumps the npm_and_yarn group with 2 updates in the / directory: [ws](https://github.com/websockets/ws) and [braces](https://github.com/micromatch/braces).
Bumps the npm_and_yarn group with 1 update in the /release/app directory: [ws](https://github.com/websockets/ws).


Updates `ws` from 7.5.7 to 7.5.10
- [Release notes](https://github.com/websockets/ws/releases)
- [Commits](https://github.com/websockets/ws/compare/7.5.7...7.5.10)

Updates `braces` from 3.0.2 to 3.0.3
- [Changelog](https://github.com/micromatch/braces/blob/master/CHANGELOG.md)
- [Commits](https://github.com/micromatch/braces/compare/3.0.2...3.0.3)

Updates `ws` from 8.13.0 to 8.17.1
- [Release notes](https://github.com/websockets/ws/releases)
- [Commits](https://github.com/websockets/ws/compare/7.5.7...7.5.10)

---
updated-dependencies:
- dependency-name: ws
  dependency-type: indirect
  dependency-group: npm_and_yarn
- dependency-name: braces
  dependency-type: indirect
  dependency-group: npm_and_yarn
- dependency-name: ws
  dependency-type: direct:production
  dependency-group: npm_and_yarn
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-07-03 22:31:09 +00:00
Hosted Weblate f674260df3 Translated using Weblate (Chinese (Simplified))
Currently translated at 100.0% (591 of 591 strings)

Translated using Weblate (Chinese (Simplified))

Currently translated at 99.4% (588 of 591 strings)

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: SunSpring <yearnsun@gmail.com>
Co-authored-by: 無情天 <kofzhanganguo@126.com>
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/zh_Hans/
Translation: feishin/Translation
2024-07-03 22:29:28 +00:00
Hosted Weblate e9315886b7 Translated using Weblate (Korean)
Currently translated at 1.1% (7 of 591 strings)

Added translation using Weblate (Korean)

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: hubag <pyosy17@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/ko/
Translation: feishin/Translation
2024-07-03 22:29:28 +00:00
Hosted Weblate 4741dd0d77 Translated using Weblate (Finnish)
Currently translated at 19.6% (116 of 591 strings)

Added translation using Weblate (Finnish)

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Jonne Saloranta <saloranta.jonne@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/fi/
Translation: feishin/Translation
2024-07-03 22:29:27 +00:00
Hosted Weblate 7a1c4f5082 Translated using Weblate (Spanish)
Currently translated at 100.0% (593 of 593 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (591 of 591 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (586 of 586 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (586 of 586 strings)

Co-authored-by: Fordas <fordas15@gmail.com>
Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: gallegonovato <fran-carro@hotmail.es>
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/es/
Translation: feishin/Translation
2024-07-03 22:29:27 +00:00
Hosted Weblate abf339bb58 Translated using Weblate (Polish)
Currently translated at 100.0% (586 of 586 strings)

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Mistify <fabianszafranski@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/pl/
Translation: feishin/Translation
2024-07-03 22:29:26 +00:00
Hosted Weblate bb8f67c4c1 Translated using Weblate (Czech)
Currently translated at 100.0% (593 of 593 strings)

Translated using Weblate (Czech)

Currently translated at 100.0% (591 of 591 strings)

Translated using Weblate (Czech)

Currently translated at 100.0% (586 of 586 strings)

Co-authored-by: Fjuro <fjuro@alius.cz>
Co-authored-by: Hosted Weblate <hosted@weblate.org>
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/cs/
Translation: feishin/Translation
2024-07-03 22:29:26 +00:00
Hosted Weblate e416c2a3b6 Translated using Weblate (Russian)
Currently translated at 97.2% (575 of 591 strings)

Translated using Weblate (Russian)

Currently translated at 97.2% (575 of 591 strings)

Translated using Weblate (Russian)

Currently translated at 95.0% (562 of 591 strings)

Translated using Weblate (Russian)

Currently translated at 95.0% (562 of 591 strings)

Translated using Weblate (Russian)

Currently translated at 70.7% (418 of 591 strings)

Translated using Weblate (Russian)

Currently translated at 70.7% (418 of 591 strings)

Co-authored-by: Blueberry <igory.ygr200@gmail.com>
Co-authored-by: Eugeniy <zamelane@vk.com>
Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: ShaDream <mogilnikovshadream@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/ru/
Translation: feishin/Translation
2024-07-03 22:29:25 +00:00
Hosted Weblate 5af4344168 Translated using Weblate (English)
Currently translated at 100.0% (586 of 586 strings)

Co-authored-by: Benjamin <iipython@proton.me>
Co-authored-by: Hosted Weblate <hosted@weblate.org>
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/en/
Translation: feishin/Translation
2024-07-03 22:29:25 +00:00
Hosted Weblate 55e958b5da Translated using Weblate (Portuguese (Brazil))
Currently translated at 34.3% (201 of 586 strings)

Co-authored-by: Cyber Hippie <neves.j@protonmail.com>
Co-authored-by: Hosted Weblate <hosted@weblate.org>
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/pt_BR/
Translation: feishin/Translation
2024-07-03 22:29:24 +00:00
Jeff 2a231ed7af Merge pull request #659 from kgarner7/fix-electron-31-build
[bugfix]: use tsx instead of ts-node, update @electron/rebuild
2024-07-03 15:29:17 -07:00
jeffvli a26f7feb31 Readd transpile-only on dev 2024-07-03 15:13:54 -07:00
jeffvli 90e267d9c7 Bump node version from 16 -> 18 in builders 2024-07-03 15:08:14 -07:00
jeffvli 1ab09c5488 Move tsx and electron/rebuild to devDependencies 2024-07-03 15:05:01 -07:00
jeffvli eadbf3ad7b Readd ts-node 2024-07-03 15:00:56 -07:00
Jeff 42bcc4190c Merge pull request #660 from sel10ut/bugfix/jellyfin-multiple-sessions
(Jellyfin) Allow multiple sessions from the same user with different instances
2024-07-03 14:49:41 -07:00
jeffvli 3c44db377b Revert "Bump electron to v31 (#643)"
This reverts commit 0c14427bdb.
2024-07-03 14:40:09 -07:00
sel10ut ba64f4c467 refactor(jellyfin): migrate auth method 2024-07-03 22:41:35 +03:00
sel10ut 596bf3a378 fix(jellyfin): allow multiple sessions from the same client type
Allow multiple sessions from the same user with different instances.
Instead of sending a hard-coded string, send a randomly generated
string `deviceId`, which already exists and is created for each
new installation.
2024-07-03 22:12:15 +03:00
Kendall Garner 91e6119afa use tsx instead of ts-node, update @electron/rebuild 2024-07-03 11:53:59 -07:00
mlnl b053538f94 Update README.md
removed unused volume
2024-07-03 18:51:46 +00:00
jeffvli 0c14427bdb Bump electron to v31 (#643) 2024-07-03 02:10:13 -07:00
Kendall Garner 110a1a63f0 simplify remote/media session (#632) 2024-07-03 01:47:26 -07:00
Kendall Garner d57b4b4b68 [bugfix]: properly clean up MPV on quit, use pid for socket (#627)
* fix cleanup

* don't delete file if windows
2024-07-03 01:36:01 -07:00
Benjamin 4191edb88c fix cache settings not being sentence cased (#657) 2024-07-03 01:29:53 -07:00
sel10ut b38930a277 tweak(jellyfin): fetch actual recent album releases (#629) 2024-07-03 01:29:34 -07:00
Gelaechter ea865f44b1 Allow jumping to lyrics (#656) 2024-07-03 01:24:31 -07:00
isaiahfuller 0768ce80a7 Add option to play similar tracks from the context menu (#650)
* Add option to play similar songs from context menu

* remove console.log
2024-07-03 01:17:56 -07:00
minicoz c960cc44b7 adding env options 2024-06-28 09:10:47 -07:00
minicoz a84276579b adding in docker compose instructions 2024-06-28 09:08:09 -07:00
Kendall Garner b30fadd149 navidrome album artist covoer art bodge 2024-05-29 02:53:41 -07:00
Kendall Garner aa89c5e80e [enhancement]: apply formatting to card values 2024-05-26 12:20:01 -07:00
Kendall Garner 38ed083693 [enhancement]: support using native image aspect ratio 2024-05-25 11:15:30 -07:00
Kendall Garner 961d1838c0 reopen window if exit to tray 2024-05-25 08:14:01 -07:00
Kendall Garner 67deb3e3d8 [bugfix]: only restart time when now for web 2024-05-23 20:41:01 -07:00
Kendall Garner 79384fa4ed add songCount to table localization 2024-05-17 23:17:31 -07:00
Kendall Garner bb2f8461ed [enhancement]: support toggling feature carousel 2024-05-15 21:48:20 -07:00
Kendall Garner 168153b211 [bugfix]: restart timestamp when adding to queue 2024-05-10 12:27:33 -07:00
Kendall Garner c5e8472746 [bugfix]: handle song grid sparse array 2024-05-09 00:14:59 -07:00
Kendall Garner a9e0689619 activate tray on single click 2024-05-08 19:10:46 -07:00
jeffvli 69acbc9a28 Bump to v0.7.1 2024-05-06 22:52:16 -07:00
jeffvli 58484b87f4 Split macOs and Windows builders 2024-05-06 22:31:22 -07:00
70 changed files with 2938 additions and 2002 deletions
+1 -1
View File
@@ -17,7 +17,7 @@ jobs:
- name: Install Node and NPM
uses: actions/setup-node@v1
with:
node-version: 16
node-version: 18
cache: npm
- name: Install dependencies
+3 -3
View File
@@ -1,4 +1,4 @@
name: Publish Windows and macOS (Manual)
name: Publish macOS (Manual)
on: workflow_dispatch
@@ -17,7 +17,7 @@ jobs:
- name: Install Node and NPM
uses: actions/setup-node@v1
with:
node-version: 16
node-version: 18
cache: npm
- name: Install dependencies
@@ -35,5 +35,5 @@ jobs:
command: |
npm run postinstall
npm run build
npm exec electron-builder -- --publish always --win --mac
npm exec electron-builder -- --publish always --mac
on_retry_command: npm cache clean --force
+1 -1
View File
@@ -20,7 +20,7 @@ jobs:
- name: Install Node and NPM
uses: actions/setup-node@v3
with:
node-version: 16
node-version: 18
cache: npm
- name: Install dependencies
+39
View File
@@ -0,0 +1,39 @@
name: Publish Windows (Manual)
on: workflow_dispatch
jobs:
publish:
runs-on: ${{ matrix.os }}
strategy:
matrix:
os: [windows-latest]
steps:
- name: Checkout git repo
uses: actions/checkout@v1
- name: Install Node and NPM
uses: actions/setup-node@v1
with:
node-version: 18
cache: npm
- name: Install dependencies
run: |
npm install --legacy-peer-deps
- name: Publish releases
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
uses: nick-invision/retry@v2.8.2
with:
timeout_minutes: 30
max_attempts: 3
retry_on: error
command: |
npm run postinstall
npm run build
npm exec electron-builder -- --publish always --win
on_retry_command: npm cache clean --force
+23
View File
@@ -70,6 +70,29 @@ docker build -t feishin .
docker run --name feishin -p 9180:9180 feishin
```
#### Docker Compose
To install via Docker Compose use the following snippit. This also works on Portainer.
```
version: '3'
services:
feishin:
container_name: feishin
image: 'ghcr.io/jeffvli/feishin:latest'
environment:
- SERVER_NAME=jellyfin # pre defined server name
- SERVER_LOCK=true # When true AND name/type/url are set, only username/password can be toggled
- SERVER_TYPE=jellyfin # navidrome also works
- SERVER_URL= # http://address:port
- PUID=1000
- PGID=1000
- UMASK=002
- TZ=America/Los_Angeles
ports:
- 9180:9180
restart: unless-stopped
```
### Configuration
1. Upon startup you will be greeted with a prompt to select the path to your MPV binary. If you do not have MPV installed, you can download it [here](https://mpv.io/installation/) or install it using any package manager supported by your OS. After inputting the path, restart the app.
+1623 -1434
View File
File diff suppressed because it is too large Load Diff
+13 -12
View File
@@ -2,7 +2,7 @@
"name": "feishin",
"productName": "Feishin",
"description": "Feishin music server",
"version": "0.7.0",
"version": "0.7.3",
"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",
@@ -14,12 +14,12 @@
"lint": "concurrently \"npm run lint:code\" \"npm run lint:styles\"",
"lint:code": "cross-env NODE_ENV=development eslint . --ext .js,.jsx,.ts,.tsx --fix",
"lint:styles": "npx stylelint **/*.tsx --fix",
"package": "ts-node ./.erb/scripts/clean.js dist && npm run build && electron-builder build --publish never",
"package:pr": "ts-node ./.erb/scripts/clean.js dist && npm run build && electron-builder build --publish never --win --mac --linux",
"package:dev": "ts-node ./.erb/scripts/clean.js dist && npm run build && electron-builder build --publish never --dir",
"postinstall": "ts-node .erb/scripts/check-native-dep.js && electron-builder install-app-deps && cross-env NODE_ENV=development TS_NODE_TRANSPILE_ONLY=true webpack --config ./.erb/configs/webpack.config.renderer.dev.dll.ts",
"start": "ts-node ./.erb/scripts/check-port-in-use.js && npm run start:renderer",
"start:main": "cross-env NODE_ENV=development electron -r ts-node/register/transpile-only ./src/main/main.ts",
"package": "node --import tsx ./.erb/scripts/clean.js dist && npm run build && electron-builder build --publish never",
"package:pr": "node --import tsx ./.erb/scripts/clean.js dist && npm run build && electron-builder build --publish never --win --mac --linux",
"package:dev": "node --import tsx ./.erb/scripts/clean.js dist && npm run build && electron-builder build --publish never --dir",
"postinstall": "node --import tsx .erb/scripts/check-native-dep.js && electron-builder install-app-deps && cross-env NODE_ENV=development TS_NODE_TRANSPILE_ONLY=true webpack --config ./.erb/configs/webpack.config.renderer.dev.dll.ts",
"start": "node --import tsx ./.erb/scripts/check-port-in-use.js && npm run start:renderer",
"start:main": "cross-env NODE_ENV=development NODE_OPTIONS=\"--import tsx\" electron -r ts-node/register/transpile-only ./src/main/main.ts",
"start:preload": "cross-env NODE_ENV=development TS_NODE_TRANSPILE_ONLY=true webpack --config ./.erb/configs/webpack.config.preload.dev.ts",
"start:remote": "cross-env NODE_ENV=developemnt TS_NODE_TRANSPILE_ONLY=true webpack --config ./.erb/configs/webpack.config.remote.dev.ts",
"start:renderer": "cross-env NODE_ENV=development TS_NODE_TRANSPILE_ONLY=true webpack serve --config ./.erb/configs/webpack.config.renderer.dev.ts",
@@ -56,7 +56,7 @@
"package.json"
],
"afterSign": ".erb/scripts/notarize.js",
"electronVersion": "27.1.0",
"electronVersion": "31.2.0",
"mac": {
"target": {
"target": "default",
@@ -199,7 +199,7 @@
]
},
"devDependencies": {
"@electron/rebuild": "^3.2.10",
"@electron/rebuild": "^3.6.0",
"@pmmmwh/react-refresh-webpack-plugin": "0.5.5",
"@stylelint/postcss-css-in-js": "^0.38.0",
"@teamsupercell/typings-for-css-modules-loader": "^2.5.1",
@@ -231,7 +231,7 @@
"css-loader": "^6.7.1",
"css-minimizer-webpack-plugin": "^3.4.1",
"detect-port": "^1.3.0",
"electron": "^26.6.10",
"electron": "^31.2.0",
"electron-builder": "^24.13.3",
"electron-devtools-installer": "^3.2.0",
"electron-notarize": "^1.2.1",
@@ -278,8 +278,9 @@
"terser-webpack-plugin": "^5.3.1",
"ts-jest": "^27.1.4",
"ts-loader": "^9.2.8",
"ts-node": "^10.7.0",
"ts-node": "^10.9.2",
"tsconfig-paths-webpack-plugin": "^4.0.0",
"tsx": "^4.16.2",
"typescript": "^5.2.2",
"typescript-plugin-styled-components": "^3.0.0",
"url-loader": "^4.1.1",
@@ -357,7 +358,7 @@
"styled-components": "^6"
},
"devEngines": {
"node": ">=14.x",
"node": ">=18.x",
"npm": ">=7.x"
},
"browserslist": [],
+44 -27
View File
@@ -1,21 +1,21 @@
{
"name": "feishin",
"version": "0.7.0",
"version": "0.7.2",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "feishin",
"version": "0.7.0",
"version": "0.7.2",
"hasInstallScript": true,
"license": "GPL-3.0",
"dependencies": {
"cheerio": "^1.0.0-rc.12",
"mpris-service": "^2.1.2",
"ws": "^8.13.0"
"ws": "^8.18.0"
},
"devDependencies": {
"electron": "25.8.4"
"electron": "31.1.0"
}
},
"node_modules/@electron/get": {
@@ -99,10 +99,13 @@
}
},
"node_modules/@types/node": {
"version": "18.16.19",
"resolved": "https://registry.npmjs.org/@types/node/-/node-18.16.19.tgz",
"integrity": "sha512-IXl7o+R9iti9eBW4Wg2hx1xQDig183jj7YLn8F7udNceyfkbn1ZxmzZXuak20gR40D7pIkIY1kYGx5VIGbaHKA==",
"dev": true
"version": "20.14.9",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.14.9.tgz",
"integrity": "sha512-06OCtnTXtWOZBJlRApleWndH4JsRVs1pDCc8dLSQp+7PpUpX3ePdHyeNSFTeSe7FtKyQkrlPvHwJOW3SLd8Oyg==",
"dev": true,
"dependencies": {
"undici-types": "~5.26.4"
}
},
"node_modules/@types/responselike": {
"version": "1.0.0",
@@ -453,14 +456,14 @@
"integrity": "sha512-jtD6YG370ZCIi/9GTaJKQxWTZD045+4R4hTk/x1UyoqadyJ9x9CgSi1RlVDQF8U2sxLLSnFkCaMihqljHIWgMg=="
},
"node_modules/electron": {
"version": "25.8.4",
"resolved": "https://registry.npmjs.org/electron/-/electron-25.8.4.tgz",
"integrity": "sha512-hUYS3RGdaa6E1UWnzeGnsdsBYOggwMMg4WGxNGvAoWtmRrr6J1BsjFW/yRq4WsJHJce2HdzQXtz4OGXV6yUCLg==",
"version": "31.1.0",
"resolved": "https://registry.npmjs.org/electron/-/electron-31.1.0.tgz",
"integrity": "sha512-TBOwqLxSxnx6+pH6GMri7R3JPH2AkuGJHfWZS0p1HsmN+Qr1T9b0IRJnnehSd/3NZAmAre4ft9Ljec7zjyKFJA==",
"dev": true,
"hasInstallScript": true,
"dependencies": {
"@electron/get": "^2.0.0",
"@types/node": "^18.11.18",
"@types/node": "^20.9.0",
"extract-zip": "^2.0.1"
},
"bin": {
@@ -1270,6 +1273,12 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/undici-types": {
"version": "5.26.5",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz",
"integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==",
"dev": true
},
"node_modules/universalify": {
"version": "0.1.2",
"resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz",
@@ -1286,9 +1295,9 @@
"dev": true
},
"node_modules/ws": {
"version": "8.13.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.13.0.tgz",
"integrity": "sha512-x9vcZYTrFPC7aSIbj7sRCYo7L/Xb8Iy+pW0ng0wt2vCJv7M9HOMy0UoN3rr+IFC7hb7vXoqS+P9ktyLLLhO+LA==",
"version": "8.18.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.18.0.tgz",
"integrity": "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==",
"engines": {
"node": ">=10.0.0"
},
@@ -1408,10 +1417,13 @@
}
},
"@types/node": {
"version": "18.16.19",
"resolved": "https://registry.npmjs.org/@types/node/-/node-18.16.19.tgz",
"integrity": "sha512-IXl7o+R9iti9eBW4Wg2hx1xQDig183jj7YLn8F7udNceyfkbn1ZxmzZXuak20gR40D7pIkIY1kYGx5VIGbaHKA==",
"dev": true
"version": "20.14.9",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.14.9.tgz",
"integrity": "sha512-06OCtnTXtWOZBJlRApleWndH4JsRVs1pDCc8dLSQp+7PpUpX3ePdHyeNSFTeSe7FtKyQkrlPvHwJOW3SLd8Oyg==",
"dev": true,
"requires": {
"undici-types": "~5.26.4"
}
},
"@types/responselike": {
"version": "1.0.0",
@@ -1672,13 +1684,13 @@
"integrity": "sha512-jtD6YG370ZCIi/9GTaJKQxWTZD045+4R4hTk/x1UyoqadyJ9x9CgSi1RlVDQF8U2sxLLSnFkCaMihqljHIWgMg=="
},
"electron": {
"version": "25.8.4",
"resolved": "https://registry.npmjs.org/electron/-/electron-25.8.4.tgz",
"integrity": "sha512-hUYS3RGdaa6E1UWnzeGnsdsBYOggwMMg4WGxNGvAoWtmRrr6J1BsjFW/yRq4WsJHJce2HdzQXtz4OGXV6yUCLg==",
"version": "31.1.0",
"resolved": "https://registry.npmjs.org/electron/-/electron-31.1.0.tgz",
"integrity": "sha512-TBOwqLxSxnx6+pH6GMri7R3JPH2AkuGJHfWZS0p1HsmN+Qr1T9b0IRJnnehSd/3NZAmAre4ft9Ljec7zjyKFJA==",
"dev": true,
"requires": {
"@electron/get": "^2.0.0",
"@types/node": "^18.11.18",
"@types/node": "^20.9.0",
"extract-zip": "^2.0.1"
}
},
@@ -2278,6 +2290,12 @@
"dev": true,
"optional": true
},
"undici-types": {
"version": "5.26.5",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz",
"integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==",
"dev": true
},
"universalify": {
"version": "0.1.2",
"resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz",
@@ -2291,10 +2309,9 @@
"dev": true
},
"ws": {
"version": "8.13.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.13.0.tgz",
"integrity": "sha512-x9vcZYTrFPC7aSIbj7sRCYo7L/Xb8Iy+pW0ng0wt2vCJv7M9HOMy0UoN3rr+IFC7hb7vXoqS+P9ktyLLLhO+LA==",
"requires": {}
"version": "8.18.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.18.0.tgz",
"integrity": "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw=="
},
"xml2js": {
"version": "0.4.23",
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "feishin",
"version": "0.7.0",
"version": "0.7.3",
"description": "",
"main": "./dist/main/main.js",
"author": {
@@ -15,10 +15,10 @@
"dependencies": {
"cheerio": "^1.0.0-rc.12",
"mpris-service": "^2.1.2",
"ws": "^8.13.0"
"ws": "^8.18.0"
},
"devDependencies": {
"electron": "25.8.4"
"electron": "31.1.0"
},
"license": "GPL-3.0"
}
+6
View File
@@ -18,6 +18,7 @@ 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';
import ko from './locales/ko.json';
const resources = {
en: { translation: en },
@@ -29,6 +30,7 @@ const resources = {
fa: { translation: fa },
fr: { translation: fr },
ja: { translation: ja },
ko: { translation: ko },
pl: { translation: pl },
'zh-Hans': { translation: zhHans },
'zh-Hant': { translation: zhHant },
@@ -68,6 +70,10 @@ export const languages = [
label: '日本語',
value: 'ja',
},
{
label: '한국어',
value: 'ko',
},
{
label: 'Nederlands',
value: 'nl',
+14 -7
View File
@@ -28,7 +28,8 @@
"shuffle_off": "náhodně zakázáno",
"addLast": "přidat poslední",
"mute": "ztlumit",
"skip_forward": "přeskočit dopředu"
"skip_forward": "přeskočit dopředu",
"playSimilarSongs": "přehrát podobné skladby"
},
"setting": {
"crossfadeStyle_description": "vyberte způsob prolnutí u přehrávače zvuku",
@@ -210,7 +211,11 @@
"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."
"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.",
"homeFeature": "carousel doporučení na domovské stránce",
"homeFeature_description": "ovládá, zda se má zobrazovat velký carousel s doporučenými alby na domovské stránce",
"imageAspectRatio": "použít nativní poměr stran obalů alb",
"imageAspectRatio_description": "pokud je povoleno, budou obaly alb zobrazeny s jejich nativním poměrem stran. u obalů, které nemají poměr 1:1, bude zbývající místo prázdné"
},
"action": {
"editPlaylist": "upravit $t(entity.playlist_one)",
@@ -371,7 +376,8 @@
"favorite": "$t(common.favorite)",
"year": "$t(common.year)",
"albumArtist": "$t(entity.albumArtist_one)",
"codec": "$t(common.codec)"
"codec": "$t(common.codec)",
"songCount": "$t(entity.track_other)"
}
},
"column": {
@@ -534,7 +540,8 @@
"numberSelected": "vybráno {{count}}",
"removeFromQueue": "$t(action.removeFromQueue)",
"showDetails": "získat informace",
"shareItem": "sdílet položku"
"shareItem": "sdílet položku",
"playSimilarSongs": "$t(player.playSimilarSongs)"
},
"home": {
"mostPlayed": "nejpřehrávanější",
@@ -563,7 +570,7 @@
},
"trackList": {
"title": "$t(entity.track_other)",
"artistTracks": "Skladby od umělce {{artist}}",
"artistTracks": "skladby od umělce {{artist}}",
"genreTracks": "$t(entity.track_other) s žánrem „{{genre}}“"
},
"globalSearch": {
@@ -579,7 +586,7 @@
},
"albumList": {
"title": "$t(entity.album_other)",
"artistAlbums": "Alba od umělce {{artist}}",
"artistAlbums": "alba od umělce {{artist}}",
"genreAlbums": "$t(entity.album_other) s žánrem „{{genre}}“"
},
"albumArtistDetail": {
@@ -588,7 +595,7 @@
"about": "O umělci {{artist}}",
"appearsOn": "také v",
"topSongs": "nejlepší skladby",
"topSongsFrom": "Nejlepší skladby od umělce {{title}}",
"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"
+10 -3
View File
@@ -281,7 +281,7 @@
"viewDiscography": "view discography",
"relatedArtists": "related $t(entity.artist_other)",
"topSongs": "top songs",
"topSongsFrom": "Top songs from {{title}}",
"topSongsFrom": "top songs from {{title}}",
"viewAll": "view all",
"viewAllTracks": "view all $t(entity.track_other)"
},
@@ -293,7 +293,7 @@
"moreFromGeneric": "more from {{item}}"
},
"albumList": {
"artistAlbums": "Albums by {{artist}}",
"artistAlbums": "albums by {{artist}}",
"genreAlbums": "\"{{genre}}\" $t(entity.album_other)",
"title": "$t(entity.album_other)"
},
@@ -322,6 +322,7 @@
"moveToTop": "$t(action.moveToTop)",
"numberSelected": "{{count}} selected",
"play": "$t(player.play)",
"playSimilarSongs": "$t(player.playSimilarSongs)",
"removeFromFavorites": "$t(action.removeFromFavorites)",
"removeFromPlaylist": "$t(action.removeFromPlaylist)",
"removeFromQueue": "$t(action.removeFromQueue)",
@@ -398,7 +399,7 @@
"tracks": "$t(entity.track_other)"
},
"trackList": {
"artistTracks": "Tracks by {{artist}}",
"artistTracks": "tracks by {{artist}}",
"genreTracks": "\"{{genre}}\" $t(entity.track_other)",
"title": "$t(entity.track_other)"
}
@@ -416,6 +417,7 @@
"playbackFetchNoResults": "no songs found",
"playbackSpeed": "playback speed",
"playRandom": "play random",
"playSimilarSongs": "play similar songs",
"previous": "previous",
"queue_clear": "clear queue",
"queue_moveToBottom": "move selected to top",
@@ -496,6 +498,8 @@
"globalMediaHotkeys_description": "enable or disable the usage of your system media hotkeys to control playback",
"homeConfiguration": "home page configuration",
"homeConfiguration_description": "configure what items are shown, and in what order, on the home page",
"homeFeature": "home featured carousel",
"homeFeature_description": "controls whether to show the large featured carousel on the home page",
"hotkey_browserBack": "browser back",
"hotkey_browserForward": "browser forward",
"hotkey_favoriteCurrentSong": "favorite $t(common.currentSong)",
@@ -529,6 +533,8 @@
"hotkey_volumeUp": "volume up",
"hotkey_zoomIn": "zoom in",
"hotkey_zoomOut": "zoom out",
"imageAspectRatio": "use native cover art aspect ratio",
"imageAspectRatio_description": "if enabled, cover art will be shown using their native aspect ratio. for art that is not 1:1, the remaining space will be empty",
"language": "language",
"language_description": "sets the language for the application ($t(common.restartRequired))",
"lyricFetch": "fetch lyrics from the internet",
@@ -679,6 +685,7 @@
"releaseDate": "release date",
"rowIndex": "row index",
"size": "$t(common.size)",
"songCount": "$t(entity.track_other)",
"title": "$t(common.title)",
"titleCombined": "$t(common.title) (combined)",
"trackNumber": "track number",
+19 -12
View File
@@ -28,7 +28,8 @@
"addLast": "añadir último",
"mute": "silencio",
"skip_forward": "saltar hacia delante",
"pause": "pausa"
"pause": "pausa",
"playSimilarSongs": "Reproducir canciones similares"
},
"setting": {
"crossfadeStyle_description": "selecciona el estilo de crossfade a usar por el reproductor de audio",
@@ -159,7 +160,7 @@
"audioPlayer": "reproductor de audio",
"hotkey_zoomOut": "reducir",
"hotkey_unfavoriteCurrentSong": "$t(common.currentSong) no favorito",
"hotkey_rate0": "limpiar calificación",
"hotkey_rate0": "Limpiar calificación",
"discordApplicationId": "id de aplicación {{discord}}",
"applicationHotkeys_description": "configura las teclas de acceso rápido de la aplicación. marca la casilla para establecerlas como teclas de acceso rápido globales (solo escritorio)",
"floatingQueueArea_description": "muestra un icono flotante en el lado derecho de la pantalla para ver la cola de reproducción",
@@ -191,11 +192,11 @@
"skipPlaylistPage": "saltar página de lista de reproducción",
"hotkey_browserForward": "avance",
"hotkey_browserBack": "retroceso",
"clearCache": "limpiar la caché del navegador",
"clearQueryCache": "limpiar la caché de feishin",
"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é",
"clearCache": "Limpiar la caché del navegador",
"clearQueryCache": "Limpiar la caché de Feishin",
"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",
"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",
"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",
@@ -210,7 +211,11 @@
"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"
"externalLinks": "Mostrar enlaces externos",
"homeFeature": "Carrusel destacado de inicio",
"homeFeature_description": "Controla si se muestra el gran carrusel destacado en la página de inicio",
"imageAspectRatio_description": "Si está habilitado, la portada será mostrada usando su relación de aspecto nativa. Para arte que no es 1:1, el espacio restante estará vacío",
"imageAspectRatio": "Usar relación de aspecto nativa de portada"
},
"action": {
"editPlaylist": "editar $t(entity.playlist_one)",
@@ -441,7 +446,8 @@
"numberSelected": "{{count}} seleccionado",
"removeFromQueue": "$t(action.removeFromQueue)",
"shareItem": "Compartir elemento",
"showDetails": "Obtener información"
"showDetails": "Obtener información",
"playSimilarSongs": "$t(player.playSimilarSongs)"
},
"home": {
"mostPlayed": "más reproducidos",
@@ -491,7 +497,7 @@
"trackList": {
"title": "$t(entity.track_other)",
"genreTracks": "\"{{genre}}\" $t(entity.track_other)",
"artistTracks": "Pistas de {{artist}}"
"artistTracks": "pistas por {{artist}}"
},
"globalSearch": {
"commands": {
@@ -507,13 +513,13 @@
"albumList": {
"title": "$t(entity.album_other)",
"genreAlbums": "\"{{genre}}\" $t(entity.album_other)",
"artistAlbums": "Álbumes de {{artist}}"
"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}}",
"topSongsFrom": "las mejores canciones de {{title}}",
"viewAll": "Ver todo",
"recentReleases": "Lanzamientos recientes",
"viewDiscography": "Ver discografía",
@@ -639,7 +645,8 @@
"genre": "$t(entity.genre_one)",
"favorite": "$t(common.favorite)",
"year": "$t(common.year)",
"codec": "$t(common.codec)"
"codec": "$t(common.codec)",
"songCount": "$t(entity.track_other)"
},
"general": {
"gap": "$t(common.gap)",
+144
View File
@@ -0,0 +1,144 @@
{
"common": {
"size": "koko",
"search": "etsi",
"sortOrder": "järjestys",
"setting": "asetus",
"title": "otsikko",
"trackNumber": "raita",
"action_one": "toiminto",
"action_other": "toiminnot",
"add": "lisää",
"areYouSure": "oletko varma?",
"ascending": "nouseva",
"backward": "takaperin",
"bitrate": "bittinopeus",
"channel_one": "kanava",
"channel_other": "kanavat",
"collapse": "luhista",
"comingSoon": "tulossa pian…",
"configure": "konfiguroi",
"confirm": "hyväksy",
"disable": "poista käytöstä",
"disc": "levy",
"dismiss": "hylkää",
"favorite": "suosikki",
"filter_one": "suodatin",
"filter_other": "suodatinta",
"filters": "suodattimet",
"forceRestartRequired": "käynnistä uudelleen ottaaksesi muutokset käyttöön… sulje ilmoitus käynnistääksesi uudelleen",
"gap": "väli",
"home": "koti",
"left": "vasen",
"limit": "raja",
"manage": "hallitse",
"menu": "valikko",
"minimize": "minimoi",
"modified": "muokattu",
"name": "nimi",
"no": "ei",
"none": "ei mitään",
"noResultsFromQuery": "kysely palautti ei tuloksia",
"note": "huomautus",
"ok": "ok",
"owner": "omistaja",
"path": "reitti",
"preview": "esikatsele",
"previousSong": "edellinen $t(entity.track_one)",
"resetToDefault": "palauta oletusarvoihin",
"restartRequired": "uudelleen käynnistys vaaditaan",
"right": "oikea",
"save": "tallenna",
"saveAndReplace": "tallenna ja korvaa",
"saveAs": "tallenna nimellä",
"unknown": "tuntematon",
"version": "versio",
"year": "vuosi",
"yes": "kyllä",
"close": "sulje",
"descending": "laskeva",
"biography": "elämänkerta",
"cancel": "peruuta",
"bpm": "bpm",
"decrease": "pienennä",
"center": "keskitä",
"clear": "tyhjennä",
"codec": "koodekki",
"create": "luo",
"description": "kuvaus",
"currentSong": "nykyinen $t(entity.track_one)",
"delete": "poista",
"duration": "kesto",
"edit": "muokkaa",
"enable": "ota käyttöön",
"expand": "laajenna",
"increase": "lisää",
"forward": "eteenpäin",
"maximize": "maksimoi",
"mbid": "MusicBrainz ID",
"share": "jaa",
"random": "satunnainen",
"reload": "lataa uudelleen",
"quit": "poistu",
"rating": "arvostelu",
"refresh": "virkistä",
"reset": "nollaa",
"playerMustBePaused": "soitin täytyy olla pysäytetty"
},
"entity": {
"album_one": "albumi",
"album_other": "albumia",
"albumArtist_one": "albumi artisti",
"albumArtist_other": "albumi artistia",
"artistWithCount_one": "{{count}} artisti",
"artistWithCount_other": "{{count}} artistia",
"playlist_one": "soittolista",
"playlist_other": "soittolistaa",
"playlistWithCount_one": "{{count}} soittolista",
"playlistWithCount_other": "{{count}} soittolistaa",
"albumArtistCount_one": "{{count}} albumi artisti",
"albumArtistCount_other": "{{count}} albumi artistia",
"albumWithCount_one": "{{count}} albumi",
"albumWithCount_other": "{{count}} albumia",
"artist_one": "artisti",
"artist_other": "artistia",
"favorite_one": "suosikki",
"favorite_other": "suosikkia",
"folder_one": "kansio",
"folder_other": "kansiota",
"folderWithCount_one": "{{count}} kansio",
"folderWithCount_other": "{{count}} kansiota",
"genre_one": "genre",
"genre_other": "genreä",
"genreWithCount_one": "{{count}} genre",
"genreWithCount_other": "{{count}} genreä",
"smartPlaylist": "älykäs $t(entity.playlist_one)",
"track_one": "raita",
"track_other": "raitaa",
"trackWithCount_one": "{{count}} raita",
"trackWithCount_other": "{{count}} raitaa"
},
"action": {
"clearQueue": "tyhjennä jono",
"createPlaylist": "luo $t(entity.playlist_one)",
"deselectAll": "poista valinta kaikista",
"editPlaylist": "muokkaa $t(entity.playlist_one)",
"removeFromQueue": "poista jonosta",
"viewPlaylists": "katsele $t(entity.playlist_other)",
"openIn": {
"lastfm": "Avaa Last.fm:ssä",
"musicbrainz": "Avaa MusicBrainz:ssä"
},
"goToPage": "mene sivulle",
"moveToBottom": "siirry pohjalle",
"moveToTop": "siirry ylös",
"addToFavorites": "lisää $t(entity.favorite_other)",
"addToPlaylist": "lisää $t(entity.playlist_one)",
"refresh": "$t(common.refresh)",
"removeFromFavorites": "poista kohteesta $t(entity.favorite_other)",
"toggleSmartPlaylistEditor": "kytke $t(entity.smartPlaylist) editori",
"deletePlaylist": "poista $t(entity.playlist_one)",
"removeFromPlaylist": "poista kohteesta $t(entity.playlist_one)",
"setRating": "aseta arvostelu"
}
}
+14
View File
@@ -0,0 +1,14 @@
{
"action": {
"createPlaylist": "$t(entity.playlist_one) 생성",
"addToFavorites": "$t(entity.favorite_other)에 추가",
"addToPlaylist": "$t(entity.playlist_one)에 추가",
"clearQueue": "대기열 지우기",
"deletePlaylist": "$t(entity.playlist_one) 삭제",
"deselectAll": "모두 선택 해제",
"editPlaylist": "$t(entity.playlist_one) 편집",
"goToPage": "페이지 이동",
"moveToBottom": "맨 아래로 이동",
"moveToTop": "맨 위로 이동"
}
}
+87 -17
View File
@@ -16,7 +16,11 @@
"createPlaylist": "utwórz $t(entity.playlist_one)",
"deletePlaylist": "usuń $t(entity.playlist_one)",
"moveToBottom": "przesuń na dół",
"setRating": "oceń"
"setRating": "oceń",
"openIn": {
"lastfm": "Otwórz w Last.fm",
"musicbrainz": "Otwórz w MusicBrainz"
}
},
"common": {
"increase": "zwiększ",
@@ -99,7 +103,17 @@
"decrease": "obniż",
"path": "ścieżka",
"center": "środkowy",
"note": "notatka"
"note": "notatka",
"albumPeak": "spadek albumu",
"albumGain": "wzrost albumu",
"mbid": "ID MusicBrainz",
"reload": "przeładuj",
"share": "udostępnij",
"trackGain": "gain utworu",
"trackPeak": "peak utworu",
"codec": "kodek",
"preview": "podgląd",
"close": "zamknij"
},
"entity": {
"genre_one": "gatunek",
@@ -168,7 +182,10 @@
"mpvRequired": "wymagane MPV",
"audioDeviceFetchError": "wystąpił błąd podczas próby znalezienia urządzeń dźwiękowych",
"invalidServer": "nieprawidłowy serwer",
"loginRateError": "zbyt dużo prób logowania, poczekaj chwilę i spróbuj ponownie"
"loginRateError": "zbyt dużo prób logowania, poczekaj chwilę i spróbuj ponownie",
"badAlbum": "ta strona jest wyświetlana, ponieważ ten utwór nie jest częścią albumu. najprawdopodobniej ten problem występuje, jeśli utwór znajduje się w nadrzędnym folderze plików z muzyką. jellyfin grupuje utwory tylko wtedy, gdy znajdują się one w folderze.",
"networkError": "wystąpił błąd sieciowy",
"openError": "nie można otworzyć pliku"
},
"filter": {
"mostPlayed": "najczęściej odtwarzane",
@@ -262,6 +279,14 @@
},
"editPlaylist": {
"title": "edytuj $t(entity.playlist_one)"
},
"shareItem": {
"allowDownloading": "zezwól na pobieranie",
"description": "opis",
"setExpiration": "ustaw czas wygaśnięcia",
"success": "link do udostępniania skopiowany do schowka (lub kliknij tutaj, aby otworzyć)",
"createFailed": "nie udało się utworzyć linku do udostępniania (czy udostępnianie jest włączone?)",
"expireInvalid": "ustawiony czas wygaśnięcia musi być w przyszłości"
}
},
"page": {
@@ -277,7 +302,9 @@
"unsynchronized": "niezsynchronizowane",
"lyricAlignment": "wyrównaj tekst",
"useImageAspectRatio": "użyj współczynnika proporcji obrazu",
"lyricGap": "odstępy tekstu"
"lyricGap": "odstępy tekstu",
"dynamicImageBlur": "rozmiar rozmycia obrazu",
"dynamicIsImage": "włącz obraz w tle"
},
"upNext": "następny",
"lyrics": "tekst",
@@ -311,7 +338,9 @@
"addFavorite": "$t(action.addToFavorites)",
"play": "$t(player.play)",
"numberSelected": "zaznaczono {{count}}",
"removeFromQueue": "$t(action.removeFromQueue)"
"removeFromQueue": "$t(action.removeFromQueue)",
"shareItem": "udostępnij pozycję",
"showDetails": "zobacz informacje"
},
"albumDetail": {
"moreFromArtist": "więcej od $t(entity.artist_one)",
@@ -321,10 +350,14 @@
"title": "$t(entity.albumArtist_other)"
},
"genreList": {
"title": "$t(entity.genre_other)"
"title": "$t(entity.genre_other)",
"showAlbums": "pokaż $t(entity.genre_one) $t(entity.album_other)",
"showTracks": "pokaż $t(entity.genre_one) $t(entity.track_other)"
},
"albumList": {
"title": "$t(entity.album_other)"
"title": "$t(entity.album_other)",
"artistAlbums": "albumy artysty {{artist}}",
"genreAlbums": "\"{{genre}}\" $t(entity.album_other)"
},
"sidebar": {
"nowPlaying": "teraz odtwarzane",
@@ -337,7 +370,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": "udostępnione $t(entity.playlist_other)"
},
"home": {
"mostPlayed": "najczęściej odtwarzane",
@@ -353,7 +387,9 @@
"windowTab": "okno"
},
"trackList": {
"title": "$t(entity.track_other)"
"title": "$t(entity.track_other)",
"artistTracks": "utwory przez {{artist}}",
"genreTracks": "\"{{genre}}\" $t(entity.track_other)"
},
"globalSearch": {
"commands": {
@@ -365,6 +401,22 @@
},
"playlistList": {
"title": "$t(entity.playlist_other)"
},
"albumArtistDetail": {
"topSongs": "popularne utwory",
"topSongsFrom": "popularne utwory z {{title}}",
"about": "O {{artist}}",
"recentReleases": "ostatnie wydania",
"viewAll": "zobacz wszystko",
"viewDiscography": "przeglądaj dyskografię",
"relatedArtists": "powiązane z $t(entity.artist_other)",
"appearsOn": "pojawia się na",
"viewAllTracks": "zobacz wszystko $t(entity.track_other)"
},
"itemDetail": {
"copyPath": "kopiuj ścieżkę do schowka",
"copiedPath": "ścieżka została skopiowana pomyślnie",
"openFile": "pokaż utwór w menedżerze plików"
}
},
"player": {
@@ -413,7 +465,7 @@
"hotkey_zoomIn": "przybliż",
"hotkey_browserForward": "przeglądarka w przód",
"audioExclusiveMode_description": "włącz wyłączny tryb wyjścia. W tym trybie, system zwykle jest zablokowany i może odtwarzać tylko poprzez mpv",
"discordUpdateInterval": "{{discord}} interwał aktualizacji obszernej obecności",
"discordUpdateInterval": "{{discord}} interwał aktualizacji rich presence",
"fontType_optionBuiltIn": "wbudowana czcionka",
"hotkey_playbackPlayPause": "odtwarzaj / wstrzymaj",
"hotkey_rate1": "oceń na 1 gwiazdkę",
@@ -449,7 +501,7 @@
"crossfadeDuration_description": "ustaw czas trwania efektu przenikania",
"language": "język",
"hotkey_toggleShuffle": "przełącz kolejność losową",
"discordRichPresence_description": "włącz status odtwarzania w {{discord}} obszernie obecny. Klucze obrazów to {{icon}}, {{playing}} i {{paused}}. ",
"discordRichPresence_description": "włącz status odtwarzania w {{discord}} rich presence. Dzięki temu będą wyświetlane informacje takie jak: {{icon}}, {{playing}} i {{paused}}. ",
"audioDevice": "urządzenia dźwiękowe",
"hotkey_rate2": "oceń na 2 gwiazdki",
"exitToTray": "zamknij do zasobnika",
@@ -475,7 +527,7 @@
"hotkey_zoomOut": "oddal",
"hotkey_unfavoriteCurrentSong": "usuń $t(common.currentSong) z ulubionych",
"hotkey_rate0": "wyczyść oceny",
"discordApplicationId": "id aplikacji {{discord}}",
"discordApplicationId": "ID aplikacji {{discord}}",
"applicationHotkeys_description": "ustaw skróty klawiszowe aplikacji. przełącz pole wyboru aby ustawić skrót globalny (tylko komputery)",
"floatingQueueArea_description": "wyświetl ikonę najechania kursorem po prawej stronie ekranu, aby wyświetlić kolejkę odtwarzania",
"hotkey_volumeMute": "wycisz",
@@ -521,7 +573,7 @@
"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",
"scrobble_description": "przekazywanie informacji o odtwarzaniu (scrobbling) do twojego serwera multimediów",
"sidePlayQueueStyle": "boczny styl kolejki odtwarzania",
"remoteUsername_description": "ustaw nazwę użytkownika dla serwera zdalnej kontroli. Jeśli nazwa użytkownika i hasło są puste, autoryzacja będzie wyłączona",
"replayGainMode_optionAlbum": "$t(entity.album_one)",
@@ -564,7 +616,21 @@
"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"
"clearCache": "wyczyść pamięć podręczną przeglądarki",
"playerAlbumArtResolution": "rozdzielczość okładki albumu odtwarzacza",
"externalLinks": "pokaż zewnętrzne linki",
"genreBehavior_description": "określa, czy kliknięcie gatunku domyślnie otwiera listę utworów czy albumów",
"mpvExtraParameters_help": "po jednym na linię",
"passwordStore": "hasła",
"passwordStore_description": "jakie hasło ma być używane. zmień to, jeśli masz problemy z przechowywaniem haseł.",
"playerAlbumArtResolution_description": "rozdzielczość podglądu okładki albumu w dużym odtwarzaczu. większa sprawia, że wygląda bardziej wyraziście, ale może spowolnić ładowanie. domyślnie 0, czyli auto",
"startMinimized": "uruchom zminimalizowany",
"startMinimized_description": "uruchom aplikację w zasobniku systemowym",
"clearCacheSuccess": "pamięć podręczna została wyczyszczona pomyślnie",
"genreBehavior": "domyślne zachowanie strony gatunek",
"externalLinks_description": "umożliwia wyświetlanie linków zewnętrznych (Last.fm, MusicBrainz) na stronach artystów/albumów",
"homeConfiguration": "konfiguracja strony głównej",
"homeConfiguration_description": "konfiguracja elementów wyświetlanych na stronie głównej i ich kolejności"
},
"table": {
"config": {
@@ -578,7 +644,9 @@
"gap": "$t(common.gap)",
"tableColumns": "kolumny tabeli",
"autoFitColumns": "automatyczne dopasowanie kolumn",
"size": "$t(common.size)"
"size": "$t(common.size)",
"itemSize": "rozmiar elementu (px)",
"itemGap": "odstęp między elementami (px)"
},
"label": {
"releaseDate": "data premiery",
@@ -606,7 +674,8 @@
"discNumber": "numer płyty",
"favorite": "$t(common.favorite)",
"year": "$t(common.year)",
"albumArtist": "$t(entity.albumArtist_one)"
"albumArtist": "$t(entity.albumArtist_one)",
"codec": "$t(common.codec)"
}
},
"column": {
@@ -632,7 +701,8 @@
"path": "ścieżka",
"discNumber": "płyta",
"channels": "$t(common.channel_other)",
"size": "$t(common.size)"
"size": "$t(common.size)",
"codec": "$t(common.codec)"
}
}
}
+24 -2
View File
@@ -212,7 +212,23 @@
"songCount": "contador de músicas",
"toYear": "até o ano",
"random": "aleatório",
"search": "buscar"
"search": "buscar",
"lastPlayed": "última tocada",
"isCompilation": "é compilação",
"trackNumber": "faixa",
"communityRating": "Nota da comunidade",
"isPublic": "é público",
"playCount": "contador de execuções",
"recentlyUpdated": "atualizado recentemente",
"dateAdded": "data de adição",
"isRecentlyPlayed": "foi tocado recentemente",
"albumArtist": "$t(entity.albumArtist_one)",
"recentlyAdded": "adicionado recentemente",
"releaseDate": "data de lançamento",
"recentlyPlayed": "tocado recentemente",
"criticRating": "Nota da crítica",
"isFavorited": "é favoritado",
"releaseYear": "ano de lançamento"
},
"player": {
"playbackFetchNoResults": "nenhuma música encontrada",
@@ -257,7 +273,13 @@
"folderWithCount_other": "{{count}} pastas",
"genreWithCount_one": "{{count}} gênero",
"genreWithCount_many": "{{count}} gêneros",
"genreWithCount_other": "{{count}} gêneros"
"genreWithCount_other": "{{count}} gêneros",
"trackWithCount_one": "faixa",
"trackWithCount_many": "faixas",
"trackWithCount_other": "faixas",
"track_one": "faixa",
"track_many": "faixas",
"track_other": "faixas"
},
"error": {
"remotePortWarning": "reinicie o servidor para aplicar a nova porta",
+269 -60
View File
@@ -15,14 +15,18 @@
"deselectAll": "снять выделение",
"moveToBottom": "вниз",
"setRating": "оценить",
"toggleSmartPlaylistEditor": "вкл/выкл $t(entity.smartPlaylist) редактор",
"removeFromFavorites": "удалить из $t(entity.favorite_other)"
"toggleSmartPlaylistEditor": "вкл./откл. редактор $t(entity.smartPlaylist)",
"removeFromFavorites": "удалить из $t(entity.favorite_other)",
"openIn": {
"lastfm": "открыть на Last.fm",
"musicbrainz": "открыть на MusicBrainz"
}
},
"common": {
"backward": "назад",
"increase": "увеличить",
"rating": "рейтинг",
"bpm": "ударов в мин.",
"bpm": "уд./мин.",
"refresh": "обновить",
"unknown": "неизвестно",
"areYouSure": "вы уверены?",
@@ -34,19 +38,19 @@
"currentSong": "текущий $t(entity.track_one)",
"collapse": "закрыть",
"trackNumber": "трек",
"descending": "убывающий",
"descending": "убывание",
"add": "добавить",
"gap": "промежуток",
"ascending": "возрастающий",
"ascending": "возрастанию",
"dismiss": "отклонить",
"year": "год",
"manage": "управлять",
"limit": "лимит",
"limit": "ограничение",
"minimize": "минимизировать",
"modified": "изменено",
"duration": "продолжительность",
"duration": "длительность",
"name": "имя",
"maximize": "максимизировать",
"maximize": "развернуть",
"decrease": "уменьшить",
"ok": "ок",
"description": "описание",
@@ -60,7 +64,7 @@
"forward": "вперёд",
"delete": "удалить",
"cancel": "отменить",
"forceRestartRequired": "перезапустите приложение, чтобы применить изменения... закройте уведомление, чтобы перезапустить приложение",
"forceRestartRequired": "перезапустите приложение, чтобы применить изменения... закройте уведомление для перезапуска",
"setting": "настройка",
"version": "версия",
"title": "название",
@@ -76,14 +80,14 @@
"action_many": "действий",
"playerMustBePaused": "воспроизведение должно быть остановлено",
"confirm": "подтвердить",
"resetToDefault": "сбросить к настройкам по умолчанию",
"home": "Главная страница",
"resetToDefault": "по умолчанию",
"home": "главная страница",
"comingSoon": "скоро будет…",
"reset": "сбросить",
"channel_one": "канал",
"channel_few": "канала",
"channel_many": "каналов",
"disable": "выключить",
"disable": "отключить",
"sortOrder": "порядок",
"menu": "меню",
"restartRequired": "необходим перезапуск приложения",
@@ -91,7 +95,7 @@
"noResultsFromQuery": "нет результатов",
"quit": "выйти",
"expand": "расширить",
"search": "Поиск",
"search": "поиск",
"saveAs": "сохранить как",
"disc": "диск",
"yes": "да",
@@ -99,7 +103,13 @@
"size": "размер",
"biography": "биография",
"note": "заметка",
"none": "нет"
"none": "нет",
"mbid": "MusicBrainz ID",
"reload": "перезагрузить",
"preview": "просмотр",
"codec": "кодек",
"share": "поделиться",
"close": "закрыть"
},
"entity": {
"album_one": "альбом",
@@ -161,7 +171,9 @@
"gap": "$t(common.gap)",
"tableColumns": "столбцы таблицы",
"autoFitColumns": "автоматически расставить столбцы",
"size": "$t(common.size)"
"size": "$t(common.size)",
"itemSize": "рамер элементов (px)",
"itemGap": "отступ между элементами (px)"
},
"label": {
"releaseDate": "дата выхода",
@@ -189,7 +201,9 @@
"discNumber": "номер диска",
"favorite": "$t(common.favorite)",
"year": "$t(common.year)",
"albumArtist": "$t(entity.albumArtist_one)"
"albumArtist": "$t(entity.albumArtist_one)",
"codec": "$t(common.codec)",
"songCount": "$t(entity.track_other)"
}
},
"column": {
@@ -205,29 +219,43 @@
"genre": "$t(entity.genre_one)",
"path": "путь",
"discNumber": "диск",
"size": "$t(common.size)"
"size": "$t(common.size)",
"dateAdded": "дата добавления",
"album": "альбом",
"albumArtist": "исполнитель альбома",
"biography": "биография",
"codec": "$t(common.codec)",
"comment": "комментарий",
"albumCount": "$t(entity.album_other)",
"artist": "$t(entity.artist_one)",
"bitrate": "битрейт",
"channels": "$t(common.channel_other)",
"bpm": "bpm"
}
},
"error": {
"remotePortWarning": "перезапустить сервер для применения нового порта",
"systemFontError": "произошла ошибка при попытке получить системные шрифты",
"playbackError": "произошла ошибка при попытке проиграть медиа",
"endpointNotImplementedError": "запрос {{endpoint}} is not implemented for {{serverType}}",
"endpointNotImplementedError": "запрос {{endpoint}} не реализован для {{serverType}}",
"remotePortError": "произошла ошибка при попытке установить порт удаленного сервера",
"serverRequired": "необходим сервер",
"authenticationFailed": "аутентификация завершилась с ошибкой",
"authenticationFailed": "авторизация завершилась с ошибкой",
"apiRouteError": "невозможно выполнить запрос",
"genericError": "произошла ошибка",
"credentialsRequired": "необходимы учётные данные",
"sessionExpiredError": "ваш сеанс истек",
"remoteEnableError": "ошибка произошла при попытке $t(common.enable) удаленного сервера",
"sessionExpiredError": "ваш сеанс истёк",
"remoteEnableError": "ошибка произошла при попытке $t(common.enable) удалённый сервер",
"localFontAccessDenied": "не получилось получить доступ к шрифтам",
"serverNotSelectedError": "не выбран сервер",
"remoteDisableError": "ошибка произошла при попытке $t(common.disable) удаленного сервера",
"mpvRequired": "Необходим MPV",
"remoteDisableError": "ошибка произошла при попытке $t(common.disable) удалённый сервер",
"mpvRequired": "необходим MPV",
"audioDeviceFetchError": "произошла ошибка с аудиоустройством",
"invalidServer": "недействительный сервер",
"loginRateError": "слишком много попыток входа, пожалуйста, попробуйте еще раз через несколько секунд"
"loginRateError": "слишком много попыток входа, пожалуйста, попробуйте ещё раз через несколько секунд",
"openError": "не удалось открыть файл",
"badAlbum": "вы видите эту страницу из-за того, что эта песня не входит в альбом. скорее всего, вы видите эту ошибку, так как песня находится в корневой директории папки с музыкой. jellyfin группирует треки только по папкам.",
"networkError": "возникла ошибка сети"
},
"filter": {
"isCompilation": "сборник",
@@ -238,14 +266,14 @@
"favorited": "любимый",
"albumArtist": "$t(entity.albumArtist_one)",
"isFavorited": "любимый",
"bpm": "ударов в мин.",
"bpm": "уд./мин.",
"disc": "диск",
"biography": "биография",
"artist": "$t(entity.artist_one)",
"duration": "продолжительность",
"duration": "длительность",
"fromYear": "из года",
"criticRating": "рейтинг критиков",
"mostPlayed": "наибольшое кол-во воспроизведений",
"mostPlayed": "самое воспроизводимое",
"comment": "комментировать",
"playCount": "кол-во воспроизведений",
"recentlyUpdated": "недавно обновлено",
@@ -254,17 +282,17 @@
"owner": "$t(common.owner)",
"title": "название",
"rating": "рейтинг",
"search": "Поиск",
"search": "поиск",
"genre": "$t(entity.genre_one)",
"recentlyAdded": "недавно добавлено",
"note": "заметка",
"name": "название",
"releaseDate": "дата выхода",
"albumCount": "$t(entity.album_other) кол-во",
"albumCount": "кол-во $t(entity.album_other)",
"path": "путь",
"isRecentlyPlayed": "недавно проигрывалась",
"releaseYear": "год выхода",
"id": "#",
"id": "",
"songCount": "кол-во песен",
"isPublic": "публичный",
"random": "случайный",
@@ -277,16 +305,16 @@
"repeat_all": "повтор всех",
"stop": "остановить",
"repeat": "повтор",
"queue_remove": "удалить выделенные",
"queue_remove": "удалить выбранное",
"playRandom": "случайные песни",
"skip": "пропустить",
"previous": "предыдущий",
"toggleFullscreenPlayer": "включить полноэкранный режим",
"skip_back": "назад",
"favorite": "любимый",
"next": "следующее",
"next": "следующий",
"shuffle": "перемешать",
"playbackFetchNoResults": "нет песен",
"playbackFetchNoResults": "песни не найдены",
"playbackFetchInProgress": "загрузка песен..",
"addNext": "добавить следующий",
"playbackSpeed": "скорость воспроизведения",
@@ -297,8 +325,8 @@
"queue_clear": "очистить очередь",
"muted": "звук отключён",
"unfavorite": "убрать из любимых",
"queue_moveToTop": "переместить выделение вниз",
"queue_moveToBottom": "переместить выделение вверх",
"queue_moveToTop": "переместить выделенное вниз",
"queue_moveToBottom": "переместить выделенное вверх",
"shuffle_off": "перемешивание выключено",
"addLast": "добавить последний",
"mute": "отключить звук",
@@ -330,7 +358,9 @@
"unsynchronized": "несинхронизировано",
"lyricAlignment": "выравнивание слов песни",
"useImageAspectRatio": "использовать соотношение сторон изображения",
"lyricGap": "пробел между словами"
"lyricGap": "пробел между словами",
"dynamicIsImage": "включить фоновое изображение",
"dynamicImageBlur": "сила размытия изображения"
},
"upNext": "следующее",
"lyrics": "слова песни",
@@ -364,7 +394,9 @@
"addFavorite": "$t(action.addToFavorites)",
"play": "$t(player.play)",
"numberSelected": "{{count}} выбрано",
"removeFromQueue": "$t(action.removeFromQueue)"
"removeFromQueue": "$t(action.removeFromQueue)",
"showDetails": "получить информацию",
"shareItem": "поделиться"
},
"home": {
"mostPlayed": "наибольшее кол-во воспроизведений",
@@ -374,7 +406,7 @@
"recentlyPlayed": "недавно прослушано"
},
"albumDetail": {
"moreFromArtist": "больше из жанра $t(entity.genre_one)",
"moreFromArtist": "больше от $t(entity.artist_one)",
"moreFromGeneric": "больше из {{item}}"
},
"setting": {
@@ -387,10 +419,13 @@
"title": "$t(entity.albumArtist_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)"
},
"trackList": {
"title": "$t(entity.track_other)"
"title": "$t(entity.track_other)",
"genreTracks": "\"{{genre}}\" $t(entity.track_other)"
},
"globalSearch": {
"commands": {
@@ -404,14 +439,32 @@
"title": "$t(entity.playlist_other)"
},
"albumList": {
"title": "$t(entity.album_other)"
"title": "$t(entity.album_other)",
"artistAlbums": "альбомы {{artist}}",
"genreAlbums": "\"{{genre}}\" $t(entity.album_other)"
},
"albumArtistDetail": {
"topSongs": "популярные треки",
"viewAll": "посмотреть всё",
"appearsOn": "появляется в",
"viewDiscography": "посмотреть дискографию",
"relatedArtists": "похож на $t(entity.artist_other)",
"viewAllTracks": "посмотреть все $t(entity.track_other)",
"recentReleases": "недавние релизы",
"about": "О {{artist}}",
"topSongsFrom": "популярные треки из {{title}}"
},
"itemDetail": {
"copyPath": "скопировать путь в буфер обмена",
"openFile": "открыть трек в менеджере файлов",
"copiedPath": "путь успешно скопирован"
}
},
"form": {
"deletePlaylist": {
"title": "удалить $t(entity.playlist_one)",
"success": "$t(entity.playlist_one) успешно удалён",
"input_confirm": "напишите название $t(entity.playlist_one), чтобы подтвердить действие"
"input_confirm": "напишите название $t(entity.playlist_one)а для подтверждения"
},
"createPlaylist": {
"input_description": "$t(common.description)",
@@ -426,16 +479,16 @@
"input_username": "пользователь",
"input_url": "url",
"input_password": "пароль",
"input_legacyAuthentication": "включить старую аутентификацию",
"input_legacyAuthentication": "включить старую авторизацию",
"input_name": "название сервера",
"success": "сервер добавлен успешно",
"input_savePassword": "сохранить пароль",
"ignoreSsl": "ignore ssl $t(common.restartRequired)",
"ignoreCors": "$t(common.restartRequired)",
"ignoreSsl": "игнорирование ssl ($t(common.restartRequired))",
"ignoreCors": "игнорирование корсетов ($t(common.restartRequired))",
"error_savePassword": "произошла ошибка во время попытки сохранения пароля"
},
"addToPlaylist": {
"success": "добавлено(а) {{message}} $t(entity.track_other) в {{numOfPlaylists}} $t(entity.playlist_other)",
"success": "добавлено: $t(entity.trackWithCount, {\"count\": {{message}} }) в $t(entity.playlistWithCount, {\"count\": {{numOfPlaylists}} })",
"title": "добавить в $t(entity.playlist_one)",
"input_skipDuplicates": "пропустить дубликаты",
"input_playlists": "$t(entity.playlist_other)"
@@ -455,35 +508,191 @@
},
"editPlaylist": {
"title": "редактировать $t(entity.playlist_one)"
},
"shareItem": {
"success": "ссылка скопирована в буфер обмена (нажмите здесь, чтобы открыть)",
"expireInvalid": "время истечения срока действия должно быть в будущем",
"createFailed": "не удалось создать ссылку для общего доступа (общий доступ включён?)",
"allowDownloading": "разрешить скачивание",
"setExpiration": "установить срок действия",
"description": "описание"
}
},
"setting": {
"accentColor": "цвет акцента",
"accentColor_description": "устанавливает цвет акцента для приложения",
"applicationHotkeys": "горячие клавиши приложения",
"crossfadeStyle_description": "Выберите вид эффекта crossfade для аудиоплеера",
"enableRemote_description": "Включает сервер удалённого управления для управления воспроизведением с помощью других устройств",
"fontType_optionSystem": "Системный шрифт",
"mpvExecutablePath_description": "Укажите папку, в которой находится исполняющий файл аудиоплеера MPV",
"crossfadeStyle_description": "выберите вид эффекта crossfade для аудиоплеера",
"enableRemote_description": "включает сервер удалённого управления для управления воспроизведением с помощью других устройств",
"fontType_optionSystem": "системный",
"mpvExecutablePath_description": "укажите папку, в которой находится исполняющий файл аудиоплеера MPV. если оставить пустым, будет использоваться путь по умолчанию",
"crossfadeStyle": "Вид эффекта crossfade",
"fontType_optionBuiltIn": "Встроенный в приложение",
"fontType_optionBuiltIn": "встроенный",
"disableLibraryUpdateOnStartup": "Отключить проверку новых версий при запуске приложения",
"minimizeToTray_description": "Сворачивать приложение в панель уведомлений",
"audioPlayer_description": "Укажите - какой аудиоплеер использовать для воспроизведения",
"disableAutomaticUpdates": "Отключить проверку обновлений",
"disableAutomaticUpdates": "отключить проверку обновлений",
"exitToTray_description": "При закрытии приложения - оно останется в панели уведомлений",
"fontType_optionCustom": "Пользовательский шрифт",
"remotePassword": "Пароль к серверу удалённого управления",
"fontType_optionCustom": "пользовательский",
"remotePassword": "пароль к серверу удалённого управления",
"font": "Шрифт",
"crossfadeDuration_description": "Укажите длительность эффекта crossfade",
"mpvExecutablePath": "Папка с аудиоплеером MPV",
"exitToTray": "Сворачивать в панель уведомлений при закрытии",
"enableRemote": "Включить сервер удалённого управления",
"fontType": "Источник шрифта",
"mpvExecutablePath": "папка с аудиоплеером MPV",
"exitToTray": "сворачивать в панель уведомлений при закрытии",
"enableRemote": "включить сервер удалённого управления",
"fontType": "тип шрифта",
"crossfadeDuration": "Длительность эффекта crossfade",
"audioPlayer": "Аудиоплеер",
"minimizeToTray": "Сворачивать в панель уведомлений",
"minimizeToTray": "сворачивать в панель уведомлений",
"font_description": "Выберите - какой шрифт использовать в приложении",
"remoteUsername": "Имя пользователя для доступа к серверу удалённого управления"
"remoteUsername": "имя пользователя для доступа к серверу удалённого управления",
"buttonSize_description": "размер кнопок в панели управления воспроизведением",
"clearCache": "очистить кэш браузера",
"clearQueryCache": "очистить кэш feishin",
"audioDevice": "устройство воспроизведения",
"audioDevice_description": "выберите устройство воспроизведения (только в режиме аудиоплеера web)",
"buttonSize": "размер кнопок панели управления воспроизведением",
"hotkey_volumeDown": "уменьшить громкость",
"playButtonBehavior_optionAddLast": "$t(player.addLast)",
"theme_description": "устанавливает тему, которая будет использоваться приложением",
"passwordStore": "хранилище паролей/секретов",
"sidebarPlaylistList": "список плейлистов в боковой панели",
"windowBarStyle_description": "выберите стиль заголовка окна",
"followLyric": "следовать тексту трека",
"volumeWheelStep": "шаг регулировки громкости колёсиком мыши",
"windowBarStyle": "стиль заголовка окна",
"hotkey_zoomOut": "уменьшить масштаб",
"playbackStyle_optionCrossFade": "затухание",
"replayGainMode": "режим {{ReplayGain}}",
"replayGainMode_optionAlbum": "$t(entity.album_one)",
"replayGainMode_optionNone": "$t(common.none)",
"replayGainMode_optionTrack": "$t(entity.track_one)",
"clearQueryCache_description": "\"мягкая очистка\" feishin. при выполнении обновляются плейлисты, метаданные треков, но сохранённые тексты треков сбрасываются. настройки, учётные данные и кэшированные изображения сохраняются",
"hotkey_favoriteCurrentSong": "добавить $t(common.currentSong) в избранное",
"genreBehavior": "поведения страницы жанров",
"globalMediaHotkeys": "глобальные мультимедийные горячие клавиши",
"hotkey_browserForward": "кнопка браузера \"вперёд\"",
"hotkey_favoritePreviousSong": "добавить $t(common.previousSong) в избранное",
"hotkey_globalSearch": "глобальный поиск",
"hotkey_playbackNext": "следующий трек",
"hotkey_playbackPause": "пауза",
"hotkey_playbackPlay": "прослушать",
"hotkey_playbackPlayPause": "прослушать / пауза",
"hotkey_playbackPrevious": "предыдущий трек",
"hotkey_playbackStop": "остановить",
"hotkey_rate0": "очистить оценку",
"hotkey_rate1": "оценить в 1 звезду",
"hotkey_rate2": "оценить в 2 звезды",
"hotkey_rate3": "оценить в 3 звезды",
"hotkey_rate4": "оценить в 4 звезды",
"hotkey_rate5": "оценить в 5 звёзд",
"hotkey_skipForward": "перемотать вперёд",
"hotkey_toggleCurrentSongFavorite": "добавить/удалить $t(common.currentSong) в избранное",
"hotkey_toggleFullScreenPlayer": "включение/выключение полноэкранного плеера",
"hotkey_togglePreviousSongFavorite": "добавить/удалить $t(common.previousSong) в избранное",
"hotkey_toggleRepeat": "переключить режим повтора",
"hotkey_toggleShuffle": "переключить перемешивание",
"hotkey_unfavoriteCurrentSong": "удалить $t(common.currentSong) из избранного",
"hotkey_unfavoritePreviousSong": "удалить $t(common.previousSong) из избранного",
"hotkey_volumeUp": "увеличить громкость",
"hotkey_zoomIn": "увеличить масштаб",
"language": "язык",
"lyricFetchProvider_description": "выберите источники для получения текстов песен. порядок источников соответствует очередности их запросов",
"minimumScrobblePercentage_description": "минимальный процент прослушивания трека, прежде чем он будет засчитан как прослушанный",
"minimumScrobbleSeconds_description": "минимальное время прослушивания трека в секундах, прежде чем он будет засчитан как прослушанный",
"playbackStyle_optionNormal": "нормальный",
"mpvExtraParameters": "параметры mpv",
"mpvExtraParameters_help": "по одному на строчку",
"playbackStyle_description": "выберите стиль воспроизведения, который будет использоваться аудиоплеером",
"playButtonBehavior_description": "устанавливает поведение кнопки воспроизведения при добавлении треков в очередь",
"playButtonBehavior": "поведение кнопки воспроизведения",
"playButtonBehavior_optionAddNext": "$t(player.addNext)",
"playButtonBehavior_optionPlay": "$t(player.play)",
"playerAlbumArtResolution_description": "разрешение большой версии обложки альбома в проигрывателе. при большем разрешении она выглядит более четкой, но может замедлить загрузку. по умолчанию равно 0 - устанавливает разрешение автоматически",
"remotePort": "порт сервера удалённого управления",
"remotePort_description": "устанавливает порт для сервера удалённого управления",
"replayGainClipping": "{{ReplayGain}} клиппинг",
"replayGainFallback": "{{ReplayGain}} по умолчанию",
"sampleRate_description": "выберите выходную частоту дискретизации, которая будет использоваться, если выбранная частота дискретизации отличается от частоты дискретизации текущего трека. при значении меньше 8000 будет использоваться частота по умолчанию",
"savePlayQueue_description": "сохранять очередь воспроизведения при закрытии приложения и восстанавливать при запуске приложения",
"showSkipButton_description": "показывать или скрывать кнопки перемотки на панели управления воспроизведением",
"sidebarConfiguration": "конфигурация боковой панели",
"sidebarConfiguration_description": "выбрать элементы и порядок их отображения на боковой панели",
"sidebarCollapsedNavigation": "кнопки навигации в боковой панели (в свёрнутом режиме)",
"showSkipButtons": "показывать кнопки перемотки",
"showSkipButtons_description": "показывать или скрывать кнопки перемотки на панели управления воспроизведением",
"sidebarPlaylistList_description": "показать или скрыть список плейлистов на боковой панели",
"skipDuration": "время перемотки",
"skipDuration_description": "задает время перемотки при использовании кнопок перемотки на панели проигрывателя",
"useSystemTheme": "использовать тему системы",
"themeLight": "тема (светлая)",
"themeLight_description": "устанавливает светлую тему приложения",
"useSystemTheme_description": "использует тему, заданную в системе (светлую/тёмную)",
"zoom": "процент масштабирования",
"zoom_description": "устанавливает процент масштабирования приложения",
"floatingQueueArea": "показать область наведения для всплывающей очереди",
"genreBehavior_description": "определяет, что отобразится при открытии на жанр — список треков или альбомов",
"globalMediaHotkeys_description": "включить или отключить использование системных мультимедийных горячих клавиш для управления воспроизведением",
"homeConfiguration_description": "позволяет настроить видимость и порядок элементов на домашней странице",
"hotkey_toggleQueue": "показать/скрыть очередь воспроизведения",
"imageAspectRatio": "использовать оригинальное соотношение сторон обложки",
"imageAspectRatio_description": "если эта опция включена, обложки будут отображаться в соответствии с их собственным соотношением сторон. для обложек не 1:1 оставшееся пространство будет пустым",
"minimumScrobblePercentage": "минимальное время для скробблинга (в процентах)",
"playbackStyle": "стиль воспроизведения",
"playerAlbumArtResolution": "разрешение обложки альбома",
"remotePassword_description": "задает пароль для сервера удалённого управления. По умолчанию эти учетные данные передаются небезопасным способом, поэтому следует использовать уникальный пароль, который вам не важен",
"replayGainClipping_description": "Предотвращение клиппинга, вызванного {{ReplayGain}}, путём автоматического снижения усиления",
"replayGainFallback_description": "усиление в db для применения, если у файла нет тегов {{ReplayGain}}",
"replayGainMode_description": "регулировать усиление громкости в соответствии со значениями {{ReplayGain}}, хранящимися в метаданных файла",
"savePlayQueue": "сохранять очередь воспроизведения",
"showSkipButton": "показывать кнопки перемотки",
"theme": "тема",
"themeDark": "тема (тёмная)",
"externalLinks": "показывать ссылки на внешние ресурсы",
"gaplessAudio": "бесшовное воспроизведение",
"gaplessAudio_optionWeak": "слабое (рекомендуется)",
"gaplessAudio_description": "устанавливает настройку mpv для бесшовного воспроизведение",
"hotkey_browserBack": "кнопка браузера \"назад\"",
"hotkey_localSearch": "поиск на странице",
"hotkey_skipBackward": "перемотать назад",
"startMinimized": "запуск в свёрнутом режиме",
"themeDark_description": "устанавливает тёмную тему приложения",
"hotkey_volumeMute": "отключить звук",
"clearCache_description": "\"жесткая очистка\" feishin. кроме очистки кэша feishin, также очищает кэш браузера (сохранённые картинки и другие ресурсы). учётные данные и настройки сохраняются",
"clearCacheSuccess": "кэш успешно удалён",
"customFontPath": "путь к пользовательскому шрифту",
"customFontPath_description": "укажите путь к пользовательскому шрифту, который будет использоваться в приложении",
"externalLinks_description": "включает отображение внешних ссылок (Last.fm, MusicBrainz) на страницах альбомов и артистов",
"floatingQueueArea_description": "включить отображение иконки наведения на правой части экрана, чтобы показать очередь воспроизведения",
"followLyric_description": "прокручивать текст трека до текущей позиции воспроизведения",
"language_description": "устанавливает язык приложения ($t(common.restartRequired))",
"lyricFetch_description": "получать тексты треков из различных интернет-источников",
"lyricFetchProvider": "источник текстов треков",
"minimumScrobbleSeconds": "минимальное время для скробблинга (в секундах)",
"replayGainPreamp": "предусиление {{ReplayGain}} (дБ)",
"sidebarCollapsedNavigation_description": "показать или скрыть кнопки навигации в свёрнутой боковой панели",
"homeConfiguration": "конфигурация домашней страницы",
"remoteUsername_description": "задает имя пользователя для сервера удалённого управления. если имя пользователя и пароль пусты, аутентификация будет отключена",
"scrobble": "скробблинг",
"replayGainPreamp_description": "настройка усиления предусилителя, применяемого к значениям {{ReplayGain}}",
"passwordStore_description": "какое хранилище паролей/секретов использовать. измените это значение, если у вас есть проблемы с хранением паролей.",
"lyricFetch": "получать тексты треков из интернета",
"sampleRate": "частота дискретизации",
"scrobble_description": "скробблинг треков на вашем медиасервере",
"startMinimized_description": "запуск приложения в области уведомлений",
"volumeWheelStep_description": "количество громкости, изменяемое при прокрутке колёсика мыши над ползунком громкости",
"discordRichPresence": "состояние профиля {{discord}}",
"discordApplicationId": "{{discord}} application id",
"discordApplicationId_description": "application id приложения {{discord}} которое будет отображаться в статусе профиля (по умолчанию {{defaultId}})",
"discordIdleStatus": "показывать состояние простоя",
"discordIdleStatus_description": "если включено, то обновляет статус, когда пользователь бездействует",
"discordUpdateInterval": "интервал обновления статуса профиля {{discord}}",
"discordUpdateInterval_description": "время в секундах между каждым обновлением (минимум 15 секунд)",
"lyricOffset_description": "Смещение появления текста треков на указанное количество миллисекунд",
"skipPlaylistPage": "пропустить страницу плейлиста",
"applicationHotkeys_description": "настройка горячих клавиш приложения. включите чекбокс, чтобы сделать горячую клавишу глобальной (только для ПК)",
"fontType_description": "встроенный позволяет выбрать один из шрифтов, предоставляемых Feishin. системный позволяет выбрать любой шрифт, предоставляемый вашей операционной системой. пользовательский позволяет выбрать свой собственный шрифт",
"discordRichPresence_description": "включить статус воспроизведения в статус профиля в {{discord}}. Ключи изображений: {{icon}}, {{playing}} и {{paused}} ",
"lyricOffset": "синхронизация текста треков (мс)"
}
}
+11 -4
View File
@@ -156,7 +156,8 @@
"mute": "静音",
"skip_forward": "向前跳过",
"playbackSpeed": "播放速度",
"pause": "暂停"
"pause": "暂停",
"playSimilarSongs": "播放类似的曲目"
},
"setting": {
"crossfadeStyle_description": "选择用于音频播放器的淡入淡出风格",
@@ -338,7 +339,11 @@
"genreBehavior_description": "确定单击流派是否默认在曲目或专辑列表中打开",
"homeConfiguration": "主页配置",
"homeConfiguration_description": "配置主页上显示的项目以及显示顺序",
"passwordStore": "密码/秘密存储"
"passwordStore": "密码/秘密存储",
"homeFeature_description": "控制是否在主页上显示大型特色轮播",
"homeFeature": "首页 精选 轮播",
"imageAspectRatio": "使用原生封面艺术纵横比",
"imageAspectRatio_description": "如果启用,封面艺术将使用其原生纵横比显示。对于不是1:1的艺术,剩余的空间将是空的"
},
"error": {
"remotePortWarning": "重启服务器使新端口生效",
@@ -498,7 +503,8 @@
"addLast": "$t(player.addLast)",
"addFavorite": "$t(action.addToFavorites)",
"showDetails": "获取信息",
"shareItem": "分享项目"
"shareItem": "分享项目",
"playSimilarSongs": "$t(player.playSimilarSongs)"
},
"trackList": {
"title": "$t(entity.track_other)",
@@ -639,7 +645,8 @@
"year": "$t(common.year)",
"albumArtist": "$t(entity.albumArtist_one)",
"titleCombined": "$t(common.title)(合并)",
"codec": "$t(common.codec)"
"codec": "$t(common.codec)",
"songCount": "$t(entity.track_other)"
}
},
"column": {
+43 -18
View File
@@ -1,4 +1,6 @@
import console from 'console';
import { rm } from 'fs/promises';
import { pid } from 'node:process';
import { app, ipcMain } from 'electron';
import uniq from 'lodash/uniq';
import MpvAPI from 'node-mpv';
@@ -18,6 +20,7 @@ declare module 'node-mpv';
// }
let mpvInstance: MpvAPI | null = null;
const socketPath = isWindows() ? `\\\\.\\pipe\\mpvserver-${pid}` : `/tmp/node-mpv-${pid}.sock`;
const NodeMpvErrorCode = {
0: 'Unable to load file or stream',
@@ -62,7 +65,6 @@ const mpvLog = (
};
const MPV_BINARY_PATH = store.get('mpv_path') as string | undefined;
const isDevelopment = process.env.NODE_ENV === 'development' || process.env.DEBUG_PROD === 'true';
const prefetchPlaylistParams = [
'--prefetch-playlist=no',
@@ -89,14 +91,12 @@ const createMpv = async (data: {
const params = uniq([...DEFAULT_MPV_PARAMETERS(extraParameters), ...(extraParameters || [])]);
const extra = isDevelopment ? '-dev' : '';
const mpv = new MpvAPI(
{
audio_only: true,
auto_restart: false,
binary: binaryPath || MPV_BINARY_PATH || undefined,
socket: isWindows() ? `\\\\.\\pipe\\mpvserver${extra}` : `/tmp/node-mpv${extra}.sock`,
socket: socketPath,
time_update: 1,
},
params,
@@ -149,6 +149,16 @@ export const getMpvInstance = () => {
return mpvInstance;
};
const quit = async () => {
const instance = getMpvInstance();
if (instance) {
await instance.quit();
if (!isWindows()) {
await rm(socketPath);
}
}
};
const setAudioPlayerFallback = (isError: boolean) => {
getMainWindow()?.webContents.send('renderer-player-fallback', isError);
};
@@ -216,7 +226,7 @@ ipcMain.handle(
ipcMain.on('player-quit', async () => {
try {
await getMpvInstance()?.stop();
await getMpvInstance()?.quit();
await quit();
} catch (err: NodeMpvError | any) {
mpvLog({ action: 'Failed to quit mpv' }, err);
} finally {
@@ -412,19 +422,34 @@ ipcMain.handle('player-get-time', async (): Promise<number | undefined> => {
}
});
app.on('before-quit', async () => {
try {
await getMpvInstance()?.stop();
await getMpvInstance()?.quit();
} catch (err: NodeMpvError | any) {
mpvLog({ action: `Failed to cleanly before-quit` }, err);
}
});
enum MpvState {
STARTED,
IN_PROGRESS,
DONE,
}
app.on('window-all-closed', async () => {
try {
await getMpvInstance()?.quit();
} catch (err: NodeMpvError | any) {
mpvLog({ action: `Failed to cleanly exit` }, err);
let mpvState = MpvState.STARTED;
app.on('before-quit', async (event) => {
switch (mpvState) {
case MpvState.DONE:
return;
case MpvState.IN_PROGRESS:
event.preventDefault();
break;
case MpvState.STARTED: {
try {
mpvState = MpvState.IN_PROGRESS;
event.preventDefault();
await getMpvInstance()?.stop();
await quit();
} catch (err: NodeMpvError | any) {
mpvLog({ action: `Failed to cleanly before-quit` }, err);
} finally {
mpvState = MpvState.DONE;
app.quit();
}
break;
}
}
});
+37 -48
View File
@@ -8,9 +8,10 @@ import { app, ipcMain } from 'electron';
import { Server as WsServer, WebSocketServer, WebSocket } from 'ws';
import manifest from './manifest.json';
import { ClientEvent, ServerEvent } from '../../../../remote/types';
import { PlayerRepeat, SongUpdate } from '../../../../renderer/types';
import { PlayerRepeat, PlayerStatus, SongState } from '../../../../renderer/types';
import { getMainWindow } from '../../../main';
import { isLinux } from '../../../utils';
import type { QueueSong } from '/@/renderer/api/types';
let mprisPlayer: any | undefined;
@@ -100,9 +101,7 @@ enum Encoding {
const GZIP_REGEX = /\bgzip\b/;
const ZLIB_REGEX = /bdeflate\b/;
let currentSong: SongUpdate = {
currentTime: 0,
};
const currentState: SongState = {};
const getEncoding = (encoding: string | string[]): Encoding => {
const encodingArray = Array.isArray(encoding) ? encoding : [encoding];
@@ -388,7 +387,7 @@ const enableServer = (config: RemoteConfig): Promise<void> => {
break;
}
case 'proxy': {
const toFetch = currentSong.song?.imageUrl?.replaceAll(
const toFetch = currentState.song?.imageUrl?.replaceAll(
/&(size|width|height=\d+)/g,
'',
);
@@ -438,9 +437,9 @@ const enableServer = (config: RemoteConfig): Promise<void> => {
volume = 0;
}
currentSong.volume = volume;
currentState.volume = volume;
broadcast({ data: { volume }, event: 'song' });
broadcast({ data: volume, event: 'volume' });
getMainWindow()?.webContents.send('request-volume', {
volume,
});
@@ -452,22 +451,22 @@ const enableServer = (config: RemoteConfig): Promise<void> => {
}
case 'favorite': {
const { favorite, id } = json;
if (id && id === currentSong.song?.id) {
if (id && id === currentState.song?.id) {
getMainWindow()?.webContents.send('request-favorite', {
favorite,
id,
serverId: currentSong.song.serverId,
serverId: currentState.song.serverId,
});
}
break;
}
case 'rating': {
const { rating, id } = json;
if (id && id === currentSong.song?.id) {
if (id && id === currentState.song?.id) {
getMainWindow()?.webContents.send('request-rating', {
id,
rating,
serverId: currentSong.song.serverId,
serverId: currentState.song.serverId,
});
}
break;
@@ -482,7 +481,7 @@ const enableServer = (config: RemoteConfig): Promise<void> => {
ws.alive = true;
});
ws.send(JSON.stringify({ data: currentSong, event: 'song' }));
ws.send(JSON.stringify({ data: currentState, event: 'state' }));
});
const heartBeat = setInterval(() => {
@@ -564,13 +563,13 @@ ipcMain.on('remote-username', (_event, username: string) => {
});
ipcMain.on('update-favorite', (_event, favorite: boolean, serverId: string, ids: string[]) => {
if (currentSong.song?.serverId !== serverId) return;
if (currentState.song?.serverId !== serverId) return;
const id = currentSong.song.id;
const id = currentState.song.id;
for (const songId of ids) {
if (songId === id) {
currentSong.song.userFavorite = favorite;
currentState.song.userFavorite = favorite;
broadcast({ data: { favorite, id: songId }, event: 'favorite' });
return;
}
@@ -578,13 +577,13 @@ ipcMain.on('update-favorite', (_event, favorite: boolean, serverId: string, ids:
});
ipcMain.on('update-rating', (_event, rating: number, serverId: string, ids: string[]) => {
if (currentSong.song?.serverId !== serverId) return;
if (currentState.song?.serverId !== serverId) return;
const id = currentSong.song.id;
const id = currentState.song.id;
for (const songId of ids) {
if (songId === id) {
currentSong.song.userRating = rating;
currentState.song.userRating = rating;
broadcast({ data: { id: songId, rating }, event: 'rating' });
return;
}
@@ -592,42 +591,32 @@ ipcMain.on('update-rating', (_event, rating: number, serverId: string, ids: stri
});
ipcMain.on('update-repeat', (_event, repeat: PlayerRepeat) => {
currentSong.repeat = repeat;
broadcast({ data: { repeat }, event: 'song' });
currentState.repeat = repeat;
broadcast({ data: repeat, event: 'repeat' });
});
ipcMain.on('update-shuffle', (_event, shuffle: boolean) => {
currentSong.shuffle = shuffle;
broadcast({ data: { shuffle }, event: 'song' });
currentState.shuffle = shuffle;
broadcast({ data: shuffle, event: 'shuffle' });
});
ipcMain.on('update-song', (_event, data: SongUpdate) => {
const { song, ...rest } = data;
const songChanged = song?.id !== currentSong.song?.id;
ipcMain.on('update-playback', (_event, status: PlayerStatus) => {
currentState.status = status;
broadcast({ data: status, event: 'playback' });
});
if (!song?.id) {
currentSong = {
...currentSong,
...data,
song: undefined,
};
} else {
currentSong = {
...currentSong,
...data,
};
}
ipcMain.on('update-song', (_event, song: QueueSong | undefined) => {
const songChanged = song?.id !== currentState.song?.id;
currentState.song = song;
if (songChanged) {
broadcast({ data: { ...rest, song: song || null }, event: 'song' });
} else {
broadcast({ data: rest, event: 'song' });
broadcast({ data: song || null, event: 'song' });
}
});
ipcMain.on('update-volume', (_event, volume: number) => {
currentSong.volume = volume;
broadcast({ data: { volume }, event: 'song' });
currentState.volume = volume;
broadcast({ data: volume, event: 'volume' });
});
if (mprisPlayer) {
@@ -639,13 +628,13 @@ if (mprisPlayer) {
? PlayerRepeat.ONE
: PlayerRepeat.NONE;
currentSong.repeat = repeat;
broadcast({ data: { repeat }, event: 'song' });
currentState.repeat = repeat;
broadcast({ data: repeat, event: 'repeat' });
});
mprisPlayer.on('shuffle', (shuffle: boolean) => {
currentSong.shuffle = shuffle;
broadcast({ data: { shuffle }, event: 'song' });
currentState.shuffle = shuffle;
broadcast({ data: shuffle, event: 'shuffle' });
});
mprisPlayer.on('volume', (vol: number) => {
@@ -656,7 +645,7 @@ if (mprisPlayer) {
} else if (volume < 0) {
volume = 0;
}
currentSong.volume = volume;
broadcast({ data: { volume }, event: 'song' });
currentState.volume = volume;
broadcast({ data: volume, event: 'volume' });
});
}
+8 -15
View File
@@ -1,7 +1,8 @@
import { ipcMain } from 'electron';
import Player from 'mpris-service';
import { PlayerRepeat, PlayerStatus, SongUpdate } from '../../../renderer/types';
import { PlayerRepeat, PlayerStatus } from '../../../renderer/types';
import { getMainWindow } from '../../main';
import { QueueSong } from '/@/renderer/api/types';
const mprisPlayer = Player({
identity: 'Feishin',
@@ -117,6 +118,10 @@ ipcMain.on('update-volume', (_event, volume) => {
mprisPlayer.volume = Number(volume) / 100;
});
ipcMain.on('update-playback', (_event, status: PlayerStatus) => {
mprisPlayer.playbackStatus = status === PlayerStatus.PLAYING ? 'Playing' : 'Paused';
});
const REPEAT_TO_MPRIS: Record<PlayerRepeat, string> = {
[PlayerRepeat.ALL]: 'Playlist',
[PlayerRepeat.ONE]: 'Track',
@@ -131,21 +136,9 @@ ipcMain.on('update-shuffle', (_event, shuffle: boolean) => {
mprisPlayer.shuffle = shuffle;
});
ipcMain.on('update-song', (_event, args: SongUpdate) => {
const { song, status, repeat, shuffle } = args || {};
ipcMain.on('update-song', (_event, song: QueueSong | undefined) => {
try {
mprisPlayer.playbackStatus = status === PlayerStatus.PLAYING ? 'Playing' : 'Paused';
if (repeat) {
mprisPlayer.loopStatus = REPEAT_TO_MPRIS[repeat];
}
if (shuffle) {
mprisPlayer.shuffle = shuffle;
}
if (!song) {
if (!song?.id) {
mprisPlayer.metadata = {};
return;
}
+24 -19
View File
@@ -67,8 +67,8 @@ if (store.get('ignore_ssl')) {
// From https://github.com/tutao/tutanota/commit/92c6ed27625fcf367f0fbcc755d83d7ff8fde94b
if (isLinux() && !process.argv.some((a) => a.startsWith('--password-store='))) {
const paswordStore = store.get('password_store', 'gnome-libsecret') as string;
app.commandLine.appendSwitch('password-store', paswordStore);
const passwordStore = store.get('password_store', 'gnome-libsecret') as string;
app.commandLine.appendSwitch('password-store', passwordStore);
}
let mainWindow: BrowserWindow | null = null;
@@ -199,7 +199,7 @@ const createTray = () => {
},
]);
tray.on('double-click', () => {
tray.on('click', () => {
mainWindow?.show();
createWinThumbarButtons();
});
@@ -210,7 +210,7 @@ const createTray = () => {
const createWindow = async (first = true) => {
if (isDevelopment) {
await installExtensions();
await installExtensions().catch(console.log);
}
const nativeFrame = store.get('window_window_bar_style') === 'linux';
@@ -364,20 +364,6 @@ 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) {
@@ -487,7 +473,7 @@ const createWindow = async (first = true) => {
const menuBuilder = new MenuBuilder(mainWindow);
menuBuilder.buildMenu();
// Open urls in the user's browser
// Open URLs in the user's browser
mainWindow.webContents.setWindowOpenHandler((edata) => {
shell.openExternal(edata.url);
return { action: 'deny' };
@@ -629,6 +615,8 @@ if (!singleInstance) {
if (mainWindow) {
if (mainWindow.isMinimized()) {
mainWindow.restore();
} else if (!mainWindow.isVisible()) {
mainWindow.show();
}
mainWindow.focus();
@@ -668,3 +656,20 @@ if (!singleInstance) {
})
.catch(console.log);
}
// Register 'open-item' handler globally, ensuring it is only registered once
if (!ipcMain.eventNames().includes('open-item')) {
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();
});
});
});
}
+8 -2
View File
@@ -1,5 +1,6 @@
import { IpcRendererEvent, ipcRenderer } from 'electron';
import { SongUpdate } from '/@/renderer/types';
import { QueueSong } from '/@/renderer/api/types';
import { PlayerStatus } from '/@/renderer/types';
const requestFavorite = (
cb: (
@@ -46,6 +47,10 @@ const updatePassword = (password: string) => {
ipcRenderer.send('remote-password', password);
};
const updatePlayback = (playback: PlayerStatus) => {
ipcRenderer.send('update-playback', playback);
};
const updateSetting = (
enabled: boolean,
port: number,
@@ -67,7 +72,7 @@ const updateShuffle = (shuffle: boolean) => {
ipcRenderer.send('update-shuffle', shuffle);
};
const updateSong = (args: SongUpdate) => {
const updateSong = (args: QueueSong | undefined) => {
ipcRenderer.send('update-song', args);
};
@@ -89,6 +94,7 @@ export const remote = {
setRemotePort,
updateFavorite,
updatePassword,
updatePlayback,
updateRating,
updateRepeat,
updateSetting,
+8 -8
View File
@@ -18,7 +18,7 @@ import {
import { PlayerRepeat, PlayerStatus } from '/@/renderer/types';
import { WrapperSlider } from '/@/remote/components/wrapped-slider';
import { Tooltip } from '/@/renderer/components/tooltip';
import { Rating } from '/@/renderer/components';
import { Rating } from '/@/renderer/components/rating';
export const RemoteContainer = () => {
const { repeat, shuffle, song, status, volume } = useInfo();
@@ -38,7 +38,7 @@ export const RemoteContainer = () => {
return (
<>
{song && (
{id && (
<>
<Title order={1}>{song.name}</Title>
<Group align="flex-end">
@@ -61,7 +61,7 @@ export const RemoteContainer = () => {
spacing={0}
>
<RemoteButton
disabled={!song}
disabled={!id}
tooltip="Previous track"
variant="default"
onClick={() => send({ event: 'previous' })}
@@ -69,8 +69,8 @@ export const RemoteContainer = () => {
<RiSkipBackFill size={25} />
</RemoteButton>
<RemoteButton
disabled={!song}
tooltip={song && status === PlayerStatus.PLAYING ? 'Pause' : 'Play'}
disabled={!id}
tooltip={id && status === PlayerStatus.PLAYING ? 'Pause' : 'Play'}
variant="default"
onClick={() => {
if (status === PlayerStatus.PLAYING) {
@@ -80,14 +80,14 @@ export const RemoteContainer = () => {
}
}}
>
{song && status === PlayerStatus.PLAYING ? (
{id && status === PlayerStatus.PLAYING ? (
<RiPauseFill size={25} />
) : (
<RiPlayFill size={25} />
)}
</RemoteButton>
<RemoteButton
disabled={!song}
disabled={!id}
tooltip="Next track"
variant="default"
onClick={() => send({ event: 'next' })}
@@ -127,7 +127,7 @@ export const RemoteContainer = () => {
</RemoteButton>
<RemoteButton
$active={song?.userFavorite}
disabled={!song}
disabled={!id}
tooltip={song?.userFavorite ? 'Unfavorite' : 'Favorite'}
variant="default"
onClick={() => {
+36 -7
View File
@@ -4,7 +4,7 @@ import merge from 'lodash/merge';
import { create } from 'zustand';
import { devtools, persist } from 'zustand/middleware';
import { immer } from 'zustand/middleware/immer';
import { ClientEvent, ServerEvent, SongUpdateSocket } from '/@/remote/types';
import type { ClientEvent, ServerEvent, SongUpdateSocket } from '/@/remote/types';
interface StatefulWebSocket extends WebSocket {
natural: boolean;
@@ -133,6 +133,12 @@ export const useRemoteStore = create<SettingsSlice>()(
});
break;
}
case 'playback': {
set((state) => {
state.info.status = data;
});
break;
}
case 'proxy': {
set((state) => {
if (state.info.song) {
@@ -149,9 +155,34 @@ export const useRemoteStore = create<SettingsSlice>()(
});
break;
}
case 'repeat': {
set((state) => {
state.info.repeat = data;
});
break;
}
case 'shuffle': {
set((state) => {
state.info.shuffle = data;
});
break;
}
case 'song': {
set((nested) => {
nested.info = { ...nested.info, ...data };
set((state) => {
console.log(data);
state.info.song = data;
});
break;
}
case 'state': {
set((state) => {
state.info = data;
});
break;
}
case 'volume': {
set((state) => {
state.info.volume = data;
});
}
}
@@ -212,11 +243,9 @@ export const useRemoteStore = create<SettingsSlice>()(
{ name: 'store_settings' },
),
{
merge: (persistedState, currentState) => {
return merge(currentState, persistedState);
},
merge: (persistedState, currentState) => merge(currentState, persistedState),
name: 'store_settings',
version: 6,
version: 7,
},
),
);
+39 -4
View File
@@ -1,7 +1,7 @@
import type { QueueSong } from '/@/renderer/api/types';
import type { SongUpdate } from '/@/renderer/types';
import type { PlayerRepeat, PlayerStatus, SongState } from '/@/renderer/types';
export interface SongUpdateSocket extends Omit<SongUpdate, 'song'> {
export interface SongUpdateSocket extends Omit<SongState, 'song'> {
song?: QueueSong | null;
}
@@ -15,6 +15,11 @@ export interface ServerFavorite {
event: 'favorite';
}
export interface ServerPlayStatus {
data: PlayerStatus;
event: 'playback';
}
export interface ServerProxy {
data: string;
event: 'proxy';
@@ -25,12 +30,42 @@ export interface ServerRating {
event: 'rating';
}
export interface ServerRepeat {
data: PlayerRepeat;
event: 'repeat';
}
export interface ServerShuffle {
data: boolean;
event: 'shuffle';
}
export interface ServerSong {
data: SongUpdateSocket;
data: QueueSong | null;
event: 'song';
}
export type ServerEvent = ServerError | ServerFavorite | ServerRating | ServerSong | ServerProxy;
export interface ServerState {
data: SongState;
event: 'state';
}
export interface ServerVolume {
data: number;
event: 'volume';
}
export type ServerEvent =
| ServerError
| ServerFavorite
| ServerPlayStatus
| ServerRating
| ServerRepeat
| ServerShuffle
| ServerSong
| ServerState
| ServerProxy
| ServerVolume;
export interface ClientSimpleEvent {
event: 'next' | 'pause' | 'play' | 'previous' | 'proxy' | 'repeat' | 'shuffle';
+11 -5
View File
@@ -6,8 +6,9 @@ import qs from 'qs';
import { ServerListItem } from '/@/renderer/api/types';
import omitBy from 'lodash/omitBy';
import { z } from 'zod';
import { authenticationFailure } from '/@/renderer/api/utils';
import { authenticationFailure, getClientType } from '/@/renderer/api/utils';
import i18n from '/@/i18n/i18n';
import packageJson from '../../../../package.json';
const c = initContract();
@@ -24,9 +25,6 @@ export const contract = c.router({
},
authenticate: {
body: jfType._parameters.authenticate,
headers: z.object({
'X-Emby-Authorization': z.string(),
}),
method: 'POST',
path: 'users/authenticatebyname',
responses: {
@@ -333,6 +331,12 @@ const parsePath = (fullPath: string) => {
};
};
export const createAuthHeader = (): string => {
return `MediaBrowser Client="Feishin", Device="${getClientType()}", DeviceId="${
useAuthStore.getState().deviceId
}", Version="${packageJson.version}"`;
};
export const jfApiClient = (args: {
server: ServerListItem | null;
signal?: AbortSignal;
@@ -359,7 +363,9 @@ export const jfApiClient = (args: {
data: body,
headers: {
...headers,
...(token && { 'X-MediaBrowser-Token': token }),
...(token
? { Authorization: createAuthHeader().concat(`, Token="${token}"`) }
: { Authorization: createAuthHeader() }),
},
method: method as Method,
params,
@@ -57,10 +57,8 @@ import {
import { jfApiClient } from '/@/renderer/api/jellyfin/jellyfin-api';
import { jfNormalize } from './jellyfin-normalize';
import { jfType } from '/@/renderer/api/jellyfin/jellyfin-types';
import packageJson from '../../../../package.json';
import { z } from 'zod';
import { JFSongListSort, JFSortOrder } from '/@/renderer/api/jellyfin.types';
import isElectron from 'is-electron';
import { ServerFeature } from '/@/renderer/api/features-types';
import { VersionInfo, getFeatures } from '/@/renderer/api/utils';
import chunk from 'lodash/chunk';
@@ -69,31 +67,6 @@ const formatCommaDelimitedString = (value: string[]) => {
return value.join(',');
};
function getHostname(): string {
if (isElectron()) {
return 'Desktop Client';
}
const agent = navigator.userAgent;
switch (true) {
case agent.toLowerCase().indexOf('edge') > -1:
return 'Microsoft Edge';
case agent.toLowerCase().indexOf('edg/') > -1:
return 'Edge Chromium'; // Match also / to avoid matching for the older Edge
case agent.toLowerCase().indexOf('opr') > -1:
return 'Opera';
case agent.toLowerCase().indexOf('chrome') > -1:
return 'Chrome';
case agent.toLowerCase().indexOf('trident') > -1:
return 'Internet Explorer';
case agent.toLowerCase().indexOf('firefox') > -1:
return 'Firefox';
case agent.toLowerCase().indexOf('safari') > -1:
return 'Safari';
default:
return 'PC';
}
}
const authenticate = async (
url: string,
body: {
@@ -108,11 +81,6 @@ const authenticate = async (
Pw: body.password,
Username: body.username,
},
headers: {
'x-emby-authorization': `MediaBrowser Client="Feishin", Device="${getHostname()}", DeviceId="Feishin-${getHostname()}-${encodeURIComponent(
body.username,
)}", Version="${packageJson.version}"`,
},
});
if (res.status !== 200) {
@@ -30,7 +30,7 @@ const getStreamUrl = (args: {
`?userId=${server?.userId}` +
`&deviceId=${deviceId}` +
'&audioCodec=aac' +
`&api_key=${server?.credential}` +
`&apiKey=${server?.credential}` +
`&playSessionId=${deviceId}` +
'&container=opus,mp3,aac,m4a,m4b,flac,wav,ogg' +
'&transcodingContainer=ts' +
@@ -193,7 +193,16 @@ const getAlbumArtistList = async (args: AlbumArtistListArgs): Promise<AlbumArtis
return {
items: res.body.data.map((albumArtist) =>
ndNormalize.albumArtist(albumArtist, apiClientProps.server),
// Navidrome native API will return only external URL small/medium/large
// image URL. Set large image to undefined to force `albumArtist` to use
// /rest/getCoverArt.view?id=ar-...
ndNormalize.albumArtist(
{
...albumArtist,
largeImageUrl: undefined,
},
apiClientProps.server,
),
),
startIndex: query.startIndex,
totalRecordCount: Number(res.body.headers.get('x-total-count') || 0),
+26
View File
@@ -1,4 +1,5 @@
import { AxiosHeaders } from 'axios';
import isElectron from 'is-electron';
import semverCoerce from 'semver/functions/coerce';
import semverGte from 'semver/functions/gte';
import { z } from 'zod';
@@ -99,4 +100,29 @@ export const getFeatures = (
return features;
};
export const getClientType = (): string => {
if (isElectron()) {
return 'Desktop Client';
}
const agent = navigator.userAgent;
switch (true) {
case agent.toLowerCase().indexOf('edge') > -1:
return 'Microsoft Edge';
case agent.toLowerCase().indexOf('edg/') > -1:
return 'Edge Chromium'; // Match also / to avoid matching for the older Edge
case agent.toLowerCase().indexOf('opr') > -1:
return 'Opera';
case agent.toLowerCase().indexOf('chrome') > -1:
return 'Chrome';
case agent.toLowerCase().indexOf('trident') > -1:
return 'Internet Explorer';
case agent.toLowerCase().indexOf('firefox') > -1:
return 'Firefox';
case agent.toLowerCase().indexOf('safari') > -1:
return 'Safari';
default:
return 'PC';
}
};
export const SEPARATOR_STRING = ' · ';
+6
View File
@@ -40,6 +40,7 @@ export const App = () => {
const theme = useTheme();
const accent = useSettingsStore((store) => store.general.accent);
const language = useSettingsStore((store) => store.general.language);
const nativeImageAspect = useSettingsStore((store) => store.general.nativeAspectRatio);
const { builtIn, custom, system, type } = useSettingsStore((state) => state.font);
const { type: playbackType } = usePlaybackSettings();
const { bindings } = useHotkeySettings();
@@ -90,6 +91,11 @@ export const App = () => {
root.style.setProperty('--primary-color', accent);
}, [accent]);
useEffect(() => {
const root = document.documentElement;
root.style.setProperty('--image-fit', nativeImageAspect ? 'contain' : 'cover');
}, [nativeImageAspect]);
const providerValue = useMemo(() => {
return { handlePlayQueueAdd };
}, [handlePlayQueueAdd]);
@@ -40,13 +40,13 @@ type WebAudio = {
gain: GainNode;
};
// Credits: http://stackoverflow.com/questions/12150729/ddg
// Credits: https://gist.github.com/novwhisky/8a1a0168b94f3b6abfaa?permalink_comment_id=1551393#gistcomment-1551393
// This is used so that the player will always have an <audio> element. This means that
// player1Source and player2Source are connected BEFORE the user presses play for
// the first time. This workaround is important for Safari, which seems to require the
// source to be connected PRIOR to resuming audio context
const EMPTY_SOURCE =
'data:audio/wav;base64,UklGRjIAAABXQVZFZm10IBIAAAABAAEAQB8AAEAfAAABAAgAAABmYWN0BAAAAAAAAABkYXRhAAAAAA==';
'data:audio/mp3;base64,SUQzBAAAAAAAI1RTU0UAAAAPAAADTGF2ZjU2LjM2LjEwMAAAAAAAAAAAAAAA//OEAAAAAAAAAAAAAAAAAAAAAAAASW5mbwAAAA8AAAAEAAABIADAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDV1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV6urq6urq6urq6urq6urq6urq6urq6urq6v////////////////////////////////8AAAAATGF2YzU2LjQxAAAAAAAAAAAAAAAAJAAAAAAAAAAAASDs90hvAAAAAAAAAAAAAAAAAAAA//MUZAAAAAGkAAAAAAAAA0gAAAAATEFN//MUZAMAAAGkAAAAAAAAA0gAAAAARTMu//MUZAYAAAGkAAAAAAAAA0gAAAAAOTku//MUZAkAAAGkAAAAAAAAA0gAAAAANVVV';
export const AudioPlayer = forwardRef(
(
+23 -7
View File
@@ -1,4 +1,5 @@
import React from 'react';
import formatDuration from 'format-duration';
import { generatePath } from 'react-router';
import { Link } from 'react-router-dom';
import styled from 'styled-components';
@@ -6,6 +7,7 @@ import { Album, AlbumArtist, Artist, Playlist, Song } from '/@/renderer/api/type
import { Text } from '/@/renderer/components/text';
import { AppRoute } from '/@/renderer/router/routes';
import { CardRow } from '/@/renderer/types';
import { formatDateAbsolute, formatDateRelative, formatRating } from '/@/renderer/utils/format';
const Row = styled.div<{ $secondary?: boolean }>`
width: 100%;
@@ -69,7 +71,10 @@ export const CardRows = ({ data, rows }: CardRowsProps) => {
)}
onClick={(e) => e.stopPropagation()}
>
{row.arrayProperty && item[row.arrayProperty]}
{row.arrayProperty &&
(row.format
? row.format(item)
: item[row.arrayProperty])}
</Text>
</React.Fragment>
))}
@@ -88,7 +93,8 @@ export const CardRows = ({ data, rows }: CardRowsProps) => {
overflow="hidden"
size={index > 0 ? 'sm' : 'md'}
>
{row.arrayProperty && item[row.arrayProperty]}
{row.arrayProperty &&
(row.format ? row.format(item) : item[row.arrayProperty])}
</Text>
))}
</Row>
@@ -114,7 +120,7 @@ export const CardRows = ({ data, rows }: CardRowsProps) => {
)}
onClick={(e) => e.stopPropagation()}
>
{data && data[row.property]}
{data && (row.format ? row.format(data) : data[row.property])}
</Text>
) : (
<Text
@@ -123,7 +129,7 @@ export const CardRows = ({ data, rows }: CardRowsProps) => {
overflow="hidden"
size={index > 0 ? 'sm' : 'md'}
>
{data && data[row.property]}
{data && (row.format ? row.format(data) : data[row.property])}
</Text>
)}
</Row>
@@ -151,12 +157,15 @@ export const ALBUM_CARD_ROWS: { [key: string]: CardRow<Album> } = {
},
},
createdAt: {
format: (song) => formatDateAbsolute(song.createdAt),
property: 'createdAt',
},
duration: {
format: (album) => (album.duration === null ? null : formatDuration(album.duration)),
property: 'duration',
},
lastPlayedAt: {
format: (album) => formatDateRelative(album.lastPlayedAt),
property: 'lastPlayedAt',
},
name: {
@@ -170,6 +179,7 @@ export const ALBUM_CARD_ROWS: { [key: string]: CardRow<Album> } = {
property: 'playCount',
},
rating: {
format: (album) => formatRating(album),
property: 'userRating',
},
releaseDate: {
@@ -208,12 +218,15 @@ export const SONG_CARD_ROWS: { [key: string]: CardRow<Song> } = {
},
},
createdAt: {
format: (song) => formatDateAbsolute(song.createdAt),
property: 'createdAt',
},
duration: {
format: (song) => (song.duration === null ? null : formatDuration(song.duration)),
property: 'duration',
},
lastPlayedAt: {
format: (song) => formatDateRelative(song.lastPlayedAt),
property: 'lastPlayedAt',
},
name: {
@@ -227,6 +240,7 @@ export const SONG_CARD_ROWS: { [key: string]: CardRow<Song> } = {
property: 'playCount',
},
rating: {
format: (song) => formatRating(song),
property: 'userRating',
},
releaseDate: {
@@ -242,12 +256,14 @@ export const ALBUMARTIST_CARD_ROWS: { [key: string]: CardRow<AlbumArtist> } = {
property: 'albumCount',
},
duration: {
format: (artist) => (artist.duration === null ? null : formatDuration(artist.duration)),
property: 'duration',
},
genres: {
property: 'genres',
},
lastPlayedAt: {
format: (artist) => formatDateRelative(artist.lastPlayedAt),
property: 'lastPlayedAt',
},
name: {
@@ -261,6 +277,7 @@ export const ALBUMARTIST_CARD_ROWS: { [key: string]: CardRow<AlbumArtist> } = {
property: 'playCount',
},
rating: {
format: (artist) => formatRating(artist),
property: 'userRating',
},
songCount: {
@@ -270,6 +287,8 @@ export const ALBUMARTIST_CARD_ROWS: { [key: string]: CardRow<AlbumArtist> } = {
export const PLAYLIST_CARD_ROWS: { [key: string]: CardRow<Playlist> } = {
duration: {
format: (playlist) =>
playlist.duration === null ? null : formatDuration(playlist.duration),
property: 'duration',
},
name: {
@@ -295,7 +314,4 @@ export const PLAYLIST_CARD_ROWS: { [key: string]: CardRow<Playlist> } = {
songCount: {
property: 'songCount',
},
updatedAt: {
property: 'songCount',
},
};
+1 -1
View File
@@ -92,7 +92,7 @@ const Image = styled(SimpleImg)`
img {
height: 100%;
object-fit: cover;
object-fit: var(--image-fit);
}
`;
@@ -63,7 +63,7 @@ const BackgroundImage = styled.img`
height: 150%;
user-select: none;
filter: blur(24px);
object-fit: cover;
object-fit: var(--image-fit);
object-position: 0 30%;
`;
@@ -122,7 +122,7 @@ const Image = styled(SimpleImg)`
img {
height: 100%;
object-fit: cover;
object-fit: var(--image-fit);
}
`;
@@ -110,7 +110,7 @@ const Image = styled(SimpleImg)`
img {
height: 100%;
object-fit: cover;
object-fit: var(--image-fit);
}
`;
@@ -13,13 +13,15 @@ import InfiniteLoader from 'react-window-infinite-loader';
import { VirtualGridWrapper } from '/@/renderer/components/virtual-grid/virtual-grid-wrapper';
import type { CardRoute, CardRow, PlayQueueAddOptions } from '/@/renderer/types';
import { ListDisplayType } from '/@/renderer/types';
import { LibraryItem } from '/@/renderer/api/types';
import { AnyLibraryItem, Genre, LibraryItem } from '/@/renderer/api/types';
type LibraryItemOrGenre = AnyLibraryItem | Genre;
export type VirtualInfiniteGridRef = {
resetLoadMoreItemsCache: () => void;
scrollTo: (index: number) => void;
setItemData: (data: any[]) => void;
updateItemData: (rule: (item: any) => any) => void;
setItemData: (data: LibraryItemOrGenre[]) => void;
updateItemData: (rule: (item: LibraryItemOrGenre) => LibraryItemOrGenre) => void;
};
interface VirtualGridProps
@@ -27,7 +29,7 @@ interface VirtualGridProps
cardRows: CardRow<any>[];
display?: ListDisplayType;
fetchFn: (options: { columnCount: number; skip: number; take: number }) => Promise<any>;
fetchInitialData?: () => any;
fetchInitialData?: () => LibraryItemOrGenre[];
handleFavorite?: (options: {
id: string[];
isFavorite: boolean;
@@ -70,7 +72,10 @@ export const VirtualInfiniteGrid = forwardRef(
const listRef = useRef<any>(null);
const loader = useRef<InfiniteLoader>(null);
const [itemData, setItemData] = useState<any[]>(fetchInitialData?.() || []);
// itemData can be a sparse array. Treat the intermediate elements as being undefined
const [itemData, setItemData] = useState<Array<LibraryItemOrGenre | undefined>>(
fetchInitialData?.() || [],
);
const { itemHeight, rowCount, columnCount } = useMemo(() => {
const itemsPerRow = width ? Math.floor(width / (itemSize + itemGap * 2)) : 5;
@@ -109,7 +114,7 @@ export const VirtualInfiniteGrid = forwardRef(
});
setItemData((itemData) => {
const newData: any[] = [...itemData];
const newData = [...itemData];
let itemIndex = 0;
for (let rowIndex = start; rowIndex < itemCount; rowIndex += 1) {
@@ -135,11 +140,11 @@ export const VirtualInfiniteGrid = forwardRef(
scrollTo: (index: number) => {
listRef?.current?.scrollToItem(index);
},
setItemData: (data: any[]) => {
setItemData: (data: LibraryItemOrGenre[]) => {
setItemData(data);
},
updateItemData: (rule) => {
setItemData((data) => data.map(rule));
setItemData((data) => data.map((item) => item && rule(item)));
},
}));
@@ -44,7 +44,7 @@ const MetadataWrapper = styled.div`
const StyledImage = styled(SimpleImg)`
img {
object-fit: cover;
object-fit: var(--image-fit);
}
`;
@@ -15,8 +15,6 @@ import type { AgGridReactProps } from '@ag-grid-community/react';
import { AgGridReact } from '@ag-grid-community/react';
import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/lib/agGridReact';
import { useClickOutside, useMergedRef } from '@mantine/hooks';
import dayjs from 'dayjs';
import relativeTime from 'dayjs/plugin/relativeTime';
import formatDuration from 'format-duration';
import { AnimatePresence } from 'framer-motion';
import { generatePath } from 'react-router';
@@ -43,7 +41,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';
import { formatDateAbsolute, formatDateRelative, formatSizeString } from '/@/renderer/utils/format';
export * from './table-config-dropdown';
export * from './table-pagination';
@@ -64,8 +62,6 @@ const DummyHeader = styled.div<{ height?: number }>`
height: ${({ height }) => height || 36}px;
`;
dayjs.extend(relativeTime);
const tableColumns: { [key: string]: ColDef } = {
actions: {
cellClass: 'ag-cell-favorite',
@@ -182,8 +178,7 @@ const tableColumns: { [key: string]: ColDef } = {
GenericTableHeader(params, { position: 'center' }),
headerName: i18n.t('table.column.dateAdded'),
suppressSizeToFit: true,
valueFormatter: (params: ValueFormatterParams) =>
params.value ? dayjs(params.value).format('MMM D, YYYY') : '',
valueFormatter: (params: ValueFormatterParams) => formatDateAbsolute(params.value),
valueGetter: (params: ValueGetterParams) =>
params.data ? params.data.createdAt : undefined,
width: 130,
@@ -225,8 +220,7 @@ const tableColumns: { [key: string]: ColDef } = {
headerComponent: (params: IHeaderParams) =>
GenericTableHeader(params, { position: 'center' }),
headerName: i18n.t('table.column.lastPlayed'),
valueFormatter: (params: ValueFormatterParams) =>
params.value ? dayjs(params.value).fromNow() : '',
valueFormatter: (params: ValueFormatterParams) => formatDateRelative(params.value),
valueGetter: (params: ValueGetterParams) =>
params.data ? params.data.lastPlayedAt : undefined,
width: 130,
@@ -258,8 +252,7 @@ const tableColumns: { [key: string]: ColDef } = {
GenericTableHeader(params, { position: 'center' }),
headerName: i18n.t('table.column.releaseDate'),
suppressSizeToFit: true,
valueFormatter: (params: ValueFormatterParams) =>
params.value ? dayjs(params.value).format('MMM D, YYYY') : '',
valueFormatter: (params: ValueFormatterParams) => formatDateAbsolute(params.value),
valueGetter: (params: ValueGetterParams) =>
params.data ? params.data.releaseDate : undefined,
width: 130,
@@ -99,7 +99,7 @@ export const AlbumArtistDetailContent = ({ background }: AlbumArtistDetailConten
_custom: {
jellyfin: {
...(server?.type === ServerType.JELLYFIN
? { ArtistIds: albumArtistId }
? { AlbumArtistIds: albumArtistId }
: undefined),
},
navidrome: {
@@ -15,7 +15,8 @@ export const QUEUE_CONTEXT_MENU_ITEMS: SetContextMenuItems = [
export const SONG_CONTEXT_MENU_ITEMS: SetContextMenuItems = [
{ id: 'play' },
{ id: 'playLast' },
{ divider: true, id: 'playNext' },
{ id: 'playNext' },
{ divider: true, id: 'playSimilarSongs' },
{ divider: true, id: 'addToPlaylist' },
{ id: 'addToFavorites' },
{ divider: true, id: 'removeFromFavorites' },
@@ -34,7 +35,8 @@ export const SONG_ALBUM_PAGE: SetContextMenuItems = [
export const PLAYLIST_SONG_CONTEXT_MENU_ITEMS: SetContextMenuItems = [
{ id: 'play' },
{ id: 'playLast' },
{ divider: true, id: 'playNext' },
{ id: 'playNext' },
{ divider: true, id: 'playSimilarSongs' },
{ id: 'addToPlaylist' },
{ divider: true, id: 'removeFromPlaylist' },
{ id: 'addToFavorites' },
@@ -46,7 +48,8 @@ export const PLAYLIST_SONG_CONTEXT_MENU_ITEMS: SetContextMenuItems = [
export const SMART_PLAYLIST_SONG_CONTEXT_MENU_ITEMS: SetContextMenuItems = [
{ id: 'play' },
{ id: 'playLast' },
{ divider: true, id: 'playNext' },
{ id: 'playNext' },
{ divider: true, id: 'playSimilarSongs' },
{ divider: true, id: 'addToPlaylist' },
{ id: 'addToFavorites' },
{ divider: true, id: 'removeFromFavorites' },
@@ -29,6 +29,7 @@ import {
RiCloseCircleLine,
RiShareForwardFill,
RiInformationFill,
RiRadio2Fill,
} from 'react-icons/ri';
import { AnyLibraryItems, LibraryItem, ServerType, AnyLibraryItem } from '/@/renderer/api/types';
import {
@@ -50,6 +51,7 @@ import { useDeletePlaylist } from '/@/renderer/features/playlists';
import { useRemoveFromPlaylist } from '/@/renderer/features/playlists/mutations/remove-from-playlist-mutation';
import { useCreateFavorite, useDeleteFavorite, useSetRating } from '/@/renderer/features/shared';
import {
getServerById,
useAuthStore,
useCurrentServer,
usePlayerStore,
@@ -58,6 +60,8 @@ import {
import { usePlaybackType } from '/@/renderer/store/settings.store';
import { Play, PlaybackType } from '/@/renderer/types';
import { ItemDetailsModal } from '/@/renderer/features/item-details/components/item-details-modal';
import { updateSong } from '/@/renderer/features/player/update-remote-song';
import { controller } from '/@/renderer/api/controller';
type ContextMenuContextProps = {
closeContextMenu: () => void;
@@ -86,7 +90,6 @@ const JELLYFIN_IGNORED_MENU_ITEMS: ContextMenuItemType[] = ['setRating', 'shareI
// const SUBSONIC_IGNORED_MENU_ITEMS: ContextMenuItemType[] = [];
const mpvPlayer = isElectron() ? window.electron.mpvPlayer : null;
const remote = isElectron() ? window.electron.remote : null;
export interface ContextMenuProviderProps {
children: ReactNode;
@@ -640,7 +643,7 @@ export const ContextMenuProvider = ({ children }: ContextMenuProviderProps) => {
ctx.tableApi?.redrawRows();
if (isCurrentSongRemoved) {
remote?.updateSong({ song: playerData.current.song });
updateSong(playerData.current.song);
}
}, [ctx.dataNodes, ctx.tableApi, playbackType, removeFromQueue]);
@@ -658,6 +661,18 @@ export const ContextMenuProvider = ({ children }: ContextMenuProviderProps) => {
});
}, [ctx.data, t]);
const handleSimilar = useCallback(async () => {
const item = ctx.data[0];
const songs = await controller.getSimilarSongs({
apiClientProps: {
server: getServerById(item.serverId),
signal: undefined,
},
query: { albumArtistIds: item.albumArtistIds, songId: item.id },
});
handlePlayQueueAdd?.({ byData: [ctx.data[0], ...songs], playType: Play.NOW });
}, [ctx, handlePlayQueueAdd]);
const contextMenuItems: Record<ContextMenuItemType, ContextMenuItem> = useMemo(() => {
return {
addToFavorites: {
@@ -719,6 +734,12 @@ export const ContextMenuProvider = ({ children }: ContextMenuProviderProps) => {
leftIcon: <RiAddCircleFill size="1.1rem" />,
onClick: () => handlePlay(Play.NEXT),
},
playSimilarSongs: {
id: 'playSimilarSongs',
label: t('page.contextMenu.playSimilarSongs', { postProcess: 'sentenceCase' }),
leftIcon: <RiRadio2Fill size="1.1rem" />,
onClick: handleSimilar,
},
removeFromFavorites: {
id: 'removeFromFavorites',
label: t('page.contextMenu.removeFromFavorites', { postProcess: 'sentenceCase' }),
@@ -838,6 +859,7 @@ export const ContextMenuProvider = ({ children }: ContextMenuProviderProps) => {
handleUpdateRating,
handleShareItem,
server,
handleSimilar,
]);
const mergedRef = useMergedRef(ref, clickOutsideRef);
+2 -1
View File
@@ -35,7 +35,8 @@ export type ContextMenuItemType =
| 'moveToTopOfQueue'
| 'removeFromQueue'
| 'deselectAll'
| 'showDetails';
| 'showDetails'
| 'playSimilarSongs';
export type SetContextMenuItems = {
children?: boolean;
@@ -33,11 +33,12 @@ const HomeRoute = () => {
const server = useCurrentServer();
const itemsPerPage = 15;
const { windowBarStyle } = useWindowSettings();
const { homeItems } = useGeneralSettings();
const { homeFeature, homeItems } = useGeneralSettings();
const feature = useAlbumList({
options: {
cacheTime: 1000 * 60,
enabled: homeFeature,
staleTime: 1000 * 60,
},
query: {
@@ -249,7 +250,7 @@ const HomeRoute = () => {
px="2rem"
spacing="lg"
>
<FeatureCarousel data={featureItemsWithImage} />
{homeFeature && <FeatureCarousel data={featureItemsWithImage} />}
{sortedCarousel.map((carousel) => (
<MemoizedSwiperGridCarousel
key={`carousel-${carousel.uniqueId}`}
@@ -1,13 +1,11 @@
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 { formatDurationString, formatSizeString } from '/@/renderer/utils';
import { replaceURLWithHTMLLinks } from '/@/renderer/utils/linkify';
import { Rating, Spoiler, Text } from '/@/renderer/components';
import { 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';
@@ -15,6 +13,7 @@ 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';
import { formatDateRelative, formatRating } from '/@/renderer/utils/format';
export type ItemDetailsModalProps = {
item: Album | AlbumArtist | Song;
@@ -82,8 +81,6 @@ const formatArtists = (isAlbumArtist: boolean) => (item: Album | Song) =>
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();
@@ -104,14 +101,6 @@ const FormatGenre = (item: Album | AlbumArtist | Song) => {
));
};
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" />;
@@ -139,11 +128,11 @@ const AlbumPropertyMapping: ItemDetailRow<Album>[] = [
{ key: 'playCount', label: 'filter.playCount' },
{
label: 'filter.lastPlayed',
render: (song) => formatDate(song.lastPlayedAt),
render: (song) => formatDateRelative(song.lastPlayedAt),
},
{
label: 'common.modified',
render: (song) => formatDate(song.updatedAt),
render: (song) => formatDateRelative(song.updatedAt),
},
{ label: 'filter.comment', render: formatComment },
{
@@ -178,7 +167,7 @@ const AlbumArtistPropertyMapping: ItemDetailRow<AlbumArtist>[] = [
{ key: 'playCount', label: 'filter.playCount' },
{
label: 'filter.lastPlayed',
render: (song) => formatDate(song.lastPlayedAt),
render: (song) => formatDateRelative(song.lastPlayedAt),
},
{
label: 'common.mbid',
@@ -256,11 +245,11 @@ const SongPropertyMapping: ItemDetailRow<Song>[] = [
{ key: 'playCount', label: 'filter.playCount' },
{
label: 'filter.lastPlayed',
render: (song) => formatDate(song.lastPlayedAt),
render: (song) => formatDateRelative(song.lastPlayedAt),
},
{
label: 'common.modified',
render: (song) => formatDate(song.updatedAt),
render: (song) => formatDateRelative(song.updatedAt),
},
{
label: 'common.albumGain',
@@ -26,6 +26,10 @@ const StyledText = styled(TextTitle)<TitleProps & { $alignment: string; $fontSiz
&.unsynchronized {
opacity: 1;
}
&.synchronized {
cursor: pointer;
}
`;
export const LyricLine = ({ text, alignment, fontSize, ...props }: LyricLineProps) => {
@@ -4,6 +4,7 @@ import {
useCurrentTime,
useLyricsSettings,
usePlaybackType,
usePlayerData,
useSeeked,
} from '/@/renderer/store';
import { PlaybackType, PlayerStatus } from '/@/renderer/types';
@@ -12,6 +13,7 @@ import isElectron from 'is-electron';
import { PlayersRef } from '/@/renderer/features/player/ref/players-ref';
import { FullLyricsMetadata, SynchronizedLyricsArray } from '/@/renderer/api/types';
import styled from 'styled-components';
import { useCenterControls } from '/@/renderer/features/player/hooks/use-center-controls';
const mpvPlayer = isElectron() ? window.electron.mpvPlayer : null;
@@ -60,8 +62,10 @@ export const SynchronizedLyrics = ({
const playersRef = PlayersRef;
const status = useCurrentStatus();
const playbackType = usePlaybackType();
const playerData = usePlayerData();
const now = useCurrentTime();
const settings = useLyricsSettings();
const centerControls = useCenterControls({ playersRef });
const seeked = useSeeked();
@@ -107,16 +111,18 @@ export const SynchronizedLyrics = ({
return 0;
}
const player = (
playersRef.current.player1 ?? playersRef.current.player2
).getInternalPlayer();
const player =
playerData.current.player === 1
? playersRef.current.player1
: playersRef.current.player2;
const underlying = player?.getInternalPlayer();
// If it is null, this probably means we added a new song while the lyrics tab is open
// and the queue was previously empty
if (!player) return 0;
if (!underlying) return 0;
return player.currentTime;
}, [playbackType, playersRef]);
return underlying.currentTime;
}, [playbackType, playersRef, playerData]);
const setCurrentLyric = useCallback(
(timeInMs: number, epoch?: number, targetIndex?: number) => {
@@ -331,7 +337,7 @@ export const SynchronizedLyrics = ({
text={`"${name} by ${artist}"`}
/>
)}
{lyrics.map(([, text], idx) => (
{lyrics.map(([time, text], idx) => (
<LyricLine
key={idx}
alignment={settings.alignment}
@@ -339,6 +345,7 @@ export const SynchronizedLyrics = ({
fontSize={settings.fontSize}
id={`lyric-${idx}`}
text={text}
onClick={() => centerControls.handleSeekSlider(time / 1000)}
/>
))}
</SynchronizedLyricsContainer>
@@ -14,13 +14,13 @@ import {
} from 'react-icons/ri';
import { Song } from '/@/renderer/api/types';
import { usePlayerControls, useQueueControls } from '/@/renderer/store';
import { PlaybackType, PlayerStatus, TableType } from '/@/renderer/types';
import { PlaybackType, 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';
import { updateSong } from '/@/renderer/features/player/update-remote-song';
const mpvPlayer = isElectron() ? window.electron.mpvPlayer : null;
const remote = isElectron() ? window.electron.remote : null;
interface PlayQueueListOptionsProps {
tableRef: MutableRefObject<{ grid: AgGridReactType<Song> } | null>;
@@ -79,7 +79,7 @@ export const PlayQueueListControls = ({ type, tableRef }: PlayQueueListOptionsPr
}
if (isCurrentSongRemoved) {
remote?.updateSong({ song: playerData.current.song });
updateSong(playerData.current.song);
}
};
@@ -91,7 +91,7 @@ export const PlayQueueListControls = ({ type, tableRef }: PlayQueueListOptionsPr
mpvPlayer!.pause();
}
remote?.updateSong({ song: undefined, status: PlayerStatus.PAUSED });
updateSong(undefined);
setCurrentTime(0);
pause();
@@ -30,16 +30,16 @@ import debounce from 'lodash/debounce';
import { ErrorBoundary } from 'react-error-boundary';
import { getColumnDefs, VirtualTable } from '/@/renderer/components/virtual-table';
import { ErrorFallback } from '/@/renderer/features/action-required';
import { PlaybackType, PlayerStatus, TableType } from '/@/renderer/types';
import { PlaybackType, TableType } from '/@/renderer/types';
import { LibraryItem, QueueSong } from '/@/renderer/api/types';
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';
import { updateSong } from '/@/renderer/features/player/update-remote-song';
const mpvPlayer = isElectron() ? window.electron.mpvPlayer : null;
const remote = isElectron() ? window.electron.remote : null;
type QueueProps = {
type: TableType;
@@ -82,11 +82,7 @@ export const PlayQueue = forwardRef(({ type }: QueueProps, ref: Ref<any>) => {
const handleDoubleClick = (e: CellDoubleClickedEvent) => {
const playerData = setCurrentTrack(e.data.uniqueId);
remote?.updateSong({
currentTime: 0,
song: playerData.current.song,
status: PlayerStatus.PLAYING,
});
updateSong(playerData.current.song);
if (playbackType === PlaybackType.LOCAL) {
mpvPlayer!.volume(volume);
@@ -62,7 +62,7 @@ const Image = styled(motion.div)`
const PlayerbarImage = styled.img`
width: 100%;
height: 100%;
object-fit: cover;
object-fit: var(--image-fit);
`;
const LineItem = styled.div<{ $secondary?: boolean }>`
@@ -1,5 +1,4 @@
import { useCallback } from 'react';
import isElectron from 'is-electron';
import styled from 'styled-components';
import { usePlaybackType, useSettingsStore } from '/@/renderer/store/settings.store';
import { PlaybackType } from '/@/renderer/types';
@@ -17,6 +16,7 @@ import { CenterControls } from './center-controls';
import { LeftControls } from './left-controls';
import { RightControls } from './right-controls';
import { PlayersRef } from '/@/renderer/features/player/ref/players-ref';
import { updateSong } from '/@/renderer/features/player/update-remote-song';
const PlayerbarContainer = styled.div`
width: 100vw;
@@ -59,8 +59,6 @@ const CenterGridItem = styled.div`
overflow: hidden;
`;
const remote = isElectron() ? window.electron.remote : null;
export const Playerbar = () => {
const playersRef = PlayersRef;
const settings = useSettingsStore((state) => state.playback);
@@ -75,13 +73,7 @@ export const Playerbar = () => {
const autoNextFn = useCallback(() => {
const playerData = autoNext();
if (remote) {
remote.updateSong({
currentTime: 0,
song: playerData.current.song,
});
}
updateSong(playerData.current.song);
}, [autoNext]);
return (
@@ -14,9 +14,9 @@ import {
import { usePlaybackType } from '/@/renderer/store/settings.store';
import { useScrobble } from '/@/renderer/features/player/hooks/use-scrobble';
import debounce from 'lodash/debounce';
import { QueueSong } from '/@/renderer/api/types';
import { toast } from '/@/renderer/components';
import { useTranslation } from 'react-i18next';
import { updateSong } from '/@/renderer/features/player/update-remote-song';
const mpvPlayer = isElectron() ? window.electron.mpvPlayer : null;
const mpvPlayerListener = isElectron() ? window.electron.mpvPlayerListener : null;
@@ -24,7 +24,7 @@ const ipc = isElectron() ? window.electron.ipc : null;
const utils = isElectron() ? window.electron.utils : null;
const mpris = isElectron() && utils?.isLinux() ? window.electron.mpris : null;
const remote = isElectron() ? window.electron.remote : null;
const mediaSession = !isElectron() || !utils?.isLinux() ? navigator.mediaSession : null;
const mediaSession = navigator.mediaSession;
export const useCenterControls = (args: { playersRef: any }) => {
const { t } = useTranslation();
@@ -46,6 +46,23 @@ export const useCenterControls = (args: { playersRef: any }) => {
const { handleScrobbleFromSongRestart, handleScrobbleFromSeek } = useScrobble();
useEffect(() => {
if (mediaSession) {
mediaSession.playbackState =
playerStatus === PlayerStatus.PLAYING ? 'playing' : 'paused';
}
remote?.updatePlayback(playerStatus);
}, [playerStatus]);
useEffect(() => {
remote?.updateRepeat(repeatStatus);
}, [repeatStatus]);
useEffect(() => {
remote?.updateShuffle(shuffleStatus !== PlayerShuffle.NONE);
}, [shuffleStatus]);
const resetPlayers = useCallback(() => {
if (player1Ref.getInternalPlayer()) {
player1Ref.getInternalPlayer().currentTime = 0;
@@ -76,61 +93,7 @@ export const useCenterControls = (args: { playersRef: any }) => {
const isMpvPlayer = isElectron() && playbackType === PlaybackType.LOCAL;
const mprisUpdateSong = (args?: {
currentTime?: number;
song?: QueueSong;
status?: PlayerStatus;
}) => {
const { song, currentTime, status } = args || {};
const time = currentTime || usePlayerStore.getState().current.time;
const playStatus = status || usePlayerStore.getState().current.status;
const track = song || usePlayerStore.getState().current.song;
remote?.updateSong({
currentTime: time,
repeat: usePlayerStore.getState().repeat,
shuffle: usePlayerStore.getState().shuffle !== PlayerShuffle.NONE,
song: track,
status: playStatus,
});
if (mediaSession) {
mediaSession.playbackState = playStatus === PlayerStatus.PLAYING ? 'playing' : 'paused';
let metadata: MediaMetadata;
if (track) {
let artwork: MediaImage[];
if (track.imageUrl) {
const image300 = track.imageUrl
?.replace(/&size=\d+/, '&size=300')
.replace(/\?width=\d+/, '?width=300')
.replace(/&height=\d+/, '&height=300');
artwork = [{ sizes: '300x300', src: image300, type: 'image/png' }];
} else {
artwork = [];
}
metadata = new MediaMetadata({
album: track.album ?? '',
artist: track.artistName,
artwork,
title: track.name,
});
} else {
metadata = new MediaMetadata();
}
mediaSession.metadata = metadata;
}
};
const handlePlay = useCallback(() => {
mprisUpdateSong({ status: PlayerStatus.PLAYING });
if (isMpvPlayer) {
mpvPlayer?.volume(usePlayerStore.getState().volume);
mpvPlayer!.play();
@@ -145,8 +108,6 @@ export const useCenterControls = (args: { playersRef: any }) => {
}, [currentPlayerRef, isMpvPlayer, play]);
const handlePause = useCallback(() => {
mprisUpdateSong({ status: PlayerStatus.PAUSED });
if (isMpvPlayer) {
mpvPlayer!.pause();
}
@@ -155,8 +116,6 @@ export const useCenterControls = (args: { playersRef: any }) => {
}, [isMpvPlayer, pause]);
const handleStop = useCallback(() => {
mprisUpdateSong({ status: PlayerStatus.PAUSED });
if (isMpvPlayer) {
mpvPlayer!.pause();
mpvPlayer!.seekTo(0);
@@ -212,13 +171,13 @@ export const useCenterControls = (args: { playersRef: any }) => {
const handleRepeatAll = {
local: () => {
const playerData = autoNext();
mprisUpdateSong({ song: playerData.current.song, status: PlayerStatus.PLAYING });
updateSong(playerData.current.song);
mpvPlayer!.autoNext(playerData);
play();
},
web: () => {
const playerData = autoNext();
mprisUpdateSong({ song: playerData.current.song, status: PlayerStatus.PLAYING });
updateSong(playerData.current.song);
},
};
@@ -226,15 +185,12 @@ export const useCenterControls = (args: { playersRef: any }) => {
local: () => {
if (isLastTrack) {
const playerData = setCurrentIndex(0);
mprisUpdateSong({ song: playerData.current.song, status: PlayerStatus.PAUSED });
updateSong(playerData.current.song);
mpvPlayer!.setQueue(playerData, true);
pause();
} else {
const playerData = autoNext();
mprisUpdateSong({
song: playerData.current.song,
status: PlayerStatus.PLAYING,
});
updateSong(playerData.current.song);
mpvPlayer!.autoNext(playerData);
play();
}
@@ -242,14 +198,10 @@ export const useCenterControls = (args: { playersRef: any }) => {
web: () => {
if (isLastTrack) {
resetPlayers();
mprisUpdateSong({ status: PlayerStatus.PAUSED });
pause();
} else {
const playerData = autoNext();
mprisUpdateSong({
song: playerData.current.song,
status: PlayerStatus.PLAYING,
});
updateSong(playerData.current.song);
resetPlayers();
}
},
@@ -258,20 +210,15 @@ export const useCenterControls = (args: { playersRef: any }) => {
const handleRepeatOne = {
local: () => {
const playerData = autoNext();
mprisUpdateSong({ song: playerData.current.song, status: PlayerStatus.PLAYING });
updateSong(playerData.current.song);
mpvPlayer!.autoNext(playerData);
play();
},
web: () => {
if (isLastTrack) {
mprisUpdateSong({ status: PlayerStatus.PAUSED });
resetPlayers();
} else {
const playerData = autoNext();
mprisUpdateSong({
song: playerData.current.song,
status: PlayerStatus.PLAYING,
});
autoNext();
resetPlayers();
}
},
@@ -309,12 +256,12 @@ export const useCenterControls = (args: { playersRef: any }) => {
const handleRepeatAll = {
local: () => {
const playerData = next();
mprisUpdateSong({ song: playerData.current.song });
updateSong(playerData.current.song);
mpvPlayer!.setQueue(playerData);
},
web: () => {
const playerData = next();
mprisUpdateSong({ song: playerData.current.song });
updateSong(playerData.current.song);
},
};
@@ -322,27 +269,24 @@ export const useCenterControls = (args: { playersRef: any }) => {
local: () => {
if (isLastTrack) {
const playerData = setCurrentIndex(0);
mprisUpdateSong({ song: playerData.current.song, status: PlayerStatus.PAUSED });
updateSong(playerData.current.song);
mpvPlayer!.setQueue(playerData, true);
pause();
} else {
const playerData = next();
mprisUpdateSong({ song: playerData.current.song });
updateSong(playerData.current.song);
mpvPlayer!.setQueue(playerData);
}
},
web: () => {
if (isLastTrack) {
const playerData = setCurrentIndex(0);
mprisUpdateSong({
song: playerData.current.song,
status: PlayerStatus.PAUSED,
});
updateSong(playerData.current.song);
resetPlayers();
pause();
} else {
const playerData = next();
mprisUpdateSong({ song: playerData.current.song });
updateSong(playerData.current.song);
resetPlayers();
}
},
@@ -352,14 +296,14 @@ export const useCenterControls = (args: { playersRef: any }) => {
local: () => {
if (!isLastTrack) {
const playerData = next();
mprisUpdateSong({ song: playerData.current.song });
updateSong(playerData.current.song);
mpvPlayer!.setQueue(playerData);
}
},
web: () => {
if (!isLastTrack) {
const playerData = next();
mprisUpdateSong({ song: playerData.current.song });
updateSong(playerData.current.song);
}
},
};
@@ -413,22 +357,22 @@ export const useCenterControls = (args: { playersRef: any }) => {
local: () => {
if (!isFirstTrack) {
const playerData = previous();
mprisUpdateSong({ song: playerData.current.song });
updateSong(playerData.current.song);
mpvPlayer!.setQueue(playerData);
} else {
const playerData = setCurrentIndex(queue.length - 1);
mprisUpdateSong({ song: playerData.current.song });
updateSong(playerData.current.song);
mpvPlayer!.setQueue(playerData);
}
},
web: () => {
if (isFirstTrack) {
const playerData = setCurrentIndex(queue.length - 1);
mprisUpdateSong({ song: playerData.current.song });
updateSong(playerData.current.song);
resetPlayers();
} else {
const playerData = previous();
mprisUpdateSong({ song: playerData.current.song });
updateSong(playerData.current.song);
resetPlayers();
}
},
@@ -438,26 +382,22 @@ export const useCenterControls = (args: { playersRef: any }) => {
local: () => {
if (isFirstTrack) {
const playerData = setCurrentIndex(0);
mprisUpdateSong({ song: playerData.current.song, status: PlayerStatus.PAUSED });
updateSong(playerData.current.song);
mpvPlayer!.setQueue(playerData, true);
pause();
} else {
const playerData = previous();
mprisUpdateSong({
currentTime: usePlayerStore.getState().current.time,
song: playerData.current.song,
});
updateSong(playerData.current.song);
mpvPlayer!.setQueue(playerData);
}
},
web: () => {
if (isFirstTrack) {
resetPlayers();
mprisUpdateSong({ status: PlayerStatus.PAUSED });
pause();
} else {
const playerData = previous();
mprisUpdateSong({ song: playerData.current.song });
updateSong(playerData.current.song);
resetPlayers();
}
},
@@ -466,12 +406,12 @@ export const useCenterControls = (args: { playersRef: any }) => {
const handleRepeatOne = {
local: () => {
const playerData = previous();
mprisUpdateSong({ song: playerData.current.song });
updateSong(playerData.current.song);
mpvPlayer!.setQueue(playerData);
},
web: () => {
const playerData = previous();
mprisUpdateSong({ song: playerData.current.song });
updateSong(playerData.current.song);
resetPlayers();
},
};
@@ -2,13 +2,7 @@ import { useCallback, useRef } from 'react';
import { useQueryClient } from '@tanstack/react-query';
import { useCurrentServer, usePlayerControls, usePlayerStore } from '/@/renderer/store';
import { usePlaybackType } from '/@/renderer/store/settings.store';
import {
PlayQueueAddOptions,
Play,
PlaybackType,
PlayerStatus,
PlayerShuffle,
} from '/@/renderer/types';
import { PlayQueueAddOptions, Play, PlaybackType } from '/@/renderer/types';
import { toast } from '/@/renderer/components/toast/index';
import isElectron from 'is-electron';
import { nanoid } from 'nanoid/non-secure';
@@ -29,6 +23,8 @@ import {
} from '/@/renderer/features/player/utils';
import { queryKeys } from '/@/renderer/api/query-keys';
import { useTranslation } from 'react-i18next';
import { PlayersRef } from '/@/renderer/features/player/ref/players-ref';
import { updateSong } from '/@/renderer/features/player/update-remote-song';
const getRootQueryKey = (itemType: LibraryItem, serverId: string) => {
let queryKey;
@@ -58,7 +54,6 @@ const getRootQueryKey = (itemType: LibraryItem, serverId: string) => {
};
const mpvPlayer = isElectron() ? window.electron.mpvPlayer : null;
const remote = isElectron() ? window.electron.remote : null;
const addToQueue = usePlayerStore.getState().actions.addToQueue;
@@ -170,6 +165,8 @@ export const useHandlePlayQueueAdd = () => {
const hadSong = usePlayerStore.getState().queue.default.length > 0;
const playerData = addToQueue({ initialIndex: initialSongIndex, playType, songs });
updateSong(playerData.current.song);
if (playbackType === PlaybackType.LOCAL) {
mpvPlayer!.volume(usePlayerStore.getState().volume);
@@ -179,6 +176,15 @@ export const useHandlePlayQueueAdd = () => {
} else {
mpvPlayer!.setQueueNext(playerData);
}
} else {
const player =
playerData.current.player === 1
? PlayersRef.current?.player1
: PlayersRef.current?.player2;
const underlying = player?.getInternalPlayer();
if (underlying && playType === Play.NOW) {
underlying.currentTime = 0;
}
}
// We should only play if the queue was empty, or we are doing play NOW
@@ -187,14 +193,6 @@ export const useHandlePlayQueueAdd = () => {
play();
}
remote?.updateSong({
currentTime: usePlayerStore.getState().current.time,
repeat: usePlayerStore.getState().repeat,
shuffle: usePlayerStore.getState().shuffle !== PlayerShuffle.NONE,
song: playerData.current.song,
status: PlayerStatus.PLAYING,
});
return null;
},
[play, playbackType, queryClient, server, t],
@@ -0,0 +1,39 @@
import isElectron from 'is-electron';
import { QueueSong } from '/@/renderer/api/types';
const remote = isElectron() ? window.electron.remote : null;
const mediaSession = navigator.mediaSession;
export const updateSong = (song: QueueSong | undefined) => {
if (mediaSession) {
let metadata: MediaMetadata;
if (song?.id) {
let artwork: MediaImage[];
if (song.imageUrl) {
const image300 = song.imageUrl
?.replace(/&size=\d+/, '&size=300')
.replace(/\?width=\d+/, '?width=300')
.replace(/&height=\d+/, '&height=300');
artwork = [{ sizes: '300x300', src: image300, type: 'image/png' }];
} else {
artwork = [];
}
metadata = new MediaMetadata({
album: song.album ?? '',
artist: song.artistName,
artwork,
title: song.name,
});
} else {
metadata = new MediaMetadata();
}
mediaSession.metadata = metadata;
}
remote?.updateSong(song);
};
@@ -46,7 +46,7 @@ const MetadataWrapper = styled.div`
`;
const StyledImage = styled.img`
object-fit: cover;
object-fit: var(--image-fit);
border-radius: 4px;
`;
@@ -90,6 +90,28 @@ export const ControlSettings = () => {
isHidden: false,
title: t('setting.playerAlbumArtResolution', { postProcess: 'sentenceCase' }),
},
{
control: (
<Switch
aria-label="Toggle using native aspect ratio"
defaultChecked={settings.nativeAspectRatio}
onChange={(e) =>
setSettings({
general: {
...settings,
nativeAspectRatio: e.currentTarget.checked,
},
})
}
/>
),
description: t('setting.imageAspectRatio', {
context: 'description',
postProcess: 'sentenceCase',
}),
isHidden: false,
title: t('setting.imageAspectRatio', { postProcess: 'sentenceCase' }),
},
{
control: (
<Switch
@@ -377,6 +399,28 @@ export const ControlSettings = () => {
isHidden: false,
title: t('setting.genreBehavior', { postProcess: 'sentenceCase' }),
},
{
control: (
<Switch
aria-label={t('setting.homeFeature', { postProcess: 'sentenceCase' })}
defaultChecked={settings.homeFeature}
onChange={(e) =>
setSettings({
general: {
...settings,
homeFeature: e.currentTarget.checked,
},
})
}
/>
),
description: t('setting.homeFeature', {
context: 'description',
postProcess: 'sentenceCase',
}),
isHidden: false,
title: t('setting.homeFeature', { postProcess: 'sentenceCase' }),
},
];
return <SettingsSection options={controlOptions} />;
@@ -49,7 +49,7 @@ export const CacheSettings = () => {
{t(`common.areYouSure`, { postProcess: 'sentenceCase' })}
</ConfirmModal>
),
title: t(`setting.${key}`),
title: t(`setting.${key}`, { postProcess: 'sentenceCase' }),
});
};
@@ -67,8 +67,9 @@ export const CacheSettings = () => {
),
description: t('setting.clearQueryCache', {
context: 'description',
postProcess: 'sentenceCase',
}),
title: t('setting.clearQueryCache'),
title: t('setting.clearQueryCache', { postProcess: 'sentenceCase' }),
},
{
control: (
@@ -83,9 +84,10 @@ export const CacheSettings = () => {
),
description: t('setting.clearCache', {
context: 'description',
postProcess: 'sentenceCase',
}),
isHidden: !browser,
title: t('setting.clearCache'),
title: t('setting.clearCache', { postProcess: 'sentenceCase' }),
},
];
@@ -85,7 +85,7 @@ export const WindowSettings = () => {
control: (
<Switch
aria-label="Toggle minimize to tray"
defaultChecked={settings.exitToTray}
defaultChecked={settings.minimizeToTray}
disabled={!isElectron()}
onChange={(e) => {
if (!e) return;
@@ -121,7 +121,7 @@
}
.image {
object-fit: cover;
object-fit: var(--image-fit);
border-radius: 5px;
}
@@ -64,7 +64,7 @@ const ImageContainer = styled(motion.div)<{ height: string }>`
const SidebarImage = styled.img`
width: 100%;
height: 100%;
object-fit: cover;
object-fit: var(--image-fit);
background: var(--placeholder-bg);
`;
+1 -1
View File
@@ -1054,7 +1054,7 @@ export const usePlayerData = () =>
usePlayerStore(
(state) => state.actions.getPlayerData(),
(a, b) => {
return a.current.nextIndex === b.current.nextIndex;
return a.current.song?.uniqueId === b.current.song?.uniqueId;
},
);
+4
View File
@@ -198,8 +198,10 @@ export interface SettingsState {
externalLinks: boolean;
followSystemTheme: boolean;
genreTarget: GenreTarget;
homeFeature: boolean;
homeItems: SortableItem<HomeItem>[];
language: string;
nativeAspectRatio: boolean;
passwordStore?: string;
playButtonBehavior: Play;
resume: boolean;
@@ -319,8 +321,10 @@ const initialState: SettingsState = {
externalLinks: true,
followSystemTheme: false,
genreTarget: GenreTarget.TRACK,
homeFeature: true,
homeItems,
language: 'en',
nativeAspectRatio: false,
passwordStore: undefined,
playButtonBehavior: Play.NOW,
resume: false,
+3 -2
View File
@@ -1,3 +1,4 @@
import { ReactNode } from 'react';
import { ServerFeatures } from '/@/renderer/api/features-types';
import {
Album,
@@ -37,6 +38,7 @@ export type TableType =
export type CardRow<T> = {
arrayProperty?: string;
format?: (value: T) => ReactNode;
property: keyof T;
route?: CardRoute;
};
@@ -209,8 +211,7 @@ export type GridCardData = {
route: CardRoute;
};
export type SongUpdate = {
currentTime?: number;
export type SongState = {
repeat?: PlayerRepeat;
shuffle?: boolean;
song?: QueueSong;
@@ -1,24 +0,0 @@
import formatDuration from 'format-duration';
export const formatDurationString = (duration: number) => {
const rawDuration = formatDuration(duration).split(':');
let string;
switch (rawDuration.length) {
case 1:
string = `${rawDuration[0]} sec`;
break;
case 2:
string = `${rawDuration[0]} min ${rawDuration[1]} sec`;
break;
case 3:
string = `${rawDuration[0]} hr ${rawDuration[1]} min ${rawDuration[2]} sec`;
break;
case 4:
string = `${rawDuration[0]} day ${rawDuration[1]} hr ${rawDuration[2]} min ${rawDuration[3]} sec`;
break;
}
return string;
};
-12
View File
@@ -1,12 +0,0 @@
const SIZES = ['B', 'KiB', 'MiB', 'GiB', 'TiB'];
export const formatSizeString = (size?: number): string => {
let count = 0;
let finalSize = size ?? 0;
while (finalSize > 1024) {
finalSize /= 1024;
count += 1;
}
return `${finalSize.toFixed(2)} ${SIZES[count]}`;
};
+56
View File
@@ -0,0 +1,56 @@
import dayjs from 'dayjs';
import relativeTime from 'dayjs/plugin/relativeTime';
import formatDuration from 'format-duration';
import type { Album, AlbumArtist, Song } from '/@/renderer/api/types';
import { Rating } from '/@/renderer/components/rating';
dayjs.extend(relativeTime);
export const formatDateAbsolute = (key: string | null) =>
key ? dayjs(key).format('MMM D, YYYY') : '';
export const formatDateRelative = (key: string | null) => (key ? dayjs(key).fromNow() : '');
export const formatDurationString = (duration: number) => {
const rawDuration = formatDuration(duration).split(':');
let string;
switch (rawDuration.length) {
case 1:
string = `${rawDuration[0]} sec`;
break;
case 2:
string = `${rawDuration[0]} min ${rawDuration[1]} sec`;
break;
case 3:
string = `${rawDuration[0]} hr ${rawDuration[1]} min ${rawDuration[2]} sec`;
break;
case 4:
string = `${rawDuration[0]} day ${rawDuration[1]} hr ${rawDuration[2]} min ${rawDuration[3]} sec`;
break;
}
return string;
};
export const formatRating = (item: Album | AlbumArtist | Song) =>
item.userRating !== null ? (
<Rating
readOnly
value={item.userRating}
/>
) : null;
const SIZES = ['B', 'KiB', 'MiB', 'GiB', 'TiB'];
export const formatSizeString = (size?: number): string => {
let count = 0;
let finalSize = size ?? 0;
while (finalSize > 1024) {
finalSize /= 1024;
count += 1;
}
return `${finalSize.toFixed(2)} ${SIZES[count]}`;
};
+1 -1
View File
@@ -5,6 +5,6 @@ export * from './constrain-sidebar-width';
export * from './title-case';
export * from './get-header-color';
export * from './parse-search-params';
export * from './format-duration-string';
export * from './rgb-to-rgba';
export * from './sentence-case';
export * from './format';