mirror of
https://github.com/jeffvli/feishin.git
synced 2026-05-08 04:50:12 +02:00
Compare commits
81 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ee33720fcd | |||
| 7d34511039 | |||
| 8b4bbc1ede | |||
| 833d4d3aac | |||
| 7e353c4723 | |||
| ae2ce0866e | |||
| 27c42dd9f4 | |||
| 52dea17d14 | |||
| baf4e7bc0b | |||
| 74c44558fe | |||
| 4033619421 | |||
| 5d206bbb1f | |||
| 3db801f2de | |||
| 0d3cf912d3 | |||
| d81f30a8b5 | |||
| a5c3b454f4 | |||
| 68e6e3cf65 | |||
| 86e6b88555 | |||
| 5cdc45836f | |||
| d438c802a4 | |||
| a838bdebb7 | |||
| 8ff2f4dfb4 | |||
| ede47fbf8f | |||
| 9eb64079f7 | |||
| 3b955bb319 | |||
| 816adfa6c7 | |||
| f91dcc6af6 | |||
| 6dc58a3ff8 | |||
| 09fa10a4e9 | |||
| 6f45e1a814 | |||
| 62ba721f26 | |||
| 67231753e4 | |||
| c16eccaecb | |||
| 0bdf1dcb75 | |||
| 598e9ca5c2 | |||
| 615f9c3515 | |||
| b7cbdb4d6c | |||
| 3c562c1398 | |||
| 3eafa73217 | |||
| 74864d9621 | |||
| cb5562d32e | |||
| e40a175e12 | |||
| f996b111b9 | |||
| 0cb5c49924 | |||
| c636029003 | |||
| db88a6bc22 | |||
| 8ccd97b574 | |||
| 3f99acf473 | |||
| 0cd37ce8ec | |||
| ee04878580 | |||
| e987049f20 | |||
| 122552287a | |||
| d318e6d341 | |||
| d96b282cae | |||
| f2ab01199f | |||
| 04b22431f4 | |||
| 31fce705ab | |||
| a28fab0ff3 | |||
| 0a1d4788ee | |||
| fafb9d4f56 | |||
| 4fdc38caee | |||
| 799cdb44d3 | |||
| 372892199f | |||
| d16184fb25 | |||
| b8564f6d41 | |||
| d474e60c51 | |||
| dfdac28f53 | |||
| 16b713bc85 | |||
| 81cd0722b1 | |||
| 1526f9b8d6 | |||
| 5b4da3bc29 | |||
| d78ea440cc | |||
| 1595805b83 | |||
| 00fa45f15d | |||
| ab05be30c0 | |||
| 3d407e5f24 | |||
| 60776b5f02 | |||
| 8699b1ffea | |||
| 16ac536f93 | |||
| f51d3d5711 | |||
| 17a4a14a4e |
@@ -40,12 +40,12 @@ jobs:
|
|||||||
version: ${{ steps.version.outputs.version }}
|
version: ${{ steps.version.outputs.version }}
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout git repo
|
- name: Checkout git repo
|
||||||
uses: actions/checkout@v1
|
uses: actions/checkout@v6
|
||||||
|
|
||||||
- name: Install Node and PNPM
|
- name: Install Node and PNPM
|
||||||
uses: pnpm/action-setup@v4.1.0
|
uses: pnpm/action-setup@v4
|
||||||
with:
|
with:
|
||||||
version: 9
|
version: 10
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: pnpm install
|
run: pnpm install
|
||||||
@@ -125,12 +125,12 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout git repo
|
- name: Checkout git repo
|
||||||
uses: actions/checkout@v1
|
uses: actions/checkout@v6
|
||||||
|
|
||||||
- name: Install Node and PNPM
|
- name: Install Node and PNPM
|
||||||
uses: pnpm/action-setup@v4.1.0
|
uses: pnpm/action-setup@v4
|
||||||
with:
|
with:
|
||||||
version: 9
|
version: 10
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: pnpm install
|
run: pnpm install
|
||||||
@@ -146,7 +146,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Build and Publish to R2 (Windows)
|
- name: Build and Publish to R2 (Windows)
|
||||||
if: matrix.os == 'windows-latest'
|
if: matrix.os == 'windows-latest'
|
||||||
uses: nick-invision/retry@v2.8.2
|
uses: nick-invision/retry@v3.0.2
|
||||||
with:
|
with:
|
||||||
timeout_minutes: 30
|
timeout_minutes: 30
|
||||||
max_attempts: 3
|
max_attempts: 3
|
||||||
@@ -157,7 +157,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Build and Publish to R2 (macOS)
|
- name: Build and Publish to R2 (macOS)
|
||||||
if: matrix.os == 'macos-latest'
|
if: matrix.os == 'macos-latest'
|
||||||
uses: nick-invision/retry@v2.8.2
|
uses: nick-invision/retry@v3.0.2
|
||||||
with:
|
with:
|
||||||
timeout_minutes: 30
|
timeout_minutes: 30
|
||||||
max_attempts: 3
|
max_attempts: 3
|
||||||
@@ -168,7 +168,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Build and Publish to R2 (Linux)
|
- name: Build and Publish to R2 (Linux)
|
||||||
if: matrix.os == 'ubuntu-latest'
|
if: matrix.os == 'ubuntu-latest'
|
||||||
uses: nick-invision/retry@v2.8.2
|
uses: nick-invision/retry@v3.0.2
|
||||||
with:
|
with:
|
||||||
timeout_minutes: 30
|
timeout_minutes: 30
|
||||||
max_attempts: 3
|
max_attempts: 3
|
||||||
@@ -179,7 +179,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Build and Publish to R2 (Linux ARM64)
|
- name: Build and Publish to R2 (Linux ARM64)
|
||||||
if: matrix.os == 'ubuntu-latest'
|
if: matrix.os == 'ubuntu-latest'
|
||||||
uses: nick-invision/retry@v2.8.2
|
uses: nick-invision/retry@v3.0.2
|
||||||
with:
|
with:
|
||||||
timeout_minutes: 30
|
timeout_minutes: 30
|
||||||
max_attempts: 3
|
max_attempts: 3
|
||||||
|
|||||||
@@ -15,12 +15,12 @@ jobs:
|
|||||||
version: ${{ steps.version.outputs.version }}
|
version: ${{ steps.version.outputs.version }}
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout git repo
|
- name: Checkout git repo
|
||||||
uses: actions/checkout@v1
|
uses: actions/checkout@v6
|
||||||
|
|
||||||
- name: Install Node and PNPM
|
- name: Install Node and PNPM
|
||||||
uses: pnpm/action-setup@v4.1.0
|
uses: pnpm/action-setup@v4
|
||||||
with:
|
with:
|
||||||
version: 9
|
version: 10
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: pnpm install
|
run: pnpm install
|
||||||
@@ -119,12 +119,12 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout git repo
|
- name: Checkout git repo
|
||||||
uses: actions/checkout@v1
|
uses: actions/checkout@v6
|
||||||
|
|
||||||
- name: Install Node and PNPM
|
- name: Install Node and PNPM
|
||||||
uses: pnpm/action-setup@v4.1.0
|
uses: pnpm/action-setup@v4
|
||||||
with:
|
with:
|
||||||
version: 9
|
version: 10
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: pnpm install
|
run: pnpm install
|
||||||
@@ -146,7 +146,7 @@ jobs:
|
|||||||
if: matrix.os == 'windows-latest'
|
if: matrix.os == 'windows-latest'
|
||||||
env:
|
env:
|
||||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
uses: nick-invision/retry@v2.8.2
|
uses: nick-invision/retry@v3.0.2
|
||||||
with:
|
with:
|
||||||
timeout_minutes: 30
|
timeout_minutes: 30
|
||||||
max_attempts: 3
|
max_attempts: 3
|
||||||
@@ -159,7 +159,7 @@ jobs:
|
|||||||
if: matrix.os == 'macos-latest'
|
if: matrix.os == 'macos-latest'
|
||||||
env:
|
env:
|
||||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
uses: nick-invision/retry@v2.8.2
|
uses: nick-invision/retry@v3.0.2
|
||||||
with:
|
with:
|
||||||
timeout_minutes: 30
|
timeout_minutes: 30
|
||||||
max_attempts: 3
|
max_attempts: 3
|
||||||
@@ -172,7 +172,7 @@ jobs:
|
|||||||
if: matrix.os == 'ubuntu-latest'
|
if: matrix.os == 'ubuntu-latest'
|
||||||
env:
|
env:
|
||||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
uses: nick-invision/retry@v2.8.2
|
uses: nick-invision/retry@v3.0.2
|
||||||
with:
|
with:
|
||||||
timeout_minutes: 30
|
timeout_minutes: 30
|
||||||
max_attempts: 3
|
max_attempts: 3
|
||||||
@@ -185,7 +185,7 @@ jobs:
|
|||||||
if: matrix.os == 'ubuntu-latest'
|
if: matrix.os == 'ubuntu-latest'
|
||||||
env:
|
env:
|
||||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
uses: nick-invision/retry@v2.8.2
|
uses: nick-invision/retry@v3.0.2
|
||||||
with:
|
with:
|
||||||
timeout_minutes: 30
|
timeout_minutes: 30
|
||||||
max_attempts: 3
|
max_attempts: 3
|
||||||
@@ -199,7 +199,7 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout git repo
|
- name: Checkout git repo
|
||||||
uses: actions/checkout@v1
|
uses: actions/checkout@v6
|
||||||
|
|
||||||
- name: Edit release with commits and title
|
- name: Edit release with commits and title
|
||||||
shell: pwsh
|
shell: pwsh
|
||||||
@@ -346,7 +346,7 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout git repo
|
- name: Checkout git repo
|
||||||
uses: actions/checkout@v1
|
uses: actions/checkout@v6
|
||||||
|
|
||||||
- name: Delete existing prereleases
|
- name: Delete existing prereleases
|
||||||
shell: pwsh
|
shell: pwsh
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ jobs:
|
|||||||
packages: write
|
packages: write
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v6
|
||||||
- name: Log in to the Container registry
|
- name: Log in to the Container registry
|
||||||
uses: docker/login-action@65b78e6e13532edd9afa3aa52ac7964289d1a9c1
|
uses: docker/login-action@65b78e6e13532edd9afa3aa52ac7964289d1a9c1
|
||||||
with:
|
with:
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ jobs:
|
|||||||
packages: write
|
packages: write
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v6
|
||||||
- name: Log in to the Container registry
|
- name: Log in to the Container registry
|
||||||
uses: docker/login-action@65b78e6e13532edd9afa3aa52ac7964289d1a9c1
|
uses: docker/login-action@65b78e6e13532edd9afa3aa52ac7964289d1a9c1
|
||||||
with:
|
with:
|
||||||
|
|||||||
@@ -12,12 +12,12 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout git repo
|
- name: Checkout git repo
|
||||||
uses: actions/checkout@v1
|
uses: actions/checkout@v6
|
||||||
|
|
||||||
- name: Install Node and PNPM
|
- name: Install Node and PNPM
|
||||||
uses: pnpm/action-setup@v4.1.0
|
uses: pnpm/action-setup@v4
|
||||||
with:
|
with:
|
||||||
version: 9
|
version: 10
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: pnpm install
|
run: pnpm install
|
||||||
@@ -25,7 +25,7 @@ jobs:
|
|||||||
- name: Build and Publish releases
|
- name: Build and Publish releases
|
||||||
env:
|
env:
|
||||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
uses: nick-invision/retry@v2.8.2
|
uses: nick-invision/retry@v3.0.2
|
||||||
with:
|
with:
|
||||||
timeout_minutes: 30
|
timeout_minutes: 30
|
||||||
max_attempts: 3
|
max_attempts: 3
|
||||||
@@ -37,7 +37,7 @@ jobs:
|
|||||||
- name: Build and Publish releases (arm64)
|
- name: Build and Publish releases (arm64)
|
||||||
env:
|
env:
|
||||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
uses: nick-invision/retry@v2.8.2
|
uses: nick-invision/retry@v3.0.2
|
||||||
with:
|
with:
|
||||||
timeout_minutes: 30
|
timeout_minutes: 30
|
||||||
max_attempts: 3
|
max_attempts: 3
|
||||||
|
|||||||
@@ -12,12 +12,12 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout git repo
|
- name: Checkout git repo
|
||||||
uses: actions/checkout@v1
|
uses: actions/checkout@v6
|
||||||
|
|
||||||
- name: Install Node and PNPM
|
- name: Install Node and PNPM
|
||||||
uses: pnpm/action-setup@v4.1.0
|
uses: pnpm/action-setup@v4
|
||||||
with:
|
with:
|
||||||
version: 9
|
version: 10
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: pnpm install
|
run: pnpm install
|
||||||
@@ -25,7 +25,7 @@ jobs:
|
|||||||
- name: Build and Publish releases
|
- name: Build and Publish releases
|
||||||
env:
|
env:
|
||||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
uses: nick-invision/retry@v2.8.2
|
uses: nick-invision/retry@v3.0.2
|
||||||
with:
|
with:
|
||||||
timeout_minutes: 30
|
timeout_minutes: 30
|
||||||
max_attempts: 3
|
max_attempts: 3
|
||||||
|
|||||||
@@ -34,19 +34,19 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout git repo
|
- name: Checkout git repo
|
||||||
uses: actions/checkout@v3
|
uses: actions/checkout@v6
|
||||||
|
|
||||||
- name: Install Node and PNPM
|
- name: Install Node and PNPM
|
||||||
uses: pnpm/action-setup@v4.1.0
|
uses: pnpm/action-setup@v4
|
||||||
with:
|
with:
|
||||||
version: 9
|
version: 10
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: pnpm install
|
run: pnpm install
|
||||||
|
|
||||||
- name: Build for Windows
|
- name: Build for Windows
|
||||||
if: ${{ matrix.os == 'windows-latest' }}
|
if: ${{ matrix.os == 'windows-latest' }}
|
||||||
uses: nick-invision/retry@v2.8.2
|
uses: nick-invision/retry@v3.0.2
|
||||||
with:
|
with:
|
||||||
timeout_minutes: 30
|
timeout_minutes: 30
|
||||||
max_attempts: 3
|
max_attempts: 3
|
||||||
@@ -56,7 +56,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Build for Linux
|
- name: Build for Linux
|
||||||
if: ${{ matrix.os == 'ubuntu-latest' }}
|
if: ${{ matrix.os == 'ubuntu-latest' }}
|
||||||
uses: nick-invision/retry@v2.8.2
|
uses: nick-invision/retry@v3.0.2
|
||||||
with:
|
with:
|
||||||
timeout_minutes: 30
|
timeout_minutes: 30
|
||||||
max_attempts: 3
|
max_attempts: 3
|
||||||
@@ -66,7 +66,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Build for MacOS
|
- name: Build for MacOS
|
||||||
if: ${{ matrix.os == 'macos-latest' }}
|
if: ${{ matrix.os == 'macos-latest' }}
|
||||||
uses: nick-invision/retry@v2.8.2
|
uses: nick-invision/retry@v3.0.2
|
||||||
with:
|
with:
|
||||||
timeout_minutes: 30
|
timeout_minutes: 30
|
||||||
max_attempts: 3
|
max_attempts: 3
|
||||||
@@ -92,21 +92,21 @@ jobs:
|
|||||||
|
|
||||||
- name: Upload Windows Binaries
|
- name: Upload Windows Binaries
|
||||||
if: ${{ matrix.os == 'windows-latest' }}
|
if: ${{ matrix.os == 'windows-latest' }}
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v7
|
||||||
with:
|
with:
|
||||||
name: windows-binaries
|
name: windows-binaries
|
||||||
path: dist/windows-binaries.zip
|
path: dist/windows-binaries.zip
|
||||||
|
|
||||||
- name: Upload Linux Binaries
|
- name: Upload Linux Binaries
|
||||||
if: ${{ matrix.os == 'ubuntu-latest' }}
|
if: ${{ matrix.os == 'ubuntu-latest' }}
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v7
|
||||||
with:
|
with:
|
||||||
name: linux-binaries
|
name: linux-binaries
|
||||||
path: dist/linux-binaries.zip
|
path: dist/linux-binaries.zip
|
||||||
|
|
||||||
- name: Upload MacOS Binaries
|
- name: Upload MacOS Binaries
|
||||||
if: ${{ matrix.os == 'macos-latest' }}
|
if: ${{ matrix.os == 'macos-latest' }}
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v7
|
||||||
with:
|
with:
|
||||||
name: macos-binaries
|
name: macos-binaries
|
||||||
path: dist/macos-binaries.zip
|
path: dist/macos-binaries.zip
|
||||||
|
|||||||
@@ -12,12 +12,12 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout git repo
|
- name: Checkout git repo
|
||||||
uses: actions/checkout@v1
|
uses: actions/checkout@v6
|
||||||
|
|
||||||
- name: Install Node and PNPM
|
- name: Install Node and PNPM
|
||||||
uses: pnpm/action-setup@v4.1.0
|
uses: pnpm/action-setup@v4
|
||||||
with:
|
with:
|
||||||
version: 9
|
version: 10
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: pnpm install
|
run: pnpm install
|
||||||
@@ -25,7 +25,7 @@ jobs:
|
|||||||
- name: Build and Publish releases
|
- name: Build and Publish releases
|
||||||
env:
|
env:
|
||||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
uses: nick-invision/retry@v2.8.2
|
uses: nick-invision/retry@v3.0.2
|
||||||
with:
|
with:
|
||||||
timeout_minutes: 30
|
timeout_minutes: 30
|
||||||
max_attempts: 3
|
max_attempts: 3
|
||||||
|
|||||||
@@ -12,12 +12,12 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout git repo
|
- name: Checkout git repo
|
||||||
uses: actions/checkout@v1
|
uses: actions/checkout@v6
|
||||||
|
|
||||||
- name: Install Node and PNPM
|
- name: Install Node and PNPM
|
||||||
uses: pnpm/action-setup@v4.1.0
|
uses: pnpm/action-setup@v4
|
||||||
with:
|
with:
|
||||||
version: 9
|
version: 10
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: pnpm install
|
run: pnpm install
|
||||||
@@ -26,7 +26,7 @@ jobs:
|
|||||||
if: matrix.os == 'windows-latest'
|
if: matrix.os == 'windows-latest'
|
||||||
env:
|
env:
|
||||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
uses: nick-invision/retry@v2.8.2
|
uses: nick-invision/retry@v3.0.2
|
||||||
with:
|
with:
|
||||||
timeout_minutes: 30
|
timeout_minutes: 30
|
||||||
max_attempts: 3
|
max_attempts: 3
|
||||||
@@ -39,7 +39,7 @@ jobs:
|
|||||||
if: matrix.os == 'macos-latest'
|
if: matrix.os == 'macos-latest'
|
||||||
env:
|
env:
|
||||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
uses: nick-invision/retry@v2.8.2
|
uses: nick-invision/retry@v3.0.2
|
||||||
with:
|
with:
|
||||||
timeout_minutes: 30
|
timeout_minutes: 30
|
||||||
max_attempts: 3
|
max_attempts: 3
|
||||||
@@ -52,7 +52,7 @@ jobs:
|
|||||||
if: matrix.os == 'ubuntu-latest'
|
if: matrix.os == 'ubuntu-latest'
|
||||||
env:
|
env:
|
||||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
uses: nick-invision/retry@v2.8.2
|
uses: nick-invision/retry@v3.0.2
|
||||||
with:
|
with:
|
||||||
timeout_minutes: 30
|
timeout_minutes: 30
|
||||||
max_attempts: 3
|
max_attempts: 3
|
||||||
@@ -65,7 +65,7 @@ jobs:
|
|||||||
if: matrix.os == 'ubuntu-latest'
|
if: matrix.os == 'ubuntu-latest'
|
||||||
env:
|
env:
|
||||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
uses: nick-invision/retry@v2.8.2
|
uses: nick-invision/retry@v3.0.2
|
||||||
with:
|
with:
|
||||||
timeout_minutes: 30
|
timeout_minutes: 30
|
||||||
max_attempts: 3
|
max_attempts: 3
|
||||||
|
|||||||
@@ -8,12 +8,12 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Check out Git repository
|
- name: Check out Git repository
|
||||||
uses: actions/checkout@v1
|
uses: actions/checkout@v6
|
||||||
|
|
||||||
- name: Install Node.js and PNPM
|
- name: Install Node.js and PNPM
|
||||||
uses: pnpm/action-setup@v4.1.0
|
uses: pnpm/action-setup@v4
|
||||||
with:
|
with:
|
||||||
version: 9
|
version: 10
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: pnpm install
|
run: pnpm install
|
||||||
|
|||||||
@@ -1,2 +1 @@
|
|||||||
legacy-peer-deps=true
|
legacy-peer-deps=true
|
||||||
only-built-dependencies=electron,esbuild
|
|
||||||
|
|||||||
+4
-4
@@ -1,5 +1,5 @@
|
|||||||
# --- Builder stage
|
# --- Builder stage
|
||||||
FROM node:23-alpine as builder
|
FROM node:23-alpine AS builder
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Copy package.json first to cache node_modules
|
# Copy package.json first to cache node_modules
|
||||||
@@ -14,11 +14,11 @@ COPY . .
|
|||||||
RUN pnpm run build:web
|
RUN pnpm run build:web
|
||||||
|
|
||||||
# --- Production stage
|
# --- Production stage
|
||||||
FROM nginx:alpine-slim
|
FROM nginxinc/nginx-unprivileged:alpine-slim
|
||||||
|
|
||||||
COPY --chown=nginx:nginx --from=builder /app/out/web /usr/share/nginx/html
|
COPY --chown=nginx:nginx --from=builder /app/out/web /usr/share/nginx/html
|
||||||
COPY ./settings.js.template /etc/nginx/templates/settings.js.template
|
COPY --chown=nginx:nginx ./settings.js.template /etc/nginx/templates/settings.js.template
|
||||||
COPY ng.conf.template /etc/nginx/templates/default.conf.template
|
COPY --chown=nginx:nginx ng.conf.template /etc/nginx/templates/default.conf.template
|
||||||
|
|
||||||
ENV SERVER_LOCK=false SERVER_NAME="" SERVER_TYPE="" SERVER_URL="" REMOTE_URL=""
|
ENV SERVER_LOCK=false SERVER_NAME="" SERVER_TYPE="" SERVER_URL="" REMOTE_URL=""
|
||||||
ENV LEGACY_AUTHENTICATION="" ANALYTICS_DISABLED="" PUBLIC_PATH="/"
|
ENV LEGACY_AUTHENTICATION="" ANALYTICS_DISABLED="" PUBLIC_PATH="/"
|
||||||
|
|||||||
@@ -59,7 +59,11 @@ For media keys to work, you will be prompted to allow Feishin to be a Trusted Ac
|
|||||||
|
|
||||||
#### Linux Notes
|
#### Linux Notes
|
||||||
|
|
||||||
We provide a small install script to download the latest `.AppImage`, make it executable, and also download the icons required by Desktop Environments. Finally, it generates a `.desktop` file to add Feishin to your Application Launcher.
|
Feishin is available in [Flathub](https://flathub.org/en/apps/org.jeffvli.feishin).
|
||||||
|
|
||||||
|
Alternatively, you can install it as an Appimage.
|
||||||
|
We provide a small install script to download the latest `.AppImage`, make it executable, and also download the icons required by Desktop Environments.
|
||||||
|
Finally, it generates a `.desktop` file to add Feishin to your Application Launcher.
|
||||||
|
|
||||||
Simply run the installer like this:
|
Simply run the installer like this:
|
||||||
|
|
||||||
@@ -114,7 +118,7 @@ services:
|
|||||||
- SERVER_LOCK=true # When true AND name/type/url are set, only username/password can be toggled
|
- SERVER_LOCK=true # When true AND name/type/url are set, only username/password can be toggled
|
||||||
- SERVER_TYPE=jellyfin # the allowed types are: jellyfin, navidrome, subsonic. These values are case insensitive
|
- SERVER_TYPE=jellyfin # the allowed types are: jellyfin, navidrome, subsonic. These values are case insensitive
|
||||||
- SERVER_URL= # http://address:port or https://address:port
|
- SERVER_URL= # http://address:port or https://address:port
|
||||||
= REMOTE_URL= # http://address or https://address
|
- REMOTE_URL= # http://address or https://address
|
||||||
- LEGACY_AUTHENTICATION=false # When SERVER_LOCK is true, sets the legacy (plaintext) authentication flag for Subsonic/OpenSubsonic servers
|
- LEGACY_AUTHENTICATION=false # When SERVER_LOCK is true, sets the legacy (plaintext) authentication flag for Subsonic/OpenSubsonic servers
|
||||||
- ANALYTICS_DISABLED=true # Set to true to disable Umami analytics tracking
|
- ANALYTICS_DISABLED=true # Set to true to disable Umami analytics tracking
|
||||||
ports:
|
ports:
|
||||||
|
|||||||
@@ -29,12 +29,14 @@ These variables override app settings **on first run** when no persisted setting
|
|||||||
| `general.themeLight` | `defaultLight` | `FS_GENERAL_THEME_LIGHT` | Same as theme (used when system is light). |
|
| `general.themeLight` | `defaultLight` | `FS_GENERAL_THEME_LIGHT` | Same as theme (used when system is light). |
|
||||||
| `general.lastfmApiKey` | *(empty)* | `FS_GENERAL_LASTFM_API_KEY` | Last.fm API key. |
|
| `general.lastfmApiKey` | *(empty)* | `FS_GENERAL_LASTFM_API_KEY` | Last.fm API key. |
|
||||||
| `general.lastFM` | `true` | `FS_GENERAL_LAST_FM` | `true` / `false` — Enable Last.fm. |
|
| `general.lastFM` | `true` | `FS_GENERAL_LAST_FM` | `true` / `false` — Enable Last.fm. |
|
||||||
|
| `general.listenBrainz` | `true` | `FS_GENERAL_LISTEN_BRAINZ` | `true` / `false` — ListenBrainz links. |
|
||||||
| `general.musicBrainz` | `true` | `FS_GENERAL_MUSIC_BRAINZ` | `true` / `false` — MusicBrainz links. |
|
| `general.musicBrainz` | `true` | `FS_GENERAL_MUSIC_BRAINZ` | `true` / `false` — MusicBrainz links. |
|
||||||
| `general.nativeAspectRatio` | `false` | `FS_GENERAL_NATIVE_ASPECT_RATIO` | `true` / `false` — Use native cover art aspect ratio. |
|
| `general.nativeAspectRatio` | `false` | `FS_GENERAL_NATIVE_ASPECT_RATIO` | `true` / `false` — Use native cover art aspect ratio. |
|
||||||
| `general.pathReplace` | *(empty)* | `FS_GENERAL_PATH_REPLACE` | Path pattern to replace (e.g. server path in Docker). |
|
| `general.pathReplace` | *(empty)* | `FS_GENERAL_PATH_REPLACE` | Path pattern to replace (e.g. server path in Docker). |
|
||||||
| `general.pathReplaceWith` | *(empty)* | `FS_GENERAL_PATH_REPLACE_WITH` | Replacement path. |
|
| `general.pathReplaceWith` | *(empty)* | `FS_GENERAL_PATH_REPLACE_WITH` | Replacement path. |
|
||||||
| `general.playerbarOpenDrawer` | `false` | `FS_GENERAL_PLAYERBAR_OPEN_DRAWER` | `true` / `false` — Open queue/lyrics as drawer from player bar. |
|
| `general.playerbarOpenDrawer` | `false` | `FS_GENERAL_PLAYERBAR_OPEN_DRAWER` | `true` / `false` — Open queue/lyrics as drawer from player bar. |
|
||||||
| `general.primaryShade` | `6` | `FS_GENERAL_PRIMARY_SHADE` | Mantine primary shade 0–9 (number). |
|
| `general.primaryShade` | `6` | `FS_GENERAL_PRIMARY_SHADE` | Mantine primary shade 0–9 (number). |
|
||||||
|
| `general.qobuz` | `true` | `FS_GENERAL_QOBUZ` | `true` / `false` — Qobuz links. |
|
||||||
| `general.resume` | `true` | `FS_GENERAL_RESUME` | `true` / `false` — Resume playback on load. |
|
| `general.resume` | `true` | `FS_GENERAL_RESUME` | `true` / `false` — Resume playback on load. |
|
||||||
| `general.showLyricsInSidebar` | `true` | `FS_GENERAL_SHOW_LYRICS_IN_SIDEBAR` | `true` / `false` — Show lyrics in sidebar. |
|
| `general.showLyricsInSidebar` | `true` | `FS_GENERAL_SHOW_LYRICS_IN_SIDEBAR` | `true` / `false` — Show lyrics in sidebar. |
|
||||||
| `general.showRatings` | `true` | `FS_GENERAL_SHOW_RATINGS` | `true` / `false` — Show star ratings. |
|
| `general.showRatings` | `true` | `FS_GENERAL_SHOW_RATINGS` | `true` / `false` — Show star ratings. |
|
||||||
@@ -44,6 +46,7 @@ These variables override app settings **on first run** when no persisted setting
|
|||||||
| `general.sidebarPlaylistList` | `true` | `FS_GENERAL_SIDEBAR_PLAYLIST_LIST` | `true` / `false` — Show playlist list in sidebar. |
|
| `general.sidebarPlaylistList` | `true` | `FS_GENERAL_SIDEBAR_PLAYLIST_LIST` | `true` / `false` — Show playlist list in sidebar. |
|
||||||
| `general.sidebarPlaylistSorting` | `false` | `FS_GENERAL_SIDEBAR_PLAYLIST_SORTING` | `true` / `false` — Enable playlist sorting in sidebar. |
|
| `general.sidebarPlaylistSorting` | `false` | `FS_GENERAL_SIDEBAR_PLAYLIST_SORTING` | `true` / `false` — Enable playlist sorting in sidebar. |
|
||||||
| `general.sideQueueType` | `sideQueue` | `FS_GENERAL_SIDE_QUEUE_TYPE` | `sideDrawerQueue` / `sideQueue` — Side play queue style. |
|
| `general.sideQueueType` | `sideQueue` | `FS_GENERAL_SIDE_QUEUE_TYPE` | `sideDrawerQueue` / `sideQueue` — Side play queue style. |
|
||||||
|
| `general.sideQueueLayout` | `horizontal` | `FS_GENERAL_SIDE_QUEUE_LAYOUT` | `horizontal` / `vertical` — Attached side queue layout orientation. |
|
||||||
| `general.useThemeAccentColor` | `false` | `FS_GENERAL_USE_THEME_ACCENT_COLOR` | `true` / `false` — Use theme’s accent color instead of custom. |
|
| `general.useThemeAccentColor` | `false` | `FS_GENERAL_USE_THEME_ACCENT_COLOR` | `true` / `false` — Use theme’s accent color instead of custom. |
|
||||||
| `general.useThemePrimaryShade` | `true` | `FS_GENERAL_USE_THEME_PRIMARY_SHADE` | `true` / `false` — Use theme’s primary shade. |
|
| `general.useThemePrimaryShade` | `true` | `FS_GENERAL_USE_THEME_PRIMARY_SHADE` | `true` / `false` — Use theme’s primary shade. |
|
||||||
| `general.zoomFactor` | `100` | `FS_GENERAL_ZOOM_FACTOR` | UI zoom percentage (number). |
|
| `general.zoomFactor` | `100` | `FS_GENERAL_ZOOM_FACTOR` | UI zoom percentage (number). |
|
||||||
|
|||||||
+3
-3
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "feishin",
|
"name": "feishin",
|
||||||
"version": "1.8.0",
|
"version": "1.9.0",
|
||||||
"description": "A modern self-hosted music player.",
|
"description": "A modern self-hosted music player.",
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"subsonic",
|
"subsonic",
|
||||||
@@ -103,7 +103,7 @@
|
|||||||
"electron-store": "^8.2.0",
|
"electron-store": "^8.2.0",
|
||||||
"electron-updater": "^6.6.2",
|
"electron-updater": "^6.6.2",
|
||||||
"fast-average-color": "^9.5.0",
|
"fast-average-color": "^9.5.0",
|
||||||
"fast-xml-parser": "^5.3.6",
|
"fast-xml-parser": "^5.3.8",
|
||||||
"format-duration": "^3.0.2",
|
"format-duration": "^3.0.2",
|
||||||
"fuse.js": "^7.1.0",
|
"fuse.js": "^7.1.0",
|
||||||
"i18next": "^25.6.2",
|
"i18next": "^25.6.2",
|
||||||
@@ -157,7 +157,7 @@
|
|||||||
"concurrently": "^9.2.1",
|
"concurrently": "^9.2.1",
|
||||||
"cross-env": "^10.1.0",
|
"cross-env": "^10.1.0",
|
||||||
"electron": "^39.4.0",
|
"electron": "^39.4.0",
|
||||||
"electron-builder": "^26.8.1",
|
"electron-builder": "^26.8.2",
|
||||||
"electron-devtools-installer": "^4.0.0",
|
"electron-devtools-installer": "^4.0.0",
|
||||||
"electron-vite": "^4.0.1",
|
"electron-vite": "^4.0.1",
|
||||||
"eslint": "^9.24.0",
|
"eslint": "^9.24.0",
|
||||||
|
|||||||
Generated
+78
-615
File diff suppressed because it is too large
Load Diff
@@ -24,12 +24,14 @@ window.FS_GENERAL_HOME_FEATURE_STYLE = "${FS_GENERAL_HOME_FEATURE_STYLE}";
|
|||||||
window.FS_GENERAL_LANGUAGE = "${FS_GENERAL_LANGUAGE}";
|
window.FS_GENERAL_LANGUAGE = "${FS_GENERAL_LANGUAGE}";
|
||||||
window.FS_GENERAL_LAST_FM = "${FS_GENERAL_LAST_FM}";
|
window.FS_GENERAL_LAST_FM = "${FS_GENERAL_LAST_FM}";
|
||||||
window.FS_GENERAL_LASTFM_API_KEY = "${FS_GENERAL_LASTFM_API_KEY}";
|
window.FS_GENERAL_LASTFM_API_KEY = "${FS_GENERAL_LASTFM_API_KEY}";
|
||||||
|
window.FS_GENERAL_LISTEN_BRAINZ = "${FS_GENERAL_LISTEN_BRAINZ}";
|
||||||
window.FS_GENERAL_MUSIC_BRAINZ = "${FS_GENERAL_MUSIC_BRAINZ}";
|
window.FS_GENERAL_MUSIC_BRAINZ = "${FS_GENERAL_MUSIC_BRAINZ}";
|
||||||
window.FS_GENERAL_NATIVE_ASPECT_RATIO = "${FS_GENERAL_NATIVE_ASPECT_RATIO}";
|
window.FS_GENERAL_NATIVE_ASPECT_RATIO = "${FS_GENERAL_NATIVE_ASPECT_RATIO}";
|
||||||
window.FS_GENERAL_PATH_REPLACE = "${FS_GENERAL_PATH_REPLACE}";
|
window.FS_GENERAL_PATH_REPLACE = "${FS_GENERAL_PATH_REPLACE}";
|
||||||
window.FS_GENERAL_PATH_REPLACE_WITH = "${FS_GENERAL_PATH_REPLACE_WITH}";
|
window.FS_GENERAL_PATH_REPLACE_WITH = "${FS_GENERAL_PATH_REPLACE_WITH}";
|
||||||
window.FS_GENERAL_PLAYERBAR_OPEN_DRAWER = "${FS_GENERAL_PLAYERBAR_OPEN_DRAWER}";
|
window.FS_GENERAL_PLAYERBAR_OPEN_DRAWER = "${FS_GENERAL_PLAYERBAR_OPEN_DRAWER}";
|
||||||
window.FS_GENERAL_PRIMARY_SHADE = "${FS_GENERAL_PRIMARY_SHADE}";
|
window.FS_GENERAL_PRIMARY_SHADE = "${FS_GENERAL_PRIMARY_SHADE}";
|
||||||
|
window.FS_GENERAL_QOBUZ = "${FS_GENERAL_QOBUZ}";
|
||||||
window.FS_GENERAL_RESUME = "${FS_GENERAL_RESUME}";
|
window.FS_GENERAL_RESUME = "${FS_GENERAL_RESUME}";
|
||||||
window.FS_GENERAL_SHOW_LYRICS_IN_SIDEBAR = "${FS_GENERAL_SHOW_LYRICS_IN_SIDEBAR}";
|
window.FS_GENERAL_SHOW_LYRICS_IN_SIDEBAR = "${FS_GENERAL_SHOW_LYRICS_IN_SIDEBAR}";
|
||||||
window.FS_GENERAL_SHOW_RATINGS = "${FS_GENERAL_SHOW_RATINGS}";
|
window.FS_GENERAL_SHOW_RATINGS = "${FS_GENERAL_SHOW_RATINGS}";
|
||||||
@@ -39,6 +41,7 @@ window.FS_GENERAL_SIDEBAR_COLLAPSE_SHARED = "${FS_GENERAL_SIDEBAR_COLLAPSE_SHARE
|
|||||||
window.FS_GENERAL_SIDEBAR_PLAYLIST_LIST = "${FS_GENERAL_SIDEBAR_PLAYLIST_LIST}";
|
window.FS_GENERAL_SIDEBAR_PLAYLIST_LIST = "${FS_GENERAL_SIDEBAR_PLAYLIST_LIST}";
|
||||||
window.FS_GENERAL_SIDEBAR_PLAYLIST_SORTING = "${FS_GENERAL_SIDEBAR_PLAYLIST_SORTING}";
|
window.FS_GENERAL_SIDEBAR_PLAYLIST_SORTING = "${FS_GENERAL_SIDEBAR_PLAYLIST_SORTING}";
|
||||||
window.FS_GENERAL_SIDE_QUEUE_TYPE = "${FS_GENERAL_SIDE_QUEUE_TYPE}";
|
window.FS_GENERAL_SIDE_QUEUE_TYPE = "${FS_GENERAL_SIDE_QUEUE_TYPE}";
|
||||||
|
window.FS_GENERAL_SIDE_QUEUE_LAYOUT = "${FS_GENERAL_SIDE_QUEUE_LAYOUT}";
|
||||||
window.FS_GENERAL_THEME = "${FS_GENERAL_THEME}";
|
window.FS_GENERAL_THEME = "${FS_GENERAL_THEME}";
|
||||||
window.FS_GENERAL_THEME_DARK = "${FS_GENERAL_THEME_DARK}";
|
window.FS_GENERAL_THEME_DARK = "${FS_GENERAL_THEME_DARK}";
|
||||||
window.FS_GENERAL_THEME_LIGHT = "${FS_GENERAL_THEME_LIGHT}";
|
window.FS_GENERAL_THEME_LIGHT = "${FS_GENERAL_THEME_LIGHT}";
|
||||||
|
|||||||
@@ -21,7 +21,13 @@
|
|||||||
"openIn": {
|
"openIn": {
|
||||||
"lastfm": "فتح في Last.fm",
|
"lastfm": "فتح في Last.fm",
|
||||||
"musicbrainz": "فتح في MusicBrainz"
|
"musicbrainz": "فتح في MusicBrainz"
|
||||||
}
|
},
|
||||||
|
"addOrRemoveFromSelection": "إضافة أو إزالة من الإختيارات",
|
||||||
|
"selectRangeOfItems": "اختر مجموعة من العناصر",
|
||||||
|
"goToCurrent": "الانتقال إلى العنصر الحالي",
|
||||||
|
"createRadioStation": "يخلق $t(entity.radioStation, {\"count\": 1})",
|
||||||
|
"deleteRadioStation": "يمسح $t(entity.radioStation, {\"count\": 1})",
|
||||||
|
"selectAll": "تحديد الكل"
|
||||||
},
|
},
|
||||||
"common": {
|
"common": {
|
||||||
"action_zero": "عملية",
|
"action_zero": "عملية",
|
||||||
|
|||||||
@@ -301,6 +301,7 @@
|
|||||||
"forward": "endavant",
|
"forward": "endavant",
|
||||||
"manage": "gestiona",
|
"manage": "gestiona",
|
||||||
"mbid": "ID de MusicBrainz",
|
"mbid": "ID de MusicBrainz",
|
||||||
|
"grouping": "agrupament",
|
||||||
"noResultsFromQuery": "la petició no ha produït resultats",
|
"noResultsFromQuery": "la petició no ha produït resultats",
|
||||||
"path": "ruta",
|
"path": "ruta",
|
||||||
"playerMustBePaused": "cal pausar el reproductor",
|
"playerMustBePaused": "cal pausar el reproductor",
|
||||||
@@ -335,7 +336,8 @@
|
|||||||
"mood": "estat d'ànim",
|
"mood": "estat d'ànim",
|
||||||
"filter_single": "senzill",
|
"filter_single": "senzill",
|
||||||
"filter_multiple": "multi",
|
"filter_multiple": "multi",
|
||||||
"rename": "reanomena"
|
"rename": "reanomena",
|
||||||
|
"newVersionAvailable": "hi ha una nova versió disponible"
|
||||||
},
|
},
|
||||||
"entity": {
|
"entity": {
|
||||||
"album_one": "àlbum",
|
"album_one": "àlbum",
|
||||||
@@ -422,8 +424,7 @@
|
|||||||
"editPlaylist": {
|
"editPlaylist": {
|
||||||
"success": "$t(entity.playlist, {\"count\": 1}) s'ha actualitzat amb èxit",
|
"success": "$t(entity.playlist, {\"count\": 1}) s'ha actualitzat amb èxit",
|
||||||
"title": "editar la $t(entity.playlist, {\"count\": 1})",
|
"title": "editar la $t(entity.playlist, {\"count\": 1})",
|
||||||
"publicJellyfinNote": "Per algun motiu, Jellyfin no exposa si una llista de reproducció és pública o no. Si voleu que es mantingui pública, seleccioneu la següent entrada",
|
"publicJellyfinNote": "Per algun motiu, Jellyfin no exposa si una llista de reproducció és pública o no. Si voleu que es mantingui pública, seleccioneu la següent entrada"
|
||||||
"editNote": "es recomana no editar manualment les llistes de reproducció grans. segur que accepteu el risc de perdre dades si sobreescriviu la llista de reproducció existent?"
|
|
||||||
},
|
},
|
||||||
"lyricSearch": {
|
"lyricSearch": {
|
||||||
"input_artist": "$t(entity.artist, {\"count\": 1})",
|
"input_artist": "$t(entity.artist, {\"count\": 1})",
|
||||||
@@ -545,7 +546,8 @@
|
|||||||
"addOrRemoveFromSelection": "afegeix o elimina de la selecció",
|
"addOrRemoveFromSelection": "afegeix o elimina de la selecció",
|
||||||
"selectRangeOfItems": "selecciona un interval d'elements",
|
"selectRangeOfItems": "selecciona un interval d'elements",
|
||||||
"selectAll": "selecciona-ho tot",
|
"selectAll": "selecciona-ho tot",
|
||||||
"openApplicationDirectory": "obre el directori de l'aplicació"
|
"openApplicationDirectory": "obre el directori de l'aplicació",
|
||||||
|
"goToCurrent": "anar a l'element actual"
|
||||||
},
|
},
|
||||||
"setting": {
|
"setting": {
|
||||||
"language_description": "estableix l'idioma de l'aplicació ($t(common.restartRequired))",
|
"language_description": "estableix l'idioma de l'aplicació ($t(common.restartRequired))",
|
||||||
@@ -898,7 +900,17 @@
|
|||||||
"blurExplicitImages": "desenfoca imatges explícites",
|
"blurExplicitImages": "desenfoca imatges explícites",
|
||||||
"blurExplicitImages_description": "les caràtules d'àlbums i cançons marcades com a explícites quedaran desenfocades",
|
"blurExplicitImages_description": "les caràtules d'àlbums i cançons marcades com a explícites quedaran desenfocades",
|
||||||
"discordStateIcon": "mostra la icona de reproducció",
|
"discordStateIcon": "mostra la icona de reproducció",
|
||||||
"discordStateIcon_description": "mostra una petita icona de reproducció a l'estat d'activitat. l'icona de pausa es mostra quan \"mostra l'estat d'activitat quan està en pausa\" està activat"
|
"discordStateIcon_description": "mostra una petita icona de reproducció a l'estat d'activitat. l'icona de pausa es mostra quan \"mostra l'estat d'activitat quan està en pausa\" està activat",
|
||||||
|
"autosave": "desa automàticament la cua de reproducció",
|
||||||
|
"autosave_description": "activa el desament automàtic de la cua de reproducció al teu servidor. això només és possible quan s'utilitza Navidrome/Subsonic i no es pot tenir una cua de reproducció mixta.",
|
||||||
|
"autosaveCount": "freqüència de desament de cua de reproducció automàtica",
|
||||||
|
"autosaveCount_description": "quants canvis de pista abans que es desi la cua. 1 (mínim) significa cada canvi de cançó",
|
||||||
|
"useThemePrimaryShade": "utilitza l'ombra primària del tema",
|
||||||
|
"useThemePrimaryShade_description": "utilitza el to primari definit al tema seleccionat per a les variants de color primari",
|
||||||
|
"primaryShade": "ombra primària",
|
||||||
|
"primaryShade_description": "substitueix el to primari (0–9) utilitzat per a botons, enllaços i altres elements de color primari",
|
||||||
|
"playerItemConfiguration_description": "configurar quins elements es mostren i en quin ordre al reproductor de pantalla completa",
|
||||||
|
"playerItemConfiguration": "configuració d'elements del jugador"
|
||||||
},
|
},
|
||||||
"table": {
|
"table": {
|
||||||
"column": {
|
"column": {
|
||||||
@@ -998,7 +1010,8 @@
|
|||||||
"image": "imatge",
|
"image": "imatge",
|
||||||
"sampleRate": "$t(common.sampleRate)",
|
"sampleRate": "$t(common.sampleRate)",
|
||||||
"composer": "compositor",
|
"composer": "compositor",
|
||||||
"titleArtist": "$t(common.title) (artista)"
|
"titleArtist": "$t(common.title) (artista)",
|
||||||
|
"albumGroup": "grup d'àlbums"
|
||||||
},
|
},
|
||||||
"view": {
|
"view": {
|
||||||
"table": "taula",
|
"table": "taula",
|
||||||
@@ -1104,7 +1117,8 @@
|
|||||||
"sleepTimer_off": "apagat",
|
"sleepTimer_off": "apagat",
|
||||||
"sleepTimer_timeRemaining": "queden {{time}}",
|
"sleepTimer_timeRemaining": "queden {{time}}",
|
||||||
"sleepTimer_setCustom": "configura el temporitzador",
|
"sleepTimer_setCustom": "configura el temporitzador",
|
||||||
"sleepTimer_cancel": "cancel·la el temporitzador"
|
"sleepTimer_cancel": "cancel·la el temporitzador",
|
||||||
|
"albumRadio": "ràdio d'àlbums"
|
||||||
},
|
},
|
||||||
"error": {
|
"error": {
|
||||||
"credentialsRequired": "credencials requerides",
|
"credentialsRequired": "credencials requerides",
|
||||||
@@ -1137,7 +1151,8 @@
|
|||||||
"noNetwork": "servidor no disponible",
|
"noNetwork": "servidor no disponible",
|
||||||
"noNetworkDescription": "no s'ha pogut connectar amb el servidor",
|
"noNetworkDescription": "no s'ha pogut connectar amb el servidor",
|
||||||
"invalidJson": "JSON invàlid",
|
"invalidJson": "JSON invàlid",
|
||||||
"serverLockSingleServer": "només es permet un servidor quan el servidor està bloquejat"
|
"serverLockSingleServer": "només es permet un servidor quan el servidor està bloquejat",
|
||||||
|
"playbackPausedDueToError": "la reproducció s'ha pausat a causa d'un error"
|
||||||
},
|
},
|
||||||
"releaseType": {
|
"releaseType": {
|
||||||
"primary": {
|
"primary": {
|
||||||
|
|||||||
@@ -411,7 +411,21 @@
|
|||||||
"autosave": "automaticky ukládat frontu přehrávání",
|
"autosave": "automaticky ukládat frontu přehrávání",
|
||||||
"autosave_description": "zapnout automatické ukládání fronty přehrávání na server. toto je možné pouze při použití Navidrome/Subsonic a není možné mít kombinovanou frontu přehrávání.",
|
"autosave_description": "zapnout automatické ukládání fronty přehrávání na server. toto je možné pouze při použití Navidrome/Subsonic a není možné mít kombinovanou frontu přehrávání.",
|
||||||
"autosaveCount": "četnost automatického ukládání fronty přehrávání",
|
"autosaveCount": "četnost automatického ukládání fronty přehrávání",
|
||||||
"autosaveCount_description": "kolik změn skladeb se může provést před uložením fronty. 1 (minimum) znamená při každé změně skladby"
|
"autosaveCount_description": "kolik změn skladeb se může provést před uložením fronty. 1 (minimum) znamená při každé změně skladby",
|
||||||
|
"spotify_description": "na stránkách umělců a alb zobrazit odkazy na Spotify",
|
||||||
|
"spotify": "zobrazit odkazy na Spotify",
|
||||||
|
"nativeSpotify_description": "otevřít v aplikaci Spotify namísto vašeho prohlížeče",
|
||||||
|
"nativeSpotify": "použít aplikaci Spotify",
|
||||||
|
"listenbrainz_description": "na stránkách umělců a alb zobrazit odkazy na ListenBrainz",
|
||||||
|
"listenbrainz": "zobrazit odkazy na ListenBrainz",
|
||||||
|
"qobuz_description": "na stránkách umělců a alb zobrazit odkazy na Qobuz",
|
||||||
|
"qobuz": "zobrazit odkazy na Qobuz",
|
||||||
|
"sidePlayQueueLayout": "rozložení postranní fronty přehrávání",
|
||||||
|
"sidePlayQueueLayout_description": "nastaví rozložení postranní lišty přehrávání",
|
||||||
|
"sidePlayQueueLayout_optionHorizontal": "na šířku",
|
||||||
|
"sidePlayQueueLayout_optionVertical": "na výšku",
|
||||||
|
"waveformLoadingDelay": "zpoždění načítání vlnové křivky",
|
||||||
|
"waveformLoadingDelay_description": "zpoždění v sekundách před načtením vlnové křivky. zvyšte, pokud jste během používání webového přehrávače zaznamenali záseky."
|
||||||
},
|
},
|
||||||
"action": {
|
"action": {
|
||||||
"editPlaylist": "upravit $t(entity.playlist, {\"count\": 1})",
|
"editPlaylist": "upravit $t(entity.playlist, {\"count\": 1})",
|
||||||
@@ -433,7 +447,10 @@
|
|||||||
"removeFromFavorites": "odebrat z $t(entity.favorite, {\"count\": 2})",
|
"removeFromFavorites": "odebrat z $t(entity.favorite, {\"count\": 2})",
|
||||||
"openIn": {
|
"openIn": {
|
||||||
"lastfm": "Otevřít v Last.fm",
|
"lastfm": "Otevřít v Last.fm",
|
||||||
"musicbrainz": "Otevřít v MusicBrainz"
|
"musicbrainz": "Otevřít v MusicBrainz",
|
||||||
|
"spotify": "Otevřít na Spotify",
|
||||||
|
"listenbrainz": "Otevřít ve službě ListenBrainz",
|
||||||
|
"qobuz": "Otevřít ve službě Qobuz"
|
||||||
},
|
},
|
||||||
"moveToNext": "přesunout na další",
|
"moveToNext": "přesunout na další",
|
||||||
"downloadStarted": "spuštěno stahování {{count}} položek",
|
"downloadStarted": "spuštěno stahování {{count}} položek",
|
||||||
@@ -579,7 +596,9 @@
|
|||||||
"filter_single": "jeden",
|
"filter_single": "jeden",
|
||||||
"filter_multiple": "několik",
|
"filter_multiple": "několik",
|
||||||
"rename": "přejmenovat",
|
"rename": "přejmenovat",
|
||||||
"newVersionAvailable": "je dostupná nová verze"
|
"newVersionAvailable": "je dostupná nová verze",
|
||||||
|
"numberOfResults": "{{numberOfResults}} výsledků",
|
||||||
|
"grouping": "seskupování"
|
||||||
},
|
},
|
||||||
"table": {
|
"table": {
|
||||||
"config": {
|
"config": {
|
||||||
@@ -1045,8 +1064,7 @@
|
|||||||
"editPlaylist": {
|
"editPlaylist": {
|
||||||
"title": "upravit $t(entity.playlist, {\"count\": 1})",
|
"title": "upravit $t(entity.playlist, {\"count\": 1})",
|
||||||
"success": "$t(entity.playlist, {\"count\": 1}) úspěšně aktualizován",
|
"success": "$t(entity.playlist, {\"count\": 1}) úspěšně aktualizován",
|
||||||
"publicJellyfinNote": "Jellyfin z nějakého důvodu neukazuje, zda je seznam skladeb veřejný, nebo ne. Pokud si přejete, aby zůstal veřejný, zvolte prosím následující vstup",
|
"publicJellyfinNote": "Jellyfin z nějakého důvodu neukazuje, zda je seznam skladeb veřejný, nebo ne. Pokud si přejete, aby zůstal veřejný, zvolte prosím následující vstup"
|
||||||
"editNote": "ruční úpravy velkých seznamů skladeb nejsou doporučeny. opravdu přijímáte riziko ztráty dat, které může vzniknout přepsáním existujícího seznamu skladeb?"
|
|
||||||
},
|
},
|
||||||
"shareItem": {
|
"shareItem": {
|
||||||
"allowDownloading": "umožnit stahování",
|
"allowDownloading": "umožnit stahování",
|
||||||
|
|||||||
@@ -359,7 +359,6 @@
|
|||||||
},
|
},
|
||||||
"editPlaylist": {
|
"editPlaylist": {
|
||||||
"publicJellyfinNote": "Jellyfin viser af en eller anden grund ikke, om en playliste er offentlig eller ej. Hvis du ønsker, at den forbliver offentlig, skal du have følgende felt markeret",
|
"publicJellyfinNote": "Jellyfin viser af en eller anden grund ikke, om en playliste er offentlig eller ej. Hvis du ønsker, at den forbliver offentlig, skal du have følgende felt markeret",
|
||||||
"editNote": "manuelle ændringer anbefales ikke for store playlister. er du sikker på, at du accepterer risikoen for datatab ved at overskrive den eksisterende playliste?",
|
|
||||||
"success": "$t(entity.playlist, {\"count\": 1}) opdateret",
|
"success": "$t(entity.playlist, {\"count\": 1}) opdateret",
|
||||||
"title": "rediger $t(entity.playlist, {\"count\": 1})"
|
"title": "rediger $t(entity.playlist, {\"count\": 1})"
|
||||||
},
|
},
|
||||||
|
|||||||
+39
-10
@@ -19,7 +19,10 @@
|
|||||||
"removeFromFavorites": "Aus $t(entity.favorite, {\"count\": 2}) entfernen",
|
"removeFromFavorites": "Aus $t(entity.favorite, {\"count\": 2}) entfernen",
|
||||||
"openIn": {
|
"openIn": {
|
||||||
"lastfm": "Auf Last.fm öffnen",
|
"lastfm": "Auf Last.fm öffnen",
|
||||||
"musicbrainz": "Auf MusicBrainz öffnen"
|
"musicbrainz": "Auf MusicBrainz öffnen",
|
||||||
|
"listenbrainz": "In ListenBrainz öffnen",
|
||||||
|
"qobuz": "In Qobuz öffnen",
|
||||||
|
"spotify": "In Spotify öffnen"
|
||||||
},
|
},
|
||||||
"moveToNext": "Als nächstes",
|
"moveToNext": "Als nächstes",
|
||||||
"downloadStarted": "Download von {{count}} Elementen gestartet",
|
"downloadStarted": "Download von {{count}} Elementen gestartet",
|
||||||
@@ -37,7 +40,8 @@
|
|||||||
"addOrRemoveFromSelection": "Zur Auswahl hinzufügen oder entfernen",
|
"addOrRemoveFromSelection": "Zur Auswahl hinzufügen oder entfernen",
|
||||||
"selectRangeOfItems": "Wählen sie eine Reihe von Elementen",
|
"selectRangeOfItems": "Wählen sie eine Reihe von Elementen",
|
||||||
"holdToMoveToTop": "Halten um nach oben zu bewegen",
|
"holdToMoveToTop": "Halten um nach oben zu bewegen",
|
||||||
"holdToMoveToBottom": "Halten um nach unten zu bewegen"
|
"holdToMoveToBottom": "Halten um nach unten zu bewegen",
|
||||||
|
"goToCurrent": "Zu aktuellem Eintrag wechseln"
|
||||||
},
|
},
|
||||||
"common": {
|
"common": {
|
||||||
"backward": "zurück",
|
"backward": "zurück",
|
||||||
@@ -123,6 +127,7 @@
|
|||||||
"preview": "Vorschau",
|
"preview": "Vorschau",
|
||||||
"reload": "Neu Laden",
|
"reload": "Neu Laden",
|
||||||
"mbid": "MusicBrainz ID",
|
"mbid": "MusicBrainz ID",
|
||||||
|
"grouping": "gruppierung",
|
||||||
"close": "schließen",
|
"close": "schließen",
|
||||||
"share": "Teilen",
|
"share": "Teilen",
|
||||||
"translation": "Übersetzung",
|
"translation": "Übersetzung",
|
||||||
@@ -160,7 +165,9 @@
|
|||||||
"rename": "Umbenennen",
|
"rename": "Umbenennen",
|
||||||
"filter_single": "einzeln",
|
"filter_single": "einzeln",
|
||||||
"filter_multiple": "mehrfach",
|
"filter_multiple": "mehrfach",
|
||||||
"retry": "Wiederholen"
|
"retry": "Wiederholen",
|
||||||
|
"newVersionAvailable": "Eine neue Version ist verfügbar",
|
||||||
|
"numberOfResults": "{{numberOfResults}} Ergebnisse"
|
||||||
},
|
},
|
||||||
"error": {
|
"error": {
|
||||||
"remotePortWarning": "Starten Sie den Server neu, um den neuen Port anzuwenden",
|
"remotePortWarning": "Starten Sie den Server neu, um den neuen Port anzuwenden",
|
||||||
@@ -193,7 +200,8 @@
|
|||||||
"noNetworkDescription": "Verbindung zum Server konnte nicht hergestellt werden",
|
"noNetworkDescription": "Verbindung zum Server konnte nicht hergestellt werden",
|
||||||
"invalidJson": "JSON ungültig",
|
"invalidJson": "JSON ungültig",
|
||||||
"serverLockSingleServer": "Nur ein Server ist erlaubt, wenn der Server gesperrt ist",
|
"serverLockSingleServer": "Nur ein Server ist erlaubt, wenn der Server gesperrt ist",
|
||||||
"settingsSyncError": "Es wurden Unstimmigkeiten zwischen den Einstellungen im Renderer und dem Hauptprozess gefunden. Starte die Anwendung neu, um die Änderungen zu übernehmen"
|
"settingsSyncError": "Es wurden Unstimmigkeiten zwischen den Einstellungen im Renderer und dem Hauptprozess gefunden. Starte die Anwendung neu, um die Änderungen zu übernehmen",
|
||||||
|
"playbackPausedDueToError": "Die Wiedergabe wurde aufgrund eines Fehlers pausiert"
|
||||||
},
|
},
|
||||||
"filter": {
|
"filter": {
|
||||||
"mostPlayed": "Meistgespielt",
|
"mostPlayed": "Meistgespielt",
|
||||||
@@ -298,8 +306,7 @@
|
|||||||
"editPlaylist": {
|
"editPlaylist": {
|
||||||
"title": "Bearbeite $t(entity.playlist, {\"count\": 1})",
|
"title": "Bearbeite $t(entity.playlist, {\"count\": 1})",
|
||||||
"success": "$t(entity.playlist, {\"count\": 1}) erfolgreich aktualisiert",
|
"success": "$t(entity.playlist, {\"count\": 1}) erfolgreich aktualisiert",
|
||||||
"publicJellyfinNote": "Jellyfin legt aus irgendwelchen Gründen nicht offen ob eine Wiedergabeliste öffentlich ist oder nicht. Wenn du möchtest, dass sie öffentlich bleibt, wähle bitte diese Option aus",
|
"publicJellyfinNote": "Jellyfin legt aus irgendwelchen Gründen nicht offen ob eine Wiedergabeliste öffentlich ist oder nicht. Wenn du möchtest, dass sie öffentlich bleibt, wähle bitte diese Option aus"
|
||||||
"editNote": "Manuelles Bearbeiten wird für große Wiedergabelisten nicht empfohlen. Bist Du sicher, dass Du die aktuelle Wiedergabeliste unter dem Risiko von Datenverlust überschrieben möchtest?"
|
|
||||||
},
|
},
|
||||||
"lyricSearch": {
|
"lyricSearch": {
|
||||||
"title": "Songtext Suche",
|
"title": "Songtext Suche",
|
||||||
@@ -312,7 +319,9 @@
|
|||||||
"expireInvalid": "Ablaufdatum muss in der Zukunft liegen",
|
"expireInvalid": "Ablaufdatum muss in der Zukunft liegen",
|
||||||
"allowDownloading": "Herunterladen zulassen",
|
"allowDownloading": "Herunterladen zulassen",
|
||||||
"success": "Link in die Zwischenablage kopiert (oder hier klicken, um zu öffnen)",
|
"success": "Link in die Zwischenablage kopiert (oder hier klicken, um zu öffnen)",
|
||||||
"createFailed": "fehler beim Teilen (Ist Teilen aktiviert?)"
|
"createFailed": "fehler beim Teilen (Ist Teilen aktiviert?)",
|
||||||
|
"copyToClipboard": "In Zwischenablage kopieren: Strg+C, Enter",
|
||||||
|
"successMustClick": "Freigabe erfolgreich erstellt. Hier klicken um diese zu öffnen"
|
||||||
},
|
},
|
||||||
"privateMode": {
|
"privateMode": {
|
||||||
"enabled": "Privatmodus aktiviert, Wiedergabe-Status wird externen Quellen nicht preisgegeben",
|
"enabled": "Privatmodus aktiviert, Wiedergabe-Status wird externen Quellen nicht preisgegeben",
|
||||||
@@ -758,7 +767,8 @@
|
|||||||
"sleepTimer_custom": "Benutzerdefiniert",
|
"sleepTimer_custom": "Benutzerdefiniert",
|
||||||
"sleepTimer_hours": "{{count}} std",
|
"sleepTimer_hours": "{{count}} std",
|
||||||
"sleepTimer_minutes": "{{count}} min",
|
"sleepTimer_minutes": "{{count}} min",
|
||||||
"trackRadio": "Song Radio"
|
"trackRadio": "Song Radio",
|
||||||
|
"albumRadio": "Album Radio"
|
||||||
},
|
},
|
||||||
"setting": {
|
"setting": {
|
||||||
"audioDevice_description": "wählen Sie das Audiogerät aus, das für die Wiedergabe verwendet werden soll (nur Webplayer)",
|
"audioDevice_description": "wählen Sie das Audiogerät aus, das für die Wiedergabe verwendet werden soll (nur Webplayer)",
|
||||||
@@ -1094,7 +1104,24 @@
|
|||||||
"trayEnabled_description": "Tray-Symbol anzeigen/verbergen. Bei Deaktivierung werden auch Minimieren/Beenden zum Tray deaktiviert",
|
"trayEnabled_description": "Tray-Symbol anzeigen/verbergen. Bei Deaktivierung werden auch Minimieren/Beenden zum Tray deaktiviert",
|
||||||
"queryBuilder": "Abfrage-Editor",
|
"queryBuilder": "Abfrage-Editor",
|
||||||
"queryBuilderCustomFields_inputLabel": "Label",
|
"queryBuilderCustomFields_inputLabel": "Label",
|
||||||
"queryBuilderCustomFields_description": "Füge benutzerdefinierte Felder für den Abfrage-Editor hinzu"
|
"queryBuilderCustomFields_description": "Füge benutzerdefinierte Felder für den Abfrage-Editor hinzu",
|
||||||
|
"autosave": "Automatisch aktuelle Wiedergabeliste speichern",
|
||||||
|
"autosave_description": "Aktiviere die automatische Speicherung der aktuellen Wiedergabe auf dem Server. Diese Funktion ist nur bei Navidrome/Subsonic Servern verfügbar und es darf sich nicht um eine gemischte Wiedergabeliste handeln.",
|
||||||
|
"autosaveCount": "Häufigkeit der automatischen Speicherung bei Wiedergabelisten",
|
||||||
|
"autosaveCount_description": "Wieviele Lieder gespielt werden, bevor die Wiedergabeliste gespeichert wird. 1 (Minimum) bedeutet die Speicherung nach jedem gespielten Lied",
|
||||||
|
"useThemeAccentColor_description": "Verwendet die Primärfarbe des gewählten Themas anstatt einer ausgewählten Akzentfarbe",
|
||||||
|
"useThemePrimaryShade": "Primärschatten des Themas nutzen",
|
||||||
|
"useThemePrimaryShade_description": "Verwendet den Primärschatten des ausgewählten Themas als primäre Farbvarianten",
|
||||||
|
"primaryShade": "Primärschatten",
|
||||||
|
"listenbrainz": "ListenBrainz Links anzeigen",
|
||||||
|
"listenbrainz_description": "Zeige Links zu ListenBrainz auf den Interpreten/Alben Seiten",
|
||||||
|
"mpvExtraParameters": "Zusätzliche mpv Parameter",
|
||||||
|
"qobuz": "Qobuz Links anzeigen",
|
||||||
|
"spotify": "Spotify Links anzeigen",
|
||||||
|
"nativeSpotify": "Spotify App benutzen",
|
||||||
|
"qobuz_description": "Zeige Links zu Qobuz auf den Interpreten/Alben Seiten",
|
||||||
|
"spotify_description": "Zeige Links zu Spotify auf den Interpreten/Alben Seiten",
|
||||||
|
"artistReleaseTypeConfiguration": "Interpreten Release Typ Einstellung"
|
||||||
},
|
},
|
||||||
"dragDropZone": {
|
"dragDropZone": {
|
||||||
"error_oneFileOnly": "Bitte wähle nur 1 Datei",
|
"error_oneFileOnly": "Bitte wähle nur 1 Datei",
|
||||||
@@ -1255,6 +1282,8 @@
|
|||||||
"miscellaneousSettings": "Verschiedenes Einstellungen",
|
"miscellaneousSettings": "Verschiedenes Einstellungen",
|
||||||
"ansiBands": "ANSI Bänder",
|
"ansiBands": "ANSI Bänder",
|
||||||
"lowResolution": "Niedrige Auflösung",
|
"lowResolution": "Niedrige Auflösung",
|
||||||
"showFPS": "FPS anzeigen"
|
"showFPS": "FPS anzeigen",
|
||||||
|
"fadePeaks": "Spitzen abblenden",
|
||||||
|
"showPeaks": "Spitzen anzeigen"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,7 +37,10 @@
|
|||||||
"openApplicationDirectory": "open application directory",
|
"openApplicationDirectory": "open application directory",
|
||||||
"openIn": {
|
"openIn": {
|
||||||
"lastfm": "Open in Last.fm",
|
"lastfm": "Open in Last.fm",
|
||||||
"musicbrainz": "Open in MusicBrainz"
|
"listenbrainz": "Open in ListenBrainz",
|
||||||
|
"musicbrainz": "Open in MusicBrainz",
|
||||||
|
"qobuz": "Open in Qobuz",
|
||||||
|
"spotify": "Open in Spotify"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"common": {
|
"common": {
|
||||||
@@ -106,11 +109,13 @@
|
|||||||
"minimize": "minimize",
|
"minimize": "minimize",
|
||||||
"modified": "modified",
|
"modified": "modified",
|
||||||
"mbid": "MusicBrainz ID",
|
"mbid": "MusicBrainz ID",
|
||||||
|
"grouping": "grouping",
|
||||||
"mood": "mood",
|
"mood": "mood",
|
||||||
"name": "name",
|
"name": "name",
|
||||||
"no": "no",
|
"no": "no",
|
||||||
"none": "none",
|
"none": "none",
|
||||||
"noResultsFromQuery": "the query returned no results",
|
"noResultsFromQuery": "the query returned no results",
|
||||||
|
"numberOfResults": "{{numberOfResults}} results",
|
||||||
"noFilters": "no filters configured",
|
"noFilters": "no filters configured",
|
||||||
"note": "note",
|
"note": "note",
|
||||||
"ok": "ok",
|
"ok": "ok",
|
||||||
@@ -366,7 +371,6 @@
|
|||||||
},
|
},
|
||||||
"editPlaylist": {
|
"editPlaylist": {
|
||||||
"publicJellyfinNote": "Jellyfin for some reason does not expose whether a playlist is public or not. If you wish for this to remain public, please have the following input selected",
|
"publicJellyfinNote": "Jellyfin for some reason does not expose whether a playlist is public or not. If you wish for this to remain public, please have the following input selected",
|
||||||
"editNote": "manual edits are not recommended for large playlists. are you sure you accept the risk of data loss incurred by overwriting the existing playlist?",
|
|
||||||
"success": "$t(entity.playlist, {\"count\": 1}) updated successfully",
|
"success": "$t(entity.playlist, {\"count\": 1}) updated successfully",
|
||||||
"title": "edit $t(entity.playlist, {\"count\": 1})"
|
"title": "edit $t(entity.playlist, {\"count\": 1})"
|
||||||
},
|
},
|
||||||
@@ -897,6 +901,8 @@
|
|||||||
"language_description": "sets the language for the application ($t(common.restartRequired))",
|
"language_description": "sets the language for the application ($t(common.restartRequired))",
|
||||||
"lastfm_description": "show links to Last.fm on artist/album pages",
|
"lastfm_description": "show links to Last.fm on artist/album pages",
|
||||||
"lastfm": "show last.fm links",
|
"lastfm": "show last.fm links",
|
||||||
|
"listenbrainz_description": "show links to ListenBrainz on artist/album pages",
|
||||||
|
"listenbrainz": "show ListenBrainz links",
|
||||||
"lastfmApiKey_description": "the API key for {{lastfm}}. required for cover art",
|
"lastfmApiKey_description": "the API key for {{lastfm}}. required for cover art",
|
||||||
"lastfmApiKey": "{{lastfm}} API key",
|
"lastfmApiKey": "{{lastfm}} API key",
|
||||||
"lyricFetch_description": "fetch lyrics from various internet sources",
|
"lyricFetch_description": "fetch lyrics from various internet sources",
|
||||||
@@ -924,6 +930,12 @@
|
|||||||
"mpvExtraParameters_help": "one per line",
|
"mpvExtraParameters_help": "one per line",
|
||||||
"musicbrainz_description": "show links to MusicBrainz on artist/album pages, where MusicBrainz ID exists",
|
"musicbrainz_description": "show links to MusicBrainz on artist/album pages, where MusicBrainz ID exists",
|
||||||
"musicbrainz": "show MusicBrainz links",
|
"musicbrainz": "show MusicBrainz links",
|
||||||
|
"qobuz_description": "show links to Qobuz on artist/album pages",
|
||||||
|
"qobuz": "show Qobuz links",
|
||||||
|
"spotify_description": "show links to Spotify on artist/album pages",
|
||||||
|
"spotify": "show Spotify links",
|
||||||
|
"nativeSpotify_description": "open in the Spotify app instead of your browser",
|
||||||
|
"nativeSpotify": "use Spotify app",
|
||||||
"neteaseTranslation_description": "When enabled, fetches and displays translated lyrics from NetEase if available",
|
"neteaseTranslation_description": "When enabled, fetches and displays translated lyrics from NetEase if available",
|
||||||
"neteaseTranslation": "Enable NetEase translations",
|
"neteaseTranslation": "Enable NetEase translations",
|
||||||
"notify": "enable song notifications",
|
"notify": "enable song notifications",
|
||||||
@@ -1031,6 +1043,10 @@
|
|||||||
"sidePlayQueueStyle_description": "sets the style of the side play queue",
|
"sidePlayQueueStyle_description": "sets the style of the side play queue",
|
||||||
"sidePlayQueueStyle_optionAttached": "attached",
|
"sidePlayQueueStyle_optionAttached": "attached",
|
||||||
"sidePlayQueueStyle_optionDetached": "detached",
|
"sidePlayQueueStyle_optionDetached": "detached",
|
||||||
|
"sidePlayQueueLayout": "side play queue layout",
|
||||||
|
"sidePlayQueueLayout_description": "sets the layout of the attached side play queue",
|
||||||
|
"sidePlayQueueLayout_optionHorizontal": "horizontal",
|
||||||
|
"sidePlayQueueLayout_optionVertical": "vertical",
|
||||||
"mediaSession_description": "enables Media Session integration, displaying media controls and metadata in the system volume overlay and lock screen",
|
"mediaSession_description": "enables Media Session integration, displaying media controls and metadata in the system volume overlay and lock screen",
|
||||||
"mediaSession": "enable media session",
|
"mediaSession": "enable media session",
|
||||||
"sidePlayQueueStyle": "side play queue style",
|
"sidePlayQueueStyle": "side play queue style",
|
||||||
@@ -1066,6 +1082,8 @@
|
|||||||
"volumeWheelStep": "volume wheel step",
|
"volumeWheelStep": "volume wheel step",
|
||||||
"volumeWidth_description": "the width of the volume slider",
|
"volumeWidth_description": "the width of the volume slider",
|
||||||
"volumeWidth": "volume slider width",
|
"volumeWidth": "volume slider width",
|
||||||
|
"waveformLoadingDelay": "waveform loading delay",
|
||||||
|
"waveformLoadingDelay_description": "delay in seconds before loading waveform. increase this value if you are experiencing stutters when using the web player.",
|
||||||
"webAudio_description": "use web audio. this enables advanced features like replaygain. disable if you experience otherwise",
|
"webAudio_description": "use web audio. this enables advanced features like replaygain. disable if you experience otherwise",
|
||||||
"webAudio": "use web audio",
|
"webAudio": "use web audio",
|
||||||
"windowBarStyle_description": "select the style of the window bar",
|
"windowBarStyle_description": "select the style of the window bar",
|
||||||
|
|||||||
+29
-11
@@ -216,7 +216,7 @@
|
|||||||
"passwordStore": "contraseñas/almacenamiento secreto",
|
"passwordStore": "contraseñas/almacenamiento secreto",
|
||||||
"homeConfiguration": "Configuración de la página de inicio",
|
"homeConfiguration": "Configuración de la página de inicio",
|
||||||
"mpvExtraParameters_help": "Uno por línea",
|
"mpvExtraParameters_help": "Uno por línea",
|
||||||
"externalLinks_description": "Permite mostrar enlaces externos (Last.fm, MusicBrainz) en las páginas del artista/álbum",
|
"externalLinks_description": "Permite mostrar enlaces externos (Last.fm, MusicBrainz) en las páginas de artistas/álbumes",
|
||||||
"homeConfiguration_description": "Configura qué elementos son mostrados y en qué orden en la página de inicio",
|
"homeConfiguration_description": "Configura qué elementos son mostrados y en qué orden en la página de inicio",
|
||||||
"clearCacheSuccess": "Caché limpiada correctamente",
|
"clearCacheSuccess": "Caché limpiada correctamente",
|
||||||
"externalLinks": "Mostrar enlaces externos",
|
"externalLinks": "Mostrar enlaces externos",
|
||||||
@@ -243,13 +243,13 @@
|
|||||||
"transcodeFormat": "formato a transcodificar",
|
"transcodeFormat": "formato a transcodificar",
|
||||||
"transcodeFormat_description": "selecciona el formato a transcodificar. dejar vacío para que el servidor decida",
|
"transcodeFormat_description": "selecciona el formato a transcodificar. dejar vacío para que el servidor decida",
|
||||||
"albumBackground": "imagen de fondo del álbum",
|
"albumBackground": "imagen de fondo del álbum",
|
||||||
"albumBackground_description": "Añade una imagen de fondo a las páginas del álbum que contienen la carátula del álbum",
|
"albumBackground_description": "Añade una imagen de fondo a las páginas de álbumes que contienen la carátula del álbum",
|
||||||
"albumBackgroundBlur": "Tamaño de desenfoque de la imagen de fondo del álbum",
|
"albumBackgroundBlur": "Tamaño de desenfoque de la imagen de fondo del álbum",
|
||||||
"albumBackgroundBlur_description": "Ajusta la cantidad de desenfoque aplicado a la imagen de fondo del álbum",
|
"albumBackgroundBlur_description": "Ajusta la cantidad de desenfoque aplicado a la imagen de fondo del álbum",
|
||||||
"playerbarOpenDrawer": "Cambiar la barra del reproductor a pantalla completa",
|
"playerbarOpenDrawer": "Cambiar la barra del reproductor a pantalla completa",
|
||||||
"playerbarOpenDrawer_description": "Permite hacer clic en la barra del reproductor para abrir el reproductor a pantalla completa",
|
"playerbarOpenDrawer_description": "Permite hacer clic en la barra del reproductor para abrir el reproductor a pantalla completa",
|
||||||
"artistConfiguration": "Configuración de la página del artista del álbum",
|
"artistConfiguration": "Configuración de la página de artistas del álbum",
|
||||||
"artistConfiguration_description": "Configura qué elementos se muestran y en qué orden en la página del artista del álbum",
|
"artistConfiguration_description": "Configura qué elementos se muestran y en qué orden en la página de artistas del álbum",
|
||||||
"playButtonBehavior_optionPlayShuffled": "$t(player.shuffle)",
|
"playButtonBehavior_optionPlayShuffled": "$t(player.shuffle)",
|
||||||
"trayEnabled": "Mostrar en el área de notificación",
|
"trayEnabled": "Mostrar en el área de notificación",
|
||||||
"trayEnabled_description": "muestra/oculta el icono/menú del área de notificación. si está deshabilitado, también deshabilita minimizar/salir a la bandeja",
|
"trayEnabled_description": "muestra/oculta el icono/menú del área de notificación. si está deshabilitado, también deshabilita minimizar/salir a la bandeja",
|
||||||
@@ -293,7 +293,7 @@
|
|||||||
"releaseChannel_optionBeta": "Beta",
|
"releaseChannel_optionBeta": "Beta",
|
||||||
"releaseChannel": "Canal de lanzamiento",
|
"releaseChannel": "Canal de lanzamiento",
|
||||||
"releaseChannel_description": "Elige entre lanzamientos estables, beta, o alpha (nightly) para las actualizaciones automáticas",
|
"releaseChannel_description": "Elige entre lanzamientos estables, beta, o alpha (nightly) para las actualizaciones automáticas",
|
||||||
"artistBackground_description": "Añade una imagen de fondo para las páginas de artista que contienen el arte del artista",
|
"artistBackground_description": "Añade una imagen de fondo para las páginas de artistas que contienen el arte de los artistas",
|
||||||
"mediaSession": "Activar sesión de medios",
|
"mediaSession": "Activar sesión de medios",
|
||||||
"mediaSession_description": "Activa la integración de la sesión de medios, mostrando los controles de medios y los metadatos en la superposición del volumen del sistema y en la pantalla de bloqueo",
|
"mediaSession_description": "Activa la integración de la sesión de medios, mostrando los controles de medios y los metadatos en la superposición del volumen del sistema y en la pantalla de bloqueo",
|
||||||
"exportImportSettings_control_description": "Exporta e importa la configuración a través de JSON",
|
"exportImportSettings_control_description": "Exporta e importa la configuración a través de JSON",
|
||||||
@@ -370,7 +370,7 @@
|
|||||||
"combinedLyricsAndVisualizer_description": "Combina letras y visualizador en el mismo panel",
|
"combinedLyricsAndVisualizer_description": "Combina letras y visualizador en el mismo panel",
|
||||||
"combinedLyricsAndVisualizer": "Combinar letras y visualizador en la barra lateral del reproductor",
|
"combinedLyricsAndVisualizer": "Combinar letras y visualizador en la barra lateral del reproductor",
|
||||||
"artistReleaseTypeConfiguration": "Configuración de tipo de lanzamiento de artista",
|
"artistReleaseTypeConfiguration": "Configuración de tipo de lanzamiento de artista",
|
||||||
"artistReleaseTypeConfiguration_description": "Configura qué tipos de lanzamiento son mostrados, y en qué orden, en la página del artista del álbum",
|
"artistReleaseTypeConfiguration_description": "Configura qué tipos de lanzamiento son mostrados, y en qué orden, en la página de artistas del álbum",
|
||||||
"mpvExtraParameters": "Parámetros adicionales de MPV",
|
"mpvExtraParameters": "Parámetros adicionales de MPV",
|
||||||
"mpvExtraParameters_description": "Argumentos adicionales a pasar a MPV",
|
"mpvExtraParameters_description": "Argumentos adicionales a pasar a MPV",
|
||||||
"hotkey_listPlayDefault": "Reproducir lista",
|
"hotkey_listPlayDefault": "Reproducir lista",
|
||||||
@@ -411,7 +411,21 @@
|
|||||||
"autosave": "Guardar automáticamente la cola de reproducción",
|
"autosave": "Guardar automáticamente la cola de reproducción",
|
||||||
"autosaveCount": "Frecuencia de guardado automática de la cola de reproducción",
|
"autosaveCount": "Frecuencia de guardado automática de la cola de reproducción",
|
||||||
"autosave_description": "Permite guardar automáticamente la cola de reproducción en tu servidor. Esto solo es posible cuando se usa Navidrome/Subsonic, y no puedes tener una cola de reproducción mezclada.",
|
"autosave_description": "Permite guardar automáticamente la cola de reproducción en tu servidor. Esto solo es posible cuando se usa Navidrome/Subsonic, y no puedes tener una cola de reproducción mezclada.",
|
||||||
"autosaveCount_description": "Cuántas pistas cambian antes de que la cola sea guardada. 1 (mínimo) quiere decir que todas las canciones cambian"
|
"autosaveCount_description": "Cuántas pistas cambian antes de que la cola sea guardada. 1 (mínimo) quiere decir que todas las canciones cambian",
|
||||||
|
"spotify_description": "Muestra enlaces a Spotify en las páginas de artistas/álbumes",
|
||||||
|
"spotify": "Mostrar enlaces de Spotify",
|
||||||
|
"nativeSpotify_description": "Abre en la aplicación de Spotify en lugar de tu navegador",
|
||||||
|
"nativeSpotify": "Usar la aplicación de Spotify",
|
||||||
|
"listenbrainz": "Mostrar enlaces a ListenBrainz",
|
||||||
|
"listenbrainz_description": "Muestra enlaces a ListenBrainz en las páginas de artistas/álbumes",
|
||||||
|
"qobuz_description": "Muestra enlaces a Qobuz en las páginas de artistas/álbumes",
|
||||||
|
"qobuz": "Mostrar enlaces a Qobuz",
|
||||||
|
"sidePlayQueueLayout_optionHorizontal": "Horizontal",
|
||||||
|
"sidePlayQueueLayout_optionVertical": "Vertical",
|
||||||
|
"sidePlayQueueLayout": "Diseño de la cola de reproducción lateral",
|
||||||
|
"sidePlayQueueLayout_description": "Establece el diseño de la cola de reproducción lateral adjunta",
|
||||||
|
"waveformLoadingDelay": "Retraso de carga de la forma de onda",
|
||||||
|
"waveformLoadingDelay_description": "Retraso en segundos antes de cargar la forma de onda. Incrementa este valor si estás experimentando tartamudeos al usar el reproductor web."
|
||||||
},
|
},
|
||||||
"action": {
|
"action": {
|
||||||
"editPlaylist": "editar $t(entity.playlist, {\"count\": 1})",
|
"editPlaylist": "editar $t(entity.playlist, {\"count\": 1})",
|
||||||
@@ -433,7 +447,10 @@
|
|||||||
"removeFromFavorites": "eliminar de $t(entity.favorite, {\"count\": 2})",
|
"removeFromFavorites": "eliminar de $t(entity.favorite, {\"count\": 2})",
|
||||||
"openIn": {
|
"openIn": {
|
||||||
"lastfm": "Abrir en Last.fm",
|
"lastfm": "Abrir en Last.fm",
|
||||||
"musicbrainz": "Abrir en MusicBrainz"
|
"musicbrainz": "Abrir en MusicBrainz",
|
||||||
|
"spotify": "Abrir en Spotify",
|
||||||
|
"listenbrainz": "Abrir en ListenBrainz",
|
||||||
|
"qobuz": "Abrir en Qobuz"
|
||||||
},
|
},
|
||||||
"moveToNext": "pasar al siguiente",
|
"moveToNext": "pasar al siguiente",
|
||||||
"downloadStarted": "Iniciada descarga de {{count}} elementos",
|
"downloadStarted": "Iniciada descarga de {{count}} elementos",
|
||||||
@@ -579,7 +596,9 @@
|
|||||||
"filter_single": "simple",
|
"filter_single": "simple",
|
||||||
"filter_multiple": "multi",
|
"filter_multiple": "multi",
|
||||||
"rename": "Renombrar",
|
"rename": "Renombrar",
|
||||||
"newVersionAvailable": "Una nueva versión está disponible"
|
"newVersionAvailable": "Una nueva versión está disponible",
|
||||||
|
"numberOfResults": "{{numberOfResults}} resultados",
|
||||||
|
"grouping": "Agrupar"
|
||||||
},
|
},
|
||||||
"error": {
|
"error": {
|
||||||
"remotePortWarning": "reiniciar el servidor para aplicar el nuevo puerto",
|
"remotePortWarning": "reiniciar el servidor para aplicar el nuevo puerto",
|
||||||
@@ -927,8 +946,7 @@
|
|||||||
"editPlaylist": {
|
"editPlaylist": {
|
||||||
"title": "editar $t(entity.playlist, {\"count\": 1})",
|
"title": "editar $t(entity.playlist, {\"count\": 1})",
|
||||||
"success": "$t(entity.playlist, {\"count\": 1}) actualizada correctamente",
|
"success": "$t(entity.playlist, {\"count\": 1}) actualizada correctamente",
|
||||||
"publicJellyfinNote": "Jellyfin por alguna razón no expone si una lista de reproducción es pública o no. Si deseas que ésta siga siendo pública, por favor ten seleccionada la siguiente entrada",
|
"publicJellyfinNote": "Jellyfin por alguna razón no expone si una lista de reproducción es pública o no. Si deseas que ésta siga siendo pública, por favor ten seleccionada la siguiente entrada"
|
||||||
"editNote": "No se recomiendan las ediciones manuales para grandes listas de reproducción. ¿Seguro que aceptas el riesgo de pérdida de información incurrido por sobrescribir la lista de reproducción existente?"
|
|
||||||
},
|
},
|
||||||
"queryEditor": {
|
"queryEditor": {
|
||||||
"input_optionMatchAll": "coincidir todos",
|
"input_optionMatchAll": "coincidir todos",
|
||||||
|
|||||||
+27
-12
@@ -15,7 +15,10 @@
|
|||||||
"viewPlaylists": "ikusi $t(entity.playlist, {\"count\": 2})",
|
"viewPlaylists": "ikusi $t(entity.playlist, {\"count\": 2})",
|
||||||
"openIn": {
|
"openIn": {
|
||||||
"lastfm": "Ireki Last.fm-n",
|
"lastfm": "Ireki Last.fm-n",
|
||||||
"musicbrainz": "Ireki MusicBrainz-en"
|
"musicbrainz": "Ireki MusicBrainz-en",
|
||||||
|
"listenbrainz": "Ireki ListenBrainz-en",
|
||||||
|
"qobuz": "Ireki Qobuz-en",
|
||||||
|
"spotify": "Ireki Spotify-n"
|
||||||
},
|
},
|
||||||
"clearQueue": "garbitu ilara",
|
"clearQueue": "garbitu ilara",
|
||||||
"createPlaylist": "sortu $t(entity.playlist, {\"count\": 1})",
|
"createPlaylist": "sortu $t(entity.playlist, {\"count\": 1})",
|
||||||
@@ -33,7 +36,8 @@
|
|||||||
"shuffleAll": "nahastu dena",
|
"shuffleAll": "nahastu dena",
|
||||||
"shuffleSelected": "nahastu aukeratutak",
|
"shuffleSelected": "nahastu aukeratutak",
|
||||||
"moveItems": "elementuak mugitu",
|
"moveItems": "elementuak mugitu",
|
||||||
"openApplicationDirectory": "ireki aplikazioaren direktorioa"
|
"openApplicationDirectory": "ireki aplikazioaren direktorioa",
|
||||||
|
"goToCurrent": "joan uneko elementura"
|
||||||
},
|
},
|
||||||
"common": {
|
"common": {
|
||||||
"add": "gehitu",
|
"add": "gehitu",
|
||||||
@@ -67,8 +71,8 @@
|
|||||||
"filter_other": "iragazkiak",
|
"filter_other": "iragazkiak",
|
||||||
"filters": "iragazkiak",
|
"filters": "iragazkiak",
|
||||||
"forceRestartRequired": "berreabiarazi aldaketak aplikatzeko... itxi notifikazioa berreabiarazteko",
|
"forceRestartRequired": "berreabiarazi aldaketak aplikatzeko... itxi notifikazioa berreabiarazteko",
|
||||||
"setting_one": "ezarpenak",
|
"setting_one": "ezarpena",
|
||||||
"setting_other": "",
|
"setting_other": "ezarpenak",
|
||||||
"share": "partekatu",
|
"share": "partekatu",
|
||||||
"action_one": "ekintza",
|
"action_one": "ekintza",
|
||||||
"action_other": "ekintzak",
|
"action_other": "ekintzak",
|
||||||
@@ -150,7 +154,10 @@
|
|||||||
"recordLabel": "diskoetxea",
|
"recordLabel": "diskoetxea",
|
||||||
"example": "adibidea",
|
"example": "adibidea",
|
||||||
"tableColumns": "taulako zutabeak",
|
"tableColumns": "taulako zutabeak",
|
||||||
"doNotShowAgain": "ez erakutsi hau berriro"
|
"doNotShowAgain": "ez erakutsi hau berriro",
|
||||||
|
"numberOfResults": "{{numberOfResults}} emaitza",
|
||||||
|
"rename": "berrizendatu",
|
||||||
|
"newVersionAvailable": "bertsio berri bat eskuragarri dago"
|
||||||
},
|
},
|
||||||
"player": {
|
"player": {
|
||||||
"repeat": "errepikatu",
|
"repeat": "errepikatu",
|
||||||
@@ -351,7 +358,11 @@
|
|||||||
"noNetwork": "zerbitzaria ez dago erabilgarri",
|
"noNetwork": "zerbitzaria ez dago erabilgarri",
|
||||||
"noNetworkDescription": "ezin izan da zerbitzari honetara konektatu",
|
"noNetworkDescription": "ezin izan da zerbitzari honetara konektatu",
|
||||||
"saveQueueFailed": "huts egin du ilara gordetzean",
|
"saveQueueFailed": "huts egin du ilara gordetzean",
|
||||||
"multipleServerSaveQueueError": "erreprodukzio-ilarak zerbitzarikoak ez diren abesti bat edo gehiago ditu. hau ez da onartzen"
|
"multipleServerSaveQueueError": "erreprodukzio-ilarak zerbitzarikoak ez diren abesti bat edo gehiago ditu. hau ez da onartzen",
|
||||||
|
"invalidJson": "JSON baliogabea",
|
||||||
|
"playbackPausedDueToError": "erreprodukzioa eten egin da errore baten ondorioz",
|
||||||
|
"serverLockSingleServer": "zerbitzaria blokeatuta dagoenean, zerbitzari bakarra onartzen da",
|
||||||
|
"settingsSyncError": "desadostasunak aurkitu dira errendatzailearen ezarpenen eta prozesu nagusiaren artean. berrabiarazi aplikazioa aldaketak aplikatzeko"
|
||||||
},
|
},
|
||||||
"filter": {
|
"filter": {
|
||||||
"disc": "diskoa",
|
"disc": "diskoa",
|
||||||
@@ -371,7 +382,7 @@
|
|||||||
"biography": "biografia",
|
"biography": "biografia",
|
||||||
"bitrate": "bit-emaria",
|
"bitrate": "bit-emaria",
|
||||||
"bpm": "bpm-ak",
|
"bpm": "bpm-ak",
|
||||||
"channels": "$t(common.channel_other)",
|
"channels": "$t(common.channel, {\"count\": 2})",
|
||||||
"comment": "iruzkina",
|
"comment": "iruzkina",
|
||||||
"favorited": "gogoko gisa markatua",
|
"favorited": "gogoko gisa markatua",
|
||||||
"genre": "$t(entity.genre, {\"count\": 1})",
|
"genre": "$t(entity.genre, {\"count\": 1})",
|
||||||
@@ -396,7 +407,9 @@
|
|||||||
"releaseYear": "argitalpen urtea",
|
"releaseYear": "argitalpen urtea",
|
||||||
"toYear": "urtera arte",
|
"toYear": "urtera arte",
|
||||||
"fromYear": "urtetik aurrera",
|
"fromYear": "urtetik aurrera",
|
||||||
"explicitStatus": "$t(common.explicitStatus)"
|
"explicitStatus": "$t(common.explicitStatus)",
|
||||||
|
"matchAnd": "eta",
|
||||||
|
"matchOr": "edo"
|
||||||
},
|
},
|
||||||
"setting": {
|
"setting": {
|
||||||
"hotkey_playbackPause": "pausatu",
|
"hotkey_playbackPause": "pausatu",
|
||||||
@@ -731,15 +744,16 @@
|
|||||||
"editPlaylist": {
|
"editPlaylist": {
|
||||||
"success": "$t(entity.playlist, {\"count\": 1}) behar bezala eguneratu da",
|
"success": "$t(entity.playlist, {\"count\": 1}) behar bezala eguneratu da",
|
||||||
"title": "$t(entity.playlist, {\"count\": 1}) editatu",
|
"title": "$t(entity.playlist, {\"count\": 1}) editatu",
|
||||||
"publicJellyfinNote": "Arrazoiren batengatik, Jellyfin ez du erakusten erreprodukzio-zerrendak publikoak diren edo ez. Hau publiko izaten jarraitzea nahi baduzu, hautatu sarrera hau",
|
"publicJellyfinNote": "Arrazoiren batengatik, Jellyfin ez du erakusten erreprodukzio-zerrendak publikoak diren edo ez. Hau publiko izaten jarraitzea nahi baduzu, hautatu sarrera hau"
|
||||||
"editNote": "ez da gomendatzen eskuzko edizioak egitea erreprodukzio-zerrenda handietarako. ziur zaude onartzen duzula lehendik dagoen erreprodukzio-zerrenda gainidazteagatik datuak galtzeko arriskua?"
|
|
||||||
},
|
},
|
||||||
"queryEditor": {
|
"queryEditor": {
|
||||||
"title": "kontsulta editorea",
|
"title": "kontsulta editorea",
|
||||||
"input_optionMatchAll": "guztiak bat etorri",
|
"input_optionMatchAll": "guztiak bat etorri",
|
||||||
"input_optionMatchAny": "edozeinekin bat etorri",
|
"input_optionMatchAny": "edozeinekin bat etorri",
|
||||||
"resetToDefault": "lehenetsitako egoerara berrezarri",
|
"resetToDefault": "lehenetsitako egoerara berrezarri",
|
||||||
"clearFilters": "garbitu iragazkiak"
|
"clearFilters": "garbitu iragazkiak",
|
||||||
|
"addRuleGroup": "gehitu arau-taldea",
|
||||||
|
"removeRuleGroup": "kendu arau-taldea"
|
||||||
},
|
},
|
||||||
"updateServer": {
|
"updateServer": {
|
||||||
"success": "zerbitzaria behar bezala eguneratu da",
|
"success": "zerbitzaria behar bezala eguneratu da",
|
||||||
@@ -751,7 +765,8 @@
|
|||||||
"disabled": "modu pribatua desgaituta, erreprodukzio egoera ikusgai dago orain gaitutako kanpoko integrazioentzat"
|
"disabled": "modu pribatua desgaituta, erreprodukzio egoera ikusgai dago orain gaitutako kanpoko integrazioentzat"
|
||||||
},
|
},
|
||||||
"largeFetchConfirmation": {
|
"largeFetchConfirmation": {
|
||||||
"title": "gehitu elementuak ilaran"
|
"title": "gehitu elementuak ilaran",
|
||||||
|
"description": "Ekintza honek uneko iragazki-ikuspegian dauden elementu guztiak gehituko ditu"
|
||||||
},
|
},
|
||||||
"createRadioStation": {
|
"createRadioStation": {
|
||||||
"input_homepageUrl": "hasierako orriaren URLa",
|
"input_homepageUrl": "hasierako orriaren URLa",
|
||||||
|
|||||||
@@ -332,8 +332,7 @@
|
|||||||
"editPlaylist": {
|
"editPlaylist": {
|
||||||
"success": "$t(entity.playlist, {\"count\": 1}) päivitetty onnistuneesti",
|
"success": "$t(entity.playlist, {\"count\": 1}) päivitetty onnistuneesti",
|
||||||
"title": "muokkaa $t(entity.playlist, {\"count\": 1})",
|
"title": "muokkaa $t(entity.playlist, {\"count\": 1})",
|
||||||
"publicJellyfinNote": "Jellyfin ei jostain syystä kerro onko soittolista julkinen vai ei. Jos haluat sen pysyvän julkisena, pidä seuraava valinta valittuna",
|
"publicJellyfinNote": "Jellyfin ei jostain syystä kerro onko soittolista julkinen vai ei. Jos haluat sen pysyvän julkisena, pidä seuraava valinta valittuna"
|
||||||
"editNote": "manuaalisia muokkauksia ei suositella suurille soittolistoille. haluatko varmasti hyväksyä riskin, että nykyinen soittolista ylikirjoitetaan ja tietoja voi hävitä?"
|
|
||||||
},
|
},
|
||||||
"lyricSearch": {
|
"lyricSearch": {
|
||||||
"input_artist": "$t(entity.artist, {\"count\": 1})",
|
"input_artist": "$t(entity.artist, {\"count\": 1})",
|
||||||
|
|||||||
+192
-157
@@ -47,7 +47,8 @@
|
|||||||
"sleepTimer_off": "éteint",
|
"sleepTimer_off": "éteint",
|
||||||
"sleepTimer_timeRemaining": "{{time}} restante(s)",
|
"sleepTimer_timeRemaining": "{{time}} restante(s)",
|
||||||
"sleepTimer_setCustom": "définir le minuteur",
|
"sleepTimer_setCustom": "définir le minuteur",
|
||||||
"sleepTimer_cancel": "annuler le minuteur"
|
"sleepTimer_cancel": "annuler le minuteur",
|
||||||
|
"albumRadio": "radio d'album"
|
||||||
},
|
},
|
||||||
"action": {
|
"action": {
|
||||||
"editPlaylist": "éditer $t(entity.playlist, {\"count\": 1})",
|
"editPlaylist": "éditer $t(entity.playlist, {\"count\": 1})",
|
||||||
@@ -69,7 +70,10 @@
|
|||||||
"removeFromFavorites": "retirer des $t(entity.favorite, {\"count\": 2})",
|
"removeFromFavorites": "retirer des $t(entity.favorite, {\"count\": 2})",
|
||||||
"openIn": {
|
"openIn": {
|
||||||
"lastfm": "Ouvrir dans Last.fm",
|
"lastfm": "Ouvrir dans Last.fm",
|
||||||
"musicbrainz": "Ouvrir dans MusicBrainz"
|
"musicbrainz": "Ouvrir dans MusicBrainz",
|
||||||
|
"listenbrainz": "Ouvrir dans ListenBrainz",
|
||||||
|
"qobuz": "Ouvrir dans Qobuz",
|
||||||
|
"spotify": "Ouvrir dans Spotify"
|
||||||
},
|
},
|
||||||
"moveToNext": "passer au suivant",
|
"moveToNext": "passer au suivant",
|
||||||
"downloadStarted": "téléchargement de {{count}} éléments en cours",
|
"downloadStarted": "téléchargement de {{count}} éléments en cours",
|
||||||
@@ -87,7 +91,8 @@
|
|||||||
"addOrRemoveFromSelection": "ajouter ou supprimer de la sélection",
|
"addOrRemoveFromSelection": "ajouter ou supprimer de la sélection",
|
||||||
"selectRangeOfItems": "sélectionner une plage d'entrées",
|
"selectRangeOfItems": "sélectionner une plage d'entrées",
|
||||||
"selectAll": "tout sélectionner",
|
"selectAll": "tout sélectionner",
|
||||||
"openApplicationDirectory": "ouvrir le répertoire de l'application"
|
"openApplicationDirectory": "ouvrir le répertoire de l'application",
|
||||||
|
"goToCurrent": "aller à la piste en cours"
|
||||||
},
|
},
|
||||||
"common": {
|
"common": {
|
||||||
"backward": "en arrière",
|
"backward": "en arrière",
|
||||||
@@ -178,6 +183,7 @@
|
|||||||
"albumPeak": "crête de l'album",
|
"albumPeak": "crête de l'album",
|
||||||
"close": "fermer",
|
"close": "fermer",
|
||||||
"mbid": "Identifiant MusicBrainz",
|
"mbid": "Identifiant MusicBrainz",
|
||||||
|
"grouping": "regroupement",
|
||||||
"preview": "aperçu",
|
"preview": "aperçu",
|
||||||
"share": "partager",
|
"share": "partager",
|
||||||
"reload": "recharger",
|
"reload": "recharger",
|
||||||
@@ -214,7 +220,9 @@
|
|||||||
"retry": "réessayer",
|
"retry": "réessayer",
|
||||||
"filter_single": "unique",
|
"filter_single": "unique",
|
||||||
"filter_multiple": "multiple",
|
"filter_multiple": "multiple",
|
||||||
"rename": "renommer"
|
"rename": "renommer",
|
||||||
|
"newVersionAvailable": "une nouvelle version est disponible",
|
||||||
|
"numberOfResults": "{{numberOfResults}} résultats"
|
||||||
},
|
},
|
||||||
"error": {
|
"error": {
|
||||||
"remotePortWarning": "redémarrer le serveur pour appliquer le nouveau port",
|
"remotePortWarning": "redémarrer le serveur pour appliquer le nouveau port",
|
||||||
@@ -235,19 +243,20 @@
|
|||||||
"mpvRequired": "MPV requis",
|
"mpvRequired": "MPV requis",
|
||||||
"audioDeviceFetchError": "une erreur s’est produite lors de la tentative d’obtenir les périphériques audio",
|
"audioDeviceFetchError": "une erreur s’est produite lors de la tentative d’obtenir les périphériques audio",
|
||||||
"invalidServer": "serveur invalide",
|
"invalidServer": "serveur invalide",
|
||||||
"loginRateError": "trop de tentative de connexion, merci de réessayer dans quelques secondes",
|
"loginRateError": "trop de tentatives de connexion, merci de réessayer dans quelques secondes",
|
||||||
"openError": "impossible d'ouvrir le fichier",
|
"openError": "impossible d'ouvrir le fichier",
|
||||||
"networkError": "une erreur de réseau est survenue",
|
"networkError": "une erreur de réseau est survenue",
|
||||||
"badAlbum": "vous voyez cette page parce que ce titre ne fait pas parti d'un album. vous rencontrez probablement cette erreur si vous avez un titre à la racine de votre dossier musique. Jellyfin gère les chansons uniquement si elles sont dans un sous-dossier, qui est lui-même dans un dossier \"Musique(s)\"",
|
"badAlbum": "vous voyez cette page parce que ce titre ne fait pas parti d'un album. vous rencontrez probablement cette erreur si vous avez un titre à la racine de votre dossier musique. Jellyfin gère les chansons uniquement si elles sont dans un sous-dossier, qui est lui-même dans un dossier \"Musique(s)\"",
|
||||||
"badValue": "option {{value}} invalide. cette valeur n'existe plus",
|
"badValue": "option {{value}} invalide. cette valeur n'existe plus",
|
||||||
"notificationDenied": "les autorisations pour les notifications ont été refusées. ce paramètre n'a aucun effet",
|
"notificationDenied": "les autorisations pour les notifications ont été refusées. ce paramètre n'a aucun effet",
|
||||||
"multipleServerSaveQueueError": "la file d'attente de lecture contient un ou plusieurs morceaux qui ne proviennent pas du serveur actuel. Ceci n'est pas prise en charge",
|
"multipleServerSaveQueueError": "la file d'attente contient un ou plusieurs morceaux qui ne proviennent pas du serveur actuel. Ceci n'est pas prise en charge",
|
||||||
"saveQueueFailed": "échec de l'enregistrement de la file d'attente",
|
"saveQueueFailed": "échec de l'enregistrement de la file d'attente",
|
||||||
"settingsSyncError": "des incohérences ont été détectées entre les paramètres du moteur de rendu et ceux du processus principal. redémarrez l'application pour appliquer les modifications",
|
"settingsSyncError": "des incohérences ont été détectées entre les paramètres du moteur de rendu et ceux du processus principal. redémarrez l'application pour appliquer les modifications",
|
||||||
"noNetwork": "serveur indisponible",
|
"noNetwork": "serveur indisponible",
|
||||||
"noNetworkDescription": "impossible de se connecter à ce serveur",
|
"noNetworkDescription": "impossible de se connecter à ce serveur",
|
||||||
"invalidJson": "JSON invalide",
|
"invalidJson": "JSON invalide",
|
||||||
"serverLockSingleServer": "un seul serveur est autorisé quand le serveur est verrouillé"
|
"serverLockSingleServer": "un seul serveur est autorisé quand le serveur est verrouillé",
|
||||||
|
"playbackPausedDueToError": "la lecture a été suspendue en raison d'une erreur"
|
||||||
},
|
},
|
||||||
"filter": {
|
"filter": {
|
||||||
"mostPlayed": "les plus joués",
|
"mostPlayed": "les plus joués",
|
||||||
@@ -258,7 +267,7 @@
|
|||||||
"title": "titre",
|
"title": "titre",
|
||||||
"rating": "note",
|
"rating": "note",
|
||||||
"search": "recherche",
|
"search": "recherche",
|
||||||
"bitrate": "bitrate",
|
"bitrate": "bitrate binaire",
|
||||||
"recentlyAdded": "ajout récent",
|
"recentlyAdded": "ajout récent",
|
||||||
"note": "note",
|
"note": "note",
|
||||||
"name": "nom",
|
"name": "nom",
|
||||||
@@ -392,7 +401,7 @@
|
|||||||
"lyrics": "paroles",
|
"lyrics": "paroles",
|
||||||
"transcoding": "transcodage",
|
"transcoding": "transcodage",
|
||||||
"discord": "discord",
|
"discord": "discord",
|
||||||
"logger": "logger",
|
"logger": "journaliseur",
|
||||||
"playerFilters": "filtres du lecteur",
|
"playerFilters": "filtres du lecteur",
|
||||||
"lyricsDisplay": "affichage des paroles"
|
"lyricsDisplay": "affichage des paroles"
|
||||||
},
|
},
|
||||||
@@ -517,10 +526,10 @@
|
|||||||
"audioDevice": "périphérique audio",
|
"audioDevice": "périphérique audio",
|
||||||
"accentColor": "couleur d'accentuation",
|
"accentColor": "couleur d'accentuation",
|
||||||
"accentColor_description": "définit la couleur d'accentuation de l'application",
|
"accentColor_description": "définit la couleur d'accentuation de l'application",
|
||||||
"applicationHotkeys": "raccourcis clavier d'application",
|
"applicationHotkeys": "raccourcis clavier de l'application",
|
||||||
"crossfadeDuration": "durée de fondu enchaîné",
|
"crossfadeDuration": "durée du fondu enchaîné",
|
||||||
"audioPlayer": "lecteur audio",
|
"audioPlayer": "lecteur audio",
|
||||||
"applicationHotkeys_description": "configurer les raccourcis clavier d’application. activer la case à cocher pour définir comme raccourci clavier global (bureau uniquement)",
|
"applicationHotkeys_description": "configurer les raccourcis clavier de l'application. cocher la case pour définir comme raccourci clavier global (bureau uniquement)",
|
||||||
"crossfadeStyle_description": "sélectionnez le style du fondu enchaîné à utiliser pour le lecteur audio",
|
"crossfadeStyle_description": "sélectionnez le style du fondu enchaîné à utiliser pour le lecteur audio",
|
||||||
"customFontPath": "chemin de police personnalisé",
|
"customFontPath": "chemin de police personnalisé",
|
||||||
"customFontPath_description": "définit le chemin de police personnalisé pour l'application",
|
"customFontPath_description": "définit le chemin de police personnalisé pour l'application",
|
||||||
@@ -528,39 +537,39 @@
|
|||||||
"hotkey_skipBackward": "reculer",
|
"hotkey_skipBackward": "reculer",
|
||||||
"hotkey_playbackPause": "pause",
|
"hotkey_playbackPause": "pause",
|
||||||
"hotkey_volumeUp": "monter le volume",
|
"hotkey_volumeUp": "monter le volume",
|
||||||
"discordIdleStatus_description": "quand activé, mettre à jour le statut pendant que le lecteur est inactif",
|
"discordIdleStatus_description": "si activé, met à jour le statut pendant que le lecteur est inactif",
|
||||||
"showSkipButtons": "affiche les boutons suivants et précédents",
|
"showSkipButtons": "afficher les boutons suivants et précédents",
|
||||||
"minimumScrobblePercentage": "durée minimal du scobble (pourcentage)",
|
"minimumScrobblePercentage": "durée minimal du scobble (pourcentage)",
|
||||||
"lyricFetch": "récupérer les paroles depuis internet",
|
"lyricFetch": "récupérer les paroles depuis internet",
|
||||||
"scrobble": "scrobble",
|
"scrobble": "scrobble",
|
||||||
"enableRemote_description": "activer le serveur de contrôle à distance, qui permet à d'autres appareils de contrôler l'application",
|
"enableRemote_description": "activer le serveur de contrôle à distance qui permet à d'autres appareils de contrôler l'application",
|
||||||
"fontType_optionSystem": "police système",
|
"fontType_optionSystem": "police système",
|
||||||
"mpvExecutablePath_description": "définit le chemin vers l'exécutable mpv, si vide, le chemin par défaut sera utilisé",
|
"mpvExecutablePath_description": "définit le chemin vers l'exécutable mpv, si vide, le chemin par défaut sera utilisé",
|
||||||
"hotkey_favoriteCurrentSong": "favori $t(common.currentSong)",
|
"hotkey_favoriteCurrentSong": "ajouter la $t(common.currentSong) aux favoris",
|
||||||
"sampleRate": "taux d'échantillonnage",
|
"sampleRate": "taux d'échantillonnage",
|
||||||
"sampleRate_description": "sélectionne le taux d'échantillonnage de sortie utilisé si la fréquence d'échantillonnage sélectionnée est différente de celle du média actuel. une valeur inférieure à 8000 utilisera la fréquence par défaut",
|
"sampleRate_description": "sélectionne le taux d'échantillonnage de sortie à utilisé si la fréquence d'échantillonnage choisie est différente de celle du média en cours. une valeur inférieure à 8000 utilisera la fréquence par défaut",
|
||||||
"hotkey_zoomIn": "zoom avant",
|
"hotkey_zoomIn": "zoom avant",
|
||||||
"scrobble_description": "scrobbler les lectures à votre serveur multimédia",
|
"scrobble_description": "scrobbler les lectures à votre serveur multimédia",
|
||||||
"hotkey_browserForward": "avancer",
|
"hotkey_browserForward": "avancer (navigateur)",
|
||||||
"discordUpdateInterval": "intervalle de mise à jour de {{discord}} Rich Presence",
|
"discordUpdateInterval": "intervalle de mise à jour du statut d'activité {{discord}}",
|
||||||
"fontType_optionBuiltIn": "police intégrée",
|
"fontType_optionBuiltIn": "police intégrée",
|
||||||
"hotkey_playbackPlayPause": "lecture / pause",
|
"hotkey_playbackPlayPause": "lecture / pause",
|
||||||
"hotkey_rate1": "noter 1 étoile",
|
"hotkey_rate1": "noter 1 étoile",
|
||||||
"hotkey_skipForward": "avancer",
|
"hotkey_skipForward": "avancer",
|
||||||
"disableLibraryUpdateOnStartup": "désactive la recherche de mise à jour au démarrage",
|
"disableLibraryUpdateOnStartup": "désactive la recherche de mise à jour au démarrage",
|
||||||
"gaplessAudio": "audio sans interruption",
|
"gaplessAudio": "audio sans interruption",
|
||||||
"minimizeToTray_description": "réduit l'application vers la barre des tâches",
|
"minimizeToTray_description": "réduit l'application vers la barre d'état système",
|
||||||
"hotkey_playbackPlay": "lecture",
|
"hotkey_playbackPlay": "lecture",
|
||||||
"hotkey_togglePreviousSongFavorite": "basculer $t(common.previousSong) favoris",
|
"hotkey_togglePreviousSongFavorite": "basculer $t(common.previousSong) dans les favoris",
|
||||||
"hotkey_volumeDown": "baisser le volume",
|
"hotkey_volumeDown": "baisser le volume",
|
||||||
"hotkey_unfavoritePreviousSong": "défavorisé $t(common.previousSong)",
|
"hotkey_unfavoritePreviousSong": "retirer $t(common.previousSong) des favoris",
|
||||||
"globalMediaHotkeys": "raccourci clavier multimédia global",
|
"globalMediaHotkeys": "touches multimédias globales",
|
||||||
"hotkey_globalSearch": "recherche globale",
|
"hotkey_globalSearch": "recherche globale",
|
||||||
"gaplessAudio_description": "définit les paramètres d'audio sans interruption pour mpv",
|
"gaplessAudio_description": "définit les paramètres d'audio sans interruption pour mpv",
|
||||||
"remoteUsername_description": "définit le nom d'utilisateur du serveur de contrôle à distance. si le nom d'utilisateur et le mot de passe sont vides, l'authentification sera désactivée",
|
"remoteUsername_description": "définit le nom d'utilisateur du serveur de contrôle à distance. si le nom d'utilisateur et le mot de passe sont vides, l'authentification sera désactivée",
|
||||||
"exitToTray_description": "quitte l'application vers la barre des tâches",
|
"exitToTray_description": "quitte l'application vers la barre d'état système",
|
||||||
"followLyric_description": "faire défiler les paroles jusqu'à la position de lecture actuelle",
|
"followLyric_description": "faire défiler les paroles jusqu'à la position actuelle de lecture",
|
||||||
"hotkey_favoritePreviousSong": "favori $t(common.previousSong)",
|
"hotkey_favoritePreviousSong": "ajouter la $t(common.previousSong) aux favoris",
|
||||||
"lyricOffset": "décalage des paroles (ms)",
|
"lyricOffset": "décalage des paroles (ms)",
|
||||||
"discordUpdateInterval_description": "temps en seconde entre chaque mise à jour (minimum de 15 secondes)",
|
"discordUpdateInterval_description": "temps en seconde entre chaque mise à jour (minimum de 15 secondes)",
|
||||||
"fontType_optionCustom": "police personnalisée",
|
"fontType_optionCustom": "police personnalisée",
|
||||||
@@ -570,64 +579,64 @@
|
|||||||
"playbackStyle_optionCrossFade": "fondu enchaîné",
|
"playbackStyle_optionCrossFade": "fondu enchaîné",
|
||||||
"hotkey_rate3": "noter 3 étoiles",
|
"hotkey_rate3": "noter 3 étoiles",
|
||||||
"font": "police",
|
"font": "police",
|
||||||
"hotkey_toggleFullScreenPlayer": "basculer en plein écran",
|
"hotkey_toggleFullScreenPlayer": "basculer en lecture plein écran",
|
||||||
"hotkey_localSearch": "recherche dans la page",
|
"hotkey_localSearch": "recherche dans la page",
|
||||||
"hotkey_toggleQueue": "basculer la liste de lecteur",
|
"hotkey_toggleQueue": "basculer la file d'attente",
|
||||||
"remotePassword_description": "définit le mot de passe du serveur de contrôle à distance. Ces identifiants sont par défaut transmises de façon non sécurisées, donc vous devriez utiliser un mot de passe unique dont vous n'avez pas grand-chose à faire",
|
"remotePassword_description": "définit le mot de passe du serveur de contrôle à distance. Ces identifiants sont par défaut transmises de façon non sécurisées, donc vous devriez utiliser un mot de passe unique dont vous n'avez pas grand-chose à faire",
|
||||||
"hotkey_rate5": "noter 5 étoiles",
|
"hotkey_rate5": "noter 5 étoiles",
|
||||||
"hotkey_playbackPrevious": "piste précédente",
|
"hotkey_playbackPrevious": "piste précédente",
|
||||||
"showSkipButtons_description": "affiche ou cache les boutons suivants et précédents de la barre de lecture",
|
"showSkipButtons_description": "affiche ou masque les boutons suivants et précédents de la barre de lecture",
|
||||||
"playbackStyle": "style de lecture",
|
"playbackStyle": "style de lecture",
|
||||||
"hotkey_toggleShuffle": "basculer la lecture aléatoire",
|
"hotkey_toggleShuffle": "activer/désactiver la lecture aléatoire",
|
||||||
"playbackStyle_description": "sélectionnez le style de lecture à utiliser pour le lecteur audio",
|
"playbackStyle_description": "sélectionnez le style de lecture à utiliser pour le lecteur audio",
|
||||||
"discordRichPresence_description": "active l'état de lecteur dans le statut d'activité {{discord}}. Les images clés sont : {{icon}}, {{playing}}, et {{paused}}",
|
"discordRichPresence_description": "active l'état de lecture dans le statut d'activité {{discord}}. Les images clés sont : {{icon}}, {{playing}}, et {{paused}}",
|
||||||
"mpvExecutablePath": "chemin de l'exécutable mpv",
|
"mpvExecutablePath": "chemin de l'exécutable mpv",
|
||||||
"hotkey_rate2": "noter 2 étoiles",
|
"hotkey_rate2": "noter 2 étoiles",
|
||||||
"playButtonBehavior_description": "définit le comportement par défaut du bouton Jouer/Pause, lors de l'ajout de titres à la file d'attente",
|
"playButtonBehavior_description": "définit le comportement par défaut du bouton lecture, lors de l'ajout de titres à la file d'attente",
|
||||||
"minimumScrobblePercentage_description": "le pourcentage minimum de la chanson qui doit être joué avant qu'elle ne soit scrobblée",
|
"minimumScrobblePercentage_description": "le pourcentage minimum du titre qui doit être écouté avant qu’elle ne soit scrobblée",
|
||||||
"exitToTray": "quitter vers la barre des tâches",
|
"exitToTray": "quitter vers la barre d'état système",
|
||||||
"hotkey_rate4": "noter 4 étoiles",
|
"hotkey_rate4": "noter 4 étoiles",
|
||||||
"enableRemote": "activer le serveur de contrôle à distance",
|
"enableRemote": "activer le serveur de contrôle à distance",
|
||||||
"showSkipButton_description": "affiche ou cache les boutons suivants et précédents de la barre de lecture",
|
"showSkipButton_description": "affiche ou masque les boutons suivants et précédents de la barre de lecture",
|
||||||
"savePlayQueue": "sauvegarder la liste de lecture",
|
"savePlayQueue": "sauvegarder la file d'attente",
|
||||||
"minimumScrobbleSeconds_description": "la durée minimale en secondes de la chanson qui doit être jouée avant qu'elle ne soit scrobblée",
|
"minimumScrobbleSeconds_description": "la durée minimale en secondes du titre qui doit être écouté avant qu’elle ne soit scrobblée",
|
||||||
"fontType_description": "La police intégrée vous permet de sélectionner une des polices fourni par feishin. La police système vous permet de sélectionner une des polices fourni par votre système d'exploitation. L'option personnalisée vous permet d'importer votre propre police",
|
"fontType_description": "Police intégrée vous permet de sélectionner l'une des polices fournies par feishin. Police système vous permet de sélectionner une des polices fournies par votre système d'exploitation. Police personnalisée vous permet d'importer votre propre police",
|
||||||
"playButtonBehavior": "comportement du bouton play",
|
"playButtonBehavior": "comportement du bouton lecture",
|
||||||
"playbackStyle_optionNormal": "normale",
|
"playbackStyle_optionNormal": "normale",
|
||||||
"hotkey_toggleRepeat": "basculer la répétition",
|
"hotkey_toggleRepeat": "activer/désactiver la répétition",
|
||||||
"lyricOffset_description": "décale les paroles par le nombre de millisecondes spécifiées",
|
"lyricOffset_description": "décale les paroles du nombre de millisecondes spécifié",
|
||||||
"fontType": "type de police",
|
"fontType": "type de police",
|
||||||
"remotePort": "port du serveur de contrôle à distance",
|
"remotePort": "port du serveur de contrôle à distance",
|
||||||
"hotkey_playbackNext": "piste suivante",
|
"hotkey_playbackNext": "piste suivante",
|
||||||
"lyricFetch_description": "récupère les paroles depuis divers source d'internet",
|
"lyricFetch_description": "récupère les paroles depuis diverses sources d'internet",
|
||||||
"lyricFetchProvider_description": "sélectionnez les fournisseurs auprès desquels récupérer les paroles",
|
"lyricFetchProvider_description": "sélectionnez les fournisseurs auprès desquels récupérer les paroles",
|
||||||
"globalMediaHotkeys_description": "active ou désactive l'utilisation des raccourcis clavier multimédia système pour contrôler la lecture",
|
"globalMediaHotkeys_description": "active ou désactive l'utilisation des touches multimédias de votre système pour contrôler la lecture",
|
||||||
"followLyric": "suivre les paroles actuelles",
|
"followLyric": "suivre les paroles en cours",
|
||||||
"discordIdleStatus": "afficher l'état d'inactivité dans le statut de l'activité",
|
"discordIdleStatus": "afficher l'état d'inactivité dans le statut de l'activité",
|
||||||
"hotkey_zoomOut": "zoom arrière",
|
"hotkey_zoomOut": "zoom arrière",
|
||||||
"hotkey_unfavoriteCurrentSong": "retirer des favoris la $t(common.currentSong)",
|
"hotkey_unfavoriteCurrentSong": "retirer $t(common.currentSong) des favoris",
|
||||||
"hotkey_rate0": "supprimer la note",
|
"hotkey_rate0": "effacer la note",
|
||||||
"hotkey_volumeMute": "couper le son",
|
"hotkey_volumeMute": "couper le son",
|
||||||
"hotkey_toggleCurrentSongFavorite": "basculer favori de la $t(common.currentSong)",
|
"hotkey_toggleCurrentSongFavorite": "basculer $t(common.currentSong) dans les favoris",
|
||||||
"remoteUsername": "nom d'utilisateur du serveur de contrôle à distance",
|
"remoteUsername": "nom d'utilisateur du serveur de contrôle à distance",
|
||||||
"hotkey_browserBack": "retour arrière",
|
"hotkey_browserBack": "revenir en arrière (navigateur)",
|
||||||
"showSkipButton": "afficher les boutons suivants et précédents",
|
"showSkipButton": "afficher les boutons suivants et précédents",
|
||||||
"minimizeToTray": "réduire vers la barre des tâches",
|
"minimizeToTray": "réduire vers la barre d'état système",
|
||||||
"gaplessAudio_optionWeak": "faible (recommandée)",
|
"gaplessAudio_optionWeak": "faible (recommandée)",
|
||||||
"minimumScrobbleSeconds": "scrobble minimum (secondes)",
|
"minimumScrobbleSeconds": "scrobble minimum (secondes)",
|
||||||
"hotkey_playbackStop": "stop",
|
"hotkey_playbackStop": "stop",
|
||||||
"font_description": "définit la police à utiliser pour l'application",
|
"font_description": "définit la police à utiliser pour l'application",
|
||||||
"savePlayQueue_description": "sauvegarde la liste de lecture quand l'application est fermée et la restaure quand l'application est ouverte",
|
"savePlayQueue_description": "sauvegarde la file d'attente quand l'application est fermée et la restaure quand l'application est ouverte",
|
||||||
"sidebarCollapsedNavigation_description": "affiche ou cache la navigation dans la barre latérale réduite",
|
"sidebarCollapsedNavigation_description": "affiche ou masque les boutons de navigation dans la barre latérale réduite",
|
||||||
"sidebarConfiguration": "configuration de la barre latérale",
|
"sidebarConfiguration": "configuration de la barre latérale",
|
||||||
"sidebarConfiguration_description": "sélectionnez les éléments et l'ordre dans lequel ils seront affichés dans la barre latérale",
|
"sidebarConfiguration_description": "sélectionnez les éléments et l'ordre dans lequel ils seront affichés dans la barre latérale",
|
||||||
"sidebarPlaylistList": "liste des listes de lecture de la barre latérale",
|
"sidebarPlaylistList": "listes de lecture de la barre latérale",
|
||||||
"sidebarCollapsedNavigation": "navigation de la barre latérale (réduite)",
|
"sidebarCollapsedNavigation": "boutons de navigation de la barre latérale (réduite)",
|
||||||
"skipDuration": "durée de l'avance rapide",
|
"skipDuration": "durée de l'avance rapide",
|
||||||
"sidePlayQueueStyle_optionAttached": "attaché",
|
"sidePlayQueueStyle_optionAttached": "attaché",
|
||||||
"sidePlayQueueStyle": "style de la liste de lecture latérale",
|
"sidePlayQueueStyle": "style de la file d'attente latérale",
|
||||||
"sidebarPlaylistList_description": "affiche ou cache la liste des listes de lecture de la barre latérale",
|
"sidebarPlaylistList_description": "affiche ou masque les listes de lecture de la barre latérale",
|
||||||
"sidePlayQueueStyle_description": "définit le style de la liste de lecture latérale",
|
"sidePlayQueueStyle_description": "définit le style de la file d'attente",
|
||||||
"sidePlayQueueStyle_optionDetached": "détaché",
|
"sidePlayQueueStyle_optionDetached": "détaché",
|
||||||
"volumeWheelStep_description": "la valeur de volume à modifier lors du défilement de la molette de la souris sur le curseur de volume",
|
"volumeWheelStep_description": "la valeur de volume à modifier lors du défilement de la molette de la souris sur le curseur de volume",
|
||||||
"theme_description": "définit le thème à utiliser pour l'application",
|
"theme_description": "définit le thème à utiliser pour l'application",
|
||||||
@@ -639,12 +648,12 @@
|
|||||||
"zoom_description": "définit le pourcentage de zoom de l'application",
|
"zoom_description": "définit le pourcentage de zoom de l'application",
|
||||||
"theme": "thème",
|
"theme": "thème",
|
||||||
"skipPlaylistPage_description": "lors de la navigation dans une liste de lecture, aller directement vers la liste des titres, au lieu de la page par défaut",
|
"skipPlaylistPage_description": "lors de la navigation dans une liste de lecture, aller directement vers la liste des titres, au lieu de la page par défaut",
|
||||||
"volumeWheelStep": "valeur du pas de volume",
|
"volumeWheelStep": "pas de la molette de volume",
|
||||||
"windowBarStyle": "style de la barre de la fenêtre",
|
"windowBarStyle": "style de la barre de la fenêtre",
|
||||||
"useSystemTheme_description": "suivre les préférences du système (mode clair ou sombre)",
|
"useSystemTheme_description": "suivre les préférences du système (mode clair ou sombre)",
|
||||||
"skipPlaylistPage": "sauter la page de listes de lecture",
|
"skipPlaylistPage": "sauter la page de listes de lecture",
|
||||||
"themeDark": "thème (sombre)",
|
"themeDark": "thème (sombre)",
|
||||||
"windowBarStyle_description": "ajuster le style de la barre de la fenêtre",
|
"windowBarStyle_description": "sélectionner le style de la barre de la fenêtre",
|
||||||
"useSystemTheme": "utiliser le thème du système",
|
"useSystemTheme": "utiliser le thème du système",
|
||||||
"discordApplicationId_description": "l'identifiant de l'application pour le statut d'activité {{discord}} (par défaut à {{defaultId}})",
|
"discordApplicationId_description": "l'identifiant de l'application pour le statut d'activité {{discord}} (par défaut à {{defaultId}})",
|
||||||
"audioExclusiveMode": "mode de sortie audio exclusif",
|
"audioExclusiveMode": "mode de sortie audio exclusif",
|
||||||
@@ -655,106 +664,106 @@
|
|||||||
"replayGainMode_optionAlbum": "$t(entity.album, {\"count\": 1})",
|
"replayGainMode_optionAlbum": "$t(entity.album, {\"count\": 1})",
|
||||||
"replayGainMode_optionTrack": "$t(entity.track, {\"count\": 1})",
|
"replayGainMode_optionTrack": "$t(entity.track, {\"count\": 1})",
|
||||||
"playButtonBehavior_optionAddNext": "$t(player.addNext)",
|
"playButtonBehavior_optionAddNext": "$t(player.addNext)",
|
||||||
"replayGainMode_description": "ajuste le gain de volume accordement à la valeur de {{ReplayGain}} sauvegardé dans les métadonnées du fichier",
|
"replayGainMode_description": "ajuste le gain du volume selon les valeurs de {{ReplayGain}} enregistrées dans les métadonnées du fichier",
|
||||||
"replayGainFallback": "valeur de repli {{ReplayGain}}",
|
"replayGainFallback": "valeur de repli de {{ReplayGain}}",
|
||||||
"replayGainClipping_description": "Prévient le clipping causé par {{ReplayGain}} en baissant automatiquement le gain",
|
"replayGainClipping_description": "empêcher la distorsion causée par {{ReplayGain}} en réduisant automatiquement le gain",
|
||||||
"replayGainPreamp": "préamplificateur (dB) de {{ReplayGain}}",
|
"replayGainPreamp": "préamplificateur (dB) de {{ReplayGain}}",
|
||||||
"replayGainClipping": "écrêtage {{ReplayGain}}",
|
"replayGainClipping": "distorsion du {{ReplayGain}}",
|
||||||
"replayGainMode": "mode de {{ReplayGain}}",
|
"replayGainMode": "mode de {{ReplayGain}}",
|
||||||
"replayGainFallback_description": "gain en dB à appliquer si le fichier n'a pas de tag {{ReplayGain}}",
|
"replayGainFallback_description": "gain en dB à appliquer si le fichier n'a pas de tags de {{ReplayGain}}",
|
||||||
"replayGainPreamp_description": "ajuste le gain de préampli appliqué a la valeur de {{ReplayGain}}",
|
"replayGainPreamp_description": "ajuste le gain de préampli appliqué aux valeurs {{ReplayGain}}",
|
||||||
"clearQueryCache": "vide le cache de feishin",
|
"clearQueryCache": "vide le cache de feishin",
|
||||||
"clearCache": "vider le cache navigateur",
|
"clearCache": "vider le cache du navigateur",
|
||||||
"buttonSize_description": "la taille des boutons de la barre de lecture",
|
"buttonSize_description": "la taille des boutons de la barre de lecture",
|
||||||
"clearQueryCache_description": "un 'soft clear' de Feishin. cela actualisera les liste de lecture, les métadonnées des titres, et réinitialisera les paroles enregistrées. les paramètres, identifiants du serveur et images mises en cache seront conservés",
|
"clearQueryCache_description": "un 'nettoyage léger' de feishin. cela actualisera les listes de lecture, les métadonnées des titres, et réinitialisera les paroles enregistrées. les paramètres, identifiants du serveur et images mises en cache seront conservés",
|
||||||
"clearCache_description": "un 'hard clear' de feishin. en plus de vider le cache de feishin, vide le cache du navigateur (images sauvegardées et autres ressources). les identifiants serveurs et paramètres sont conservés",
|
"clearCache_description": "un 'nettoyage complet' de feishin. en plus de vider le cache de feishin, vide le cache du navigateur (images sauvegardées et autres ressources). les identifiants serveurs et paramètres sont conservés",
|
||||||
"buttonSize": "taille des boutons du lecteur",
|
"buttonSize": "taille des boutons de la barre de lecture",
|
||||||
"clearCacheSuccess": "le cache a été vidé",
|
"clearCacheSuccess": "cache vidé avec succès",
|
||||||
"externalLinks_description": "activer l'affichage de liens externes (Last.fm, MusicBrainz) sur la page de l'artiste/album",
|
"externalLinks_description": "activer l'affichage de liens externes (Last.fm, MusicBrainz) sur les pages d'artiste/album",
|
||||||
"startMinimized_description": "démarrer l'application dans la barre des tâches",
|
"startMinimized_description": "démarrer l'application dans la barre d'état système",
|
||||||
"externalLinks": "afficher les liens externes",
|
"externalLinks": "afficher les liens externes",
|
||||||
"homeConfiguration": "configuration de la page d'accueil",
|
"homeConfiguration": "configuration de la page d'accueil",
|
||||||
"homeFeature": "carrousel de la page d'accueil",
|
"homeFeature": "carrousel de la page d'accueil",
|
||||||
"homeFeature_description": "active ou désactive le carrousel sur la page d'accueil",
|
"homeFeature_description": "contrôle l’affichage du carrousel principal sur la page d’accueil",
|
||||||
"imageAspectRatio": "utiliser le rapport hauteur/largeur natif de la pochette d'album",
|
"imageAspectRatio": "utiliser le rapport hauteur/largeur natif de la pochette d'album",
|
||||||
"imageAspectRatio_description": "si cette option est activée, les pochettes d'album seront affichées en utilisant leur rapport hauteur/largeur natif. pour les pochettes qui n'ont pas un rapport 1:1 (carré), l'espace restant sera vide",
|
"imageAspectRatio_description": "si cette option est activée, les pochettes d'album seront affichées en utilisant leur rapport hauteur/largeur natif. pour les pochettes qui n'ont pas un rapport 1:1 (carré), l'espace restant sera vide",
|
||||||
"mpvExtraParameters_help": "un par ligne",
|
"mpvExtraParameters_help": "un par ligne",
|
||||||
"passwordStore_description": "quel mot de passe utiliser. changez cela si vous rencontrez des problèmes pour stocker les mots de passe",
|
"passwordStore_description": "quel gestionnaire de mots de passe/secret utiliser. modifiez ceci si vous rencontrez des problèmes pour stocker les mots de passe",
|
||||||
"passwordStore": "mots de passe",
|
"passwordStore": "gestionnaire de mots de passe/secrets",
|
||||||
"homeConfiguration_description": "configurer quels éléments sont affichés sur la page d'accueil, et dans quel ordre",
|
"homeConfiguration_description": "configurer quels éléments sont affichés sur la page d'accueil, et dans quel ordre",
|
||||||
"startMinimized": "démarrer l'application en mode réduit",
|
"startMinimized": "démarrer l'application en mode réduit",
|
||||||
"transcode_description": "permet le transcodage vers différents formats",
|
"transcode_description": "permet le transcodage vers différents formats",
|
||||||
"transcodeBitrate_description": "sélectionne le débit du transcodage. 0 signifie que le serveur choisit",
|
"transcodeBitrate_description": "sélectionne le débit binaire du transcodage. 0 signifie que le serveur choisit",
|
||||||
"transcodeFormat_description": "sélectionne le format du transcodage. laisser vide pour laisser le serveur décider",
|
"transcodeFormat_description": "sélectionne le format du transcodage. laisser vide pour laisser le serveur décider",
|
||||||
"volumeWidth": "largeur de la barre de volume",
|
"volumeWidth": "largeur de la barre de volume",
|
||||||
"volumeWidth_description": "la largeur de la barre de volume",
|
"volumeWidth_description": "la largeur de la barre de volume",
|
||||||
"customCssEnable": "activer le css personnalisé",
|
"customCssEnable": "active le css personnalisé",
|
||||||
"customCssEnable_description": "permet d'écrire du css personnalisé",
|
"customCssEnable_description": "permet l'écriture de css personnalisé",
|
||||||
"customCssNotice": "Attention : bien qu'il y ait un certain assainissement (blocage de url() et de content :), l'utilisation de css personnalisé peut toujours présenter des risques en modifiant l'interface",
|
"customCssNotice": "Attention : bien qu'il y ait un certain assainissement (blocage de url() et de content :), l'utilisation de css personnalisé peut toujours présenter des risques en modifiant l'interface",
|
||||||
"customCss": "css personnalisé",
|
"customCss": "css personnalisé",
|
||||||
"webAudio": "utiliser l'audio web",
|
"webAudio": "utiliser l'audio web",
|
||||||
"transcodeBitrate": "débit binaire du transcodage",
|
"transcodeBitrate": "débit binaire du transcodage",
|
||||||
"transcodeFormat": "format de transcodage",
|
"transcodeFormat": "format de transcodage",
|
||||||
"webAudio_description": "utiliser l'audio web. cela permet d'utiliser des fonctions avancées comme le replaygain. désactivez si vous rencontrez d'autres problèmes",
|
"webAudio_description": "utiliser l'audio web. cela permet d'utiliser des fonctions avancées comme le replaygain. désactivez si cela cause des problèmes",
|
||||||
"artistConfiguration": "page de configuration de l'artiste de l'album",
|
"artistConfiguration": "configuration de la page d'artiste d'album",
|
||||||
"artistConfiguration_description": "configurer les éléments et l'ordre à afficher, sur la page de l'artiste de l'album",
|
"artistConfiguration_description": "configurer les éléments et l'ordre à afficher, sur la page d'artiste d'album",
|
||||||
"contextMenu": "configuration du menu contextuel (clic droit)",
|
"contextMenu": "configuration du menu contextuel (clic droit)",
|
||||||
"contextMenu_description": "permet de masquer les éléments qui s'affichent dans le menu lorsque vous cliquez avec le bouton droit de la souris sur un élément. les éléments qui ne sont pas cochés seront masqués",
|
"contextMenu_description": "permet de masquer les éléments qui s'affichent dans le menu lorsque vous cliquez droit sur une entrée. les éléments qui ne sont pas cochés seront masqués",
|
||||||
"albumBackground": "image d'arrière-plan de l'album",
|
"albumBackground": "image d'arrière-plan de l'album",
|
||||||
"albumBackground_description": "ajoute une image d'arrière-plan pour les pages de l'album contenant une pochette d'album",
|
"albumBackground_description": "ajoute une image d'arrière-plan pour les pages d'album contenant une pochette d'album",
|
||||||
"albumBackgroundBlur_description": "ajuste le niveau de flou appliqué à l'image d'arrière-plan de l'album",
|
"albumBackgroundBlur_description": "ajuste le niveau de flou appliqué à l'image d'arrière-plan de l'album",
|
||||||
"playButtonBehavior_optionPlayShuffled": "$t(player.shuffle)",
|
"playButtonBehavior_optionPlayShuffled": "$t(player.shuffle)",
|
||||||
"playerbarOpenDrawer": "basculement plein écran de la barre de lecteur",
|
"playerbarOpenDrawer": "basculement en plein écran de la barre de lecture",
|
||||||
"playerbarOpenDrawer_description": "permet de cliquer sur la barre du lecteur pour ouvrir le lecteur plein écran",
|
"playerbarOpenDrawer_description": "permet de cliquer sur la barre de lecture pour ouvrir le lecteur plein écran",
|
||||||
"translationApiProvider": "fournisseur d'api de traduction",
|
"translationApiProvider": "fournisseur d'API de traduction",
|
||||||
"discordListening": "afficher le statut d'écoute",
|
"discordListening": "afficher le status en \"écoute\"",
|
||||||
"discordListening_description": "afficher le statut comme étant en écoute au lieu de lecture",
|
"discordListening_description": "afficher le statut comme étant en écoute au lieu de jouer",
|
||||||
"translationApiKey_description": "clé api à utiliser pour traduire les paroles (ne prend en charge que les points de terminaison de service globaux)",
|
"translationApiKey_description": "clé API pour la traduction (point de terminaison global uniquement)",
|
||||||
"translationTargetLanguage": "traduction langue cible",
|
"translationTargetLanguage": "langue cible de traduction",
|
||||||
"trayEnabled": "montrer le plateau",
|
"trayEnabled": "afficher la barre d’état système",
|
||||||
"translationApiProvider_description": "le fournisseur d'api à utiliser pour la traduction des paroles",
|
"translationApiProvider_description": "fournisseur d'API pour la traduction",
|
||||||
"customCss_description": "contenu css personnalisé. Remarque : le contenu et les URL distantes sont des propriétés non autorisées. Un aperçu de votre contenu est affiché ci-dessous. Des champs supplémentaires que vous n'avez pas définis sont présents en raison de la vérification",
|
"customCss_description": "contenu css personnalisé. Remarque : les propriétés 'content' et les URL distantes ne sont pas autorisées. Un aperçu de votre contenu est affiché ci-dessous. Des champs supplémentaires que vous n'avez pas définis sont présents en raison d'assainissement",
|
||||||
"translationApiKey": "clé api de traduction",
|
"translationApiKey": "clé API de traduction",
|
||||||
"translationTargetLanguage_description": "langue cible pour la traduction des paroles",
|
"translationTargetLanguage_description": "langue cible pour la traduction",
|
||||||
"trayEnabled_description": "afficher ou masquer l'icône et le menu de la barre d'état système. si désactivé, désactive également la réduction et la sortie vers la barre d'état système",
|
"trayEnabled_description": "afficher/masquer l’icône/le menu dans la barre d’état système. si désactivé, désactive également la réduction/fermeture vers la barre d’état système",
|
||||||
"albumBackgroundBlur": "intensité du flou de l'image d'arrière-plan de l'album",
|
"albumBackgroundBlur": "intensité du flou de l'image d'arrière-plan de l'album",
|
||||||
"lastfmApiKey": "clé API {{lastfm}}",
|
"lastfmApiKey": "clé API {{lastfm}}",
|
||||||
"lastfmApiKey_description": "la clé API pour {{lastfm}}. requise pour la pochette d'album",
|
"lastfmApiKey_description": "la clé API pour {{lastfm}}. requise pour la pochette d'album",
|
||||||
"discordServeImage": "servir l'image {{discord}} depuis le serveur",
|
"discordServeImage": "servir l'image {{discord}} depuis le serveur",
|
||||||
"discordServeImage_description": "partage de la pochette d'album de Rich Presence {{discord}} depuis le serveur directement (disponible uniquement pour Jellyfin et Navidrome). {{discord}} utilise un bot pour récupérer les images, votre serveur doit donc être accessible depuis internet",
|
"discordServeImage_description": "partage la pochette d'album pour le statut d'activité {{discord}} depuis le serveur directement (disponible uniquement pour Jellyfin et Navidrome). {{discord}} utilise un bot pour récupérer les images, votre serveur doit donc être accessible depuis internet",
|
||||||
"lastfm": "affiche les liens de last.fm",
|
"lastfm": "afficher les liens last.fm",
|
||||||
"musicbrainz_description": "affiche les liens vers MusicBrainz sur les pages des artistes/albums, quand l'identifiant MusicBrainz existe",
|
"musicbrainz_description": "affiche les liens vers MusicBrainz sur les pages artiste/album, lorsque l'identifiant MusicBrainz existe",
|
||||||
"lastfm_description": "affiche les liens vers Last.fm sur les pages des artistes/albums",
|
"lastfm_description": "affiche les liens vers last.fm sur les pages artiste/album",
|
||||||
"musicbrainz": "affiche les liens MusicBrainz",
|
"musicbrainz": "affiche les liens MusicBrainz",
|
||||||
"neteaseTranslation": "Activer les traductions NetEase",
|
"neteaseTranslation": "Activer les traductions NetEase",
|
||||||
"neteaseTranslation_description": "Lorsque cette option est activée, récupère et affiche les paroles traduites de NetEase si elles sont disponibles",
|
"neteaseTranslation_description": "si activé, récupère et affiche les paroles traduites de NetEase si elles sont disponibles",
|
||||||
"preferLocalLyrics_description": "privilégier les paroles locales aux paroles distantes lorsqu'elles sont disponibles",
|
"preferLocalLyrics_description": "privilégier les paroles locales aux paroles distantes lorsqu'elles sont disponibles",
|
||||||
"preferLocalLyrics": "privilégier les paroles locales",
|
"preferLocalLyrics": "privilégier les paroles locales",
|
||||||
"discordPausedStatus_description": "quand activé, le status s'affichera lorsque le lecteur est en pause",
|
"discordPausedStatus_description": "si activé, le statut affichera lorsque la lecture est en pause",
|
||||||
"discordPausedStatus": "afficher le statut d’activité en pause",
|
"discordPausedStatus": "afficher le statut d’activité même en pause",
|
||||||
"preservePitch": "préserver la hauteur",
|
"preservePitch": "préserver la hauteur",
|
||||||
"preservePitch_description": "préserver la hauteur lors du changement de la vitesse de lecture",
|
"preservePitch_description": "préserver la hauteur lors du changement de la vitesse de lecture",
|
||||||
"discordDisplayType": "type d'affichage du statut {{discord}}",
|
"discordDisplayType": "type d'affichage du statut {{discord}}",
|
||||||
"discordDisplayType_description": "modifie ce que vous écoutez dans votre statut",
|
"discordDisplayType_description": "modifie ce que vous écoutez dans votre statut",
|
||||||
"discordDisplayType_songname": "nom du morceau",
|
"discordDisplayType_songname": "nom du titre",
|
||||||
"discordDisplayType_artistname": "nom(s) d’artiste",
|
"discordDisplayType_artistname": "nom(s) d’artiste",
|
||||||
"hotkey_navigateHome": "aller à l'accueil",
|
"hotkey_navigateHome": "aller à l'accueil",
|
||||||
"preventSleepOnPlayback_description": "Empêche la mise en veille du lecteur lorsque la musique est en cours de lecture",
|
"preventSleepOnPlayback_description": "empêcher l’écran de s’éteindre pendant la lecture de la musique",
|
||||||
"preventSleepOnPlayback": "Empêche la mise en veille lors de la lecture",
|
"preventSleepOnPlayback": "empêche la mise en veille lors de la lecture",
|
||||||
"discordLinkType": "lien de Rich Presence {{discord}}",
|
"discordLinkType": "lien du statut d'activité {{discord}}",
|
||||||
"discordLinkType_description": "Ajoute des liens externes vers {{lastfm}} ou {{musicbrainz}} aux champs piste et artiste de la Rich Presence de {{discord}}. {{musicbrainz}} est la méthode la plus précise, mais nécessite des balises et ne fournit pas de liens vers les artistes, tandis que {{lastfm}} doit toujours fournir un lien. Aucune requête réseau supplémentaire n'est effectuée",
|
"discordLinkType_description": "ajoute des liens externes vers {{lastfm}} ou {{musicbrainz}} aux champs titre et artiste du statut d'activité {{discord}}. {{musicbrainz}} est la méthode la plus précise, mais nécessite des balises et ne fournit pas de liens vers les artistes, tandis que {{lastfm}} devrait toujours fournir un lien. aucune requête réseau supplémentaire n'est effectuée",
|
||||||
"discordLinkType_none": "$t(common.none)",
|
"discordLinkType_none": "$t(common.none)",
|
||||||
"discordLinkType_mbz_lastfm": "{{musicbrainz}} avec {{lastfm}} si le premier n'est pas disponible",
|
"discordLinkType_mbz_lastfm": "{{musicbrainz}} avec {{lastfm}} comme solution de repli",
|
||||||
"artistBackground": "image d'arrière-plan de l'artiste",
|
"artistBackground": "image d'arrière-plan de l'artiste",
|
||||||
"artistBackground_description": "ajoute une image d'arrière-plan pour les pages d'artiste contenant une image de l'artiste",
|
"artistBackground_description": "ajoute une image d'arrière-plan pour les pages d'artiste contenant une image de l'artiste",
|
||||||
"artistBackgroundBlur": "intensité du flou sur l'image d'arrière-plan d'artiste",
|
"artistBackgroundBlur": "intensité du flou sur l'image d'arrière-plan de l'artiste",
|
||||||
"artistBackgroundBlur_description": "ajuste la quantité de flou appliquée à l'image d'arrière-plan de l'artiste",
|
"artistBackgroundBlur_description": "ajuste la quantité de flou appliquée à l'image d'arrière-plan de l'artiste",
|
||||||
"releaseChannel_optionLatest": "dernière",
|
"releaseChannel_optionLatest": "dernière",
|
||||||
"releaseChannel_optionBeta": "bêta",
|
"releaseChannel_optionBeta": "bêta",
|
||||||
"releaseChannel": "canal de diffusion",
|
"releaseChannel": "canal de diffusion",
|
||||||
"releaseChannel_description": "choisissez entre les versions stables, bêta, ou alpha (nightly) pour les mises à jour automatiques",
|
"releaseChannel_description": "choisissez entre les versions stable, bêta ou alpha (nocturne) pour les mises à jour automatiques",
|
||||||
"mediaSession": "activer media session",
|
"mediaSession": "activer media session",
|
||||||
"mediaSession_description": "active l'intégration Media Session, affichant les commandes multimédias et les métadonnées dans la superposition du volume du système et l'écran de verrouillage",
|
"mediaSession_description": "active l'intégration de Media Session, affichant les contrôles multimédias et les métadonnées dans la superposition du volume du système et sur l'écran de verrouillage",
|
||||||
"enableAutoTranslation_description": "activer la traduction automatiquement lorsque les paroles sont chargées",
|
"enableAutoTranslation_description": "activer la traduction automatiquement lorsque les paroles sont chargées",
|
||||||
"enableAutoTranslation": "activer la traduction automatique",
|
"enableAutoTranslation": "activer la traduction automatique",
|
||||||
"exportImportSettings_control_description": "exporter et importer les paramètres en JSON",
|
"exportImportSettings_control_description": "exporter et importer les paramètres en JSON",
|
||||||
@@ -766,46 +775,46 @@
|
|||||||
"exportImportSettings_importSuccess": "les paramètres ont été importés avec succès !",
|
"exportImportSettings_importSuccess": "les paramètres ont été importés avec succès !",
|
||||||
"exportImportSettings_notValidJSON": "le fichier transmis n'est pas un JSON valide",
|
"exportImportSettings_notValidJSON": "le fichier transmis n'est pas un JSON valide",
|
||||||
"exportImportSettings_offendingKeyError": "la clé \"{{offendingKey}}\" est incorrecte - {{reason}}",
|
"exportImportSettings_offendingKeyError": "la clé \"{{offendingKey}}\" est incorrecte - {{reason}}",
|
||||||
"exportImportSettings_importModalTitle": "paramètres d'importation feishin",
|
"exportImportSettings_importModalTitle": "importer les paramètres de feishin",
|
||||||
"crossfadeStyle": "style de fondu enchaîné",
|
"crossfadeStyle": "style du fondu enchaîné",
|
||||||
"discordRichPresence": "{{discord}} Rich Presence",
|
"discordRichPresence": "statut d'activité {{discord}} (rich presence)",
|
||||||
"language": "langage",
|
"language": "langage",
|
||||||
"notify_description": "affiche une notification lorsque la chanson en cours change",
|
"notify_description": "afficher des notifications lors du changement du titre en cours",
|
||||||
"transcode": "activer le transcodage",
|
"transcode": "activer le transcodage",
|
||||||
"notify": "activer les notifications de chansons",
|
"notify": "activer les notifications de titre",
|
||||||
"analyticsDisable": "Désactiver l'analytique basée sur l'utilisation",
|
"analyticsDisable": "Désactiver l'analytique basée sur l'utilisation",
|
||||||
"analyticsDisable_description": "les données d'utilisation anonymisées sont envoyées au développeur afin de contribuer à l'amélioration de l'application",
|
"analyticsDisable_description": "les données d'utilisation anonymisées sont envoyées au développeur afin de contribuer à l'amélioration de l'application",
|
||||||
"playerbarSlider": "barre de lecture",
|
"playerbarSlider": "barre de progression",
|
||||||
"playerbarSliderType_optionSlider": "pleine",
|
"playerbarSliderType_optionSlider": "pleine",
|
||||||
"playerbarSliderType_optionWaveform": "forme d'onde",
|
"playerbarSliderType_optionWaveform": "forme d'onde",
|
||||||
"playerbarWaveformAlign": "forme d'onde alignée",
|
"playerbarWaveformAlign": "alignement de l'onde",
|
||||||
"playerbarWaveformAlign_optionTop": "haut",
|
"playerbarWaveformAlign_optionTop": "haut",
|
||||||
"playerbarWaveformAlign_optionCenter": "centre",
|
"playerbarWaveformAlign_optionCenter": "centre",
|
||||||
"playerbarWaveformAlign_optionBottom": "bas",
|
"playerbarWaveformAlign_optionBottom": "bas",
|
||||||
"playerbarWaveformBarWidth": "largeur de la barre en forme d'onde",
|
"playerbarWaveformBarWidth": "largeur des barres de l'onde",
|
||||||
"playerbarWaveformGap": "écart de la forme d'onde",
|
"playerbarWaveformGap": "espacement de l'onde",
|
||||||
"playerbarWaveformRadius": "rayon de la forme d'onde",
|
"playerbarWaveformRadius": "rayon des barres de l'onde",
|
||||||
"showLyricsInSidebar_description": "un panneau sera attaché à la file d'attente de lecture, qui affichera les paroles",
|
"showLyricsInSidebar_description": "un panneau sera attaché à la file d'attente, qui affichera les paroles",
|
||||||
"showLyricsInSidebar": "afficher les paroles dans la barre de lecture latérale",
|
"showLyricsInSidebar": "afficher les paroles dans la barre de lecture latérale",
|
||||||
"showVisualizerInSidebar_description": "un panneau sera ajouté à la barre de lecture latérale qui affiche le visualiseur",
|
"showVisualizerInSidebar_description": "un panneau sera ajouté à la barre de lecture latérale qui affiche le visualiseur",
|
||||||
"showVisualizerInSidebar": "afficher le visualiseur dans la barre de lecture latérale",
|
"showVisualizerInSidebar": "afficher le visualiseur dans la barre de lecture latérale",
|
||||||
"audioFadeOnStatusChange": "diminution du volume sonore lors du changement d'état du statut",
|
"audioFadeOnStatusChange": "fondu audio lors du basculement lecture/pause",
|
||||||
"audioFadeOnStatusChange_description": "permet le fondu enchaîné et le fondu au noir quand la lecture/pause change d'états du statut",
|
"audioFadeOnStatusChange_description": "active le fondu sortant et entrant lors du changement de statut lecture/pause",
|
||||||
"queryBuilder": "constructeur de requêtes",
|
"queryBuilder": "constructeur de requêtes",
|
||||||
"queryBuilderCustomFields_inputLabel": "label",
|
"queryBuilderCustomFields_inputLabel": "label",
|
||||||
"queryBuilderCustomFields_inputTag": "tag",
|
"queryBuilderCustomFields_inputTag": "tag",
|
||||||
"queryBuilderCustomFields": "champs personnalisé",
|
"queryBuilderCustomFields": "champs personnalisé",
|
||||||
"queryBuilderCustomFields_description": "ajouter un champ personnalisé à utiliser dans les constructeurs de requêtes",
|
"queryBuilderCustomFields_description": "ajouter des champs personnalisés à utiliser dans les constructeurs de requêtes",
|
||||||
"autoDJ": "DJ auto",
|
"autoDJ": "DJ auto",
|
||||||
"autoDJ_description": "ajouter automatiquement des titres similaire à la file d'attente",
|
"autoDJ_description": "ajouter automatiquement des titres similaire à la file d'attente",
|
||||||
"autoDJ_itemCount": "nombre d'entrée",
|
"autoDJ_itemCount": "nombre d'entrée",
|
||||||
"autoDJ_itemCount_description": "le nombre d'entrées tentées d'être ajoutées à la file d'attente lorsque le DJ auto est activé",
|
"autoDJ_itemCount_description": "le nombre d'entrées tentées d'être ajoutées à la file d'attente lorsque le DJ auto est activé",
|
||||||
"autoDJ_timing": "timing",
|
"autoDJ_timing": "timing",
|
||||||
"autoDJ_timing_description": "le nombre de titres restant dans la file d'attente avant le déclenchement du DJ auto",
|
"autoDJ_timing_description": "le nombre de titres restant dans la file d'attente avant le déclenchement du DJ auto",
|
||||||
"followCurrentSong_description": "défiler automatiquement jusqu'au titre en cours de lecture dans la file d'attente",
|
"followCurrentSong_description": "défiler automatiquement la file d'attente jusqu'au titre en cours",
|
||||||
"followCurrentSong": "suivre le titre en cours",
|
"followCurrentSong": "suivre le titre en cours",
|
||||||
"logLevel": "niveau de log",
|
"logLevel": "niveau de journalisation",
|
||||||
"logLevel_description": "définis le niveau minimum de log à afficher. débogage affiche tous les logs, erreur affiche seulement les erreurs",
|
"logLevel_description": "définit le niveau minimum de journalisation à afficher. le mode debug affiche tous les logs, le mode error n’affiche que les erreurs",
|
||||||
"logLevel_optionDebug": "débogage",
|
"logLevel_optionDebug": "débogage",
|
||||||
"logLevel_optionError": "erreur",
|
"logLevel_optionError": "erreur",
|
||||||
"logLevel_optionInfo": "info",
|
"logLevel_optionInfo": "info",
|
||||||
@@ -815,20 +824,20 @@
|
|||||||
"playerbarSlider_description": "la forme d'onde n'est pas recommandée sur une connexion lente ou limitée",
|
"playerbarSlider_description": "la forme d'onde n'est pas recommandée sur une connexion lente ou limitée",
|
||||||
"useThemeAccentColor": "utiliser la couleur d'accent du thème",
|
"useThemeAccentColor": "utiliser la couleur d'accent du thème",
|
||||||
"useThemeAccentColor_description": "utiliser la couleur principale définie dans le thème sélectionné au lieu de la couleur d'accentuation personnalisée",
|
"useThemeAccentColor_description": "utiliser la couleur principale définie dans le thème sélectionné au lieu de la couleur d'accentuation personnalisée",
|
||||||
"artistReleaseTypeConfiguration": "configuration du type de sortie de l'artiste",
|
"artistReleaseTypeConfiguration": "configuration des sorties de l'artiste",
|
||||||
"artistReleaseTypeConfiguration_description": "configure quel type de sortie est affiché, et dans quel ordre, sur la page artiste de l'album",
|
"artistReleaseTypeConfiguration_description": "configure quels types de sortie sont affichés et dans quel ordre, sur la page d'artiste d'album",
|
||||||
"mpvExtraParameters": "paramètres supplémentaires de mpv",
|
"mpvExtraParameters": "paramètres supplémentaires de mpv",
|
||||||
"mpvExtraParameters_description": "arguments supplémentaires à transmettre à mpv",
|
"mpvExtraParameters_description": "arguments supplémentaires à transmettre à mpv",
|
||||||
"pathReplace": "remplacement du chemin de fichier",
|
"pathReplace": "remplacement du chemin de fichier",
|
||||||
"pathReplace_description": "remplacez le chemin de fichier par défaut de votre serveur",
|
"pathReplace_description": "remplacez le chemin de fichier par défaut de votre serveur",
|
||||||
"pathReplace_optionRemovePrefix": "supprimer un prefix",
|
"pathReplace_optionRemovePrefix": "supprimer un prefix",
|
||||||
"pathReplace_optionAddPrefix": "ajouter un prefix",
|
"pathReplace_optionAddPrefix": "ajouter un prefix",
|
||||||
"artistRadioCount_description": "définit le nombre de titres à récupérer pour la radio d'artiste et la radio de titre",
|
"artistRadioCount_description": "définit le nombre de titres à récupérer pour les radio d'artiste/piste",
|
||||||
"artistRadioCount": "nombre de radio d'artiste/titre",
|
"artistRadioCount": "radio d'artiste/piste",
|
||||||
"imageResolution": "résolution d'image",
|
"imageResolution": "résolution d'image",
|
||||||
"imageResolution_description": "la résolution d'image utilisée dans l'application. définir une valeur à 0 utilisera la résolution native de l'image",
|
"imageResolution_description": "la résolution d'image utilisée dans l'application. définir une valeur à 0 utilisera la résolution native de l'image",
|
||||||
"imageResolution_optionTable": "tableau",
|
"imageResolution_optionTable": "tableau",
|
||||||
"imageResolution_optionItemCard": "entrée de carte",
|
"imageResolution_optionItemCard": "carte",
|
||||||
"imageResolution_optionSidebar": "barre latérale",
|
"imageResolution_optionSidebar": "barre latérale",
|
||||||
"imageResolution_optionHeader": "en-tête",
|
"imageResolution_optionHeader": "en-tête",
|
||||||
"imageResolution_optionFullScreenPlayer": "lecteur en plein écran",
|
"imageResolution_optionFullScreenPlayer": "lecteur en plein écran",
|
||||||
@@ -839,27 +848,50 @@
|
|||||||
"analyticsEnable": "Envoyer des métriques d'utilisation",
|
"analyticsEnable": "Envoyer des métriques d'utilisation",
|
||||||
"analyticsEnable_description": "Des métriques d'utilisation anonymisées sont envoyées au développeur pour aider à améliorer l'application",
|
"analyticsEnable_description": "Des métriques d'utilisation anonymisées sont envoyées au développeur pour aider à améliorer l'application",
|
||||||
"automaticUpdates": "Mises à jour automatiques",
|
"automaticUpdates": "Mises à jour automatiques",
|
||||||
"automaticUpdates_description": "Vérifier l'existence de mises à jour et les installer automatiquement",
|
"automaticUpdates_description": "Vérifie et installe les mises à jour automatiquement",
|
||||||
"releaseChannel_optionAlpha": "alpha (toutes les nuits)",
|
"releaseChannel_optionAlpha": "alpha (nocturne)",
|
||||||
"discordStateIcon": "afficher l’icône de lecture",
|
"discordStateIcon": "afficher l’icône de lecture",
|
||||||
"discordStateIcon_description": "affiche une petite icône de lecture dans le statut d'activité. l'icône de pause est toujours affichée lorsque \"Afficher le statut d'activité en pause\" est activé",
|
"discordStateIcon_description": "affiche une petite icône de lecture dans le statut d'activité. l'icône de pause est toujours affichée lorsque \"Afficher le statut d’activité même en pause\" est activé",
|
||||||
"homeFeatureStyle_description": "contrôle le style du carousel d'accueil à la une",
|
"homeFeatureStyle_description": "contrôle le style du carrousel de la page d’accueil",
|
||||||
"homeFeatureStyle": "style de carousel à la une de l'accueil",
|
"homeFeatureStyle": "style du carrousel de la page d’accueil",
|
||||||
"homeFeatureStyle_optionMultiple": "multiple",
|
"homeFeatureStyle_optionMultiple": "multiple",
|
||||||
"homeFeatureStyle_optionSingle": "simple",
|
"homeFeatureStyle_optionSingle": "simple",
|
||||||
"blurExplicitImages": "flouter les images explicites",
|
"blurExplicitImages": "flouter les images explicites",
|
||||||
"blurExplicitImages_description": "les pochettes d'albums et de chansons étiquetées comme explicites seront floutées",
|
"blurExplicitImages_description": "les pochettes de titre et d'albums étiquetées comme explicites seront floutées",
|
||||||
"enableGridMultiSelect": "activer la sélection multiple dans la grille",
|
"enableGridMultiSelect": "activer la sélection multiple dans la grille",
|
||||||
"enableGridMultiSelect_description": "quand activé, permet la sélection de plusieurs entrées dans la vue en grille. quand désactivé, cliquer sur un item de la grille mène vers la page de l'entrée",
|
"enableGridMultiSelect_description": "si activé, permet la sélection de plusieurs entrées dans la vue en grille. si désactivé, cliquer sur un item de la grille mène vers la page de l'entrée",
|
||||||
"sidebarPlaylistSorting_description": "permet le tri manuel des listes de lecture dans la barre latérale en utilisant le drag and drop plutôt que l'ordre par défaut du serveur",
|
"sidebarPlaylistSorting_description": "permet le tri manuel des listes de lecture dans la barre latérale en utilisant le glisser-déposer au lieu de l'ordre par défaut du serveur",
|
||||||
"sidebarPlaylistSorting": "tri des listes de lecture dans la barre latérale",
|
"sidebarPlaylistSorting": "tri des listes de lecture dans la barre latérale",
|
||||||
"sidebarPlaylistListFilterRegex_description": "masquer les listes de lecture dans la barre latérale qui correspondent à cette expression régulière",
|
"sidebarPlaylistListFilterRegex_description": "masquer les listes de lecture dans la barre latérale correspondant à cette expression régulière",
|
||||||
"sidebarPlaylistListFilterRegex_placeholder": "ex. ^Mix Journalier*",
|
"sidebarPlaylistListFilterRegex_placeholder": "ex. ^Mix Journalier*",
|
||||||
"sidebarPlaylistListFilterRegex": "filtre d'expression régulière de liste de lecture",
|
"sidebarPlaylistListFilterRegex": "filtre de liste de lecture en expression régulière",
|
||||||
"autosave": "sauvegarder automatiquement la file d'attente",
|
"autosave": "sauvegarder automatiquement la file d'attente",
|
||||||
"autosave_description": "activez la sauvegarde automatique de la file d'attente de lecture sur votre serveur. Cette fonction est uniquement disponible avec Navidrome/Subsonic et ne permet pas d'utiliser une file d'attente mixte.",
|
"autosave_description": "activez la sauvegarde automatique de la file d'attente sur votre serveur. Cette fonction est uniquement disponible avec Navidrome/Subsonic et ne permet pas d'utiliser une file d'attente mixte.",
|
||||||
"autosaveCount": "fréquence de sauvegarde automatique de la file d'attente",
|
"autosaveCount": "fréquence de sauvegarde automatique de la file d'attente",
|
||||||
"autosaveCount_description": "nombre de changement de piste avant la sauvegarde de la file d'attente. 1 (minimum) signifie chaque changement de titre"
|
"autosaveCount_description": "nombre de changement de piste avant la sauvegarde de la file d'attente. 1 (minimum) signifie chaque changement de titre",
|
||||||
|
"useThemePrimaryShade": "utiliser la teinte principale du thème",
|
||||||
|
"useThemePrimaryShade_description": "utiliser la teinte principale définie dans le thème sélectionné pour les variantes de couleur primaire",
|
||||||
|
"primaryShade": "teinte principale",
|
||||||
|
"primaryShade_description": "remplacer la teinte principale (0–9) utilisée pour les boutons, les liens et les autres éléments de couleur primaire",
|
||||||
|
"hotkey_listNavigateToPage": "naviguer vers la page de l'élément",
|
||||||
|
"hotkey_listPlayDefault": "lecture de la liste",
|
||||||
|
"hotkey_listPlayLast": "lire en dernier",
|
||||||
|
"hotkey_listPlayNext": "lire ensuite",
|
||||||
|
"hotkey_listPlayNow": "lire maintenant",
|
||||||
|
"playerItemConfiguration_description": "configurer les éléments affichés et leur ordre dans le lecteur plein écran",
|
||||||
|
"playerItemConfiguration": "configuration des éléments du lecteur",
|
||||||
|
"listenbrainz_description": "afficher les liens vers ListenBrainz sur les pages d'artiste/album",
|
||||||
|
"listenbrainz": "afficher les liens ListenBrainz",
|
||||||
|
"qobuz_description": "afficher les liens vers Qobuz sur les pages d'artiste/album",
|
||||||
|
"qobuz": "afficher les liens Qobuz",
|
||||||
|
"spotify_description": "afficher les liens vers Spotify sur les pages d'artiste/album",
|
||||||
|
"spotify": "afficher les liens Spotify",
|
||||||
|
"nativeSpotify_description": "ouvrir dans l'application Spotify plutôt que le navigateur",
|
||||||
|
"nativeSpotify": "utiliser l'application Spotify",
|
||||||
|
"sidePlayQueueLayout": "disposition de la file d'attente",
|
||||||
|
"sidePlayQueueLayout_description": "définit la disposition de la file d'attente attaché",
|
||||||
|
"sidePlayQueueLayout_optionHorizontal": "horizontal",
|
||||||
|
"sidePlayQueueLayout_optionVertical": "vertical"
|
||||||
},
|
},
|
||||||
"form": {
|
"form": {
|
||||||
"deletePlaylist": {
|
"deletePlaylist": {
|
||||||
@@ -917,8 +949,7 @@
|
|||||||
"editPlaylist": {
|
"editPlaylist": {
|
||||||
"title": "modifier $t(entity.playlist, {\"count\": 1})",
|
"title": "modifier $t(entity.playlist, {\"count\": 1})",
|
||||||
"publicJellyfinNote": "Jellyfin n'indique pas si une liste de lecture est publique ou non. Si vous souhaitez que cette liste de lecture reste publique, veuillez sélectionner l'entrée suivante",
|
"publicJellyfinNote": "Jellyfin n'indique pas si une liste de lecture est publique ou non. Si vous souhaitez que cette liste de lecture reste publique, veuillez sélectionner l'entrée suivante",
|
||||||
"success": "$t(entity.playlist, {\"count\": 1}) mis à jour avec succès",
|
"success": "$t(entity.playlist, {\"count\": 1}) mis à jour avec succès"
|
||||||
"editNote": "les modifications manuelles ne sont pas recommandées pour les listes de lecture volumineuses. êtes-vous sûre d'accepter le risque d'une perte de données en écrasant la liste de lecture existante ?"
|
|
||||||
},
|
},
|
||||||
"lyricSearch": {
|
"lyricSearch": {
|
||||||
"title": "recherche de paroles",
|
"title": "recherche de paroles",
|
||||||
@@ -963,7 +994,7 @@
|
|||||||
"input_streamUrl": "lien du flux en direct"
|
"input_streamUrl": "lien du flux en direct"
|
||||||
},
|
},
|
||||||
"saveQueue": {
|
"saveQueue": {
|
||||||
"success": "file d'attente de lecture enregistrée sur le serveur"
|
"success": "file d'attente enregistrée sur le serveur"
|
||||||
},
|
},
|
||||||
"lyricsExport": {
|
"lyricsExport": {
|
||||||
"export": "exporter les paroles",
|
"export": "exporter les paroles",
|
||||||
@@ -987,7 +1018,7 @@
|
|||||||
"folderWithCount_one": "{{count}} dossier",
|
"folderWithCount_one": "{{count}} dossier",
|
||||||
"folderWithCount_many": "{{count}} dossiers",
|
"folderWithCount_many": "{{count}} dossiers",
|
||||||
"folderWithCount_other": "{{count}} dossiers",
|
"folderWithCount_other": "{{count}} dossiers",
|
||||||
"albumArtist_one": "artiste de l'album",
|
"albumArtist_one": "artiste d'album",
|
||||||
"albumArtist_many": "artistes d'albums",
|
"albumArtist_many": "artistes d'albums",
|
||||||
"albumArtist_other": "artistes d'albums",
|
"albumArtist_other": "artistes d'albums",
|
||||||
"track_one": "piste",
|
"track_one": "piste",
|
||||||
@@ -1034,14 +1065,14 @@
|
|||||||
"table": {
|
"table": {
|
||||||
"config": {
|
"config": {
|
||||||
"general": {
|
"general": {
|
||||||
"displayType": "Type d'affichage",
|
"displayType": "type d'affichage",
|
||||||
"tableColumns": "colonnes de la liste",
|
"tableColumns": "colonnes de la liste",
|
||||||
"autoFitColumns": "colonnes à ajustement automatique",
|
"autoFitColumns": "ajuster automatiquement la largeur des colonnes",
|
||||||
"gap": "$t(common.gap)",
|
"gap": "$t(common.gap)",
|
||||||
"size": "$t(common.size)",
|
"size": "$t(common.size)",
|
||||||
"itemGap": "écart entre les éléments (en pixel)",
|
"itemGap": "écart entre les éléments (en pixel)",
|
||||||
"itemSize": "taille des élements (en pixel)",
|
"itemSize": "taille des élements (en pixel)",
|
||||||
"followCurrentSong": "suivre la chanson actuelle",
|
"followCurrentSong": "suivre le titre actuelle",
|
||||||
"advancedSettings": "paramètres avancés",
|
"advancedSettings": "paramètres avancés",
|
||||||
"autosize": "taille automatique",
|
"autosize": "taille automatique",
|
||||||
"moveUp": "monter",
|
"moveUp": "monter",
|
||||||
@@ -1106,7 +1137,8 @@
|
|||||||
"bitDepth": "$t(common.bitDepth)",
|
"bitDepth": "$t(common.bitDepth)",
|
||||||
"sampleRate": "$t(common.sampleRate)",
|
"sampleRate": "$t(common.sampleRate)",
|
||||||
"composer": "compositeur",
|
"composer": "compositeur",
|
||||||
"titleArtist": "$t(common.title) (artiste)"
|
"titleArtist": "$t(common.title) (artiste)",
|
||||||
|
"albumGroup": "groupe d'album"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"column": {
|
"column": {
|
||||||
@@ -1114,13 +1146,13 @@
|
|||||||
"album": "album",
|
"album": "album",
|
||||||
"rating": "note",
|
"rating": "note",
|
||||||
"favorite": "favori",
|
"favorite": "favori",
|
||||||
"playCount": "écoutes",
|
"playCount": "lectures",
|
||||||
"releaseYear": "année",
|
"releaseYear": "année",
|
||||||
"biography": "biographie",
|
"biography": "biographie",
|
||||||
"releaseDate": "date de sortie",
|
"releaseDate": "date de sortie",
|
||||||
"bitrate": "bitrate",
|
"bitrate": "débit binaire",
|
||||||
"title": "titre",
|
"title": "titre",
|
||||||
"bpm": "bpm",
|
"bpm": "BPM",
|
||||||
"dateAdded": "date d'ajout",
|
"dateAdded": "date d'ajout",
|
||||||
"trackNumber": "piste",
|
"trackNumber": "piste",
|
||||||
"albumArtist": "artiste de l'album",
|
"albumArtist": "artiste de l'album",
|
||||||
@@ -1342,6 +1374,9 @@
|
|||||||
"d": "D",
|
"d": "D",
|
||||||
"z": "Z"
|
"z": "Z"
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
"lumiBars": "Lumi Bars",
|
||||||
|
"outlineBars": "Outline Bars",
|
||||||
|
"splitGradient": "Split Gradient"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -313,8 +313,7 @@
|
|||||||
"editPlaylist": {
|
"editPlaylist": {
|
||||||
"success": "$t(entity.playlist, {\"count\": 1}) sikeresen módosítva",
|
"success": "$t(entity.playlist, {\"count\": 1}) sikeresen módosítva",
|
||||||
"publicJellyfinNote": "A Jellyfin valamiért nem teszi közzé, hogy egy lejátszási lista publikus-e vagy sem. Amennyiben azt szeretnéd, hogy publikus maradjon, válaszd ki az alábbi beviteli mezőt",
|
"publicJellyfinNote": "A Jellyfin valamiért nem teszi közzé, hogy egy lejátszási lista publikus-e vagy sem. Amennyiben azt szeretnéd, hogy publikus maradjon, válaszd ki az alábbi beviteli mezőt",
|
||||||
"title": "szerkesztés $t(entity.playlist, {\"count\": 1})",
|
"title": "szerkesztés $t(entity.playlist, {\"count\": 1})"
|
||||||
"editNote": "A kézi szerkesztés nem ajánlott nagy lejátszási listák esetén. Biztosan vállalod a meglévő lejátszási lista felülírásával járó adatvesztés kockázatát?"
|
|
||||||
},
|
},
|
||||||
"lyricSearch": {
|
"lyricSearch": {
|
||||||
"input_artist": "$t(entity.artist, {\"count\": 1})",
|
"input_artist": "$t(entity.artist, {\"count\": 1})",
|
||||||
|
|||||||
@@ -304,8 +304,7 @@
|
|||||||
"editPlaylist": {
|
"editPlaylist": {
|
||||||
"publicJellyfinNote": "Jellyfin entah bagaimana tidak menampilkan apakah playlist ini publik atau tidak. Jika Anda ingin playlist ini tetap publik, harap pilih entri berikut",
|
"publicJellyfinNote": "Jellyfin entah bagaimana tidak menampilkan apakah playlist ini publik atau tidak. Jika Anda ingin playlist ini tetap publik, harap pilih entri berikut",
|
||||||
"success": "$t(entity.playlist, {\"count\": 1}) berhasil diperbarui",
|
"success": "$t(entity.playlist, {\"count\": 1}) berhasil diperbarui",
|
||||||
"title": "ubah $t(entity.playlist, {\"count\": 1})",
|
"title": "ubah $t(entity.playlist, {\"count\": 1})"
|
||||||
"editNote": "pengeditan manual tidak disarankan untuk playlist besar. apakah Anda yakin menerima risiko kehilangan data yang timbul akibat menimpa playlist yang ada?"
|
|
||||||
},
|
},
|
||||||
"lyricSearch": {
|
"lyricSearch": {
|
||||||
"input_artist": "$t(entity.artist, {\"count\": 1})",
|
"input_artist": "$t(entity.artist, {\"count\": 1})",
|
||||||
|
|||||||
@@ -128,6 +128,7 @@
|
|||||||
"close": "chiudi",
|
"close": "chiudi",
|
||||||
"codec": "codec",
|
"codec": "codec",
|
||||||
"mbid": "MusicBrainz ID",
|
"mbid": "MusicBrainz ID",
|
||||||
|
"grouping": "raggruppamento",
|
||||||
"preview": "anteprima",
|
"preview": "anteprima",
|
||||||
"reload": "aggiorna",
|
"reload": "aggiorna",
|
||||||
"share": "condividi",
|
"share": "condividi",
|
||||||
|
|||||||
+273
-51
@@ -7,7 +7,7 @@
|
|||||||
"playRandom": "ランダム再生",
|
"playRandom": "ランダム再生",
|
||||||
"skip": "スキップ",
|
"skip": "スキップ",
|
||||||
"previous": "前へ",
|
"previous": "前へ",
|
||||||
"toggleFullscreenPlayer": "フルスクリーンプレーヤーの切り替え",
|
"toggleFullscreenPlayer": "全画面プレーヤーに切り替える",
|
||||||
"skip_back": "前へスキップ",
|
"skip_back": "前へスキップ",
|
||||||
"favorite": "お気に入り",
|
"favorite": "お気に入り",
|
||||||
"next": "次へ",
|
"next": "次へ",
|
||||||
@@ -44,7 +44,11 @@
|
|||||||
"sleepTimer_off": "オフ",
|
"sleepTimer_off": "オフ",
|
||||||
"sleepTimer_timeRemaining": "残り {{time}}",
|
"sleepTimer_timeRemaining": "残り {{time}}",
|
||||||
"sleepTimer_setCustom": "タイマーを設定",
|
"sleepTimer_setCustom": "タイマーを設定",
|
||||||
"sleepTimer_cancel": "タイマーをキャンセル"
|
"sleepTimer_cancel": "タイマーをキャンセル",
|
||||||
|
"holdToShuffle": "長押しでシャッフル",
|
||||||
|
"albumRadio": "アルバム・ラジオ",
|
||||||
|
"artistRadio": "アーティストラジオ",
|
||||||
|
"trackRadio": "ラジオを追跡する"
|
||||||
},
|
},
|
||||||
"setting": {
|
"setting": {
|
||||||
"crossfadeStyle_description": "オーディオプレーヤーが使用するクロスフェードのスタイルを選択します",
|
"crossfadeStyle_description": "オーディオプレーヤーが使用するクロスフェードのスタイルを選択します",
|
||||||
@@ -56,7 +60,7 @@
|
|||||||
"theme_description": "アプリケーションに使用するテーマを設定します",
|
"theme_description": "アプリケーションに使用するテーマを設定します",
|
||||||
"hotkey_playbackPause": "一時停止",
|
"hotkey_playbackPause": "一時停止",
|
||||||
"replayGainFallback": "{{ReplayGain}} フォールバック",
|
"replayGainFallback": "{{ReplayGain}} フォールバック",
|
||||||
"sidebarCollapsedNavigation_description": "折りたたみサイドバーのナビゲーションを表示/非表示にします",
|
"sidebarCollapsedNavigation_description": "折りたたまれたサイドバーのナビゲーションを表示または非表示にします",
|
||||||
"hotkey_volumeUp": "音量を上げる",
|
"hotkey_volumeUp": "音量を上げる",
|
||||||
"skipDuration": "スキップの長さ",
|
"skipDuration": "スキップの長さ",
|
||||||
"discordIdleStatus_description": "有効にすると、プレーヤーがアイドル状態でもステータスを更新します",
|
"discordIdleStatus_description": "有効にすると、プレーヤーがアイドル状態でもステータスを更新します",
|
||||||
@@ -71,7 +75,7 @@
|
|||||||
"mpvExecutablePath_description": "MPV を実行するファイルパスを設定します。空のままにすると、デフォルトのパスが使用されます",
|
"mpvExecutablePath_description": "MPV を実行するファイルパスを設定します。空のままにすると、デフォルトのパスが使用されます",
|
||||||
"replayGainClipping_description": "自動的にゲインを下げて {{ReplayGain}} によるクリッピングを防ぎます",
|
"replayGainClipping_description": "自動的にゲインを下げて {{ReplayGain}} によるクリッピングを防ぎます",
|
||||||
"replayGainPreamp": "{{ReplayGain}} プリアンプ (dB)",
|
"replayGainPreamp": "{{ReplayGain}} プリアンプ (dB)",
|
||||||
"hotkey_favoriteCurrentSong": "$t(common.currentSong) をお気に入り",
|
"hotkey_favoriteCurrentSong": "$t(common.currentSong) をお気に入りに登録",
|
||||||
"sampleRate": "サンプルレート",
|
"sampleRate": "サンプルレート",
|
||||||
"sidePlayQueueStyle_optionAttached": "結合",
|
"sidePlayQueueStyle_optionAttached": "結合",
|
||||||
"sidebarConfiguration": "サイドバー設定",
|
"sidebarConfiguration": "サイドバー設定",
|
||||||
@@ -90,15 +94,15 @@
|
|||||||
"hotkey_skipForward": "次へスキップ",
|
"hotkey_skipForward": "次へスキップ",
|
||||||
"disableLibraryUpdateOnStartup": "起動時の新バージョンチェックを無効にします",
|
"disableLibraryUpdateOnStartup": "起動時の新バージョンチェックを無効にします",
|
||||||
"discordApplicationId_description": "{{discord}} に Rich Presence ステータスを表示するためのアプリケーション ID (デフォルトは {{defaultId}} です)",
|
"discordApplicationId_description": "{{discord}} に Rich Presence ステータスを表示するためのアプリケーション ID (デフォルトは {{defaultId}} です)",
|
||||||
"sidePlayQueueStyle": "サイド再生キュースタイル",
|
"sidePlayQueueStyle": "サイド再生キューの形式",
|
||||||
"gaplessAudio": "ギャップレス再生",
|
"gaplessAudio": "ギャップレス再生",
|
||||||
"playButtonBehavior_optionAddLast": "$t(player.addLast)",
|
"playButtonBehavior_optionAddLast": "$t(player.addLast)",
|
||||||
"zoom": "ズーム率",
|
"zoom": "ズーム率",
|
||||||
"minimizeToTray_description": "最小化ボタンが押された際、システムトレイに格納します",
|
"minimizeToTray_description": "最小化ボタンが押された際、システムトレイに格納します",
|
||||||
"hotkey_playbackPlay": "再生",
|
"hotkey_playbackPlay": "再生",
|
||||||
"hotkey_togglePreviousSongFavorite": "$t(common.previousSong) をお気に入り登録/解除",
|
"hotkey_togglePreviousSongFavorite": "$t(common.previousSong) のお気に入りを切り替え",
|
||||||
"hotkey_volumeDown": "音量を下げる",
|
"hotkey_volumeDown": "音量を下げる",
|
||||||
"hotkey_unfavoritePreviousSong": "$t(common.previousSong) をお気に入り解除",
|
"hotkey_unfavoritePreviousSong": "$t(common.previousSong) をお気に入りから解除",
|
||||||
"audioPlayer_description": "再生に使用するオーディオプレーヤーを選択します",
|
"audioPlayer_description": "再生に使用するオーディオプレーヤーを選択します",
|
||||||
"globalMediaHotkeys": "グローバルメディアホットキー",
|
"globalMediaHotkeys": "グローバルメディアホットキー",
|
||||||
"hotkey_globalSearch": "グローバル検索",
|
"hotkey_globalSearch": "グローバル検索",
|
||||||
@@ -106,7 +110,7 @@
|
|||||||
"remoteUsername_description": "リモートコントロール サーバーのユーザ名を設定します。 ユーザー名とパスワードの両方が空の場合、認証は無効になります",
|
"remoteUsername_description": "リモートコントロール サーバーのユーザ名を設定します。 ユーザー名とパスワードの両方が空の場合、認証は無効になります",
|
||||||
"exitToTray_description": "アプリケーション終了ボタンが押された際、システムトレイに格納します",
|
"exitToTray_description": "アプリケーション終了ボタンが押された際、システムトレイに格納します",
|
||||||
"followLyric_description": "現在の再生位置に歌詞をスクロールします",
|
"followLyric_description": "現在の再生位置に歌詞をスクロールします",
|
||||||
"hotkey_favoritePreviousSong": "$t(common.previousSong) をお気に入り",
|
"hotkey_favoritePreviousSong": "$t(common.previousSong) をお気に入りに登録",
|
||||||
"replayGainMode_optionAlbum": "$t(entity.album, {\"count\": 1})",
|
"replayGainMode_optionAlbum": "$t(entity.album, {\"count\": 1})",
|
||||||
"lyricOffset": "歌詞のオフセット (ミリ秒)",
|
"lyricOffset": "歌詞のオフセット (ミリ秒)",
|
||||||
"discordUpdateInterval_description": "更新間隔 (秒単位、最小 15 秒)",
|
"discordUpdateInterval_description": "更新間隔 (秒単位、最小 15 秒)",
|
||||||
@@ -121,7 +125,7 @@
|
|||||||
"font": "フォント",
|
"font": "フォント",
|
||||||
"replayGainMode_optionTrack": "$t(entity.track, {\"count\": 1})",
|
"replayGainMode_optionTrack": "$t(entity.track, {\"count\": 1})",
|
||||||
"themeLight_description": "アプリケーションに使用するライトテーマを設定します",
|
"themeLight_description": "アプリケーションに使用するライトテーマを設定します",
|
||||||
"hotkey_toggleFullScreenPlayer": "フルスクリーンプレーヤーの切り替え",
|
"hotkey_toggleFullScreenPlayer": "全画面プレーヤーに切り替え",
|
||||||
"hotkey_localSearch": "ページ内検索",
|
"hotkey_localSearch": "ページ内検索",
|
||||||
"hotkey_toggleQueue": "キューの切り替え",
|
"hotkey_toggleQueue": "キューの切り替え",
|
||||||
"zoom_description": "アプリケーションのズーム率を設定します",
|
"zoom_description": "アプリケーションのズーム率を設定します",
|
||||||
@@ -150,9 +154,9 @@
|
|||||||
"fontType_description": "組み込みフォントの場合、Feishin が提供するフォントの中から 1 つ選択します。 システムフォントの場合、OS が提供する任意のフォントを選択できます。 カスタムフォントの場合、フォントファイルを自身で選択できます",
|
"fontType_description": "組み込みフォントの場合、Feishin が提供するフォントの中から 1 つ選択します。 システムフォントの場合、OS が提供する任意のフォントを選択できます。 カスタムフォントの場合、フォントファイルを自身で選択できます",
|
||||||
"playButtonBehavior": "再生ボタンの動作",
|
"playButtonBehavior": "再生ボタンの動作",
|
||||||
"volumeWheelStep": "音量ホイールステップ",
|
"volumeWheelStep": "音量ホイールステップ",
|
||||||
"sidebarPlaylistList_description": "サイドバーでプレイリストのリストを表示/非表示にします",
|
"sidebarPlaylistList_description": "サイドバーのプレイリストを表示または非表示にします",
|
||||||
"accentColor": "アクセントカラー",
|
"accentColor": "アクセントカラー",
|
||||||
"sidePlayQueueStyle_description": "サイド再生キューのスタイルを設定します",
|
"sidePlayQueueStyle_description": "サイド再生キューの形式を設定します",
|
||||||
"accentColor_description": "アプリケーションが利用するアクセントカラーを設定します",
|
"accentColor_description": "アプリケーションが利用するアクセントカラーを設定します",
|
||||||
"replayGainMode": "{{ReplayGain}} モード",
|
"replayGainMode": "{{ReplayGain}} モード",
|
||||||
"playbackStyle_optionNormal": "通常",
|
"playbackStyle_optionNormal": "通常",
|
||||||
@@ -161,7 +165,7 @@
|
|||||||
"replayGainPreamp_description": "{{ReplayGain}} の値に適用されるプリアンプゲインを調整します",
|
"replayGainPreamp_description": "{{ReplayGain}} の値に適用されるプリアンプゲインを調整します",
|
||||||
"hotkey_toggleRepeat": "リピートの切り替え",
|
"hotkey_toggleRepeat": "リピートの切り替え",
|
||||||
"lyricOffset_description": "歌詞のオフセットをミリ秒単位で指定します",
|
"lyricOffset_description": "歌詞のオフセットをミリ秒単位で指定します",
|
||||||
"sidebarConfiguration_description": "サイドバーに表示されるアイテムと並び順を選択します",
|
"sidebarConfiguration_description": "サイドバーに表示する項目と順序を選択します",
|
||||||
"fontType": "フォントタイプ",
|
"fontType": "フォントタイプ",
|
||||||
"remotePort": "リモートコントロールサーバーのポート",
|
"remotePort": "リモートコントロールサーバーのポート",
|
||||||
"applicationHotkeys": "アプリケーションホットキー",
|
"applicationHotkeys": "アプリケーションホットキー",
|
||||||
@@ -178,16 +182,16 @@
|
|||||||
"sidePlayQueueStyle_optionDetached": "分離",
|
"sidePlayQueueStyle_optionDetached": "分離",
|
||||||
"audioPlayer": "オーディオプレーヤー",
|
"audioPlayer": "オーディオプレーヤー",
|
||||||
"hotkey_zoomOut": "縮小",
|
"hotkey_zoomOut": "縮小",
|
||||||
"hotkey_unfavoriteCurrentSong": "$t(common.currentSong) をお気に入り解除",
|
"hotkey_unfavoriteCurrentSong": "$t(common.currentSong) をお気に入りから解除",
|
||||||
"hotkey_rate0": "評価をクリア",
|
"hotkey_rate0": "評価をクリア",
|
||||||
"discordApplicationId": "{{discord}} アプリケーション ID",
|
"discordApplicationId": "{{discord}} アプリケーション ID",
|
||||||
"applicationHotkeys_description": "アプリケーションのホットキーを設定します。チェックボックスを切り替えて、グローバルホットキーとして設定します (デスクトップのみ)",
|
"applicationHotkeys_description": "アプリケーションのホットキーを設定します。チェックボックスを切り替えて、グローバルホットキーとして設定します (デスクトップのみ)",
|
||||||
"hotkey_volumeMute": "音量をミュート",
|
"hotkey_volumeMute": "音量をミュート",
|
||||||
"hotkey_toggleCurrentSongFavorite": "$t(common.currentSong) をお気に入り登録/解除",
|
"hotkey_toggleCurrentSongFavorite": "$t(common.currentSong) のお気に入りを切り替え",
|
||||||
"remoteUsername": "リモートコントロールサーバーのユーザー名",
|
"remoteUsername": "リモートコントロールサーバーのユーザー名",
|
||||||
"hotkey_browserBack": "ブラウザ 戻る",
|
"hotkey_browserBack": "ブラウザ 戻る",
|
||||||
"showSkipButton": "スキップボタンを表示",
|
"showSkipButton": "スキップボタンを表示",
|
||||||
"sidebarPlaylistList": "サイドバー プレイリスト リスト",
|
"sidebarPlaylistList": "サイドバーのプレイリスト",
|
||||||
"minimizeToTray": "最小化時にシステムトレイに格納",
|
"minimizeToTray": "最小化時にシステムトレイに格納",
|
||||||
"skipPlaylistPage": "プレイリストページをスキップ",
|
"skipPlaylistPage": "プレイリストページをスキップ",
|
||||||
"themeDark": "テーマ (ダーク)",
|
"themeDark": "テーマ (ダーク)",
|
||||||
@@ -215,10 +219,10 @@
|
|||||||
"trayEnabled": "トレイを表示する",
|
"trayEnabled": "トレイを表示する",
|
||||||
"volumeWidth_description": "音量スライダーの幅",
|
"volumeWidth_description": "音量スライダーの幅",
|
||||||
"volumeWidth": "音量スライダーの幅",
|
"volumeWidth": "音量スライダーの幅",
|
||||||
"webAudio_description": "Web Audio を使用します。これにより、リプレイゲインなどの高度な機能が有効になります。それ以外の場合は無効にしてください",
|
"webAudio_description": "Web Audio を使用します。これにより、リプレイゲインなどの高度な機能が有効になります。問題が発生した場合は無効にしてください",
|
||||||
"mpvExtraParameters_help": "1 行に 1 つずつ",
|
"mpvExtraParameters_help": "1 行に 1 つずつ",
|
||||||
"musicbrainz_description": "MusicBrainz ID が存在するアーティスト/アルバムページに MusicBrainz へのリンクを表示します",
|
"musicbrainz_description": "MusicBrainz ID が存在するアーティストとアルバムページに MusicBrainz へのリンクを表示します",
|
||||||
"musicbrainz": "MusicBrainz リンクを表示する",
|
"musicbrainz": "MusicBrainz のリンクを表示",
|
||||||
"neteaseTranslation_description": "有効にすると、利用可能な場合は NetEase から翻訳された歌詞を取得して表示します",
|
"neteaseTranslation_description": "有効にすると、利用可能な場合は NetEase から翻訳された歌詞を取得して表示します",
|
||||||
"neteaseTranslation": "NetEase 翻訳歌詞を有効にする",
|
"neteaseTranslation": "NetEase 翻訳歌詞を有効にする",
|
||||||
"passwordStore_description": "使用するパスワード / シークレットストア。パスワードの保存に問題がある場合はこれを変更してください",
|
"passwordStore_description": "使用するパスワード / シークレットストア。パスワードの保存に問題がある場合はこれを変更してください",
|
||||||
@@ -234,8 +238,8 @@
|
|||||||
"imageAspectRatio": "ネイティブのカバーアートの縦横比を使用する",
|
"imageAspectRatio": "ネイティブのカバーアートの縦横比を使用する",
|
||||||
"language": "言語",
|
"language": "言語",
|
||||||
"imageAspectRatio_description": "有効にすると、カバーアートはネイティブの縦横比で表示されます。縦横比が 1:1 でない場合、残りのスペースは空白になります",
|
"imageAspectRatio_description": "有効にすると、カバーアートはネイティブの縦横比で表示されます。縦横比が 1:1 でない場合、残りのスペースは空白になります",
|
||||||
"lastfm_description": "アーティスト/アルバムページに Last.fm へのリンクを表示します",
|
"lastfm_description": "アーティストとアルバムページに Last.fm へのリンクを表示します",
|
||||||
"lastfm": "Last.fm リンクを表示する",
|
"lastfm": "Last.fm のリンクを表示",
|
||||||
"lastfmApiKey": "{{lastfm}} API キー",
|
"lastfmApiKey": "{{lastfm}} API キー",
|
||||||
"homeConfiguration_description": "ホーム画面に表示する項目と表示順序を設定します",
|
"homeConfiguration_description": "ホーム画面に表示する項目と表示順序を設定します",
|
||||||
"homeConfiguration": "ホーム画面の設定",
|
"homeConfiguration": "ホーム画面の設定",
|
||||||
@@ -283,7 +287,7 @@
|
|||||||
"exportImportSettings_control_title": "設定をインポート/エクスポート",
|
"exportImportSettings_control_title": "設定をインポート/エクスポート",
|
||||||
"exportImportSettings_control_description": "JSON 経由で設定をエクスポートおよびインポートする",
|
"exportImportSettings_control_description": "JSON 経由で設定をエクスポートおよびインポートする",
|
||||||
"exportImportSettings_destructiveWarning": "設定のインポートは破壊的です。下の「インポート」をクリックする前に、上記の内容を必ずご確認ください!",
|
"exportImportSettings_destructiveWarning": "設定のインポートは破壊的です。下の「インポート」をクリックする前に、上記の内容を必ずご確認ください!",
|
||||||
"hotkey_navigateHome": "ホームに移動",
|
"hotkey_navigateHome": "ホーム画面へ移動",
|
||||||
"playButtonBehavior_optionPlayShuffled": "$t(player.shuffle)",
|
"playButtonBehavior_optionPlayShuffled": "$t(player.shuffle)",
|
||||||
"playerbarOpenDrawer": "プレーヤーバーの全画面表示切り替え",
|
"playerbarOpenDrawer": "プレーヤーバーの全画面表示切り替え",
|
||||||
"transcode": "トランスコーディングを有効にする",
|
"transcode": "トランスコーディングを有効にする",
|
||||||
@@ -325,11 +329,11 @@
|
|||||||
"followCurrentSong": "現在の曲をフォロー",
|
"followCurrentSong": "現在の曲をフォロー",
|
||||||
"followCurrentSong_description": "再生キューを現在再生中の曲まで自動的にスクロールします",
|
"followCurrentSong_description": "再生キューを現在再生中の曲まで自動的にスクロールします",
|
||||||
"logLevel": "ログレベル",
|
"logLevel": "ログレベル",
|
||||||
"logLevel_description": "表示するログの最小レベルを設定します。debug はすべてのログを表示し、error はエラーのみを表示します",
|
"logLevel_description": "表示するログの最小レベルを設定します。Debug はすべてのログを表示し、Error はエラーのみを表示します",
|
||||||
"logLevel_optionDebug": "debug",
|
"logLevel_optionDebug": "Debug",
|
||||||
"logLevel_optionError": "error",
|
"logLevel_optionError": "Error",
|
||||||
"logLevel_optionInfo": "info",
|
"logLevel_optionInfo": "Info",
|
||||||
"logLevel_optionWarn": "warn",
|
"logLevel_optionWarn": "Warn",
|
||||||
"playerFilters": "キューから曲をフィルタリング",
|
"playerFilters": "キューから曲をフィルタリング",
|
||||||
"playerFilters_description": "以下の基準に基づいて曲をキューに追加しないようにします",
|
"playerFilters_description": "以下の基準に基づいて曲をキューに追加しないようにします",
|
||||||
"artistRadioCount": "アーティスト / トラックのラジオカウント",
|
"artistRadioCount": "アーティスト / トラックのラジオカウント",
|
||||||
@@ -337,7 +341,7 @@
|
|||||||
"imageResolution": "画像の解像度",
|
"imageResolution": "画像の解像度",
|
||||||
"imageResolution_description": "アプリ内で使用される画像の解像度。値を 0 に設定すると、デフォルトでネイティブ画像解像度が適用されます",
|
"imageResolution_description": "アプリ内で使用される画像の解像度。値を 0 に設定すると、デフォルトでネイティブ画像解像度が適用されます",
|
||||||
"showLyricsInSidebar_description": "添付の再生キューに歌詞を表示するパネルが追加されます",
|
"showLyricsInSidebar_description": "添付の再生キューに歌詞を表示するパネルが追加されます",
|
||||||
"showLyricsInSidebar": "プレーヤーのサイドバーに歌詞を表示する",
|
"showLyricsInSidebar": "サイドバーのプレーヤーに歌詞を表示",
|
||||||
"showRatings": "星評価を表示する",
|
"showRatings": "星評価を表示する",
|
||||||
"imageResolution_optionSidebar": "サイドバー",
|
"imageResolution_optionSidebar": "サイドバー",
|
||||||
"imageResolution_optionHeader": "ヘッダー",
|
"imageResolution_optionHeader": "ヘッダー",
|
||||||
@@ -348,12 +352,12 @@
|
|||||||
"playerbarSliderType_optionWaveform": "波形",
|
"playerbarSliderType_optionWaveform": "波形",
|
||||||
"playerbarWaveformAlign": "波形アライメント",
|
"playerbarWaveformAlign": "波形アライメント",
|
||||||
"showRatings_description": "インターフェースに星評価機能を表示するかどうかを制御します",
|
"showRatings_description": "インターフェースに星評価機能を表示するかどうかを制御します",
|
||||||
"showVisualizerInSidebar": "プレーヤーのサイドバーにビジュアライザーを表示する",
|
"showVisualizerInSidebar": "サイドバーのプレーヤーにビジュアライザーを表示",
|
||||||
"combinedLyricsAndVisualizer": "プレーヤーのサイドバーに歌詞とビジュアライザーを統合する",
|
"combinedLyricsAndVisualizer": "サイドバーのプレーヤーに歌詞とビジュアライザーを統合",
|
||||||
"audioFadeOnStatusChange_description": "再生 / 一時停止の状態が変わったときにフェードアウトとフェードインを有効にします",
|
"audioFadeOnStatusChange_description": "再生 / 一時停止の状態が変わったときにフェードアウトとフェードインを有効にします",
|
||||||
"audioFadeOnStatusChange": "ステータス変更時の音声フェード",
|
"audioFadeOnStatusChange": "ステータス変更時の音声フェード",
|
||||||
"combinedLyricsAndVisualizer_description": "歌詞とビジュアライザーを同じパネルに統合します",
|
"combinedLyricsAndVisualizer_description": "歌詞とビジュアライザーを同じパネルに統合します",
|
||||||
"showVisualizerInSidebar_description": "プレーヤーのサイドバーにビジュアライザーを表示するパネルが追加されます",
|
"showVisualizerInSidebar_description": "サイドバーのプレーヤーにビジュアライザーを表示するパネルが追加されます",
|
||||||
"queryBuilderCustomFields": "カスタムフィールド",
|
"queryBuilderCustomFields": "カスタムフィールド",
|
||||||
"queryBuilderCustomFields_inputLabel": "ラベル",
|
"queryBuilderCustomFields_inputLabel": "ラベル",
|
||||||
"queryBuilderCustomFields_inputTag": "タグ",
|
"queryBuilderCustomFields_inputTag": "タグ",
|
||||||
@@ -375,7 +379,53 @@
|
|||||||
"automaticUpdates_description": "更新を自動的に確認してインストールします",
|
"automaticUpdates_description": "更新を自動的に確認してインストールします",
|
||||||
"releaseChannel_optionAlpha": "アルファ (nightly)",
|
"releaseChannel_optionAlpha": "アルファ (nightly)",
|
||||||
"discordStateIcon": "再生中アイコンを表示",
|
"discordStateIcon": "再生中アイコンを表示",
|
||||||
"discordStateIcon_description": "Rich Presence ステータスに小さな再生アイコンを表示します。「一時停止時に Rich Presence を表示」が有効になっている場合は、常に一時停止アイコンが表示されます"
|
"discordStateIcon_description": "Rich Presence ステータスに小さな再生アイコンを表示します。「一時停止時に Rich Presence を表示」が有効になっている場合は、常に一時停止アイコンが表示されます",
|
||||||
|
"sidebarPlaylistListFilterRegex_description": "この正規表現に一致するプレイリストをサイドバーから非表示にします",
|
||||||
|
"sidebarPlaylistListFilterRegex_placeholder": "例: ^Daily Mix.*",
|
||||||
|
"sidebarPlaylistListFilterRegex": "プレイリストフィルターの正規表現",
|
||||||
|
"sidebarPlaylistSorting": "サイドバーでプレイリストを並べ替え",
|
||||||
|
"sidebarPlaylistSorting_description": "デフォルトのサーバー順ではなく、ドラッグアンドドロップを使用してサイドバーでプレイリストを手動で並べ替えることができます",
|
||||||
|
"playerItemConfiguration_description": "全画面プレーヤーに表示する項目と順序を設定します",
|
||||||
|
"playerItemConfiguration": "プレーヤーの項目設定",
|
||||||
|
"autosave": "再生キューを自動的に保存",
|
||||||
|
"autosave_description": "再生キューをサーバーに自動的に保存できるようにします。これは Navidrome/Subsonic を使用している場合にのみ可能であり、再生キューを混在させることはできません。",
|
||||||
|
"autosaveCount": "自動再生キューの保存頻度",
|
||||||
|
"autosaveCount_description": "キューが保存されるまでにトラックが変更される回数を設定します。1 (最小値) は曲が変わるたびに保存されることを意味します",
|
||||||
|
"useThemePrimaryShade_description": "選択したテーマで定義されたプライマリシェードをプライマリカラーのバリアントに使用します",
|
||||||
|
"useThemePrimaryShade": "テーマのプライマリシェードを使用",
|
||||||
|
"primaryShade": "プライマリシェード",
|
||||||
|
"primaryShade_description": "ボタン、リンク、およびその他の主要色要素に使用されるプライマリシェード (0–9) を上書きします",
|
||||||
|
"playerbarWaveformAlign_optionTop": "上部",
|
||||||
|
"playerbarWaveformAlign_optionCenter": "中央",
|
||||||
|
"playerbarWaveformAlign_optionBottom": "下部",
|
||||||
|
"imageResolution_optionTable": "表",
|
||||||
|
"imageResolution_optionItemCard": "アイテムカード",
|
||||||
|
"blurExplicitImages": "露骨な画像をぼかす",
|
||||||
|
"blurExplicitImages_description": "露骨な表現を含むタグが付けられたアルバムおよび楽曲のアートワークをぼかします",
|
||||||
|
"enableGridMultiSelect": "グリッドの複数選択を有効にする",
|
||||||
|
"enableGridMultiSelect_description": "有効にすると、グリッドビューで複数のアイテムを選択できます。無効にすると、グリッドアイテムの画像をクリックするとアイテムページに移動します",
|
||||||
|
"playerbarWaveformBarWidth": "波形バーの幅",
|
||||||
|
"playerbarWaveformGap": "波形ギャップ",
|
||||||
|
"playerbarWaveformRadius": "波形半径",
|
||||||
|
"hotkey_listNavigateToPage": "項目の詳細ページへ移動",
|
||||||
|
"hotkey_listPlayDefault": "リストを再生 (デフォルト)",
|
||||||
|
"hotkey_listPlayLast": "最後に再生",
|
||||||
|
"hotkey_listPlayNext": "次に再生",
|
||||||
|
"hotkey_listPlayNow": "今すぐ再生",
|
||||||
|
"spotify_description": "アーティストとアルバムページに Spotify へのリンクを表示します",
|
||||||
|
"spotify": "Spotify のリンクを表示",
|
||||||
|
"nativeSpotify_description": "ブラウザーの代わりに Spotify アプリで開きます",
|
||||||
|
"nativeSpotify": "Spotify アプリを使用",
|
||||||
|
"listenbrainz_description": "アーティストとアルバムページに ListenBrainz へのリンクを表示します",
|
||||||
|
"listenbrainz": "ListenBrainz のリンクを表示",
|
||||||
|
"qobuz_description": "アーティストとアルバムページに Qobuz へのリンクを表示します",
|
||||||
|
"qobuz": "Qobuz のリンクを表示",
|
||||||
|
"sidePlayQueueLayout": "サイド再生キューのレイアウト",
|
||||||
|
"sidePlayQueueLayout_description": "結合されたサイド再生キューのレイアウトを設定します",
|
||||||
|
"sidePlayQueueLayout_optionHorizontal": "水平",
|
||||||
|
"sidePlayQueueLayout_optionVertical": "垂直",
|
||||||
|
"waveformLoadingDelay": "波形読み込みの遅延",
|
||||||
|
"waveformLoadingDelay_description": "波形を読み込むまでの遅延時間(秒単位)を設定します。Web プレーヤー使用時にカクつきが発生する場合は、この値を増やしてください。"
|
||||||
},
|
},
|
||||||
"action": {
|
"action": {
|
||||||
"editPlaylist": "$t(entity.playlist, {\"count\": 1}) を編集",
|
"editPlaylist": "$t(entity.playlist, {\"count\": 1}) を編集",
|
||||||
@@ -397,7 +447,10 @@
|
|||||||
"removeFromFavorites": "$t(entity.favorite, {\"count\": 2}) から削除",
|
"removeFromFavorites": "$t(entity.favorite, {\"count\": 2}) から削除",
|
||||||
"openIn": {
|
"openIn": {
|
||||||
"lastfm": "Last.fm で開く",
|
"lastfm": "Last.fm で開く",
|
||||||
"musicbrainz": "MusicBrainz で開く"
|
"musicbrainz": "MusicBrainz で開く",
|
||||||
|
"spotify": "Spotify で開く",
|
||||||
|
"listenbrainz": "ListenBrainz で開く",
|
||||||
|
"qobuz": "Qobuz で開く"
|
||||||
},
|
},
|
||||||
"moveToNext": "次",
|
"moveToNext": "次",
|
||||||
"downloadStarted": "{{count}} 曲のダウンロードを開始しました",
|
"downloadStarted": "{{count}} 曲のダウンロードを開始しました",
|
||||||
@@ -415,7 +468,8 @@
|
|||||||
"holdToMoveToBottom": "押し続けると一番下に移動します",
|
"holdToMoveToBottom": "押し続けると一番下に移動します",
|
||||||
"openApplicationDirectory": "アプリケーションディレクトリを開く",
|
"openApplicationDirectory": "アプリケーションディレクトリを開く",
|
||||||
"selectRangeOfItems": "項目の範囲を選択",
|
"selectRangeOfItems": "項目の範囲を選択",
|
||||||
"addOrRemoveFromSelection": "選択に追加または削除"
|
"addOrRemoveFromSelection": "選択に追加または削除",
|
||||||
|
"goToCurrent": "現在の項目へ移動"
|
||||||
},
|
},
|
||||||
"common": {
|
"common": {
|
||||||
"backward": "戻る",
|
"backward": "戻る",
|
||||||
@@ -463,8 +517,8 @@
|
|||||||
"setting_other": "設定",
|
"setting_other": "設定",
|
||||||
"version": "バージョン",
|
"version": "バージョン",
|
||||||
"title": "タイトル",
|
"title": "タイトル",
|
||||||
"filter_other": "フィルタ",
|
"filter_other": "フィルター",
|
||||||
"filters": "フィルタ",
|
"filters": "フィルター",
|
||||||
"create": "作成",
|
"create": "作成",
|
||||||
"bitrate": "ビットレート",
|
"bitrate": "ビットレート",
|
||||||
"saveAndReplace": "保存して変更",
|
"saveAndReplace": "保存して変更",
|
||||||
@@ -533,19 +587,23 @@
|
|||||||
"clean": "クリーン",
|
"clean": "クリーン",
|
||||||
"filter_single": "シングル",
|
"filter_single": "シングル",
|
||||||
"filter_multiple": "複数枚組",
|
"filter_multiple": "複数枚組",
|
||||||
"rename": "名前を変更"
|
"rename": "名前を変更",
|
||||||
|
"newVersionAvailable": "新しいバージョンが利用可能です",
|
||||||
|
"numberOfResults": "{{numberOfResults}} 件の結果",
|
||||||
|
"grouping": "グループ化"
|
||||||
},
|
},
|
||||||
"table": {
|
"table": {
|
||||||
"config": {
|
"config": {
|
||||||
"view": {
|
"view": {
|
||||||
"table": "テーブル",
|
"table": "表",
|
||||||
"grid": "グリッド",
|
"grid": "グリッド",
|
||||||
"list": "リスト"
|
"list": "リスト",
|
||||||
|
"detail": "詳細"
|
||||||
},
|
},
|
||||||
"general": {
|
"general": {
|
||||||
"displayType": "表示タイプ",
|
"displayType": "表示タイプ",
|
||||||
"gap": "$t(common.gap)",
|
"gap": "$t(common.gap)",
|
||||||
"tableColumns": "テーブル カラム",
|
"tableColumns": "テーブル列",
|
||||||
"autoFitColumns": "カラム長を自動調整",
|
"autoFitColumns": "カラム長を自動調整",
|
||||||
"size": "$t(common.size)",
|
"size": "$t(common.size)",
|
||||||
"itemSize": "項目のサイズ (px)",
|
"itemSize": "項目のサイズ (px)",
|
||||||
@@ -565,7 +623,14 @@
|
|||||||
"size_compact": "コンパクト",
|
"size_compact": "コンパクト",
|
||||||
"size_large": "大きい",
|
"size_large": "大きい",
|
||||||
"pagination_itemsPerPage": "ページあたりの項目数",
|
"pagination_itemsPerPage": "ページあたりの項目数",
|
||||||
"pagination_infinite": "無限"
|
"pagination_infinite": "無限",
|
||||||
|
"pagination": "ページネーション",
|
||||||
|
"pagination_paginate": "ページ分割",
|
||||||
|
"showHeader": "ヘッダーを表示",
|
||||||
|
"verticalBorders": "列の境界線",
|
||||||
|
"rowHoverHighlight": "行ホバーハイライト",
|
||||||
|
"alternateRowColors": "交互の行の色",
|
||||||
|
"horizontalBorders": "行の境界線"
|
||||||
},
|
},
|
||||||
"label": {
|
"label": {
|
||||||
"releaseDate": "発売日",
|
"releaseDate": "発売日",
|
||||||
@@ -602,7 +667,8 @@
|
|||||||
"image": "画像",
|
"image": "画像",
|
||||||
"sampleRate": "$t(common.sampleRate)",
|
"sampleRate": "$t(common.sampleRate)",
|
||||||
"composer": "作曲家",
|
"composer": "作曲家",
|
||||||
"titleArtist": "$t(common.title) (アーティスト)"
|
"titleArtist": "$t(common.title) (アーティスト)",
|
||||||
|
"albumGroup": "アルバムグループ"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"column": {
|
"column": {
|
||||||
@@ -666,7 +732,8 @@
|
|||||||
"saveQueueFailed": "キューを保存できませんでした",
|
"saveQueueFailed": "キューを保存できませんでした",
|
||||||
"settingsSyncError": "レンダラーとメインプロセスの設定に矛盾が見つかりました。変更を適用するにはアプリケーションを再起動してください",
|
"settingsSyncError": "レンダラーとメインプロセスの設定に矛盾が見つかりました。変更を適用するにはアプリケーションを再起動してください",
|
||||||
"invalidJson": "無効な JSON",
|
"invalidJson": "無効な JSON",
|
||||||
"serverLockSingleServer": "サーバーがロックされている場合、1 つのサーバーのみが許可されます"
|
"serverLockSingleServer": "サーバーがロックされている場合、1 つのサーバーのみが許可されます",
|
||||||
|
"playbackPausedDueToError": "エラーのため再生が一時停止されました"
|
||||||
},
|
},
|
||||||
"filter": {
|
"filter": {
|
||||||
"mostPlayed": "最も多く再生",
|
"mostPlayed": "最も多く再生",
|
||||||
@@ -712,7 +779,9 @@
|
|||||||
"id": "ID",
|
"id": "ID",
|
||||||
"album": "$t(entity.album, {\"count\": 1})",
|
"album": "$t(entity.album, {\"count\": 1})",
|
||||||
"explicitStatus": "$t(common.explicitStatus)",
|
"explicitStatus": "$t(common.explicitStatus)",
|
||||||
"sortName": "ソート名"
|
"sortName": "ソート名",
|
||||||
|
"matchAnd": "すべて",
|
||||||
|
"matchOr": "いずれか"
|
||||||
},
|
},
|
||||||
"page": {
|
"page": {
|
||||||
"sidebar": {
|
"sidebar": {
|
||||||
@@ -884,7 +953,8 @@
|
|||||||
"groupingTypePrimary": "主なリリースタイプ",
|
"groupingTypePrimary": "主なリリースタイプ",
|
||||||
"favoriteSongs": "お気に入りの曲",
|
"favoriteSongs": "お気に入りの曲",
|
||||||
"topSongsCommunity": "コミュニティ",
|
"topSongsCommunity": "コミュニティ",
|
||||||
"favoriteSongsFrom": "{{title}} のお気に入りの曲"
|
"favoriteSongsFrom": "{{title}} のお気に入りの曲",
|
||||||
|
"topSongsPersonal": "個人的"
|
||||||
},
|
},
|
||||||
"manageServers": {
|
"manageServers": {
|
||||||
"title": "サーバーの管理",
|
"title": "サーバーの管理",
|
||||||
@@ -986,8 +1056,7 @@
|
|||||||
"editPlaylist": {
|
"editPlaylist": {
|
||||||
"title": "$t(entity.playlist, {\"count\": 1}) を編集",
|
"title": "$t(entity.playlist, {\"count\": 1}) を編集",
|
||||||
"publicJellyfinNote": "Jellyfin では、何らかの理由でプレイリストが公開されているかどうかが表示されません。公開されたままにしたい場合は、以下の項目を選択してください",
|
"publicJellyfinNote": "Jellyfin では、何らかの理由でプレイリストが公開されているかどうかが表示されません。公開されたままにしたい場合は、以下の項目を選択してください",
|
||||||
"success": "$t(entity.playlist, {\"count\": 1}) が正常に更新されました",
|
"success": "$t(entity.playlist, {\"count\": 1}) が正常に更新されました"
|
||||||
"editNote": "大規模なプレイリストの場合、手動編集は推奨されません。既存のプレイリストを上書きすることでデータ損失が発生するリスクを許容しますか?"
|
|
||||||
},
|
},
|
||||||
"shareItem": {
|
"shareItem": {
|
||||||
"allowDownloading": "ダウンロードを許可",
|
"allowDownloading": "ダウンロードを許可",
|
||||||
@@ -995,7 +1064,9 @@
|
|||||||
"setExpiration": "有効期限を設定",
|
"setExpiration": "有効期限を設定",
|
||||||
"success": "共有リンクがクリップボードにコピーされました (またはここをクリックして開きます)",
|
"success": "共有リンクがクリップボードにコピーされました (またはここをクリックして開きます)",
|
||||||
"expireInvalid": "有効期限は将来の日時である必要があります",
|
"expireInvalid": "有効期限は将来の日時である必要があります",
|
||||||
"createFailed": "共有リンクを作成できませんでした (共有は有効になっていますか?)"
|
"createFailed": "共有リンクを作成できませんでした (共有は有効になっていますか?)",
|
||||||
|
"copyToClipboard": "クリップボードにコピー: Ctrl+C、Enter",
|
||||||
|
"successMustClick": "共有リンクが正常に作成されました。開くにはここをクリックしてください"
|
||||||
},
|
},
|
||||||
"privateMode": {
|
"privateMode": {
|
||||||
"enabled": "プライベートモードが有効になりました。再生ステータスは外部連携から非表示になっています",
|
"enabled": "プライベートモードが有効になりました。再生ステータスは外部連携から非表示になっています",
|
||||||
@@ -1011,7 +1082,7 @@
|
|||||||
"title": "ラジオ局を作成",
|
"title": "ラジオ局を作成",
|
||||||
"input_homepageUrl": "ホームページ URL",
|
"input_homepageUrl": "ホームページ URL",
|
||||||
"input_name": "名前",
|
"input_name": "名前",
|
||||||
"input_streamUrl": "Stream URL"
|
"input_streamUrl": "ストリーム URL"
|
||||||
},
|
},
|
||||||
"lyricsExport": {
|
"lyricsExport": {
|
||||||
"export": "歌詞をエクスポート",
|
"export": "歌詞をエクスポート",
|
||||||
@@ -1072,9 +1143,15 @@
|
|||||||
"audiobook": "オーディオブック",
|
"audiobook": "オーディオブック",
|
||||||
"audioDrama": "オーディオドラマ",
|
"audioDrama": "オーディオドラマ",
|
||||||
"compilation": "コンピレーション",
|
"compilation": "コンピレーション",
|
||||||
"djMix": "DJ Mix",
|
"djMix": "DJ ミックス",
|
||||||
"demo": "デモ",
|
"demo": "デモ",
|
||||||
"soundtrack": "サウンドトラック"
|
"soundtrack": "サウンドトラック",
|
||||||
|
"fieldRecording": "フィールドレコーディング",
|
||||||
|
"interview": "インタビュー",
|
||||||
|
"live": "ライブ",
|
||||||
|
"mixtape": "ミックステープ",
|
||||||
|
"remix": "リミックス",
|
||||||
|
"spokenWord": "スポークン・ワード"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"datetime": {
|
"datetime": {
|
||||||
@@ -1110,6 +1187,151 @@
|
|||||||
},
|
},
|
||||||
"visualizer": {
|
"visualizer": {
|
||||||
"visualizerType": "ビジュアライザーの種類",
|
"visualizerType": "ビジュアライザーの種類",
|
||||||
"colors": "色"
|
"colors": "色",
|
||||||
|
"cyclePresets": "サイクルプリセット",
|
||||||
|
"cycleTime": "サイクルタイム(秒)",
|
||||||
|
"includeAllPresets": "すべてのプリセットを含める",
|
||||||
|
"ignoredPresets": "無視されたプリセット",
|
||||||
|
"selectedPresets": "選択されたプリセット",
|
||||||
|
"randomizeNextPreset": "次のプリセットをランダム化",
|
||||||
|
"blendTime": "ブレンド時間",
|
||||||
|
"presets": "プリセット",
|
||||||
|
"selectPreset": "プリセットを選択",
|
||||||
|
"applyPreset": "プリセットを適用",
|
||||||
|
"saveAsPreset": "プリセットとして保存",
|
||||||
|
"updatePreset": "プリセットを更新",
|
||||||
|
"copyConfiguration": "設定をコピーする",
|
||||||
|
"pasteConfiguration": "設定を貼り付け",
|
||||||
|
"pasteConfigurationPlaceholder": "ここに JSON 設定を貼り付けてください...",
|
||||||
|
"pasteFromClipboard": "クリップボードから貼り付け",
|
||||||
|
"applyConfiguration": "設定を適用",
|
||||||
|
"configCopied": "設定をクリップボードにコピーしました",
|
||||||
|
"configCopyFailed": "設定のコピーに失敗しました",
|
||||||
|
"configPasted": "設定が正常に適用されました",
|
||||||
|
"configPasteFailed": "設定の適用に失敗しました。形式を確認してください。",
|
||||||
|
"configPasteReadFailed": "クリップボードからの読み取りに失敗しました",
|
||||||
|
"presetName": "プリセット名",
|
||||||
|
"presetNamePlaceholder": "プリセット名を入力",
|
||||||
|
"general": "全般",
|
||||||
|
"mode": "モード",
|
||||||
|
"mode1To8": "モード 1 - 8",
|
||||||
|
"mode10": "モード 10",
|
||||||
|
"barSpace": "バースペース",
|
||||||
|
"lineWidth": "線幅",
|
||||||
|
"fillAlpha": "アルファ塗りつぶしを設定",
|
||||||
|
"channelLayout": "チャンネルレイアウト",
|
||||||
|
"maxFPS": "最大フレームレート",
|
||||||
|
"opacity": "不透明度",
|
||||||
|
"customGradients": "カスタムグラデーション",
|
||||||
|
"addCustomGradient": "カスタムグラデーションを追加",
|
||||||
|
"gradientName": "グラデーション名",
|
||||||
|
"gradientNamePlaceholder": "グラデーション名",
|
||||||
|
"vertical": "垂直",
|
||||||
|
"horizontal": "水平",
|
||||||
|
"colorStops": "カラー停止点の数",
|
||||||
|
"addColor": "色を加える",
|
||||||
|
"position": "位置",
|
||||||
|
"level": "レベル",
|
||||||
|
"remove": "取り除く",
|
||||||
|
"pasteGradient": "グラデーションを貼り付け",
|
||||||
|
"pasteGradientPlaceholder": "グラデーションの JSON をここに貼り付けてください...",
|
||||||
|
"custom": "カスタム",
|
||||||
|
"builtIn": "組み込み",
|
||||||
|
"colorMode": "カラーモード",
|
||||||
|
"gradient": "勾配",
|
||||||
|
"gradientLeft": "左へのグラデーション",
|
||||||
|
"gradientRight": "右へのグラデーション",
|
||||||
|
"fft": "高速フーリエ変換",
|
||||||
|
"fftSize": "FFT サイズ",
|
||||||
|
"smoothing": "平滑化",
|
||||||
|
"frequencyRangeAndScaling": "周波数範囲とスケーリング",
|
||||||
|
"minimumFrequency": "最小周波数",
|
||||||
|
"maximumFrequency": "最大周波数",
|
||||||
|
"frequencyScale": "周波数スケール",
|
||||||
|
"sensitivity": "感度",
|
||||||
|
"weightingFilter": "重み付けフィルタ",
|
||||||
|
"minimumDecibels": "最小デシベル",
|
||||||
|
"maximumDecibels": "最大デシベル",
|
||||||
|
"linearAmplitude": "線形振幅",
|
||||||
|
"linearBoost": "リニアブースト",
|
||||||
|
"peakBehavior": "ピーク時の振る舞い",
|
||||||
|
"showPeaks": "ピークスを表示",
|
||||||
|
"fadePeaks": "フェードピークス",
|
||||||
|
"peakLine": "ピークライン",
|
||||||
|
"gravity": "重力",
|
||||||
|
"peakFadeTime": "ピークフェード時間(ミリ秒)",
|
||||||
|
"peakHoldTime": "ピークホールド時間(ミリ秒)",
|
||||||
|
"radialSpectrum": "放射状スペクトル",
|
||||||
|
"radial": "ラジアル",
|
||||||
|
"radialInvert": "放射状インバート",
|
||||||
|
"spinSpeed": "回転速度",
|
||||||
|
"radius": "半径",
|
||||||
|
"reflexMirror": "反射鏡",
|
||||||
|
"reflexFit": "リフレックス・フィット",
|
||||||
|
"reflexRatio": "反射比",
|
||||||
|
"reflexAlpha": "リフレックス・アルファ",
|
||||||
|
"reflexBrightness": "反射輝度",
|
||||||
|
"mirror": "鏡",
|
||||||
|
"miscellaneousSettings": "その他の設定",
|
||||||
|
"alphaBars": "アルファバー",
|
||||||
|
"ansiBands": "ANSI バンド",
|
||||||
|
"ledBars": "LED バー",
|
||||||
|
"trueLeds": "真の LED",
|
||||||
|
"lumiBars": "ルミ・バー",
|
||||||
|
"outlineBars": "アウトラインバー",
|
||||||
|
"roundBars": "丸棒",
|
||||||
|
"lowResolution": "低解像度",
|
||||||
|
"splitGradient": "分割グラデーション",
|
||||||
|
"showFPS": "FPS を表示",
|
||||||
|
"showScaleX": "X 軸スケールを表示",
|
||||||
|
"noteLabels": "注釈ラベル",
|
||||||
|
"showScaleY": "Y 軸スケールを表示",
|
||||||
|
"options": {
|
||||||
|
"mode": {
|
||||||
|
"0": "[0] 離散周波数",
|
||||||
|
"1": "[1] 1/24 オクターブ / 240 バンド",
|
||||||
|
"2": "[2] 1/12 オクターブ / 120 バンド",
|
||||||
|
"3": "[3] 1/8 オクターブ / 80 バンド",
|
||||||
|
"4": "[4] 1/6 オクターブ / 60 バンド",
|
||||||
|
"5": "[5] 1/4 オクターブ / 40 バンド",
|
||||||
|
"6": "[6] 1/3 オクターブ / 30 バンド",
|
||||||
|
"7": "[7] 半オクターブ / 20 バンド",
|
||||||
|
"8": "[8] フルオクターブ / 10 バンド",
|
||||||
|
"10": "[10] 折れ線グラフ / 面グラフ"
|
||||||
|
},
|
||||||
|
"colorMode": {
|
||||||
|
"gradient": "勾配",
|
||||||
|
"barIndex": "バー・インデックス",
|
||||||
|
"barLevel": "バーレベル"
|
||||||
|
},
|
||||||
|
"gradient": {
|
||||||
|
"classic": "クラシック",
|
||||||
|
"prism": "プリズム",
|
||||||
|
"rainbow": "虹",
|
||||||
|
"steelblue": "スチールブルー",
|
||||||
|
"orangered": "オレンジレッド"
|
||||||
|
},
|
||||||
|
"channelLayout": {
|
||||||
|
"single": "シングル",
|
||||||
|
"dualCombined": "デュアルコンバインド",
|
||||||
|
"dualHorizontal": "デュアル水平",
|
||||||
|
"dualVertical": "デュアルバーティカル"
|
||||||
|
},
|
||||||
|
"frequencyScale": {
|
||||||
|
"none": "なし",
|
||||||
|
"bark": "樹皮スケール",
|
||||||
|
"linear": "線形スケール",
|
||||||
|
"log": "対数スケール",
|
||||||
|
"mel": "メル尺度"
|
||||||
|
},
|
||||||
|
"weightingFilter": {
|
||||||
|
"none": "なし",
|
||||||
|
"a": "A",
|
||||||
|
"b": "B",
|
||||||
|
"c": "C",
|
||||||
|
"d": "D",
|
||||||
|
"z": "Z"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,8 @@
|
|||||||
"action": {
|
"action": {
|
||||||
"openIn": {
|
"openIn": {
|
||||||
"lastfm": "Åpne i Last.fm",
|
"lastfm": "Åpne i Last.fm",
|
||||||
"musicbrainz": "Åpne i MusicBrainz"
|
"musicbrainz": "Åpne i MusicBrainz",
|
||||||
|
"spotify": "Åpne i Spotify"
|
||||||
},
|
},
|
||||||
"moveToBottom": "flytt til bunnen",
|
"moveToBottom": "flytt til bunnen",
|
||||||
"deletePlaylist": "slett $t(entity.playlist, {\"count\": 1})",
|
"deletePlaylist": "slett $t(entity.playlist, {\"count\": 1})",
|
||||||
@@ -159,7 +160,8 @@
|
|||||||
"gridRows": "rutenettrader",
|
"gridRows": "rutenettrader",
|
||||||
"tableColumns": "tabellkolonner",
|
"tableColumns": "tabellkolonner",
|
||||||
"itemsMore": "{{count}} fler",
|
"itemsMore": "{{count}} fler",
|
||||||
"explicitStatus": "grovhetsstatus"
|
"explicitStatus": "grovhetsstatus",
|
||||||
|
"newVersionAvailable": "en ny version er tilgjengelig"
|
||||||
},
|
},
|
||||||
"entity": {
|
"entity": {
|
||||||
"smartPlaylist": "smart $t(entity.playlist, {\"count\": 1})",
|
"smartPlaylist": "smart $t(entity.playlist, {\"count\": 1})",
|
||||||
@@ -233,7 +235,8 @@
|
|||||||
"saveQueueFailed": "kunne ikke lagre kø",
|
"saveQueueFailed": "kunne ikke lagre kø",
|
||||||
"multipleServerSaveQueueError": "Spillekøen har en eller flere sanger som ikke finnes på gjeldene tjener. Dette er ikke støttet",
|
"multipleServerSaveQueueError": "Spillekøen har en eller flere sanger som ikke finnes på gjeldene tjener. Dette er ikke støttet",
|
||||||
"serverLockSingleServer": "kun én tjener er tillatt når tjener er låst",
|
"serverLockSingleServer": "kun én tjener er tillatt når tjener er låst",
|
||||||
"settingsSyncError": "avvik ble funnet mellom innstillinger i avspilleren og hovedprosessen. ta en omstart av applikasjonen for å aktivere endringene"
|
"settingsSyncError": "avvik ble funnet mellom innstillinger i avspilleren og hovedprosessen. ta en omstart av applikasjonen for å aktivere endringene",
|
||||||
|
"playbackPausedDueToError": "avspilling ble paused på grunn av en feil"
|
||||||
},
|
},
|
||||||
"filter": {
|
"filter": {
|
||||||
"bpm": "bpm",
|
"bpm": "bpm",
|
||||||
@@ -319,7 +322,8 @@
|
|||||||
"success": "la $t(entity.trackWithCount, {\"count\": {{message}} }) til $t(entity.playlistWithCount, {\"count\": {{numOfPlaylists}} })",
|
"success": "la $t(entity.trackWithCount, {\"count\": {{message}} }) til $t(entity.playlistWithCount, {\"count\": {{numOfPlaylists}} })",
|
||||||
"title": "legg til i $t(entity.playlist, {\"count\": 1})",
|
"title": "legg til i $t(entity.playlist, {\"count\": 1})",
|
||||||
"input_skipDuplicates": "hopp over duplikater",
|
"input_skipDuplicates": "hopp over duplikater",
|
||||||
"input_playlists": "$t(entity.playlist, {\"count\": 2})"
|
"input_playlists": "$t(entity.playlist, {\"count\": 2})",
|
||||||
|
"searchOrCreate": "søk $t(entity.playlist, {\"count\": 2}) eller skriv for å opprette en"
|
||||||
},
|
},
|
||||||
"deletePlaylist": {
|
"deletePlaylist": {
|
||||||
"title": "slett $t(entity.playlist, {\"count\": 1})",
|
"title": "slett $t(entity.playlist, {\"count\": 1})",
|
||||||
@@ -328,7 +332,8 @@
|
|||||||
},
|
},
|
||||||
"editPlaylist": {
|
"editPlaylist": {
|
||||||
"title": "rediger $t(entity.playlist, {\"count\": 1})",
|
"title": "rediger $t(entity.playlist, {\"count\": 1})",
|
||||||
"success": "$t(entity.playlist, {\"count\": 1}) er oppdatert"
|
"success": "$t(entity.playlist, {\"count\": 1}) er oppdatert",
|
||||||
|
"publicJellyfinNote": "Jellyfin av en grunn kan ikke oppgi om en spilleliste er offentlig eller ikke. Hvis du ønsker at denne skal beholdes offentlig, vennligst ha følgende inndata valgt"
|
||||||
},
|
},
|
||||||
"shareItem": {
|
"shareItem": {
|
||||||
"allowDownloading": "tillat nedlasting",
|
"allowDownloading": "tillat nedlasting",
|
||||||
@@ -336,7 +341,9 @@
|
|||||||
"createFailed": "opprettelse av delt ressurs feilet (er deling aktivert?)",
|
"createFailed": "opprettelse av delt ressurs feilet (er deling aktivert?)",
|
||||||
"setExpiration": "angi utløpstid",
|
"setExpiration": "angi utløpstid",
|
||||||
"success": "del lenke som er kopiert til utklippstavlen (eller klikk her for å åpne)",
|
"success": "del lenke som er kopiert til utklippstavlen (eller klikk her for å åpne)",
|
||||||
"expireInvalid": "utløpstid må være et fremtidig tidspunkt"
|
"expireInvalid": "utløpstid må være et fremtidig tidspunkt",
|
||||||
|
"copyToClipboard": "Kopier til kopitavle: Ctrl+C, Enter",
|
||||||
|
"successMustClick": "opprettet deling. trykk her for å åpne"
|
||||||
},
|
},
|
||||||
"updateServer": {
|
"updateServer": {
|
||||||
"success": "vellykket oppdatering av serveren",
|
"success": "vellykket oppdatering av serveren",
|
||||||
@@ -367,7 +374,19 @@
|
|||||||
},
|
},
|
||||||
"lyricsExport": {
|
"lyricsExport": {
|
||||||
"export": "eksporter sangtekster",
|
"export": "eksporter sangtekster",
|
||||||
"input_synced": "eksporter sunkroniserte sangtekster"
|
"input_synced": "eksporter sunkroniserte sangtekster",
|
||||||
|
"input_offset": "$t(setting.lyricOffset)"
|
||||||
|
},
|
||||||
|
"shuffleAll": {
|
||||||
|
"title": "spill av tilfeldig",
|
||||||
|
"input_genre": "$t(entity.genre, {\"count\": 1})",
|
||||||
|
"input_limit": "hvor mange sanger?",
|
||||||
|
"input_minYear": "fra år",
|
||||||
|
"input_maxYear": "til år",
|
||||||
|
"input_played": "avspillingsfilter",
|
||||||
|
"input_played_optionAll": "alle sanger",
|
||||||
|
"input_played_optionUnplayed": "bare uavspilte sanger",
|
||||||
|
"input_played_optionPlayed": "bare avspilte sanger"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"page": {
|
"page": {
|
||||||
@@ -503,7 +522,7 @@
|
|||||||
"playlists": "$t(entity.playlist, {\"count\": 2})",
|
"playlists": "$t(entity.playlist, {\"count\": 2})",
|
||||||
"search": "$t(common.search)",
|
"search": "$t(common.search)",
|
||||||
"settings": "$t(common.setting, {\"count\": 2})",
|
"settings": "$t(common.setting, {\"count\": 2})",
|
||||||
"shared": "delt $t(entity.playlist, {\"count\": 2})",
|
"shared": "delte $t(entity.playlist, {\"count\": 2})",
|
||||||
"artists": "$t(entity.artist, {\"count\": 2})",
|
"artists": "$t(entity.artist, {\"count\": 2})",
|
||||||
"myLibrary": "mitt bibliotek"
|
"myLibrary": "mitt bibliotek"
|
||||||
},
|
},
|
||||||
@@ -512,7 +531,8 @@
|
|||||||
"advanced": "avansert",
|
"advanced": "avansert",
|
||||||
"hotkeysTab": "hurtigtaster",
|
"hotkeysTab": "hurtigtaster",
|
||||||
"playbackTab": "avspilling",
|
"playbackTab": "avspilling",
|
||||||
"windowTab": "vindu"
|
"windowTab": "vindu",
|
||||||
|
"theme": "tema"
|
||||||
},
|
},
|
||||||
"playlistList": {
|
"playlistList": {
|
||||||
"title": "$t(entity.playlist, {\"count\": 2})"
|
"title": "$t(entity.playlist, {\"count\": 2})"
|
||||||
|
|||||||
@@ -19,7 +19,10 @@
|
|||||||
"clearQueue": "verwijder lijst",
|
"clearQueue": "verwijder lijst",
|
||||||
"openIn": {
|
"openIn": {
|
||||||
"lastfm": "Open in Last.fm",
|
"lastfm": "Open in Last.fm",
|
||||||
"musicbrainz": "Open in MusicBrainz"
|
"musicbrainz": "Open in MusicBrainz",
|
||||||
|
"listenbrainz": "Openen in ListenBrainz",
|
||||||
|
"qobuz": "Openen in Qobuz",
|
||||||
|
"spotify": "Openen in Spotify"
|
||||||
},
|
},
|
||||||
"moveToNext": "ga naar volgende",
|
"moveToNext": "ga naar volgende",
|
||||||
"downloadStarted": "begonnen met downloaden van {{count}} items",
|
"downloadStarted": "begonnen met downloaden van {{count}} items",
|
||||||
@@ -37,7 +40,8 @@
|
|||||||
"moveDown": "verplaats omlaag",
|
"moveDown": "verplaats omlaag",
|
||||||
"holdToMoveToTop": "ingedrukt houden om naar begin te verplaatsen",
|
"holdToMoveToTop": "ingedrukt houden om naar begin te verplaatsen",
|
||||||
"holdToMoveToBottom": "ingedrukt houden om naar einde te verplaatsen",
|
"holdToMoveToBottom": "ingedrukt houden om naar einde te verplaatsen",
|
||||||
"openApplicationDirectory": "applicatiemap openen"
|
"openApplicationDirectory": "applicatiemap openen",
|
||||||
|
"goToCurrent": "ga naar huidige item"
|
||||||
},
|
},
|
||||||
"common": {
|
"common": {
|
||||||
"backward": "achteruit",
|
"backward": "achteruit",
|
||||||
@@ -130,6 +134,7 @@
|
|||||||
"bitDepth": "bitdiepte",
|
"bitDepth": "bitdiepte",
|
||||||
"codec": "codec",
|
"codec": "codec",
|
||||||
"mbid": "MusicBrainz ID",
|
"mbid": "MusicBrainz ID",
|
||||||
|
"grouping": "groepering",
|
||||||
"share": "deel",
|
"share": "deel",
|
||||||
"explicit": "expliciet",
|
"explicit": "expliciet",
|
||||||
"sampleRate": "sample rate",
|
"sampleRate": "sample rate",
|
||||||
@@ -159,7 +164,9 @@
|
|||||||
"retry": "opnieuw proberen",
|
"retry": "opnieuw proberen",
|
||||||
"filter_single": "single",
|
"filter_single": "single",
|
||||||
"rename": "hernoemen",
|
"rename": "hernoemen",
|
||||||
"filter_multiple": "meerdere"
|
"filter_multiple": "meerdere",
|
||||||
|
"numberOfResults": "{{numberOfResults}} resultaten",
|
||||||
|
"newVersionAvailable": "een nieuwe versie is beschikbaar"
|
||||||
},
|
},
|
||||||
"filter": {
|
"filter": {
|
||||||
"rating": "rating",
|
"rating": "rating",
|
||||||
@@ -452,7 +459,8 @@
|
|||||||
"saveQueueFailed": "kan wachtrij niet opslaan",
|
"saveQueueFailed": "kan wachtrij niet opslaan",
|
||||||
"settingsSyncError": "Er zijn verschillen gevonden tussen de instellingen in de renderer en het hoofdproces. Start de applicatie opnieuw op om de wijzigingen toe te passen",
|
"settingsSyncError": "Er zijn verschillen gevonden tussen de instellingen in de renderer en het hoofdproces. Start de applicatie opnieuw op om de wijzigingen toe te passen",
|
||||||
"invalidJson": "ongeldige JSON",
|
"invalidJson": "ongeldige JSON",
|
||||||
"serverLockSingleServer": "slechts één server is toegestaan als server op slot is gezet"
|
"serverLockSingleServer": "slechts één server is toegestaan als server op slot is gezet",
|
||||||
|
"playbackPausedDueToError": "afspelen gepauzeerd vanwege een fout"
|
||||||
},
|
},
|
||||||
"entity": {
|
"entity": {
|
||||||
"genre_one": "genre",
|
"genre_one": "genre",
|
||||||
@@ -561,7 +569,8 @@
|
|||||||
"title": "$t(common.title)",
|
"title": "$t(common.title)",
|
||||||
"titleArtist": "$t(common.title) (artiest)",
|
"titleArtist": "$t(common.title) (artiest)",
|
||||||
"titleCombined": "$t(common.title) (gecombineerd)",
|
"titleCombined": "$t(common.title) (gecombineerd)",
|
||||||
"year": "$t(common.year)"
|
"year": "$t(common.year)",
|
||||||
|
"albumGroup": "albumgroep"
|
||||||
},
|
},
|
||||||
"general": {
|
"general": {
|
||||||
"advancedSettings": "geavanceerde instellingen",
|
"advancedSettings": "geavanceerde instellingen",
|
||||||
@@ -954,7 +963,29 @@
|
|||||||
"automaticUpdates_description": "Zoek en installeer updates automatisch",
|
"automaticUpdates_description": "Zoek en installeer updates automatisch",
|
||||||
"releaseChannel_optionAlpha": "alfa (nachtelijk)",
|
"releaseChannel_optionAlpha": "alfa (nachtelijk)",
|
||||||
"discordStateIcon": "toon afspeelicoon",
|
"discordStateIcon": "toon afspeelicoon",
|
||||||
"discordStateIcon_description": "toon een klein afspeelicoon in de rich-presence-status. het gepauzeerde icoon wordt altijd getoond als \"rich presence tonen wanneer gepauzeerd\" is ingeschakeld"
|
"discordStateIcon_description": "toon een klein afspeelicoon in de rich-presence-status. het gepauzeerde icoon wordt altijd getoond als \"rich presence tonen wanneer gepauzeerd\" is ingeschakeld",
|
||||||
|
"autosave": "afspeelwachtrij automatisch opslaan",
|
||||||
|
"autosave_description": "schakel in dat de afspeelwachtrij automatisch wordt opgeslagen op je server. dit is enkel mogelijk bij gebruik van Navidrome/Subsonic met een niet-gemengde afspeelwachtrij.",
|
||||||
|
"autosaveCount": "frequentie automatisch opslaan afspeelwachtrij",
|
||||||
|
"autosaveCount_description": "aantal nummerwisselingen voordat de wachtrij wordt opgeslagen. bij 1 (het minimum) treedt dit bij elke nummerwissel op",
|
||||||
|
"useThemePrimaryShade": "gebruikt primaire tint van thema",
|
||||||
|
"useThemePrimaryShade_description": "gebruik de primaire tint die in het geselecteerde thema is ingesteld voor hoofdkleurvarianten",
|
||||||
|
"primaryShade": "primaire tint",
|
||||||
|
"primaryShade_description": "overschrijf de primaire tint (0-9) die wordt gebruikt voor knoppen, links en andere elementen die de hoofdkleur gebruiken",
|
||||||
|
"listenbrainz_description": "toon links naar ListenBrainz op artiest- en albumpagina's",
|
||||||
|
"listenbrainz": "toon ListenBrainz-links",
|
||||||
|
"qobuz_description": "toon links naar Qobuz op artiest- en albumpagina's",
|
||||||
|
"qobuz": "toon Qobuz-links",
|
||||||
|
"spotify_description": "toon links naar Spotify op artiest- en albumpagina's",
|
||||||
|
"spotify": "toon Spotify-links",
|
||||||
|
"nativeSpotify_description": "open de Spotify app in plaats van de webbrowser",
|
||||||
|
"nativeSpotify": "gebruik Spotify app",
|
||||||
|
"playerItemConfiguration_description": "stel in welke items en in welke volgorde te tonen in de volledigschermspeler",
|
||||||
|
"playerItemConfiguration": "configuratie van afspeel-item",
|
||||||
|
"sidePlayQueueLayout": "uitlijning afspeelwachtrij aan zijkant",
|
||||||
|
"sidePlayQueueLayout_description": "stel de uitlijning in voor de afspeelwachtrij die aan de zijkant is gekoppeld",
|
||||||
|
"sidePlayQueueLayout_optionHorizontal": "horizontaal",
|
||||||
|
"sidePlayQueueLayout_optionVertical": "verticaal"
|
||||||
},
|
},
|
||||||
"form": {
|
"form": {
|
||||||
"addServer": {
|
"addServer": {
|
||||||
@@ -1013,8 +1044,7 @@
|
|||||||
"editPlaylist": {
|
"editPlaylist": {
|
||||||
"title": "$t(entity.playlist, {\"count\": 1}) aanpassen",
|
"title": "$t(entity.playlist, {\"count\": 1}) aanpassen",
|
||||||
"publicJellyfinNote": "Jellyfin laat niet weten of een playlist publiek of privaat is. Als u wilt dat dit publiek blijft, selecteer de volgende invoer",
|
"publicJellyfinNote": "Jellyfin laat niet weten of een playlist publiek of privaat is. Als u wilt dat dit publiek blijft, selecteer de volgende invoer",
|
||||||
"success": "$t(entity.playlist, {\"count\": 1}) succesvol geüpdatet",
|
"success": "$t(entity.playlist, {\"count\": 1}) succesvol geüpdatet"
|
||||||
"editNote": "Handmatige bewerking wordt afgeraden voor grote afspeellijsten. Weet je zeker dat je het risico op dataverlies wilt accepteren door de bestaande afspeellijst te overschrijven?"
|
|
||||||
},
|
},
|
||||||
"updateServer": {
|
"updateServer": {
|
||||||
"title": "update server",
|
"title": "update server",
|
||||||
@@ -1114,7 +1144,8 @@
|
|||||||
"sleepTimer_off": "uit",
|
"sleepTimer_off": "uit",
|
||||||
"sleepTimer_timeRemaining": "{{time}} resterend",
|
"sleepTimer_timeRemaining": "{{time}} resterend",
|
||||||
"sleepTimer_setCustom": "timer instellen",
|
"sleepTimer_setCustom": "timer instellen",
|
||||||
"sleepTimer_cancel": "timer annuleren"
|
"sleepTimer_cancel": "timer annuleren",
|
||||||
|
"albumRadio": "albumradio"
|
||||||
},
|
},
|
||||||
"datetime": {
|
"datetime": {
|
||||||
"minuteShort": "m",
|
"minuteShort": "m",
|
||||||
|
|||||||
@@ -19,7 +19,10 @@
|
|||||||
"setRating": "oceń",
|
"setRating": "oceń",
|
||||||
"openIn": {
|
"openIn": {
|
||||||
"lastfm": "Otwórz w Last.fm",
|
"lastfm": "Otwórz w Last.fm",
|
||||||
"musicbrainz": "Otwórz w MusicBrainz"
|
"musicbrainz": "Otwórz w MusicBrainz",
|
||||||
|
"listenbrainz": "Otwórz w ListenBrainz",
|
||||||
|
"qobuz": "Otwórz w Qobuz",
|
||||||
|
"spotify": "Otwórz w Spotify"
|
||||||
},
|
},
|
||||||
"moveToNext": "przesuń na następne",
|
"moveToNext": "przesuń na następne",
|
||||||
"downloadStarted": "rozpoczęto pobieranie {{count}} elementów",
|
"downloadStarted": "rozpoczęto pobieranie {{count}} elementów",
|
||||||
@@ -164,7 +167,9 @@
|
|||||||
"example": "przykład",
|
"example": "przykład",
|
||||||
"filter_multiple": "multi",
|
"filter_multiple": "multi",
|
||||||
"filter_single": "single",
|
"filter_single": "single",
|
||||||
"rename": "zmień nazwę"
|
"rename": "zmień nazwę",
|
||||||
|
"newVersionAvailable": "nowa wersja jest dostępna",
|
||||||
|
"numberOfResults": "{{numberOfResults}} wyników"
|
||||||
},
|
},
|
||||||
"entity": {
|
"entity": {
|
||||||
"genre_one": "gatunek",
|
"genre_one": "gatunek",
|
||||||
@@ -369,8 +374,7 @@
|
|||||||
"editPlaylist": {
|
"editPlaylist": {
|
||||||
"title": "edytuj $t(entity.playlist, {\"count\": 1})",
|
"title": "edytuj $t(entity.playlist, {\"count\": 1})",
|
||||||
"success": "$t(entity.playlist, {\"count\": 1}) zaktualizowana pomyślnie",
|
"success": "$t(entity.playlist, {\"count\": 1}) zaktualizowana pomyślnie",
|
||||||
"publicJellyfinNote": "Z jakiegoś powodu Jellyfin nie udostępnia informacji na temat publiczności playlisty. Jeżeli chcesz, aby ta pozostała publiczna, mniej wybraną poniższą opcję",
|
"publicJellyfinNote": "Z jakiegoś powodu Jellyfin nie udostępnia informacji na temat publiczności playlisty. Jeżeli chcesz, aby ta pozostała publiczna, mniej wybraną poniższą opcję"
|
||||||
"editNote": "manualne edytowanie nie jest zalecane dla dużych playlist. czy na pewno zgadzasz się na ryzyko utraty danych wywołane przez nadpisanie istniejącej playlisty?"
|
|
||||||
},
|
},
|
||||||
"shareItem": {
|
"shareItem": {
|
||||||
"allowDownloading": "zezwól na pobieranie",
|
"allowDownloading": "zezwól na pobieranie",
|
||||||
@@ -1038,7 +1042,23 @@
|
|||||||
"primaryShade": "główny odcień",
|
"primaryShade": "główny odcień",
|
||||||
"primaryShade_description": "nadpisz główny odcień (0-9) używany dla przycisków, linków i innych głównie pokolorowanych elementów",
|
"primaryShade_description": "nadpisz główny odcień (0-9) używany dla przycisków, linków i innych głównie pokolorowanych elementów",
|
||||||
"playerItemConfiguration_description": "skonfiguruj jakie elementy są pokazywane i w jakiej kolejności, w odtwarzaczu pełnoekranowym",
|
"playerItemConfiguration_description": "skonfiguruj jakie elementy są pokazywane i w jakiej kolejności, w odtwarzaczu pełnoekranowym",
|
||||||
"playerItemConfiguration": "konfiguracja elementów odtwarzacza"
|
"playerItemConfiguration": "konfiguracja elementów odtwarzacza",
|
||||||
|
"autosave": "automatycznie zapisuj kolejkę odtwarzania",
|
||||||
|
"autosave_description": "włącz automatyczne zapisywanie kolejki odtwarzania na twój serwer. to jest możliwe tylko gdy używane jest Navidrome/Subsonic, i nie masz zmixowanej kolejki odtwarzania.",
|
||||||
|
"autosaveCount": "częstotliwość automatycznego zapisywania kolejki odtwarzania",
|
||||||
|
"autosaveCount_description": "ile razy piosenka zostanie zmieniona przed zapisaniem kolejki. 1 (najmniejsze) oznacza zapisywanie kolejki po każdej zmianie piosenki",
|
||||||
|
"listenbrainz_description": "pokaż linki do ListenBrainz na stronach wykonawców/albumów",
|
||||||
|
"listenbrainz": "pokaż linki ListenBrainz",
|
||||||
|
"qobuz_description": "pokaż linki do Qobuz na stronach wykonawców/albumów",
|
||||||
|
"qobuz": "pokaż linki Qobuz",
|
||||||
|
"spotify_description": "pokaż linki do Spotify na stronach wykonawców/albumów",
|
||||||
|
"spotify": "pokaż linki Spotify",
|
||||||
|
"nativeSpotify_description": "otwieraj w aplikacji Spotify zamiast w twojej przeglądarce",
|
||||||
|
"nativeSpotify": "używaj aplikacji Spotify",
|
||||||
|
"sidePlayQueueLayout": "układ boczny kolejki odtwarzania",
|
||||||
|
"sidePlayQueueLayout_description": "ustawia układ przyczepionej z boku kolejki odtwarzania",
|
||||||
|
"sidePlayQueueLayout_optionHorizontal": "poziomy",
|
||||||
|
"sidePlayQueueLayout_optionVertical": "pionowy"
|
||||||
},
|
},
|
||||||
"table": {
|
"table": {
|
||||||
"config": {
|
"config": {
|
||||||
|
|||||||
@@ -84,6 +84,7 @@
|
|||||||
"size": "tamanho",
|
"size": "tamanho",
|
||||||
"note": "observação",
|
"note": "observação",
|
||||||
"mbid": "ID no MusicBrainz",
|
"mbid": "ID no MusicBrainz",
|
||||||
|
"grouping": "agrupamento",
|
||||||
"reload": "recarregar",
|
"reload": "recarregar",
|
||||||
"codec": "codec",
|
"codec": "codec",
|
||||||
"preview": "pré-visualizar",
|
"preview": "pré-visualizar",
|
||||||
|
|||||||
@@ -72,6 +72,7 @@
|
|||||||
"forceRestartRequired": "reinicie para aplicar as alterações… feche a notificação para reiniciar",
|
"forceRestartRequired": "reinicie para aplicar as alterações… feche a notificação para reiniciar",
|
||||||
"forward": "para frente",
|
"forward": "para frente",
|
||||||
"gap": "intervalo",
|
"gap": "intervalo",
|
||||||
|
"grouping": "agrupamento",
|
||||||
"home": "início",
|
"home": "início",
|
||||||
"increase": "incrementar",
|
"increase": "incrementar",
|
||||||
"left": "esquerda",
|
"left": "esquerda",
|
||||||
|
|||||||
+169
-80
@@ -25,19 +25,20 @@
|
|||||||
"addOrRemoveFromSelection": "добавить или удалить из выделения",
|
"addOrRemoveFromSelection": "добавить или удалить из выделения",
|
||||||
"createRadioStation": "создать $t(entity.radioStation, {\"count\": 1})",
|
"createRadioStation": "создать $t(entity.radioStation, {\"count\": 1})",
|
||||||
"deleteRadioStation": "удалить $t(entity.radioStation, {\"count\": 1})",
|
"deleteRadioStation": "удалить $t(entity.radioStation, {\"count\": 1})",
|
||||||
"selectAll": "выделить все",
|
"selectAll": "выбрать все",
|
||||||
"downloadStarted": "Начата загрузка {{count}} предметов",
|
"downloadStarted": "Начата загрузка {{count}} предметов",
|
||||||
"moveUp": "перейти наверх",
|
"moveUp": "перейти вверх",
|
||||||
"moveDown": "Перейти вниз",
|
"moveDown": "перейти вниз",
|
||||||
"holdToMoveToTop": "Удержать для перехода на верх",
|
"holdToMoveToTop": "Удержать для перехода на верх",
|
||||||
"holdToMoveToBottom": "удержать для перехода вниз",
|
"holdToMoveToBottom": "удержать для перехода вниз",
|
||||||
"moveItems": "переместить предметы",
|
"moveItems": "переместить элементы",
|
||||||
"shuffle": "Перемешать",
|
"shuffle": "Перемешать",
|
||||||
"shuffleAll": "перемешать все",
|
"shuffleAll": "перемешать все",
|
||||||
"shuffleSelected": "Смешать выбранное",
|
"shuffleSelected": "перемешать выбранные",
|
||||||
"viewMore": "Посмотреть больше",
|
"viewMore": "Посмотреть больше",
|
||||||
"openApplicationDirectory": "открыть папку приложения",
|
"openApplicationDirectory": "открыть папку приложения",
|
||||||
"selectRangeOfItems": "выбрать диапазон элементов"
|
"selectRangeOfItems": "выбрать диапазон элементов",
|
||||||
|
"goToCurrent": "перейти к текущему элементу"
|
||||||
},
|
},
|
||||||
"common": {
|
"common": {
|
||||||
"backward": "назад",
|
"backward": "назад",
|
||||||
@@ -125,6 +126,7 @@
|
|||||||
"note": "заметка",
|
"note": "заметка",
|
||||||
"none": "нет",
|
"none": "нет",
|
||||||
"mbid": "MusicBrainz ID",
|
"mbid": "MusicBrainz ID",
|
||||||
|
"grouping": "группировка",
|
||||||
"reload": "перезагрузить",
|
"reload": "перезагрузить",
|
||||||
"preview": "просмотр",
|
"preview": "просмотр",
|
||||||
"codec": "кодек",
|
"codec": "кодек",
|
||||||
@@ -136,7 +138,7 @@
|
|||||||
"albumPeak": "пик альбома",
|
"albumPeak": "пик альбома",
|
||||||
"trackPeak": "пик трека",
|
"trackPeak": "пик трека",
|
||||||
"additionalParticipants": "Другие участники",
|
"additionalParticipants": "Другие участники",
|
||||||
"newVersion": "новая версия приложения установлена ({{version}})",
|
"newVersion": "установлена новая версия ({{version}})",
|
||||||
"viewReleaseNotes": "Список изменений",
|
"viewReleaseNotes": "Список изменений",
|
||||||
"bitDepth": "Разрядность",
|
"bitDepth": "Разрядность",
|
||||||
"sampleRate": "частота дискретизации",
|
"sampleRate": "частота дискретизации",
|
||||||
@@ -163,7 +165,9 @@
|
|||||||
"example": "пример",
|
"example": "пример",
|
||||||
"rename": "переименовать",
|
"rename": "переименовать",
|
||||||
"explicit": "нецензурная лексика",
|
"explicit": "нецензурная лексика",
|
||||||
"externalLinks": "внешние ссылки"
|
"externalLinks": "внешние ссылки",
|
||||||
|
"explicitStatus": "признак нецензурного контента",
|
||||||
|
"newVersionAvailable": "доступна новая версия"
|
||||||
},
|
},
|
||||||
"entity": {
|
"entity": {
|
||||||
"album_one": "альбом",
|
"album_one": "альбом",
|
||||||
@@ -182,8 +186,8 @@
|
|||||||
"play_one": "{{count}} прослушивание",
|
"play_one": "{{count}} прослушивание",
|
||||||
"play_few": "{{count}} прослушивание",
|
"play_few": "{{count}} прослушивание",
|
||||||
"play_many": "{{count}} прослушивание",
|
"play_many": "{{count}} прослушивание",
|
||||||
"artist_one": "автор",
|
"artist_one": "исполнитель",
|
||||||
"artist_few": "автора",
|
"artist_few": "исполнителя",
|
||||||
"artist_many": "исполнителей",
|
"artist_many": "исполнителей",
|
||||||
"folderWithCount_one": "{{count}} папка",
|
"folderWithCount_one": "{{count}} папка",
|
||||||
"folderWithCount_few": "{{count}} папки",
|
"folderWithCount_few": "{{count}} папки",
|
||||||
@@ -203,9 +207,9 @@
|
|||||||
"albumWithCount_one": "{{count}} альбом",
|
"albumWithCount_one": "{{count}} альбом",
|
||||||
"albumWithCount_few": "{{count}} альбома",
|
"albumWithCount_few": "{{count}} альбома",
|
||||||
"albumWithCount_many": "{{count}} альбомов",
|
"albumWithCount_many": "{{count}} альбомов",
|
||||||
"favorite_one": "любимый",
|
"favorite_one": "избранное",
|
||||||
"favorite_few": "любимых",
|
"favorite_few": "избранное",
|
||||||
"favorite_many": "любимые",
|
"favorite_many": "избранные",
|
||||||
"artistWithCount_one": "{{count}} автор",
|
"artistWithCount_one": "{{count}} автор",
|
||||||
"artistWithCount_few": "{{count}} автора",
|
"artistWithCount_few": "{{count}} автора",
|
||||||
"artistWithCount_many": "{{count}} авторов",
|
"artistWithCount_many": "{{count}} авторов",
|
||||||
@@ -222,9 +226,9 @@
|
|||||||
"radioStation_one": "радиостанция",
|
"radioStation_one": "радиостанция",
|
||||||
"radioStation_few": "радиостанции",
|
"radioStation_few": "радиостанции",
|
||||||
"radioStation_many": "радиостанции",
|
"radioStation_many": "радиостанции",
|
||||||
"radioStationWithCount_one": "Радиостанция",
|
"radioStationWithCount_one": "{{count}} радиостанция",
|
||||||
"radioStationWithCount_few": "Радиостанций",
|
"radioStationWithCount_few": "{{count}} радиостанции",
|
||||||
"radioStationWithCount_many": "Радиостанции"
|
"radioStationWithCount_many": "{{count}} радиостанций"
|
||||||
},
|
},
|
||||||
"table": {
|
"table": {
|
||||||
"config": {
|
"config": {
|
||||||
@@ -253,8 +257,6 @@
|
|||||||
"trackNumber": "номер трека",
|
"trackNumber": "номер трека",
|
||||||
"rowIndex": "номер строки",
|
"rowIndex": "номер строки",
|
||||||
"rating": "$t(common.rating)",
|
"rating": "$t(common.rating)",
|
||||||
"artist": "$t(entity.artist, {\"count\": 1})",
|
|
||||||
"album": "$t(entity.album, {\"count\": 1})",
|
|
||||||
"note": "$t(common.note)",
|
"note": "$t(common.note)",
|
||||||
"biography": "$t(common.biography)",
|
"biography": "$t(common.biography)",
|
||||||
"owner": "$t(common.owner)",
|
"owner": "$t(common.owner)",
|
||||||
@@ -263,13 +265,10 @@
|
|||||||
"playCount": "количество воспроизведений",
|
"playCount": "количество воспроизведений",
|
||||||
"bitrate": "$t(common.bitrate)",
|
"bitrate": "$t(common.bitrate)",
|
||||||
"actions": "$t(common.action_other)",
|
"actions": "$t(common.action_other)",
|
||||||
"genre": "$t(entity.genre, {\"count\": 1})",
|
|
||||||
"discNumber": "номер диска",
|
"discNumber": "номер диска",
|
||||||
"favorite": "$t(common.favorite)",
|
"favorite": "$t(common.favorite)",
|
||||||
"year": "$t(common.year)",
|
"year": "$t(common.year)",
|
||||||
"albumArtist": "$t(entity.albumArtist, {\"count\": 1})",
|
|
||||||
"codec": "$t(common.codec)",
|
"codec": "$t(common.codec)",
|
||||||
"songCount": "$t(entity.track, {\"count\": 2})",
|
|
||||||
"titleArtist": "$t(common.title) (артист)"
|
"titleArtist": "$t(common.title) (артист)"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -281,9 +280,7 @@
|
|||||||
"lastPlayed": "последний",
|
"lastPlayed": "последний",
|
||||||
"releaseDate": "дата выхода",
|
"releaseDate": "дата выхода",
|
||||||
"title": "название",
|
"title": "название",
|
||||||
"songCount": "$t(entity.track, {\"count\": 2})",
|
|
||||||
"trackNumber": "трек",
|
"trackNumber": "трек",
|
||||||
"genre": "$t(entity.genre, {\"count\": 1})",
|
|
||||||
"path": "путь",
|
"path": "путь",
|
||||||
"discNumber": "диск",
|
"discNumber": "диск",
|
||||||
"size": "$t(common.size)",
|
"size": "$t(common.size)",
|
||||||
@@ -293,8 +290,6 @@
|
|||||||
"biography": "биография",
|
"biography": "биография",
|
||||||
"codec": "$t(common.codec)",
|
"codec": "$t(common.codec)",
|
||||||
"comment": "комментарий",
|
"comment": "комментарий",
|
||||||
"albumCount": "$t(entity.album, {\"count\": 2})",
|
|
||||||
"artist": "$t(entity.artist, {\"count\": 1})",
|
|
||||||
"bitrate": "битрейт",
|
"bitrate": "битрейт",
|
||||||
"channels": "$t(common.channel_other)",
|
"channels": "$t(common.channel_other)",
|
||||||
"bpm": "bpm"
|
"bpm": "bpm"
|
||||||
@@ -308,7 +303,7 @@
|
|||||||
"remotePortError": "произошла ошибка при попытке установить порт удаленного сервера",
|
"remotePortError": "произошла ошибка при попытке установить порт удаленного сервера",
|
||||||
"serverRequired": "сервер не выбран",
|
"serverRequired": "сервер не выбран",
|
||||||
"authenticationFailed": "не удалось авторизироваться",
|
"authenticationFailed": "не удалось авторизироваться",
|
||||||
"apiRouteError": "невозможно выполнить запрос",
|
"apiRouteError": "не удалось выполнить запрос",
|
||||||
"genericError": "произошла ошибка",
|
"genericError": "произошла ошибка",
|
||||||
"credentialsRequired": "введите данные для входа",
|
"credentialsRequired": "введите данные для входа",
|
||||||
"sessionExpiredError": "ваш сеанс истёк",
|
"sessionExpiredError": "ваш сеанс истёк",
|
||||||
@@ -331,7 +326,8 @@
|
|||||||
"saveQueueFailed": "Не удалось сохранить очередь",
|
"saveQueueFailed": "Не удалось сохранить очередь",
|
||||||
"settingsSyncError": "обнаружены несоответствия между настройками рендерера и основным процессом. перезапустите приложение, чтобы изменения вступили в силу",
|
"settingsSyncError": "обнаружены несоответствия между настройками рендерера и основным процессом. перезапустите приложение, чтобы изменения вступили в силу",
|
||||||
"invalidJson": "невалидный JSON",
|
"invalidJson": "невалидный JSON",
|
||||||
"serverLockSingleServer": "при заблокированном сервере разрешается использовать только один сервер"
|
"serverLockSingleServer": "при заблокированном сервере разрешается использовать только один сервер",
|
||||||
|
"playbackPausedDueToError": "воспроизведение было приостановлено из-за ошибки"
|
||||||
},
|
},
|
||||||
"filter": {
|
"filter": {
|
||||||
"isCompilation": "сборник",
|
"isCompilation": "сборник",
|
||||||
@@ -340,12 +336,10 @@
|
|||||||
"dateAdded": "дата добавления",
|
"dateAdded": "дата добавления",
|
||||||
"communityRating": "рейтинг сообщества",
|
"communityRating": "рейтинг сообщества",
|
||||||
"favorited": "любимый",
|
"favorited": "любимый",
|
||||||
"albumArtist": "$t(entity.albumArtist, {\"count\": 1})",
|
|
||||||
"isFavorited": "любимые",
|
"isFavorited": "любимые",
|
||||||
"bpm": "уд./мин.",
|
"bpm": "уд./мин.",
|
||||||
"disc": "диск",
|
"disc": "диск",
|
||||||
"biography": "биография",
|
"biography": "биография",
|
||||||
"artist": "$t(entity.artist, {\"count\": 1})",
|
|
||||||
"duration": "длительность",
|
"duration": "длительность",
|
||||||
"fromYear": "год",
|
"fromYear": "год",
|
||||||
"criticRating": "рейтинг критиков",
|
"criticRating": "рейтинг критиков",
|
||||||
@@ -353,13 +347,11 @@
|
|||||||
"comment": "комментировать",
|
"comment": "комментировать",
|
||||||
"playCount": "количество воспроизведений",
|
"playCount": "количество воспроизведений",
|
||||||
"recentlyUpdated": "обновленные недавно",
|
"recentlyUpdated": "обновленные недавно",
|
||||||
"channels": "$t(common.channel_other)",
|
|
||||||
"recentlyPlayed": "проигрывались недавно",
|
"recentlyPlayed": "проигрывались недавно",
|
||||||
"owner": "$t(common.owner)",
|
"owner": "$t(common.owner)",
|
||||||
"title": "название",
|
"title": "название",
|
||||||
"rating": "рейтинг",
|
"rating": "рейтинг",
|
||||||
"search": "поиск",
|
"search": "поиск",
|
||||||
"genre": "$t(entity.genre, {\"count\": 1})",
|
|
||||||
"recentlyAdded": "недавно добавленные",
|
"recentlyAdded": "недавно добавленные",
|
||||||
"note": "заметка",
|
"note": "заметка",
|
||||||
"name": "название",
|
"name": "название",
|
||||||
@@ -379,6 +371,10 @@
|
|||||||
"matchAnd": "и",
|
"matchAnd": "и",
|
||||||
"matchOr": "или",
|
"matchOr": "или",
|
||||||
"sortName": "сортировка по имени",
|
"sortName": "сортировка по имени",
|
||||||
|
"albumArtist": "$t(entity.albumArtist, {\"count\": 1})",
|
||||||
|
"artist": "$t(entity.artist, {\"count\": 1})",
|
||||||
|
"channels": "$t(common.channel, {\"count\": 2})",
|
||||||
|
"genre": "$t(entity.genre, {\"count\": 1})",
|
||||||
"explicitStatus": "$t(common.explicitStatus)"
|
"explicitStatus": "$t(common.explicitStatus)"
|
||||||
},
|
},
|
||||||
"player": {
|
"player": {
|
||||||
@@ -405,7 +401,7 @@
|
|||||||
"pause": "пауза",
|
"pause": "пауза",
|
||||||
"queue_clear": "очистить очередь",
|
"queue_clear": "очистить очередь",
|
||||||
"muted": "звук отключён",
|
"muted": "звук отключён",
|
||||||
"unfavorite": "убрать из любимых",
|
"unfavorite": "убрать из избранного",
|
||||||
"queue_moveToTop": "переместить выделенное вниз",
|
"queue_moveToTop": "переместить выделенное вниз",
|
||||||
"queue_moveToBottom": "переместить выделенное вверх",
|
"queue_moveToBottom": "переместить выделенное вверх",
|
||||||
"shuffle_off": "перемешивание выключено",
|
"shuffle_off": "перемешивание выключено",
|
||||||
@@ -428,26 +424,18 @@
|
|||||||
"sleepTimer_hours": "{{count}} часов",
|
"sleepTimer_hours": "{{count}} часов",
|
||||||
"sleepTimer_off": "выключено",
|
"sleepTimer_off": "выключено",
|
||||||
"sleepTimer_timeRemaining": "{{time}} осталось",
|
"sleepTimer_timeRemaining": "{{time}} осталось",
|
||||||
"sleepTimer_setCustom": "установить таймер"
|
"sleepTimer_setCustom": "установить таймер",
|
||||||
|
"sleepTimer_custom": "пользовательский",
|
||||||
|
"sleepTimer_cancel": "отменить таймер"
|
||||||
},
|
},
|
||||||
"page": {
|
"page": {
|
||||||
"sidebar": {
|
"sidebar": {
|
||||||
"nowPlaying": "сейчас играет",
|
"nowPlaying": "сейчас играет",
|
||||||
"playlists": "$t(entity.playlist, {\"count\": 2})",
|
|
||||||
"search": "$t(common.search)",
|
"search": "$t(common.search)",
|
||||||
"tracks": "$t(entity.track, {\"count\": 2})",
|
|
||||||
"albums": "$t(entity.album, {\"count\": 2})",
|
|
||||||
"genres": "$t(entity.genre, {\"count\": 2})",
|
|
||||||
"folders": "$t(entity.folder, {\"count\": 2})",
|
|
||||||
"settings": "$t(common.setting, {\"count\": 2})",
|
|
||||||
"home": "$t(common.home)",
|
"home": "$t(common.home)",
|
||||||
"artists": "$t(entity.artist, {\"count\": 2})",
|
|
||||||
"albumArtists": "$t(entity.albumArtist, {\"count\": 2})",
|
|
||||||
"myLibrary": "Моя библиотека",
|
"myLibrary": "Моя библиотека",
|
||||||
"shared": "Публичные плейлисты $t(entity.playlist, {\"count\": 2})",
|
"shared": "Публичные плейлисты $t(entity.playlist, {\"count\": 2})",
|
||||||
"collections": "коллекции",
|
"collections": "коллекции"
|
||||||
"favorites": "$t(entity.favorite, {\"count\": 2})",
|
|
||||||
"radio": "$t(entity.radioStation, {\"count\": 2})"
|
|
||||||
},
|
},
|
||||||
"fullscreenPlayer": {
|
"fullscreenPlayer": {
|
||||||
"config": {
|
"config": {
|
||||||
@@ -475,7 +463,6 @@
|
|||||||
"appMenu": {
|
"appMenu": {
|
||||||
"selectServer": "список серверов",
|
"selectServer": "список серверов",
|
||||||
"version": "версия {{version}}",
|
"version": "версия {{version}}",
|
||||||
"settings": "$t(common.setting, {\"count\": 2})",
|
|
||||||
"manageServers": "редактировать список серверов",
|
"manageServers": "редактировать список серверов",
|
||||||
"expandSidebar": "развернуть боковую панель",
|
"expandSidebar": "развернуть боковую панель",
|
||||||
"collapseSidebar": "Скрыть боковую панель",
|
"collapseSidebar": "Скрыть боковую панель",
|
||||||
@@ -521,10 +508,9 @@
|
|||||||
"goToAlbum": "Перейти к $t(entity.album, {\"count\": 1})",
|
"goToAlbum": "Перейти к $t(entity.album, {\"count\": 1})",
|
||||||
"goToAlbumArtist": "Перейти к $t(entity.albumArtist, {\"count\": 1})",
|
"goToAlbumArtist": "Перейти к $t(entity.albumArtist, {\"count\": 1})",
|
||||||
"goTo": "перейти в",
|
"goTo": "перейти в",
|
||||||
"moveItems": "$t(action.moveItems)",
|
|
||||||
"moveToNext": "$t(action.moveToNext)",
|
"moveToNext": "$t(action.moveToNext)",
|
||||||
"playSimilarSongs": "$t(player.playSimilarSongs)",
|
"playShuffled": "$t(player.shuffle)",
|
||||||
"playShuffled": "$t(player.shuffle)"
|
"moveItems": "$t(action.moveItems)"
|
||||||
},
|
},
|
||||||
"home": {
|
"home": {
|
||||||
"mostPlayed": "слушают чаще всего",
|
"mostPlayed": "слушают чаще всего",
|
||||||
@@ -532,8 +518,7 @@
|
|||||||
"title": "$t(common.home)",
|
"title": "$t(common.home)",
|
||||||
"explore": "откройте новое",
|
"explore": "откройте новое",
|
||||||
"recentlyPlayed": "игралось недавно",
|
"recentlyPlayed": "игралось недавно",
|
||||||
"recentlyReleased": "Новинки",
|
"recentlyReleased": "Новинки"
|
||||||
"genres": "$t(entity.genre, {\"count\": 2})"
|
|
||||||
},
|
},
|
||||||
"albumDetail": {
|
"albumDetail": {
|
||||||
"moreFromArtist": "больше от $t(entity.artist, {\"count\": 1})",
|
"moreFromArtist": "больше от $t(entity.artist, {\"count\": 1})",
|
||||||
@@ -563,19 +548,13 @@
|
|||||||
"logger": "Отладка",
|
"logger": "Отладка",
|
||||||
"playerFilters": "фильтры проигрывателя",
|
"playerFilters": "фильтры проигрывателя",
|
||||||
"queryBuilder": "конструктор очереди",
|
"queryBuilder": "конструктор очереди",
|
||||||
"discord": "discord"
|
"discord": "дискорд"
|
||||||
},
|
|
||||||
"albumArtistList": {
|
|
||||||
"title": "$t(entity.albumArtist, {\"count\": 2})"
|
|
||||||
},
|
},
|
||||||
"genreList": {
|
"genreList": {
|
||||||
"title": "$t(entity.genre, {\"count\": 2})",
|
|
||||||
"showAlbums": "показать $t(entity.genre, {\"count\": 1}) $t(entity.album, {\"count\": 2})",
|
"showAlbums": "показать $t(entity.genre, {\"count\": 1}) $t(entity.album, {\"count\": 2})",
|
||||||
"showTracks": "показать $t(entity.genre, {\"count\": 1}) $t(entity.track, {\"count\": 2})"
|
"showTracks": "показать $t(entity.genre, {\"count\": 1}) $t(entity.track, {\"count\": 2})"
|
||||||
},
|
},
|
||||||
"trackList": {
|
"trackList": {
|
||||||
"title": "$t(entity.track, {\"count\": 2})",
|
|
||||||
"genreTracks": "\"{{genre}}\" $t(entity.track, {\"count\": 2})",
|
|
||||||
"artistTracks": "Треки {{artist}}"
|
"artistTracks": "Треки {{artist}}"
|
||||||
},
|
},
|
||||||
"globalSearch": {
|
"globalSearch": {
|
||||||
@@ -589,13 +568,10 @@
|
|||||||
"playlist": {
|
"playlist": {
|
||||||
"reorder": "сортировка доступна только по ID"
|
"reorder": "сортировка доступна только по ID"
|
||||||
},
|
},
|
||||||
"playlistList": {
|
|
||||||
"title": "$t(entity.playlist, {\"count\": 2})"
|
|
||||||
},
|
|
||||||
"albumList": {
|
"albumList": {
|
||||||
"title": "$t(entity.album, {\"count\": 2})",
|
|
||||||
"artistAlbums": "альбомы {{artist}}",
|
"artistAlbums": "альбомы {{artist}}",
|
||||||
"genreAlbums": "\"{{genre}}\" $t(entity.album, {\"count\": 2})"
|
"genreAlbums": "\"{{genre}}\"\n$t(entity.album, {\"count\": 2})",
|
||||||
|
"title": "$t(entity.album, {\"count\": 2})"
|
||||||
},
|
},
|
||||||
"albumArtistDetail": {
|
"albumArtistDetail": {
|
||||||
"topSongs": "популярные треки",
|
"topSongs": "популярные треки",
|
||||||
@@ -631,13 +607,15 @@
|
|||||||
"overrideExisting": "переопределить существующий"
|
"overrideExisting": "переопределить существующий"
|
||||||
},
|
},
|
||||||
"releasenotes": {
|
"releasenotes": {
|
||||||
"commitsSinceStable": "коммито после {{stable}}"
|
"commitsSinceStable": "коммито после {{stable}}",
|
||||||
|
"noStableReleaseToCompare": "нет стабильной версии, с которой можно было бы сравнить",
|
||||||
|
"noNewCommits": "изменения в этом диапазоне отсутствуют"
|
||||||
|
},
|
||||||
|
"albumArtistList": {
|
||||||
|
"title": "$t(entity.albumArtist, {\"count\": 2})"
|
||||||
},
|
},
|
||||||
"favorites": {
|
"favorites": {
|
||||||
"title": "$t(entity.favorite, {\"count\": 2})"
|
"title": "$t(entity.favorite, {\"count\": 2})"
|
||||||
},
|
|
||||||
"folderList": {
|
|
||||||
"title": "$t(entity.folder, {\"count\": 2})"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"form": {
|
"form": {
|
||||||
@@ -676,9 +654,9 @@
|
|||||||
"success": "добавлено: $t(entity.trackWithCount, {\"count\": {{message}} }) в $t(entity.playlistWithCount, {\"count\": {{numOfPlaylists}} })",
|
"success": "добавлено: $t(entity.trackWithCount, {\"count\": {{message}} }) в $t(entity.playlistWithCount, {\"count\": {{numOfPlaylists}} })",
|
||||||
"title": "добавить в $t(entity.playlist, {\"count\": 1})",
|
"title": "добавить в $t(entity.playlist, {\"count\": 1})",
|
||||||
"input_skipDuplicates": "не добавлять дубликаты",
|
"input_skipDuplicates": "не добавлять дубликаты",
|
||||||
"input_playlists": "$t(entity.playlist, {\"count\": 2})",
|
|
||||||
"create": "создать $t(entity.playlist, {\"count\": 1}) {{playlist}}",
|
"create": "создать $t(entity.playlist, {\"count\": 1}) {{playlist}}",
|
||||||
"searchOrCreate": "для создания нового списка выполните поиск по $t(entity.playlist, {\"count\": 2}) или введите соответствующий текст"
|
"searchOrCreate": "для создания нового списка выполните поиск по $t(entity.playlist, {\"count\": 2}) или введите соответствующий текст",
|
||||||
|
"input_playlists": "$t(entity.playlist, {\"count\": 2})"
|
||||||
},
|
},
|
||||||
"updateServer": {
|
"updateServer": {
|
||||||
"title": "обновление сервера",
|
"title": "обновление сервера",
|
||||||
@@ -695,14 +673,13 @@
|
|||||||
},
|
},
|
||||||
"lyricSearch": {
|
"lyricSearch": {
|
||||||
"input_name": "$t(common.name)",
|
"input_name": "$t(common.name)",
|
||||||
"input_artist": "$t(entity.artist, {\"count\": 1})",
|
"title": "поиск слов песни",
|
||||||
"title": "поиск слов песни"
|
"input_artist": "$t(entity.artist, {\"count\": 1})"
|
||||||
},
|
},
|
||||||
"editPlaylist": {
|
"editPlaylist": {
|
||||||
"title": "редактировать $t(entity.playlist, {\"count\": 1})",
|
"title": "редактировать $t(entity.playlist, {\"count\": 1})",
|
||||||
"success": "$t(entity.playlist, {\"count\": 1}) обновлён успешно",
|
"success": "$t(entity.playlist, {\"count\": 1}) обновлён успешно",
|
||||||
"publicJellyfinNote": "Jellyfin по какой-то причине не предоставляет информацию о том, публичный плейлист или нет. Если вы хотите, чтобы он остался публичным, выберите следующую опцию",
|
"publicJellyfinNote": "Jellyfin по какой-то причине не предоставляет информацию о том, публичный плейлист или нет. Если вы хотите, чтобы он остался публичным, выберите следующую опцию"
|
||||||
"editNote": "редактирование больших плейлистов вручную не рекомендуется. Вы уверены, что готовы принять риск потери данных, который может возникнуть в результате перезаписи существующего плейлиста?"
|
|
||||||
},
|
},
|
||||||
"shareItem": {
|
"shareItem": {
|
||||||
"success": "ссылка скопирована в буфер обмена (нажмите здесь, чтобы открыть)",
|
"success": "ссылка скопирована в буфер обмена (нажмите здесь, чтобы открыть)",
|
||||||
@@ -746,7 +723,7 @@
|
|||||||
"input_played": "воспроизвести фильтр",
|
"input_played": "воспроизвести фильтр",
|
||||||
"input_played_optionAll": "все треки",
|
"input_played_optionAll": "все треки",
|
||||||
"input_played_optionUnplayed": "только не игранные треки",
|
"input_played_optionUnplayed": "только не игранные треки",
|
||||||
"input_played_optionPlayed": "только игранные треки",
|
"input_played_optionPlayed": "только воспроизведённые треки",
|
||||||
"input_genre": "$t(entity.genre, {\"count\": 1})"
|
"input_genre": "$t(entity.genre, {\"count\": 1})"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -786,7 +763,7 @@
|
|||||||
"clearCache": "очистить кэш браузера",
|
"clearCache": "очистить кэш браузера",
|
||||||
"clearQueryCache": "очистить кэш feishin",
|
"clearQueryCache": "очистить кэш feishin",
|
||||||
"audioDevice": "устройство воспроизведения",
|
"audioDevice": "устройство воспроизведения",
|
||||||
"audioDevice_description": "выберите устройство воспроизведения (только в режиме аудиоплеера web)",
|
"audioDevice_description": "выберите устройство воспроизведения",
|
||||||
"buttonSize": "размер кнопок панели управления воспроизведением",
|
"buttonSize": "размер кнопок панели управления воспроизведением",
|
||||||
"hotkey_volumeDown": "уменьшить громкость",
|
"hotkey_volumeDown": "уменьшить громкость",
|
||||||
"playButtonBehavior_optionAddLast": "$t(player.addLast)",
|
"playButtonBehavior_optionAddLast": "$t(player.addLast)",
|
||||||
@@ -800,9 +777,7 @@
|
|||||||
"hotkey_zoomOut": "уменьшить масштаб",
|
"hotkey_zoomOut": "уменьшить масштаб",
|
||||||
"playbackStyle_optionCrossFade": "затухание",
|
"playbackStyle_optionCrossFade": "затухание",
|
||||||
"replayGainMode": "режим {{ReplayGain}}",
|
"replayGainMode": "режим {{ReplayGain}}",
|
||||||
"replayGainMode_optionAlbum": "$t(entity.album, {\"count\": 1})",
|
|
||||||
"replayGainMode_optionNone": "$t(common.none)",
|
"replayGainMode_optionNone": "$t(common.none)",
|
||||||
"replayGainMode_optionTrack": "$t(entity.track, {\"count\": 1})",
|
|
||||||
"clearQueryCache_description": "так называемая \"мягкая очистка\" feishin: обновляются плейлисты, метаданные треков, но сохранённые тексты треков сбрасываются. настройки, учётные данные и кэшированные изображения сохраняются",
|
"clearQueryCache_description": "так называемая \"мягкая очистка\" feishin: обновляются плейлисты, метаданные треков, но сохранённые тексты треков сбрасываются. настройки, учётные данные и кэшированные изображения сохраняются",
|
||||||
"hotkey_favoriteCurrentSong": "добавить $t(common.currentSong) в избранное",
|
"hotkey_favoriteCurrentSong": "добавить $t(common.currentSong) в избранное",
|
||||||
"globalMediaHotkeys": "глобальные мультимедийные горячие клавиши",
|
"globalMediaHotkeys": "глобальные мультимедийные горячие клавиши",
|
||||||
@@ -963,11 +938,122 @@
|
|||||||
"releaseChannel_optionBeta": "Бета",
|
"releaseChannel_optionBeta": "Бета",
|
||||||
"releaseChannel_optionLatest": "последний",
|
"releaseChannel_optionLatest": "последний",
|
||||||
"releaseChannel": "Тип релиза",
|
"releaseChannel": "Тип релиза",
|
||||||
"releaseChannel_description": "Выберите между стабильной или бета версией для автоматического обновления",
|
"releaseChannel_description": "Выберите между стабильной, бета или альфа (ночной) версией для автоматического обновления",
|
||||||
"discordDisplayType_artistname": "Имя (имена) исполнителя",
|
"discordDisplayType_artistname": "Имя (имена) исполнителя",
|
||||||
"discordDisplayType_description": "это меняет то, что вы слушаете в своем статусе",
|
"discordDisplayType_description": "это меняет то, что вы слушаете в своем статусе",
|
||||||
"discordDisplayType_songname": "имя песни",
|
"discordDisplayType_songname": "имя песни",
|
||||||
"discordDisplayType": "{{discord}} тип отображения"
|
"discordDisplayType": "{{discord}} тип отображения",
|
||||||
|
"autosave": "автоматическое сохранение очереди воспроизведения",
|
||||||
|
"autosave_description": "включите автоматическое сохранение очереди воспроизведения на вашем сервере. это возможно только при использовании Navidrome/Subsonic, и у вас не может быть смешанной очереди воспроизведения.",
|
||||||
|
"autosaveCount_description": "количество изменений трека перед сохранением очереди. 1 (минимум) означает каждое изменение песни",
|
||||||
|
"useThemePrimaryShade": "используйте основной оттенок темы",
|
||||||
|
"useThemePrimaryShade_description": "используйте основной оттенок, определенный в выбранной теме, для выбора основного цвета",
|
||||||
|
"primaryShade": "основной оттенок",
|
||||||
|
"primaryShade_description": "переопределите основной оттенок (0-9), используемый для кнопок, ссылок и других элементов основного цвета",
|
||||||
|
"analyticsEnable": "Отправлять аналитику использования",
|
||||||
|
"analyticsEnable_description": "Анонимные данные использования отправляются разработчику с целью улучшения приложения",
|
||||||
|
"artistReleaseTypeConfiguration": "настройка типов релизов исполнителя",
|
||||||
|
"artistReleaseTypeConfiguration_description": "настройте, какие типы релизов отображаются и в каком порядке на странице исполнителя",
|
||||||
|
"automaticUpdates": "Автообновления",
|
||||||
|
"automaticUpdates_description": "Проверять и устанавливать обновления автоматически",
|
||||||
|
"discordLinkType_description": "добавляет ссылки на {{lastfm}} / {{musicbrainz}} в Rich Presence {{discord}} для треков и исполнителей. {{musicbrainz}} точнее, но зависит от тегов и не даёт ссылок на артистов {{lastfm}} почти всегда предоставляет ссылку. Без дополнительных сетевых запросов.",
|
||||||
|
"blurExplicitImages": "скрывать нецензурные изображения размытием",
|
||||||
|
"blurExplicitImages_description": "обложки с нецензурным контентом будут размываются",
|
||||||
|
"autosaveCount": "частота автоматического сохранения очереди воспроизведения",
|
||||||
|
"discordLinkType_mbz_lastfm": "{{musicbrainz}} (запасной источник: {{lastfm}} )",
|
||||||
|
"discordLinkType": "интеграция {{discord}} статуса",
|
||||||
|
"discordListening_description": "Показывать статус \"Слушает\" вместо \"Играет\"",
|
||||||
|
"discordListening": "показывать статус \"Слушает\"",
|
||||||
|
"discordPausedStatus_description": "если включено, статус будет отображаться даже когда воспроизведение на паузе",
|
||||||
|
"discordPausedStatus": "показывать расширенный статус при паузе",
|
||||||
|
"discordRichPresence": "{{discord}}: расширенный статус",
|
||||||
|
"discordStateIcon": "показывать иконку воспроизведения",
|
||||||
|
"enableAutoTranslation_description": "включить автоматический перевод при получении текста",
|
||||||
|
"enableAutoTranslation": "включить автоперевод",
|
||||||
|
"exportImportSettings_control_description": "экспорт/импорт настроек в JSON",
|
||||||
|
"exportImportSettings_control_exportText": "экспорт настроек",
|
||||||
|
"exportImportSettings_control_importText": "импорт настроек",
|
||||||
|
"exportImportSettings_control_title": "импорт/экспорт настроек",
|
||||||
|
"exportImportSettings_destructiveWarning": "Импорт настроек полностью заменит ваши текущие настройки. Убедитесь, что все данные выше верны, перед тем как нажать кнопку «Импорт»!",
|
||||||
|
"exportImportSettings_importBtn": "Импорт настроек",
|
||||||
|
"exportImportSettings_importModalTitle": "Импорт настроек Feishin",
|
||||||
|
"exportImportSettings_importSuccess": "Настройки успешно импортированы!",
|
||||||
|
"exportImportSettings_notValidJSON": "Некорректный JSON-файл",
|
||||||
|
"exportImportSettings_offendingKeyError": "Неверный ключ \"{{offendingKey}}\": {{reason}}",
|
||||||
|
"followCurrentSong_description": "Автоматически прокручивать очередь до текущего трека",
|
||||||
|
"followCurrentSong": "следить за текущим треком",
|
||||||
|
"homeFeatureStyle_description": "настройка стиля карусели на главном экране",
|
||||||
|
"homeFeatureStyle": "стиль карусели на главной",
|
||||||
|
"homeFeatureStyle_optionMultiple": "несколько",
|
||||||
|
"language": "Язык интерфейса",
|
||||||
|
"autoDJ": "авто DJ",
|
||||||
|
"releaseChannel_optionAlpha": "альфа (ночная версия)",
|
||||||
|
"discordServeImage": "предоставить {{discord}} изображения с сервера",
|
||||||
|
"discordServeImage_description": "получать обложки треков для {{discord}} rich presence непосредственно с сервера, доступно только для Jellyfin и Navidrome. {{discord}} использует бота для получения картинок, поэтому ваш сервер должен быть доступен из общедоступной сети",
|
||||||
|
"discordStateIcon_description": "показывать иконку \"играет\" в статусе. иконка паузы показывается всегда когда опция \"Показывать расширенный статус при паузе\" включена",
|
||||||
|
"homeFeatureStyle_optionSingle": "одиночный",
|
||||||
|
"hotkey_navigateHome": "перейти на главную",
|
||||||
|
"lastfm_description": "показывать ссылки Last.fm на страницах артистов и альбомов",
|
||||||
|
"lastfm": "показывать ссылки last.fm",
|
||||||
|
"lastfmApiKey_description": "API ключ для {{lastfm}}. необходим для обложек",
|
||||||
|
"lastfmApiKey": "API ключ {{lastfm}}",
|
||||||
|
"logLevel": "детализация логов",
|
||||||
|
"logLevel_description": "определяет степень детализации логов. \"отладка\" отображает все логи, \"ошибка\" отображает только ошибки",
|
||||||
|
"logLevel_optionDebug": "отладка",
|
||||||
|
"logLevel_optionError": "ошибка",
|
||||||
|
"logLevel_optionInfo": "инфо",
|
||||||
|
"logLevel_optionWarn": "предупреждение",
|
||||||
|
"mpvExtraParameters": "дополнительные параметры mpv",
|
||||||
|
"mpvExtraParameters_description": "дополнительные аргументы, передаваемые mpv",
|
||||||
|
"musicbrainz_description": "показывать ссылки MusicBrainz на страницах артистов и альбомов, где есть ID MusicBrainz",
|
||||||
|
"musicbrainz": "показывать ссылки MusicBrainz",
|
||||||
|
"neteaseTranslation_description": "Если включено, получает и отображает переведённые текста песен с NetEase по возможности",
|
||||||
|
"neteaseTranslation": "Включить переводы NetEase",
|
||||||
|
"notify": "включить уведомления о песнях",
|
||||||
|
"notify_description": "показывать уведомления при смене песни",
|
||||||
|
"pathReplace": "замена пути к файлу",
|
||||||
|
"pathReplace_description": "заменяет стандартный путь сервера",
|
||||||
|
"pathReplace_optionRemovePrefix": "убрать префикс",
|
||||||
|
"pathReplace_optionAddPrefix": "добавить префикс",
|
||||||
|
"playerFilters": "Фильтр песен в очереди",
|
||||||
|
"playerFilters_description": "пропускает песни при добавлении в очередь, основываясь на заданном критерии",
|
||||||
|
"artistRadioCount_description": "определяет количество песен для добавления в радио по артисту/треку",
|
||||||
|
"artistRadioCount": "кол-во радио по артисту/треку",
|
||||||
|
"imageResolution": "разрешение изображения",
|
||||||
|
"imageResolution_description": "разрешение изображений, используемых в приложении. при значении \"0\" будет использоваться исходное разрешение",
|
||||||
|
"imageResolution_optionItemCard": "карточка элемента",
|
||||||
|
"imageResolution_optionSidebar": "боковая панель",
|
||||||
|
"imageResolution_optionHeader": "заголовок",
|
||||||
|
"imageResolution_optionFullScreenPlayer": "полноэкранный проигрыватель",
|
||||||
|
"playerbarSlider": "ползунок проигрывателя",
|
||||||
|
"playerbarSlider_description": "waveform не рекомендуется при слабом подключении к интернету",
|
||||||
|
"playerbarSliderType_optionSlider": "ползунок",
|
||||||
|
"playerbarSliderType_optionWaveform": "waveform",
|
||||||
|
"playerbarWaveformAlign": "положение waveform",
|
||||||
|
"playerbarWaveformAlign_optionTop": "верх",
|
||||||
|
"playerbarWaveformAlign_optionCenter": "центр",
|
||||||
|
"playerbarWaveformAlign_optionBottom": "низ",
|
||||||
|
"playerbarWaveformBarWidth": "ширина элемента waveform",
|
||||||
|
"playerbarWaveformGap": "промежутки waveform",
|
||||||
|
"playerbarWaveformRadius": "радиус waveform",
|
||||||
|
"preferLocalLyrics_description": "по возможности предпочитать локальные текста песен загружаемым",
|
||||||
|
"preferLocalLyrics": "предпочтитать локальные текста песен",
|
||||||
|
"showLyricsInSidebar_description": "к очереди воспроизведения будет добавлена панель, отображающая текст песни",
|
||||||
|
"showLyricsInSidebar": "показывать текст песни в боковой панели проигрывателя",
|
||||||
|
"showRatings_description": "определяет, отображается ли в интерфейсе функция звёздного рейтинга",
|
||||||
|
"showRatings": "показывать звёздный рейтинг",
|
||||||
|
"enableGridMultiSelect": "включить множественное выделение",
|
||||||
|
"enableGridMultiSelect_description": "если включено, то позволяет выделять несколько элементов в таблицах. если отключено, то нажатие на элемент таблицы откроет страницу элемента",
|
||||||
|
"showVisualizerInSidebar_description": "к боковой части проигрывателя будет добавлена панель, показывающая визуализатор",
|
||||||
|
"showVisualizerInSidebar": "показывать визуализатор в боковой панели",
|
||||||
|
"combinedLyricsAndVisualizer_description": "Объединяет текст песни и визуализатор в одну панель заместо двух",
|
||||||
|
"combinedLyricsAndVisualizer": "объединить текст и визуализатор в одну панель",
|
||||||
|
"preservePitch_description": "сохраняет тональность при изменении скорости воспроизведения",
|
||||||
|
"preservePitch": "сохранять тональность",
|
||||||
|
"audioFadeOnStatusChange": "плавное изменение звука",
|
||||||
|
"audioFadeOnStatusChange_description": "включает эффекты затухания и появления звука при изменении статуса (пауза/проигрывание)",
|
||||||
|
"preventSleepOnPlayback_description": "запрещает спящий режим экрана, пока играет музыка",
|
||||||
|
"preventSleepOnPlayback": "не переходить в спящий режим"
|
||||||
},
|
},
|
||||||
"releaseType": {
|
"releaseType": {
|
||||||
"secondary": {
|
"secondary": {
|
||||||
@@ -979,7 +1065,10 @@
|
|||||||
"live": "прямой эфир",
|
"live": "прямой эфир",
|
||||||
"soundtrack": "саундтрек",
|
"soundtrack": "саундтрек",
|
||||||
"spokenWord": "Художественная декламация",
|
"spokenWord": "Художественная декламация",
|
||||||
"audioDrama": "радиопостановка"
|
"audioDrama": "радиопостановка",
|
||||||
|
"fieldRecording": "запись вне студии",
|
||||||
|
"mixtape": "сборник",
|
||||||
|
"djMix": "dj микс"
|
||||||
},
|
},
|
||||||
"primary": {
|
"primary": {
|
||||||
"other": "другие",
|
"other": "другие",
|
||||||
@@ -1027,7 +1116,7 @@
|
|||||||
"updatePreset": "Обновить пресет",
|
"updatePreset": "Обновить пресет",
|
||||||
"copyConfiguration": "Копировать Конфигурацию",
|
"copyConfiguration": "Копировать Конфигурацию",
|
||||||
"pasteConfiguration": "Вставить Конфигурацию",
|
"pasteConfiguration": "Вставить Конфигурацию",
|
||||||
"pasteConfigurationPlaceholder": "Вставить JSON конфигурацию",
|
"pasteConfigurationPlaceholder": "Вставить JSON конфигурацию...",
|
||||||
"pasteFromClipboard": "Вставить из буфера обмена",
|
"pasteFromClipboard": "Вставить из буфера обмена",
|
||||||
"applyConfiguration": "Применить Конфигурацию",
|
"applyConfiguration": "Применить Конфигурацию",
|
||||||
"configCopied": "Конфигурация скопирована в буфер обмена",
|
"configCopied": "Конфигурация скопирована в буфер обмена",
|
||||||
|
|||||||
@@ -310,8 +310,7 @@
|
|||||||
"editPlaylist": {
|
"editPlaylist": {
|
||||||
"title": "திருத்து $t(entity.playlist, {\"count\": 1})",
|
"title": "திருத்து $t(entity.playlist, {\"count\": 1})",
|
||||||
"publicJellyfinNote": "சில காரணங்களால் செல்லிஃபின் ஒரு பிளேலிச்ட் பொதுவில் இல்லையா என்பதை அம்பலப்படுத்தவில்லை. இது பொதுவில் இருக்க விரும்பினால், தயவுசெய்து பின்வரும் உள்ளீட்டைத் தேர்ந்தெடுக்கவும்",
|
"publicJellyfinNote": "சில காரணங்களால் செல்லிஃபின் ஒரு பிளேலிச்ட் பொதுவில் இல்லையா என்பதை அம்பலப்படுத்தவில்லை. இது பொதுவில் இருக்க விரும்பினால், தயவுசெய்து பின்வரும் உள்ளீட்டைத் தேர்ந்தெடுக்கவும்",
|
||||||
"success": "$t(entity.playlist, {\"count\": 1}) வெற்றிகரமாகப் புதுப்பிக்கப்பட்டது",
|
"success": "$t(entity.playlist, {\"count\": 1}) வெற்றிகரமாகப் புதுப்பிக்கப்பட்டது"
|
||||||
"editNote": "பெரிய பிளேலிச்ட்களுக்கு கைமுறை திருத்தங்கள் பரிந்துரைக்கப்படவில்லை. ஏற்கனவே உள்ள பிளேலிச்ட்டை மேலெழுதுவதால் ஏற்படும் தரவு இழப்பின் அபாயத்தை நிச்சயமாக ஏற்றுக்கொள்கிறீர்களா?"
|
|
||||||
},
|
},
|
||||||
"lyricSearch": {
|
"lyricSearch": {
|
||||||
"input_artist": "$t(entity.artist, {\"count\": 1})",
|
"input_artist": "$t(entity.artist, {\"count\": 1})",
|
||||||
|
|||||||
@@ -98,6 +98,7 @@
|
|||||||
"forceRestartRequired": "перезапустіть, щоб застосувати зміни… закрийте повідомлення, щоб перезапустити",
|
"forceRestartRequired": "перезапустіть, щоб застосувати зміни… закрийте повідомлення, щоб перезапустити",
|
||||||
"forward": "уперед",
|
"forward": "уперед",
|
||||||
"gap": "прогалина",
|
"gap": "прогалина",
|
||||||
|
"grouping": "групування",
|
||||||
"home": "додому",
|
"home": "додому",
|
||||||
"increase": "збільшити",
|
"increase": "збільшити",
|
||||||
"left": "ліво",
|
"left": "ліво",
|
||||||
@@ -382,7 +383,6 @@
|
|||||||
},
|
},
|
||||||
"editPlaylist": {
|
"editPlaylist": {
|
||||||
"publicJellyfinNote": "Jellyfin з якоїсь причини не показує, чи є плейлист публічним чи ні. Якщо ви хочете, щоб він залишався публічним, виберіть варіант нижче",
|
"publicJellyfinNote": "Jellyfin з якоїсь причини не показує, чи є плейлист публічним чи ні. Якщо ви хочете, щоб він залишався публічним, виберіть варіант нижче",
|
||||||
"editNote": "ручне редагування не рекомендується для великих плейлистів. ви впевнені, що готові прийняти ризик втрати даних, який виникає при перезапису існуючого плейлисту?",
|
|
||||||
"success": "$t(entity.playlist, {\"count\": 1}) успішно оновлено",
|
"success": "$t(entity.playlist, {\"count\": 1}) успішно оновлено",
|
||||||
"title": "змінити $t(entity.playlist, {\"count\": 1})"
|
"title": "змінити $t(entity.playlist, {\"count\": 1})"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -19,7 +19,10 @@
|
|||||||
"goToPage": "前往页面",
|
"goToPage": "前往页面",
|
||||||
"openIn": {
|
"openIn": {
|
||||||
"lastfm": "在 Last.fm 中打开",
|
"lastfm": "在 Last.fm 中打开",
|
||||||
"musicbrainz": "在 MusicBrainz 中打开"
|
"musicbrainz": "在 MusicBrainz 中打开",
|
||||||
|
"listenbrainz": "在 ListenBrainz 中打开",
|
||||||
|
"qobuz": "在 Qobuz 中打开",
|
||||||
|
"spotify": "在 Spotify 中打开"
|
||||||
},
|
},
|
||||||
"moveToNext": "移至下一首",
|
"moveToNext": "移至下一首",
|
||||||
"downloadStarted": "开始下载 {{count}} 个项目",
|
"downloadStarted": "开始下载 {{count}} 个项目",
|
||||||
@@ -157,7 +160,8 @@
|
|||||||
"mood": "氛围",
|
"mood": "氛围",
|
||||||
"rename": "重命名",
|
"rename": "重命名",
|
||||||
"filter_multiple": "多项",
|
"filter_multiple": "多项",
|
||||||
"newVersionAvailable": "a new version is available"
|
"newVersionAvailable": "新版本现已可用",
|
||||||
|
"numberOfResults": "{{numberOfResults}} 结果"
|
||||||
},
|
},
|
||||||
"entity": {
|
"entity": {
|
||||||
"albumArtist_other": "专辑艺术家",
|
"albumArtist_other": "专辑艺术家",
|
||||||
@@ -448,7 +452,7 @@
|
|||||||
"discordServeImage": "从服务器提供 {{discord}} 图像",
|
"discordServeImage": "从服务器提供 {{discord}} 图像",
|
||||||
"discordServeImage_description": "从服务器本身分享 {{discord}} rich presence 的封面艺术,仅适用于 Jellyfin 和 Navidrome。 {{discord}} 使用机器人来获取图像,因此您的服务器必须可通过公共互联网访问",
|
"discordServeImage_description": "从服务器本身分享 {{discord}} rich presence 的封面艺术,仅适用于 Jellyfin 和 Navidrome。 {{discord}} 使用机器人来获取图像,因此您的服务器必须可通过公共互联网访问",
|
||||||
"musicbrainz": "显示 MusicBrainz 链接",
|
"musicbrainz": "显示 MusicBrainz 链接",
|
||||||
"musicbrainz_description": "在存在 MusicBrainz ID 的艺术家/专辑页面上显示 MusicBrainz 的链接",
|
"musicbrainz_description": "在艺术家/专辑页面上显示 MusicBrainz 链接(如果存在 MusicBrainz ID)",
|
||||||
"lastfm": "显示 last.fm 链接",
|
"lastfm": "显示 last.fm 链接",
|
||||||
"lastfm_description": "在艺术家/专辑页面上显示 Last.fm 的链接",
|
"lastfm_description": "在艺术家/专辑页面上显示 Last.fm 的链接",
|
||||||
"preferLocalLyrics_description": "优先选择本地歌词(如有),而不是远程歌词",
|
"preferLocalLyrics_description": "优先选择本地歌词(如有),而不是远程歌词",
|
||||||
@@ -593,7 +597,19 @@
|
|||||||
"primaryShade": "主色调",
|
"primaryShade": "主色调",
|
||||||
"primaryShade_description": "覆盖按钮、链接和其他主色元素使用的主色调(0-9)",
|
"primaryShade_description": "覆盖按钮、链接和其他主色元素使用的主色调(0-9)",
|
||||||
"playerItemConfiguration_description": "配置全屏播放器上显示的项目及其显示顺序",
|
"playerItemConfiguration_description": "配置全屏播放器上显示的项目及其显示顺序",
|
||||||
"playerItemConfiguration": "播放器项目配置"
|
"playerItemConfiguration": "播放器项目配置",
|
||||||
|
"listenbrainz_description": "在艺术家/专辑页面上显示 ListenBrainz 链接",
|
||||||
|
"listenbrainz": "显示 ListenBrainz 链接",
|
||||||
|
"qobuz_description": "在艺术家/专辑页面上显示 Qobuz 链接",
|
||||||
|
"qobuz": "显示 Qobuz 链接",
|
||||||
|
"spotify_description": "在艺术家/专辑页面上显示 Spotify 链接",
|
||||||
|
"spotify": "显示 Spotify 链接",
|
||||||
|
"nativeSpotify_description": "在 Spotify 应用中打开,而不是在浏览器中打开",
|
||||||
|
"nativeSpotify": "使用 Spotify 应用",
|
||||||
|
"sidePlayQueueLayout": "侧边播放队列布局",
|
||||||
|
"sidePlayQueueLayout_description": "设置附加侧边播放队列的布局",
|
||||||
|
"sidePlayQueueLayout_optionHorizontal": "水平",
|
||||||
|
"sidePlayQueueLayout_optionVertical": "垂直"
|
||||||
},
|
},
|
||||||
"error": {
|
"error": {
|
||||||
"remotePortWarning": "重启服务器使新端口生效",
|
"remotePortWarning": "重启服务器使新端口生效",
|
||||||
@@ -945,8 +961,7 @@
|
|||||||
"editPlaylist": {
|
"editPlaylist": {
|
||||||
"title": "编辑$t(entity.playlist, {\"count\": 1})",
|
"title": "编辑$t(entity.playlist, {\"count\": 1})",
|
||||||
"publicJellyfinNote": "Jellyfin 出于某种原因不会显示播放列表是否公开。如果您希望保持公开,请选择以下输入",
|
"publicJellyfinNote": "Jellyfin 出于某种原因不会显示播放列表是否公开。如果您希望保持公开,请选择以下输入",
|
||||||
"success": "$t(entity.playlist, {\"count\": 1})更新成功",
|
"success": "$t(entity.playlist, {\"count\": 1})更新成功"
|
||||||
"editNote": "不建议对大型播放列表进行手动编辑,你确定接受新播放列表覆盖已有播放列表可能导致的数据丢失风险吗?"
|
|
||||||
},
|
},
|
||||||
"lyricSearch": {
|
"lyricSearch": {
|
||||||
"title": "搜索歌词",
|
"title": "搜索歌词",
|
||||||
@@ -1212,21 +1227,21 @@
|
|||||||
"options": {
|
"options": {
|
||||||
"channelLayout": {
|
"channelLayout": {
|
||||||
"single": "单项",
|
"single": "单项",
|
||||||
"dualCombined": "Dual-Combined",
|
"dualCombined": "双重组合",
|
||||||
"dualHorizontal": "Dual-Horizontal",
|
"dualHorizontal": "双水平",
|
||||||
"dualVertical": "Dual-Vertical"
|
"dualVertical": "双垂直"
|
||||||
},
|
},
|
||||||
"mode": {
|
"mode": {
|
||||||
"0": "[0] 离散频率",
|
"0": "[0] 离散频率",
|
||||||
"1": "[1] 1/24th octave / 240 bands",
|
"1": "[1] 1/24倍频程 / 240频段",
|
||||||
"2": "[2] 1/12th octave / 120 bands",
|
"2": "[2] 1/12 倍频程 / 120 频段",
|
||||||
"3": "[3] 1/8th octave / 80 bands",
|
"3": "[3] 1/8倍频程 / 80频段",
|
||||||
"4": "[4] 1/6th octave / 60 bands",
|
"4": "[4] 1/6倍频程 / 60频段",
|
||||||
"5": "[5] 1/4th octave / 40 bands",
|
"5": "[5] 1/4倍频程 / 40频段",
|
||||||
"6": "[6] 1/3rd octave / 30 bands",
|
"6": "[6] 1/3倍频程 / 30频段",
|
||||||
"7": "[7] Half octave / 20 bands",
|
"7": "[7] 半倍频程 / 20 频段",
|
||||||
"8": "[8] Full octave / 10 bands",
|
"8": "[8] 全倍频程 / 10 频段",
|
||||||
"10": "[10] Line / Area graph"
|
"10": "[10] 折线图 / 面积图"
|
||||||
},
|
},
|
||||||
"colorMode": {
|
"colorMode": {
|
||||||
"gradient": "渐变",
|
"gradient": "渐变",
|
||||||
@@ -1242,10 +1257,10 @@
|
|||||||
},
|
},
|
||||||
"frequencyScale": {
|
"frequencyScale": {
|
||||||
"none": "无",
|
"none": "无",
|
||||||
"bark": "Bark Scale",
|
"bark": "树皮鳞片",
|
||||||
"linear": "Linear Scale",
|
"linear": "线性刻度",
|
||||||
"log": "Log Scale",
|
"log": "对数刻度",
|
||||||
"mel": "Mel Scale"
|
"mel": "梅尔刻度"
|
||||||
},
|
},
|
||||||
"weightingFilter": {
|
"weightingFilter": {
|
||||||
"none": "无",
|
"none": "无",
|
||||||
@@ -1304,13 +1319,13 @@
|
|||||||
"showScaleX": "显示比例尺 X",
|
"showScaleX": "显示比例尺 X",
|
||||||
"noteLabels": "笔记标签",
|
"noteLabels": "笔记标签",
|
||||||
"showScaleY": "显示比例尺 Y",
|
"showScaleY": "显示比例尺 Y",
|
||||||
"alphaBars": "Alpha Bars",
|
"alphaBars": "Alpha 条",
|
||||||
"ansiBands": "ANSI Bands",
|
"ansiBands": "ANSI 频段",
|
||||||
"ledBars": "LED Bars",
|
"ledBars": "LED 灯条",
|
||||||
"trueLeds": "True LEDs",
|
"trueLeds": "真正的LED",
|
||||||
"lumiBars": "Lumi Bars",
|
"lumiBars": "Lumi 条",
|
||||||
"outlineBars": "Outline Bars",
|
"outlineBars": "轮廓栏",
|
||||||
"roundBars": "Round Bars"
|
"roundBars": "圆条"
|
||||||
},
|
},
|
||||||
"queryBuilder": {
|
"queryBuilder": {
|
||||||
"standardTags": "标准标签",
|
"standardTags": "标准标签",
|
||||||
|
|||||||
@@ -116,7 +116,9 @@
|
|||||||
"itemsMore": "{{count}} 更多",
|
"itemsMore": "{{count}} 更多",
|
||||||
"filter_single": "單選",
|
"filter_single": "單選",
|
||||||
"filter_multiple": "複選",
|
"filter_multiple": "複選",
|
||||||
"newVersionAvailable": "有新的版本可供使用"
|
"newVersionAvailable": "有新的版本可供使用",
|
||||||
|
"numberOfResults": "{{numberOfResults}} 項結果",
|
||||||
|
"grouping": "分組"
|
||||||
},
|
},
|
||||||
"error": {
|
"error": {
|
||||||
"endpointNotImplementedError": "{{serverType}} 尚未實現端點 {{endpoint}}",
|
"endpointNotImplementedError": "{{serverType}} 尚未實現端點 {{endpoint}}",
|
||||||
@@ -776,7 +778,21 @@
|
|||||||
"autosave": "自動儲存播放佇列",
|
"autosave": "自動儲存播放佇列",
|
||||||
"autosave_description": "啟用自動將播放佇列儲存到您的伺服器。這只有在使用Navidrome/Subsonic時才可使用,並且您不能有混合播放佇列。",
|
"autosave_description": "啟用自動將播放佇列儲存到您的伺服器。這只有在使用Navidrome/Subsonic時才可使用,並且您不能有混合播放佇列。",
|
||||||
"autosaveCount": "自動播放佇列儲存頻率",
|
"autosaveCount": "自動播放佇列儲存頻率",
|
||||||
"autosaveCount_description": "在儲存佇列之前,有多少曲目更改。1(最小)表示每次歌曲更改"
|
"autosaveCount_description": "在儲存佇列之前,有多少曲目更改。1(最小)表示每次歌曲更改",
|
||||||
|
"spotify_description": "在藝人與專輯頁面顯示 Spotify 的連結",
|
||||||
|
"spotify": "顯示 Spotify 的連結",
|
||||||
|
"nativeSpotify_description": "在 Spotify 應用程式中開啟,而非在瀏覽器中開啟",
|
||||||
|
"nativeSpotify": "使用 Spotify 應用程式",
|
||||||
|
"sidePlayQueueLayout": "側邊播放佇列佈局",
|
||||||
|
"sidePlayQueueLayout_description": "設定吸附側邊播放佇列的佈局",
|
||||||
|
"sidePlayQueueLayout_optionHorizontal": "水平",
|
||||||
|
"sidePlayQueueLayout_optionVertical": "垂直",
|
||||||
|
"listenbrainz_description": "在藝術家/專輯頁面上顯示 ListenBrainz 的連結",
|
||||||
|
"listenbrainz": "顯示 ListenBrainz 連結",
|
||||||
|
"qobuz_description": "在藝術家/專輯頁面上顯示 Qobuz 的連結",
|
||||||
|
"qobuz": "顯示 Qobuz 連結",
|
||||||
|
"waveformLoadingDelay": "波形載入延遲",
|
||||||
|
"waveformLoadingDelay_description": "載入波形前的延遲(以秒為單位)。如果您在使用網頁播放器時遇到卡頓,請增加此值。"
|
||||||
},
|
},
|
||||||
"table": {
|
"table": {
|
||||||
"config": {
|
"config": {
|
||||||
@@ -908,7 +924,10 @@
|
|||||||
"moveToNext": "移至下一項",
|
"moveToNext": "移至下一項",
|
||||||
"openIn": {
|
"openIn": {
|
||||||
"lastfm": "在Last.fm開啟",
|
"lastfm": "在Last.fm開啟",
|
||||||
"musicbrainz": "在MusicBrainz開啟"
|
"musicbrainz": "在MusicBrainz開啟",
|
||||||
|
"spotify": "在 Spotify 中開啟",
|
||||||
|
"listenbrainz": "在 ListenBrainz 中開啟",
|
||||||
|
"qobuz": "在 Qobuz 中開啟"
|
||||||
},
|
},
|
||||||
"downloadStarted": "已開始下載 {{count}} 項內容",
|
"downloadStarted": "已開始下載 {{count}} 項內容",
|
||||||
"moveItems": "移動項目",
|
"moveItems": "移動項目",
|
||||||
@@ -1059,8 +1078,7 @@
|
|||||||
"editPlaylist": {
|
"editPlaylist": {
|
||||||
"title": "編輯$t(entity.playlist, {\"count\": 1})",
|
"title": "編輯$t(entity.playlist, {\"count\": 1})",
|
||||||
"publicJellyfinNote": "Jellyfin 出於某種原因,不會顯示播放清單是否公開。如果您希望保持公開狀態,請選擇以下輸入",
|
"publicJellyfinNote": "Jellyfin 出於某種原因,不會顯示播放清單是否公開。如果您希望保持公開狀態,請選擇以下輸入",
|
||||||
"success": "$t(entity.playlist, {\"count\": 1}) 更新成功",
|
"success": "$t(entity.playlist, {\"count\": 1}) 更新成功"
|
||||||
"editNote": "不建議手動編輯大型播放清單,你確定要承擔覆寫現有播放清單可能造成的資料遺失風險嗎?"
|
|
||||||
},
|
},
|
||||||
"shareItem": {
|
"shareItem": {
|
||||||
"allowDownloading": "允許下載",
|
"allowDownloading": "允許下載",
|
||||||
|
|||||||
@@ -5,6 +5,10 @@ import { getLyricsBySongId as getGenius, getSearchResults as searchGenius } from
|
|||||||
import { getLyricsBySongId as getLrcLib, getSearchResults as searchLrcLib } from './lrclib';
|
import { getLyricsBySongId as getLrcLib, getSearchResults as searchLrcLib } from './lrclib';
|
||||||
import { getLyricsBySongId as getNetease, getSearchResults as searchNetease } from './netease';
|
import { getLyricsBySongId as getNetease, getSearchResults as searchNetease } from './netease';
|
||||||
import { orderSearchResults } from './shared';
|
import { orderSearchResults } from './shared';
|
||||||
|
import {
|
||||||
|
getLyricsBySongId as getSimpMusic,
|
||||||
|
getSearchResults as searchSimpMusic,
|
||||||
|
} from './simpmusic';
|
||||||
|
|
||||||
import { Song } from '/@/shared/types/domain-types';
|
import { Song } from '/@/shared/types/domain-types';
|
||||||
|
|
||||||
@@ -12,6 +16,7 @@ export enum LyricSource {
|
|||||||
GENIUS = 'Genius',
|
GENIUS = 'Genius',
|
||||||
LRCLIB = 'lrclib.net',
|
LRCLIB = 'lrclib.net',
|
||||||
NETEASE = 'NetEase',
|
NETEASE = 'NetEase',
|
||||||
|
SIMPMUSIC = 'SimpMusic',
|
||||||
}
|
}
|
||||||
|
|
||||||
export type FullLyricsMetadata = Omit<InternetProviderLyricResponse, 'id' | 'lyrics' | 'source'> & {
|
export type FullLyricsMetadata = Omit<InternetProviderLyricResponse, 'id' | 'lyrics' | 'source'> & {
|
||||||
@@ -66,12 +71,14 @@ const SEARCH_FETCHERS: Record<LyricSource, SearchFetcher> = {
|
|||||||
[LyricSource.GENIUS]: searchGenius,
|
[LyricSource.GENIUS]: searchGenius,
|
||||||
[LyricSource.LRCLIB]: searchLrcLib,
|
[LyricSource.LRCLIB]: searchLrcLib,
|
||||||
[LyricSource.NETEASE]: searchNetease,
|
[LyricSource.NETEASE]: searchNetease,
|
||||||
|
[LyricSource.SIMPMUSIC]: searchSimpMusic,
|
||||||
};
|
};
|
||||||
|
|
||||||
const GET_FETCHERS: Record<LyricSource, GetFetcher> = {
|
const GET_FETCHERS: Record<LyricSource, GetFetcher> = {
|
||||||
[LyricSource.GENIUS]: getGenius,
|
[LyricSource.GENIUS]: getGenius,
|
||||||
[LyricSource.LRCLIB]: getLrcLib,
|
[LyricSource.LRCLIB]: getLrcLib,
|
||||||
[LyricSource.NETEASE]: getNetease,
|
[LyricSource.NETEASE]: getNetease,
|
||||||
|
[LyricSource.SIMPMUSIC]: getSimpMusic,
|
||||||
};
|
};
|
||||||
|
|
||||||
const MAX_CACHED_ITEMS = 10;
|
const MAX_CACHED_ITEMS = 10;
|
||||||
@@ -191,6 +198,7 @@ const searchRemoteLyrics = async (params: LyricSearchQuery) => {
|
|||||||
[LyricSource.GENIUS]: [],
|
[LyricSource.GENIUS]: [],
|
||||||
[LyricSource.LRCLIB]: [],
|
[LyricSource.LRCLIB]: [],
|
||||||
[LyricSource.NETEASE]: [],
|
[LyricSource.NETEASE]: [],
|
||||||
|
[LyricSource.SIMPMUSIC]: [],
|
||||||
};
|
};
|
||||||
for (const item of allSearchResults) {
|
for (const item of allSearchResults) {
|
||||||
results[item.source].push(item);
|
results[item.source].push(item);
|
||||||
|
|||||||
@@ -58,14 +58,16 @@ export async function getSearchResults(
|
|||||||
): Promise<InternetProviderLyricSearchResponse[] | null> {
|
): Promise<InternetProviderLyricSearchResponse[] | null> {
|
||||||
let result: AxiosResponse<LrcLibSearchResponse[]>;
|
let result: AxiosResponse<LrcLibSearchResponse[]>;
|
||||||
|
|
||||||
if (!params.name) {
|
if (!params.name && !params.artist) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const searchQuery = [params.name, params.artist].join(' ');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
result = await axios.get<LrcLibSearchResponse[]>(SEEARCH_URL, {
|
result = await axios.get<LrcLibSearchResponse[]>(SEEARCH_URL, {
|
||||||
params: {
|
params: {
|
||||||
q: params.name,
|
q: searchQuery,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|||||||
@@ -0,0 +1,126 @@
|
|||||||
|
import axios, { AxiosResponse } from 'axios';
|
||||||
|
|
||||||
|
import {
|
||||||
|
InternetProviderLyricResponse,
|
||||||
|
InternetProviderLyricSearchResponse,
|
||||||
|
LyricSearchQuery,
|
||||||
|
LyricSource,
|
||||||
|
} from '.';
|
||||||
|
import { orderSearchResults } from './shared';
|
||||||
|
|
||||||
|
const API_URL = 'https://api-lyrics.simpmusic.org/v1';
|
||||||
|
|
||||||
|
const TIMEOUT_MS = 5000;
|
||||||
|
|
||||||
|
export interface SimpMusicLyric {
|
||||||
|
albumName?: string;
|
||||||
|
artistName: string;
|
||||||
|
durationSeconds?: number;
|
||||||
|
id: string;
|
||||||
|
plainLyric?: string;
|
||||||
|
richSyncLyrics?: string;
|
||||||
|
songTitle: string;
|
||||||
|
syncedLyrics?: string;
|
||||||
|
videoId: string;
|
||||||
|
vote?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SimpMusicSearchResponse {
|
||||||
|
data: SimpMusicLyric[];
|
||||||
|
success: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getLyricsBySongId(songId: string): Promise<null | string> {
|
||||||
|
let result: AxiosResponse;
|
||||||
|
|
||||||
|
try {
|
||||||
|
result = await axios.get(`${API_URL}/${songId}`, {
|
||||||
|
timeout: TIMEOUT_MS,
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
console.error('SimpMusic lyrics request errored:', (e as Error)?.message);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const firstLyric = (result.data.data?.[0] ?? null) as null | SimpMusicLyric;
|
||||||
|
if (!firstLyric) return null;
|
||||||
|
|
||||||
|
return firstLyric.syncedLyrics || firstLyric.plainLyric || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getSearchResults(
|
||||||
|
params: LyricSearchQuery,
|
||||||
|
): Promise<InternetProviderLyricSearchResponse[] | null> {
|
||||||
|
let result: AxiosResponse<SimpMusicSearchResponse>;
|
||||||
|
|
||||||
|
if (!params.name) return null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
result = await axios.get<SimpMusicSearchResponse>(`${API_URL}/search`, {
|
||||||
|
params: {
|
||||||
|
q: params.name,
|
||||||
|
},
|
||||||
|
timeout: TIMEOUT_MS,
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
console.error('SimpMusic search errored:', (e as Error)?.message);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!result.data?.data) return null;
|
||||||
|
|
||||||
|
const songResults: InternetProviderLyricSearchResponse[] = result.data.data.map((song) => ({
|
||||||
|
artist: song.artistName,
|
||||||
|
id: song.videoId,
|
||||||
|
isSync: song.syncedLyrics ? true : false,
|
||||||
|
name: song.songTitle,
|
||||||
|
source: LyricSource.SIMPMUSIC,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return orderSearchResults({ params, results: songResults });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function query(
|
||||||
|
params: LyricSearchQuery,
|
||||||
|
): Promise<InternetProviderLyricResponse | null> {
|
||||||
|
if (!params.name) return null;
|
||||||
|
|
||||||
|
let search: AxiosResponse<SimpMusicSearchResponse>;
|
||||||
|
|
||||||
|
try {
|
||||||
|
search = await axios.get<SimpMusicSearchResponse>(`${API_URL}/search`, {
|
||||||
|
params: {
|
||||||
|
q: params.name,
|
||||||
|
},
|
||||||
|
timeout: TIMEOUT_MS,
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
console.error('SimpMusic search errored:', (e as Error).message);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const first = search.data?.data?.[0];
|
||||||
|
if (!first) return null;
|
||||||
|
|
||||||
|
let lyric: AxiosResponse<SimpMusicLyric>;
|
||||||
|
|
||||||
|
try {
|
||||||
|
lyric = await axios.get<SimpMusicLyric>(`${API_URL}/${first.videoId}`, {
|
||||||
|
timeout: TIMEOUT_MS,
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
console.error('SimpMusic lyrics fetch errored:', (e as Error).message);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const lyrics = lyric.data.syncedLyrics || lyric.data.plainLyric || null;
|
||||||
|
if (!lyrics) return null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
artist: lyric.data.artistName,
|
||||||
|
id: lyric.data.videoId,
|
||||||
|
lyrics,
|
||||||
|
name: lyric.data.songTitle,
|
||||||
|
source: LyricSource.SIMPMUSIC,
|
||||||
|
};
|
||||||
|
}
|
||||||
+12
-4
@@ -431,6 +431,7 @@ const createTray = () => {
|
|||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
if (!isMacOS()) {
|
||||||
tray.on('click', () => {
|
tray.on('click', () => {
|
||||||
if (store.get('window_minimize_to_tray')) {
|
if (store.get('window_minimize_to_tray')) {
|
||||||
if (mainWindow?.isVisible()) {
|
if (mainWindow?.isVisible()) {
|
||||||
@@ -444,6 +445,7 @@ const createTray = () => {
|
|||||||
createWinThumbarButtons();
|
createWinThumbarButtons();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
tray.setToolTip('Feishin');
|
tray.setToolTip('Feishin');
|
||||||
tray.setContextMenu(contextMenu);
|
tray.setContextMenu(contextMenu);
|
||||||
@@ -740,11 +742,17 @@ const playbackType = store.get('playbackType', PlayerType.WEB) as PlayerType;
|
|||||||
const shouldDisableMediaFeatures =
|
const shouldDisableMediaFeatures =
|
||||||
isLinux() || !enableMediaSession || playbackType !== PlayerType.WEB;
|
isLinux() || !enableMediaSession || playbackType !== PlayerType.WEB;
|
||||||
|
|
||||||
|
const chromiumDisabledFeatures: string[] = [];
|
||||||
|
// Fractional scaling on Wayland: https://github.com/jeffvli/feishin/issues/1271#issuecomment-4063326712
|
||||||
|
if (isLinux()) {
|
||||||
|
chromiumDisabledFeatures.push('WaylandFractionalScaleV1');
|
||||||
|
}
|
||||||
if (shouldDisableMediaFeatures) {
|
if (shouldDisableMediaFeatures) {
|
||||||
app.commandLine.appendSwitch(
|
chromiumDisabledFeatures.push('HardwareMediaKeyHandling', 'MediaSessionService');
|
||||||
'disable-features',
|
}
|
||||||
'HardwareMediaKeyHandling,MediaSessionService',
|
|
||||||
);
|
if (chromiumDisabledFeatures.length > 0) {
|
||||||
|
app.commandLine.appendSwitch('disable-features', chromiumDisabledFeatures.join(','));
|
||||||
}
|
}
|
||||||
|
|
||||||
// https://github.com/electron/electron/issues/46538#issuecomment-2808806722
|
// https://github.com/electron/electron/issues/46538#issuecomment-2808806722
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ import {
|
|||||||
ControllerEndpoint,
|
ControllerEndpoint,
|
||||||
InternalControllerEndpoint,
|
InternalControllerEndpoint,
|
||||||
ServerType,
|
ServerType,
|
||||||
|
SetPlaylistSongsArgs,
|
||||||
|
SetPlaylistSongsResponse,
|
||||||
} from '/@/shared/types/domain-types';
|
} from '/@/shared/types/domain-types';
|
||||||
|
|
||||||
type ApiController = {
|
type ApiController = {
|
||||||
@@ -67,6 +69,7 @@ const getPathReplaceSettings = () => {
|
|||||||
|
|
||||||
const addContext = <T extends { apiClientProps: any; context?: any }>(args: T): T => {
|
const addContext = <T extends { apiClientProps: any; context?: any }>(args: T): T => {
|
||||||
const pathSettings = getPathReplaceSettings();
|
const pathSettings = getPathReplaceSettings();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...args,
|
...args,
|
||||||
context: {
|
context: {
|
||||||
@@ -717,7 +720,9 @@ export const controller: GeneralController = {
|
|||||||
const server = getServerById(args.apiClientProps.serverId);
|
const server = getServerById(args.apiClientProps.serverId);
|
||||||
|
|
||||||
if (!server) {
|
if (!server) {
|
||||||
return '';
|
throw new Error(
|
||||||
|
`${i18n.t('error.apiRouteError', { postProcess: 'sentenceCase' })}: getStreamUrl`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return apiController(
|
return apiController(
|
||||||
@@ -885,6 +890,20 @@ export const controller: GeneralController = {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
setPlaylistSongs: function (args: SetPlaylistSongsArgs): Promise<SetPlaylistSongsResponse> {
|
||||||
|
const server = getServerById(args.apiClientProps.serverId);
|
||||||
|
|
||||||
|
if (!server) {
|
||||||
|
throw new Error(
|
||||||
|
`${i18n.t('error.apiRouteError', { postProcess: 'sentenceCase' })}: setPlaylistSongs`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return apiController(
|
||||||
|
'setPlaylistSongs',
|
||||||
|
server.type,
|
||||||
|
)?.(addContext({ ...args, apiClientProps: { ...args.apiClientProps, server } }));
|
||||||
|
},
|
||||||
setRating(args) {
|
setRating(args) {
|
||||||
const server = getServerById(args.apiClientProps.serverId);
|
const server = getServerById(args.apiClientProps.serverId);
|
||||||
|
|
||||||
|
|||||||
@@ -1283,7 +1283,7 @@ export const JellyfinController: InternalControllerEndpoint = {
|
|||||||
apiClientProps,
|
apiClientProps,
|
||||||
query: { ...query, limit: 1, startIndex: 0 },
|
query: { ...query, limit: 1, startIndex: 0 },
|
||||||
}).then((result) => result!.totalRecordCount!),
|
}).then((result) => result!.totalRecordCount!),
|
||||||
getStreamUrl: ({ apiClientProps: { server }, query }) => {
|
getStreamUrl: async ({ apiClientProps: { server }, query }) => {
|
||||||
const { bitrate, format, id, transcode } = query;
|
const { bitrate, format, id, transcode } = query;
|
||||||
const deviceId = '';
|
const deviceId = '';
|
||||||
|
|
||||||
@@ -1769,6 +1769,24 @@ export const JellyfinController: InternalControllerEndpoint = {
|
|||||||
),
|
),
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
setPlaylistSongs: async (args) => {
|
||||||
|
const { apiClientProps, body } = args;
|
||||||
|
|
||||||
|
const res = await jfApiClient(apiClientProps).updatePlaylist({
|
||||||
|
body: {
|
||||||
|
Ids: body.songIds,
|
||||||
|
},
|
||||||
|
params: {
|
||||||
|
id: body.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.status !== 204) {
|
||||||
|
throw new Error('Failed to update playlist songs');
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
},
|
||||||
updateInternetRadioStation: async (args) => {
|
updateInternetRadioStation: async (args) => {
|
||||||
const { apiClientProps, body, query } = args;
|
const { apiClientProps, body, query } = args;
|
||||||
|
|
||||||
@@ -1798,14 +1816,8 @@ export const JellyfinController: InternalControllerEndpoint = {
|
|||||||
|
|
||||||
const res = await jfApiClient(apiClientProps).updatePlaylist({
|
const res = await jfApiClient(apiClientProps).updatePlaylist({
|
||||||
body: {
|
body: {
|
||||||
Genres: body.genres?.map((item) => ({ Id: item.id, Name: item.name })) || [],
|
|
||||||
IsPublic: body.public,
|
IsPublic: body.public,
|
||||||
MediaType: 'Audio',
|
|
||||||
Name: body.name,
|
Name: body.name,
|
||||||
PremiereDate: null,
|
|
||||||
ProviderIds: {},
|
|
||||||
Tags: [],
|
|
||||||
UserId: apiClientProps.server?.userId, // Required
|
|
||||||
},
|
},
|
||||||
params: {
|
params: {
|
||||||
id: query.id,
|
id: query.id,
|
||||||
@@ -1820,31 +1832,6 @@ export const JellyfinController: InternalControllerEndpoint = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
// const getArtistList = async (args: ArtistListArgs): Promise<AlbumArtistListResponse> => {
|
|
||||||
// const { query, apiClientProps } = args;
|
|
||||||
|
|
||||||
// const res = await jfApiClient(apiClientProps).getAlbumArtistList({
|
|
||||||
// query: {
|
|
||||||
// Limit: query.limit,
|
|
||||||
// ParentId: query.musicFolderId,
|
|
||||||
// Recursive: true,
|
|
||||||
// SortBy: artistListSortMap.jellyfin[query.sortBy] || 'SortName,Name',
|
|
||||||
// SortOrder: sortOrderMap.jellyfin[query.sortOrder],
|
|
||||||
// StartIndex: query.startIndex,
|
|
||||||
// },
|
|
||||||
// });
|
|
||||||
|
|
||||||
// if (res.status !== 200) {
|
|
||||||
// throw new Error('Failed to get artist list');
|
|
||||||
// }
|
|
||||||
|
|
||||||
// return {
|
|
||||||
// items: res.body.Items.map((item) => jfNormalize.albumArtist(item, apiClientProps.server)),
|
|
||||||
// startIndex: query.startIndex,
|
|
||||||
// totalRecordCount: res.body.TotalRecordCount,
|
|
||||||
// };
|
|
||||||
// };
|
|
||||||
|
|
||||||
function getLibraryId(musicFolderId?: string | string[]) {
|
function getLibraryId(musicFolderId?: string | string[]) {
|
||||||
return Array.isArray(musicFolderId) ? musicFolderId[0] : musicFolderId;
|
return Array.isArray(musicFolderId) ? musicFolderId[0] : musicFolderId;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -604,6 +604,7 @@ export const NavidromeController: InternalControllerEndpoint = {
|
|||||||
query: {
|
query: {
|
||||||
_end: -1,
|
_end: -1,
|
||||||
_order: 'ASC',
|
_order: 'ASC',
|
||||||
|
_sort: NDSongListSort.ID,
|
||||||
_start: 0,
|
_start: 0,
|
||||||
...excludeMissing(apiClientProps.server),
|
...excludeMissing(apiClientProps.server),
|
||||||
},
|
},
|
||||||
@@ -636,7 +637,7 @@ export const NavidromeController: InternalControllerEndpoint = {
|
|||||||
throw new Error('Failed to get play queue');
|
throw new Error('Failed to get play queue');
|
||||||
}
|
}
|
||||||
|
|
||||||
const { changedBy, current, items, position, updatedAt } = res.body.data;
|
const { changedBy, current, items = [], position, updatedAt } = res.body.data; // if there is no queue saved, items is undefined
|
||||||
|
|
||||||
const entries = items.map((song) =>
|
const entries = items.map((song) =>
|
||||||
ndNormalize.song(
|
ndNormalize.song(
|
||||||
@@ -747,17 +748,25 @@ export const NavidromeController: InternalControllerEndpoint = {
|
|||||||
getSongList: async (args) => {
|
getSongList: async (args) => {
|
||||||
const { apiClientProps, query } = args;
|
const { apiClientProps, query } = args;
|
||||||
|
|
||||||
|
const ALBUM_IDS_BATCH_SIZE = 500;
|
||||||
|
const albumIds = query.albumIds;
|
||||||
|
const shouldBatch = albumIds && albumIds.length > ALBUM_IDS_BATCH_SIZE;
|
||||||
|
|
||||||
|
const fetchAlbums = async (albumIdBatch: string[] | undefined) => {
|
||||||
const res = await ndApiClient(apiClientProps).getSongList({
|
const res = await ndApiClient(apiClientProps).getSongList({
|
||||||
query: {
|
query: {
|
||||||
_end: query.startIndex + (query.limit || -1),
|
_end: query.startIndex + (query.limit || -1),
|
||||||
_order: sortOrderMap.navidrome[query.sortOrder],
|
_order: sortOrderMap.navidrome[query.sortOrder],
|
||||||
_sort: songListSortMap.navidrome[query.sortBy],
|
_sort: songListSortMap.navidrome[query.sortBy],
|
||||||
_start: query.startIndex,
|
_start: query.startIndex,
|
||||||
album_id: query.albumIds,
|
album_id: albumIdBatch ?? query.albumIds,
|
||||||
genre_id: query.genreIds,
|
genre_id: query.genreIds,
|
||||||
[getArtistSongKey(apiClientProps.server)]: query.artistIds ?? query.albumArtistIds,
|
[getArtistSongKey(apiClientProps.server)]:
|
||||||
...(hasFeature(apiClientProps.server, ServerFeature.TRACK_YES_NO_RATING_FILTER) &&
|
query.artistIds ?? query.albumArtistIds,
|
||||||
query.hasRating !== undefined
|
...(hasFeature(
|
||||||
|
apiClientProps.server,
|
||||||
|
ServerFeature.TRACK_YES_NO_RATING_FILTER,
|
||||||
|
) && query.hasRating !== undefined
|
||||||
? { has_rating: query.hasRating }
|
? { has_rating: query.hasRating }
|
||||||
: {}),
|
: {}),
|
||||||
library_id: getLibraryId(query.musicFolderId),
|
library_id: getLibraryId(query.musicFolderId),
|
||||||
@@ -782,10 +791,34 @@ export const NavidromeController: InternalControllerEndpoint = {
|
|||||||
args.context?.pathReplaceWith,
|
args.context?.pathReplaceWith,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
startIndex: query?.startIndex || 0,
|
|
||||||
totalRecordCount: Number(res.body.headers.get('x-total-count') || 0),
|
totalRecordCount: Number(res.body.headers.get('x-total-count') || 0),
|
||||||
};
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
if (shouldBatch && albumIds) {
|
||||||
|
const batches: string[][] = [];
|
||||||
|
for (let i = 0; i < albumIds.length; i += ALBUM_IDS_BATCH_SIZE) {
|
||||||
|
batches.push(albumIds.slice(i, i + ALBUM_IDS_BATCH_SIZE));
|
||||||
|
}
|
||||||
|
|
||||||
|
const results = await Promise.all(batches.map((batch) => fetchAlbums(batch)));
|
||||||
|
|
||||||
|
return {
|
||||||
|
items: results.flatMap((r) => r.items),
|
||||||
|
startIndex: query?.startIndex ?? 0,
|
||||||
|
totalRecordCount: results.reduce((sum, r) => sum + r.totalRecordCount, 0),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const albums = await fetchAlbums(undefined);
|
||||||
|
|
||||||
|
return {
|
||||||
|
items: albums.items,
|
||||||
|
startIndex: query?.startIndex ?? 0,
|
||||||
|
totalRecordCount: albums.totalRecordCount,
|
||||||
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
getSongListCount: async ({ apiClientProps, query }) =>
|
getSongListCount: async ({ apiClientProps, query }) =>
|
||||||
NavidromeController.getSongList({
|
NavidromeController.getSongList({
|
||||||
apiClientProps,
|
apiClientProps,
|
||||||
@@ -978,6 +1011,7 @@ export const NavidromeController: InternalControllerEndpoint = {
|
|||||||
query: {
|
query: {
|
||||||
_end: -1,
|
_end: -1,
|
||||||
_order: 'ASC',
|
_order: 'ASC',
|
||||||
|
_sort: NDSongListSort.ID,
|
||||||
_start: 0,
|
_start: 0,
|
||||||
...excludeMissing(apiClientProps.server),
|
...excludeMissing(apiClientProps.server),
|
||||||
},
|
},
|
||||||
@@ -1088,6 +1122,7 @@ export const NavidromeController: InternalControllerEndpoint = {
|
|||||||
},
|
},
|
||||||
scrobble: SubsonicController.scrobble,
|
scrobble: SubsonicController.scrobble,
|
||||||
search: SubsonicController.search,
|
search: SubsonicController.search,
|
||||||
|
setPlaylistSongs: SubsonicController.setPlaylistSongs,
|
||||||
setRating: SubsonicController.setRating,
|
setRating: SubsonicController.setRating,
|
||||||
shareItem: async (args) => {
|
shareItem: async (args) => {
|
||||||
const { apiClientProps, body } = args;
|
const { apiClientProps, body } = args;
|
||||||
|
|||||||
@@ -347,6 +347,11 @@ export const queryKeys: Record<
|
|||||||
list: (serverId: string) => [serverId, 'roles'] as const,
|
list: (serverId: string) => [serverId, 'roles'] as const,
|
||||||
},
|
},
|
||||||
search: {
|
search: {
|
||||||
|
infiniteList: (
|
||||||
|
serverId: string,
|
||||||
|
type: 'albumArtists' | 'albums' | 'songs',
|
||||||
|
searchTerm: string,
|
||||||
|
) => [serverId, 'search', 'infiniteList', type, searchTerm] as const,
|
||||||
list: (serverId: string, query?: SearchQuery) => {
|
list: (serverId: string, query?: SearchQuery) => {
|
||||||
if (query) return [serverId, 'search', 'list', query] as const;
|
if (query) return [serverId, 'search', 'list', query] as const;
|
||||||
return [serverId, 'search', 'list'] as const;
|
return [serverId, 'search', 'list'] as const;
|
||||||
|
|||||||
@@ -250,6 +250,23 @@ export const contract = c.router({
|
|||||||
200: ssType._response.topSongsList,
|
200: ssType._response.topSongsList,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
getTranscodeDecision: {
|
||||||
|
body: ssType._body.getTranscodeDecision,
|
||||||
|
method: 'POST',
|
||||||
|
path: 'getTranscodeDecision.view',
|
||||||
|
query: ssType._parameters.getTranscodeDecision,
|
||||||
|
responses: {
|
||||||
|
200: ssType._response.getTranscodeDecision,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
getTranscodeStream: {
|
||||||
|
method: 'GET',
|
||||||
|
path: 'getTranscodeStream.view',
|
||||||
|
query: ssType._parameters.getTranscodeStream,
|
||||||
|
responses: {
|
||||||
|
200: z.string(),
|
||||||
|
},
|
||||||
|
},
|
||||||
getUser: {
|
getUser: {
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
path: 'getUser.view',
|
path: 'getUser.view',
|
||||||
@@ -392,7 +409,7 @@ export const ssApiClient = (args: {
|
|||||||
const { server, signal, silent, url } = args;
|
const { server, signal, silent, url } = args;
|
||||||
|
|
||||||
return initClient(contract, {
|
return initClient(contract, {
|
||||||
api: async ({ headers, method, path }) => {
|
api: async ({ body, headers, method, path, rawQuery }) => {
|
||||||
let baseUrl: string | undefined;
|
let baseUrl: string | undefined;
|
||||||
const authParams: Record<string, any> = {};
|
const authParams: Record<string, any> = {};
|
||||||
|
|
||||||
@@ -423,6 +440,28 @@ export const ssApiClient = (args: {
|
|||||||
url: `${baseUrl}/${api}`,
|
url: `${baseUrl}/${api}`,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const isGetTranscodeDecisionPost =
|
||||||
|
method === 'POST' && api === 'getTranscodeDecision.view';
|
||||||
|
|
||||||
|
if (isGetTranscodeDecisionPost && body != null) {
|
||||||
|
request.method = 'POST';
|
||||||
|
request.headers = {
|
||||||
|
...headers,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
};
|
||||||
|
request.data = body;
|
||||||
|
request.params = {
|
||||||
|
c: 'Feishin',
|
||||||
|
f: 'json',
|
||||||
|
v: '1.13.0',
|
||||||
|
...authParams,
|
||||||
|
...(typeof rawQuery === 'object' && rawQuery !== null
|
||||||
|
? (rawQuery as Record<string, unknown>)
|
||||||
|
: {}),
|
||||||
|
};
|
||||||
|
} else if (hasFeature(server, ServerFeature.OS_FORM_POST)) {
|
||||||
|
headers['Content-Type'] = 'application/x-www-form-urlencoded';
|
||||||
|
request.method = 'POST';
|
||||||
const data = {
|
const data = {
|
||||||
c: 'Feishin',
|
c: 'Feishin',
|
||||||
f: 'json',
|
f: 'json',
|
||||||
@@ -430,12 +469,15 @@ export const ssApiClient = (args: {
|
|||||||
...authParams,
|
...authParams,
|
||||||
...params,
|
...params,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (hasFeature(server, ServerFeature.OS_FORM_POST)) {
|
|
||||||
headers['Content-Type'] = 'application/x-www-form-urlencoded';
|
|
||||||
request.method = 'POST';
|
|
||||||
request.data = qs.stringify(data, { arrayFormat: 'repeat' });
|
request.data = qs.stringify(data, { arrayFormat: 'repeat' });
|
||||||
} else {
|
} else {
|
||||||
|
const data = {
|
||||||
|
c: 'Feishin',
|
||||||
|
f: 'json',
|
||||||
|
v: '1.13.0',
|
||||||
|
...authParams,
|
||||||
|
...params,
|
||||||
|
};
|
||||||
request.method = method;
|
request.method = method;
|
||||||
request.params = data;
|
request.params = data;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,12 @@ import md5 from 'md5';
|
|||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
import { contract, ssApiClient } from '/@/renderer/api/subsonic/subsonic-api';
|
import { contract, ssApiClient } from '/@/renderer/api/subsonic/subsonic-api';
|
||||||
|
import {
|
||||||
|
getDefaultTranscodingProfiles,
|
||||||
|
getDirectPlayProfiles,
|
||||||
|
} from '/@/renderer/features/player/components/audio-players';
|
||||||
import { randomString } from '/@/renderer/utils';
|
import { randomString } from '/@/renderer/utils';
|
||||||
|
import { logFn } from '/@/renderer/utils/logger';
|
||||||
import { getServerUrl } from '/@/renderer/utils/normalize-server-url';
|
import { getServerUrl } from '/@/renderer/utils/normalize-server-url';
|
||||||
import { ssNormalize } from '/@/shared/api/subsonic/subsonic-normalize';
|
import { ssNormalize } from '/@/shared/api/subsonic/subsonic-normalize';
|
||||||
import {
|
import {
|
||||||
@@ -87,6 +92,151 @@ const ALBUM_LIST_SORT_MAPPING: Record<AlbumListSort, AlbumListSortType | undefin
|
|||||||
const MAX_SUBSONIC_ITEMS = 500;
|
const MAX_SUBSONIC_ITEMS = 500;
|
||||||
const SUBSONIC_FAST_BATCH_SIZE = MAX_SUBSONIC_ITEMS * 10;
|
const SUBSONIC_FAST_BATCH_SIZE = MAX_SUBSONIC_ITEMS * 10;
|
||||||
|
|
||||||
|
// const TRANSCODE_DIRECT_PLAY_PROFILES = [
|
||||||
|
// {
|
||||||
|
// audioCodecs: ['mp3'],
|
||||||
|
// containers: ['mp3'],
|
||||||
|
// maxAudioChannels: 2,
|
||||||
|
// protocols: ['http'],
|
||||||
|
// },
|
||||||
|
// {
|
||||||
|
// audioCodecs: ['aac'],
|
||||||
|
// containers: ['m4a', 'mp4'],
|
||||||
|
// maxAudioChannels: 2,
|
||||||
|
// protocols: ['http'],
|
||||||
|
// },
|
||||||
|
// {
|
||||||
|
// audioCodecs: ['vorbis'],
|
||||||
|
// containers: ['ogg'],
|
||||||
|
// maxAudioChannels: 2,
|
||||||
|
// protocols: ['http'],
|
||||||
|
// },
|
||||||
|
// {
|
||||||
|
// audioCodecs: ['opus'],
|
||||||
|
// containers: ['ogg', 'webm'],
|
||||||
|
// maxAudioChannels: 2,
|
||||||
|
// protocols: ['http'],
|
||||||
|
// },
|
||||||
|
// {
|
||||||
|
// audioCodecs: ['pcm'],
|
||||||
|
// containers: ['wav'],
|
||||||
|
// maxAudioChannels: 2,
|
||||||
|
// protocols: ['http'],
|
||||||
|
// },
|
||||||
|
// {
|
||||||
|
// audioCodecs: ['flac'],
|
||||||
|
// containers: ['flac'],
|
||||||
|
// maxAudioChannels: 2,
|
||||||
|
// protocols: ['http'],
|
||||||
|
// },
|
||||||
|
// ];
|
||||||
|
|
||||||
|
// const TRANSCODE_UNSUPPORTED_DIRECT_PLAY_PROFILES = [
|
||||||
|
// {
|
||||||
|
// containers: ["m4a", "mp4"],
|
||||||
|
// audioCodecs: ["alac"],
|
||||||
|
// protocols: ["http"],
|
||||||
|
// maxAudioChannels: 2
|
||||||
|
// },
|
||||||
|
// {
|
||||||
|
// containers: ["m4a", "mp4"],
|
||||||
|
// audioCodecs: ["ac3", "eac3"],
|
||||||
|
// protocols: ["http"],
|
||||||
|
// maxAudioChannels: 6
|
||||||
|
// },
|
||||||
|
// {
|
||||||
|
// containers: ["ogg"],
|
||||||
|
// audioCodecs: ["flac", "speex"],
|
||||||
|
// protocols: ["http"],
|
||||||
|
// maxAudioChannels: 2
|
||||||
|
// },
|
||||||
|
// {
|
||||||
|
// containers: ["wav"],
|
||||||
|
// audioCodecs: ["adpcm", "gsm", "aac", "mp3"],
|
||||||
|
// protocols: ["http"],
|
||||||
|
// maxAudioChannels: 2
|
||||||
|
// },
|
||||||
|
// {
|
||||||
|
// containers: ["mkv"],
|
||||||
|
// audioCodecs: ["aac", "mp3", "flac", "opus", "vorbis", "ac3", "eac3", "dts"],
|
||||||
|
// protocols: ["http"],
|
||||||
|
// maxAudioChannels: 8
|
||||||
|
// },
|
||||||
|
// {
|
||||||
|
// containers: ["avi"],
|
||||||
|
// audioCodecs: ["mp3", "ac3", "pcm", "aac"],
|
||||||
|
// protocols: ["http"],
|
||||||
|
// maxAudioChannels: 6
|
||||||
|
// },
|
||||||
|
// {
|
||||||
|
// containers: ["asf", "wma"],
|
||||||
|
// audioCodecs: ["wma", "pcm", "mp3"],
|
||||||
|
// protocols: ["http"],
|
||||||
|
// maxAudioChannels: 2
|
||||||
|
// },
|
||||||
|
// {
|
||||||
|
// containers: ["caf"],
|
||||||
|
// audioCodecs: ["pcm", "aac", "alac", "mp3"],
|
||||||
|
// protocols: ["http"],
|
||||||
|
// maxAudioChannels: 8
|
||||||
|
// },
|
||||||
|
// {
|
||||||
|
// containers: ["3gp"],
|
||||||
|
// audioCodecs: ["aac", "amr"],
|
||||||
|
// protocols: ["http"],
|
||||||
|
// maxAudioChannels: 2
|
||||||
|
// },
|
||||||
|
// {
|
||||||
|
// containers: ["amr"],
|
||||||
|
// audioCodecs: ["amr"],
|
||||||
|
// protocols: ["http"],
|
||||||
|
// maxAudioChannels: 1
|
||||||
|
// },
|
||||||
|
// {
|
||||||
|
// containers: ["ape"],
|
||||||
|
// audioCodecs: ["ape"],
|
||||||
|
// protocols: ["http"],
|
||||||
|
// maxAudioChannels: 2
|
||||||
|
// },
|
||||||
|
// {
|
||||||
|
// containers: ["wv"],
|
||||||
|
// audioCodecs: ["wavpack"],
|
||||||
|
// protocols: ["http"],
|
||||||
|
// maxAudioChannels: 2
|
||||||
|
// },
|
||||||
|
// {
|
||||||
|
// containers: ["ac3"],
|
||||||
|
// audioCodecs: ["ac3"],
|
||||||
|
// protocols: ["http"],
|
||||||
|
// maxAudioChannels: 6
|
||||||
|
// },
|
||||||
|
// {
|
||||||
|
// containers: ["eac3"],
|
||||||
|
// audioCodecs: ["eac3"],
|
||||||
|
// protocols: ["http"],
|
||||||
|
// maxAudioChannels: 8
|
||||||
|
// },
|
||||||
|
// {
|
||||||
|
// containers: ["dts"],
|
||||||
|
// audioCodecs: ["dts"],
|
||||||
|
// protocols: ["http"],
|
||||||
|
// maxAudioChannels: 8
|
||||||
|
// }
|
||||||
|
// ];
|
||||||
|
|
||||||
|
function appendTranscodeParams(url: string, format?: string, bitrate?: number) {
|
||||||
|
let streamUrl = url;
|
||||||
|
|
||||||
|
if (format) {
|
||||||
|
streamUrl += `&format=${format}`;
|
||||||
|
}
|
||||||
|
if (bitrate !== undefined) {
|
||||||
|
streamUrl += `&maxBitRate=${bitrate}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return streamUrl;
|
||||||
|
}
|
||||||
|
|
||||||
function sortAndPaginate<T>(
|
function sortAndPaginate<T>(
|
||||||
items: T[],
|
items: T[],
|
||||||
options: {
|
options: {
|
||||||
@@ -1137,15 +1287,15 @@ export const SubsonicController: InternalControllerEndpoint = {
|
|||||||
const res = await ssApiClient(apiClientProps).getPlayQueueByIndex();
|
const res = await ssApiClient(apiClientProps).getPlayQueueByIndex();
|
||||||
|
|
||||||
if (res.status !== 200) {
|
if (res.status !== 200) {
|
||||||
throw new Error('Failed to get random songs');
|
throw new Error('Failed to get play queue');
|
||||||
}
|
}
|
||||||
|
|
||||||
const { changed, changedBy, currentIndex, entry, position, username } =
|
const { changed, changedBy, currentIndex, entry, position, username } =
|
||||||
res.body.playQueueByIndex;
|
res.body.playQueueByIndex || {}; // if there is no queue saved, playQueueByIndex may be undefined from a bug in Navidrome
|
||||||
|
|
||||||
return {
|
return {
|
||||||
changed,
|
changed: changed ?? '',
|
||||||
changedBy,
|
changedBy: changedBy ?? '',
|
||||||
currentIndex: currentIndex ?? 0,
|
currentIndex: currentIndex ?? 0,
|
||||||
entry:
|
entry:
|
||||||
entry?.map((song) =>
|
entry?.map((song) =>
|
||||||
@@ -1157,13 +1307,13 @@ export const SubsonicController: InternalControllerEndpoint = {
|
|||||||
),
|
),
|
||||||
) || [],
|
) || [],
|
||||||
positionMs: position ?? 0,
|
positionMs: position ?? 0,
|
||||||
username,
|
username: username ?? '',
|
||||||
};
|
};
|
||||||
} else {
|
} else {
|
||||||
const res = await ssApiClient(apiClientProps).getPlayQueue();
|
const res = await ssApiClient(apiClientProps).getPlayQueue();
|
||||||
|
|
||||||
if (res.status !== 200) {
|
if (res.status !== 200) {
|
||||||
throw new Error('Failed to get random songs');
|
throw new Error('Failed to get play queue');
|
||||||
}
|
}
|
||||||
|
|
||||||
const { changed, changedBy, current, entry, position, username } = res.body.playQueue;
|
const { changed, changedBy, current, entry, position, username } = res.body.playQueue;
|
||||||
@@ -1273,6 +1423,10 @@ export const SubsonicController: InternalControllerEndpoint = {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (subsonicFeatures[SubsonicExtensions.TRANSCODING]) {
|
||||||
|
features.osTranscodeDecision = [1];
|
||||||
|
}
|
||||||
|
|
||||||
if (subsonicFeatures[SubsonicExtensions.SONG_LYRICS]) {
|
if (subsonicFeatures[SubsonicExtensions.SONG_LYRICS]) {
|
||||||
features.lyricsMultipleStructured = [1];
|
features.lyricsMultipleStructured = [1];
|
||||||
}
|
}
|
||||||
@@ -1801,20 +1955,81 @@ export const SubsonicController: InternalControllerEndpoint = {
|
|||||||
|
|
||||||
return totalRecordCount;
|
return totalRecordCount;
|
||||||
},
|
},
|
||||||
getStreamUrl: ({ apiClientProps: { server }, query }) => {
|
getStreamUrl: async ({ apiClientProps, query }) => {
|
||||||
const { bitrate, format, id, transcode } = query;
|
const { server } = apiClientProps;
|
||||||
let url = `${server?.url}/rest/stream.view?id=${id}&v=1.13.0&c=Feishin&${server?.credential}`;
|
const { bitrate, format, id, mediaType = 'song', skipAutoTranscode, transcode } = query;
|
||||||
|
|
||||||
|
const streamUrl = `${server?.url}/rest/stream.view?id=${id}&v=1.13.0&c=Feishin&${server?.credential}`;
|
||||||
|
|
||||||
|
// If transcoding is explicitly enabled, just return the direct transcoded stream URL
|
||||||
if (transcode) {
|
if (transcode) {
|
||||||
if (format) {
|
return appendTranscodeParams(streamUrl, format, bitrate);
|
||||||
url += `&format=${format}`;
|
|
||||||
}
|
|
||||||
if (bitrate !== undefined) {
|
|
||||||
url += `&maxBitRate=${bitrate}`;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return url;
|
// Used in cases where MPV is the default player, since mpv handles basically every audio format
|
||||||
|
if (skipAutoTranscode) {
|
||||||
|
return streamUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the server supports transcoding decision, always use it to determine if we need to transcode
|
||||||
|
if (hasFeature(server, ServerFeature.OS_TRANSCODE_DECISION)) {
|
||||||
|
const maxTranscodingAudioBitrate = 0;
|
||||||
|
|
||||||
|
const directPlayProfiles = getDirectPlayProfiles();
|
||||||
|
const transcodingProfiles = getDefaultTranscodingProfiles();
|
||||||
|
|
||||||
|
const transcodeDecision = await ssApiClient(apiClientProps).getTranscodeDecision({
|
||||||
|
body: {
|
||||||
|
codecProfiles: [],
|
||||||
|
directPlayProfiles,
|
||||||
|
maxAudioBitrate: 0,
|
||||||
|
maxTranscodingAudioBitrate,
|
||||||
|
name: 'Feishin',
|
||||||
|
platform: navigator.userAgent,
|
||||||
|
transcodingProfiles,
|
||||||
|
},
|
||||||
|
query: {
|
||||||
|
mediaId: id,
|
||||||
|
mediaType,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (transcodeDecision.status !== 200) {
|
||||||
|
throw new Error('Failed to get transcode decision');
|
||||||
|
}
|
||||||
|
|
||||||
|
const td = transcodeDecision.body.transcodeDecision;
|
||||||
|
const requiresTranscoding = !td?.canDirectPlay;
|
||||||
|
|
||||||
|
// If the server does not require transcoding, just return the direct stream URL
|
||||||
|
if (!requiresTranscoding) {
|
||||||
|
return streamUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
logFn.info(`Song ${id} requires transcoding: ${[td.transcodeReason].join(', ')}`);
|
||||||
|
|
||||||
|
// If the server does not return transcode params, manually create the transcode params
|
||||||
|
if (!td.transcodeParams) {
|
||||||
|
return appendTranscodeParams(streamUrl, format, bitrate);
|
||||||
|
}
|
||||||
|
|
||||||
|
const transcodeStreamUrl = await ssApiClient(apiClientProps).getTranscodeStream({
|
||||||
|
query: {
|
||||||
|
mediaId: id,
|
||||||
|
mediaType,
|
||||||
|
offset: 0,
|
||||||
|
transcodeParams: td.transcodeParams,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (transcodeStreamUrl.status !== 200) {
|
||||||
|
throw new Error('Failed to get transcode stream');
|
||||||
|
}
|
||||||
|
|
||||||
|
return transcodeStreamUrl.body;
|
||||||
|
}
|
||||||
|
|
||||||
|
return streamUrl;
|
||||||
},
|
},
|
||||||
getStructuredLyrics: async (args) => {
|
getStructuredLyrics: async (args) => {
|
||||||
const { apiClientProps, query } = args;
|
const { apiClientProps, query } = args;
|
||||||
@@ -2118,6 +2333,22 @@ export const SubsonicController: InternalControllerEndpoint = {
|
|||||||
),
|
),
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
setPlaylistSongs: async (args) => {
|
||||||
|
const { apiClientProps, body } = args;
|
||||||
|
|
||||||
|
const res = await ssApiClient(apiClientProps).createPlaylist({
|
||||||
|
query: {
|
||||||
|
playlistId: body.id,
|
||||||
|
songId: body.songIds,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.status !== 200) {
|
||||||
|
throw new Error('Failed to update playlist songs');
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
},
|
||||||
setRating: async (args) => {
|
setRating: async (args) => {
|
||||||
const { apiClientProps, query } = args;
|
const { apiClientProps, query } = args;
|
||||||
|
|
||||||
|
|||||||
@@ -67,10 +67,19 @@
|
|||||||
padding: var(--theme-spacing-md);
|
padding: var(--theme-spacing-md);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.single-carousel-container .carousel {
|
||||||
|
min-height: 240px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.single-carousel-container .carousel-item {
|
||||||
|
min-height: 240px;
|
||||||
|
}
|
||||||
|
|
||||||
.single-carousel-container .carousel-item .content {
|
.single-carousel-container .carousel-item .content {
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
gap: var(--theme-spacing-lg);
|
gap: var(--theme-spacing-md);
|
||||||
align-items: flex-end;
|
align-items: flex-end;
|
||||||
|
min-height: 240px;
|
||||||
padding: var(--theme-spacing-xl);
|
padding: var(--theme-spacing-xl);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -40,7 +40,14 @@
|
|||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(var(--cards-to-show, 2), minmax(0, 1fr));
|
grid-template-columns: repeat(var(--cards-to-show, 2), minmax(0, 1fr));
|
||||||
gap: var(--theme-spacing-md);
|
gap: var(--theme-spacing-md);
|
||||||
|
contain: layout paint;
|
||||||
|
content-visibility: auto;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
will-change: transform;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
min-height: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.page-indicator {
|
.page-indicator {
|
||||||
|
|||||||
@@ -64,6 +64,7 @@ export interface ItemCardProps {
|
|||||||
enableMultiSelect?: boolean;
|
enableMultiSelect?: boolean;
|
||||||
enableNavigation?: boolean;
|
enableNavigation?: boolean;
|
||||||
imageAsLink?: boolean;
|
imageAsLink?: boolean;
|
||||||
|
imageFetchPriority?: 'auto' | 'high' | 'low';
|
||||||
internalState?: ItemListStateActions;
|
internalState?: ItemListStateActions;
|
||||||
isRound?: boolean;
|
isRound?: boolean;
|
||||||
itemType: LibraryItem;
|
itemType: LibraryItem;
|
||||||
@@ -80,6 +81,7 @@ export const ItemCard = ({
|
|||||||
enableMultiSelect,
|
enableMultiSelect,
|
||||||
enableNavigation = true,
|
enableNavigation = true,
|
||||||
imageAsLink,
|
imageAsLink,
|
||||||
|
imageFetchPriority,
|
||||||
internalState,
|
internalState,
|
||||||
isRound,
|
isRound,
|
||||||
itemType,
|
itemType,
|
||||||
@@ -102,6 +104,7 @@ export const ItemCard = ({
|
|||||||
enableMultiSelect={enableMultiSelect}
|
enableMultiSelect={enableMultiSelect}
|
||||||
enableNavigation={enableNavigation}
|
enableNavigation={enableNavigation}
|
||||||
imageAsLink={imageAsLink}
|
imageAsLink={imageAsLink}
|
||||||
|
imageFetchPriority={imageFetchPriority}
|
||||||
imageUrl={imageUrl}
|
imageUrl={imageUrl}
|
||||||
internalState={internalState}
|
internalState={internalState}
|
||||||
isRound={isRound}
|
isRound={isRound}
|
||||||
@@ -121,6 +124,7 @@ export const ItemCard = ({
|
|||||||
enableMultiSelect={enableMultiSelect}
|
enableMultiSelect={enableMultiSelect}
|
||||||
enableNavigation={enableNavigation}
|
enableNavigation={enableNavigation}
|
||||||
imageAsLink={imageAsLink}
|
imageAsLink={imageAsLink}
|
||||||
|
imageFetchPriority={imageFetchPriority}
|
||||||
imageUrl={imageUrl}
|
imageUrl={imageUrl}
|
||||||
internalState={internalState}
|
internalState={internalState}
|
||||||
isRound={isRound}
|
isRound={isRound}
|
||||||
@@ -140,6 +144,7 @@ export const ItemCard = ({
|
|||||||
enableExpansion={enableExpansion}
|
enableExpansion={enableExpansion}
|
||||||
enableNavigation={enableNavigation}
|
enableNavigation={enableNavigation}
|
||||||
imageAsLink={imageAsLink}
|
imageAsLink={imageAsLink}
|
||||||
|
imageFetchPriority={imageFetchPriority}
|
||||||
imageUrl={imageUrl}
|
imageUrl={imageUrl}
|
||||||
internalState={internalState}
|
internalState={internalState}
|
||||||
isRound={isRound}
|
isRound={isRound}
|
||||||
@@ -157,6 +162,7 @@ export interface ItemCardDerivativeProps extends Omit<ItemCardProps, 'type'> {
|
|||||||
enableExpansion?: boolean;
|
enableExpansion?: boolean;
|
||||||
enableNavigation?: boolean;
|
enableNavigation?: boolean;
|
||||||
imageAsLink?: boolean;
|
imageAsLink?: boolean;
|
||||||
|
imageFetchPriority?: 'auto' | 'high' | 'low';
|
||||||
imageUrl: string | undefined;
|
imageUrl: string | undefined;
|
||||||
internalState?: ItemListStateActions;
|
internalState?: ItemListStateActions;
|
||||||
rows: DataRow[];
|
rows: DataRow[];
|
||||||
@@ -171,6 +177,7 @@ const CompactItemCard = ({
|
|||||||
enableMultiSelect,
|
enableMultiSelect,
|
||||||
enableNavigation,
|
enableNavigation,
|
||||||
imageAsLink,
|
imageAsLink,
|
||||||
|
imageFetchPriority,
|
||||||
internalState,
|
internalState,
|
||||||
isRound,
|
isRound,
|
||||||
itemType,
|
itemType,
|
||||||
@@ -365,6 +372,7 @@ const CompactItemCard = ({
|
|||||||
explicitStatus={
|
explicitStatus={
|
||||||
'explicitStatus' in data && data ? data.explicitStatus : null
|
'explicitStatus' in data && data ? data.explicitStatus : null
|
||||||
}
|
}
|
||||||
|
fetchPriority={imageFetchPriority}
|
||||||
id={data?.imageId}
|
id={data?.imageId}
|
||||||
itemType={itemType}
|
itemType={itemType}
|
||||||
src={(data as Album | AlbumArtist | Playlist | Song)?.imageUrl}
|
src={(data as Album | AlbumArtist | Playlist | Song)?.imageUrl}
|
||||||
@@ -475,6 +483,7 @@ const DefaultItemCard = ({
|
|||||||
enableExpansion,
|
enableExpansion,
|
||||||
enableNavigation,
|
enableNavigation,
|
||||||
imageAsLink,
|
imageAsLink,
|
||||||
|
imageFetchPriority,
|
||||||
internalState,
|
internalState,
|
||||||
isRound,
|
isRound,
|
||||||
itemType,
|
itemType,
|
||||||
@@ -602,6 +611,7 @@ const DefaultItemCard = ({
|
|||||||
explicitStatus={
|
explicitStatus={
|
||||||
'explicitStatus' in data && data ? data.explicitStatus : null
|
'explicitStatus' in data && data ? data.explicitStatus : null
|
||||||
}
|
}
|
||||||
|
fetchPriority={imageFetchPriority}
|
||||||
id={data?.imageId}
|
id={data?.imageId}
|
||||||
itemType={itemType}
|
itemType={itemType}
|
||||||
src={(data as Album | AlbumArtist | Playlist | Song)?.imageUrl}
|
src={(data as Album | AlbumArtist | Playlist | Song)?.imageUrl}
|
||||||
@@ -710,6 +720,7 @@ const PosterItemCard = ({
|
|||||||
enableMultiSelect,
|
enableMultiSelect,
|
||||||
enableNavigation,
|
enableNavigation,
|
||||||
imageAsLink,
|
imageAsLink,
|
||||||
|
imageFetchPriority,
|
||||||
internalState,
|
internalState,
|
||||||
isRound,
|
isRound,
|
||||||
itemType,
|
itemType,
|
||||||
@@ -902,6 +913,7 @@ const PosterItemCard = ({
|
|||||||
explicitStatus={
|
explicitStatus={
|
||||||
'explicitStatus' in data && data ? data.explicitStatus : null
|
'explicitStatus' in data && data ? data.explicitStatus : null
|
||||||
}
|
}
|
||||||
|
fetchPriority={imageFetchPriority}
|
||||||
id={(data as { imageId: string })?.imageId}
|
id={(data as { imageId: string })?.imageId}
|
||||||
itemType={itemType}
|
itemType={itemType}
|
||||||
src={(data as { imageUrl: string })?.imageUrl}
|
src={(data as { imageUrl: string })?.imageUrl}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import React, {
|
|||||||
memo,
|
memo,
|
||||||
ReactElement,
|
ReactElement,
|
||||||
Ref,
|
Ref,
|
||||||
|
RefObject,
|
||||||
useCallback,
|
useCallback,
|
||||||
useEffect,
|
useEffect,
|
||||||
useId,
|
useId,
|
||||||
@@ -723,9 +724,21 @@ const VirtualizedTableGrid = ({
|
|||||||
|
|
||||||
VirtualizedTableGrid.displayName = 'VirtualizedTableGrid';
|
VirtualizedTableGrid.displayName = 'VirtualizedTableGrid';
|
||||||
|
|
||||||
|
function shallowEqualNumberArrays(a: number[], b: number[]): boolean {
|
||||||
|
if (a === b) return true;
|
||||||
|
if (a.length !== b.length) return false;
|
||||||
|
for (let i = 0; i < a.length; i++) {
|
||||||
|
if (a[i] !== b[i]) return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
const MemoizedVirtualizedTableGrid = memo(VirtualizedTableGrid, (prevProps, nextProps) => {
|
const MemoizedVirtualizedTableGrid = memo(VirtualizedTableGrid, (prevProps, nextProps) => {
|
||||||
return (
|
return (
|
||||||
prevProps.calculatedColumnWidths === nextProps.calculatedColumnWidths &&
|
shallowEqualNumberArrays(
|
||||||
|
prevProps.calculatedColumnWidths,
|
||||||
|
nextProps.calculatedColumnWidths,
|
||||||
|
) &&
|
||||||
prevProps.cellPadding === nextProps.cellPadding &&
|
prevProps.cellPadding === nextProps.cellPadding &&
|
||||||
prevProps.controls === nextProps.controls &&
|
prevProps.controls === nextProps.controls &&
|
||||||
prevProps.data === nextProps.data &&
|
prevProps.data === nextProps.data &&
|
||||||
@@ -741,6 +754,7 @@ const MemoizedVirtualizedTableGrid = memo(VirtualizedTableGrid, (prevProps, next
|
|||||||
prevProps.enableScrollShadow === nextProps.enableScrollShadow &&
|
prevProps.enableScrollShadow === nextProps.enableScrollShadow &&
|
||||||
prevProps.enableSelection === nextProps.enableSelection &&
|
prevProps.enableSelection === nextProps.enableSelection &&
|
||||||
prevProps.enableVerticalBorders === nextProps.enableVerticalBorders &&
|
prevProps.enableVerticalBorders === nextProps.enableVerticalBorders &&
|
||||||
|
prevProps.getItem === nextProps.getItem &&
|
||||||
prevProps.getRowHeight === nextProps.getRowHeight &&
|
prevProps.getRowHeight === nextProps.getRowHeight &&
|
||||||
prevProps.groups === nextProps.groups &&
|
prevProps.groups === nextProps.groups &&
|
||||||
prevProps.headerHeight === nextProps.headerHeight &&
|
prevProps.headerHeight === nextProps.headerHeight &&
|
||||||
@@ -867,6 +881,396 @@ interface ItemTableListProps {
|
|||||||
startRowIndex?: number;
|
startRowIndex?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const ItemTableListStickyUI = memo(
|
||||||
|
({
|
||||||
|
calculatedColumnWidths,
|
||||||
|
CellComponent,
|
||||||
|
containerRef,
|
||||||
|
data,
|
||||||
|
enableHeader,
|
||||||
|
enableStickyGroupRows,
|
||||||
|
enableStickyHeader,
|
||||||
|
getRowHeightWrapper,
|
||||||
|
groups,
|
||||||
|
headerHeight,
|
||||||
|
internalState,
|
||||||
|
parsedColumns,
|
||||||
|
pinnedLeftColumnCount,
|
||||||
|
pinnedLeftColumnRef,
|
||||||
|
pinnedRightColumnCount,
|
||||||
|
pinnedRightColumnRef,
|
||||||
|
pinnedRowRef,
|
||||||
|
rowHeight,
|
||||||
|
rowRef,
|
||||||
|
size,
|
||||||
|
stickyHeaderItemProps,
|
||||||
|
totalColumnCount,
|
||||||
|
}: {
|
||||||
|
calculatedColumnWidths: number[];
|
||||||
|
CellComponent: JSXElementConstructor<CellComponentProps<TableItemProps>>;
|
||||||
|
containerRef: RefObject<HTMLDivElement | null>;
|
||||||
|
data: unknown[];
|
||||||
|
enableHeader: boolean;
|
||||||
|
enableStickyGroupRows: boolean;
|
||||||
|
enableStickyHeader: boolean;
|
||||||
|
getRowHeightWrapper: (index: number) => number;
|
||||||
|
groups?: TableGroupHeader[];
|
||||||
|
headerHeight: number;
|
||||||
|
internalState: ItemListStateActions;
|
||||||
|
parsedColumns: ReturnType<typeof parseTableColumns>;
|
||||||
|
pinnedLeftColumnCount: number;
|
||||||
|
pinnedLeftColumnRef: RefObject<HTMLDivElement | null>;
|
||||||
|
pinnedRightColumnCount: number;
|
||||||
|
pinnedRightColumnRef: RefObject<HTMLDivElement | null>;
|
||||||
|
pinnedRowRef: RefObject<HTMLDivElement | null>;
|
||||||
|
rowHeight?: ((index: number, cellProps: TableItemProps) => number) | number;
|
||||||
|
rowRef: RefObject<HTMLDivElement | null>;
|
||||||
|
size: 'compact' | 'default' | 'large';
|
||||||
|
stickyHeaderItemProps: TableItemProps;
|
||||||
|
totalColumnCount: number;
|
||||||
|
}) => {
|
||||||
|
const stickyHeaderRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const stickyGroupRowRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const stickyHeaderLeftRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const stickyHeaderMainRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const stickyHeaderRightRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
|
const { shouldShowStickyHeader, stickyTop } = useStickyTableHeader({
|
||||||
|
containerRef,
|
||||||
|
enabled: enableHeader && enableStickyHeader,
|
||||||
|
headerRef: pinnedRowRef,
|
||||||
|
mainGridRef: rowRef,
|
||||||
|
pinnedLeftColumnRef,
|
||||||
|
pinnedRightColumnRef,
|
||||||
|
stickyHeaderMainRef,
|
||||||
|
});
|
||||||
|
|
||||||
|
useStickyHeaderPositioning({
|
||||||
|
containerRef,
|
||||||
|
shouldShowStickyHeader,
|
||||||
|
stickyHeaderRef,
|
||||||
|
});
|
||||||
|
|
||||||
|
const {
|
||||||
|
shouldShowStickyGroupRow,
|
||||||
|
stickyGroupIndex,
|
||||||
|
stickyTop: stickyGroupTop,
|
||||||
|
} = useStickyTableGroupRows({
|
||||||
|
containerRef,
|
||||||
|
enabled: enableStickyGroupRows && !!groups && groups.length > 0,
|
||||||
|
getRowHeight: getRowHeightWrapper,
|
||||||
|
groups,
|
||||||
|
headerHeight,
|
||||||
|
mainGridRef: rowRef,
|
||||||
|
shouldShowStickyHeader,
|
||||||
|
stickyHeaderTop: stickyTop,
|
||||||
|
});
|
||||||
|
|
||||||
|
const shouldRenderStickyGroupRow = shouldShowStickyGroupRow;
|
||||||
|
|
||||||
|
useStickyGroupRowPositioning({
|
||||||
|
containerRef,
|
||||||
|
shouldRenderStickyGroupRow,
|
||||||
|
stickyGroupRowRef,
|
||||||
|
});
|
||||||
|
|
||||||
|
const StickyHeader = useMemo(() => {
|
||||||
|
if (!shouldShowStickyHeader || !enableHeader) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pinnedLeftWidth = calculatedColumnWidths
|
||||||
|
.slice(0, pinnedLeftColumnCount)
|
||||||
|
.reduce((sum, width) => sum + width, 0);
|
||||||
|
const mainWidth = calculatedColumnWidths
|
||||||
|
.slice(pinnedLeftColumnCount, pinnedLeftColumnCount + totalColumnCount)
|
||||||
|
.reduce((sum, width) => sum + width, 0);
|
||||||
|
const pinnedRightWidth = calculatedColumnWidths
|
||||||
|
.slice(pinnedLeftColumnCount + totalColumnCount)
|
||||||
|
.reduce((sum, width) => sum + width, 0);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={styles.stickyHeader}
|
||||||
|
ref={stickyHeaderRef}
|
||||||
|
style={{
|
||||||
|
top: `${stickyTop}px`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className={styles.stickyHeaderRow}>
|
||||||
|
{pinnedLeftColumnCount > 0 && (
|
||||||
|
<div
|
||||||
|
className={clsx(
|
||||||
|
styles.stickyHeaderSection,
|
||||||
|
styles.stickyHeaderPinnedLeft,
|
||||||
|
)}
|
||||||
|
ref={stickyHeaderLeftRef}
|
||||||
|
style={{
|
||||||
|
flex: '0 1 auto',
|
||||||
|
minWidth: `${pinnedLeftWidth}px`,
|
||||||
|
overflow: 'hidden',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{parsedColumns
|
||||||
|
.filter((col) => col.pinned === 'left')
|
||||||
|
.map((col) => {
|
||||||
|
const columnIndex = parsedColumns.findIndex(
|
||||||
|
(c) => c === col,
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<CellComponent
|
||||||
|
ariaAttributes={{
|
||||||
|
'aria-colindex': columnIndex + 1,
|
||||||
|
role: 'gridcell',
|
||||||
|
}}
|
||||||
|
columnIndex={columnIndex}
|
||||||
|
key={col.id}
|
||||||
|
rowIndex={0}
|
||||||
|
style={{
|
||||||
|
height: headerHeight,
|
||||||
|
width: calculatedColumnWidths[columnIndex],
|
||||||
|
}}
|
||||||
|
{...stickyHeaderItemProps}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div
|
||||||
|
className={clsx(
|
||||||
|
styles.stickyHeaderSection,
|
||||||
|
styles.stickyHeaderMain,
|
||||||
|
styles.noScrollbar,
|
||||||
|
)}
|
||||||
|
ref={stickyHeaderMainRef}
|
||||||
|
style={{
|
||||||
|
flex: '1 1 auto',
|
||||||
|
minWidth: 0,
|
||||||
|
overflowX: 'auto',
|
||||||
|
overflowY: 'hidden',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
minWidth: `${mainWidth}px`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{parsedColumns
|
||||||
|
.filter((col) => col.pinned === null)
|
||||||
|
.map((col) => {
|
||||||
|
const columnIndex = parsedColumns.findIndex(
|
||||||
|
(c) => c === col,
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<CellComponent
|
||||||
|
ariaAttributes={{
|
||||||
|
'aria-colindex': columnIndex + 1,
|
||||||
|
role: 'gridcell',
|
||||||
|
}}
|
||||||
|
columnIndex={columnIndex}
|
||||||
|
key={col.id}
|
||||||
|
rowIndex={0}
|
||||||
|
style={{
|
||||||
|
flexShrink: 0,
|
||||||
|
height: headerHeight,
|
||||||
|
width: calculatedColumnWidths[columnIndex],
|
||||||
|
}}
|
||||||
|
{...stickyHeaderItemProps}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{pinnedRightColumnCount > 0 && (
|
||||||
|
<div
|
||||||
|
className={clsx(
|
||||||
|
styles.stickyHeaderSection,
|
||||||
|
styles.stickyHeaderPinnedRight,
|
||||||
|
)}
|
||||||
|
ref={stickyHeaderRightRef}
|
||||||
|
style={{
|
||||||
|
flex: '0 1 auto',
|
||||||
|
minWidth: `${pinnedRightWidth}px`,
|
||||||
|
overflow: 'hidden',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{parsedColumns
|
||||||
|
.filter((col) => col.pinned === 'right')
|
||||||
|
.map((col) => {
|
||||||
|
const columnIndex = parsedColumns.findIndex(
|
||||||
|
(c) => c === col,
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<CellComponent
|
||||||
|
ariaAttributes={{
|
||||||
|
'aria-colindex': columnIndex + 1,
|
||||||
|
role: 'gridcell',
|
||||||
|
}}
|
||||||
|
columnIndex={columnIndex}
|
||||||
|
key={col.id}
|
||||||
|
rowIndex={0}
|
||||||
|
style={{
|
||||||
|
height: headerHeight,
|
||||||
|
width: calculatedColumnWidths[columnIndex],
|
||||||
|
}}
|
||||||
|
{...stickyHeaderItemProps}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}, [
|
||||||
|
shouldShowStickyHeader,
|
||||||
|
enableHeader,
|
||||||
|
stickyTop,
|
||||||
|
calculatedColumnWidths,
|
||||||
|
pinnedLeftColumnCount,
|
||||||
|
pinnedRightColumnCount,
|
||||||
|
totalColumnCount,
|
||||||
|
parsedColumns,
|
||||||
|
headerHeight,
|
||||||
|
CellComponent,
|
||||||
|
stickyHeaderItemProps,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const groupRowHeight = useMemo(() => {
|
||||||
|
if (stickyGroupIndex === null || !groups) {
|
||||||
|
const height = size === 'compact' ? 40 : size === 'large' ? 88 : 64;
|
||||||
|
return typeof rowHeight === 'number' ? rowHeight : height;
|
||||||
|
}
|
||||||
|
|
||||||
|
let cumulativeDataIndex = 0;
|
||||||
|
const headerOffset = enableHeader ? 1 : 0;
|
||||||
|
for (let i = 0; i < stickyGroupIndex; i++) {
|
||||||
|
cumulativeDataIndex += groups[i].itemCount;
|
||||||
|
}
|
||||||
|
const groupHeaderIndex = headerOffset + cumulativeDataIndex + stickyGroupIndex;
|
||||||
|
|
||||||
|
return getRowHeightWrapper(groupHeaderIndex);
|
||||||
|
}, [stickyGroupIndex, groups, getRowHeightWrapper, enableHeader, rowHeight, size]);
|
||||||
|
|
||||||
|
const StickyGroupRow = useMemo(() => {
|
||||||
|
if (!shouldRenderStickyGroupRow || stickyGroupIndex === null || !groups) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const group = groups[stickyGroupIndex];
|
||||||
|
const originalData = data.filter((item) => item !== null);
|
||||||
|
let cumulativeDataIndex = 0;
|
||||||
|
for (let i = 0; i < stickyGroupIndex; i++) {
|
||||||
|
cumulativeDataIndex += groups[i].itemCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
const groupContent = group.render({
|
||||||
|
data: originalData,
|
||||||
|
groupIndex: stickyGroupIndex,
|
||||||
|
index: 0,
|
||||||
|
internalState,
|
||||||
|
startDataIndex: cumulativeDataIndex,
|
||||||
|
});
|
||||||
|
|
||||||
|
const pinnedLeftWidth = calculatedColumnWidths
|
||||||
|
.slice(0, pinnedLeftColumnCount)
|
||||||
|
.reduce((sum, width) => sum + width, 0);
|
||||||
|
const mainWidth = calculatedColumnWidths
|
||||||
|
.slice(pinnedLeftColumnCount, pinnedLeftColumnCount + totalColumnCount)
|
||||||
|
.reduce((sum, width) => sum + width, 0);
|
||||||
|
const pinnedRightWidth = calculatedColumnWidths
|
||||||
|
.slice(pinnedLeftColumnCount + totalColumnCount)
|
||||||
|
.reduce((sum, width) => sum + width, 0);
|
||||||
|
|
||||||
|
const totalTableWidth = calculatedColumnWidths.reduce((sum, width) => sum + width, 0);
|
||||||
|
const actualStickyTop = stickyGroupTop;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={styles.stickyGroupRow}
|
||||||
|
ref={stickyGroupRowRef}
|
||||||
|
style={{
|
||||||
|
top: `${actualStickyTop}px`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className={styles.stickyGroupRowContent}>
|
||||||
|
{pinnedLeftColumnCount > 0 && (
|
||||||
|
<div
|
||||||
|
className={styles.stickyGroupRowSection}
|
||||||
|
style={{ width: `${pinnedLeftWidth}px` }}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
height: groupRowHeight,
|
||||||
|
width: `${pinnedLeftWidth}px`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{groupContent}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div
|
||||||
|
className={styles.stickyGroupRowSection}
|
||||||
|
style={{
|
||||||
|
marginLeft: pinnedLeftColumnCount > 0 ? 0 : '-2rem',
|
||||||
|
marginRight: '-2rem',
|
||||||
|
paddingLeft: pinnedLeftColumnCount > 0 ? 0 : '2rem',
|
||||||
|
paddingRight: '2rem',
|
||||||
|
width: `${mainWidth}px`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
height: groupRowHeight,
|
||||||
|
marginLeft: pinnedLeftWidth > 0 ? `-${pinnedLeftWidth}px` : 0,
|
||||||
|
width: `${totalTableWidth}px`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{groupContent}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{pinnedRightColumnCount > 0 && (
|
||||||
|
<div
|
||||||
|
className={styles.stickyGroupRowSection}
|
||||||
|
style={{ width: `${pinnedRightWidth}px` }}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
height: groupRowHeight,
|
||||||
|
width: `${pinnedRightWidth}px`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}, [
|
||||||
|
shouldRenderStickyGroupRow,
|
||||||
|
stickyGroupIndex,
|
||||||
|
groups,
|
||||||
|
data,
|
||||||
|
internalState,
|
||||||
|
calculatedColumnWidths,
|
||||||
|
pinnedLeftColumnCount,
|
||||||
|
pinnedRightColumnCount,
|
||||||
|
totalColumnCount,
|
||||||
|
groupRowHeight,
|
||||||
|
stickyGroupTop,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{StickyHeader}
|
||||||
|
{StickyGroupRow}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
ItemTableListStickyUI.displayName = 'ItemTableListStickyUI';
|
||||||
|
|
||||||
const BaseItemTableList = ({
|
const BaseItemTableList = ({
|
||||||
activeRowId,
|
activeRowId,
|
||||||
autoFitColumns = false,
|
autoFitColumns = false,
|
||||||
@@ -966,28 +1370,6 @@ const BaseItemTableList = ({
|
|||||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||||
const mergedContainerRef = useMergedRef(containerRef, focusRef);
|
const mergedContainerRef = useMergedRef(containerRef, focusRef);
|
||||||
|
|
||||||
const stickyHeaderRef = useRef<HTMLDivElement | null>(null);
|
|
||||||
const stickyGroupRowRef = useRef<HTMLDivElement | null>(null);
|
|
||||||
const stickyHeaderLeftRef = useRef<HTMLDivElement | null>(null);
|
|
||||||
const stickyHeaderMainRef = useRef<HTMLDivElement | null>(null);
|
|
||||||
const stickyHeaderRightRef = useRef<HTMLDivElement | null>(null);
|
|
||||||
|
|
||||||
const { shouldShowStickyHeader, stickyTop } = useStickyTableHeader({
|
|
||||||
containerRef: containerRef,
|
|
||||||
enabled: enableHeader && enableStickyHeader,
|
|
||||||
headerRef: pinnedRowRef,
|
|
||||||
mainGridRef: rowRef,
|
|
||||||
pinnedLeftColumnRef,
|
|
||||||
pinnedRightColumnRef,
|
|
||||||
stickyHeaderMainRef,
|
|
||||||
});
|
|
||||||
|
|
||||||
useStickyHeaderPositioning({
|
|
||||||
containerRef,
|
|
||||||
shouldShowStickyHeader,
|
|
||||||
stickyHeaderRef,
|
|
||||||
});
|
|
||||||
|
|
||||||
useContainerWidthTracking({
|
useContainerWidthTracking({
|
||||||
autoFitColumns,
|
autoFitColumns,
|
||||||
containerRef,
|
containerRef,
|
||||||
@@ -1089,30 +1471,6 @@ const BaseItemTableList = ({
|
|||||||
[enableHeader, headerHeight, rowHeight, pinnedRowCount, size],
|
[enableHeader, headerHeight, rowHeight, pinnedRowCount, size],
|
||||||
);
|
);
|
||||||
|
|
||||||
const {
|
|
||||||
shouldShowStickyGroupRow,
|
|
||||||
stickyGroupIndex,
|
|
||||||
stickyTop: stickyGroupTop,
|
|
||||||
} = useStickyTableGroupRows({
|
|
||||||
containerRef: containerRef,
|
|
||||||
enabled: enableStickyGroupRows && !!groups && groups.length > 0,
|
|
||||||
getRowHeight: getRowHeightWrapper,
|
|
||||||
groups,
|
|
||||||
headerHeight,
|
|
||||||
mainGridRef: rowRef,
|
|
||||||
shouldShowStickyHeader,
|
|
||||||
stickyHeaderTop: stickyTop,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Show sticky group row whenever it should be shown
|
|
||||||
const shouldRenderStickyGroupRow = shouldShowStickyGroupRow;
|
|
||||||
|
|
||||||
useStickyGroupRowPositioning({
|
|
||||||
containerRef,
|
|
||||||
shouldRenderStickyGroupRow,
|
|
||||||
stickyGroupRowRef,
|
|
||||||
});
|
|
||||||
|
|
||||||
const getDataFn = useCallback(() => {
|
const getDataFn = useCallback(() => {
|
||||||
return data;
|
return data;
|
||||||
}, [data]);
|
}, [data]);
|
||||||
@@ -1247,291 +1605,6 @@ const BaseItemTableList = ({
|
|||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
const StickyHeader = useMemo(() => {
|
|
||||||
if (!shouldShowStickyHeader || !enableHeader) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const pinnedLeftWidth = calculatedColumnWidths
|
|
||||||
.slice(0, pinnedLeftColumnCount)
|
|
||||||
.reduce((sum, width) => sum + width, 0);
|
|
||||||
const mainWidth = calculatedColumnWidths
|
|
||||||
.slice(pinnedLeftColumnCount, pinnedLeftColumnCount + totalColumnCount)
|
|
||||||
.reduce((sum, width) => sum + width, 0);
|
|
||||||
const pinnedRightWidth = calculatedColumnWidths
|
|
||||||
.slice(pinnedLeftColumnCount + totalColumnCount)
|
|
||||||
.reduce((sum, width) => sum + width, 0);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={styles.stickyHeader}
|
|
||||||
ref={stickyHeaderRef}
|
|
||||||
style={{
|
|
||||||
top: `${stickyTop}px`,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className={styles.stickyHeaderRow}>
|
|
||||||
{pinnedLeftColumnCount > 0 && (
|
|
||||||
<div
|
|
||||||
className={clsx(
|
|
||||||
styles.stickyHeaderSection,
|
|
||||||
styles.stickyHeaderPinnedLeft,
|
|
||||||
)}
|
|
||||||
ref={stickyHeaderLeftRef}
|
|
||||||
style={{
|
|
||||||
flex: '0 1 auto',
|
|
||||||
minWidth: `${pinnedLeftWidth}px`,
|
|
||||||
overflow: 'hidden',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{parsedColumns
|
|
||||||
.filter((col) => col.pinned === 'left')
|
|
||||||
.map((col) => {
|
|
||||||
const columnIndex = parsedColumns.findIndex((c) => c === col);
|
|
||||||
return (
|
|
||||||
<CellComponent
|
|
||||||
ariaAttributes={{
|
|
||||||
'aria-colindex': columnIndex + 1,
|
|
||||||
role: 'gridcell',
|
|
||||||
}}
|
|
||||||
columnIndex={columnIndex}
|
|
||||||
key={col.id}
|
|
||||||
rowIndex={0}
|
|
||||||
style={{
|
|
||||||
height: headerHeight,
|
|
||||||
width: calculatedColumnWidths[columnIndex],
|
|
||||||
}}
|
|
||||||
{...stickyHeaderItemProps}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div
|
|
||||||
className={clsx(
|
|
||||||
styles.stickyHeaderSection,
|
|
||||||
styles.stickyHeaderMain,
|
|
||||||
styles.noScrollbar,
|
|
||||||
)}
|
|
||||||
ref={stickyHeaderMainRef}
|
|
||||||
style={{
|
|
||||||
flex: '1 1 auto',
|
|
||||||
minWidth: 0,
|
|
||||||
overflowX: 'auto',
|
|
||||||
overflowY: 'hidden',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: 'flex',
|
|
||||||
minWidth: `${mainWidth}px`,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{parsedColumns
|
|
||||||
.filter((col) => col.pinned === null)
|
|
||||||
.map((col) => {
|
|
||||||
const columnIndex = parsedColumns.findIndex((c) => c === col);
|
|
||||||
return (
|
|
||||||
<CellComponent
|
|
||||||
ariaAttributes={{
|
|
||||||
'aria-colindex': columnIndex + 1,
|
|
||||||
role: 'gridcell',
|
|
||||||
}}
|
|
||||||
columnIndex={columnIndex}
|
|
||||||
key={col.id}
|
|
||||||
rowIndex={0}
|
|
||||||
style={{
|
|
||||||
flexShrink: 0,
|
|
||||||
height: headerHeight,
|
|
||||||
width: calculatedColumnWidths[columnIndex],
|
|
||||||
}}
|
|
||||||
{...stickyHeaderItemProps}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{pinnedRightColumnCount > 0 && (
|
|
||||||
<div
|
|
||||||
className={clsx(
|
|
||||||
styles.stickyHeaderSection,
|
|
||||||
styles.stickyHeaderPinnedRight,
|
|
||||||
)}
|
|
||||||
ref={stickyHeaderRightRef}
|
|
||||||
style={{
|
|
||||||
flex: '0 1 auto',
|
|
||||||
minWidth: `${pinnedRightWidth}px`,
|
|
||||||
overflow: 'hidden',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{parsedColumns
|
|
||||||
.filter((col) => col.pinned === 'right')
|
|
||||||
.map((col) => {
|
|
||||||
const columnIndex = parsedColumns.findIndex((c) => c === col);
|
|
||||||
return (
|
|
||||||
<CellComponent
|
|
||||||
ariaAttributes={{
|
|
||||||
'aria-colindex': columnIndex + 1,
|
|
||||||
role: 'gridcell',
|
|
||||||
}}
|
|
||||||
columnIndex={columnIndex}
|
|
||||||
key={col.id}
|
|
||||||
rowIndex={0}
|
|
||||||
style={{
|
|
||||||
height: headerHeight,
|
|
||||||
width: calculatedColumnWidths[columnIndex],
|
|
||||||
}}
|
|
||||||
{...stickyHeaderItemProps}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}, [
|
|
||||||
shouldShowStickyHeader,
|
|
||||||
enableHeader,
|
|
||||||
stickyTop,
|
|
||||||
calculatedColumnWidths,
|
|
||||||
pinnedLeftColumnCount,
|
|
||||||
pinnedRightColumnCount,
|
|
||||||
totalColumnCount,
|
|
||||||
parsedColumns,
|
|
||||||
headerHeight,
|
|
||||||
CellComponent,
|
|
||||||
stickyHeaderItemProps,
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Calculate group row height (use same as regular table row height)
|
|
||||||
const groupRowHeight = useMemo(() => {
|
|
||||||
if (stickyGroupIndex === null || !groups) {
|
|
||||||
const height = size === 'compact' ? 40 : size === 'large' ? 88 : 64;
|
|
||||||
return typeof rowHeight === 'number' ? rowHeight : height;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Calculate the row index for this group header
|
|
||||||
let cumulativeDataIndex = 0;
|
|
||||||
const headerOffset = enableHeader ? 1 : 0;
|
|
||||||
for (let i = 0; i < stickyGroupIndex; i++) {
|
|
||||||
cumulativeDataIndex += groups[i].itemCount;
|
|
||||||
}
|
|
||||||
const groupHeaderIndex = headerOffset + cumulativeDataIndex + stickyGroupIndex;
|
|
||||||
|
|
||||||
// Use the regular row height for group rows
|
|
||||||
return getRowHeightWrapper(groupHeaderIndex);
|
|
||||||
}, [stickyGroupIndex, groups, getRowHeightWrapper, enableHeader, rowHeight, size]);
|
|
||||||
|
|
||||||
const StickyGroupRow = useMemo(() => {
|
|
||||||
if (!shouldRenderStickyGroupRow || stickyGroupIndex === null || !groups) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const group = groups[stickyGroupIndex];
|
|
||||||
const originalData = data.filter((item) => item !== null);
|
|
||||||
let cumulativeDataIndex = 0;
|
|
||||||
for (let i = 0; i < stickyGroupIndex; i++) {
|
|
||||||
cumulativeDataIndex += groups[i].itemCount;
|
|
||||||
}
|
|
||||||
|
|
||||||
const groupContent = group.render({
|
|
||||||
data: originalData,
|
|
||||||
groupIndex: stickyGroupIndex,
|
|
||||||
index: 0,
|
|
||||||
internalState,
|
|
||||||
startDataIndex: cumulativeDataIndex,
|
|
||||||
});
|
|
||||||
|
|
||||||
const pinnedLeftWidth = calculatedColumnWidths
|
|
||||||
.slice(0, pinnedLeftColumnCount)
|
|
||||||
.reduce((sum, width) => sum + width, 0);
|
|
||||||
const mainWidth = calculatedColumnWidths
|
|
||||||
.slice(pinnedLeftColumnCount, pinnedLeftColumnCount + totalColumnCount)
|
|
||||||
.reduce((sum, width) => sum + width, 0);
|
|
||||||
const pinnedRightWidth = calculatedColumnWidths
|
|
||||||
.slice(pinnedLeftColumnCount + totalColumnCount)
|
|
||||||
.reduce((sum, width) => sum + width, 0);
|
|
||||||
|
|
||||||
const totalTableWidth = calculatedColumnWidths.reduce((sum, width) => sum + width, 0);
|
|
||||||
|
|
||||||
// Calculate the actual sticky position accounting for sticky header
|
|
||||||
const actualStickyTop = stickyGroupTop;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={styles.stickyGroupRow}
|
|
||||||
ref={stickyGroupRowRef}
|
|
||||||
style={{
|
|
||||||
top: `${actualStickyTop}px`,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className={styles.stickyGroupRowContent}>
|
|
||||||
{pinnedLeftColumnCount > 0 && (
|
|
||||||
<div
|
|
||||||
className={styles.stickyGroupRowSection}
|
|
||||||
style={{ width: `${pinnedLeftWidth}px` }}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
height: groupRowHeight,
|
|
||||||
width: `${pinnedLeftWidth}px`,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{groupContent}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div
|
|
||||||
className={styles.stickyGroupRowSection}
|
|
||||||
style={{
|
|
||||||
marginLeft: pinnedLeftColumnCount > 0 ? 0 : '-2rem',
|
|
||||||
marginRight: '-2rem',
|
|
||||||
paddingLeft: pinnedLeftColumnCount > 0 ? 0 : '2rem',
|
|
||||||
paddingRight: '2rem',
|
|
||||||
width: `${mainWidth}px`,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
height: groupRowHeight,
|
|
||||||
marginLeft: pinnedLeftWidth > 0 ? `-${pinnedLeftWidth}px` : 0,
|
|
||||||
width: `${totalTableWidth}px`,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{groupContent}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{pinnedRightColumnCount > 0 && (
|
|
||||||
<div
|
|
||||||
className={styles.stickyGroupRowSection}
|
|
||||||
style={{ width: `${pinnedRightWidth}px` }}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
height: groupRowHeight,
|
|
||||||
width: `${pinnedRightWidth}px`,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}, [
|
|
||||||
shouldRenderStickyGroupRow,
|
|
||||||
stickyGroupIndex,
|
|
||||||
groups,
|
|
||||||
data,
|
|
||||||
internalState,
|
|
||||||
calculatedColumnWidths,
|
|
||||||
pinnedLeftColumnCount,
|
|
||||||
pinnedRightColumnCount,
|
|
||||||
totalColumnCount,
|
|
||||||
groupRowHeight,
|
|
||||||
stickyGroupTop,
|
|
||||||
]);
|
|
||||||
|
|
||||||
useListHotkeys({
|
useListHotkeys({
|
||||||
controls,
|
controls,
|
||||||
focused,
|
focused,
|
||||||
@@ -1607,8 +1680,30 @@ const BaseItemTableList = ({
|
|||||||
{...animationProps.fadeIn}
|
{...animationProps.fadeIn}
|
||||||
transition={{ duration: enableEntranceAnimation ? 0.3 : 0, ease: 'anticipate' }}
|
transition={{ duration: enableEntranceAnimation ? 0.3 : 0, ease: 'anticipate' }}
|
||||||
>
|
>
|
||||||
{StickyHeader}
|
<ItemTableListStickyUI
|
||||||
{StickyGroupRow}
|
calculatedColumnWidths={calculatedColumnWidths}
|
||||||
|
CellComponent={optimizedCellComponent}
|
||||||
|
containerRef={containerRef}
|
||||||
|
data={data}
|
||||||
|
enableHeader={!!enableHeader}
|
||||||
|
enableStickyGroupRows={!!enableStickyGroupRows}
|
||||||
|
enableStickyHeader={!!enableStickyHeader}
|
||||||
|
getRowHeightWrapper={getRowHeightWrapper}
|
||||||
|
groups={groups}
|
||||||
|
headerHeight={headerHeight}
|
||||||
|
internalState={internalState}
|
||||||
|
parsedColumns={parsedColumns}
|
||||||
|
pinnedLeftColumnCount={pinnedLeftColumnCount}
|
||||||
|
pinnedLeftColumnRef={pinnedLeftColumnRef}
|
||||||
|
pinnedRightColumnCount={pinnedRightColumnCount}
|
||||||
|
pinnedRightColumnRef={pinnedRightColumnRef}
|
||||||
|
pinnedRowRef={pinnedRowRef}
|
||||||
|
rowHeight={rowHeight}
|
||||||
|
rowRef={rowRef}
|
||||||
|
size={size}
|
||||||
|
stickyHeaderItemProps={stickyHeaderItemProps}
|
||||||
|
totalColumnCount={totalColumnCount}
|
||||||
|
/>
|
||||||
<MemoizedVirtualizedTableGrid
|
<MemoizedVirtualizedTableGrid
|
||||||
calculatedColumnWidths={calculatedColumnWidths}
|
calculatedColumnWidths={calculatedColumnWidths}
|
||||||
CellComponent={optimizedCellComponent}
|
CellComponent={optimizedCellComponent}
|
||||||
|
|||||||
@@ -94,6 +94,7 @@ interface AlbumMetadataTagsProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const MOOD_TAG = 'mood';
|
const MOOD_TAG = 'mood';
|
||||||
|
const GROUPING_TAG = 'grouping';
|
||||||
const RELEASE_COUNTRY_TAG = 'releasecountry';
|
const RELEASE_COUNTRY_TAG = 'releasecountry';
|
||||||
const RELEASE_STATUS_TAG = 'releasestatus';
|
const RELEASE_STATUS_TAG = 'releasestatus';
|
||||||
|
|
||||||
@@ -155,6 +156,30 @@ const AlbumMetadataTags = ({ album }: AlbumMetadataTagsProps) => {
|
|||||||
}));
|
}));
|
||||||
}, [album]);
|
}, [album]);
|
||||||
|
|
||||||
|
const groupingItems = useMemo(() => {
|
||||||
|
if (!album) return [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
album.tags?.[GROUPING_TAG]?.map((tag) => {
|
||||||
|
if (album._serverType !== ServerType.NAVIDROME) {
|
||||||
|
return { id: tag, label: tag, url: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
const searchParams = new URLSearchParams();
|
||||||
|
const paramsWithCustom = setJsonSearchParam(
|
||||||
|
searchParams,
|
||||||
|
FILTER_KEYS.ALBUM._CUSTOM,
|
||||||
|
{ grouping: [tag] },
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
id: tag,
|
||||||
|
label: tag,
|
||||||
|
url: `${AppRoute.LIBRARY_ALBUMS}?${paramsWithCustom.toString()}`,
|
||||||
|
};
|
||||||
|
}) ?? []
|
||||||
|
);
|
||||||
|
}, [album]);
|
||||||
|
|
||||||
const recordLabels = useMemo(() => {
|
const recordLabels = useMemo(() => {
|
||||||
if (!album?.recordLabels || album.recordLabels.length === 0) return [];
|
if (!album?.recordLabels || album.recordLabels.length === 0) return [];
|
||||||
|
|
||||||
@@ -221,6 +246,29 @@ const AlbumMetadataTags = ({ album }: AlbumMetadataTagsProps) => {
|
|||||||
items={moodTagItems}
|
items={moodTagItems}
|
||||||
title={t('common.mood', { postProcess: 'sentenceCase' })}
|
title={t('common.mood', { postProcess: 'sentenceCase' })}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{groupingItems.length > 0 && (
|
||||||
|
<Stack align="center" className={styles.metadataPillGroup} gap="xs">
|
||||||
|
<Text fw={600} isNoSelect size="sm" tt="uppercase">
|
||||||
|
{t('common.grouping', { postProcess: 'sentenceCase' })}
|
||||||
|
</Text>
|
||||||
|
<div className={styles['pill-group-wrapper']}>
|
||||||
|
<Pill.Group>
|
||||||
|
{groupingItems.map((item) =>
|
||||||
|
item.url ? (
|
||||||
|
<PillLink key={`grouping-${item.id}`} size="md" to={item.url}>
|
||||||
|
{item.label}
|
||||||
|
</PillLink>
|
||||||
|
) : (
|
||||||
|
<Pill key={`grouping-${item.id}`} size="md">
|
||||||
|
{item.label}
|
||||||
|
</Pill>
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
</Pill.Group>
|
||||||
|
</div>
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -296,21 +344,60 @@ interface AlbumMetadataExternalLinksProps {
|
|||||||
albumName?: string;
|
albumName?: string;
|
||||||
externalLinks: boolean;
|
externalLinks: boolean;
|
||||||
lastFM: boolean;
|
lastFM: boolean;
|
||||||
|
listenBrainz: boolean;
|
||||||
mbzId?: null | string;
|
mbzId?: null | string;
|
||||||
|
mbzReleaseGroupId?: null | string;
|
||||||
musicBrainz: boolean;
|
musicBrainz: boolean;
|
||||||
|
nativeSpotify: boolean;
|
||||||
|
qobuz: boolean;
|
||||||
|
spotify: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const getListenBrainzUrl = (
|
||||||
|
mbzReleaseGroupId: null | string,
|
||||||
|
albumArtist?: string,
|
||||||
|
albumName?: string,
|
||||||
|
) => {
|
||||||
|
if (mbzReleaseGroupId) {
|
||||||
|
return `https://listenbrainz.org/album/${mbzReleaseGroupId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (albumArtist || albumName) {
|
||||||
|
return `https://listenbrainz.org/search/?search_term=${encodeURIComponent([albumArtist, albumName].filter(Boolean).join(' ').trim())}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getQobuzUrl = (albumArtist?: string, albumName?: string) => {
|
||||||
|
if (albumArtist || albumName) {
|
||||||
|
return `https://www.qobuz.com/us-en/search/albums/${encodeURIComponent([albumArtist, albumName].filter(Boolean).join(' ').trim())}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
const AlbumMetadataExternalLinks = ({
|
const AlbumMetadataExternalLinks = ({
|
||||||
albumArtist,
|
albumArtist,
|
||||||
albumName,
|
albumName,
|
||||||
externalLinks,
|
externalLinks,
|
||||||
lastFM,
|
lastFM,
|
||||||
|
listenBrainz,
|
||||||
mbzId,
|
mbzId,
|
||||||
|
mbzReleaseGroupId,
|
||||||
musicBrainz,
|
musicBrainz,
|
||||||
|
nativeSpotify,
|
||||||
|
qobuz,
|
||||||
|
spotify,
|
||||||
}: AlbumMetadataExternalLinksProps) => {
|
}: AlbumMetadataExternalLinksProps) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
if (!externalLinks || (!lastFM && !musicBrainz)) return null;
|
const listenBrainzUrl = getListenBrainzUrl(mbzReleaseGroupId || null, albumArtist, albumName);
|
||||||
|
const qobuzUrl = getQobuzUrl(albumArtist, albumName);
|
||||||
|
|
||||||
|
if (!externalLinks || (!lastFM && !listenBrainz && !musicBrainz && !qobuz && !spotify)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack gap="xs">
|
<Stack gap="xs">
|
||||||
@@ -319,7 +406,7 @@ const AlbumMetadataExternalLinks = ({
|
|||||||
postProcess: 'sentenceCase',
|
postProcess: 'sentenceCase',
|
||||||
})}
|
})}
|
||||||
</Text>
|
</Text>
|
||||||
<Group className={styles.externalLinksGroup} gap="sm">
|
<Group className={styles.externalLinksGroup} gap="xs">
|
||||||
{lastFM && (
|
{lastFM && (
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
component="a"
|
component="a"
|
||||||
@@ -328,8 +415,7 @@ const AlbumMetadataExternalLinks = ({
|
|||||||
)}/${encodeURIComponent(albumName || '')}`}
|
)}/${encodeURIComponent(albumName || '')}`}
|
||||||
icon="brandLastfm"
|
icon="brandLastfm"
|
||||||
iconProps={{
|
iconProps={{
|
||||||
fill: 'default',
|
size: '2xl',
|
||||||
size: 'xl',
|
|
||||||
}}
|
}}
|
||||||
radius="md"
|
radius="md"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
@@ -346,8 +432,7 @@ const AlbumMetadataExternalLinks = ({
|
|||||||
href={`https://musicbrainz.org/release/${mbzId}`}
|
href={`https://musicbrainz.org/release/${mbzId}`}
|
||||||
icon="brandMusicBrainz"
|
icon="brandMusicBrainz"
|
||||||
iconProps={{
|
iconProps={{
|
||||||
fill: 'default',
|
size: '2xl',
|
||||||
size: 'xl',
|
|
||||||
}}
|
}}
|
||||||
radius="md"
|
radius="md"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
@@ -358,6 +443,61 @@ const AlbumMetadataExternalLinks = ({
|
|||||||
variant="subtle"
|
variant="subtle"
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
{listenBrainz && listenBrainzUrl && (
|
||||||
|
<ActionIcon
|
||||||
|
component="a"
|
||||||
|
href={listenBrainzUrl}
|
||||||
|
icon="brandListenBrainz"
|
||||||
|
iconProps={{
|
||||||
|
size: '2xl',
|
||||||
|
}}
|
||||||
|
radius="md"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
target="_blank"
|
||||||
|
tooltip={{
|
||||||
|
label: t('action.openIn.listenbrainz'),
|
||||||
|
}}
|
||||||
|
variant="subtle"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{qobuz && qobuzUrl && (
|
||||||
|
<ActionIcon
|
||||||
|
component="a"
|
||||||
|
href={qobuzUrl}
|
||||||
|
icon="brandQobuz"
|
||||||
|
iconProps={{
|
||||||
|
size: '2xl',
|
||||||
|
}}
|
||||||
|
radius="md"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
target="_blank"
|
||||||
|
tooltip={{
|
||||||
|
label: t('action.openIn.qobuz'),
|
||||||
|
}}
|
||||||
|
variant="subtle"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{spotify && (
|
||||||
|
<ActionIcon
|
||||||
|
component="a"
|
||||||
|
href={
|
||||||
|
nativeSpotify
|
||||||
|
? `spotify:search:${encodeURIComponent(albumArtist || '')}%20${encodeURIComponent(albumName || '')}`
|
||||||
|
: `https://open.spotify.com/search/${encodeURIComponent(albumArtist || '')}%20${encodeURIComponent(albumName || '')}`
|
||||||
|
}
|
||||||
|
icon="brandSpotify"
|
||||||
|
iconProps={{
|
||||||
|
size: '2xl',
|
||||||
|
}}
|
||||||
|
radius="md"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
target={nativeSpotify ? undefined : '_blank'}
|
||||||
|
tooltip={{
|
||||||
|
label: t('action.openIn.spotify'),
|
||||||
|
}}
|
||||||
|
variant="subtle"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</Group>
|
</Group>
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
@@ -370,7 +510,8 @@ export const AlbumDetailContent = () => {
|
|||||||
albumQueries.detail({ query: { id: albumId }, serverId: server.id }),
|
albumQueries.detail({ query: { id: albumId }, serverId: server.id }),
|
||||||
);
|
);
|
||||||
|
|
||||||
const { externalLinks, lastFM, musicBrainz } = useExternalLinks();
|
const { externalLinks, lastFM, listenBrainz, musicBrainz, nativeSpotify, qobuz, spotify } =
|
||||||
|
useExternalLinks();
|
||||||
|
|
||||||
const comment = detailQuery?.data?.comment;
|
const comment = detailQuery?.data?.comment;
|
||||||
|
|
||||||
@@ -401,8 +542,13 @@ export const AlbumDetailContent = () => {
|
|||||||
albumName={detailQuery?.data?.name}
|
albumName={detailQuery?.data?.name}
|
||||||
externalLinks={externalLinks}
|
externalLinks={externalLinks}
|
||||||
lastFM={lastFM}
|
lastFM={lastFM}
|
||||||
|
listenBrainz={listenBrainz}
|
||||||
mbzId={mbzId || undefined}
|
mbzId={mbzId || undefined}
|
||||||
|
mbzReleaseGroupId={detailQuery?.data?.mbzReleaseGroupId}
|
||||||
musicBrainz={musicBrainz}
|
musicBrainz={musicBrainz}
|
||||||
|
nativeSpotify={nativeSpotify}
|
||||||
|
qobuz={qobuz}
|
||||||
|
spotify={spotify}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ export function AlbumGridCarousel(props: AlbumGridCarouselProps) {
|
|||||||
data={album}
|
data={album}
|
||||||
enableDrag
|
enableDrag
|
||||||
enableExpansion
|
enableExpansion
|
||||||
|
imageFetchPriority="low"
|
||||||
itemType={LibraryItem.ALBUM}
|
itemType={LibraryItem.ALBUM}
|
||||||
rows={rows}
|
rows={rows}
|
||||||
type="poster"
|
type="poster"
|
||||||
|
|||||||
@@ -70,6 +70,7 @@ const BaseAlbumInfiniteCarousel = (props: AlbumCarouselProps & { rows: DataRow[]
|
|||||||
data={album}
|
data={album}
|
||||||
enableDrag
|
enableDrag
|
||||||
enableExpansion
|
enableExpansion
|
||||||
|
imageFetchPriority="low"
|
||||||
itemType={LibraryItem.ALBUM}
|
itemType={LibraryItem.ALBUM}
|
||||||
rows={rows}
|
rows={rows}
|
||||||
type="poster"
|
type="poster"
|
||||||
|
|||||||
@@ -167,6 +167,9 @@ const getSettingsProperties = (): SettingsProperties => {
|
|||||||
'settings.lyrics.sources.netease': ignoreWeb(
|
'settings.lyrics.sources.netease': ignoreWeb(
|
||||||
settings.lyrics.sources.includes(LyricSource.NETEASE),
|
settings.lyrics.sources.includes(LyricSource.NETEASE),
|
||||||
),
|
),
|
||||||
|
'settings.lyrics.sources.simpmusic': ignoreWeb(
|
||||||
|
settings.lyrics.sources.includes(LyricSource.SIMPMUSIC),
|
||||||
|
),
|
||||||
'settings.minimizeToTray': ignoreWeb(settings.window.minimizeToTray),
|
'settings.minimizeToTray': ignoreWeb(settings.window.minimizeToTray),
|
||||||
// 'settings.musicBrainz': settings.general.musicBrainz,
|
// 'settings.musicBrainz': settings.general.musicBrainz,
|
||||||
'settings.nativeAspectRatio': settings.general.nativeAspectRatio,
|
'settings.nativeAspectRatio': settings.general.nativeAspectRatio,
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ import {
|
|||||||
useSuspenseQuery,
|
useSuspenseQuery,
|
||||||
UseSuspenseQueryResult,
|
UseSuspenseQueryResult,
|
||||||
} from '@tanstack/react-query';
|
} from '@tanstack/react-query';
|
||||||
import { LayoutGroup, motion } from 'motion/react';
|
import { motion } from 'motion/react';
|
||||||
import { Suspense, useCallback, useMemo, useRef, useState } from 'react';
|
import { memo, Suspense, useCallback, useMemo, useRef, useState } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { createSearchParams, generatePath, Link, useLocation, useParams } from 'react-router';
|
import { createSearchParams, generatePath, Link, useLocation, useParams } from 'react-router';
|
||||||
|
|
||||||
@@ -888,22 +888,54 @@ interface AlbumArtistMetadataExternalLinksProps {
|
|||||||
artistName?: string;
|
artistName?: string;
|
||||||
externalLinks: boolean;
|
externalLinks: boolean;
|
||||||
lastFM: boolean;
|
lastFM: boolean;
|
||||||
|
listenBrainz: boolean;
|
||||||
mbzId?: null | string;
|
mbzId?: null | string;
|
||||||
musicBrainz: boolean;
|
musicBrainz: boolean;
|
||||||
|
nativeSpotify: boolean;
|
||||||
order?: number;
|
order?: number;
|
||||||
|
qobuz: boolean;
|
||||||
|
spotify: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const getListenBrainzUrl = (mbzId: null | string, artistName?: string) => {
|
||||||
|
if (mbzId) {
|
||||||
|
return `https://listenbrainz.org/artist/${mbzId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (artistName) {
|
||||||
|
return `https://listenbrainz.org/search/?search_term=${encodeURIComponent(artistName)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getQobuzUrl = (artistName?: string) => {
|
||||||
|
if (artistName) {
|
||||||
|
return `https://www.qobuz.com/us-en/search/artists/${encodeURIComponent(artistName)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
const AlbumArtistMetadataExternalLinks = ({
|
const AlbumArtistMetadataExternalLinks = ({
|
||||||
artistName,
|
artistName,
|
||||||
externalLinks,
|
externalLinks,
|
||||||
lastFM,
|
lastFM,
|
||||||
|
listenBrainz,
|
||||||
mbzId,
|
mbzId,
|
||||||
musicBrainz,
|
musicBrainz,
|
||||||
|
nativeSpotify,
|
||||||
order,
|
order,
|
||||||
|
qobuz,
|
||||||
|
spotify,
|
||||||
}: AlbumArtistMetadataExternalLinksProps) => {
|
}: AlbumArtistMetadataExternalLinksProps) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const listenBrainzUrl = getListenBrainzUrl(mbzId || null, artistName);
|
||||||
|
const qobuzUrl = getQobuzUrl(artistName);
|
||||||
|
|
||||||
if (!externalLinks || (!lastFM && !musicBrainz)) return null;
|
if (!externalLinks || (!lastFM && !listenBrainz && !musicBrainz && !qobuz && !spotify)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Grid.Col order={order} span={12}>
|
<Grid.Col order={order} span={12}>
|
||||||
@@ -913,15 +945,14 @@ const AlbumArtistMetadataExternalLinks = ({
|
|||||||
postProcess: 'sentenceCase',
|
postProcess: 'sentenceCase',
|
||||||
})}
|
})}
|
||||||
</Text>
|
</Text>
|
||||||
<Group gap="sm">
|
<Group gap="xs">
|
||||||
{lastFM && (
|
{lastFM && (
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
component="a"
|
component="a"
|
||||||
href={`https://www.last.fm/music/${encodeURIComponent(artistName || '')}`}
|
href={`https://www.last.fm/music/${encodeURIComponent(artistName || '')}`}
|
||||||
icon="brandLastfm"
|
icon="brandLastfm"
|
||||||
iconProps={{
|
iconProps={{
|
||||||
fill: 'default',
|
size: '2xl',
|
||||||
size: 'xl',
|
|
||||||
}}
|
}}
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
@@ -937,8 +968,7 @@ const AlbumArtistMetadataExternalLinks = ({
|
|||||||
href={`https://musicbrainz.org/artist/${mbzId}`}
|
href={`https://musicbrainz.org/artist/${mbzId}`}
|
||||||
icon="brandMusicBrainz"
|
icon="brandMusicBrainz"
|
||||||
iconProps={{
|
iconProps={{
|
||||||
fill: 'default',
|
size: '2xl',
|
||||||
size: 'xl',
|
|
||||||
}}
|
}}
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
@@ -948,6 +978,58 @@ const AlbumArtistMetadataExternalLinks = ({
|
|||||||
variant="subtle"
|
variant="subtle"
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
{listenBrainz && listenBrainzUrl && (
|
||||||
|
<ActionIcon
|
||||||
|
component="a"
|
||||||
|
href={listenBrainzUrl}
|
||||||
|
icon="brandListenBrainz"
|
||||||
|
iconProps={{
|
||||||
|
size: '2xl',
|
||||||
|
}}
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
target="_blank"
|
||||||
|
tooltip={{
|
||||||
|
label: t('action.openIn.listenbrainz'),
|
||||||
|
}}
|
||||||
|
variant="subtle"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{qobuz && qobuzUrl && (
|
||||||
|
<ActionIcon
|
||||||
|
component="a"
|
||||||
|
href={qobuzUrl}
|
||||||
|
icon="brandQobuz"
|
||||||
|
iconProps={{
|
||||||
|
size: '2xl',
|
||||||
|
}}
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
target="_blank"
|
||||||
|
tooltip={{
|
||||||
|
label: t('action.openIn.qobuz'),
|
||||||
|
}}
|
||||||
|
variant="subtle"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{spotify && (
|
||||||
|
<ActionIcon
|
||||||
|
component="a"
|
||||||
|
href={
|
||||||
|
nativeSpotify
|
||||||
|
? `spotify:search:${encodeURIComponent(artistName || '')}`
|
||||||
|
: `https://open.spotify.com/search/${encodeURIComponent(artistName || '')}`
|
||||||
|
}
|
||||||
|
icon="brandSpotify"
|
||||||
|
iconProps={{
|
||||||
|
size: '2xl',
|
||||||
|
}}
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
target={nativeSpotify ? undefined : '_blank'}
|
||||||
|
tooltip={{
|
||||||
|
label: t('action.openIn.spotify'),
|
||||||
|
}}
|
||||||
|
variant="subtle"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</Group>
|
</Group>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Grid.Col>
|
</Grid.Col>
|
||||||
@@ -1050,7 +1132,8 @@ export const AlbumArtistDetailContent = ({
|
|||||||
}: AlbumArtistDetailContentProps) => {
|
}: AlbumArtistDetailContentProps) => {
|
||||||
const artistItems = useArtistItems();
|
const artistItems = useArtistItems();
|
||||||
const artistRadioCount = useArtistRadioCount();
|
const artistRadioCount = useArtistRadioCount();
|
||||||
const { externalLinks, lastFM, musicBrainz } = useExternalLinks();
|
const { externalLinks, lastFM, listenBrainz, musicBrainz, nativeSpotify, qobuz, spotify } =
|
||||||
|
useExternalLinks();
|
||||||
const { albumArtistId, artistId } = useParams() as {
|
const { albumArtistId, artistId } = useParams() as {
|
||||||
albumArtistId?: string;
|
albumArtistId?: string;
|
||||||
artistId?: string;
|
artistId?: string;
|
||||||
@@ -1136,14 +1219,19 @@ export const AlbumArtistDetailContent = ({
|
|||||||
genres={detailQuery.data?.genres}
|
genres={detailQuery.data?.genres}
|
||||||
order={genresOrder}
|
order={genresOrder}
|
||||||
/>
|
/>
|
||||||
{externalLinks && (lastFM || musicBrainz) && (
|
{externalLinks &&
|
||||||
|
(lastFM || listenBrainz || musicBrainz || qobuz || spotify) && (
|
||||||
<AlbumArtistMetadataExternalLinks
|
<AlbumArtistMetadataExternalLinks
|
||||||
artistName={detailQuery.data?.name}
|
artistName={detailQuery.data?.name}
|
||||||
externalLinks={externalLinks}
|
externalLinks={externalLinks}
|
||||||
lastFM={lastFM}
|
lastFM={lastFM}
|
||||||
|
listenBrainz={listenBrainz}
|
||||||
mbzId={mbzId}
|
mbzId={mbzId}
|
||||||
musicBrainz={musicBrainz}
|
musicBrainz={musicBrainz}
|
||||||
|
nativeSpotify={nativeSpotify}
|
||||||
order={externalLinksOrder}
|
order={externalLinksOrder}
|
||||||
|
qobuz={qobuz}
|
||||||
|
spotify={spotify}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{enabledItem.biography && (
|
{enabledItem.biography && (
|
||||||
@@ -1182,8 +1270,8 @@ export const AlbumArtistDetailContent = ({
|
|||||||
interface AlbumSectionProps {
|
interface AlbumSectionProps {
|
||||||
albums: Album[];
|
albums: Album[];
|
||||||
controls: ItemControls;
|
controls: ItemControls;
|
||||||
cq: ReturnType<typeof useContainerQuery>;
|
|
||||||
enableExpansion?: boolean;
|
enableExpansion?: boolean;
|
||||||
|
itemsPerRow: number;
|
||||||
releaseType: string;
|
releaseType: string;
|
||||||
rows: DataRow[] | undefined;
|
rows: DataRow[] | undefined;
|
||||||
title: React.ReactNode | string;
|
title: React.ReactNode | string;
|
||||||
@@ -1203,18 +1291,16 @@ const getItemsPerRow = (cq: ReturnType<typeof useContainerQuery>) => {
|
|||||||
return 2;
|
return 2;
|
||||||
};
|
};
|
||||||
|
|
||||||
const AlbumSection = ({
|
const AlbumSection = memo(function AlbumSection({
|
||||||
albums,
|
albums,
|
||||||
controls,
|
controls,
|
||||||
cq,
|
|
||||||
enableExpansion,
|
enableExpansion,
|
||||||
|
itemsPerRow,
|
||||||
releaseType,
|
releaseType,
|
||||||
rows,
|
rows,
|
||||||
title,
|
title,
|
||||||
}: AlbumSectionProps) => {
|
}: AlbumSectionProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const itemsPerRow = getItemsPerRow(cq);
|
|
||||||
const albumCount = albums.length;
|
const albumCount = albums.length;
|
||||||
const [showAll, setShowAll] = useState(false);
|
const [showAll, setShowAll] = useState(false);
|
||||||
const player = usePlayer();
|
const player = usePlayer();
|
||||||
@@ -1259,6 +1345,27 @@ const AlbumSection = ({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const DisplayedAlbumsMemo = useMemo(() => {
|
||||||
|
return displayedAlbums.map((album) => (
|
||||||
|
<motion.div
|
||||||
|
className={styles.albumGridItem}
|
||||||
|
key={album.id}
|
||||||
|
layoutId={`${releaseType}-${album.id}`}
|
||||||
|
>
|
||||||
|
<MemoizedItemCard
|
||||||
|
controls={controls}
|
||||||
|
data={album}
|
||||||
|
enableDrag
|
||||||
|
enableExpansion={enableExpansion ?? true}
|
||||||
|
itemType={LibraryItem.ALBUM}
|
||||||
|
rows={rows}
|
||||||
|
type="poster"
|
||||||
|
withControls
|
||||||
|
/>
|
||||||
|
</motion.div>
|
||||||
|
));
|
||||||
|
}, [controls, displayedAlbums, enableExpansion, releaseType, rows]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack gap="md">
|
<Stack gap="md">
|
||||||
<div className={styles.albumSectionTitle}>
|
<div className={styles.albumSectionTitle}>
|
||||||
@@ -1320,30 +1427,7 @@ const AlbumSection = ({
|
|||||||
} as React.CSSProperties
|
} as React.CSSProperties
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{displayedAlbums.map((album) => (
|
{DisplayedAlbumsMemo}
|
||||||
<motion.div
|
|
||||||
className={styles.albumGridItem}
|
|
||||||
key={album.id}
|
|
||||||
layout
|
|
||||||
layoutId={`${releaseType}-${album.id}`}
|
|
||||||
transition={{
|
|
||||||
duration: 0.5,
|
|
||||||
ease: 'easeInOut',
|
|
||||||
layout: { duration: 0.5, ease: 'easeInOut' },
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<MemoizedItemCard
|
|
||||||
controls={controls}
|
|
||||||
data={album}
|
|
||||||
enableDrag
|
|
||||||
enableExpansion={enableExpansion ?? true}
|
|
||||||
itemType={LibraryItem.ALBUM}
|
|
||||||
rows={rows}
|
|
||||||
type="poster"
|
|
||||||
withControls
|
|
||||||
/>
|
|
||||||
</motion.div>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
{hasMoreAlbums && !showAll && (
|
{hasMoreAlbums && !showAll && (
|
||||||
<Group justify="center" w="100%">
|
<Group justify="center" w="100%">
|
||||||
@@ -1354,7 +1438,7 @@ const AlbumSection = ({
|
|||||||
)}
|
)}
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
};
|
});
|
||||||
|
|
||||||
import { useArtistAlbumsGrouped } from '/@/renderer/features/artists/hooks/use-artist-albums-grouped';
|
import { useArtistAlbumsGrouped } from '/@/renderer/features/artists/hooks/use-artist-albums-grouped';
|
||||||
|
|
||||||
@@ -1412,6 +1496,23 @@ const ArtistAlbums = ({ albumsQuery, order }: ArtistAlbumsProps) => {
|
|||||||
],
|
],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
const itemsPerRow = getItemsPerRow(cq);
|
||||||
|
|
||||||
|
const ReleaseTypeEntriesMemo = useMemo(() => {
|
||||||
|
return releaseTypeEntries.map(({ albums, displayName, releaseType }) => (
|
||||||
|
<AlbumSection
|
||||||
|
albums={albums}
|
||||||
|
controls={controls}
|
||||||
|
enableExpansion
|
||||||
|
itemsPerRow={itemsPerRow}
|
||||||
|
key={releaseType}
|
||||||
|
releaseType={releaseType}
|
||||||
|
rows={rows}
|
||||||
|
title={displayName}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
}, [releaseTypeEntries, itemsPerRow, controls, rows]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Grid.Col order={order} span={12}>
|
<Grid.Col order={order} span={12}>
|
||||||
<Stack gap="md">
|
<Stack gap="md">
|
||||||
@@ -1459,22 +1560,7 @@ const ArtistAlbums = ({ albumsQuery, order }: ArtistAlbumsProps) => {
|
|||||||
</Group>
|
</Group>
|
||||||
{releaseTypeEntries.length > 0 && (
|
{releaseTypeEntries.length > 0 && (
|
||||||
<div className={styles.albumSectionContainer} ref={cq.ref}>
|
<div className={styles.albumSectionContainer} ref={cq.ref}>
|
||||||
{cq.isCalculated && (
|
{cq.isCalculated && <>{ReleaseTypeEntriesMemo}</>}
|
||||||
<LayoutGroup>
|
|
||||||
{releaseTypeEntries.map(({ albums, displayName, releaseType }) => (
|
|
||||||
<AlbumSection
|
|
||||||
albums={albums}
|
|
||||||
controls={controls}
|
|
||||||
cq={cq}
|
|
||||||
enableExpansion
|
|
||||||
key={releaseType}
|
|
||||||
releaseType={releaseType}
|
|
||||||
rows={rows}
|
|
||||||
title={displayName}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</LayoutGroup>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|||||||
+46
@@ -0,0 +1,46 @@
|
|||||||
|
import { ListSearchInput } from '/@/renderer/features/shared/components/list-search-input';
|
||||||
|
import {
|
||||||
|
CLIENT_SIDE_SONG_FILTERS,
|
||||||
|
ListSortByDropdownControlled,
|
||||||
|
} from '/@/renderer/features/shared/components/list-sort-by-dropdown';
|
||||||
|
import { ListSortOrderToggleButtonControlled } from '/@/renderer/features/shared/components/list-sort-order-toggle-button';
|
||||||
|
import { useAppStore } from '/@/renderer/store/app.store';
|
||||||
|
import { Divider } from '/@/shared/components/divider/divider';
|
||||||
|
import { Flex } from '/@/shared/components/flex/flex';
|
||||||
|
import { Group } from '/@/shared/components/group/group';
|
||||||
|
import { LibraryItem, SongListSort, SortOrder } from '/@/shared/types/domain-types';
|
||||||
|
|
||||||
|
export const AlbumArtistDetailFavoriteSongsListHeaderFilters = () => {
|
||||||
|
const albumArtistDetailFavoriteSongsSort = useAppStore(
|
||||||
|
(state) => state.albumArtistDetailFavoriteSongsSort,
|
||||||
|
);
|
||||||
|
const setAlbumArtistDetailFavoriteSongsSort = useAppStore(
|
||||||
|
(state) => state.actions.setAlbumArtistDetailFavoriteSongsSort,
|
||||||
|
);
|
||||||
|
const sortBy = albumArtistDetailFavoriteSongsSort.sortBy;
|
||||||
|
const sortOrder = albumArtistDetailFavoriteSongsSort.sortOrder;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Flex justify="space-between">
|
||||||
|
<Group gap="sm" w="100%">
|
||||||
|
<ListSortByDropdownControlled
|
||||||
|
filters={CLIENT_SIDE_SONG_FILTERS}
|
||||||
|
itemType={LibraryItem.SONG}
|
||||||
|
setSortBy={(value) =>
|
||||||
|
setAlbumArtistDetailFavoriteSongsSort(value as SongListSort, sortOrder)
|
||||||
|
}
|
||||||
|
sortBy={sortBy}
|
||||||
|
/>
|
||||||
|
<Divider orientation="vertical" />
|
||||||
|
<ListSortOrderToggleButtonControlled
|
||||||
|
setSortOrder={(value) =>
|
||||||
|
setAlbumArtistDetailFavoriteSongsSort(sortBy, value as SortOrder)
|
||||||
|
}
|
||||||
|
sortOrder={sortOrder}
|
||||||
|
/>
|
||||||
|
<Divider orientation="vertical" />
|
||||||
|
<ListSearchInput />
|
||||||
|
</Group>
|
||||||
|
</Flex>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -69,6 +69,7 @@ const BaseAlbumArtistInfiniteCarousel = (props: AlbumArtistCarouselProps & { row
|
|||||||
controls={controls}
|
controls={controls}
|
||||||
data={albumArtist}
|
data={albumArtist}
|
||||||
enableDrag
|
enableDrag
|
||||||
|
imageFetchPriority="low"
|
||||||
itemType={LibraryItem.ALBUM_ARTIST}
|
itemType={LibraryItem.ALBUM_ARTIST}
|
||||||
rows={rows}
|
rows={rows}
|
||||||
type="poster"
|
type="poster"
|
||||||
|
|||||||
+33
-4
@@ -10,12 +10,19 @@ import { ItemControls } from '/@/renderer/components/item-list/types';
|
|||||||
import { ListContext } from '/@/renderer/context/list-context';
|
import { ListContext } from '/@/renderer/context/list-context';
|
||||||
import { artistsQueries } from '/@/renderer/features/artists/api/artists-api';
|
import { artistsQueries } from '/@/renderer/features/artists/api/artists-api';
|
||||||
import { AlbumArtistDetailFavoriteSongsListHeader } from '/@/renderer/features/artists/components/album-artist-detail-favorite-songs-list-header';
|
import { AlbumArtistDetailFavoriteSongsListHeader } from '/@/renderer/features/artists/components/album-artist-detail-favorite-songs-list-header';
|
||||||
|
import { AlbumArtistDetailFavoriteSongsListHeaderFilters } from '/@/renderer/features/artists/components/album-artist-detail-favorite-songs-list-header-filters';
|
||||||
import { usePlayer } from '/@/renderer/features/player/context/player-context';
|
import { usePlayer } from '/@/renderer/features/player/context/player-context';
|
||||||
|
import { applyClientSideSongFilters } from '/@/renderer/features/playlists/hooks/use-playlist-track-list';
|
||||||
import { AnimatedPage } from '/@/renderer/features/shared/components/animated-page';
|
import { AnimatedPage } from '/@/renderer/features/shared/components/animated-page';
|
||||||
|
import { FilterBar } from '/@/renderer/features/shared/components/filter-bar';
|
||||||
import { PageErrorBoundary } from '/@/renderer/features/shared/components/page-error-boundary';
|
import { PageErrorBoundary } from '/@/renderer/features/shared/components/page-error-boundary';
|
||||||
|
import { useSearchTermFilter } from '/@/renderer/features/shared/hooks/use-search-term-filter';
|
||||||
|
import { FILTER_KEYS, searchLibraryItems } from '/@/renderer/features/shared/utils';
|
||||||
import { usePlayerSong } from '/@/renderer/store';
|
import { usePlayerSong } from '/@/renderer/store';
|
||||||
|
import { useAppStore } from '/@/renderer/store/app.store';
|
||||||
import { useCurrentServer } from '/@/renderer/store/auth.store';
|
import { useCurrentServer } from '/@/renderer/store/auth.store';
|
||||||
import { useSettingsStore } from '/@/renderer/store/settings.store';
|
import { useSettingsStore } from '/@/renderer/store/settings.store';
|
||||||
|
import { sortSongList } from '/@/shared/api/utils';
|
||||||
import { LibraryItem, Song } from '/@/shared/types/domain-types';
|
import { LibraryItem, Song } from '/@/shared/types/domain-types';
|
||||||
import { ItemListKey, Play } from '/@/shared/types/types';
|
import { ItemListKey, Play } from '/@/shared/types/types';
|
||||||
|
|
||||||
@@ -43,12 +50,31 @@ const AlbumArtistDetailFavoriteSongsListRoute = () => {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
const itemCount = favoriteSongsQuery?.data?.items?.length || 0;
|
|
||||||
const songs = useMemo(
|
const songs = useMemo(
|
||||||
() => favoriteSongsQuery?.data?.items || [],
|
() => favoriteSongsQuery?.data?.items || [],
|
||||||
[favoriteSongsQuery?.data?.items],
|
[favoriteSongsQuery?.data?.items],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const albumArtistDetailFavoriteSongsSort = useAppStore(
|
||||||
|
(state) => state.albumArtistDetailFavoriteSongsSort,
|
||||||
|
);
|
||||||
|
const sortBy = albumArtistDetailFavoriteSongsSort.sortBy;
|
||||||
|
const sortOrder = albumArtistDetailFavoriteSongsSort.sortOrder;
|
||||||
|
|
||||||
|
const { searchTerm } = useSearchTermFilter();
|
||||||
|
|
||||||
|
const sortedSongs = useMemo(() => {
|
||||||
|
const filtered = applyClientSideSongFilters(songs, {
|
||||||
|
[FILTER_KEYS.SHARED.SEARCH_TERM]: searchTerm,
|
||||||
|
});
|
||||||
|
const searched = searchTerm
|
||||||
|
? searchLibraryItems(filtered, searchTerm, LibraryItem.SONG)
|
||||||
|
: filtered;
|
||||||
|
return sortSongList(searched, sortBy, sortOrder);
|
||||||
|
}, [songs, sortBy, sortOrder, searchTerm]);
|
||||||
|
|
||||||
|
const itemCount = sortedSongs.length;
|
||||||
|
|
||||||
const tableConfig = useSettingsStore((state) => state.lists[ItemListKey.SONG]?.table);
|
const tableConfig = useSettingsStore((state) => state.lists[ItemListKey.SONG]?.table);
|
||||||
const currentSong = usePlayerSong();
|
const currentSong = usePlayerSong();
|
||||||
const player = usePlayer();
|
const player = usePlayer();
|
||||||
@@ -96,7 +122,7 @@ const AlbumArtistDetailFavoriteSongsListRoute = () => {
|
|||||||
<AnimatedPage>
|
<AnimatedPage>
|
||||||
<ListContext.Provider value={providerValue}>
|
<ListContext.Provider value={providerValue}>
|
||||||
<AlbumArtistDetailFavoriteSongsListHeader
|
<AlbumArtistDetailFavoriteSongsListHeader
|
||||||
data={songs}
|
data={sortedSongs}
|
||||||
itemCount={itemCount}
|
itemCount={itemCount}
|
||||||
title={detailQuery?.data?.name || 'Unknown'}
|
title={detailQuery?.data?.name || 'Unknown'}
|
||||||
/>
|
/>
|
||||||
@@ -109,16 +135,19 @@ const AlbumArtistDetailFavoriteSongsListRoute = () => {
|
|||||||
<AnimatedPage>
|
<AnimatedPage>
|
||||||
<ListContext.Provider value={providerValue}>
|
<ListContext.Provider value={providerValue}>
|
||||||
<AlbumArtistDetailFavoriteSongsListHeader
|
<AlbumArtistDetailFavoriteSongsListHeader
|
||||||
data={songs}
|
data={sortedSongs}
|
||||||
itemCount={itemCount}
|
itemCount={itemCount}
|
||||||
title={detailQuery?.data?.name || 'Unknown'}
|
title={detailQuery?.data?.name || 'Unknown'}
|
||||||
/>
|
/>
|
||||||
|
<FilterBar>
|
||||||
|
<AlbumArtistDetailFavoriteSongsListHeaderFilters />
|
||||||
|
</FilterBar>
|
||||||
<ItemTableList
|
<ItemTableList
|
||||||
activeRowId={currentSongId}
|
activeRowId={currentSongId}
|
||||||
autoFitColumns={tableConfig.autoFitColumns}
|
autoFitColumns={tableConfig.autoFitColumns}
|
||||||
CellComponent={ItemTableListColumn}
|
CellComponent={ItemTableListColumn}
|
||||||
columns={columns}
|
columns={columns}
|
||||||
data={songs}
|
data={sortedSongs}
|
||||||
enableAlternateRowColors={tableConfig.enableAlternateRowColors}
|
enableAlternateRowColors={tableConfig.enableAlternateRowColors}
|
||||||
enableDrag
|
enableDrag
|
||||||
enableExpansion={false}
|
enableExpansion={false}
|
||||||
|
|||||||
@@ -90,7 +90,7 @@ const LoginRoute = () => {
|
|||||||
value: serverUrl,
|
value: serverUrl,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
isValid: remoteUrl !== '',
|
isValid: true,
|
||||||
key: 'REMOTE_URL',
|
key: 'REMOTE_URL',
|
||||||
value: remoteUrl,
|
value: remoteUrl,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import {
|
|||||||
} from '/@/renderer/features/lyrics/unsynchronized-lyrics';
|
} from '/@/renderer/features/lyrics/unsynchronized-lyrics';
|
||||||
import { openLyricsSettingsModal } from '/@/renderer/features/lyrics/utils/open-lyrics-settings-modal';
|
import { openLyricsSettingsModal } from '/@/renderer/features/lyrics/utils/open-lyrics-settings-modal';
|
||||||
import { usePlayerEvents } from '/@/renderer/features/player/audio-player/hooks/use-player-events';
|
import { usePlayerEvents } from '/@/renderer/features/player/audio-player/hooks/use-player-events';
|
||||||
|
import { useIsRadioActive } from '/@/renderer/features/radio/hooks/use-radio-player';
|
||||||
import { ComponentErrorBoundary } from '/@/renderer/features/shared/components/component-error-boundary';
|
import { ComponentErrorBoundary } from '/@/renderer/features/shared/components/component-error-boundary';
|
||||||
import { queryClient } from '/@/renderer/lib/react-query';
|
import { queryClient } from '/@/renderer/lib/react-query';
|
||||||
import { useLyricsSettings, usePlayerSong } from '/@/renderer/store';
|
import { useLyricsSettings, usePlayerSong } from '/@/renderer/store';
|
||||||
@@ -42,6 +43,10 @@ type LyricsProps = {
|
|||||||
|
|
||||||
export const Lyrics = ({ fadeOutNoLyricsMessage = true, settingsKey = 'default' }: LyricsProps) => {
|
export const Lyrics = ({ fadeOutNoLyricsMessage = true, settingsKey = 'default' }: LyricsProps) => {
|
||||||
const currentSong = usePlayerSong();
|
const currentSong = usePlayerSong();
|
||||||
|
const isRadioActive = useIsRadioActive();
|
||||||
|
|
||||||
|
const isLyricsDisabled = isRadioActive;
|
||||||
|
|
||||||
const {
|
const {
|
||||||
enableAutoTranslation,
|
enableAutoTranslation,
|
||||||
preferLocalLyrics,
|
preferLocalLyrics,
|
||||||
@@ -91,7 +96,8 @@ export const Lyrics = ({ fadeOutNoLyricsMessage = true, settingsKey = 'default'
|
|||||||
lyricsQueries.songLyrics(
|
lyricsQueries.songLyrics(
|
||||||
{
|
{
|
||||||
options: {
|
options: {
|
||||||
enabled: !!pendingSongId && pendingSongId === currentSong?.id,
|
enabled:
|
||||||
|
!!pendingSongId && pendingSongId === currentSong?.id && !isLyricsDisabled,
|
||||||
},
|
},
|
||||||
query: { songId: currentSong?.id || '' },
|
query: { songId: currentSong?.id || '' },
|
||||||
serverId: currentSong?._serverId || '',
|
serverId: currentSong?._serverId || '',
|
||||||
@@ -110,11 +116,15 @@ export const Lyrics = ({ fadeOutNoLyricsMessage = true, settingsKey = 'default'
|
|||||||
return computeSelectedFromResult(data, preferLocalLyrics, indexToUse);
|
return computeSelectedFromResult(data, preferLocalLyrics, indexToUse);
|
||||||
}, [data, indexToUse, preferLocalLyrics]);
|
}, [data, indexToUse, preferLocalLyrics]);
|
||||||
|
|
||||||
|
const displayLyrics = isLyricsDisabled ? null : lyrics;
|
||||||
|
|
||||||
const currentOffsetMs = useMemo(() => {
|
const currentOffsetMs = useMemo(() => {
|
||||||
if (!data) return 0;
|
if (!data) return 0;
|
||||||
return getDisplayOffset(lyrics, data.selectedOffsetMs, indexToUse, data.local);
|
return getDisplayOffset(lyrics, data.selectedOffsetMs, indexToUse, data.local);
|
||||||
}, [data, indexToUse, lyrics]);
|
}, [data, indexToUse, lyrics]);
|
||||||
|
|
||||||
|
const displayOffsetMs = isLyricsDisabled ? 0 : currentOffsetMs;
|
||||||
|
|
||||||
const handleOnSearchOverride = useCallback(
|
const handleOnSearchOverride = useCallback(
|
||||||
(params: LyricsOverride) => {
|
(params: LyricsOverride) => {
|
||||||
if (!lyricsKey) return;
|
if (!lyricsKey) return;
|
||||||
@@ -192,7 +202,7 @@ export const Lyrics = ({ fadeOutNoLyricsMessage = true, settingsKey = 'default'
|
|||||||
}, [currentSong, lyricsKey]);
|
}, [currentSong, lyricsKey]);
|
||||||
|
|
||||||
const fetchTranslation = useCallback(async () => {
|
const fetchTranslation = useCallback(async () => {
|
||||||
if (!lyrics) return;
|
if (!lyrics || isLyricsDisabled) return;
|
||||||
const originalLyrics = Array.isArray(lyrics.lyrics)
|
const originalLyrics = Array.isArray(lyrics.lyrics)
|
||||||
? lyrics.lyrics.map(([, line]) => line).join('\n')
|
? lyrics.lyrics.map(([, line]) => line).join('\n')
|
||||||
: lyrics.lyrics;
|
: lyrics.lyrics;
|
||||||
@@ -204,7 +214,13 @@ export const Lyrics = ({ fadeOutNoLyricsMessage = true, settingsKey = 'default'
|
|||||||
);
|
);
|
||||||
setTranslatedLyrics(TranslatedText);
|
setTranslatedLyrics(TranslatedText);
|
||||||
setShowTranslation(true);
|
setShowTranslation(true);
|
||||||
}, [lyrics, translationApiKey, translationApiProvider, translationTargetLanguage]);
|
}, [
|
||||||
|
isLyricsDisabled,
|
||||||
|
lyrics,
|
||||||
|
translationApiKey,
|
||||||
|
translationApiProvider,
|
||||||
|
translationTargetLanguage,
|
||||||
|
]);
|
||||||
|
|
||||||
const handleOnTranslateLyric = useCallback(async () => {
|
const handleOnTranslateLyric = useCallback(async () => {
|
||||||
if (translatedLyrics) {
|
if (translatedLyrics) {
|
||||||
@@ -226,10 +242,10 @@ export const Lyrics = ({ fadeOutNoLyricsMessage = true, settingsKey = 'default'
|
|||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (lyrics && !translatedLyrics && enableAutoTranslation) {
|
if (displayLyrics && !translatedLyrics && enableAutoTranslation) {
|
||||||
fetchTranslation();
|
fetchTranslation();
|
||||||
}
|
}
|
||||||
}, [lyrics, translatedLyrics, enableAutoTranslation, fetchTranslation]);
|
}, [displayLyrics, translatedLyrics, enableAutoTranslation, fetchTranslation]);
|
||||||
|
|
||||||
const languages = useMemo(() => {
|
const languages = useMemo(() => {
|
||||||
const local = data?.local;
|
const local = data?.local;
|
||||||
@@ -242,8 +258,8 @@ export const Lyrics = ({ fadeOutNoLyricsMessage = true, settingsKey = 'default'
|
|||||||
return [];
|
return [];
|
||||||
}, [data?.local]);
|
}, [data?.local]);
|
||||||
|
|
||||||
const isLoadingLyrics = isLoading;
|
const isLoadingLyrics = isLoading && !isLyricsDisabled;
|
||||||
const hasNoLyrics = !lyrics;
|
const hasNoLyrics = !displayLyrics;
|
||||||
const [shouldFadeOut, setShouldFadeOut] = useState(false);
|
const [shouldFadeOut, setShouldFadeOut] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -267,10 +283,10 @@ export const Lyrics = ({ fadeOutNoLyricsMessage = true, settingsKey = 'default'
|
|||||||
}, [isLoadingLyrics, hasNoLyrics, fadeOutNoLyricsMessage]);
|
}, [isLoadingLyrics, hasNoLyrics, fadeOutNoLyricsMessage]);
|
||||||
|
|
||||||
const handleExportLyrics = useCallback(() => {
|
const handleExportLyrics = useCallback(() => {
|
||||||
if (lyrics) {
|
if (displayLyrics) {
|
||||||
openLyricsExportModal({ lyrics, offsetMs: currentOffsetMs, synced });
|
openLyricsExportModal({ lyrics: displayLyrics, offsetMs: currentOffsetMs, synced });
|
||||||
}
|
}
|
||||||
}, [currentOffsetMs, lyrics, synced]);
|
}, [currentOffsetMs, displayLyrics, synced]);
|
||||||
|
|
||||||
const handleOpenSettings = () => {
|
const handleOpenSettings = () => {
|
||||||
openLyricsSettingsModal(settingsKey);
|
openLyricsSettingsModal(settingsKey);
|
||||||
@@ -318,14 +334,14 @@ export const Lyrics = ({ fadeOutNoLyricsMessage = true, settingsKey = 'default'
|
|||||||
>
|
>
|
||||||
{synced ? (
|
{synced ? (
|
||||||
<SynchronizedLyrics
|
<SynchronizedLyrics
|
||||||
{...(lyrics as SynchronizedLyricsProps)}
|
{...(displayLyrics as SynchronizedLyricsProps)}
|
||||||
offsetMs={currentOffsetMs}
|
offsetMs={displayOffsetMs}
|
||||||
settingsKey={settingsKey}
|
settingsKey={settingsKey}
|
||||||
translatedLyrics={showTranslation ? translatedLyrics : null}
|
translatedLyrics={showTranslation ? translatedLyrics : null}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<UnsynchronizedLyrics
|
<UnsynchronizedLyrics
|
||||||
{...(lyrics as UnsynchronizedLyricsProps)}
|
{...(displayLyrics as UnsynchronizedLyricsProps)}
|
||||||
settingsKey={settingsKey}
|
settingsKey={settingsKey}
|
||||||
translatedLyrics={showTranslation ? translatedLyrics : null}
|
translatedLyrics={showTranslation ? translatedLyrics : null}
|
||||||
/>
|
/>
|
||||||
@@ -336,10 +352,10 @@ export const Lyrics = ({ fadeOutNoLyricsMessage = true, settingsKey = 'default'
|
|||||||
)}
|
)}
|
||||||
<div className={styles.actionsContainer}>
|
<div className={styles.actionsContainer}>
|
||||||
<LyricsActions
|
<LyricsActions
|
||||||
hasLyrics={!!lyrics}
|
hasLyrics={!!displayLyrics}
|
||||||
index={indexToUse}
|
index={indexToUse}
|
||||||
languages={languages}
|
languages={languages}
|
||||||
offsetMs={currentOffsetMs}
|
offsetMs={displayOffsetMs}
|
||||||
onExportLyrics={handleExportLyrics}
|
onExportLyrics={handleExportLyrics}
|
||||||
onRemoveLyric={handleOnRemoveLyric}
|
onRemoveLyric={handleOnRemoveLyric}
|
||||||
onSearchOverride={handleOnSearchOverride}
|
onSearchOverride={handleOnSearchOverride}
|
||||||
|
|||||||
@@ -109,9 +109,8 @@ export const MpvPlayerEngine = (props: MpvPlayerEngineProps) => {
|
|||||||
|
|
||||||
const extraParameters: string[] = [...mpvExtraParameters];
|
const extraParameters: string[] = [...mpvExtraParameters];
|
||||||
|
|
||||||
if (mpvAudioDeviceId) {
|
const audioDevice = mpvAudioDeviceId?.trim() || 'auto';
|
||||||
extraParameters.push(`--audio-device=${mpvAudioDeviceId}`);
|
extraParameters.push(`--audio-device=${audioDevice}`);
|
||||||
}
|
|
||||||
|
|
||||||
await mpvPlayer?.initialize({
|
await mpvPlayer?.initialize({
|
||||||
extraParameters,
|
extraParameters,
|
||||||
@@ -125,10 +124,10 @@ export const MpvPlayerEngine = (props: MpvPlayerEngineProps) => {
|
|||||||
if (!radioState.currentStreamUrl) {
|
if (!radioState.currentStreamUrl) {
|
||||||
const playerData = usePlayerStore.getState().getPlayerData();
|
const playerData = usePlayerStore.getState().getPlayerData();
|
||||||
const currentSongUrl = playerData.currentSong
|
const currentSongUrl = playerData.currentSong
|
||||||
? getSongUrl(playerData.currentSong, transcode)
|
? await getSongUrl(playerData.currentSong, transcode, true)
|
||||||
: undefined;
|
: undefined;
|
||||||
const nextSongUrl = playerData.nextSong
|
const nextSongUrl = playerData.nextSong
|
||||||
? getSongUrl(playerData.nextSong, transcode)
|
? await getSongUrl(playerData.nextSong, transcode, true)
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
if (currentSongUrl && nextSongUrl && !hasPopulatedQueueRef.current && mpvPlayer) {
|
if (currentSongUrl && nextSongUrl && !hasPopulatedQueueRef.current && mpvPlayer) {
|
||||||
@@ -275,20 +274,23 @@ export const MpvPlayerEngine = (props: MpvPlayerEngineProps) => {
|
|||||||
onMediaPrev: () => {
|
onMediaPrev: () => {
|
||||||
replaceMpvQueue(transcode);
|
replaceMpvQueue(transcode);
|
||||||
},
|
},
|
||||||
onNextSongInsertion: (song) => {
|
onNextSongInsertion: async (song) => {
|
||||||
const radioState = useRadioStore.getState();
|
const radioState = useRadioStore.getState();
|
||||||
|
|
||||||
if (radioState.currentStreamUrl) {
|
if (radioState.currentStreamUrl) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const nextSongUrl = song ? getSongUrl(song, transcode) : undefined;
|
const nextSongUrl = song ? await getSongUrl(song, transcode, true) : undefined;
|
||||||
mpvPlayer?.setQueueNext(nextSongUrl);
|
mpvPlayer?.setQueueNext(nextSongUrl);
|
||||||
},
|
},
|
||||||
onPlayerPlay: () => {
|
onPlayerPlay: () => {
|
||||||
replaceMpvQueue(transcode);
|
replaceMpvQueue(transcode);
|
||||||
},
|
},
|
||||||
onQueueCleared: () => {},
|
onQueueCleared: () => {},
|
||||||
|
onQueueRestored: () => {
|
||||||
|
replaceMpvQueue(transcode);
|
||||||
|
},
|
||||||
},
|
},
|
||||||
[transcode],
|
[transcode],
|
||||||
);
|
);
|
||||||
@@ -337,19 +339,19 @@ export const MpvPlayerEngine = (props: MpvPlayerEngineProps) => {
|
|||||||
|
|
||||||
MpvPlayerEngine.displayName = 'MpvPlayerEngine';
|
MpvPlayerEngine.displayName = 'MpvPlayerEngine';
|
||||||
|
|
||||||
function handleMpvAutoNext(transcode: {
|
async function handleMpvAutoNext(transcode: {
|
||||||
bitrate?: number | undefined;
|
bitrate?: number | undefined;
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
format?: string | undefined;
|
format?: string | undefined;
|
||||||
}) {
|
}) {
|
||||||
const playerData = usePlayerStore.getState().getPlayerData();
|
const playerData = usePlayerStore.getState().getPlayerData();
|
||||||
const nextSongUrl = playerData.nextSong
|
const nextSongUrl = playerData.nextSong
|
||||||
? getSongUrl(playerData.nextSong, transcode)
|
? await getSongUrl(playerData.nextSong, transcode, true)
|
||||||
: undefined;
|
: undefined;
|
||||||
mpvPlayer?.autoNext(nextSongUrl);
|
mpvPlayer?.autoNext(nextSongUrl);
|
||||||
}
|
}
|
||||||
|
|
||||||
function replaceMpvQueue(transcode: {
|
async function replaceMpvQueue(transcode: {
|
||||||
bitrate?: number | undefined;
|
bitrate?: number | undefined;
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
format?: string | undefined;
|
format?: string | undefined;
|
||||||
@@ -363,10 +365,10 @@ function replaceMpvQueue(transcode: {
|
|||||||
|
|
||||||
const playerData = usePlayerStore.getState().getPlayerData();
|
const playerData = usePlayerStore.getState().getPlayerData();
|
||||||
const currentSongUrl = playerData.currentSong
|
const currentSongUrl = playerData.currentSong
|
||||||
? getSongUrl(playerData.currentSong, transcode)
|
? await getSongUrl(playerData.currentSong, transcode, true)
|
||||||
: undefined;
|
: undefined;
|
||||||
const nextSongUrl = playerData.nextSong
|
const nextSongUrl = playerData.nextSong
|
||||||
? getSongUrl(playerData.nextSong, transcode)
|
? await getSongUrl(playerData.nextSong, transcode, true)
|
||||||
: undefined;
|
: undefined;
|
||||||
mpvPlayer?.setQueue(currentSongUrl, nextSongUrl, false);
|
mpvPlayer?.setQueue(currentSongUrl, nextSongUrl, false);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -119,6 +119,8 @@ export const WebPlayerEngine = (props: WebPlayerEngineProps) => {
|
|||||||
player2Ref.current?.getInternalPlayer()?.pause();
|
player2Ref.current?.getInternalPlayer()?.pause();
|
||||||
},
|
},
|
||||||
play() {
|
play() {
|
||||||
|
player1Ref.current?.getInternalPlayer()?.pause();
|
||||||
|
player2Ref.current?.getInternalPlayer()?.pause();
|
||||||
if (playerNum === 1) {
|
if (playerNum === 1) {
|
||||||
player1Ref.current?.getInternalPlayer()?.play();
|
player1Ref.current?.getInternalPlayer()?.play();
|
||||||
} else {
|
} else {
|
||||||
@@ -157,6 +159,11 @@ export const WebPlayerEngine = (props: WebPlayerEngineProps) => {
|
|||||||
const volume1 = convertToLogVolume(internalVolume1);
|
const volume1 = convertToLogVolume(internalVolume1);
|
||||||
const volume2 = convertToLogVolume(internalVolume2);
|
const volume2 = convertToLogVolume(internalVolume2);
|
||||||
|
|
||||||
|
const pauseBothPlayers = useCallback(() => {
|
||||||
|
player1Ref.current?.getInternalPlayer()?.pause();
|
||||||
|
player2Ref.current?.getInternalPlayer()?.pause();
|
||||||
|
}, []);
|
||||||
|
|
||||||
const handleOnError = (
|
const handleOnError = (
|
||||||
playerRef: React.RefObject<null | ReactPlayer>,
|
playerRef: React.RefObject<null | ReactPlayer>,
|
||||||
onEnded: () => void,
|
onEnded: () => void,
|
||||||
@@ -186,6 +193,7 @@ export const WebPlayerEngine = (props: WebPlayerEngineProps) => {
|
|||||||
networkRetryCountRef.current += 1;
|
networkRetryCountRef.current += 1;
|
||||||
const audio = target;
|
const audio = target;
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
|
pauseBothPlayers();
|
||||||
audio.load();
|
audio.load();
|
||||||
audio.play().catch(() => {
|
audio.play().catch(() => {
|
||||||
logFn.error(logMsg[LogCategory.PLAYER].playbackError, {
|
logFn.error(logMsg[LogCategory.PLAYER].playbackError, {
|
||||||
@@ -202,6 +210,7 @@ export const WebPlayerEngine = (props: WebPlayerEngineProps) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pauseBothPlayers();
|
||||||
if (error?.code === MediaError.MEDIA_ERR_DECODE) {
|
if (error?.code === MediaError.MEDIA_ERR_DECODE) {
|
||||||
onEnded();
|
onEnded();
|
||||||
} else {
|
} else {
|
||||||
@@ -217,6 +226,20 @@ export const WebPlayerEngine = (props: WebPlayerEngineProps) => {
|
|||||||
networkRetryCount2.current = 0;
|
networkRetryCount2.current = 0;
|
||||||
}, [src1, src2]);
|
}, [src1, src2]);
|
||||||
|
|
||||||
|
// When not transitioning, ensure only the active player can play (e.g. after seek/prev during transition)
|
||||||
|
useEffect(() => {
|
||||||
|
if (isTransitioning) return;
|
||||||
|
if (playerStatus !== PlayerStatus.PLAYING) {
|
||||||
|
pauseBothPlayers();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (playerNum === 1) {
|
||||||
|
player2Ref.current?.getInternalPlayer()?.pause();
|
||||||
|
} else {
|
||||||
|
player1Ref.current?.getInternalPlayer()?.pause();
|
||||||
|
}
|
||||||
|
}, [isTransitioning, playerNum, playerStatus, pauseBothPlayers]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const player1 = player1Ref.current?.getInternalPlayer();
|
const player1 = player1Ref.current?.getInternalPlayer();
|
||||||
if (player1 && player1 instanceof HTMLAudioElement) {
|
if (player1 && player1 instanceof HTMLAudioElement) {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useMemo, useRef } from 'react';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { useEffect, useRef } from 'react';
|
||||||
|
|
||||||
import { api } from '/@/renderer/api';
|
import { api } from '/@/renderer/api';
|
||||||
import { TranscodingConfig } from '/@/renderer/store';
|
import { TranscodingConfig } from '/@/renderer/store';
|
||||||
@@ -10,52 +11,71 @@ export function useSongUrl(
|
|||||||
transcode: TranscodingConfig,
|
transcode: TranscodingConfig,
|
||||||
): string | undefined {
|
): string | undefined {
|
||||||
const prior = useRef(['', '']);
|
const prior = useRef(['', '']);
|
||||||
|
const shouldReusePrior = Boolean(
|
||||||
|
song?._serverId && current && prior.current[0] === song._uniqueId && prior.current[1],
|
||||||
|
);
|
||||||
|
|
||||||
return useMemo(() => {
|
const { data: queryStreamUrl } = useQuery({
|
||||||
if (song?._serverId) {
|
enabled: Boolean(song?._serverId) && !shouldReusePrior,
|
||||||
// If we are the current track, we do not want a transcoding
|
queryFn: () =>
|
||||||
// reconfiguration to force a restart.
|
api.controller.getStreamUrl({
|
||||||
if (current && prior.current[0] === song._uniqueId) {
|
apiClientProps: { serverId: song!._serverId },
|
||||||
return prior.current[1];
|
|
||||||
}
|
|
||||||
|
|
||||||
const url = api.controller.getStreamUrl({
|
|
||||||
apiClientProps: { serverId: song._serverId },
|
|
||||||
query: {
|
query: {
|
||||||
bitrate: transcode.bitrate,
|
bitrate: transcode.bitrate,
|
||||||
format: transcode.format,
|
format: transcode.format,
|
||||||
id: song.id,
|
id: song!.id,
|
||||||
transcode: transcode.enabled,
|
transcode: transcode.enabled,
|
||||||
},
|
},
|
||||||
});
|
}),
|
||||||
|
queryKey: [
|
||||||
// transcoding enabled; save the updated result
|
|
||||||
prior.current = [song._uniqueId, url];
|
|
||||||
return url;
|
|
||||||
}
|
|
||||||
|
|
||||||
// no track; clear result
|
|
||||||
prior.current = ['', ''];
|
|
||||||
return undefined;
|
|
||||||
}, [
|
|
||||||
song?._serverId,
|
song?._serverId,
|
||||||
song?._uniqueId,
|
'stream-url',
|
||||||
song?.id,
|
song?.id,
|
||||||
current,
|
shouldReusePrior ? 'reuse-prior' : transcode.bitrate,
|
||||||
transcode.bitrate,
|
shouldReusePrior ? 'reuse-prior' : transcode.format,
|
||||||
transcode.format,
|
shouldReusePrior ? 'reuse-prior' : transcode.enabled,
|
||||||
transcode.enabled,
|
] as const,
|
||||||
]);
|
staleTime: 60 * 1000,
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!song?._serverId) {
|
||||||
|
prior.current = ['', ''];
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const getSongUrl = (song: QueueSong, transcode: TranscodingConfig) => {
|
if (!queryStreamUrl) {
|
||||||
return api.controller.getStreamUrl({
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save resolved URL to avoid restarting current track on transcode setting changes.
|
||||||
|
prior.current = [song._uniqueId, queryStreamUrl];
|
||||||
|
}, [song?._serverId, song?._uniqueId, queryStreamUrl]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!song?._serverId) {
|
||||||
|
prior.current = ['', ''];
|
||||||
|
}
|
||||||
|
}, [song?._serverId]);
|
||||||
|
|
||||||
|
return shouldReusePrior ? prior.current[1] : queryStreamUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getSongUrl = async (
|
||||||
|
song: QueueSong,
|
||||||
|
transcode: TranscodingConfig,
|
||||||
|
skipAutoTranscode?: boolean,
|
||||||
|
) => {
|
||||||
|
const url = await api.controller.getStreamUrl({
|
||||||
apiClientProps: { serverId: song._serverId },
|
apiClientProps: { serverId: song._serverId },
|
||||||
query: {
|
query: {
|
||||||
bitrate: transcode.bitrate,
|
bitrate: transcode.bitrate,
|
||||||
format: transcode.format,
|
format: transcode.format,
|
||||||
id: song.id,
|
id: song.id,
|
||||||
|
skipAutoTranscode,
|
||||||
transcode: transcode.enabled,
|
transcode: transcode.enabled,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
return url;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -37,6 +37,52 @@ import { toast } from '/@/shared/components/toast/toast';
|
|||||||
import { LibraryItem } from '/@/shared/types/domain-types';
|
import { LibraryItem } from '/@/shared/types/domain-types';
|
||||||
import { PlayerType } from '/@/shared/types/types';
|
import { PlayerType } from '/@/shared/types/types';
|
||||||
|
|
||||||
|
const CODEC_PROBES = [
|
||||||
|
{ codec: 'mp3', container: 'mp3', mime: 'audio/mpeg' },
|
||||||
|
{ codec: 'aac', container: 'mp4', mime: 'audio/mp4; codecs="mp4a.40.2"' },
|
||||||
|
{ codec: 'opus', container: 'ogg', mime: 'audio/ogg; codecs="opus"' },
|
||||||
|
{ codec: 'vorbis', container: 'ogg', mime: 'audio/ogg; codecs="vorbis"' },
|
||||||
|
{ codec: 'flac', container: 'flac', mime: 'audio/flac' },
|
||||||
|
{ codec: 'wav', container: 'wav', mime: 'audio/wav' },
|
||||||
|
{ codec: 'alac', container: 'mp4', mime: 'audio/mp4; codecs="alac"' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const DEFAULT_TRANSCODING_PROFILES = [
|
||||||
|
{ audioCodec: 'opus', container: 'ogg', protocol: 'http' },
|
||||||
|
{ audioCodec: 'mp3', container: 'mp3', protocol: 'http' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const DIRECT_PLAY_PROFILES: {
|
||||||
|
audioCodecs: string[];
|
||||||
|
containers: string[];
|
||||||
|
protocols: string[];
|
||||||
|
}[] = [];
|
||||||
|
|
||||||
|
export function getDefaultTranscodingProfiles() {
|
||||||
|
return DEFAULT_TRANSCODING_PROFILES;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getDirectPlayProfiles() {
|
||||||
|
return DIRECT_PLAY_PROFILES;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Shamelessly taken from NavidromeUI
|
||||||
|
function detectBrowserProfile() {
|
||||||
|
const audio = new Audio();
|
||||||
|
|
||||||
|
for (const { codec, container, mime } of CODEC_PROBES) {
|
||||||
|
if (audio.canPlayType(mime) === 'probably') {
|
||||||
|
DIRECT_PLAY_PROFILES.push({
|
||||||
|
audioCodecs: [codec],
|
||||||
|
containers: [container],
|
||||||
|
protocols: ['http'],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return DIRECT_PLAY_PROFILES;
|
||||||
|
}
|
||||||
|
|
||||||
export const AudioPlayers = () => {
|
export const AudioPlayers = () => {
|
||||||
const playbackType = usePlaybackType();
|
const playbackType = usePlaybackType();
|
||||||
const serverId = useCurrentServerId();
|
const serverId = useCurrentServerId();
|
||||||
@@ -49,6 +95,11 @@ export const AudioPlayers = () => {
|
|||||||
} = usePlaybackSettings();
|
} = usePlaybackSettings();
|
||||||
const { setWebAudio, webAudio: audioContext } = useWebAudio();
|
const { setWebAudio, webAudio: audioContext } = useWebAudio();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
console.log('getDirectPlayProfiles');
|
||||||
|
detectBrowserProfile();
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<SleepTimerHook />
|
<SleepTimerHook />
|
||||||
|
|||||||
@@ -118,7 +118,7 @@ export const FullScreenPlayerQueue = () => {
|
|||||||
<FullScreenSimilarSongs />
|
<FullScreenSimilarSongs />
|
||||||
</div>
|
</div>
|
||||||
) : activeTab === 'lyrics' ? (
|
) : activeTab === 'lyrics' ? (
|
||||||
<Lyrics />
|
<Lyrics fadeOutNoLyricsMessage={false} />
|
||||||
) : activeTab === 'visualizer' && type === PlayerType.WEB && webAudio ? (
|
) : activeTab === 'visualizer' && type === PlayerType.WEB && webAudio ? (
|
||||||
<Suspense fallback={<></>}>
|
<Suspense fallback={<></>}>
|
||||||
{visualizerType === 'butterchurn' ? (
|
{visualizerType === 'butterchurn' ? (
|
||||||
|
|||||||
@@ -34,7 +34,10 @@ import { LibraryItem } from '/@/shared/types/domain-types';
|
|||||||
export const LeftControls = () => {
|
export const LeftControls = () => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { setSideBar } = useAppStoreActions();
|
const { setSideBar } = useAppStoreActions();
|
||||||
const { expanded: isFullScreenPlayerExpanded } = useFullScreenPlayerStore();
|
const {
|
||||||
|
expanded: isFullScreenPlayerExpanded,
|
||||||
|
visualizerExpanded: isFullScreenVisualizerExpanded,
|
||||||
|
} = useFullScreenPlayerStore();
|
||||||
const setFullScreenPlayerStore = useSetFullScreenPlayerStore();
|
const setFullScreenPlayerStore = useSetFullScreenPlayerStore();
|
||||||
|
|
||||||
const { collapsed, image } = useAppStore(
|
const { collapsed, image } = useAppStore(
|
||||||
@@ -62,7 +65,14 @@ export const LeftControls = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
e?.stopPropagation();
|
e?.stopPropagation();
|
||||||
setFullScreenPlayerStore({ expanded: !isFullScreenPlayerExpanded });
|
|
||||||
|
const shouldClose = isFullScreenPlayerExpanded || isFullScreenVisualizerExpanded;
|
||||||
|
|
||||||
|
if (shouldClose) {
|
||||||
|
setFullScreenPlayerStore({ expanded: false, visualizerExpanded: false });
|
||||||
|
} else {
|
||||||
|
setFullScreenPlayerStore({ expanded: true });
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleToggleSidebarImage = (e?: MouseEvent<HTMLButtonElement>) => {
|
const handleToggleSidebarImage = (e?: MouseEvent<HTMLButtonElement>) => {
|
||||||
|
|||||||
@@ -556,7 +556,7 @@ export const MobileFullscreenPlayer = () => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.lyricsContent}>
|
<div className={styles.lyricsContent}>
|
||||||
<Lyrics />
|
<Lyrics fadeOutNoLyricsMessage={false} />
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -7,10 +7,10 @@ import { CustomPlayerbarSlider } from './playerbar-slider';
|
|||||||
import styles from './playerbar-waveform.module.css';
|
import styles from './playerbar-waveform.module.css';
|
||||||
|
|
||||||
import { useSongUrl } from '/@/renderer/features/player/audio-player/hooks/use-stream-url';
|
import { useSongUrl } from '/@/renderer/features/player/audio-player/hooks/use-stream-url';
|
||||||
|
import { PlayerbarSeekSlider } from '/@/renderer/features/player/components/playerbar-seek-slider';
|
||||||
import { usePlayer } from '/@/renderer/features/player/context/player-context';
|
import { usePlayer } from '/@/renderer/features/player/context/player-context';
|
||||||
import { BarAlign, usePlayerbarSlider, usePlayerSong, usePlayerTimestamp } from '/@/renderer/store';
|
import { BarAlign, usePlayerbarSlider, usePlayerSong, usePlayerTimestamp } from '/@/renderer/store';
|
||||||
import { useAppThemeColors, useColorScheme } from '/@/renderer/themes/use-app-theme';
|
import { useAppThemeColors, useColorScheme } from '/@/renderer/themes/use-app-theme';
|
||||||
import { Spinner } from '/@/shared/components/spinner/spinner';
|
|
||||||
import { Text } from '/@/shared/components/text/text';
|
import { Text } from '/@/shared/components/text/text';
|
||||||
|
|
||||||
export const PlayerbarWaveform = () => {
|
export const PlayerbarWaveform = () => {
|
||||||
@@ -18,6 +18,7 @@ export const PlayerbarWaveform = () => {
|
|||||||
const playerbarSlider = usePlayerbarSlider();
|
const playerbarSlider = usePlayerbarSlider();
|
||||||
const currentTime = usePlayerTimestamp();
|
const currentTime = usePlayerTimestamp();
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const audioElementRef = useRef<HTMLAudioElement>(document.createElement('audio'));
|
||||||
const { mediaSeekToTimestamp } = usePlayer();
|
const { mediaSeekToTimestamp } = usePlayer();
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
const [isDragging, setIsDragging] = useState(false);
|
const [isDragging, setIsDragging] = useState(false);
|
||||||
@@ -29,7 +30,7 @@ export const PlayerbarWaveform = () => {
|
|||||||
|
|
||||||
const songDuration = currentSong?.duration ? currentSong.duration / 1000 : 0;
|
const songDuration = currentSong?.duration ? currentSong.duration / 1000 : 0;
|
||||||
|
|
||||||
const streamUrl = useSongUrl(currentSong, true, { bitrate: 64, enabled: true, format: 'mp3' });
|
const streamUrl = useSongUrl(currentSong, true, { bitrate: 64, enabled: false, format: 'mp3' });
|
||||||
|
|
||||||
const { color } = useAppThemeColors();
|
const { color } = useAppThemeColors();
|
||||||
const primaryColor = (color['--theme-colors-primary'] as string) || 'rgb(53, 116, 252)';
|
const primaryColor = (color['--theme-colors-primary'] as string) || 'rgb(53, 116, 252)';
|
||||||
@@ -56,28 +57,20 @@ export const PlayerbarWaveform = () => {
|
|||||||
fillParent: true,
|
fillParent: true,
|
||||||
height: 18,
|
height: 18,
|
||||||
interact: false,
|
interact: false,
|
||||||
|
media: audioElementRef.current,
|
||||||
normalize: false,
|
normalize: false,
|
||||||
progressColor: primaryColor,
|
progressColor: primaryColor,
|
||||||
url: streamUrl || undefined,
|
|
||||||
waveColor,
|
waveColor,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Reset loading state when stream URL changes and ensure media is muted
|
// Reset loading state when stream URL changes and ensure media is muted
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
if (wavesurfer) {
|
}, [streamUrl]);
|
||||||
wavesurfer.setVolume(0);
|
|
||||||
const mediaElement = wavesurfer.getMediaElement();
|
|
||||||
if (mediaElement) {
|
|
||||||
mediaElement.muted = true;
|
|
||||||
mediaElement.volume = 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [streamUrl, wavesurfer]);
|
|
||||||
|
|
||||||
// Handle waveform ready state
|
// Handle waveform ready state
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!wavesurfer) return;
|
if (!wavesurfer || !streamUrl) return;
|
||||||
|
|
||||||
const handleReady = () => {
|
const handleReady = () => {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
@@ -90,20 +83,18 @@ export const PlayerbarWaveform = () => {
|
|||||||
|
|
||||||
wavesurfer.on('ready', handleReady);
|
wavesurfer.on('ready', handleReady);
|
||||||
|
|
||||||
// Check if already loaded
|
const waveformTimeout = setTimeout(
|
||||||
if (wavesurfer.getDuration() > 0) {
|
() => {
|
||||||
setIsLoading(false);
|
wavesurfer.load(streamUrl);
|
||||||
const mediaElement = wavesurfer.getMediaElement();
|
},
|
||||||
if (mediaElement) {
|
playerbarSlider?.loadingDelay ? playerbarSlider.loadingDelay * 1000 : 2000,
|
||||||
mediaElement.muted = true;
|
);
|
||||||
mediaElement.volume = 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
wavesurfer.un('ready', handleReady);
|
wavesurfer.un('ready', handleReady);
|
||||||
|
clearTimeout(waveformTimeout);
|
||||||
};
|
};
|
||||||
}, [wavesurfer]);
|
}, [wavesurfer, streamUrl, playerbarSlider.loadingDelay]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!wavesurfer) return;
|
if (!wavesurfer) return;
|
||||||
@@ -363,12 +354,12 @@ export const PlayerbarWaveform = () => {
|
|||||||
height: '100%',
|
height: '100%',
|
||||||
left: 0,
|
left: 0,
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
top: 0,
|
top: 3,
|
||||||
width: '100%',
|
width: '100%',
|
||||||
}}
|
}}
|
||||||
transition={{ duration: 0.2 }}
|
transition={{ duration: 0.2 }}
|
||||||
>
|
>
|
||||||
<Spinner container />
|
<PlayerbarSeekSlider max={songDuration} min={0} />
|
||||||
</motion.div>
|
</motion.div>
|
||||||
)}
|
)}
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
|
|||||||
@@ -13,7 +13,9 @@ import {
|
|||||||
} from '/@/renderer/store/sleep-timer.store';
|
} from '/@/renderer/store/sleep-timer.store';
|
||||||
import { ActionIcon } from '/@/shared/components/action-icon/action-icon';
|
import { ActionIcon } from '/@/shared/components/action-icon/action-icon';
|
||||||
import { Button } from '/@/shared/components/button/button';
|
import { Button } from '/@/shared/components/button/button';
|
||||||
|
import { Divider } from '/@/shared/components/divider/divider';
|
||||||
import { Flex } from '/@/shared/components/flex/flex';
|
import { Flex } from '/@/shared/components/flex/flex';
|
||||||
|
import { Grid } from '/@/shared/components/grid/grid';
|
||||||
import { Group } from '/@/shared/components/group/group';
|
import { Group } from '/@/shared/components/group/group';
|
||||||
import { NumberInput } from '/@/shared/components/number-input/number-input';
|
import { NumberInput } from '/@/shared/components/number-input/number-input';
|
||||||
import { Popover } from '/@/shared/components/popover/popover';
|
import { Popover } from '/@/shared/components/popover/popover';
|
||||||
@@ -30,6 +32,8 @@ const PRESET_OPTIONS = [
|
|||||||
{ minutes: 45, mode: 'timed' as const },
|
{ minutes: 45, mode: 'timed' as const },
|
||||||
{ minutes: 60, mode: 'timed' as const },
|
{ minutes: 60, mode: 'timed' as const },
|
||||||
{ minutes: 120, mode: 'timed' as const },
|
{ minutes: 120, mode: 'timed' as const },
|
||||||
|
{ minutes: 180, mode: 'timed' as const },
|
||||||
|
{ minutes: 240, mode: 'timed' as const },
|
||||||
];
|
];
|
||||||
|
|
||||||
function formatRemaining(totalSeconds: number): string {
|
function formatRemaining(totalSeconds: number): string {
|
||||||
@@ -209,7 +213,7 @@ export const SleepTimerButton = () => {
|
|||||||
</Popover.Target>
|
</Popover.Target>
|
||||||
<Popover.Dropdown>
|
<Popover.Dropdown>
|
||||||
<Stack gap="xs" p="xs">
|
<Stack gap="xs" p="xs">
|
||||||
<Text fw="600" size="sm" ta="center">
|
<Text fw="600" pb="md" size="sm" ta="center">
|
||||||
{t('player.sleepTimer', { postProcess: 'titleCase' })}
|
{t('player.sleepTimer', { postProcess: 'titleCase' })}
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
@@ -249,7 +253,8 @@ export const SleepTimerButton = () => {
|
|||||||
</Flex>
|
</Flex>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{PRESET_OPTIONS.map((option, index) => (
|
{PRESET_OPTIONS.filter((option) => option.mode === 'endOfSong').map(
|
||||||
|
(option, index) => (
|
||||||
<Button
|
<Button
|
||||||
fullWidth
|
fullWidth
|
||||||
justify="flex-start"
|
justify="flex-start"
|
||||||
@@ -259,11 +264,38 @@ export const SleepTimerButton = () => {
|
|||||||
handlePreset(option);
|
handlePreset(option);
|
||||||
}}
|
}}
|
||||||
size="xs"
|
size="xs"
|
||||||
variant="subtle"
|
variant="outline"
|
||||||
>
|
>
|
||||||
{getPresetLabel(option)}
|
{getPresetLabel(option)}
|
||||||
</Button>
|
</Button>
|
||||||
))}
|
),
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Divider my="md" />
|
||||||
|
|
||||||
|
<Grid gutter="xs">
|
||||||
|
{PRESET_OPTIONS.filter((option) => option.mode === 'timed').map(
|
||||||
|
(option, index) => (
|
||||||
|
<Grid.Col key={index} span={4}>
|
||||||
|
<Button
|
||||||
|
fullWidth
|
||||||
|
justify="flex-start"
|
||||||
|
key={index}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handlePreset(option);
|
||||||
|
}}
|
||||||
|
size="xs"
|
||||||
|
variant="outline"
|
||||||
|
>
|
||||||
|
{getPresetLabel(option)}
|
||||||
|
</Button>
|
||||||
|
</Grid.Col>
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<Divider my="md" />
|
||||||
|
|
||||||
{!showCustom ? (
|
{!showCustom ? (
|
||||||
<Button
|
<Button
|
||||||
@@ -274,7 +306,8 @@ export const SleepTimerButton = () => {
|
|||||||
setShowCustom(true);
|
setShowCustom(true);
|
||||||
}}
|
}}
|
||||||
size="xs"
|
size="xs"
|
||||||
variant="subtle"
|
ta="center"
|
||||||
|
variant="outline"
|
||||||
>
|
>
|
||||||
{t('player.sleepTimer_custom', { postProcess: 'sentenceCase' })}
|
{t('player.sleepTimer_custom', { postProcess: 'sentenceCase' })}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -46,22 +46,10 @@ export const useSaveQueue = () => {
|
|||||||
throw new Error(t('error.serverRequired', { postProcess: 'sentenceCase' }));
|
throw new Error(t('error.serverRequired', { postProcess: 'sentenceCase' }));
|
||||||
}
|
}
|
||||||
|
|
||||||
const { player, queue } = usePlayerStore.getState();
|
const state = usePlayerStore.getState();
|
||||||
let uniqueIds: string[] = [];
|
const queue = state.getQueue();
|
||||||
|
|
||||||
if (queue.shuffled.length > 0) {
|
if (queue.items.some((item) => item._serverId !== serverId)) {
|
||||||
for (const shuffledIndex of queue.shuffled) {
|
|
||||||
uniqueIds.push(queue.default[shuffledIndex]);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
uniqueIds = queue.default;
|
|
||||||
}
|
|
||||||
|
|
||||||
const songs: string[] = [];
|
|
||||||
|
|
||||||
if (uniqueIds.length > 0) {
|
|
||||||
for (const song of uniqueIds) {
|
|
||||||
if (queue.songs[song]._serverId !== serverId) {
|
|
||||||
toast.error({
|
toast.error({
|
||||||
message: t('error.multipleServerSaveQueueError', {
|
message: t('error.multipleServerSaveQueueError', {
|
||||||
postProcess: 'sentenceCase',
|
postProcess: 'sentenceCase',
|
||||||
@@ -74,17 +62,13 @@ export const useSaveQueue = () => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
songs?.push(queue.songs[song].id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await api.controller.savePlayQueue({
|
await api.controller.savePlayQueue({
|
||||||
apiClientProps: { serverId },
|
apiClientProps: { serverId },
|
||||||
query: {
|
query: {
|
||||||
currentIndex: queue.default.length > 0 ? player.index : undefined,
|
currentIndex: queue.items.length > 0 ? state.player.index : undefined,
|
||||||
positionMs: useTimestampStoreBase.getState().timestamp * 1000,
|
positionMs: useTimestampStoreBase.getState().timestamp * 1000,
|
||||||
songs,
|
songs: queue.items.map((item) => item.id),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -302,9 +302,9 @@ const PlaylistDetailTrackViewContent = ({ data }: { data: PlaylistSongListRespon
|
|||||||
};
|
};
|
||||||
|
|
||||||
const PlaylistDetailSongList = ({ data }: { data: PlaylistSongListResponse }) => {
|
const PlaylistDetailSongList = ({ data }: { data: PlaylistSongListResponse }) => {
|
||||||
const { displayMode } = useListContext();
|
const { displayMode, mode } = useListContext();
|
||||||
|
|
||||||
if (displayMode === LibraryItem.ALBUM) {
|
if (mode !== 'edit' && displayMode === LibraryItem.ALBUM) {
|
||||||
return <PlaylistDetailAlbumView data={data} />;
|
return <PlaylistDetailAlbumView data={data} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+29
-107
@@ -44,13 +44,7 @@ import { Modal } from '/@/shared/components/modal/modal';
|
|||||||
import { Tooltip } from '/@/shared/components/tooltip/tooltip';
|
import { Tooltip } from '/@/shared/components/tooltip/tooltip';
|
||||||
import { useDisclosure } from '/@/shared/hooks/use-disclosure';
|
import { useDisclosure } from '/@/shared/hooks/use-disclosure';
|
||||||
import { useLocalStorage } from '/@/shared/hooks/use-local-storage';
|
import { useLocalStorage } from '/@/shared/hooks/use-local-storage';
|
||||||
import {
|
import { LibraryItem, Song, SongListSort, SortOrder } from '/@/shared/types/domain-types';
|
||||||
LibraryItem,
|
|
||||||
Playlist,
|
|
||||||
SongListSort,
|
|
||||||
SortOrder,
|
|
||||||
UpdatePlaylistBody,
|
|
||||||
} from '/@/shared/types/domain-types';
|
|
||||||
import { ItemListKey } from '/@/shared/types/types';
|
import { ItemListKey } from '/@/shared/types/types';
|
||||||
|
|
||||||
interface PlaylistDetailSongListHeaderFiltersProps {
|
interface PlaylistDetailSongListHeaderFiltersProps {
|
||||||
@@ -124,7 +118,7 @@ export const PlaylistDetailSongListHeaderFilters = ({
|
|||||||
isSmartPlaylist,
|
isSmartPlaylist,
|
||||||
}: PlaylistDetailSongListHeaderFiltersProps) => {
|
}: PlaylistDetailSongListHeaderFiltersProps) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { listKey: listKeyFromContext, mode, setMode } = useListContext();
|
const { listData, listKey: listKeyFromContext, mode, setMode } = useListContext();
|
||||||
const { playlistId } = useParams() as { playlistId: string };
|
const { playlistId } = useParams() as { playlistId: string };
|
||||||
const playlistTarget = usePlaylistTarget();
|
const playlistTarget = usePlaylistTarget();
|
||||||
const { setPlaylistBehavior } = useSettingsStoreActions();
|
const { setPlaylistBehavior } = useSettingsStoreActions();
|
||||||
@@ -170,10 +164,19 @@ export const PlaylistDetailSongListHeaderFilters = ({
|
|||||||
key: 'playlist-header-collapsed',
|
key: 'playlist-header-collapsed',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const tracks = useMemo(() => {
|
||||||
|
if (!listData?.length) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return (listData as Song[]).map((song) => song.id);
|
||||||
|
}, [listData]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Flex justify="space-between" ref={containerRef}>
|
<Flex justify="space-between" ref={containerRef}>
|
||||||
<Group gap="sm" w="100%">
|
<Group gap="sm" w="100%">
|
||||||
<Button
|
<Button
|
||||||
|
disabled={isEditMode}
|
||||||
leftSection={<Icon icon="arrowLeftRight" />}
|
leftSection={<Icon icon="arrowLeftRight" />}
|
||||||
onClick={handleToggleDisplayMode}
|
onClick={handleToggleDisplayMode}
|
||||||
variant="subtle"
|
variant="subtle"
|
||||||
@@ -199,15 +202,15 @@ export const PlaylistDetailSongListHeaderFilters = ({
|
|||||||
<MoreButton onClick={handleMore} />
|
<MoreButton onClick={handleMore} />
|
||||||
</Group>
|
</Group>
|
||||||
<Group gap="sm" wrap="nowrap">
|
<Group gap="sm" wrap="nowrap">
|
||||||
{isViewEditMode && <SaveAndReplaceButton mode={mode} playlist={detailQuery.data} />}
|
{isViewEditMode && <SaveAndReplaceButton mode={mode} songIds={tracks} />}
|
||||||
{isViewEditMode && (
|
{isViewEditMode && (
|
||||||
<Button
|
<Button
|
||||||
onClick={() => setMode?.(mode === 'edit' ? 'view' : 'edit')}
|
onClick={() => setMode?.(mode === 'edit' ? 'view' : 'edit')}
|
||||||
uppercase
|
uppercase
|
||||||
variant="subtle"
|
variant={mode === 'edit' ? 'state-error' : 'subtle'}
|
||||||
>
|
>
|
||||||
{mode === 'edit'
|
{mode === 'edit'
|
||||||
? t('common.view', { postProcess: 'titleCase' })
|
? t('common.cancel', { postProcess: 'titleCase' })
|
||||||
: t('common.edit', { postProcess: 'titleCase' })}
|
: t('common.edit', { postProcess: 'titleCase' })}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
@@ -248,39 +251,33 @@ export const PlaylistDetailSongListHeaderFilters = ({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const openSaveAndReplaceModal = (playlistId: string, updateBody: UpdatePlaylistBody) => {
|
export const openSaveAndReplaceModal = (
|
||||||
|
playlistId: string,
|
||||||
|
songIds: string[],
|
||||||
|
onSuccess: () => void,
|
||||||
|
) => {
|
||||||
openContextModal({
|
openContextModal({
|
||||||
innerProps: { playlistId, updateBody },
|
innerProps: { onSuccess, playlistId, songIds },
|
||||||
modalKey: 'saveAndReplace',
|
modalKey: 'saveAndReplace',
|
||||||
size: 'sm',
|
size: 'sm',
|
||||||
title: i18n.t('common.saveAndReplace', { postProcess: 'titleCase' }) as string,
|
title: i18n.t('common.saveAndReplace', { postProcess: 'titleCase' }) as string,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const SaveAndReplaceButton = ({
|
const SaveAndReplaceButton = ({ mode, songIds }: { mode?: 'edit' | 'view'; songIds: string[] }) => {
|
||||||
mode,
|
|
||||||
playlist,
|
|
||||||
}: {
|
|
||||||
mode: 'edit' | 'view' | undefined;
|
|
||||||
playlist: Playlist | undefined;
|
|
||||||
}) => {
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { playlistId } = useParams() as { playlistId: string };
|
const { playlistId } = useParams() as { playlistId: string };
|
||||||
|
const { setMode } = useListContext();
|
||||||
|
|
||||||
|
const onSuccess = useCallback(() => {
|
||||||
|
setMode?.('view');
|
||||||
|
}, [setMode]);
|
||||||
|
|
||||||
const handleOpenModal = useCallback(() => {
|
const handleOpenModal = useCallback(() => {
|
||||||
if (!playlistId || !playlist) return;
|
if (!playlistId) return;
|
||||||
|
|
||||||
const updateBody: UpdatePlaylistBody = {
|
openSaveAndReplaceModal(playlistId, songIds, onSuccess);
|
||||||
comment: playlist.description ?? '',
|
}, [playlistId, songIds, onSuccess]);
|
||||||
name: playlist.name,
|
|
||||||
ownerId: playlist.ownerId ?? '',
|
|
||||||
public: playlist.public ?? false,
|
|
||||||
queryBuilderRules: playlist.rules ?? undefined,
|
|
||||||
sync: playlist.sync ?? false,
|
|
||||||
};
|
|
||||||
|
|
||||||
openSaveAndReplaceModal(playlistId, updateBody);
|
|
||||||
}, [playlistId, playlist]);
|
|
||||||
|
|
||||||
if (mode === 'view') {
|
if (mode === 'view') {
|
||||||
return null;
|
return null;
|
||||||
@@ -297,78 +294,3 @@ const SaveAndReplaceButton = ({
|
|||||||
</Button>
|
</Button>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
// const GenreFilterSelection = () => {
|
|
||||||
// const { t } = useTranslation();
|
|
||||||
// const { playlistId } = useParams() as { playlistId: string };
|
|
||||||
// const serverId = useCurrentServerId();
|
|
||||||
|
|
||||||
// const { data } = useQuery(playlistsQueries.songList({ query: { id: playlistId }, serverId }));
|
|
||||||
|
|
||||||
// const genres = useMemo(() => {
|
|
||||||
// const uniqueGenres = new Map<string, string>();
|
|
||||||
|
|
||||||
// data?.items.forEach((song) => {
|
|
||||||
// song.genres.forEach((genre) => {
|
|
||||||
// if (genre.id) {
|
|
||||||
// uniqueGenres.set(genre.id, genre.name);
|
|
||||||
// }
|
|
||||||
// });
|
|
||||||
// });
|
|
||||||
|
|
||||||
// return Array.from(uniqueGenres.entries()).map(([id, name]) => ({
|
|
||||||
// label: name,
|
|
||||||
// value: id,
|
|
||||||
// }));
|
|
||||||
// }, [data?.items]);
|
|
||||||
|
|
||||||
// return (
|
|
||||||
// <Stack p="md" style={{ background: 'var(--theme-colors-surface)', height: '12rem' }}>
|
|
||||||
// <Text>{t('filter.genre', { postProcess: 'titleCase' })}</Text>
|
|
||||||
// <ScrollArea>
|
|
||||||
// <ul style={{ listStyle: 'none', margin: 0, padding: 0 }}>
|
|
||||||
// {genres.map((genre) => (
|
|
||||||
// <li key={genre.value}>{genre.label}</li>
|
|
||||||
// ))}
|
|
||||||
// </ul>
|
|
||||||
// </ScrollArea>
|
|
||||||
// </Stack>
|
|
||||||
// );
|
|
||||||
// };
|
|
||||||
|
|
||||||
// const ArtistFilterSelection = () => {
|
|
||||||
// const { t } = useTranslation();
|
|
||||||
// const { playlistId } = useParams() as { playlistId: string };
|
|
||||||
// const serverId = useCurrentServerId();
|
|
||||||
|
|
||||||
// const { data } = useQuery(playlistsQueries.songList({ query: { id: playlistId }, serverId }));
|
|
||||||
|
|
||||||
// const artists = useMemo(() => {
|
|
||||||
// const uniqueArtists = new Map<string, string>();
|
|
||||||
|
|
||||||
// data?.items.forEach((song) => {
|
|
||||||
// song.artists.forEach((artist) => {
|
|
||||||
// if (artist.id) {
|
|
||||||
// uniqueArtists.set(artist.id, artist.name);
|
|
||||||
// }
|
|
||||||
// });
|
|
||||||
// });
|
|
||||||
|
|
||||||
// return Array.from(uniqueArtists.entries()).map(([id, name]) => ({
|
|
||||||
// label: name,
|
|
||||||
// value: id,
|
|
||||||
// }));
|
|
||||||
// }, [data?.items]);
|
|
||||||
|
|
||||||
// return (
|
|
||||||
// <Stack style={{ height: '12rem' }}>
|
|
||||||
// <Text>{t('filter.artist', { postProcess: 'titleCase' })}</Text>
|
|
||||||
// <ScrollArea>
|
|
||||||
// <ul style={{ listStyle: 'none', margin: 0, padding: 0 }}>
|
|
||||||
// {artists.map((artist) => (
|
|
||||||
// <li key={artist.value}>{artist.label}</li>
|
|
||||||
// ))}
|
|
||||||
// </ul>
|
|
||||||
// </ScrollArea>
|
|
||||||
// </Stack>
|
|
||||||
// );
|
|
||||||
// };
|
|
||||||
|
|||||||
@@ -2,21 +2,20 @@ import { closeAllModals, ContextModalProps } from '@mantine/modals';
|
|||||||
import { useCallback } from 'react';
|
import { useCallback } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
import { useUpdatePlaylist } from '/@/renderer/features/playlists/mutations/update-playlist-mutation';
|
import { useUpdatePlaylistTracks } from '/@/renderer/features/playlists/mutations/update-playlist-tracks-mutation';
|
||||||
import { useCurrentServerId } from '/@/renderer/store';
|
import { useCurrentServerId } from '/@/renderer/store';
|
||||||
import { ConfirmModal } from '/@/shared/components/modal/modal';
|
import { ConfirmModal } from '/@/shared/components/modal/modal';
|
||||||
import { Text } from '/@/shared/components/text/text';
|
import { Text } from '/@/shared/components/text/text';
|
||||||
import { toast } from '/@/shared/components/toast/toast';
|
import { toast } from '/@/shared/components/toast/toast';
|
||||||
import { UpdatePlaylistBody } from '/@/shared/types/domain-types';
|
|
||||||
|
|
||||||
export const SaveAndReplaceContextModal = ({
|
export const SaveAndReplaceContextModal = ({
|
||||||
innerProps,
|
innerProps,
|
||||||
}: ContextModalProps<{ playlistId: string; updateBody: UpdatePlaylistBody }>) => {
|
}: ContextModalProps<{ onSuccess: () => void; playlistId: string; songIds: string[] }>) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { playlistId, updateBody } = innerProps;
|
const { onSuccess, playlistId, songIds } = innerProps;
|
||||||
const serverId = useCurrentServerId();
|
const serverId = useCurrentServerId();
|
||||||
|
|
||||||
const updatePlaylistMutation = useUpdatePlaylist({});
|
const updatePlaylistMutation = useUpdatePlaylistTracks({});
|
||||||
|
|
||||||
const handleConfirm = useCallback(() => {
|
const handleConfirm = useCallback(() => {
|
||||||
if (!serverId || !playlistId) {
|
if (!serverId || !playlistId) {
|
||||||
@@ -27,8 +26,10 @@ export const SaveAndReplaceContextModal = ({
|
|||||||
updatePlaylistMutation.mutate(
|
updatePlaylistMutation.mutate(
|
||||||
{
|
{
|
||||||
apiClientProps: { serverId },
|
apiClientProps: { serverId },
|
||||||
body: updateBody,
|
body: {
|
||||||
query: { id: playlistId },
|
id: playlistId,
|
||||||
|
songIds,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
onError: (err) => {
|
onError: (err) => {
|
||||||
@@ -41,6 +42,7 @@ export const SaveAndReplaceContextModal = ({
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
|
onSuccess();
|
||||||
closeAllModals();
|
closeAllModals();
|
||||||
toast.success({
|
toast.success({
|
||||||
message: t('form.editPlaylist.success', {
|
message: t('form.editPlaylist.success', {
|
||||||
@@ -50,11 +52,11 @@ export const SaveAndReplaceContextModal = ({
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}, [t, serverId, playlistId, updateBody, updatePlaylistMutation]);
|
}, [serverId, playlistId, updatePlaylistMutation, songIds, t, onSuccess]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ConfirmModal loading={updatePlaylistMutation.isPending} onConfirm={handleConfirm}>
|
<ConfirmModal loading={updatePlaylistMutation.isPending} onConfirm={handleConfirm}>
|
||||||
<Text>{t('form.editPlaylist.editNote', { postProcess: 'sentenceCase' })}</Text>
|
<Text>{t('common.areYouSure', { postProcess: 'sentenceCase' })}</Text>
|
||||||
</ConfirmModal>
|
</ConfirmModal>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -3,8 +3,13 @@ import { AxiosError } from 'axios';
|
|||||||
|
|
||||||
import { api } from '/@/renderer/api';
|
import { api } from '/@/renderer/api';
|
||||||
import { queryKeys } from '/@/renderer/api/query-keys';
|
import { queryKeys } from '/@/renderer/api/query-keys';
|
||||||
|
import { infiniteLoaderDataQueryKey } from '/@/renderer/components/item-list/helpers/item-list-infinite-loader';
|
||||||
import { MutationHookArgs } from '/@/renderer/lib/react-query';
|
import { MutationHookArgs } from '/@/renderer/lib/react-query';
|
||||||
import { CreatePlaylistArgs, CreatePlaylistResponse } from '/@/shared/types/domain-types';
|
import {
|
||||||
|
CreatePlaylistArgs,
|
||||||
|
CreatePlaylistResponse,
|
||||||
|
LibraryItem,
|
||||||
|
} from '/@/shared/types/domain-types';
|
||||||
|
|
||||||
export const useCreatePlaylist = (args: MutationHookArgs) => {
|
export const useCreatePlaylist = (args: MutationHookArgs) => {
|
||||||
const { options } = args || {};
|
const { options } = args || {};
|
||||||
@@ -17,12 +22,19 @@ export const useCreatePlaylist = (args: MutationHookArgs) => {
|
|||||||
apiClientProps: { serverId: args.apiClientProps.serverId },
|
apiClientProps: { serverId: args.apiClientProps.serverId },
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
onSuccess: (_args, variables) => {
|
...options,
|
||||||
|
onSuccess: (data, variables, context) => {
|
||||||
|
const { serverId } = variables.apiClientProps;
|
||||||
queryClient.invalidateQueries({
|
queryClient.invalidateQueries({
|
||||||
exact: false,
|
exact: false,
|
||||||
queryKey: queryKeys.playlists.list(variables.apiClientProps.serverId),
|
queryKey: queryKeys.playlists.root(serverId),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
exact: false,
|
||||||
|
queryKey: infiniteLoaderDataQueryKey(serverId, LibraryItem.PLAYLIST),
|
||||||
|
});
|
||||||
|
options?.onSuccess?.(data, variables, context);
|
||||||
},
|
},
|
||||||
...options,
|
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -3,13 +3,18 @@ import { AxiosError } from 'axios';
|
|||||||
|
|
||||||
import { api } from '/@/renderer/api';
|
import { api } from '/@/renderer/api';
|
||||||
import { queryKeys } from '/@/renderer/api/query-keys';
|
import { queryKeys } from '/@/renderer/api/query-keys';
|
||||||
|
import { infiniteLoaderDataQueryKey } from '/@/renderer/components/item-list/helpers/item-list-infinite-loader';
|
||||||
import {
|
import {
|
||||||
applyDeletePlaylistOptimisticUpdates,
|
applyDeletePlaylistOptimisticUpdates,
|
||||||
PreviousQueryData,
|
PreviousQueryData,
|
||||||
restorePlaylistQueryData,
|
restorePlaylistQueryData,
|
||||||
} from '/@/renderer/features/playlists/mutations/playlist-optimistic-updates';
|
} from '/@/renderer/features/playlists/mutations/playlist-optimistic-updates';
|
||||||
import { MutationHookArgs } from '/@/renderer/lib/react-query';
|
import { MutationHookArgs } from '/@/renderer/lib/react-query';
|
||||||
import { DeletePlaylistArgs, DeletePlaylistResponse } from '/@/shared/types/domain-types';
|
import {
|
||||||
|
DeletePlaylistArgs,
|
||||||
|
DeletePlaylistResponse,
|
||||||
|
LibraryItem,
|
||||||
|
} from '/@/shared/types/domain-types';
|
||||||
|
|
||||||
export const useDeletePlaylist = (args: MutationHookArgs) => {
|
export const useDeletePlaylist = (args: MutationHookArgs) => {
|
||||||
const { options } = args || {};
|
const { options } = args || {};
|
||||||
@@ -34,13 +39,20 @@ export const useDeletePlaylist = (args: MutationHookArgs) => {
|
|||||||
});
|
});
|
||||||
return applyDeletePlaylistOptimisticUpdates(queryClient, variables);
|
return applyDeletePlaylistOptimisticUpdates(queryClient, variables);
|
||||||
},
|
},
|
||||||
onSuccess: (_data, variables) => {
|
...options,
|
||||||
|
onSuccess: (data, variables, context) => {
|
||||||
|
const { serverId } = variables.apiClientProps;
|
||||||
queryClient.invalidateQueries({
|
queryClient.invalidateQueries({
|
||||||
exact: false,
|
exact: false,
|
||||||
queryKey: queryKeys.playlists.list(variables.apiClientProps.serverId),
|
queryKey: queryKeys.playlists.root(serverId),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
exact: false,
|
||||||
|
queryKey: infiniteLoaderDataQueryKey(serverId, LibraryItem.PLAYLIST),
|
||||||
|
});
|
||||||
|
options?.onSuccess?.(data, variables, context);
|
||||||
},
|
},
|
||||||
...options,
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { AxiosError } from 'axios';
|
||||||
|
|
||||||
|
import { api } from '/@/renderer/api';
|
||||||
|
import { queryKeys } from '/@/renderer/api/query-keys';
|
||||||
|
import { MutationHookArgs } from '/@/renderer/lib/react-query';
|
||||||
|
import { SetPlaylistSongsArgs } from '/@/shared/types/domain-types';
|
||||||
|
|
||||||
|
export const useUpdatePlaylistTracks = (args: MutationHookArgs) => {
|
||||||
|
const { options } = args || {};
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation<null, AxiosError, SetPlaylistSongsArgs, null>({
|
||||||
|
mutationFn: (args) =>
|
||||||
|
api.controller.setPlaylistSongs({
|
||||||
|
...args,
|
||||||
|
apiClientProps: { serverId: args.apiClientProps.serverId },
|
||||||
|
}),
|
||||||
|
onSuccess: (_data, variables) => {
|
||||||
|
const { apiClientProps, body } = variables;
|
||||||
|
const serverId = apiClientProps.serverId;
|
||||||
|
|
||||||
|
if (!serverId) return;
|
||||||
|
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: queryKeys.playlists.list(serverId),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (body?.id) {
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: queryKeys.playlists.detail(serverId, body.id),
|
||||||
|
});
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: queryKeys.playlists.songList(serverId, body.id),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
...options,
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -275,7 +275,9 @@ const PlaylistDetailSongListRoute = () => {
|
|||||||
|
|
||||||
<ListWithSidebarContainer>
|
<ListWithSidebarContainer>
|
||||||
<ListWithSidebarContainer.SidebarPortal>
|
<ListWithSidebarContainer.SidebarPortal>
|
||||||
|
<Suspense fallback={<Spinner container />}>
|
||||||
<PlaylistSongListFiltersSidebar />
|
<PlaylistSongListFiltersSidebar />
|
||||||
|
</Suspense>
|
||||||
</ListWithSidebarContainer.SidebarPortal>
|
</ListWithSidebarContainer.SidebarPortal>
|
||||||
<Suspense fallback={<Spinner container />}>
|
<Suspense fallback={<Spinner container />}>
|
||||||
<PlaylistDetailSongListContent />
|
<PlaylistDetailSongListContent />
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import { queryOptions } from '@tanstack/react-query';
|
import { infiniteQueryOptions, queryOptions } from '@tanstack/react-query';
|
||||||
|
|
||||||
import { api } from '/@/renderer/api';
|
import { api } from '/@/renderer/api';
|
||||||
import { queryKeys } from '/@/renderer/api/query-keys';
|
import { queryKeys } from '/@/renderer/api/query-keys';
|
||||||
import { QueryHookArgs } from '/@/renderer/lib/react-query';
|
import { QueryHookArgs } from '/@/renderer/lib/react-query';
|
||||||
import { SearchQuery } from '/@/shared/types/domain-types';
|
import { SearchQuery, SearchResponse } from '/@/shared/types/domain-types';
|
||||||
|
|
||||||
|
const SEARCH_PAGE_SIZE = 4;
|
||||||
|
|
||||||
export const searchQueries = {
|
export const searchQueries = {
|
||||||
search: (args: QueryHookArgs<SearchQuery>) => {
|
search: (args: QueryHookArgs<SearchQuery>) => {
|
||||||
@@ -18,4 +20,103 @@ export const searchQueries = {
|
|||||||
...args.options,
|
...args.options,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
searchAlbumArtistsInfinite: (args: {
|
||||||
|
enabled?: boolean;
|
||||||
|
searchTerm: string;
|
||||||
|
serverId: string | undefined;
|
||||||
|
}) => {
|
||||||
|
const { enabled = true, searchTerm, serverId } = args;
|
||||||
|
return infiniteQueryOptions({
|
||||||
|
enabled: Boolean(serverId && searchTerm && enabled),
|
||||||
|
getNextPageParam: (lastPage: SearchResponse, allPages: SearchResponse[]) => {
|
||||||
|
const len = lastPage.albumArtists.length;
|
||||||
|
if (len < SEARCH_PAGE_SIZE) return undefined;
|
||||||
|
return allPages.length * SEARCH_PAGE_SIZE;
|
||||||
|
},
|
||||||
|
initialPageParam: 0,
|
||||||
|
queryFn: ({ pageParam, signal }) => {
|
||||||
|
if (!serverId) throw new Error('serverId required');
|
||||||
|
const startIndex = (pageParam ?? 0) as number;
|
||||||
|
return api.controller.search({
|
||||||
|
apiClientProps: { serverId, signal },
|
||||||
|
query: {
|
||||||
|
albumArtistLimit: SEARCH_PAGE_SIZE,
|
||||||
|
albumArtistStartIndex: startIndex,
|
||||||
|
albumLimit: 0,
|
||||||
|
albumStartIndex: 0,
|
||||||
|
query: searchTerm,
|
||||||
|
songLimit: 0,
|
||||||
|
songStartIndex: 0,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
queryKey: queryKeys.search.infiniteList(serverId ?? '', 'albumArtists', searchTerm),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
searchAlbumsInfinite: (args: {
|
||||||
|
enabled?: boolean;
|
||||||
|
searchTerm: string;
|
||||||
|
serverId: string | undefined;
|
||||||
|
}) => {
|
||||||
|
const { enabled = true, searchTerm, serverId } = args;
|
||||||
|
return infiniteQueryOptions({
|
||||||
|
enabled: Boolean(serverId && searchTerm && enabled),
|
||||||
|
getNextPageParam: (lastPage: SearchResponse, allPages: SearchResponse[]) => {
|
||||||
|
const len = lastPage.albums.length;
|
||||||
|
if (len < SEARCH_PAGE_SIZE) return undefined;
|
||||||
|
return allPages.length * SEARCH_PAGE_SIZE;
|
||||||
|
},
|
||||||
|
initialPageParam: 0,
|
||||||
|
queryFn: ({ pageParam, signal }) => {
|
||||||
|
if (!serverId) throw new Error('serverId required');
|
||||||
|
const startIndex = (pageParam ?? 0) as number;
|
||||||
|
return api.controller.search({
|
||||||
|
apiClientProps: { serverId, signal },
|
||||||
|
query: {
|
||||||
|
albumArtistLimit: 0,
|
||||||
|
albumArtistStartIndex: 0,
|
||||||
|
albumLimit: SEARCH_PAGE_SIZE,
|
||||||
|
albumStartIndex: startIndex,
|
||||||
|
query: searchTerm,
|
||||||
|
songLimit: 0,
|
||||||
|
songStartIndex: 0,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
queryKey: queryKeys.search.infiniteList(serverId ?? '', 'albums', searchTerm),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
searchSongsInfinite: (args: {
|
||||||
|
enabled?: boolean;
|
||||||
|
searchTerm: string;
|
||||||
|
serverId: string | undefined;
|
||||||
|
}) => {
|
||||||
|
const { enabled = true, searchTerm, serverId } = args;
|
||||||
|
return infiniteQueryOptions({
|
||||||
|
enabled: Boolean(serverId && searchTerm && enabled),
|
||||||
|
getNextPageParam: (lastPage: SearchResponse, allPages: SearchResponse[]) => {
|
||||||
|
const len = lastPage.songs.length;
|
||||||
|
if (len < SEARCH_PAGE_SIZE) return undefined;
|
||||||
|
return allPages.length * SEARCH_PAGE_SIZE;
|
||||||
|
},
|
||||||
|
initialPageParam: 0,
|
||||||
|
queryFn: ({ pageParam, signal }) => {
|
||||||
|
if (!serverId) throw new Error('serverId required');
|
||||||
|
const startIndex = (pageParam ?? 0) as number;
|
||||||
|
return api.controller.search({
|
||||||
|
apiClientProps: { serverId, signal },
|
||||||
|
query: {
|
||||||
|
albumArtistLimit: 0,
|
||||||
|
albumArtistStartIndex: 0,
|
||||||
|
albumLimit: 0,
|
||||||
|
albumStartIndex: 0,
|
||||||
|
query: searchTerm,
|
||||||
|
songLimit: SEARCH_PAGE_SIZE,
|
||||||
|
songStartIndex: startIndex,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
queryKey: queryKeys.search.infiniteList(serverId ?? '', 'songs', searchTerm),
|
||||||
|
});
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
.root {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.heading {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--theme-spacing-xs);
|
||||||
|
align-items: center;
|
||||||
|
font-size: var(--theme-font-size-sm);
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.heading:hover {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.heading:focus-visible {
|
||||||
|
outline: 2px solid var(--theme-colors-primary);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chevron {
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 1rem;
|
||||||
|
height: 1rem;
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitle {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.items {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import { ReactNode, useCallback, useState } from 'react';
|
||||||
|
|
||||||
|
import styles from './collapsible-command-group.module.css';
|
||||||
|
|
||||||
|
import { Group } from '/@/shared/components/group/group';
|
||||||
|
import { Icon } from '/@/shared/components/icon/icon';
|
||||||
|
import { Paper } from '/@/shared/components/paper/paper';
|
||||||
|
|
||||||
|
interface CollapsibleCommandGroupProps {
|
||||||
|
children: ReactNode;
|
||||||
|
defaultExpanded?: boolean;
|
||||||
|
expanded?: boolean;
|
||||||
|
heading: string;
|
||||||
|
onToggle?: () => void;
|
||||||
|
subtitle?: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CollapsibleCommandGroup({
|
||||||
|
children,
|
||||||
|
defaultExpanded = true,
|
||||||
|
expanded: controlledExpanded,
|
||||||
|
heading,
|
||||||
|
onToggle,
|
||||||
|
subtitle,
|
||||||
|
}: CollapsibleCommandGroupProps) {
|
||||||
|
const [internalExpanded, setInternalExpanded] = useState(defaultExpanded);
|
||||||
|
|
||||||
|
const isControlled = controlledExpanded !== undefined && onToggle !== undefined;
|
||||||
|
const expanded = isControlled ? controlledExpanded : internalExpanded;
|
||||||
|
|
||||||
|
const toggle = useCallback(() => {
|
||||||
|
if (isControlled) {
|
||||||
|
onToggle?.();
|
||||||
|
} else {
|
||||||
|
setInternalExpanded((prev) => !prev);
|
||||||
|
}
|
||||||
|
}, [isControlled, onToggle]);
|
||||||
|
|
||||||
|
const handleKeyDown = useCallback(
|
||||||
|
(e: React.KeyboardEvent) => {
|
||||||
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
|
e.preventDefault();
|
||||||
|
toggle();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[toggle],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.root}>
|
||||||
|
<Paper p="sm" radius="sm" withBorder>
|
||||||
|
<div
|
||||||
|
className={styles.heading}
|
||||||
|
onClick={toggle}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
>
|
||||||
|
<Icon className={styles.chevron} icon={expanded ? 'dropdown' : 'arrowRightS'} />
|
||||||
|
<Group justify="space-between" w="100%">
|
||||||
|
<span>{heading}</span>
|
||||||
|
{subtitle && <span className={styles.subtitle}>{subtitle}</span>}
|
||||||
|
</Group>
|
||||||
|
</div>
|
||||||
|
</Paper>
|
||||||
|
{expanded && <div className={styles.items}>{children}</div>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,45 +1,141 @@
|
|||||||
import { useQuery } from '@tanstack/react-query';
|
import { useCallback, useRef, useState } from 'react';
|
||||||
import { Fragment, useCallback, useRef, useState } from 'react';
|
|
||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
import { generatePath, useNavigate } from 'react-router';
|
|
||||||
|
|
||||||
import { searchQueries } from '/@/renderer/features/search/api/search-api';
|
|
||||||
import { Command, CommandPalettePages } from '/@/renderer/features/search/components/command';
|
import { Command, CommandPalettePages } from '/@/renderer/features/search/components/command';
|
||||||
import { CommandItemSelectable } from '/@/renderer/features/search/components/command-item-selectable';
|
|
||||||
import { GoToCommands } from '/@/renderer/features/search/components/go-to-commands';
|
import { GoToCommands } from '/@/renderer/features/search/components/go-to-commands';
|
||||||
import { HomeCommands } from '/@/renderer/features/search/components/home-commands';
|
import { HomeCommands } from '/@/renderer/features/search/components/home-commands';
|
||||||
import { LibraryCommandItem } from '/@/renderer/features/search/components/library-command-item';
|
import { SearchAlbumArtistsSection } from '/@/renderer/features/search/components/search-album-artists-section';
|
||||||
|
import { SearchAlbumsSection } from '/@/renderer/features/search/components/search-albums-section';
|
||||||
|
import { SearchSongsSection } from '/@/renderer/features/search/components/search-songs-section';
|
||||||
import { ServerCommands } from '/@/renderer/features/search/components/server-commands';
|
import { ServerCommands } from '/@/renderer/features/search/components/server-commands';
|
||||||
import { AppRoute } from '/@/renderer/router/routes';
|
import { useAppStore } from '/@/renderer/store';
|
||||||
import { useCurrentServer } from '/@/renderer/store';
|
|
||||||
import { ActionIcon } from '/@/shared/components/action-icon/action-icon';
|
import { ActionIcon } from '/@/shared/components/action-icon/action-icon';
|
||||||
import { Box } from '/@/shared/components/box/box';
|
import { Breadcrumb } from '/@/shared/components/breadcrumb/breadcrumb';
|
||||||
import { Button } from '/@/shared/components/button/button';
|
import { Button } from '/@/shared/components/button/button';
|
||||||
|
import { Divider } from '/@/shared/components/divider/divider';
|
||||||
import { Group } from '/@/shared/components/group/group';
|
import { Group } from '/@/shared/components/group/group';
|
||||||
import { Icon } from '/@/shared/components/icon/icon';
|
import { Icon } from '/@/shared/components/icon/icon';
|
||||||
import { Kbd } from '/@/shared/components/kbd/kbd';
|
import { Kbd } from '/@/shared/components/kbd/kbd';
|
||||||
import { Modal } from '/@/shared/components/modal/modal';
|
import { Modal } from '/@/shared/components/modal/modal';
|
||||||
import { Spinner } from '/@/shared/components/spinner/spinner';
|
import { Stack } from '/@/shared/components/stack/stack';
|
||||||
import { TextInput } from '/@/shared/components/text-input/text-input';
|
import { TextInput } from '/@/shared/components/text-input/text-input';
|
||||||
import { useDebouncedValue } from '/@/shared/hooks/use-debounced-value';
|
import { useDebouncedValue } from '/@/shared/hooks/use-debounced-value';
|
||||||
import { useDisclosure } from '/@/shared/hooks/use-disclosure';
|
import { useDisclosure } from '/@/shared/hooks/use-disclosure';
|
||||||
import { LibraryItem } from '/@/shared/types/domain-types';
|
|
||||||
|
|
||||||
interface CommandPaletteProps {
|
interface CommandPaletteProps {
|
||||||
modalProps: (typeof useDisclosure)['arguments'];
|
modalProps: (typeof useDisclosure)['arguments'];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const SEARCH_SECTION_IDS = {
|
||||||
|
albums: 'albums',
|
||||||
|
artists: 'artists',
|
||||||
|
tracks: 'tracks',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
interface CommandPaletteSearchProps {
|
||||||
|
children?: React.ReactNode;
|
||||||
|
isHome: boolean;
|
||||||
|
onSelectResult: () => void;
|
||||||
|
query: string;
|
||||||
|
searchInputRef: React.RefObject<HTMLInputElement | null>;
|
||||||
|
setQuery: (query: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function CommandPaletteSearch({
|
||||||
|
children,
|
||||||
|
isHome,
|
||||||
|
onSelectResult,
|
||||||
|
query,
|
||||||
|
searchInputRef,
|
||||||
|
setQuery,
|
||||||
|
}: CommandPaletteSearchProps) {
|
||||||
|
const [debouncedQuery] = useDebouncedValue(query, 400);
|
||||||
|
const searchSectionsExpanded = useAppStore(
|
||||||
|
(state) => state.commandPaletteSearchSectionsExpanded,
|
||||||
|
);
|
||||||
|
const setSearchSectionExpanded = useAppStore(
|
||||||
|
(state) => state.actions.setCommandPaletteSearchSectionExpanded,
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<TextInput
|
||||||
|
data-autofocus
|
||||||
|
leftSection={<Icon icon="search" />}
|
||||||
|
onChange={(e) => setQuery(e.currentTarget.value)}
|
||||||
|
ref={searchInputRef}
|
||||||
|
rightSection={
|
||||||
|
query && (
|
||||||
|
<ActionIcon
|
||||||
|
onClick={() => {
|
||||||
|
setQuery('');
|
||||||
|
searchInputRef.current?.focus();
|
||||||
|
}}
|
||||||
|
variant="transparent"
|
||||||
|
>
|
||||||
|
<Icon icon="x" />
|
||||||
|
</ActionIcon>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
size="sm"
|
||||||
|
value={query}
|
||||||
|
/>
|
||||||
|
<Divider my="sm" />
|
||||||
|
<Command.List>
|
||||||
|
<Stack gap="xs">
|
||||||
|
<SearchAlbumsSection
|
||||||
|
debouncedQuery={debouncedQuery ?? ''}
|
||||||
|
expanded={searchSectionsExpanded[SEARCH_SECTION_IDS.albums] ?? true}
|
||||||
|
isHome={isHome}
|
||||||
|
onSelectResult={onSelectResult}
|
||||||
|
onToggle={() =>
|
||||||
|
setSearchSectionExpanded(
|
||||||
|
SEARCH_SECTION_IDS.albums,
|
||||||
|
!(searchSectionsExpanded[SEARCH_SECTION_IDS.albums] ?? true),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
query={query}
|
||||||
|
/>
|
||||||
|
<SearchAlbumArtistsSection
|
||||||
|
debouncedQuery={debouncedQuery ?? ''}
|
||||||
|
expanded={searchSectionsExpanded[SEARCH_SECTION_IDS.artists] ?? true}
|
||||||
|
isHome={isHome}
|
||||||
|
onSelectResult={onSelectResult}
|
||||||
|
onToggle={() =>
|
||||||
|
setSearchSectionExpanded(
|
||||||
|
SEARCH_SECTION_IDS.artists,
|
||||||
|
!(searchSectionsExpanded[SEARCH_SECTION_IDS.artists] ?? true),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
query={query}
|
||||||
|
/>
|
||||||
|
<SearchSongsSection
|
||||||
|
debouncedQuery={debouncedQuery ?? ''}
|
||||||
|
expanded={searchSectionsExpanded[SEARCH_SECTION_IDS.tracks] ?? true}
|
||||||
|
isHome={isHome}
|
||||||
|
onSelectResult={onSelectResult}
|
||||||
|
onToggle={() =>
|
||||||
|
setSearchSectionExpanded(
|
||||||
|
SEARCH_SECTION_IDS.tracks,
|
||||||
|
!(searchSectionsExpanded[SEARCH_SECTION_IDS.tracks] ?? true),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
query={query}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
{children}
|
||||||
|
</Command.List>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export const CommandPalette = ({ modalProps }: CommandPaletteProps) => {
|
export const CommandPalette = ({ modalProps }: CommandPaletteProps) => {
|
||||||
const navigate = useNavigate();
|
|
||||||
const server = useCurrentServer();
|
|
||||||
const [value, setValue] = useState('');
|
const [value, setValue] = useState('');
|
||||||
const [query, setQuery] = useState('');
|
const [query, setQuery] = useState('');
|
||||||
const [debouncedQuery] = useDebouncedValue(query, 400);
|
|
||||||
const [pages, setPages] = useState<CommandPalettePages[]>([CommandPalettePages.HOME]);
|
const [pages, setPages] = useState<CommandPalettePages[]>([CommandPalettePages.HOME]);
|
||||||
const activePage = pages[pages.length - 1];
|
const activePage = pages[pages.length - 1];
|
||||||
const isHome = activePage === CommandPalettePages.HOME;
|
const isHome = activePage === CommandPalettePages.HOME;
|
||||||
|
const commandRootRef = useRef<HTMLDivElement>(null);
|
||||||
const searchInputRef = useRef<HTMLInputElement>(null);
|
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
const popPage = useCallback(() => {
|
const popPage = useCallback(() => {
|
||||||
setPages((pages) => {
|
setPages((pages) => {
|
||||||
@@ -49,25 +145,10 @@ export const CommandPalette = ({ modalProps }: CommandPaletteProps) => {
|
|||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const { data, isLoading } = useQuery(
|
const handleSelectResult = useCallback(() => {
|
||||||
searchQueries.search({
|
modalProps.handlers.close();
|
||||||
options: { enabled: isHome && debouncedQuery !== '' && query !== '' },
|
setQuery('');
|
||||||
query: {
|
}, [modalProps.handlers]);
|
||||||
albumArtistLimit: 4,
|
|
||||||
albumArtistStartIndex: 0,
|
|
||||||
albumLimit: 4,
|
|
||||||
albumStartIndex: 0,
|
|
||||||
query: debouncedQuery,
|
|
||||||
songLimit: 4,
|
|
||||||
songStartIndex: 0,
|
|
||||||
},
|
|
||||||
serverId: server?.id,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
const showAlbumGroup = isHome && Boolean(query && data && data?.albums?.length > 0);
|
|
||||||
const showArtistGroup = isHome && Boolean(query && data && data?.albumArtists?.length > 0);
|
|
||||||
const showTrackGroup = isHome && Boolean(query && data && data?.songs?.length > 0);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal
|
<Modal
|
||||||
@@ -94,19 +175,10 @@ export const CommandPalette = ({ modalProps }: CommandPaletteProps) => {
|
|||||||
}}
|
}}
|
||||||
size="lg"
|
size="lg"
|
||||||
styles={{
|
styles={{
|
||||||
|
body: { padding: '0' },
|
||||||
header: { display: 'none' },
|
header: { display: 'none' },
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Group gap="sm" mb="1rem">
|
|
||||||
{pages.map((page, index) => (
|
|
||||||
<Fragment key={page}>
|
|
||||||
{index > 0 && ' > '}
|
|
||||||
<Button disabled size="compact-md" variant="default">
|
|
||||||
{page?.toLocaleUpperCase()}
|
|
||||||
</Button>
|
|
||||||
</Fragment>
|
|
||||||
))}
|
|
||||||
</Group>
|
|
||||||
<Command
|
<Command
|
||||||
filter={(value, search) => {
|
filter={(value, search) => {
|
||||||
if (value.includes(search)) return 1;
|
if (value.includes(search)) return 1;
|
||||||
@@ -115,147 +187,45 @@ export const CommandPalette = ({ modalProps }: CommandPaletteProps) => {
|
|||||||
}}
|
}}
|
||||||
label="Global Command Menu"
|
label="Global Command Menu"
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
// Focus the search input when navigating with arrow keys
|
|
||||||
// to prevent the focus from staying on the command-item ActionIcon
|
|
||||||
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
|
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
|
||||||
searchInputRef.current?.focus();
|
searchInputRef.current?.focus();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (e.key === 'Tab' && !e.shiftKey) {
|
||||||
|
const root = commandRootRef.current;
|
||||||
|
if (!root) return;
|
||||||
|
|
||||||
|
const selectedItem = root.querySelector(
|
||||||
|
'[cmdk-item][aria-selected="true"]',
|
||||||
|
) as HTMLElement | null;
|
||||||
|
|
||||||
|
if (!selectedItem) return;
|
||||||
|
|
||||||
|
const focusTarget = selectedItem.querySelector(
|
||||||
|
'button:not([disabled]), [tabindex]:not([tabindex="-1"])',
|
||||||
|
) as HTMLElement | null;
|
||||||
|
|
||||||
|
if (!focusTarget) return;
|
||||||
|
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
focusTarget.focus();
|
||||||
|
});
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
onValueChange={setValue}
|
onValueChange={setValue}
|
||||||
|
ref={commandRootRef}
|
||||||
value={value}
|
value={value}
|
||||||
>
|
>
|
||||||
<TextInput
|
<CommandPaletteSearch
|
||||||
data-autofocus
|
isHome={isHome}
|
||||||
leftSection={<Icon icon="search" />}
|
onSelectResult={handleSelectResult}
|
||||||
onChange={(e) => setQuery(e.currentTarget.value)}
|
query={query}
|
||||||
ref={searchInputRef}
|
searchInputRef={searchInputRef}
|
||||||
rightSection={
|
setQuery={setQuery}
|
||||||
query && (
|
|
||||||
<ActionIcon
|
|
||||||
onClick={() => {
|
|
||||||
setQuery('');
|
|
||||||
searchInputRef.current?.focus();
|
|
||||||
}}
|
|
||||||
variant="transparent"
|
|
||||||
>
|
>
|
||||||
<Icon icon="x" />
|
|
||||||
</ActionIcon>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
size="sm"
|
|
||||||
value={query}
|
|
||||||
/>
|
|
||||||
<Command.Separator />
|
|
||||||
<Command.List>
|
|
||||||
<Command.Empty>No results found.</Command.Empty>
|
|
||||||
{showAlbumGroup && (
|
|
||||||
<Command.Group heading="Albums">
|
|
||||||
{data?.albums?.map((album) => (
|
|
||||||
<CommandItemSelectable
|
|
||||||
key={`search-album-${album.id}`}
|
|
||||||
onSelect={() => {
|
|
||||||
navigate(
|
|
||||||
generatePath(AppRoute.LIBRARY_ALBUMS_DETAIL, {
|
|
||||||
albumId: album.id,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
modalProps.handlers.close();
|
|
||||||
setQuery('');
|
|
||||||
}}
|
|
||||||
value={`search-${album.id}`}
|
|
||||||
>
|
|
||||||
{({ isHighlighted }) => (
|
|
||||||
<LibraryCommandItem
|
|
||||||
explicitStatus={album.explicitStatus}
|
|
||||||
id={album.id}
|
|
||||||
imageId={album.imageId}
|
|
||||||
imageUrl={album.imageUrl}
|
|
||||||
isHighlighted={isHighlighted}
|
|
||||||
itemType={LibraryItem.ALBUM}
|
|
||||||
subtitle={album.albumArtists
|
|
||||||
.map((artist) => artist.name)
|
|
||||||
.join(', ')}
|
|
||||||
title={album.name}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</CommandItemSelectable>
|
|
||||||
))}
|
|
||||||
</Command.Group>
|
|
||||||
)}
|
|
||||||
{showArtistGroup && (
|
|
||||||
<Command.Group heading="Artists">
|
|
||||||
{data?.albumArtists.map((artist) => (
|
|
||||||
<CommandItemSelectable
|
|
||||||
key={`artist-${artist.id}`}
|
|
||||||
onSelect={() => {
|
|
||||||
navigate(
|
|
||||||
generatePath(AppRoute.LIBRARY_ALBUM_ARTISTS_DETAIL, {
|
|
||||||
albumArtistId: artist.id,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
modalProps.handlers.close();
|
|
||||||
setQuery('');
|
|
||||||
}}
|
|
||||||
value={`search-${artist.id}`}
|
|
||||||
>
|
|
||||||
{({ isHighlighted }) => (
|
|
||||||
<LibraryCommandItem
|
|
||||||
disabled={artist?.albumCount === 0}
|
|
||||||
id={artist.id}
|
|
||||||
imageId={artist.imageId}
|
|
||||||
imageUrl={artist.imageUrl}
|
|
||||||
isHighlighted={isHighlighted}
|
|
||||||
itemType={LibraryItem.ALBUM_ARTIST}
|
|
||||||
subtitle={
|
|
||||||
artist?.albumCount !== undefined &&
|
|
||||||
artist?.albumCount !== null
|
|
||||||
? t('entity.albumWithCount', {
|
|
||||||
count: artist.albumCount,
|
|
||||||
})
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
title={artist.name}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</CommandItemSelectable>
|
|
||||||
))}
|
|
||||||
</Command.Group>
|
|
||||||
)}
|
|
||||||
{showTrackGroup && (
|
|
||||||
<Command.Group heading="Tracks">
|
|
||||||
{data?.songs.map((song) => (
|
|
||||||
<CommandItemSelectable
|
|
||||||
key={`artist-${song.id}`}
|
|
||||||
onSelect={() => {
|
|
||||||
navigate(
|
|
||||||
generatePath(AppRoute.LIBRARY_ALBUMS_DETAIL, {
|
|
||||||
albumId: song.albumId,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
modalProps.handlers.close();
|
|
||||||
setQuery('');
|
|
||||||
}}
|
|
||||||
value={`search-${song.id}`}
|
|
||||||
>
|
|
||||||
{({ isHighlighted }) => (
|
|
||||||
<LibraryCommandItem
|
|
||||||
explicitStatus={song.explicitStatus}
|
|
||||||
id={song.id}
|
|
||||||
imageId={song.imageId}
|
|
||||||
imageUrl={song.imageUrl}
|
|
||||||
isHighlighted={isHighlighted}
|
|
||||||
itemType={LibraryItem.SONG}
|
|
||||||
song={song}
|
|
||||||
subtitle={song.artists
|
|
||||||
.map((artist) => artist.name)
|
|
||||||
.join(', ')}
|
|
||||||
title={song.name}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</CommandItemSelectable>
|
|
||||||
))}
|
|
||||||
</Command.Group>
|
|
||||||
)}
|
|
||||||
{activePage === CommandPalettePages.HOME && (
|
{activePage === CommandPalettePages.HOME && (
|
||||||
<HomeCommands
|
<HomeCommands
|
||||||
handleClose={modalProps.handlers.close}
|
handleClose={modalProps.handlers.close}
|
||||||
@@ -279,13 +249,23 @@ export const CommandPalette = ({ modalProps }: CommandPaletteProps) => {
|
|||||||
setQuery={setQuery}
|
setQuery={setQuery}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</Command.List>
|
</CommandPaletteSearch>
|
||||||
</Command>
|
</Command>
|
||||||
<Box mt="0.5rem" p="0.5rem">
|
<Divider my="sm" />
|
||||||
<Group justify="space-between">
|
<Group justify="space-between">
|
||||||
<Command.Loading>
|
<Breadcrumb separator={<Icon icon="arrowRight" />}>
|
||||||
{isHome && isLoading && query !== '' && <Spinner />}
|
{pages.map((page, index) => (
|
||||||
</Command.Loading>
|
<Button
|
||||||
|
key={page}
|
||||||
|
onClick={() => setPages((prev) => prev.slice(0, index + 1))}
|
||||||
|
size="compact-xs"
|
||||||
|
variant="subtle"
|
||||||
|
>
|
||||||
|
{page?.toLocaleUpperCase()}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</Breadcrumb>
|
||||||
|
|
||||||
<Group gap="sm">
|
<Group gap="sm">
|
||||||
<Kbd size="md">ESC</Kbd>
|
<Kbd size="md">ESC</Kbd>
|
||||||
<Kbd size="md">↑</Kbd>
|
<Kbd size="md">↑</Kbd>
|
||||||
@@ -293,7 +273,6 @@ export const CommandPalette = ({ modalProps }: CommandPaletteProps) => {
|
|||||||
<Kbd size="md">⏎</Kbd>
|
<Kbd size="md">⏎</Kbd>
|
||||||
</Group>
|
</Group>
|
||||||
</Group>
|
</Group>
|
||||||
</Box>
|
|
||||||
</Modal>
|
</Modal>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ input[cmdk-input] {
|
|||||||
[cmdk-group-items] {
|
[cmdk-group-items] {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: var(--theme-spacing-xs);
|
gap: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
[cmdk-item] {
|
[cmdk-item] {
|
||||||
|
|||||||
@@ -32,3 +32,14 @@
|
|||||||
background: alpha(var(--theme-colors-foreground-muted), 0.3);
|
background: alpha(var(--theme-colors-foreground-muted), 0.3);
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.controls {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
svg {
|
||||||
|
width: var(--theme-font-size-sm);
|
||||||
|
height: var(--theme-font-size-sm);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -16,6 +16,24 @@ import { Text } from '/@/shared/components/text/text';
|
|||||||
import { ExplicitStatus, LibraryItem, Song } from '/@/shared/types/domain-types';
|
import { ExplicitStatus, LibraryItem, Song } from '/@/shared/types/domain-types';
|
||||||
import { Play } from '/@/shared/types/types';
|
import { Play } from '/@/shared/types/types';
|
||||||
|
|
||||||
|
const createPlayKeyDownHandler = (
|
||||||
|
playType: Play,
|
||||||
|
disabled: boolean,
|
||||||
|
onPlay: (type: Play) => void,
|
||||||
|
) => {
|
||||||
|
return (e: React.KeyboardEvent) => {
|
||||||
|
if (e.key === ' ' || e.key === 'Enter') {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
if (!disabled) {
|
||||||
|
onPlay(playType);
|
||||||
|
}
|
||||||
|
} else if (e.key === 'Tab') {
|
||||||
|
e.stopPropagation();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
interface LibraryCommandItemProps {
|
interface LibraryCommandItemProps {
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
explicitStatus?: ExplicitStatus | null;
|
explicitStatus?: ExplicitStatus | null;
|
||||||
@@ -113,35 +131,53 @@ export const LibraryCommandItem = ({
|
|||||||
</div>
|
</div>
|
||||||
<div className={styles.metadataWrapper}>
|
<div className={styles.metadataWrapper}>
|
||||||
<Text overflow="hidden">{title}</Text>
|
<Text overflow="hidden">{title}</Text>
|
||||||
<Text isMuted overflow="hidden">
|
<Text isMuted overflow="hidden" size="sm">
|
||||||
{subtitle}
|
{subtitle}
|
||||||
</Text>
|
</Text>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{showControls && (
|
{showControls && (
|
||||||
<ActionIconGroup>
|
<ActionIconGroup className={styles.controls}>
|
||||||
<PlayTooltip disabled={disabled} type={Play.NOW}>
|
<PlayTooltip disabled={disabled} type={Play.NOW}>
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
icon="mediaPlay"
|
icon="mediaPlay"
|
||||||
variant="subtle"
|
size="xs"
|
||||||
|
variant="default"
|
||||||
{...handlePlayNow.handlers}
|
{...handlePlayNow.handlers}
|
||||||
{...handlePlayNow.props}
|
{...handlePlayNow.props}
|
||||||
|
onKeyDown={createPlayKeyDownHandler(
|
||||||
|
Play.NOW,
|
||||||
|
Boolean(disabled ?? handlePlayNow.props.disabled),
|
||||||
|
handlePlay,
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
</PlayTooltip>
|
</PlayTooltip>
|
||||||
<PlayTooltip disabled={disabled} type={Play.NEXT}>
|
<PlayTooltip disabled={disabled} type={Play.NEXT}>
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
icon="mediaPlayNext"
|
icon="mediaPlayNext"
|
||||||
variant="subtle"
|
size="xs"
|
||||||
|
variant="default"
|
||||||
{...handlePlayNext.handlers}
|
{...handlePlayNext.handlers}
|
||||||
{...handlePlayNext.props}
|
{...handlePlayNext.props}
|
||||||
|
onKeyDown={createPlayKeyDownHandler(
|
||||||
|
Play.NEXT,
|
||||||
|
Boolean(disabled ?? handlePlayNext.props.disabled),
|
||||||
|
handlePlay,
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
</PlayTooltip>
|
</PlayTooltip>
|
||||||
<PlayTooltip disabled={disabled} type={Play.LAST}>
|
<PlayTooltip disabled={disabled} type={Play.LAST}>
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
icon="mediaPlayLast"
|
icon="mediaPlayLast"
|
||||||
variant="subtle"
|
size="xs"
|
||||||
|
variant="default"
|
||||||
{...handlePlayLast.handlers}
|
{...handlePlayLast.handlers}
|
||||||
{...handlePlayLast.props}
|
{...handlePlayLast.props}
|
||||||
|
onKeyDown={createPlayKeyDownHandler(
|
||||||
|
Play.LAST,
|
||||||
|
Boolean(disabled ?? handlePlayLast.props.disabled),
|
||||||
|
handlePlay,
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
</PlayTooltip>
|
</PlayTooltip>
|
||||||
</ActionIconGroup>
|
</ActionIconGroup>
|
||||||
|
|||||||
@@ -0,0 +1,162 @@
|
|||||||
|
import { useInfiniteQuery } from '@tanstack/react-query';
|
||||||
|
import { nanoid } from 'nanoid/non-secure';
|
||||||
|
import { useCallback } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { createSearchParams, generatePath, useNavigate } from 'react-router';
|
||||||
|
|
||||||
|
import { searchQueries } from '/@/renderer/features/search/api/search-api';
|
||||||
|
import { CollapsibleCommandGroup } from '/@/renderer/features/search/components/collapsible-command-group';
|
||||||
|
import { CommandItemSelectable } from '/@/renderer/features/search/components/command-item-selectable';
|
||||||
|
import { LibraryCommandItem } from '/@/renderer/features/search/components/library-command-item';
|
||||||
|
import { FILTER_KEYS } from '/@/renderer/features/shared/utils';
|
||||||
|
import { AppRoute } from '/@/renderer/router/routes';
|
||||||
|
import { useCurrentServer } from '/@/renderer/store';
|
||||||
|
import { Box } from '/@/shared/components/box/box';
|
||||||
|
import { Button } from '/@/shared/components/button/button';
|
||||||
|
import { Spinner } from '/@/shared/components/spinner/spinner';
|
||||||
|
import { Text } from '/@/shared/components/text/text';
|
||||||
|
import { LibraryItem } from '/@/shared/types/domain-types';
|
||||||
|
|
||||||
|
interface SearchAlbumArtistsSectionProps {
|
||||||
|
debouncedQuery: string;
|
||||||
|
expanded: boolean;
|
||||||
|
isHome: boolean;
|
||||||
|
onSelectResult: () => void;
|
||||||
|
onToggle: () => void;
|
||||||
|
query: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SearchAlbumArtistsSection({
|
||||||
|
debouncedQuery,
|
||||||
|
expanded,
|
||||||
|
isHome,
|
||||||
|
onSelectResult,
|
||||||
|
onToggle,
|
||||||
|
query,
|
||||||
|
}: SearchAlbumArtistsSectionProps) {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const server = useCurrentServer();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const { data, fetchNextPage, hasNextPage, isFetched, isFetchingNextPage, isLoading } =
|
||||||
|
useInfiniteQuery(
|
||||||
|
searchQueries.searchAlbumArtistsInfinite({
|
||||||
|
enabled: isHome && debouncedQuery !== '' && query !== '',
|
||||||
|
searchTerm: debouncedQuery,
|
||||||
|
serverId: server?.id,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const artists = data?.pages.flatMap((p) => p.albumArtists) ?? [];
|
||||||
|
const showSection = isHome;
|
||||||
|
const numberOfResults = hasNextPage ? `${artists.length}+` : artists.length;
|
||||||
|
|
||||||
|
const handleGoToPage = useCallback(() => {
|
||||||
|
navigate(
|
||||||
|
{
|
||||||
|
pathname: AppRoute.LIBRARY_ALBUM_ARTISTS,
|
||||||
|
search: createSearchParams({
|
||||||
|
[FILTER_KEYS.SHARED.SEARCH_TERM]: debouncedQuery || query,
|
||||||
|
}).toString(),
|
||||||
|
},
|
||||||
|
{ state: { navigationId: nanoid() } },
|
||||||
|
);
|
||||||
|
onSelectResult();
|
||||||
|
}, [debouncedQuery, navigate, onSelectResult, query]);
|
||||||
|
|
||||||
|
if (!showSection) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CollapsibleCommandGroup
|
||||||
|
expanded={expanded}
|
||||||
|
heading={t('entity.albumArtist', { count: 2, postProcess: 'titleCase' })}
|
||||||
|
onToggle={onToggle}
|
||||||
|
subtitle={
|
||||||
|
isFetched ? (
|
||||||
|
<>
|
||||||
|
{query ? (
|
||||||
|
<Button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
handleGoToPage();
|
||||||
|
}}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
}}
|
||||||
|
size="compact-xs"
|
||||||
|
variant="filled"
|
||||||
|
w="8rem"
|
||||||
|
>
|
||||||
|
{t('common.numberOfResults', { numberOfResults })}
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
) : undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{isLoading ? (
|
||||||
|
<Box p="md">
|
||||||
|
<Spinner container />
|
||||||
|
</Box>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{artists.map((artist) => (
|
||||||
|
<CommandItemSelectable
|
||||||
|
key={`search-artist-${artist.id}`}
|
||||||
|
onSelect={() => {
|
||||||
|
navigate(
|
||||||
|
generatePath(AppRoute.LIBRARY_ALBUM_ARTISTS_DETAIL, {
|
||||||
|
albumArtistId: artist.id,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
onSelectResult();
|
||||||
|
}}
|
||||||
|
value={`search-artist-${artist.id}`}
|
||||||
|
>
|
||||||
|
{({ isHighlighted }) => (
|
||||||
|
<LibraryCommandItem
|
||||||
|
disabled={artist?.albumCount === 0}
|
||||||
|
id={artist.id}
|
||||||
|
imageId={artist.imageId}
|
||||||
|
imageUrl={artist.imageUrl}
|
||||||
|
isHighlighted={isHighlighted}
|
||||||
|
itemType={LibraryItem.ALBUM_ARTIST}
|
||||||
|
subtitle={
|
||||||
|
artist?.albumCount !== undefined &&
|
||||||
|
artist?.albumCount !== null
|
||||||
|
? t('entity.albumWithCount', {
|
||||||
|
count: artist.albumCount,
|
||||||
|
})
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
title={artist.name}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</CommandItemSelectable>
|
||||||
|
))}
|
||||||
|
{hasNextPage && (
|
||||||
|
<CommandItemSelectable
|
||||||
|
disabled={isFetchingNextPage}
|
||||||
|
onSelect={() => fetchNextPage()}
|
||||||
|
value="search-artists-load-more"
|
||||||
|
>
|
||||||
|
{() => (
|
||||||
|
<Text>
|
||||||
|
{isFetchingNextPage ? (
|
||||||
|
<Spinner />
|
||||||
|
) : (
|
||||||
|
<Text size="sm">
|
||||||
|
{t('action.viewMore', { postProcess: 'titleCase' })}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</CommandItemSelectable>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</CollapsibleCommandGroup>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,163 @@
|
|||||||
|
import { useInfiniteQuery } from '@tanstack/react-query';
|
||||||
|
import { nanoid } from 'nanoid/non-secure';
|
||||||
|
import { useCallback } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { createSearchParams, generatePath, useNavigate } from 'react-router';
|
||||||
|
|
||||||
|
import { searchQueries } from '/@/renderer/features/search/api/search-api';
|
||||||
|
import { CollapsibleCommandGroup } from '/@/renderer/features/search/components/collapsible-command-group';
|
||||||
|
import { CommandItemSelectable } from '/@/renderer/features/search/components/command-item-selectable';
|
||||||
|
import { LibraryCommandItem } from '/@/renderer/features/search/components/library-command-item';
|
||||||
|
import { FILTER_KEYS } from '/@/renderer/features/shared/utils';
|
||||||
|
import { AppRoute } from '/@/renderer/router/routes';
|
||||||
|
import { useCurrentServer } from '/@/renderer/store';
|
||||||
|
import { Box } from '/@/shared/components/box/box';
|
||||||
|
import { Button } from '/@/shared/components/button/button';
|
||||||
|
import { Spinner } from '/@/shared/components/spinner/spinner';
|
||||||
|
import { Text } from '/@/shared/components/text/text';
|
||||||
|
import { LibraryItem } from '/@/shared/types/domain-types';
|
||||||
|
|
||||||
|
interface SearchAlbumsSectionProps {
|
||||||
|
debouncedQuery: string;
|
||||||
|
expanded: boolean;
|
||||||
|
isHome: boolean;
|
||||||
|
onSelectResult: () => void;
|
||||||
|
onToggle: () => void;
|
||||||
|
query: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SearchAlbumsSection({
|
||||||
|
debouncedQuery,
|
||||||
|
expanded,
|
||||||
|
isHome,
|
||||||
|
onSelectResult,
|
||||||
|
onToggle,
|
||||||
|
query,
|
||||||
|
}: SearchAlbumsSectionProps) {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const server = useCurrentServer();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const { data, fetchNextPage, hasNextPage, isFetched, isFetchingNextPage, isLoading } =
|
||||||
|
useInfiniteQuery(
|
||||||
|
searchQueries.searchAlbumsInfinite({
|
||||||
|
enabled: isHome && debouncedQuery !== '' && query !== '',
|
||||||
|
searchTerm: debouncedQuery,
|
||||||
|
serverId: server?.id,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const albums = data?.pages.flatMap((p) => p.albums) ?? [];
|
||||||
|
const showSection = isHome;
|
||||||
|
const numberOfResults = hasNextPage ? `${albums.length}+` : albums.length;
|
||||||
|
|
||||||
|
const handleGoToPage = useCallback(() => {
|
||||||
|
navigate(
|
||||||
|
{
|
||||||
|
pathname: AppRoute.LIBRARY_ALBUMS,
|
||||||
|
search: createSearchParams({
|
||||||
|
[FILTER_KEYS.SHARED.SEARCH_TERM]: debouncedQuery || query,
|
||||||
|
}).toString(),
|
||||||
|
},
|
||||||
|
{ state: { navigationId: nanoid() } },
|
||||||
|
);
|
||||||
|
onSelectResult();
|
||||||
|
}, [debouncedQuery, navigate, onSelectResult, query]);
|
||||||
|
|
||||||
|
if (!showSection) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CollapsibleCommandGroup
|
||||||
|
expanded={expanded}
|
||||||
|
heading={t('entity.album', { count: 2, postProcess: 'titleCase' })}
|
||||||
|
onToggle={onToggle}
|
||||||
|
subtitle={
|
||||||
|
isFetched ? (
|
||||||
|
<>
|
||||||
|
{query ? (
|
||||||
|
<Button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
handleGoToPage();
|
||||||
|
}}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
}}
|
||||||
|
size="compact-xs"
|
||||||
|
variant="filled"
|
||||||
|
w="8rem"
|
||||||
|
>
|
||||||
|
{t('common.numberOfResults', { numberOfResults })}
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
) : undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{isLoading ? (
|
||||||
|
<Box p="md">
|
||||||
|
<Spinner container />
|
||||||
|
</Box>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{albums.map((album) => (
|
||||||
|
<CommandItemSelectable
|
||||||
|
key={`search-album-${album.id}`}
|
||||||
|
onSelect={() => {
|
||||||
|
navigate(
|
||||||
|
generatePath(AppRoute.LIBRARY_ALBUMS_DETAIL, {
|
||||||
|
albumId: album.id,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
onSelectResult();
|
||||||
|
}}
|
||||||
|
value={`search-album-${album.id}`}
|
||||||
|
>
|
||||||
|
{({ isHighlighted }) => (
|
||||||
|
<LibraryCommandItem
|
||||||
|
explicitStatus={album.explicitStatus}
|
||||||
|
id={album.id}
|
||||||
|
imageId={album.imageId}
|
||||||
|
imageUrl={album.imageUrl}
|
||||||
|
isHighlighted={isHighlighted}
|
||||||
|
itemType={LibraryItem.ALBUM}
|
||||||
|
subtitle={album.albumArtists
|
||||||
|
.map((artist) => artist.name)
|
||||||
|
.join(', ')}
|
||||||
|
title={album.name}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</CommandItemSelectable>
|
||||||
|
))}
|
||||||
|
{hasNextPage && (
|
||||||
|
<CommandItemSelectable
|
||||||
|
disabled={isFetchingNextPage}
|
||||||
|
onSelect={() => fetchNextPage()}
|
||||||
|
value="search-albums-load-more"
|
||||||
|
>
|
||||||
|
{() => (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'center',
|
||||||
|
width: '100%',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isFetchingNextPage ? (
|
||||||
|
<Spinner />
|
||||||
|
) : (
|
||||||
|
<Text size="sm">
|
||||||
|
{t('action.viewMore', { postProcess: 'titleCase' })}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CommandItemSelectable>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</CollapsibleCommandGroup>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
import { useInfiniteQuery } from '@tanstack/react-query';
|
||||||
|
import { nanoid } from 'nanoid/non-secure';
|
||||||
|
import { useCallback } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { createSearchParams, generatePath, useNavigate } from 'react-router';
|
||||||
|
|
||||||
|
import { searchQueries } from '/@/renderer/features/search/api/search-api';
|
||||||
|
import { CollapsibleCommandGroup } from '/@/renderer/features/search/components/collapsible-command-group';
|
||||||
|
import { CommandItemSelectable } from '/@/renderer/features/search/components/command-item-selectable';
|
||||||
|
import { LibraryCommandItem } from '/@/renderer/features/search/components/library-command-item';
|
||||||
|
import { FILTER_KEYS } from '/@/renderer/features/shared/utils';
|
||||||
|
import { AppRoute } from '/@/renderer/router/routes';
|
||||||
|
import { useCurrentServer } from '/@/renderer/store';
|
||||||
|
import { Box } from '/@/shared/components/box/box';
|
||||||
|
import { Button } from '/@/shared/components/button/button';
|
||||||
|
import { Spinner } from '/@/shared/components/spinner/spinner';
|
||||||
|
import { Text } from '/@/shared/components/text/text';
|
||||||
|
import { LibraryItem } from '/@/shared/types/domain-types';
|
||||||
|
|
||||||
|
interface SearchSongsSectionProps {
|
||||||
|
debouncedQuery: string;
|
||||||
|
expanded: boolean;
|
||||||
|
isHome: boolean;
|
||||||
|
onSelectResult: () => void;
|
||||||
|
onToggle: () => void;
|
||||||
|
query: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SearchSongsSection({
|
||||||
|
debouncedQuery,
|
||||||
|
expanded,
|
||||||
|
isHome,
|
||||||
|
onSelectResult,
|
||||||
|
onToggle,
|
||||||
|
query,
|
||||||
|
}: SearchSongsSectionProps) {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const server = useCurrentServer();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const { data, fetchNextPage, hasNextPage, isFetched, isFetchingNextPage, isLoading } =
|
||||||
|
useInfiniteQuery(
|
||||||
|
searchQueries.searchSongsInfinite({
|
||||||
|
enabled: isHome && debouncedQuery !== '' && query !== '',
|
||||||
|
searchTerm: debouncedQuery,
|
||||||
|
serverId: server?.id,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const songs = data?.pages.flatMap((p) => p.songs) ?? [];
|
||||||
|
const showSection = isHome;
|
||||||
|
const numberOfResults = hasNextPage ? `${songs.length}+` : songs.length;
|
||||||
|
|
||||||
|
const handleGoToPage = useCallback(() => {
|
||||||
|
navigate(
|
||||||
|
{
|
||||||
|
pathname: AppRoute.LIBRARY_SONGS,
|
||||||
|
search: createSearchParams({
|
||||||
|
[FILTER_KEYS.SHARED.SEARCH_TERM]: debouncedQuery || query,
|
||||||
|
}).toString(),
|
||||||
|
},
|
||||||
|
{ state: { navigationId: nanoid() } },
|
||||||
|
);
|
||||||
|
onSelectResult();
|
||||||
|
}, [debouncedQuery, navigate, onSelectResult, query]);
|
||||||
|
|
||||||
|
if (!showSection) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CollapsibleCommandGroup
|
||||||
|
expanded={expanded}
|
||||||
|
heading={t('entity.track', { count: 2, postProcess: 'titleCase' })}
|
||||||
|
onToggle={onToggle}
|
||||||
|
subtitle={
|
||||||
|
isFetched ? (
|
||||||
|
<>
|
||||||
|
{query ? (
|
||||||
|
<Button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
handleGoToPage();
|
||||||
|
}}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
}}
|
||||||
|
size="compact-xs"
|
||||||
|
variant="filled"
|
||||||
|
w="8rem"
|
||||||
|
>
|
||||||
|
{t('common.numberOfResults', { numberOfResults })}
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
) : undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{isLoading ? (
|
||||||
|
<Box p="md">
|
||||||
|
<Spinner container />
|
||||||
|
</Box>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{songs.map((song) => (
|
||||||
|
<CommandItemSelectable
|
||||||
|
key={`search-song-${song.id}`}
|
||||||
|
onSelect={() => {
|
||||||
|
navigate(
|
||||||
|
generatePath(AppRoute.LIBRARY_ALBUMS_DETAIL, {
|
||||||
|
albumId: song.albumId,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
onSelectResult();
|
||||||
|
}}
|
||||||
|
value={`search-song-${song.id}`}
|
||||||
|
>
|
||||||
|
{({ isHighlighted }) => (
|
||||||
|
<LibraryCommandItem
|
||||||
|
explicitStatus={song.explicitStatus}
|
||||||
|
id={song.id}
|
||||||
|
imageId={song.imageId}
|
||||||
|
imageUrl={song.imageUrl}
|
||||||
|
isHighlighted={isHighlighted}
|
||||||
|
itemType={LibraryItem.SONG}
|
||||||
|
song={song}
|
||||||
|
subtitle={song.artists.map((artist) => artist.name).join(', ')}
|
||||||
|
title={song.name}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</CommandItemSelectable>
|
||||||
|
))}
|
||||||
|
{hasNextPage && (
|
||||||
|
<CommandItemSelectable
|
||||||
|
disabled={isFetchingNextPage}
|
||||||
|
onSelect={() => fetchNextPage()}
|
||||||
|
value="search-songs-load-more"
|
||||||
|
>
|
||||||
|
{() => (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'center',
|
||||||
|
width: '100%',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isFetchingNextPage ? (
|
||||||
|
<Spinner />
|
||||||
|
) : (
|
||||||
|
<Text size="sm">
|
||||||
|
{t('action.viewMore', { postProcess: 'titleCase' })}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CommandItemSelectable>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</CollapsibleCommandGroup>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
} from '/@/renderer/features/settings/components/settings-section';
|
} from '/@/renderer/features/settings/components/settings-section';
|
||||||
import {
|
import {
|
||||||
HomeFeatureStyle,
|
HomeFeatureStyle,
|
||||||
|
SideQueueLayout,
|
||||||
SideQueueType,
|
SideQueueType,
|
||||||
useFontSettings,
|
useFontSettings,
|
||||||
useGeneralSettings,
|
useGeneralSettings,
|
||||||
@@ -74,6 +75,23 @@ const SIDE_QUEUE_OPTIONS = [
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const SIDE_QUEUE_LAYOUT_OPTIONS = [
|
||||||
|
{
|
||||||
|
label: t('setting.sidePlayQueueLayout', {
|
||||||
|
context: 'optionHorizontal',
|
||||||
|
postProcess: 'sentenceCase',
|
||||||
|
}),
|
||||||
|
value: 'horizontal',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t('setting.sidePlayQueueLayout', {
|
||||||
|
context: 'optionVertical',
|
||||||
|
postProcess: 'sentenceCase',
|
||||||
|
}),
|
||||||
|
value: 'vertical',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
const FONT_TYPES: Font[] = [
|
const FONT_TYPES: Font[] = [
|
||||||
{
|
{
|
||||||
label: i18n.t('setting.fontType', {
|
label: i18n.t('setting.fontType', {
|
||||||
@@ -541,65 +559,26 @@ export const ApplicationSettings = memo(() => {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
control: (
|
control: (
|
||||||
<Switch
|
<SegmentedControl
|
||||||
defaultChecked={settings.externalLinks}
|
aria-label={t('setting.sidePlayQueueLayout', { postProcess: 'sentenceCase' })}
|
||||||
onChange={(e) => {
|
data={SIDE_QUEUE_LAYOUT_OPTIONS}
|
||||||
|
defaultValue={settings.sideQueueLayout}
|
||||||
|
onChange={(e) =>
|
||||||
setSettings({
|
setSettings({
|
||||||
general: {
|
general: {
|
||||||
...settings,
|
...settings,
|
||||||
externalLinks: e.currentTarget.checked,
|
sideQueueLayout: e as SideQueueLayout,
|
||||||
},
|
},
|
||||||
});
|
})
|
||||||
}}
|
}
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
description: t('setting.externalLinks', {
|
description: t('setting.sidePlayQueueLayout', {
|
||||||
context: 'description',
|
context: 'description',
|
||||||
postProcess: 'sentenceCase',
|
postProcess: 'sentenceCase',
|
||||||
}),
|
}),
|
||||||
title: t('setting.externalLinks', { postProcess: 'sentenceCase' }),
|
isHidden: settings.sideQueueType !== 'sideQueue',
|
||||||
},
|
title: t('setting.sidePlayQueueLayout', { postProcess: 'sentenceCase' }),
|
||||||
{
|
|
||||||
control: (
|
|
||||||
<Switch
|
|
||||||
defaultChecked={settings.lastFM}
|
|
||||||
onChange={(e) => {
|
|
||||||
setSettings({
|
|
||||||
general: {
|
|
||||||
...settings,
|
|
||||||
lastFM: e.currentTarget.checked,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
description: t('setting.lastfm', {
|
|
||||||
context: 'description',
|
|
||||||
postProcess: 'sentenceCase',
|
|
||||||
}),
|
|
||||||
isHidden: !settings.externalLinks,
|
|
||||||
title: t('setting.lastfm', { postProcess: 'sentenceCase' }),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
control: (
|
|
||||||
<Switch
|
|
||||||
defaultChecked={settings.musicBrainz}
|
|
||||||
onChange={(e) => {
|
|
||||||
setSettings({
|
|
||||||
general: {
|
|
||||||
...settings,
|
|
||||||
musicBrainz: e.currentTarget.checked,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
description: t('setting.musicbrainz', {
|
|
||||||
context: 'description',
|
|
||||||
postProcess: 'sentenceCase',
|
|
||||||
}),
|
|
||||||
isHidden: !settings.externalLinks,
|
|
||||||
title: t('setting.musicbrainz', { postProcess: 'sentenceCase' }),
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
control: (
|
control: (
|
||||||
|
|||||||
@@ -477,6 +477,36 @@ export const ControlSettings = memo(() => {
|
|||||||
postProcess: 'sentenceCase',
|
postProcess: 'sentenceCase',
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
control: (
|
||||||
|
<NumberInput
|
||||||
|
defaultValue={playerbarSlider?.loadingDelay ?? 2}
|
||||||
|
max={30}
|
||||||
|
min={0}
|
||||||
|
onBlur={(e) => {
|
||||||
|
setSettings({
|
||||||
|
general: {
|
||||||
|
...settings,
|
||||||
|
playerbarSlider: {
|
||||||
|
...playerbarSlider,
|
||||||
|
loadingDelay: e.currentTarget.value
|
||||||
|
? Number(e.currentTarget.value)
|
||||||
|
: 2,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
rightSection={<Text size="sm">s</Text>}
|
||||||
|
width={75}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
description: t('setting.waveformLoadingDelay', {
|
||||||
|
context: 'description',
|
||||||
|
postProcess: 'sentenceCase',
|
||||||
|
}),
|
||||||
|
isHidden: false,
|
||||||
|
title: t('setting.waveformLoadingDelay', { postProcess: 'sentenceCase' }),
|
||||||
|
},
|
||||||
]
|
]
|
||||||
: []),
|
: []),
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -0,0 +1,171 @@
|
|||||||
|
import { memo } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
import {
|
||||||
|
SettingOption,
|
||||||
|
SettingsSection,
|
||||||
|
} from '/@/renderer/features/settings/components/settings-section';
|
||||||
|
import { useGeneralSettings, useSettingsStoreActions } from '/@/renderer/store/settings.store';
|
||||||
|
import { Switch } from '/@/shared/components/switch/switch';
|
||||||
|
|
||||||
|
export const ExternalLinksSettings = memo(() => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const settings = useGeneralSettings();
|
||||||
|
const { setSettings } = useSettingsStoreActions();
|
||||||
|
|
||||||
|
const options: SettingOption[] = [
|
||||||
|
{
|
||||||
|
control: (
|
||||||
|
<Switch
|
||||||
|
defaultChecked={settings.externalLinks}
|
||||||
|
onChange={(e) => {
|
||||||
|
setSettings({
|
||||||
|
general: {
|
||||||
|
...settings,
|
||||||
|
externalLinks: e.currentTarget.checked,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
description: t('setting.externalLinks', {
|
||||||
|
context: 'description',
|
||||||
|
postProcess: 'sentenceCase',
|
||||||
|
}),
|
||||||
|
title: t('setting.externalLinks', { postProcess: 'sentenceCase' }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
control: (
|
||||||
|
<Switch
|
||||||
|
defaultChecked={settings.lastFM}
|
||||||
|
onChange={(e) => {
|
||||||
|
setSettings({
|
||||||
|
general: {
|
||||||
|
...settings,
|
||||||
|
lastFM: e.currentTarget.checked,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
description: t('setting.lastfm', {
|
||||||
|
context: 'description',
|
||||||
|
postProcess: 'sentenceCase',
|
||||||
|
}),
|
||||||
|
isHidden: !settings.externalLinks,
|
||||||
|
title: t('setting.lastfm', { postProcess: 'sentenceCase' }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
control: (
|
||||||
|
<Switch
|
||||||
|
defaultChecked={settings.listenBrainz}
|
||||||
|
onChange={(e) => {
|
||||||
|
setSettings({
|
||||||
|
general: {
|
||||||
|
...settings,
|
||||||
|
listenBrainz: e.currentTarget.checked,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
description: t('setting.listenbrainz', {
|
||||||
|
context: 'description',
|
||||||
|
postProcess: 'sentenceCase',
|
||||||
|
}),
|
||||||
|
isHidden: !settings.externalLinks,
|
||||||
|
title: t('setting.listenbrainz', { postProcess: 'sentenceCase' }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
control: (
|
||||||
|
<Switch
|
||||||
|
defaultChecked={settings.musicBrainz}
|
||||||
|
onChange={(e) => {
|
||||||
|
setSettings({
|
||||||
|
general: {
|
||||||
|
...settings,
|
||||||
|
musicBrainz: e.currentTarget.checked,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
description: t('setting.musicbrainz', {
|
||||||
|
context: 'description',
|
||||||
|
postProcess: 'sentenceCase',
|
||||||
|
}),
|
||||||
|
isHidden: !settings.externalLinks,
|
||||||
|
title: t('setting.musicbrainz', { postProcess: 'sentenceCase' }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
control: (
|
||||||
|
<Switch
|
||||||
|
defaultChecked={settings.qobuz}
|
||||||
|
onChange={(e) => {
|
||||||
|
setSettings({
|
||||||
|
general: {
|
||||||
|
...settings,
|
||||||
|
qobuz: e.currentTarget.checked,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
description: t('setting.qobuz', {
|
||||||
|
context: 'description',
|
||||||
|
postProcess: 'sentenceCase',
|
||||||
|
}),
|
||||||
|
isHidden: !settings.externalLinks,
|
||||||
|
title: t('setting.qobuz', { postProcess: 'sentenceCase' }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
control: (
|
||||||
|
<Switch
|
||||||
|
defaultChecked={settings.spotify}
|
||||||
|
onChange={(e) => {
|
||||||
|
setSettings({
|
||||||
|
general: {
|
||||||
|
...settings,
|
||||||
|
spotify: e.currentTarget.checked,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
description: t('setting.spotify', {
|
||||||
|
context: 'description',
|
||||||
|
postProcess: 'sentenceCase',
|
||||||
|
}),
|
||||||
|
isHidden: !settings.externalLinks,
|
||||||
|
title: t('setting.spotify', { postProcess: 'sentenceCase' }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
control: (
|
||||||
|
<Switch
|
||||||
|
defaultChecked={settings.nativeSpotify}
|
||||||
|
onChange={(e) => {
|
||||||
|
setSettings({
|
||||||
|
general: {
|
||||||
|
...settings,
|
||||||
|
nativeSpotify: e.currentTarget.checked,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
description: t('setting.nativeSpotify', {
|
||||||
|
context: 'description',
|
||||||
|
postProcess: 'sentenceCase',
|
||||||
|
}),
|
||||||
|
isHidden: !settings.externalLinks || !settings.spotify,
|
||||||
|
title: t('setting.nativeSpotify', { postProcess: 'sentenceCase' }),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SettingsSection
|
||||||
|
options={options}
|
||||||
|
title={t('common.externalLinks', { postProcess: 'sentenceCase' })}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -3,6 +3,7 @@ import { Fragment } from 'react/jsx-runtime';
|
|||||||
|
|
||||||
import { ApplicationSettings } from '/@/renderer/features/settings/components/general/application-settings';
|
import { ApplicationSettings } from '/@/renderer/features/settings/components/general/application-settings';
|
||||||
import { ControlSettings } from '/@/renderer/features/settings/components/general/control-settings';
|
import { ControlSettings } from '/@/renderer/features/settings/components/general/control-settings';
|
||||||
|
import { ExternalLinksSettings } from '/@/renderer/features/settings/components/general/external-links-settings';
|
||||||
import { LyricSettings } from '/@/renderer/features/settings/components/general/lyric-settings';
|
import { LyricSettings } from '/@/renderer/features/settings/components/general/lyric-settings';
|
||||||
import { QueryBuilderSettings } from '/@/renderer/features/settings/components/general/query-builder-settings';
|
import { QueryBuilderSettings } from '/@/renderer/features/settings/components/general/query-builder-settings';
|
||||||
import { ScrobbleSettings } from '/@/renderer/features/settings/components/general/scrobble-settings';
|
import { ScrobbleSettings } from '/@/renderer/features/settings/components/general/scrobble-settings';
|
||||||
@@ -22,6 +23,7 @@ export const GeneralTab = memo(() => {
|
|||||||
const baseSections = [
|
const baseSections = [
|
||||||
{ component: ThemeSettings, key: 'theme' },
|
{ component: ThemeSettings, key: 'theme' },
|
||||||
{ component: ApplicationSettings, key: 'application' },
|
{ component: ApplicationSettings, key: 'application' },
|
||||||
|
{ component: ExternalLinksSettings, key: 'externalLinks' },
|
||||||
{ component: ControlSettings, key: 'control' },
|
{ component: ControlSettings, key: 'control' },
|
||||||
{ component: SidebarSettings, key: 'sidebar' },
|
{ component: SidebarSettings, key: 'sidebar' },
|
||||||
{ component: ScrobbleSettings, key: 'scrobble' },
|
{ component: ScrobbleSettings, key: 'scrobble' },
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ export const SidebarReorder = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<DraggableItems
|
<DraggableItems
|
||||||
description="setting.sidebarCollapsedNavigation"
|
description="setting.sidebarConfiguration"
|
||||||
itemLabels={SIDEBAR_ITEMS}
|
itemLabels={SIDEBAR_ITEMS}
|
||||||
items={mergedSidebarItems as unknown as SidebarItemType[]}
|
items={mergedSidebarItems as unknown as SidebarItemType[]}
|
||||||
setItems={setSidebarItems}
|
setItems={setSidebarItems}
|
||||||
|
|||||||
@@ -1,12 +1,25 @@
|
|||||||
|
import { useLocation } from 'react-router';
|
||||||
|
|
||||||
import { SearchInput } from '/@/renderer/features/shared/components/search-input';
|
import { SearchInput } from '/@/renderer/features/shared/components/search-input';
|
||||||
import { useSearchTermFilter } from '/@/renderer/features/shared/hooks/use-search-term-filter';
|
import { useSearchTermFilter } from '/@/renderer/features/shared/hooks/use-search-term-filter';
|
||||||
|
|
||||||
|
function navigationIdFromState(state: unknown): string | undefined {
|
||||||
|
if (state && typeof state === 'object' && 'navigationId' in state) {
|
||||||
|
const id = (state as { navigationId: unknown }).navigationId;
|
||||||
|
return typeof id === 'string' ? id : undefined;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
export const ListSearchInput = () => {
|
export const ListSearchInput = () => {
|
||||||
const { searchTerm, setSearchTerm } = useSearchTermFilter();
|
const { searchTerm, setSearchTerm } = useSearchTermFilter();
|
||||||
|
const { state } = useLocation();
|
||||||
|
const navigationId = navigationIdFromState(state);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SearchInput
|
<SearchInput
|
||||||
defaultValue={searchTerm}
|
defaultValue={searchTerm}
|
||||||
|
key={navigationId ?? 'list-search-input'}
|
||||||
onChange={(e) => setSearchTerm(e.target.value || null)}
|
onChange={(e) => setSearchTerm(e.target.value || null)}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -21,6 +21,10 @@
|
|||||||
|
|
||||||
.handle-top {
|
.handle-top {
|
||||||
top: 0;
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 4px;
|
||||||
|
cursor: ns-resize;
|
||||||
}
|
}
|
||||||
|
|
||||||
.handle-right {
|
.handle-right {
|
||||||
@@ -29,6 +33,10 @@
|
|||||||
|
|
||||||
.handle-bottom {
|
.handle-bottom {
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 4px;
|
||||||
|
cursor: ns-resize;
|
||||||
}
|
}
|
||||||
|
|
||||||
.handle-left {
|
.handle-left {
|
||||||
|
|||||||
@@ -86,6 +86,7 @@ const BaseSongInfiniteCarousel = (props: SongCarouselProps & { rows: DataRow[] }
|
|||||||
controls={controls}
|
controls={controls}
|
||||||
data={song}
|
data={song}
|
||||||
enableDrag
|
enableDrag
|
||||||
|
imageFetchPriority="low"
|
||||||
itemType={LibraryItem.SONG}
|
itemType={LibraryItem.SONG}
|
||||||
rows={rows}
|
rows={rows}
|
||||||
type="poster"
|
type="poster"
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user