Compare commits

...

87 Commits

Author SHA1 Message Date
jeffvli 1f7d510110 update to v0.21.1 2025-10-13 04:48:10 -07:00
Hosted Weblate 44fae06143 Translated using Weblate (Chinese (Traditional Han script))
Currently translated at 99.8% (715 of 716 strings)

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: linger <linger0517@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/zh_Hant/
Translation: feishin/Translation
2025-10-13 11:33:21 +00:00
Hosted Weblate 1e24e12a55 Translated using Weblate (Catalan)
Currently translated at 100.0% (716 of 716 strings)

Co-authored-by: Ondo <SparkyOndo@proton.me>
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/ca/
Translation: feishin/Translation
2025-10-13 11:33:20 +00:00
Hosted Weblate 358bdec2b6 Translated using Weblate (Basque)
Currently translated at 81.9% (587 of 716 strings)

Translated using Weblate (Basque)

Currently translated at 73.1% (524 of 716 strings)

Co-authored-by: Aitor Astorga <a.astorga.sdv@protonmail.com>
Co-authored-by: Hosted Weblate <hosted@weblate.org>
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/eu/
Translation: feishin/Translation
2025-10-13 11:33:20 +00:00
jeffvli 1b8661d566 fix playback controls being called multiple times on media key input 2025-10-13 04:33:11 -07:00
Kendall Garner 68476deb98 fix album song count 2025-10-12 16:44:05 -07:00
jeffvli c88c6cf55e add mediasession playback controls 2025-10-12 16:37:24 -07:00
jeffvli f332d547a3 rename lint job 2025-10-12 16:20:50 -07:00
jeffvli 5803ac04d1 update linux desktop file instructions with disclaimer 2025-10-12 16:08:59 -07:00
jeffvli 58becc5c8e add useMediaSession hook to set metadata and status 2025-10-12 16:07:59 -07:00
jeffvli 309b49b46e refactor playerbar slider to separate component 2025-10-12 16:07:00 -07:00
jeffvli 40e7eda882 hide mediasession setting on desktop non-windows 2025-10-12 15:53:16 -07:00
jeffvli 1081829aa4 add new languages to config 2025-10-12 15:43:34 -07:00
Hosted Weblate 5a411417b0 Translated using Weblate (German)
Currently translated at 86.3% (613 of 710 strings)

Translated using Weblate (German)

Currently translated at 86.3% (613 of 710 strings)

Co-authored-by: Erik Val <Elaktrato@gmail.com>
Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Trrevvoorr <trevinofficial@hotmail.com>
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/de/
Translation: feishin/Translation
2025-10-12 15:29:08 -07:00
Hosted Weblate 8472a4013e Translated using Weblate (Chinese (Simplified Han script))
Currently translated at 100.0% (714 of 714 strings)

Translated using Weblate (Chinese (Simplified Han script))

Currently translated at 100.0% (714 of 714 strings)

Translated using Weblate (Chinese (Simplified Han script))

Currently translated at 100.0% (710 of 710 strings)

Translated using Weblate (Chinese (Simplified Han script))

Currently translated at 100.0% (706 of 706 strings)

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: 無情天 <kofzhanganguo@126.com>
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/zh_Hans/
Translation: feishin/Translation
2025-10-12 15:06:43 -07:00
Hosted Weblate 14bb135116 Translated using Weblate (French)
Currently translated at 100.0% (706 of 706 strings)

Translated using Weblate (French)

Currently translated at 100.0% (699 of 699 strings)

Translated using Weblate (French)

Currently translated at 100.0% (699 of 699 strings)

Translated using Weblate (French)

Currently translated at 100.0% (699 of 699 strings)

Co-authored-by: Dylan MONTIGAUD <dylanmontigaud17@gmail.com>
Co-authored-by: Hosted Weblate <hosted@weblate.org>
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/fr/
Translation: feishin/Translation
2025-10-12 15:06:43 -07:00
Hosted Weblate 9583c3343b Translated using Weblate (Spanish)
Currently translated at 100.0% (714 of 714 strings)

Translated using Weblate (Spanish)

Currently translated at 99.8% (709 of 710 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (706 of 706 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (705 of 705 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (701 of 701 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (699 of 699 strings)

Co-authored-by: Aitor Astorga <a.astorga.sdv@protonmail.com>
Co-authored-by: Fordas <fordas15@gmail.com>
Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: jaime-grj <weblate.4ljj9@aleeas.com>
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/es/
Translation: feishin/Translation
2025-10-12 15:06:43 -07:00
Hosted Weblate d28b90afa6 Translated using Weblate (Arabic)
Currently translated at 16.3% (114 of 699 strings)

Added translation using Weblate (Arabic)

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: xB <abxb19@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/ar/
Translation: feishin/Translation
2025-10-12 15:06:43 -07:00
Hosted Weblate 479b93410f Translated using Weblate (Italian)
Currently translated at 100.0% (706 of 706 strings)

Co-authored-by: Daivy <reale805@gmail.com>
Co-authored-by: Hosted Weblate <hosted@weblate.org>
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/it/
Translation: feishin/Translation
2025-10-12 15:06:43 -07:00
Hosted Weblate 3496e1c938 Translated using Weblate (Polish)
Currently translated at 97.9% (685 of 699 strings)

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: skajmer <skajmer@protonmail.com>
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/pl/
Translation: feishin/Translation
2025-10-12 15:06:43 -07:00
Hosted Weblate be44906b49 Translated using Weblate (Czech)
Currently translated at 100.0% (710 of 710 strings)

Translated using Weblate (Czech)

Currently translated at 100.0% (706 of 706 strings)

Translated using Weblate (Czech)

Currently translated at 100.0% (705 of 705 strings)

Translated using Weblate (Czech)

Currently translated at 100.0% (701 of 701 strings)

Co-authored-by: Fjuro <fjuro@alius.cz>
Co-authored-by: Hosted Weblate <hosted@weblate.org>
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/cs/
Translation: feishin/Translation
2025-10-12 15:06:42 -07:00
Hosted Weblate 31488ce973 Translated using Weblate (Catalan)
Currently translated at 100.0% (706 of 706 strings)

Translated using Weblate (Catalan)

Currently translated at 100.0% (706 of 706 strings)

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Ondo <SparkyOndo@proton.me>
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/ca/
Translation: feishin/Translation
2025-10-12 15:06:42 -07:00
Hosted Weblate 0b0696a42b Translated using Weblate (Japanese)
Currently translated at 72.8% (517 of 710 strings)

Co-authored-by: Erik Val <Elaktrato@gmail.com>
Co-authored-by: Hosted Weblate <hosted@weblate.org>
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/ja/
Translation: feishin/Translation
2025-10-12 15:06:42 -07:00
Hosted Weblate 6d51f66641 Translated using Weblate (Basque)
Currently translated at 73.3% (521 of 710 strings)

Translated using Weblate (Basque)

Currently translated at 57.7% (407 of 705 strings)

Translated using Weblate (Basque)

Currently translated at 44.1% (311 of 705 strings)

Translated using Weblate (Basque)

Currently translated at 41.7% (293 of 701 strings)

Translated using Weblate (Basque)

Currently translated at 15.5% (109 of 699 strings)

Translated using Weblate (Basque)

Currently translated at 11.1% (78 of 699 strings)

Translated using Weblate (Basque)

Currently translated at 7.8% (55 of 699 strings)

Added translation using Weblate (Basque)

Co-authored-by: Aitor Astorga <a.astorga.sdv@protonmail.com>
Co-authored-by: Hosted Weblate <hosted@weblate.org>
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/eu/
Translation: feishin/Translation
2025-10-12 15:06:42 -07:00
Hosted Weblate 9ec8ec806d Translated using Weblate (German)
Currently translated at 86.3% (613 of 710 strings)

Translated using Weblate (German)

Currently translated at 86.3% (613 of 710 strings)

Co-authored-by: Erik Val <Elaktrato@gmail.com>
Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Trrevvoorr <trevinofficial@hotmail.com>
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/de/
Translation: feishin/Translation
2025-10-12 15:06:42 -07:00
Hosted Weblate 1baae08dc7 Translated using Weblate (Turkish)
Currently translated at 99.4% (701 of 705 strings)

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Slincess <kisacikdevran0@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/tr/
Translation: feishin/Translation
2025-10-12 15:06:25 -07:00
Flame Sage b987324899 Added a Linux Notes section below MacOS for desktop. (#1020)
* Added a Linux Notes section below MacOS for desktop.
2025-10-12 15:01:18 -07:00
Mike Benz f07393c82a enable mediaSession api (#1040)
* enable mediaSession api
2025-10-12 14:59:30 -07:00
Lyall 3636384508 show time remaining instead of duration on click (#1179)
* show time remaining on duration click
2025-10-12 14:22:58 -07:00
jeffvli fc847631d3 move prerelease set to new step 2025-10-12 03:57:08 -07:00
jeffvli 1d9f462959 fix prerelease notes 2025-10-12 03:56:57 -07:00
jeffvli 369b956c66 remove EP_PRE_RELEASE variable on publish 2025-10-12 03:30:36 -07:00
jeffvli 5f14ccf70d set electron-builder publish to draft, convert to prerelease after build 2025-10-12 03:21:09 -07:00
jeffvli f9679f3bda handle prerelease versions in autoupdater beta channel 2025-10-12 02:33:49 -07:00
jeffvli 9f8d9a5b28 move update settings to advanced tab 2025-10-12 02:33:35 -07:00
jeffvli 912aea8174 rename latest channel string to "stable" 2025-10-12 02:31:58 -07:00
jeffvli 9ae4be9336 add missing publish to win beta 2025-10-12 01:05:54 -07:00
jeffvli 1686e7ad0b add separate beta publish scripts 2025-10-12 00:54:07 -07:00
jeffvli bf6047da17 add separate electron builder config for beta channel 2025-10-12 00:53:05 -07:00
jeffvli c3b4a9edf8 attempt fix on beta channel release files 2025-10-11 20:25:33 -07:00
jeffvli 0340cc8a85 update to v0.21.0 2025-10-11 20:12:17 -07:00
jeffvli 3f7a402ce8 add commit notes to beta deploy 2025-10-11 20:05:26 -07:00
jeffvli 20c585aa1c remove unneeded tag deletion 2025-10-11 19:43:08 -07:00
jeffvli 0248997a75 delete old tags in addition to release 2025-10-11 19:41:19 -07:00
jeffvli aaaeea1fa5 split workflow into separate jobs, fix release rename step 2025-10-11 19:39:52 -07:00
jeffvli 22504e9e84 simplify prerelease deletion 2025-10-11 19:28:16 -07:00
jeffvli 5fb2ae839f fix previous release parser 2025-10-11 19:23:05 -07:00
jeffvli 15b00910f3 rework nightly deploy again
- rename to beta
- autodelete previous beta releases
- rename release title to Beta
2025-10-11 19:09:56 -07:00
jeffvli 6cce72a22a rework nightly deploy
- rename to development
- only manual push
- allow input for semantic version number
- set release to github prerelease instead of draft
2025-10-11 19:02:45 -07:00
jeffvli d48fe81d7f re-add build in nightly 2025-10-11 17:14:04 -07:00
jeffvli f0d0f826fb remove duplicate build in nightly 2025-10-11 15:06:52 -07:00
jeffvli 4d12a4d6cb add release channel setting and implementation 2025-10-11 15:05:29 -07:00
jeffvli f14d1f3c5c convert version bump to use pwsh 2025-10-11 14:15:03 -07:00
jeffvli cc466cb0f4 remove exemption for enhancements for stale issues 2025-10-11 13:26:43 -07:00
jeffvli 20941c0405 add initial nightly release workflow 2025-10-11 13:06:51 -07:00
Kendall Garner d52c067dc7 allow customizing windows install 2025-10-11 12:40:27 -07:00
Kendall Garner fccbf83c12 bugfix: handle playlist with no tracks 2025-10-11 12:39:59 -07:00
Kendall Garner 7817059a9e update serve image docs 2025-10-11 08:07:40 -07:00
Luis Canada d3a986e93c Add PWA to web app (#1175)
* add PWA to web app

* Fix sw.js not registering and lint

* Change sw and manifest to live at root

* Revert "Change sw and manifest to live at root"

This reverts commit 4c27d92467.
2025-10-11 14:12:25 +00:00
Kendall Garner 6733047942 improve jellyfin participants 2025-10-10 19:32:11 -07:00
Kendall Garner 452803fc72 support artist art as artist background 2025-10-10 18:26:28 -07:00
jeffvli 4e4a0464d6 pin pnpm/action-setup to v4.1.0 2025-10-10 12:36:08 -07:00
Kendall Garner 4ff317eac9 fix nonexistent filter 2025-10-05 21:25:19 -07:00
Kendall Garner 306167fee3 playlist sort and refactoring 2025-10-05 19:13:35 -07:00
Kendall Garner 1cbb3e56bc add recently released to home page, refactor home route 2025-10-05 07:51:36 -07:00
Kendall Garner 7c24f7cba4 use margin bottom for notifications component to not disable center controls 2025-10-04 07:34:48 -07:00
Evelyn Gravett 1b278cb33a Feature: Add song and artist links to discord RPC (#1160)
* Add song and artist links to discord RPC

* use first artist name for artist link, full artist name for song link

* use first album artist for song link

* add discord rpc links setting

* simplify discord link settings

* fix setting description

* add musicbrainz links

* fix callback missing dependency

* use encodeURIComponent for lastfm links

Co-authored-by: Kendall Garner <17521368+kgarner7@users.noreply.github.com>

* split musicbrainz ids

* combine link settings

---------

Co-authored-by: Kendall Garner <17521368+kgarner7@users.noreply.github.com>
2025-10-04 03:27:59 +00:00
Kendall Garner f1a75d8e81 allow zero warnings on lint 2025-09-30 07:58:57 -07:00
Kendall Garner 4a48598260 add multiple genre support for nd albums/tracks 2025-09-28 19:59:20 -07:00
Kendall Garner 6df270ba34 server add/edit refactor, allow jellyfin prefer instant mix 2025-09-28 19:19:24 -07:00
Kendall Garner eb0ccec0bc Remove cached queries on editing server 2025-09-28 19:10:47 -07:00
Kendall Garner 8caf898172 have default background for artist top songs 2025-09-28 17:12:34 -07:00
Kendall Garner 508013958f ND >= 0.56.0: search songs by artist | album artist id 2025-09-27 20:00:34 -07:00
Kendall Garner c448352ec8 fix linter error 2025-09-26 17:30:20 -07:00
Henry e344adfeed Add autodiscovery for Jellyfin servers (#1146)
* Add autodiscovery for Jellyfin servers

* Remove debugging aids

you didn't see anything

* Fix linter errors

* Send a discovery packet to localhost too
2025-09-26 22:53:19 +00:00
Jeff bca4a14f2e adjust web playback error handler (#1150) 2025-09-24 18:09:30 -07:00
Kendall Garner f4be797f16 Add comment describing jellyfin image tag invalidation 2025-09-24 08:12:00 -07:00
Kendall Garner 2feef206fb add Navidrome/Jellyfin image cache invalidation 2025-09-24 08:05:22 -07:00
dependabot[bot] eea36f720a Bump axios in the npm_and_yarn group across 1 directory (#1145)
Bumps the npm_and_yarn group with 1 update in the / directory: [axios](https://github.com/axios/axios).


Updates `axios` from 1.9.0 to 1.12.0
- [Release notes](https://github.com/axios/axios/releases)
- [Changelog](https://github.com/axios/axios/blob/v1.x/CHANGELOG.md)
- [Commits](https://github.com/axios/axios/compare/v1.9.0...v1.12.0)

---
updated-dependencies:
- dependency-name: axios
  dependency-version: 1.12.0
  dependency-type: direct:production
  dependency-group: npm_and_yarn
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-09-23 13:25:52 -07:00
dependabot[bot] 76350ed5af Bump vite in the npm_and_yarn group across 1 directory (#1115)
Bumps the npm_and_yarn group with 1 update in the / directory: [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite).


Updates `vite` from 6.3.5 to 6.3.6
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/v6.3.6/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v6.3.6/packages/vite)

---
updated-dependencies:
- dependency-name: vite
  dependency-version: 6.3.6
  dependency-type: direct:development
  dependency-group: npm_and_yarn
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-09-23 12:51:06 -07:00
Gabriele Mancini 4f38e16857 Feature: added playlist duration badge (#1130) 2025-09-23 12:45:08 -07:00
Malachi Soord 8a3edb71df feat: add semantic selectors for now-playing media (#1138)
* feat: add semantic selectors for now-playing media

This change adds unique class names to the elements that display the currently playing media information. This makes it easier for extension developers to parse the DOM and understand what media is playing.

The following classes have been added:
- `media-player`: The main player container.
- `player-cover-art`: The cover art of the playing track.
- `song-title`: The title of the playing track.
- `song-artist`: The artist of the playing track.
- `song-album`: The album of the playing track.
- `player-state-playing`/`player-state-paused`: The state of the player.
- `elapsed-time`: The elapsed time of the playing track.
- `total-duration`: The total duration of the playing track.

---------

Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com>
2025-09-23 12:44:22 -07:00
jeffvli 55e35e9b24 set default body background to #000 2025-09-22 18:24:04 -07:00
Gabriele Mancini 6abdbd2f3e Feature: added silent song notification setting (#1129)
* feat: added silent song notification
2025-09-17 21:06:59 -07:00
Kendall Garner 1d46cd5ff9 client-side only sort for all playlists (#1125)
* initial client-side only sort for all playlists

* allow reordering jellyfin (assume it works properly) and navidrome

* on playlist page, add to queue by sort order
2025-09-17 21:06:30 -07:00
Kendall Garner d68165dab5 move title to default layout 2025-09-15 20:10:56 -07:00
Kendall Garner dad80adb8b raise window on mpris raise 2025-09-15 19:31:10 -07:00
94 changed files with 5252 additions and 994 deletions
+332
View File
@@ -0,0 +1,332 @@
name: Publish Beta (Manual)
on:
workflow_dispatch:
inputs:
version:
description: 'Semantic version number (e.g., 1.0.0) - beta suffix will be added automatically'
required: false
type: string
jobs:
prepare:
runs-on: ubuntu-latest
outputs:
version: ${{ steps.version.outputs.version }}
steps:
- name: Checkout git repo
uses: actions/checkout@v1
- name: Install Node and PNPM
uses: pnpm/action-setup@v4.1.0
with:
version: 9
- name: Install dependencies
run: pnpm install
- name: Validate and set version with beta suffix
id: version
shell: pwsh
run: |
$inputVersion = "${{ github.event.inputs.version }}"
Write-Host "Input version: $inputVersion"
if ($inputVersion -eq "" -or $inputVersion -eq "null") {
# No input version provided, auto-increment patch version
Write-Host "No version provided, auto-incrementing patch version..."
# Get current version from package.json
$currentVersion = (Get-Content package.json | ConvertFrom-Json).version
Write-Host "Current version: $currentVersion"
# Remove any existing suffix (like -beta) to get clean semantic version
$cleanVersion = $currentVersion -replace '-.*$', ''
# Extract major, minor, patch components
$versionParts = $cleanVersion.Split('.')
if ($versionParts.Length -ne 3) {
Write-Error "Current version format is invalid: $cleanVersion"
exit 1
}
$major = [int]$versionParts[0]
$minor = [int]$versionParts[1]
$patch = [int]$versionParts[2]
# Increment patch version
$newPatch = $patch + 1
$inputVersion = "$major.$minor.$newPatch"
Write-Host "Auto-generated version: $inputVersion"
} else {
# Validate semantic version format (major.minor.patch)
$versionPattern = '^\d+\.\d+\.\d+$'
if ($inputVersion -notmatch $versionPattern) {
Write-Error "Invalid version format. Expected semantic version (e.g., 1.0.0), got: $inputVersion"
exit 1
}
}
# Add beta suffix
$versionWithBeta = "$inputVersion-beta"
Write-Host "Setting version to: $versionWithBeta"
# Update package.json
$packageJson = Get-Content package.json | ConvertFrom-Json
$packageJson.version = $versionWithBeta
$packageJson | ConvertTo-Json -Depth 10 | Set-Content package.json
Write-Host "Updated package.json version to: $versionWithBeta"
# Set output for other jobs
echo "version=$versionWithBeta" >> $env:GITHUB_OUTPUT
- name: Delete existing releases and tags
shell: pwsh
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
# Get the version that was set in the previous step
$versionWithBeta = "${{ steps.version.outputs.version }}"
Write-Host "Checking for existing releases with tag: $versionWithBeta"
# Find and delete any releases with isPrerelease "true"
Write-Host "Deleting existing prereleases..."
Write-Host "Searching for releases with isPrerelease 'true'..."
$betaReleases = gh release list --limit 100 --json tagName,isPrerelease,name | ConvertFrom-Json | Where-Object { $_.isPrerelease -eq $true }
if ($betaReleases) {
Write-Host "Found $($betaReleases.Count) release(s) with isPrerelease 'true':"
foreach ($release in $betaReleases) {
Write-Host " - Tag: $($release.tagName), Title: $($release.name)"
gh release delete $release.tagName --yes --cleanup-tag
Write-Host " Deleted release with tag: $($release.tagName)"
}
} else {
Write-Host "No releases found with isPrerelease 'true'"
}
publish:
needs: prepare
runs-on: ${{ matrix.os }}
strategy:
matrix:
os: [windows-latest, macos-latest, ubuntu-latest]
steps:
- name: Checkout git repo
uses: actions/checkout@v1
- name: Install Node and PNPM
uses: pnpm/action-setup@v4.1.0
with:
version: 9
- name: Install dependencies
run: pnpm install
- name: Set version from prepare job
shell: pwsh
run: |
$versionWithBeta = "${{ needs.prepare.outputs.version }}"
Write-Host "Setting version from prepare job: $versionWithBeta"
# Update package.json with the version from prepare job
$packageJson = Get-Content package.json | ConvertFrom-Json
$packageJson.version = $versionWithBeta
$packageJson | ConvertTo-Json -Depth 10 | Set-Content package.json
Write-Host "Updated package.json version to: $versionWithBeta"
- name: Build and Publish releases (Windows)
if: matrix.os == 'windows-latest'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
uses: nick-invision/retry@v2.8.2
with:
timeout_minutes: 30
max_attempts: 3
retry_on: error
command: |
pnpm run publish:win:beta
on_retry_command: pnpm cache delete
- name: Build and Publish releases (macOS)
if: matrix.os == 'macos-latest'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
uses: nick-invision/retry@v2.8.2
with:
timeout_minutes: 30
max_attempts: 3
retry_on: error
command: |
pnpm run publish:mac:beta
on_retry_command: pnpm cache delete
- name: Build and Publish releases (Linux)
if: matrix.os == 'ubuntu-latest'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
uses: nick-invision/retry@v2.8.2
with:
timeout_minutes: 30
max_attempts: 3
retry_on: error
command: |
pnpm run publish:linux:beta
on_retry_command: pnpm cache delete
- name: Build and Publish releases (Linux ARM64)
if: matrix.os == 'ubuntu-latest'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
uses: nick-invision/retry@v2.8.2
with:
timeout_minutes: 30
max_attempts: 3
retry_on: error
command: |
pnpm run publish:linux-arm64:beta
on_retry_command: pnpm cache delete
edit-release:
needs: [prepare, publish]
runs-on: ubuntu-latest
steps:
- name: Checkout git repo
uses: actions/checkout@v1
- name: Edit release with commits and title
shell: pwsh
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
# Get the version from the prepare job
$versionWithBeta = "${{ needs.prepare.outputs.version }}"
$tagVersion = "v" + $versionWithBeta
Write-Host "Editing release for tag: $tagVersion"
# Check if release exists
$releaseExists = gh release view $tagVersion 2>$null
if ($LASTEXITCODE -eq 0) {
Write-Host "Found release with tag $tagVersion"
# Get current release notes
# Find the latest non-prerelease tag
Write-Host "Finding latest non-prerelease tag..."
$latestNonPrerelease = gh release list --limit 100 --json tagName,isPrerelease | ConvertFrom-Json | Where-Object { $_.isPrerelease -eq $false -and $_.tagName -ne $tagVersion } | Select-Object -First 1
if ($latestNonPrerelease) {
$latestTag = $latestNonPrerelease.tagName
Write-Host "Latest non-prerelease tag: $latestTag"
# Get commits between latest non-prerelease and current HEAD
Write-Host "Getting commits between $latestTag and HEAD..."
# Use proper git range syntax and handle PowerShell string interpolation
$gitRange = "$latestTag..HEAD"
Write-Host "Git range: $gitRange"
# Get commits using proper git command with datetime
$commits = git log --oneline --pretty=format:"%ad|%s|%h" --date=short $gitRange
# Check if commits exist
if ($commits -and $commits.Trim() -ne "") {
Write-Host "Found commits:"
Write-Host $commits
# Group commits by date
$groupedCommits = @{}
foreach ($line in $commits) {
if ($line.Trim() -ne "") {
$parts = $line.Split('|')
$date = $parts[0]
$message = $parts[1]
$hash = $parts[2]
if (-not $groupedCommits.ContainsKey($date)) {
$groupedCommits[$date] = @()
}
$groupedCommits[$date] += "- $message ($hash)"
}
}
# Build formatted release notes grouped by date
$commitNotes = "## Changes since $latestTag`n`n"
$sortedDates = $groupedCommits.Keys | Sort-Object -Descending
foreach ($date in $sortedDates) {
$commitNotes += "### $date`n"
foreach ($commit in $groupedCommits[$date]) {
$commitNotes += "$commit`n"
}
$commitNotes += "`n"
}
$releaseNotes = $commitNotes
} else {
Write-Host "No commits found between $latestTag and HEAD"
Write-Host "Trying alternative approach..."
# Alternative: get commits since the tag (not range) with datetime
$commits = git log --oneline --pretty=format:"%ad|%s|%h" --date=short $latestTag.. --not $latestTag
if ($commits -and $commits.Trim() -ne "") {
Write-Host "Found commits with alternative method:"
Write-Host $commits
# Group commits by date
$groupedCommits = @{}
foreach ($line in $commits) {
if ($line.Trim() -ne "") {
$parts = $line.Split('|')
$date = $parts[0]
$message = $parts[1]
$hash = $parts[2]
if (-not $groupedCommits.ContainsKey($date)) {
$groupedCommits[$date] = @()
}
$groupedCommits[$date] += "- $message ($hash)"
}
}
# Build formatted release notes grouped by date
$commitNotes = "## Changes since $latestTag`n`n"
$sortedDates = $groupedCommits.Keys | Sort-Object -Descending
foreach ($date in $sortedDates) {
$commitNotes += "### $date`n"
foreach ($commit in $groupedCommits[$date]) {
$commitNotes += "$commit`n"
}
$commitNotes += "`n"
}
$releaseNotes = $commitNotes
} else {
Write-Host "Still no commits found, using basic release notes"
$releaseNotes = "## Beta Release`n`nThis is a beta release."
}
}
} else {
Write-Host "No non-prerelease tags found, using basic release notes"
$releaseNotes = "## Beta Release`n`nThis is a beta release."
}
# Update the release with new title and notes
Write-Host "Updating release with title 'Beta' and new notes..."
gh release edit $tagVersion --title "Beta" --notes "$releaseNotes"
Write-Host "Successfully updated release title to 'Beta' and added commit notes"
} else {
Write-Host "No release found with tag $tagVersion"
}
- name: Set release as prerelease
shell: pwsh
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
gh release edit $tagVersion --prerelease
Write-Host "Successfully set release as prerelease"
+1 -3
View File
@@ -15,7 +15,7 @@ jobs:
uses: actions/checkout@v1
- name: Install Node and PNPM
uses: pnpm/action-setup@v4
uses: pnpm/action-setup@v4.1.0
with:
version: 9
@@ -31,7 +31,6 @@ jobs:
max_attempts: 3
retry_on: error
command: |
pnpm run package:linux
pnpm run publish:linux
on_retry_command: pnpm cache delete
@@ -44,6 +43,5 @@ jobs:
max_attempts: 3
retry_on: error
command: |
pnpm run package:linux-arm64
pnpm run publish:linux-arm64
on_retry_command: pnpm cache delete
+1 -2
View File
@@ -15,7 +15,7 @@ jobs:
uses: actions/checkout@v1
- name: Install Node and PNPM
uses: pnpm/action-setup@v4
uses: pnpm/action-setup@v4.1.0
with:
version: 9
@@ -31,6 +31,5 @@ jobs:
max_attempts: 3
retry_on: error
command: |
pnpm run package:mac
pnpm run publish:mac
on_retry_command: pnpm cache delete
+1 -1
View File
@@ -18,7 +18,7 @@ jobs:
uses: actions/checkout@v3
- name: Install Node and PNPM
uses: pnpm/action-setup@v4
uses: pnpm/action-setup@v4.1.0
with:
version: 9
+1 -2
View File
@@ -15,7 +15,7 @@ jobs:
uses: actions/checkout@v1
- name: Install Node and PNPM
uses: pnpm/action-setup@v4
uses: pnpm/action-setup@v4.1.0
with:
version: 9
@@ -31,6 +31,5 @@ jobs:
max_attempts: 3
retry_on: error
command: |
pnpm run package:win
pnpm run publish:win
on_retry_command: pnpm cache delete
+1 -1
View File
@@ -42,6 +42,6 @@ jobs:
stale-issue-label: 'stale'
exempt-issue-labels: 'enhancement,keep,security'
exempt-issue-labels: 'keep,security'
stale-pr-label: 'stale'
exempt-pr-labels: 'keep,security'
+2 -2
View File
@@ -3,7 +3,7 @@ name: Test
on: [push, pull_request]
jobs:
release:
lint:
runs-on: ${{ matrix.os }}
strategy:
@@ -15,7 +15,7 @@ jobs:
uses: actions/checkout@v1
- name: Install Node.js and PNPM
uses: pnpm/action-setup@v4
uses: pnpm/action-setup@v4.1.0
with:
version: 9
+20 -4
View File
@@ -57,6 +57,18 @@ If you're using a device running macOS 12 (Monterey) or higher, [check here](htt
For media keys to work, you will be prompted to allow Feishin to be a Trusted Accessibility Client. After allowing, you will need to restart Feishin for the privacy settings to take effect.
#### Linux Notes
If you're using a Linux device, a `.desktop` file is recommended for easy launching of Feishin.
Download the [latest release (AppImage)](https://github.com/jeffvli/feishin/releases) and [application icon](https://github.com/jeffvli/feishin/blob/development/resources/icon.png?raw=true) to your `~/applications/` folder. This folder may need to be created if it does not already exist.
Rename the icon to `Feishin-linux-x86_64.png`.
Save the [example desktop file](https://raw.githubusercontent.com/jeffvli/feishin/refs/heads/development/feishin.desktop) as `~/.local/share/applications/feishin.desktop`.
You will now see Feishin show up in your menu. The properties in the example desktop file may need to be modified to match your system.
### Web and Docker
Visit [https://feishin.vercel.app](https://feishin.vercel.app) to use the hosted web version of Feishin. The web client only supports the web player backend.
@@ -157,14 +169,18 @@ This project is built off of [electron-vite](https://github.com/alex8088/electro
- `pnpm run build:remote` - Build the remote app (remote)
- `pnpm run build:web` - Build the standalone web app (renderer)
- `pnpm run package` - Package the project
- `pnpm run package:dev` - Package the project for development
- `pnpm run package:linux` - Package the project for Linux
- `pnpm run package:mac` - Package the project for Mac
- `pnpm run package:win` - Package the project for Windows
- `pnpm run package:dev` - Package the project for development locally
- `pnpm run package:linux` - Package the project for Linux locally
- `pnpm run package:mac` - Package the project for Mac locally
- `pnpm run package:win` - Package the project for Windows locally
- `pnpm run publish:linux` - Publish the project for Linux
- `pnpm run publish:linux:beta` - Publish the project for Linux (beta channel)
- `pnpm run publish:linux-arm64` - Publish the project for Linux ARM64
- `pnpm run publish:linux-arm64:beta` - Publish the project for Linux ARM64 (beta channel)
- `pnpm run publish:mac` - Publish the project for Mac
- `pnpm run publish:mac:beta` - Publish the project for Mac (beta channel)
- `pnpm run publish:win` - Publish the project for Windows
- `pnpm run publish:win:beta` - Publish the project for Windows (beta channel)
- `pnpm run typecheck` - Type check the project
- `pnpm run typecheck:node` - Type check the project with tsconfig.node.json
- `pnpm run typecheck:web` - Type check the project with tsconfig.web.json
+58
View File
@@ -0,0 +1,58 @@
appId: org.jeffvli.feishin
productName: Feishin
artifactName: ${productName}-${version}-${os}-${arch}.${ext}
electronVersion: 35.1.5
directories:
buildResources: assets
files:
- 'out/**/*'
- 'package.json'
extraResources:
- assets/**
asarUnpack:
- resources/**
win:
target:
- zip
- nsis
icon: assets/icons/icon.png
nsis:
allowToChangeInstallationDirectory: true
oneClick: false
shortcutName: ${productName}
uninstallDisplayName: ${productName}
createDesktopShortcut: always
mac:
target:
target: default
arch:
- arm64
- x64
icon: assets/icons/icon.icns
type: distribution
hardenedRuntime: true
entitlements: assets/entitlements.mac.plist
entitlementsInherit: assets/entitlements.mac.plist
gatekeeperAssess: false
notarize: false
dmg:
contents: [{ x: 130, y: 220 }, { x: 410, y: 220, type: link, path: /Applications }]
linux:
target:
- AppImage
- tar.xz
category: AudioVideo;Audio;Player
icon: assets/icons/icon.png
artifactName: ${productName}-${os}-${arch}.${ext}
npmRebuild: false
publish:
provider: github
owner: jeffvli
repo: feishin
channel: beta
releaseType: draft
+9
View File
@@ -16,10 +16,14 @@ win:
- zip
- nsis
icon: assets/icons/icon.png
nsis:
allowToChangeInstallationDirectory: true
oneClick: false
shortcutName: ${productName}
uninstallDisplayName: ${productName}
createDesktopShortcut: always
mac:
target:
target: default
@@ -33,8 +37,10 @@ mac:
entitlementsInherit: assets/entitlements.mac.plist
gatekeeperAssess: false
notarize: false
dmg:
contents: [{ x: 130, y: 220 }, { x: 410, y: 220, type: link, path: /Applications }]
linux:
target:
- AppImage
@@ -42,8 +48,11 @@ linux:
category: AudioVideo;Audio;Player
icon: assets/icons/icon.png
artifactName: ${productName}-${os}-${arch}.${ext}
npmRebuild: false
publish:
provider: github
owner: jeffvli
repo: feishin
channel: latest
releaseType: draft
+9
View File
@@ -0,0 +1,9 @@
[Desktop Entry]
Name=Feishin
Comment=An Electron-based music streaming app
Exec=/home/username/.applications/Feishin-linux-x86_64.AppImage
Icon=/home/username/.applications/Feishin-linux-x86_64.png
Terminal=false
Type=Application
Categories=AudioVideo;Audio;Music;Player;
StartupNotify=true
+15 -10
View File
@@ -1,6 +1,6 @@
{
"name": "feishin",
"version": "0.20.1",
"version": "0.21.1",
"description": "A modern self-hosted music player.",
"keywords": [
"subsonic",
@@ -30,9 +30,9 @@
"i18next": "i18next -c src/i18n/i18next-parser.config.js",
"postinstall": "electron-builder install-app-deps",
"lint": "pnpm run lint-code && pnpm run lint-styles",
"lint-code": "eslint --cache .",
"lint-code": "eslint --max-warnings=0 --cache .",
"lint-code:fix": "eslint --cache --fix .",
"lint-styles": "stylelint 'src/**/*.{css,scss}'",
"lint-styles": "stylelint --max-warnings=0 'src/**/*.{css,scss}'",
"lint-styles:fix": "stylelint 'src/**/*.{css,scss}' --fix",
"lint:fix": "pnpm run lint-code:fix && pnpm run lint-styles:fix",
"package": "pnpm run build && electron-builder",
@@ -44,10 +44,14 @@
"package:mac:pr": "pnpm run build && electron-builder --mac --publish never",
"package:win": "pnpm run build && electron-builder --win",
"package:win:pr": "pnpm run build && electron-builder --win --publish never",
"publish:linux": "electron-builder --publish always --linux",
"publish:linux-arm64": "electron-builder --publish always --linux --arm64",
"publish:mac": "electron-builder --publish always --mac",
"publish:win": "electron-builder --publish always --win",
"publish:linux": "pnpm run build && electron-builder --publish always --linux",
"publish:linux-arm64": "pnpm run build && electron-builder --publish always --linux --arm64",
"publish:linux-arm64:beta": "pnpm run build && electron-builder --config electron-builder-beta.yml --publish always --linux --arm64",
"publish:linux:beta": "pnpm run build && electron-builder --config electron-builder-beta.yml --publish always --linux",
"publish:mac": "pnpm run build && electron-builder --publish always --mac",
"publish:mac:beta": "pnpm run build && electron-builder --config electron-builder-beta.yml --publish always --mac",
"publish:win": "pnpm run build && electron-builder --publish always --win",
"publish:win:beta": "pnpm run build && electron-builder --config electron-builder-beta.yml --publish always --win",
"start": "electron-vite preview",
"typecheck": "pnpm run typecheck:node && pnpm run typecheck:web",
"typecheck:node": "tsc --noEmit -p tsconfig.node.json --composite false",
@@ -78,7 +82,7 @@
"@xhayper/discord-rpc": "^1.3.0",
"audiomotion-analyzer": "^4.5.0",
"auto-text-size": "^0.2.3",
"axios": "^1.6.0",
"axios": "^1.12.0",
"cheerio": "^1.0.0",
"clsx": "^2.0.0",
"cmdk": "^0.2.0",
@@ -164,10 +168,11 @@
"stylelint-config-recess-order": "^7.1.0",
"stylelint-config-standard": "^38.0.0",
"typescript": "^5.8.3",
"vite": "^6.3.5",
"vite": "^6.3.6",
"vite-plugin-conditional-import": "^0.1.7",
"vite-plugin-dynamic-import": "^1.6.0",
"vite-plugin-ejs": "^1.7.0"
"vite-plugin-ejs": "^1.7.0",
"vite-plugin-pwa": "^1.0.3"
},
"pnpm": {
"onlyBuiltDependencies": [
+2241 -187
View File
File diff suppressed because it is too large Load Diff
+14 -2
View File
@@ -2,11 +2,13 @@ import { PostProcessorModule, StringMap, TOptions } from 'i18next';
import i18n from 'i18next';
import { initReactI18next } from 'react-i18next';
import ar from './locales/ar.json';
import ca from './locales/ca.json';
import cs from './locales/cs.json';
import de from './locales/de.json';
import en from './locales/en.json';
import es from './locales/es.json';
import eu from './locales/eu.json';
import fa from './locales/fa.json';
import fi from './locales/fi.json';
import fr from './locales/fr.json';
@@ -30,11 +32,13 @@ import zhHans from './locales/zh-Hans.json';
import zhHant from './locales/zh-Hant.json';
const resources = {
ar: { translation: ar },
ca: { translation: ca },
cs: { translation: cs },
de: { translation: de },
en: { translation: en },
es: { translation: es },
eu: { translation: eu },
fa: { translation: fa },
fi: { translation: fi },
fr: { translation: fr },
@@ -63,6 +67,10 @@ export const languages = [
label: 'English',
value: 'en',
},
{
label: 'العربية',
value: 'ar',
},
{
label: 'Català',
value: 'ca',
@@ -71,13 +79,17 @@ export const languages = [
label: 'Čeština',
value: 'cs',
},
{
label: 'Deutsch',
value: 'de',
},
{
label: 'Español',
value: 'es',
},
{
label: 'Deutsch',
value: 'de',
label: 'Basque',
value: 'eu',
},
{
label: 'Français',
+149
View File
@@ -0,0 +1,149 @@
{
"action": {
"addToFavorites": "إضافة الى $t(entity.favorite_other)",
"addToPlaylist": "إضافة الى $t(entity.playlist_one)",
"clearQueue": "مسح قائمة الإنتظار",
"createPlaylist": "إنشاء $t(entity.playlist_one)",
"deletePlaylist": "حذف $t(entity.playlist_one)",
"deselectAll": "إلغاء تحديد الكل",
"editPlaylist": "تعديل $t(entity.playlist_one)",
"goToPage": "اذهب الى صفحة",
"moveToNext": "الذهاب الى التالي",
"moveToBottom": "الذهاب الى الأسفل",
"moveToTop": "الذهاب الى الأعلى",
"refresh": "$t(common.refresh)",
"removeFromFavorites": "حذف من $t(entity.favorite_other)",
"removeFromPlaylist": "حذف من $t(entity.playlist_one)",
"removeFromQueue": "حذف من قائمة الإنتظار",
"setRating": "تحديد التقييم",
"toggleSmartPlaylistEditor": "تشغيل / إطفاء وضع التعديل لـ $t(entity.smartPlaylist)",
"viewPlaylists": "إظهار $t(entity.playlist_other)",
"openIn": {
"lastfm": "فتح في Last.fm",
"musicbrainz": "فتح في MusicBrainz"
}
},
"common": {
"action_zero": "عملية",
"action_one": "عملية",
"action_two": "عمليتين",
"action_few": "عمليات",
"action_many": "عمليات",
"action_other": "عمليات",
"add": "إضافة",
"additionalParticipants": "مشاركين إضافيين",
"newVersion": "تم تثبيت تحديث جديد {{version}}",
"viewReleaseNotes": "عرض معلومات الإصدار",
"albumGain": "مستوى صوت الألبوم",
"albumPeak": "اعلى مستوى للألبوم",
"areYouSure": "هل أنت متأكد؟",
"ascending": "تصاعدي",
"backward": "خلف",
"biography": "سيرة",
"bitDepth": "عمق البت",
"bitrate": "معدل البت (البت ريت)",
"bpm": "نبضة في الدقيقة",
"cancel": "إلغاء",
"center": "منتصف",
"channel_zero": "قناة",
"channel_one": "قناة",
"channel_two": "قناتين",
"channel_few": "قنوات",
"channel_many": "قنوات",
"channel_other": "قنوات",
"clear": "مسح",
"close": "إغلاق",
"codec": "كوديك",
"collapse": "طي",
"comingSoon": "قريبًا…",
"configure": "تعديل",
"confirm": "تأكيد",
"create": "إنشاء",
"currentSong": "$t(entity.track_one) الحالي",
"decrease": "تنقيص",
"delete": "حذف",
"descending": "تنازلي",
"description": "وصف",
"disable": "تعطيل",
"disc": "قرص",
"dismiss": "إخفاء",
"duration": "مدة",
"edit": "تعديل",
"enable": "تفعيل",
"expand": "توسيع",
"favorite": "مفضلة",
"filter_zero": "فلتر",
"filter_one": "فلتر",
"filter_two": "فلاتر",
"filter_few": "فلاتر",
"filter_many": "فلاتر",
"filter_other": "فلاتر",
"filters": "فلاتر",
"forceRestartRequired": "اعد التشغيل لتطبيق التعديلات... اغلق التنبية لإعادة التشغيل",
"forward": "امام",
"gap": "فجوة",
"home": "الرئيسية",
"increase": "زيادة",
"left": "يسار",
"limit": "حد",
"manage": "إدارة",
"maximize": "تكبير",
"menu": "القائمة",
"minimize": "تصغير",
"modified": "تم تعديله",
"mbid": "معرف MusicBrainz",
"name": "إسم",
"no": "لا",
"none": "لا شي",
"noResultsFromQuery": "لا توجد نتائج",
"note": "ملاحظة",
"ok": "نعم",
"owner": "المالك",
"path": "المسار",
"playerMustBePaused": "يجب إيقاف المشغل",
"preview": "معاينة",
"previousSong": "$t(entity.track_one) السابق",
"quit": "خروج",
"random": "عشوائي",
"rating": "التقييم",
"refresh": "تحديث",
"reload": "تحديث",
"reset": "إعادة تعيين",
"resetToDefault": "إعادة تعيين الى الافتراضي",
"restartRequired": "يجب إعادة التشغيل",
"right": "يمين",
"sampleRate": "معدل العينة (sample rate)",
"save": "حفظ",
"saveAndReplace": "حفظ واستبدال",
"saveAs": "حفظ بإسم",
"search": "بحث",
"setting": "إعداد",
"share": "نشر",
"size": "حجم",
"sortOrder": "الترتيب",
"tags": "العلامات",
"title": "العنوان",
"trackNumber": "رقم المسار",
"trackGain": "مستوى صوت المسار",
"trackPeak": "اعلى مستوى للمسار",
"translation": "الترجمة",
"unknown": "غير معروف",
"version": "الإصدار",
"year": "السنة",
"yes": "نعم"
},
"entity": {
"album_zero": "الالبوم",
"album_one": "الالبوم",
"album_two": "الالبومين",
"album_few": "الالبومات",
"album_many": "الالبومات",
"album_other": "الالبومات",
"albumArtist_zero": "فنان الالبوم",
"albumArtist_one": "فنان الالبوم",
"albumArtist_two": "فنان الالبومين",
"albumArtist_few": "فنان الالبومات",
"albumArtist_many": "فنان الالبومات",
"albumArtist_other": "فنان الالبومات"
}
}
+22 -5
View File
@@ -22,7 +22,7 @@
"appearsOn": "apareix a",
"recentReleases": "Llançaments recents",
"viewDiscography": "Mosta la discografia",
"topSongs": "millos cançons",
"topSongs": "millors cançons",
"topSongsFrom": "les millors cançons de {{title}}",
"viewAll": "mostra-ho tot"
},
@@ -89,7 +89,8 @@
"explore": "explora la teva biblioteca",
"newlyAdded": "afegits recentment",
"mostPlayed": "els més reproduïts",
"recentlyPlayed": "reproduït recentment"
"recentlyPlayed": "reproduït recentment",
"recentlyReleased": "estrenat fa poc"
},
"playlistList": {
"title": "$t(entity.playlist_other)"
@@ -349,7 +350,9 @@
"input_savePassword": "desa la contrasenya",
"input_url": "url",
"success": "servidor afegit correctament",
"title": "afegeix un servidor"
"title": "afegeix un servidor",
"input_preferInstantMix": "prefereix el mix instantani",
"input_preferInstantMixDescription": "utilitza només el mix instantani per obtenir cançons similars. útil si teniu complements que modifiquin aquest comportament"
},
"shareItem": {
"description": "descripció",
@@ -480,7 +483,7 @@
"discordRichPresence": "estat d'activitat de {{discord}}",
"discordRichPresence_description": "activa l'estat de reproducció a l'activitat de {{discord}}. Les tecles d'imatge són: {{icon}}, {{playing}} i {{paused}}",
"discordServeImage": "serveix imatges de {{discord}} des del servidor",
"discordServeImage_description": "comparteix la caràtula per l'estat d'activitat de {{discord}} des del servidor; només disponible per jellyfin i navidrome",
"discordServeImage_description": "comparteix la caràtula per l'estat d'activitat de {{discord}} des del servidor; només disponible per jellyfin i navidrome. {{discord}} fa ser un bot per trobar les imatges, de manera que el vostre servidor ha de ser visible per l'internet públic.",
"discordUpdateInterval": "interval d'actualització de l'estat d'activitat de {{discord}}",
"doubleClickBehavior": "posa en cua totes les pistes cercades en fer doble clic",
"doubleClickBehavior_description": "si està actiu, totes les pistes coincidents en una cerca de pistes es posaran a la cua. altrament, només la que seleccioneu s'afegirà a la cua",
@@ -646,7 +649,21 @@
"discordDisplayType_artistname": "nom de l'artista",
"hotkey_navigateHome": "ves a l'inici",
"preventSleepOnPlayback": "evitar entrar en repòs durant la reproducció",
"preventSleepOnPlayback_description": "evita que la pantalla s'adormi mentre la música es reprodueix"
"preventSleepOnPlayback_description": "evita que la pantalla s'adormi mentre la música es reprodueix",
"discordLinkType": "enllaços d'estat de {{discord}}",
"discordLinkType_description": "afegeix enllaços externs a {{lastfm}} o {{musicbrainz}} als camps de cançó i artista a l'estat d'activitat de {{discord}}. {{musicbrainz}} és el més precís, però requereix etiquetes i no proporciona enllaços d'artista, mentre que {{lastfm}} hauria de propocionar un enllaç sempre. no fa sol·licituds de xarxa addicionals",
"discordLinkType_none": "$t(common.none)",
"discordLinkType_mbz_lastfm": "{{musicbrainz}} amb {{lastfm}} com a alternativa",
"artistBackground": "imatge de fons de l'artista",
"artistBackground_description": "afegeix una imatge de fons per les pàgines d'artista amb l'art de l'artista",
"artistBackgroundBlur": "mida del desenfocament de la imatge de fons de l'artista",
"artistBackgroundBlur_description": "ajusta la quantitat de desenfocament aplicat a la imatge de fons de l'artista",
"releaseChannel_optionLatest": "estable",
"releaseChannel_optionBeta": "beta",
"releaseChannel": "canal de versions",
"releaseChannel_description": "tria entre versions estables i versions beta per les actualitzacions automàtiques",
"mediaSession": "activa Media Session",
"mediaSession_description": "Activa la integració amb Windows Media Session per mostrar els controls multimèdia i les metadades a l'indicador de volum del sistema i la pantalla de bloqueig (només per Windows)"
},
"table": {
"column": {
+14 -3
View File
@@ -280,7 +280,15 @@
"discordDisplayType_artistname": "jména umělců",
"hotkey_navigateHome": "přejít domů",
"preventSleepOnPlayback": "zabránit uspání při přehrávání",
"preventSleepOnPlayback_description": "zabránit uspání displeje během přehrávání hudby"
"preventSleepOnPlayback_description": "zabránit uspání displeje během přehrávání hudby",
"discordLinkType": "odkazy ve stavu na službě {{discord}}",
"discordLinkType_description": "přidá externí odkazy na {{lastfm}} nebo {{musicbrainz}} do polí skladby a umělce ve stavu na službě {{discord}}. {{musicbrainz}} je nejpřesnější, ale vyžaduje značky a neposkytuje odkazy na umělce, zatímco {{lastfm}} by mělo vždy poskytnout odkaz. neprovádí žádné další síťové požadavky",
"discordLinkType_none": "$t(common.none)",
"discordLinkType_mbz_lastfm": "{{musicbrainz}} se zálohou na {{lastfm}}",
"artistBackground": "obrázek umělce na pozadí",
"artistBackground_description": "přidá obrázek na pozadí u stránek umělců",
"artistBackgroundBlur": "velikost rozostření obrázku umělce na pozadí",
"artistBackgroundBlur_description": "upraví velikost rozostření použitého na obrázek umělce na pozadí"
},
"action": {
"editPlaylist": "upravit $t(entity.playlist_one)",
@@ -637,7 +645,8 @@
"newlyAdded": "nově přidáno",
"title": "$t(common.home)",
"explore": "procházet z vaší knihovny",
"recentlyPlayed": "nedávno přehráno"
"recentlyPlayed": "nedávno přehráno",
"recentlyReleased": "nedávno vydáno"
},
"albumDetail": {
"moreFromArtist": "více od tohoto umělce",
@@ -733,7 +742,9 @@
"input_savePassword": "uložit heslo",
"ignoreSsl": "ignorovat SSL $t(common.restartRequired)",
"ignoreCors": "ignorovat CORS $t(common.restartRequired)",
"error_savePassword": "při ukládání hesla se vyskytla chyba"
"error_savePassword": "při ukládání hesla se vyskytla chyba",
"input_preferInstantMix": "preferovat instantní mix",
"input_preferInstantMixDescription": "pro získání podobných skladeb použít pouze instantní mix. užitečné, pokud máte doplňky, které upravují toto chování"
},
"addToPlaylist": {
"success": "přidáno $t(entity.trackWithCount, {\"count\": {{message}} }) do $t(entity.playlistWithCount, {\"count\": {{numOfPlaylists}} })",
+21 -7
View File
@@ -234,7 +234,8 @@
},
"editPlaylist": {
"title": "Bearbeite $t(entity.playlist_one)",
"success": "$t(entity.playlist_one) erfolgreich aktualisiert"
"success": "$t(entity.playlist_one) erfolgreich aktualisiert",
"publicJellyfinNote": "Jellyfin legt aus irgendwelchen Gründen nicht offen ob eine Playlist öffentlich ist oder nicht. Wenn du möchtest, dass sie öffentlich bleibt, wähle bitte diese Option aus"
},
"lyricSearch": {
"title": "Songtext Suche",
@@ -246,7 +247,13 @@
"setExpiration": "Ablaufdatum setzen",
"expireInvalid": "Ablaufdatum muss in der Zukunft liegen",
"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?)"
},
"privateMode": {
"enabled": "Privatmodus aktiviert, Wiedergabe-Status wird externen Quellen nicht preisgegeben",
"disabled": "Privatmodus deaktiviert, Wiedergabe-Status wird externen Quellen preisgegeben",
"title": "Privatmodus"
}
},
"entity": {
@@ -386,14 +393,17 @@
"goBack": "Gehe zurück",
"goForward": "Gehe vorwärts",
"settings": "$t(common.setting_other)",
"quit": "$t(common.quit)"
"quit": "$t(common.quit)",
"privateModeOff": "Privatmodus deaktivieren",
"privateModeOn": "Privatmodus aktivieren"
},
"home": {
"mostPlayed": "Meistgespielt",
"newlyAdded": "Neu hinzugefügte Veröffentlichungen",
"explore": "Entdecke deine Bibliothek",
"recentlyPlayed": "Kürzlich gespielt",
"title": "$t(common.home)"
"title": "$t(common.home)",
"recentlyReleased": "kürzlich veröffentlicht"
},
"albumDetail": {
"moreFromArtist": "Mehr von diesem $t(entity.artist_one)",
@@ -428,7 +438,9 @@
"playShuffled": "$t(player.shuffle)",
"download": "Download",
"playSimilarSongs": "$t(player.playSimilarSongs)",
"moveToNext": "$t(action.moveToNext)"
"moveToNext": "$t(action.moveToNext)",
"shareItem": "teilen",
"showDetails": "Informationen"
},
"sidebar": {
"nowPlaying": "läuft gerade",
@@ -481,7 +493,8 @@
"viewAllTracks": "Alle $t(entity.track_other) ansehen",
"topSongsFrom": "Toplieder von {{title}}",
"viewAll": "Alles ansehen",
"topSongs": "Toplieder"
"topSongs": "Toplieder",
"relatedArtists": "ähnliche $t(entity.artist_other)"
},
"manageServers": {
"title": "Servers verwalten",
@@ -709,6 +722,7 @@
"clearCacheSuccess": "Cache erfolgreich geleert",
"contextMenu": "Kontextmenü-Einstellungen (Rechtsklick)",
"customCssEnable_description": "ermöglicht das Schreiben benutzerdefinierten CSS.",
"doubleClickBehavior": "bei Doppelklick alle gesuchten Tracks zur Warteschlange hinzufügen"
"doubleClickBehavior": "bei Doppelklick alle gesuchten Tracks zur Warteschlange hinzufügen",
"artistBackground": "Künstler Hintergrundbild"
}
}
+18 -1
View File
@@ -237,6 +237,8 @@
"input_legacyAuthentication": "enable legacy authentication",
"input_name": "server name",
"input_password": "password",
"input_preferInstantMix": "prefer instant mix",
"input_preferInstantMixDescription": "only use instant mix to get similar songs. useful if you have plugins that modify this behavior",
"input_savePassword": "save password",
"input_url": "url",
"input_username": "username",
@@ -409,6 +411,7 @@
"mostPlayed": "most played",
"newlyAdded": "newly added releases",
"recentlyPlayed": "recently played",
"recentlyReleased": "recently released",
"title": "$t(common.home)"
},
"itemDetail": {
@@ -494,6 +497,10 @@
"albumBackgroundBlur_description": "adjusts the amount of blur applied to the album background image",
"applicationHotkeys": "application hotkeys",
"applicationHotkeys_description": "configure application hotkeys. toggle the checkbox to set as a global hotkey (desktop only)",
"artistBackground": "artist background image",
"artistBackground_description": "adds a background image for artist pages containing the artist art",
"artistBackgroundBlur": "artist background image blur size",
"artistBackgroundBlur_description": "adjusts the amount of blur applied to the artist background image",
"artistConfiguration": "album artist page configuration",
"artistConfiguration_description": "configure what items are shown, and in what order, on the album artist page",
"audioDevice": "audio device",
@@ -523,6 +530,10 @@
"customFontPath": "custom font path",
"customFontPath_description": "sets the path to the custom font to use for the application",
"disableAutomaticUpdates": "disable automatic updates",
"releaseChannel_optionLatest": "stable",
"releaseChannel_optionBeta": "beta",
"releaseChannel": "release channel",
"releaseChannel_description": "choose between stable releases or beta releases for automatic updates",
"disableLibraryUpdateOnStartup": "disable checking for new versions on startup",
"discordApplicationId": "{{discord}} application id",
"discordApplicationId_description": "the application id for {{discord}} rich presence (defaults to {{defaultId}})",
@@ -535,13 +546,17 @@
"discordRichPresence": "{{discord}} rich presence",
"discordRichPresence_description": "enable playback status in {{discord}} rich presence. Image keys are: {{icon}}, {{playing}}, and {{paused}}",
"discordServeImage": "serve {{discord}} images from server",
"discordServeImage_description": "share cover art for {{discord}} rich presence from server itself, only available for jellyfin and navidrome",
"discordServeImage_description": "share cover art for {{discord}} rich presence from server itself, only available for jellyfin and navidrome. {{discord}} uses a bot to fetch images, so your server must be reachable from the public internet.",
"discordUpdateInterval": "{{discord}} rich presence update interval",
"discordUpdateInterval_description": "the time in seconds between each update (minimum 15 seconds)",
"discordDisplayType": "{{discord}} presence display type",
"discordDisplayType_description": "changes what you are listening to in your status",
"discordDisplayType_songname": "song name",
"discordDisplayType_artistname": "artist name(s)",
"discordLinkType": "{{discord}} presence links",
"discordLinkType_description": "adds external links to {{lastfm}} or {{musicbrainz}} to the song and artist fields in {{discord}} rich presence. {{musicbrainz}} is the most accurate but requires tags and doesn't provide artist links while {{lastfm}} should always provide a link. makes no extra network requests",
"discordLinkType_none": "$t(common.none)",
"discordLinkType_mbz_lastfm": "{{musicbrainz}} with {{lastfm}} fallback",
"doubleClickBehavior": "queue all searched tracks when double clicking",
"doubleClickBehavior_description": "if true, all matching tracks in a track search will be queued. otherwise, only the clicked one will be queued",
"enableRemote": "enable remote control server",
@@ -712,6 +727,8 @@
"transcodeBitrate_description": "selects the bitrate to transcode. 0 means let the server pick",
"transcodeFormat": "format to transcode",
"transcodeFormat_description": "selects the format to transcode. leave empty to let the server decide",
"mediaSession": "enable media session",
"mediaSession_description": "Enables Windows Media Session integration, displaying media controls and metadata in the system volume overlay and lock screen (Windows only)",
"translationApiProvider": "translation api provider",
"translationApiProvider_description": "api provider for translation",
"translationApiKey": "translation api key",
+23 -8
View File
@@ -259,7 +259,7 @@
"lastfmApiKey_description": "la clave API para {{lastfm}}. Requerida para la portada",
"lastfmApiKey": "Clave API para {{lastfm}}",
"discordServeImage": "Servir imágenes de {{discord}} desde el servidor",
"discordServeImage_description": "Comparte el arte de la portada para el estado de actividad de {{discord}} desde el propio servidor, solo disponible para Jellyfin y Navidrome",
"discordServeImage_description": "Comparte el arte de la portada para el estado de actividad de {{discord}} desde el propio servidor, solo disponible para Jellyfin y Navidrome. {{discord}} usa un bot para obtener las imágenes, por lo que tu servidor debe ser alcanzable desde el Internet público.",
"lastfm": "Mostrar enlaces de last.fm",
"lastfm_description": "Muestra enlaces a last.fm en las páginas de artistas/álbumes",
"musicbrainz": "Mostrar enlaces de MusicBrainz",
@@ -280,7 +280,19 @@
"discordDisplayType": "Tipo de pantalla de actividad de {{discord}}",
"hotkey_navigateHome": "Navegar a inicio",
"preventSleepOnPlayback": "Evitar entrar en reposo durante la reproducción",
"preventSleepOnPlayback_description": "Evita que la pantalla entre en reposo mientras se está reproduciendo música"
"preventSleepOnPlayback_description": "Evita que la pantalla entre en reposo mientras se está reproduciendo música",
"discordLinkType": "Enlaces de estado de {{discord}}",
"discordLinkType_none": "$t(common.none)",
"discordLinkType_mbz_lastfm": "{{musicbrainz}} con {{lastfm}} como alternativa",
"discordLinkType_description": "Añade enlaces externos a {{lastfm}} o {{musicbrainz}} a la canción y campos del artista en el estado de actividad de {{discord}} . {{musicbrainz}} es el más preciso pero requiere etiquetas y no proporciona enlaces del artista mientras que {{lastfm}} debería siempre proporcionar un enlace. No realiza peticiones de red adicionales",
"artistBackground": "imagen de fondo del artista",
"artistBackgroundBlur": "tamaño de desenfoque de imagen de fondo del artista",
"artistBackgroundBlur_description": "ajusta la cantidad de desenfoque aplicado a la imagen de fondo del artista",
"releaseChannel_optionLatest": "Estable",
"releaseChannel_optionBeta": "Beta",
"releaseChannel": "Canal de lanzamiento",
"releaseChannel_description": "Elige entre lanzamientos estables o beta 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"
},
"action": {
"editPlaylist": "editar $t(entity.playlist_one)",
@@ -313,7 +325,7 @@
"bpm": "lpm",
"refresh": "actualizar",
"unknown": "desconocido",
"areYouSure": "estás seguro?",
"areYouSure": "seguro?",
"edit": "editar",
"favorite": "favorito",
"left": "izquierda",
@@ -478,7 +490,7 @@
},
"page": {
"sidebar": {
"nowPlaying": "en reproducción",
"nowPlaying": "reproduciendo",
"playlists": "$t(entity.playlist_other)",
"search": "$t(common.search)",
"tracks": "$t(entity.track_other)",
@@ -503,8 +515,8 @@
"quit": "$t(common.quit)",
"goBack": "retroceder",
"goForward": "avanzar",
"privateModeOff": "Apagar modo privado",
"privateModeOn": "Encender modo privado"
"privateModeOff": "Desactivar modo privado",
"privateModeOn": "Activar modo privado"
},
"contextMenu": {
"addToPlaylist": "$t(action.addToPlaylist)",
@@ -537,7 +549,8 @@
"newlyAdded": "nuevos lanzamientos añadidos",
"title": "$t(common.home)",
"explore": "explora desde tu biblioteca",
"recentlyPlayed": "reproducidos recientemente"
"recentlyPlayed": "reproducidos recientemente",
"recentlyReleased": "Lanzado recientemente"
},
"fullscreenPlayer": {
"upNext": "siguiente",
@@ -656,7 +669,9 @@
"input_savePassword": "guardar contraseña",
"ignoreSsl": "ignorar ssl ($t(common.restartRequired))",
"ignoreCors": "ignorar cors ($t(common.restartRequired))",
"error_savePassword": "un error ocurrió cuando se intentó guardar la contraseña"
"error_savePassword": "un error ocurrió cuando se intentó guardar la contraseña",
"input_preferInstantMix": "Preferir mix instantáneo",
"input_preferInstantMixDescription": "Usa solo el mix instantáneo para obtener canciones similares. Útil si tienes complementos que modifican este comportamiento"
},
"addToPlaylist": {
"success": "añadido $t(entity.trackWithCount, {\"count\": {{message}} }) a $t(entity.playlistWithCount, {\"count\": {{numOfPlaylists}} })",
+699
View File
@@ -0,0 +1,699 @@
{
"action": {
"deselectAll": "deshautatu dena",
"editPlaylist": "editatu $t(entity.playlist_one)",
"goToPage": "joan orrira",
"moveToNext": "mugitu hurrengora",
"moveToBottom": "mugitu behera",
"moveToTop": "mugitu gora",
"refresh": "$t(common.refresh)",
"removeFromFavorites": "kendu $t(entity.favorite_other)-(e)tik",
"removeFromPlaylist": "kendu $t(entity.playlist_one)-(e)tik",
"removeFromQueue": "kendu ilaratik",
"setRating": "ezarri balorazioa",
"toggleSmartPlaylistEditor": "txandakatu $t(entity.smartPlaylist) editorea",
"viewPlaylists": "ikusi $t(entity.playlist_other)",
"openIn": {
"lastfm": "Ireki Last.fm-n",
"musicbrainz": "Ireki MusicBrainz-en"
},
"clearQueue": "garbitu ilara",
"createPlaylist": "sortu $t(entity.playlist_one)",
"deletePlaylist": "ezabatu $t(entity.playlist_one)",
"addToFavorites": "gehitu $t(entity.favorite_other)-(e)ra",
"addToPlaylist": "gehitu $t(entity.playlist_one)-(e)ra"
},
"common": {
"add": "gehitu",
"additionalParticipants": "partaide gehigarriak",
"newVersion": "bertsio berri bat instalatu da ({{version}})",
"viewReleaseNotes": "ikusi argitalpen oharrak",
"areYouSure": "ziur zaude?",
"ascending": "goranzkoa",
"backward": "atzeraka",
"biography": "biografia",
"close": "itxi",
"codec": "kodeka",
"collapse": "tolestu",
"configure": "konfiguratu",
"confirm": "berretsi",
"create": "sortu",
"currentSong": "uneko $t(entity.track_one)",
"decrease": "gutxitu",
"delete": "ezabatu",
"descending": "beheranzkoa",
"description": "deskripzioa",
"disable": "desgaitu",
"disc": "diskoa",
"dismiss": "baztertu",
"duration": "iraupena",
"edit": "editatu",
"enable": "gaitu",
"expand": "zabaldu",
"favorite": "gogokoa",
"filter_one": "iragazkia",
"filter_other": "iragazkiak",
"filters": "iragazkiak",
"forceRestartRequired": "berreabiarazi aldaketak aplikatzeko... itxi notifikazioa berreabiarazteko",
"setting": "ezarpena",
"share": "partekatu",
"action_one": "ekintza",
"action_other": "ekintzak",
"unknown": "ezezaguna",
"version": "bertsioa",
"year": "urtea",
"yes": "bai",
"bitrate": "bit-emaria",
"bpm": "bpm",
"cancel": "utzi",
"center": "lerrokatu",
"channel_one": "kanala",
"channel_other": "kanalak",
"clear": "garbitu",
"forward": "aurrerantz",
"home": "etxea",
"increase": "handitu",
"left": "ezkerra",
"limit": "mugatu",
"manage": "kudeatu",
"maximize": "maximizatu",
"menu": "menua",
"minimize": "minimizatu",
"modified": "aldatuta",
"mbid": "MusicBrainz IDa",
"name": "izena",
"no": "ez",
"none": "bat ere ez",
"noResultsFromQuery": "kontsultak ez du emaitzik itzuli",
"note": "oharra",
"ok": "ados",
"owner": "jabea",
"path": "bidea",
"playerMustBePaused": "erreproduzitzailea pausatuta egon behar da",
"preview": "aurrebista",
"previousSong": "aurreko $t(entity.track_one)",
"quit": "irten",
"random": "ausazkoa",
"rating": "balorazioa",
"refresh": "freskatu",
"reload": "birkargatu",
"reset": "berrerazi",
"right": "eskuina",
"save": "gorde",
"search": "bilatu",
"size": "tamaina",
"sortOrder": "ordena",
"tags": "etiketak",
"title": "tituloa",
"trackNumber": "pista",
"translation": "itzulpena",
"albumGain": "album irabazpena",
"bitDepth": "bit-sakonera",
"resetToDefault": "lehenetsitako egoerara berrezarri",
"restartRequired": "berrabiarazi behar da",
"sampleRate": "laginketa-tasa",
"saveAndReplace": "gorde eta ordezkatu",
"saveAs": "gorde honela",
"trackGain": "pista irabazpena",
"comingSoon": "laster…",
"trackPeak": "pistaren gailurra",
"albumPeak": "albumaren gailurra"
},
"player": {
"repeat": "errepikatu",
"play": "erreproduzitu",
"previous": "aurrekoa",
"pause": "pausatu",
"favorite": "gogokoa",
"mute": "isilarazi",
"muted": "isilduta",
"next": "hurrengoa",
"skip": "saltatu",
"stop": "gelditu",
"unfavorite": "kendu gogokoetatik",
"addLast": "gehitu azkena",
"addNext": "gehitu hurrengoa",
"playbackFetchInProgress": "abestiak kargatzen…",
"playbackSpeed": "erreprodukzio-abiadura",
"playRandom": "erreproduzitu auzaz",
"playbackFetchNoResults": "ez da abestirik aurkitu",
"playSimilarSongs": "erreproduzitu antzeko abestiak",
"queue_clear": "garbitu ilara",
"queue_moveToBottom": "gora eraman hautatutakoak",
"queue_moveToTop": "behera eraman hautatutakoak",
"queue_remove": "kendu hautatutakoak",
"repeat_all": "errepikatu dena",
"repeat_off": "errepikapena desgaituta",
"shuffle": "erreproduzitu ausaz",
"shuffle_off": "auza desgaituta",
"skip_back": "saltatu atzeraka",
"skip_forward": "saltatu aurreraka",
"toggleFullscreenPlayer": "txandakatu pantaila osoko erreproduzitzailea",
"viewQueue": "ikusi ilara"
},
"table": {
"config": {
"view": {
"table": "taula",
"list": "zerrenda",
"card": "txartela",
"grid": "sareta",
"poster": "kartela"
},
"general": {
"gap": "$t(common.gap)",
"size": "$t(common.size)",
"tableColumns": "taula zutabeak",
"itemSize": "elementuaren tamaina (px)",
"followCurrentSong": "jarraitu uneko abestia"
},
"label": {
"actions": "$t(common.action_other)",
"album": "$t(entity.album_one)",
"albumArtist": "$t(entity.albumArtist_one)",
"artist": "$t(entity.artist_one)",
"biography": "$t(common.biography)",
"bitrate": "$t(common.bitrate)",
"bpm": "$t(common.bpm)",
"channels": "$t(common.channel_other)",
"codec": "$t(common.codec)",
"duration": "$t(common.duration)",
"favorite": "$t(common.favorite)",
"genre": "$t(entity.genre_one)",
"note": "$t(common.note)",
"owner": "$t(common.owner)",
"path": "$t(common.path)",
"rating": "$t(common.rating)",
"size": "$t(common.size)",
"songCount": "$t(entity.track_other)",
"title": "$t(common.title)",
"year": "$t(common.year)",
"titleCombined": "$t(common.title) (batuta)",
"releaseDate": "argitalpen data",
"playCount": "erreprodukzio kopurua",
"lastPlayed": "azken aldiz entzundakoa",
"discNumber": "disko zenbakia",
"dateAdded": "gehitze data"
}
},
"column": {
"album": "albuma",
"albumCount": "$t(entity.album_other)",
"artist": "$t(entity.artist_one)",
"biography": "biografia",
"bitrate": "bit-emaria",
"channels": "$t(common.channel_other)",
"codec": "$t(common.codec)",
"discNumber": "diskoa",
"favorite": "gogokoa",
"genre": "$t(entity.genre_one)",
"path": "bidea",
"rating": "balorazioa",
"releaseYear": "urtea",
"size": "$t(common.size)",
"songCount": "$t(entity.track_other)",
"title": "tituloa",
"trackNumber": "pista",
"bpm": "bpm",
"comment": "iruzkina",
"playCount": "erreprodukzioak",
"releaseDate": "argitalpen data",
"lastPlayed": "azken aldiz entzundakoa",
"dateAdded": "gehitutako data",
"albumArtist": "albumeko artista"
}
},
"entity": {
"album_one": "albuma",
"album_other": "albumak",
"albumArtist_one": "albumaren artista",
"albumArtist_other": "albumaren artistak",
"albumArtistCount_one": "album artista {{count}}",
"albumArtistCount_other": "{{count}} album artista",
"albumWithCount_one": "album {{count}}",
"albumWithCount_other": "{{count}} album",
"artist_one": "artista",
"artist_other": "artistak",
"artistWithCount_one": "artista {{count}}",
"artistWithCount_other": "{{count}} artista",
"favorite_one": "gogokoa",
"favorite_other": "gogokoak",
"folder_one": "karpeta",
"folder_other": "karpetak",
"folderWithCount_one": "karpeta {{count}}",
"folderWithCount_other": "{{count}} karpeta",
"genre_one": "generoa",
"genre_other": "generoak",
"genreWithCount_one": "genero {{count}}generoa",
"genreWithCount_other": "{{count}} genero",
"playlist_one": "erreprodukzio-zerrenda",
"playlist_other": "erreprodukzio-zerrendak",
"play_one": "erreprodukzio {{count}}",
"play_other": "{{count}} erreprodukzio",
"playlistWithCount_one": "erreprodukzio-zerrenda {{count}}",
"playlistWithCount_other": "{{count}} erreprodukzio-zerrenda",
"smartPlaylist": "$t(entity.playlist_one) adimentsua",
"track_one": "pista",
"track_other": "pistak",
"song_one": "abestia",
"song_other": "abestiak",
"trackWithCount_one": "pista {{count}}",
"trackWithCount_other": "{{count}} pista"
},
"error": {
"apiRouteError": "ezin izan da eskaera bideratu",
"audioDeviceFetchError": "errore bat gertatu da audio gailuak lortzen saiatzean",
"authenticationFailed": "autentifikazioa huts egin du",
"badValue": "\"{{value}}\" aukera baliogabea. Balio hau ez da gehiago existitzen.",
"credentialsRequired": "kredentzialak beharrezkoak dira",
"endpointNotImplementedError": "{{endpoint}} amaiera-puntua ez dago {{serverType}}-(e)rako inplementatuta",
"genericError": "errore bat gertatu da",
"invalidServer": "zerbitzari baliogabea",
"localFontAccessDenied": "tokiko letra-tipoetarako sarbidea ukatuta",
"mpvRequired": "MPV beharrezkoa da",
"networkError": "sareko errore bat gertatu da",
"openError": "ezin izan da fitxategia ireki",
"playbackError": "errore bat gertatu da multimedia erreproduzitzen saiatzean",
"remoteDisableError": "errore bat gertatu da urruneko zerbitzaria $t(common.disable) desgaitzen saiatzean",
"remoteEnableError": "errore bat gertatu da urruneko zerbitzaria $t(common.enable) gaitzen saiatzean",
"remotePortError": "errore bat gertatu da urruneko zerbitzariaren ataka ezartzen saiatzean",
"remotePortWarning": "Berrabiarazi zerbitzaria portu berria aplikatzeko",
"serverNotSelectedError": "ez da zerbitzaririk hautatu",
"serverRequired": "zerbitzaria beharrezkoa da",
"sessionExpiredError": "zure saioa iraungi da",
"badAlbum": "Orrialde hau ikusten ari zara abesti hau album batekoa ez delako. Ziurrenik arazo hau ikusten ari zara zure musika karpetaren goiko mailan abesti bat baduzu. Jellyfinek abestiak karpeta batean badaude taldekatzen ditu bakarrik.",
"loginRateError": "Saioa hasteko saiakera gehiegi egin dira, saiatu berriro segundo batzuk barru",
"notificationDenied": "Jakinarazpenetarako baimenak ukatu dira. Ezarpen honek ez du eraginik.",
"systemFontError": "errore bat gertatu da sistemaren letra-tipoak lortzen saiatzean"
},
"filter": {
"disc": "diskoa",
"duration": "iraupena",
"id": "id-a",
"isPublic": "publikoa da",
"name": "izena",
"note": "oharra",
"owner": "$t(common.owner)",
"path": "bidea",
"random": "ausazkoa",
"rating": "balorazioa",
"trackNumber": "pista",
"album": "$t(entity.album_one)",
"albumArtist": "$t(entity.albumArtist_one)",
"artist": "$t(entity.artist_one)",
"biography": "biografia",
"bitrate": "bit-emaria",
"bpm": "bpm-ak",
"channels": "$t(common.channel_other)",
"comment": "iruzkina",
"favorited": "gogoko gisa markatua",
"genre": "$t(entity.genre_one)",
"search": "bilatu",
"title": "tituloa",
"albumCount": "$t(entity.album_other) kopurua",
"communityRating": "komunitatearen balorazioa",
"criticRating": "kritikarien balorazioa",
"dateAdded": "gehitutako data",
"isCompilation": "konpilazioa da",
"isFavorited": "gogokoetan dago",
"isRated": "baloratua dago",
"isRecentlyPlayed": "duela gutxi entzundakoa",
"lastPlayed": "azken aldiz entzundakoa",
"mostPlayed": "gehien entzundakoa",
"playCount": "erreprodukzio kopurua",
"recentlyAdded": "duela gutxi gehitutakoa",
"recentlyPlayed": "duela gutxi entzundakoa",
"recentlyUpdated": "duela gutxi eguneratua",
"songCount": "abesti kopurua",
"releaseDate": "argitalpen data",
"releaseYear": "argitalpen urtea",
"toYear": "urtera arte",
"fromYear": "urtetik aurrera"
},
"setting": {
"hotkey_playbackPause": "pausatu",
"hotkey_playbackPlay": "erreproduzitu",
"language": "hizkuntza",
"playbackStyle_optionNormal": "normala",
"playButtonBehavior_optionPlay": "$t(player.play)",
"playButtonBehavior_optionPlayShuffled": "$t(player.shuffle)",
"replayGainMode_optionAlbum": "$t(entity.album_one)",
"replayGainMode_optionNone": "$t(common.none)",
"replayGainMode_optionTrack": "$t(entity.track_one)",
"font": "letra-tipoa",
"hotkey_playbackStop": "gelditu",
"buttonSize_description": "erreproduzitzailearen barrako botoien tamaina",
"clearCache": "garbitu nabigatzailearen katxea",
"clearQueryCache": "garbitu feishinen katxea",
"clearCacheSuccess": "katxea behar bezala garbitu da",
"contextMenu": "testuinguru-menuaren konfigurazioa (klik eskuineko botoiarekin)",
"customCssEnable": "gaitu css pertsonalizatua",
"customCssEnable_description": "css pertsonalizatua idazteko aukera eman.",
"customCss": "css pertsonalizatua",
"customFontPath": "letra-tipo pertsonalizatuaren bidea",
"customFontPath_description": "aplikazioan erabiliko den letra-tipo pertsonalizatuaren bidea ezartzen du",
"disableAutomaticUpdates": "desgaitu eguneratze automatikoak",
"discordApplicationId": "{{discord}} aplikazioaren IDa",
"followLyric": "jarraitu uneko letra",
"font_description": "aplikazioan erabiliko den letra-tipoa ezartzen du",
"fontType": "letra-tipo mota",
"fontType_optionCustom": "letra-tipo pertsonalizatua",
"fontType_optionSystem": "sistemaren letra-tipoa",
"gaplessAudio_optionWeak": "ahula (gomendatua)",
"homeConfiguration": "hasierako orriaren konfigurazioa",
"hotkey_favoriteCurrentSong": "$t(common.currentSong) gogokoa",
"hotkey_favoritePreviousSong": "$t(common.previousSong) gogokoa",
"hotkey_navigateHome": "nabigatu etxera",
"hotkey_playbackNext": "hurrengo pista",
"hotkey_playbackPlayPause": "erreproduzitu / pausatu",
"hotkey_playbackPrevious": "aurreko pista",
"hotkey_skipBackward": "saltatu atzeraka",
"hotkey_skipForward": "saltatu aurrerantz",
"hotkey_toggleCurrentSongFavorite": "txandakatu $t(common.currentSong) gogokoa",
"hotkey_toggleFullScreenPlayer": "txandakatu pantaila osoko erreproduzitzailea",
"hotkey_togglePreviousSongFavorite": "txandakatu $t(common.previousSong) gogokoa",
"hotkey_toggleQueue": "txandakatu ilara",
"hotkey_toggleRepeat": "txandakatu errepikapena",
"hotkey_toggleShuffle": "txandakatu auzazkoa",
"hotkey_unfavoriteCurrentSong": "kendu $t(common.currentSong) gogokoetatik",
"hotkey_unfavoritePreviousSong": "kendu $t(common.previousSong) gogokoetatik",
"hotkey_volumeDown": "bolumena jaitsi",
"hotkey_volumeMute": "isilarazi bolumena",
"hotkey_volumeUp": "bolumena igo",
"hotkey_zoomIn": "hurbildu",
"hotkey_zoomOut": "txikiagotu",
"language_description": "aplikazioaren hizkuntza ezartzen du ($t(common.restartRequired))",
"lastfm": "erakutsi last.fm estekak",
"lastfm_description": "erakutsi last.fm-rako estekak artista/album orrialdeetan",
"lastfmApiKey": "{{lastfm}} API gakoa",
"lastfmApiKey_description": "{{lastfm}}-ren API gakoa. Azaleko arterako beharrezkoa.",
"lyricFetch": "eskuratu letrak internetetik",
"lyricFetch_description": "Eskuratu letrak hainbat internet iturrietatik",
"notify": "gaitu abesti japinarazpenak",
"notify_description": "erakutsi jakinarazpenak uneko abestia aldatzean",
"audioExclusiveMode_description": "gaitu irteera esklusiboko modua. Modu honetan, sistema normalean blokeatuta egoten da, eta mpv-k bakarrik atera ahal izango du audioa",
"audioDevice_description": "aukeratu erreproduzitzeko erabiliko den audio gailua (web erreproduzitzailea bakarrik)",
"audioPlayer": "audio erreproduzitzailea",
"audioPlayer_description": "aukeratu erabiliko den audio erreproduzitzailea",
"buttonSize": "erreproduzitzaile barrako botoien tamaina",
"crossfadeDuration": "crossfade iraupena",
"crossfadeDuration_description": "crossfade efektuaren iraupena ezartzen du",
"crossfadeStyle": "crossfade estiloa",
"crossfadeStyle_description": "aukeratu audio erreproduzitzailearentzat erabiliko den crossfade estiloa",
"disableLibraryUpdateOnStartup": "desgaitu bertsio berrien egiaztapena abiaraztean",
"discordApplicationId_description": "{{discord}} jarduera-egoeraren aplikazioaren IDa (lehenetsia {{defaultId}} da)",
"discordPausedStatus": "erakutsi jarduera-egoera pausatuta dagoenean",
"discordPausedStatus_description": "gaituta dagoenean, egoera agertuko da erreproduzitzailea pausatuta dagoenean",
"discordIdleStatus": "erakutsi inaktibo jarduera-egoeran",
"discordIdleStatus_description": "gaituta dagoenean, eguneratu egoera erreproduzitzailea inaktibo dagoen bitartean",
"discordListening_description": "erakutsi egoera entzuten bezala erreproduzitzen ordez",
"discordListening": "erakutsi egoera entzuten bezala",
"discordRichPresence": "{{discord}} jarduera-egoera",
"discordRichPresence_description": "gaitu erreprodukzioa egoera {{discord}}-en jarduera-egoeran. Irudi gakoak hauek dira: {{icon}}, {{playing}}, eta {{paused}}",
"discordServeImage": "zerbitzatu {{discord}} irudiak zerbitzaritik",
"discordServeImage_description": "partekatu {{discord}} jarduera-egoerarentzako azala artea zerbitzaritik bertatik, Jellyfin eta Navidrome-rentzat bakarrik eskuragarri. {{discord}}-(e)k bot bat erabiltzen du irudiak eskuratzeko, beraz, zure zerbitzaria internet publikotik eskuragarri egon behar da.",
"discordUpdateInterval": "{{discord}} jarduera-egoera eguneraketa tartea",
"discordLinkType_none": "$t(common.none)",
"albumBackground": "albumaren atzeko planoaren irudia",
"albumBackground_description": "albumaren azala artea duten album orrietarako atzeko plano irudi bat gehitzen du",
"albumBackgroundBlur": "albumaren atzeko planoaren irudiaren lausotze tamaina",
"discordLinkType_description": "{{lastfm}} edo {{musicbrainz}}-(e)rako kanpoko estekak gehitzen ditu abesti eta artista eremuetan {{discord}} jarduera-egoeran. {{musicbrainz}} da zehatzena, baina etiketak behar ditu eta ez ditu artistaren estekak ematen, {{lastfm}}-k beti esteka bat eman beharko lukeen bitartean. ez du sareko eskaera gehigarririk egiten",
"playButtonBehavior_optionAddNext": "$t(player.addNext)",
"scrobble": "scrobble",
"sidePlayQueueStyle_optionAttached": "erantsita",
"sidePlayQueueStyle_optionDetached": "bereizita",
"theme": "gaia",
"audioDevice": "audio gailua",
"discordDisplayType_songname": "abesti izena",
"discordDisplayType_artistname": "artista izena(k)",
"fontType_optionBuiltIn": "barneko letra-tipoa",
"hotkey_globalSearch": "bilaketa globala",
"albumBackgroundBlur_description": "albumaren atzeko planoaren irudiari aplikatzen zaion lausotze-kopurua doitzen du",
"artistBackground": "artistaren atzeko planoaren irudia",
"artistBackgroundBlur": "artistaren atzeko planoko irudiaren lausotze-tamaina",
"artistBackgroundBlur_description": "artistaren atzeko planoaren irudiari aplikatzen zaion lausotze-kopurua doitzen du",
"artistConfiguration": "albumaren artistaren konfigurazio orria",
"artistConfiguration_description": "konfiguratu zein elementu erakusten diren eta zein ordenatan albumaren artistaren orrian",
"audioExclusiveMode": "audio esklusiboko modua",
"releaseChannel_optionLatest": "egonkorra",
"releaseChannel_optionBeta": "beta",
"releaseChannel": "argitalpen kanala",
"releaseChannel_description": "aukeratu argitalpen egonkorren edo beta artean eguneratze automatikoak lortzeko",
"discordUpdateInterval_description": "eguneratze bakoitzaren arteko denbora segundotan (gutxienez 15 segundo)",
"discordDisplayType": "{{discord}} jarduera-pantailaren mota",
"discordDisplayType_description": "zure egoeran entzuten ari zarena aldatzen du",
"discordLinkType": "{{discord}} egoera estekak",
"fontType_description": "barneko letra-tipoa Feishinek eskaintzen dituen letra-tipoetako bat aukeratzen du. sistemaren letra-tipoa zure sistema eragileak eskaintzen duen edozein letra-tipo hautatzeko aukera ematen dizu. pertsonalizatua zure letra-tipoa eskaintzeko aukera ematen dizu",
"genreBehavior": "genero orriaren portaera lehenetsia",
"homeConfiguration_description": "konfiguratu zein elementu erakusten diren hasierako orrian eta zein ordenatan",
"homeFeature": "etxeko karrusela nabarmendua",
"homeFeature_description": "hasierako orrian karrusel nabarmen handia erakutsi behar den ala ez kontrolatzen du",
"hotkey_localSearch": "orrian bilatu",
"hotkey_rate0": "garbitu balorazioa",
"hotkey_rate1": "1 izarretako balorazioa",
"hotkey_rate2": "2 izarretako balorazioa",
"hotkey_rate3": "3 izarretako balorazioa",
"hotkey_rate4": "4 izarretako balorazioa",
"hotkey_rate5": "5 izarretako balorazioa",
"zoom_description": "aplikazioaren zoom ehunekoa ezartzen du",
"zoom": "zoom ehunekoa",
"windowBarStyle_description": "aukeratu leiho-barraren estiloa",
"windowBarStyle": "leiho-barra estiloa",
"webAudio": "erabili web audioa",
"useSystemTheme_description": "jarraitu sistemak definitutako argi edo iluntasun lehentasuna",
"useSystemTheme": "erabili sistemaren gaia",
"translationTargetLanguage_description": "itzulpenerako helburu-hizkuntza",
"translationTargetLanguage": "itzulpenerako helburu-hizkuntza",
"translationApiKey": "itzulpen api gakoa",
"translationApiProvider_description": "itzulpenerako api hornitzailea",
"translationApiProvider": "itzulpen api hornitzailea",
"mediaSession": "gaitu multimedia saioa",
"themeLight_description": "aplikaziorako erabiliko den gaia argia ezartzen du",
"themeLight": "gaia (argia)",
"themeDark_description": "aplikaziorako erabiliko den gai iluna ezartzen du",
"themeDark": "gaia (iluna)",
"theme_description": "aplikaziorako erabiliko den gaia ezartzen du",
"externalLinks": "kanpoko estekak erakutsi",
"externalLinks_description": "kanpoko estekak (Last.fm, MusicBrainz) artista/album orrietan erakustea gaitzen du",
"exitToTray": "irten erretilura"
},
"form": {
"addServer": {
"input_password": "pasahitza",
"input_url": "url-a",
"input_username": "erabiltzaile-izena",
"error_savePassword": "errore bat gertatu da pasahitza gordetzen saiatzean",
"input_name": "zerbitzari izena",
"input_savePassword": "pasahitza gorde",
"title": "zerbitzaria gehitu",
"ignoreCors": "alde batera utzi cors $t(common.restartRequired)",
"ignoreSsl": "alde batera utzi ssl $t(common.restartRequired)",
"input_legacyAuthentication": "gaitu zaharkitutako autentifikazioa",
"success": "zerbitzaria behar bezala gehitu da"
},
"addToPlaylist": {
"input_playlists": "$t(entity.playlist_other)",
"success": "$t(entity.trackWithCount, {\"count\": {{message}} }) gehitu da $t(entity.playlistWithCount, {\"count\": {{numOfPlaylists}} })-ra",
"input_skipDuplicates": "saltatu bikoiztuak",
"title": "gehitu $t(entity.playlist_one)-(a)ri"
},
"createPlaylist": {
"input_description": "$t(common.description)",
"input_name": "$t(common.name)",
"input_owner": "$t(common.owner)",
"input_public": "publikoa",
"title": "$t(entity.playlist_one) sortu",
"success": "$t(entity.playlist_one) behar bezala sortu da"
},
"lyricSearch": {
"input_artist": "$t(entity.artist_one)",
"input_name": "$t(common.name)",
"title": "letra bilatu"
},
"shareItem": {
"description": "deskripzioa",
"setExpiration": "iraungitze-data ezarri",
"success": "partekatzeko esteka arbelera kopiatu da (edo egin klik hemen irekitzeko)",
"expireInvalid": "iraungitze-data etorkizunean izan behar da",
"allowDownloading": "baimendu deskargatzea",
"createFailed": "partekatzea sortzeak huts egin du (partekatzea gaituta al dago?)"
},
"deletePlaylist": {
"success": "$t(entity.playlist_one) behar bezala ezabatu da",
"title": "$t(entity.playlist_one) ezabatu",
"input_confirm": "idatzi $t(entity.playlist_one)-(a)ren izena berresteko"
},
"editPlaylist": {
"success": "$t(entity.playlist_one) behar bezala eguneratu da",
"title": "$t(entity.playlist_one) editatu",
"publicJellyfinNote": "Arrazoiren batengatik, Jellyfin ez du erakusten erreprodukzio-zerrendak publikoak diren edo ez. Hau publiko izaten jarraitzea nahi baduzu, hautatu sarrera hau"
},
"queryEditor": {
"title": "kontsulta editorea",
"input_optionMatchAll": "guztiak bat etorri",
"input_optionMatchAny": "edozeinekin bat etorri"
},
"updateServer": {
"success": "zerbitzaria behar bezala eguneratu da",
"title": "zerbitzaria eguneratu"
},
"privateMode": {
"title": "modu pribatua",
"enabled": "modu pribatua gaituta, erreprodukzio egoera kanpoko integrazioetatik ezkutatuta dago orain",
"disabled": "modu pribatua desgaituta, erreprodukzio egoera ikusgai dago orain gaitutako kanpoko integrazioentzat"
}
},
"page": {
"albumArtistList": {
"title": "$t(entity.albumArtist_other)"
},
"albumDetail": {
"released": "argitaratuta",
"moreFromArtist": "$t(entity.artist_one) honetatik gehiago",
"moreFromGeneric": "{{item}}-(e)tik gehiago"
},
"albumList": {
"title": "$t(entity.album_other)",
"genreAlbums": "\"{{genre}}\" $t(entity.album_other)",
"artistAlbums": "{{artist}}-(a)ren albumak"
},
"appMenu": {
"quit": "$t(common.quit)",
"settings": "$t(common.setting_other)",
"collapseSidebar": "tolestu alboko barra",
"expandSidebar": "zabaldu alboko barra",
"goBack": "atzera",
"goForward": "aurrera",
"manageServers": "kudeatu zerbitzariak",
"privateModeOff": "itzali modu pribatua",
"privateModeOn": "aktibatu modu pribatua",
"selectServer": "aukeratu zerbitzaria",
"version": "bertsioa {{version}}",
"openBrowserDevtools": "ireki nabigatzailearen garapen tresnak"
},
"manageServers": {
"url": "URLa",
"username": "erabiltzaile-izena",
"title": "kudeatu zerbitzariak",
"serverDetails": "zerbitzariaren xehetasunak",
"editServerDetailsTooltip": "editatu zerbitzariaren xehetasunak",
"removeServer": "kendu zerbitzaria"
},
"contextMenu": {
"addFavorite": "$t(action.addToFavorites)",
"addLast": "$t(player.addLast)",
"addNext": "$t(player.addNext)",
"addToFavorites": "$t(action.addToFavorites)",
"addToPlaylist": "$t(action.addToPlaylist)",
"createPlaylist": "$t(action.createPlaylist)",
"deletePlaylist": "$t(action.deletePlaylist)",
"deselectAll": "$t(action.deselectAll)",
"download": "deskargatu",
"moveToNext": "$t(action.moveToNext)",
"moveToBottom": "$t(action.moveToBottom)",
"moveToTop": "$t(action.moveToTop)",
"play": "$t(player.play)",
"playSimilarSongs": "$t(player.playSimilarSongs)",
"removeFromFavorites": "$t(action.removeFromFavorites)",
"removeFromPlaylist": "$t(action.removeFromPlaylist)",
"removeFromQueue": "$t(action.removeFromQueue)",
"setRating": "$t(action.setRating)",
"playShuffled": "$t(player.shuffle)",
"numberSelected": "{{count}} hautatuta",
"shareItem": "partekatu elementua",
"goToAlbum": "joan $t(entity.album_one)-(e)ra",
"goToAlbumArtist": "joan $t(entity.albumArtist_one)-(e)ra",
"showDetails": "informazioa lortu"
},
"fullscreenPlayer": {
"config": {
"opacity": "opakotasuna",
"synchronized": "sinkronizatuta",
"unsynchronized": "sinkronizatu gabe",
"dynamicIsImage": "gaitu atzeko planoaren irudia",
"followCurrentLyric": "jarraitu uneko letra",
"lyricSize": "letraren tamaina",
"dynamicBackground": "atzeko plano dinamikoa",
"dynamicImageBlur": "irudiaren lausotze tamaina",
"lyricAlignment": "letraren lerrokatzea",
"showLyricMatch": "erakutsi letren bat-etortzea",
"showLyricProvider": "erakutsi letra hornitzailea",
"lyricOffset": "letra-desplazamendua (ms)"
},
"lyrics": "letrak",
"related": "erlazionatuta",
"upNext": "hurrengoa",
"visualizer": "bistaratzailea",
"noLyrics": "ez da letrarik aurkitu"
},
"genreList": {
"title": "$t(entity.genre_other)",
"showAlbums": "erakutsi $t(entity.album_other) $t(entity.album_other)",
"showTracks": "erakutsi $t(entity.genre_one) $t(entity.track_other)"
},
"globalSearch": {
"title": "komandoak",
"commands": {
"goToPage": "joan orrira",
"searchFor": "bilatu {{query}}",
"serverCommands": "zerbitzariaren komandoak"
}
},
"home": {
"title": "$t(common.home)",
"mostPlayed": "gehien entzundakoak",
"newlyAdded": "azken aldian gehitutako argitalpenak",
"recentlyPlayed": "azken aldian entzundakoak",
"recentlyReleased": "azken aldian argitaratutak",
"explore": "arakatu zure liburutegitik"
},
"playlistList": {
"title": "$t(entity.playlist_other)"
},
"setting": {
"advanced": "aurreratua",
"generalTab": "orokorra",
"playbackTab": "erreprodukzioa",
"windowTab": "leihoa"
},
"sidebar": {
"albumArtists": "$t(entity.albumArtist_other)",
"albums": "$t(entity.album_other)",
"artists": "$t(entity.artist_other)",
"folders": "$t(entity.folder_other)",
"genres": "$t(entity.genre_other)",
"home": "$t(common.home)",
"playlists": "$t(entity.playlist_other)",
"search": "$t(common.search)",
"settings": "$t(common.setting_other)",
"tracks": "$t(entity.track_other)",
"myLibrary": "nire liburutegia",
"nowPlaying": "orain erreproduzitzen",
"shared": "partekatutako $t(entity.playlist_other)"
},
"trackList": {
"title": "$t(entity.track_other)",
"genreTracks": "\"{{genre}}\" $t(entity.track_other)",
"artistTracks": "{{artist}}-(r)en abestiak"
},
"albumArtistDetail": {
"about": "{{artist}}-(r)i buruz",
"relatedArtists": "erlazionatutako $t(entity.artist_other)",
"topSongs": "abesti nagusiak",
"topSongsFrom": "{{title}}-(a)ren abesti nagusiak",
"viewAll": "ikusi guztiak",
"viewAllTracks": "ikusi $t(entity.track_other) guztiak",
"appearsOn": "agertzen da hemen",
"recentReleases": "azken argitalpenak",
"viewDiscography": "ikusi diskografia"
},
"itemDetail": {
"copyPath": "kopiatu bidea arbelean",
"openFile": "erakutsi pista fitxategi-kudeatzailean",
"copiedPath": "bidea behar bezala kopiatu da"
},
"playlist": {
"reorder": "berrantolaketa IDaren arabera ordenatzean bakarrik gaituta dago"
}
}
}
+46 -39
View File
@@ -65,7 +65,7 @@
"unknown": "inconnu",
"areYouSure": "êtes-vous sûr?",
"edit": "éditer",
"favorite": "favoris",
"favorite": "favori",
"left": "gauche",
"save": "enregistrer",
"right": "droite",
@@ -85,7 +85,7 @@
"duration": "durée",
"name": "nom",
"maximize": "agrandir",
"decrease": "baisser",
"decrease": "diminuer",
"ok": "ok",
"description": "description",
"configure": "configurer",
@@ -110,7 +110,7 @@
"filter_other": "filtres",
"filters": "filtres",
"create": "créer",
"bitrate": "bitrate",
"bitrate": "bit binaire",
"saveAndReplace": "enregistrer et remplacer",
"action_one": "action",
"action_many": "actions",
@@ -144,7 +144,7 @@
"albumGain": "gain de l'album",
"albumPeak": "crête de l'album",
"close": "fermer",
"mbid": "Identifiants MusicBrainz",
"mbid": "Identifiant MusicBrainz",
"preview": "aperçu",
"share": "partager",
"reload": "recharger",
@@ -157,7 +157,7 @@
"newVersion": "une nouvelle version vient d'être installée ({{version}})",
"viewReleaseNotes": "voir la note de version",
"sampleRate": "taux d'échantillonnage",
"bitDepth": "bit par échantillon"
"bitDepth": "format d'échantillonnage"
},
"error": {
"remotePortWarning": "redémarrer le serveur pour appliquer le nouveau port",
@@ -187,7 +187,7 @@
},
"filter": {
"mostPlayed": "plus joués",
"playCount": "nombre d'écoute",
"playCount": "nombre d'écoutes",
"isCompilation": "est une compilation",
"recentlyPlayed": "récemment joué",
"isRated": "est noté",
@@ -202,7 +202,7 @@
"releaseDate": "date de sortie",
"communityRating": "note de la communauté",
"path": "chemin",
"favorited": "favoris",
"favorited": "favori",
"isRecentlyPlayed": "est récemment joué",
"isFavorited": "est favori",
"bpm": "BPM",
@@ -212,7 +212,7 @@
"songCount": "nombre de chansons",
"duration": "durée",
"random": "aléatoire",
"lastPlayed": "dernier joué",
"lastPlayed": "écouté récemment",
"toYear": "à l'année",
"fromYear": "depuis l'année",
"criticRating": "note des critiques",
@@ -256,10 +256,10 @@
"lyricAlignment": "alignement des paroles",
"useImageAspectRatio": "utiliser le ratio de l'image",
"opacity": "opacité",
"lyricSize": "Taille des paroles",
"lyricSize": "taille des paroles",
"lyricGap": "espacement des lettres",
"dynamicIsImage": "activer l'image d'arrière-plan",
"dynamicImageBlur": "intensité de flou sur image d'arrière-plan",
"dynamicImageBlur": "intensité du flou sur l'image d'arrière-plan",
"lyricOffset": "paroles décalées (ms)"
},
"upNext": "à suivre",
@@ -285,9 +285,10 @@
"home": {
"mostPlayed": "Les plus joués",
"newlyAdded": "Ajoutés récemment",
"explore": "explorer depuis la bibliothèque",
"explore": "Explorer depuis la bibliothèque",
"recentlyPlayed": "Joués récemment",
"title": "$t(common.home)"
"title": "$t(common.home)",
"recentlyReleased": "Sortis récemment"
},
"albumDetail": {
"moreFromArtist": "plus de $t(entity.artist_one)",
@@ -364,7 +365,7 @@
"viewAllTracks": "voir tout $t(entity.track_other)",
"recentReleases": "sorties récentes",
"viewDiscography": "voir la discographie",
"relatedArtists": "en rapport avec $t(entity.artist_other)",
"relatedArtists": "$t(entity.artist_other) similaires",
"topSongs": "meilleurs titres"
},
"itemDetail": {
@@ -394,12 +395,12 @@
"accentColor": "couleur d'accentuation",
"accentColor_description": "définit la couleur d'accentuation de l'application",
"applicationHotkeys": "raccourcis clavier d'application",
"crossfadeDuration": "durée de fondue enchaînée",
"crossfadeDuration": "durée de fondu enchaîné",
"audioPlayer": "lecteur audio",
"applicationHotkeys_description": "configurer les raccourcis clavier dapplication. activer la case à cocher pour définir comme raccourci clavier global (bureau uniquement)",
"crossfadeStyle_description": "sélectionnez le style du fondu enchaîné à utiliser pour le lecteur audio",
"customFontPath": "chemin de police personnalisé",
"disableAutomaticUpdates": "désactiver les mises à jour automatique",
"disableAutomaticUpdates": "désactiver les mises à jour automatiques",
"customFontPath_description": "définit le chemin de police personnalisé pour l'application",
"remotePort_description": "définit le port du serveur de contrôle à distance",
"hotkey_skipBackward": "reculer",
@@ -417,9 +418,9 @@
"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",
"hotkey_zoomIn": "zoom avant",
"scrobble_description": "scrobble les lectures à votre serveur multimédia",
"scrobble_description": "scrobbler les lectures à votre serveur multimédia",
"hotkey_browserForward": "avancer",
"discordUpdateInterval": "interval de mise à jour de {{discord}} rich presence",
"discordUpdateInterval": "intervalle de mise à jour de {{discord}} Rich Presence",
"fontType_optionBuiltIn": "police intégrée",
"hotkey_playbackPlayPause": "lecture / pause",
"hotkey_rate1": "noter 1 étoile",
@@ -502,11 +503,11 @@
"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",
"sidebarPlaylistList": "liste des listes de lecture de la barre latérale",
"sidebarCollapsedNavigation": "navigation de la barre latéral (réduite)",
"sidebarCollapsedNavigation": "navigation de la barre latérale (réduite)",
"skipDuration": "durée de l'avance rapide",
"sidePlayQueueStyle_optionAttached": "attaché",
"sidePlayQueueStyle": "style de la liste de lecture latérale",
"sidebarPlaylistList_description": "affiche ou cache le menu de listes de lecture de la barre latérale",
"sidebarPlaylistList_description": "affiche ou cache la liste des listes de lecture de la barre latérale",
"sidePlayQueueStyle_description": "définit le style de la liste de lecture latérale",
"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",
@@ -559,13 +560,13 @@
"homeConfiguration": "configuration 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",
"imageAspectRatio": "utiliser le rapport hauteur/largeur natif de la pochette",
"imageAspectRatio_description": "si cette option est activée, les pochettes 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": "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",
"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.",
"playerAlbumArtResolution": "résolution de la pochette de l'album du lecteur",
"playerAlbumArtResolution": "résolution de la pochette d'album du lecteur",
"passwordStore": "mots de passe",
"playerAlbumArtResolution_description": "la résolution pour l'aperçu de la pochette d'album agrandie du lecteur. plus grand le rend plus net, mais peut ralentir le chargement. la valeur par défaut est 0 (automatique)",
"playerAlbumArtResolution_description": "résolution pour l'aperçu de la pochette d'album agrandie du lecteur. plus grand le rend plus net, mais peut ralentir le chargement. la valeur par défaut est 0 (automatique)",
"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",
"genreBehavior_description": "détermine si cliquer sur un genre ouvre par défaut la liste des pistes ou des albums",
@@ -589,7 +590,7 @@
"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",
"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 les illustrations de l'album",
"albumBackground_description": "ajoute une image d'arrière-plan pour les pages de l'album contenant une pochette d'album",
"albumBackgroundBlur_description": "ajuste le niveau de flou appliqué à l'image d'arrière-plan de l'album",
"playButtonBehavior_optionPlayShuffled": "$t(player.shuffle)",
"playerbarOpenDrawer": "basculement plein écran de la barre de lecteur",
@@ -607,15 +608,15 @@
"transcodeNote": "prend effet après 1 (web) - 2 (mpv) titres",
"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",
"doubleClickBehavior_description": "si vrai, toutes les pistes correspondantes dans une recherche de piste seront mises en file d'attente. sinon, seule celle sur laquelle vous avez cliqué sera mise en file d'attente",
"albumBackgroundBlur": "taille 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_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_description": "partage pochette du status d'activité {{discord}} depuis le serveur lui même, disponible uniquement pour jellyfin et navidrome",
"discordServeImage_description": "partage de la pochette d'album de Rich Presence {{discord}} depuis le serveur directement (disponible uniquement pour Jellyfin et Navidrome)",
"lastfm": "affiche les liens de last.fm",
"musicbrainz_description": "affiches les liens vers musicbrainz sur les pages des artistes/albums, quand mbid existes",
"musicbrainz_description": "affiche les liens vers MusicBrainz sur les pages des artistes/albums, quand l'identifiant MusicBrainz existe",
"lastfm_description": "affiche les liens vers last.fm sur les pages des artistes/albums",
"musicbrainz": "affiches les liens musicbrainz",
"musicbrainz": "affiche les liens MusicBrainz",
"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.",
"preferLocalLyrics_description": "privilégier les paroles locales aux paroles distantes lorsqu'elles sont disponibles",
@@ -627,12 +628,16 @@
"notify": "activer les notifications des chansons",
"notify_description": "affiche une notification lors du changement de chanson",
"discordDisplayType": "type d'affichage du status {{discord}}",
"discordDisplayType_description": "change ce que vous écoutez dans votre statut",
"discordDisplayType_description": "modifie ce que vous écoutez dans votre statut",
"discordDisplayType_songname": "nom du morceau",
"discordDisplayType_artistname": "nom(s) dartiste",
"hotkey_navigateHome": "aller à l'accueil",
"preventSleepOnPlayback_description": "Empêche la mise en veille du lecteur lorsque la musique est en cours de lecture",
"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_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_none": "$t(common.none)",
"discordLinkType_mbz_lastfm": "{{musicbrainz}} avec {{lastfm}} si le premier n'est pas disponible"
},
"form": {
"deletePlaylist": {
@@ -651,7 +656,9 @@
"input_savePassword": "enregister le mot de passe",
"ignoreSsl": "ignorer ssl $t(common.restartRequired)",
"ignoreCors": "ignorer cors $t(common.restartRequired)",
"error_savePassword": "une erreur sest produite lors de la tentative de sauvegarde du mot de passe"
"error_savePassword": "une erreur sest produite lors de la tentative de sauvegarde du mot de passe",
"input_preferInstantMix": "Préférer le mix instantané",
"input_preferInstantMixDescription": "Utiliser uniquement le mix instantané pour jouer des pistes similaires. Activez cette option si vous avez des plugins qui modifient ce comportement"
},
"addToPlaylist": {
"success": "$t(entity.trackWithCount, {\"count\": {{message}} }) ajouté à $t(entity.playlistWithCount, {\"count\": {{numOfPlaylists}} })",
@@ -682,7 +689,7 @@
"success": "$t(entity.playlist_one) mis à jour avec succès"
},
"lyricSearch": {
"title": "rechercher parole",
"title": "recherche de paroles",
"input_name": "$t(common.name)",
"input_artist": "$t(entity.artist_one)"
},
@@ -747,9 +754,9 @@
"trackWithCount_one": "{{count}} piste",
"trackWithCount_many": "{{count}} pistes",
"trackWithCount_other": "{{count}} pistes",
"play_one": "{{count}} écouter",
"play_many": "{{count}} écoute",
"play_other": "{{count}} écoute",
"play_one": "{{count}} écoute",
"play_many": "{{count}} écoutes",
"play_other": "{{count}} écoutes",
"song_one": "titre",
"song_many": "titres",
"song_other": "titres"
@@ -777,7 +784,7 @@
"releaseDate": "date de sortie",
"titleCombined": "$t(common.title) (combiné)",
"dateAdded": "date d'ajout",
"lastPlayed": "dernière écoute",
"lastPlayed": "écouté récemment",
"trackNumber": "numéro de piste",
"rowIndex": "index de ligne",
"playCount": "nombre de lecture",
@@ -809,7 +816,7 @@
"album": "album",
"rating": "note",
"favorite": "favori",
"playCount": "lectures",
"playCount": "écoutes",
"releaseYear": "année",
"biography": "biographie",
"releaseDate": "date de sortie",
@@ -822,7 +829,7 @@
"path": "chemin",
"discNumber": "disque",
"albumCount": "$t(entity.album_other)",
"lastPlayed": "dernière lecture",
"lastPlayed": "écouté récemment",
"artist": "$t(entity.artist_one)",
"genre": "$t(entity.genre_one)",
"songCount": "$t(entity.track_other)",
+10 -3
View File
@@ -404,7 +404,11 @@
"notify_description": "mostra una notifica quando cambia la traccia riprodotta",
"preventSleepOnPlayback": "non sospendere in riproduzione",
"preventSleepOnPlayback_description": "non sospendere il sistema quando la riproduzione è attiva",
"discordDisplayType": "stile dello stato su {{discord}}"
"discordDisplayType": "stile dello stato su {{discord}}",
"discordLinkType": "link di attività {{discord}}",
"discordLinkType_description": "aggiunge collegamenti esterni a {{lastfm}} o {{musicbrainz}} ai campi del brano e dell'artista nell'attività {{discord}}. {{musicbrainz}} è il più accurato, ma richiede tag e non fornisce collegamenti dell'artista mentre {{lastfm}} dovrebbe sempre fornire un link. non rende richieste di rete extra",
"discordLinkType_none": "$t(common.none)",
"discordLinkType_mbz_lastfm": "{{musicbrainz}} con {{lastfm}} fallback"
},
"error": {
"remotePortWarning": "riavvia il server per applicare la nuova porta",
@@ -560,7 +564,8 @@
"newlyAdded": "nuovi rilasci aggiunti",
"title": "$t(common.home)",
"explore": "esplora dalla tua libreria",
"recentlyPlayed": "riprodotti recentemente"
"recentlyPlayed": "riprodotti recentemente",
"recentlyReleased": "appena pubblicato"
},
"albumDetail": {
"moreFromArtist": "di più da questo $t(entity.artist_one)",
@@ -656,7 +661,9 @@
"input_savePassword": "salva password",
"ignoreSsl": "ignora ssl ($t(common.restartRequired))",
"ignoreCors": "ignora cors ($t(common.restartRequired))",
"error_savePassword": "si è verificato un errore quando si è provato a salvare la password"
"error_savePassword": "si è verificato un errore quando si è provato a salvare la password",
"input_preferInstantMix": "preferisci mix istantaneo",
"input_preferInstantMixDescription": "usa solo mix istantaneo per ottenere canzoni simili. utile se si dispone di plugin che modificano questo comportamento"
},
"addToPlaylist": {
"success": "aggiunto $t(entity.trackWithCount, {\"count\": {{message}} }) a $t(entity.playlistWithCount, {\"count\": {{numOfPlaylists}} })",
+5 -1
View File
@@ -209,7 +209,11 @@
"moveToBottom": "末尾に移動",
"setRating": "評価",
"toggleSmartPlaylistEditor": "$t(entity.smartPlaylist) エディタの切り替え",
"removeFromFavorites": "$t(entity.favorite_other) から削除"
"removeFromFavorites": "$t(entity.favorite_other) から削除",
"openIn": {
"lastfm": "Last.fmで開く",
"musicbrainz": "MusicBrainzで開く"
}
},
"common": {
"backward": "戻る",
+17 -4
View File
@@ -200,7 +200,8 @@
"badAlbum": "ta strona jest wyświetlana, ponieważ ten utwór nie jest częścią albumu. najprawdopodobniej ten problem występuje, jeśli utwór znajduje się w nadrzędnym folderze plików z muzyką. jellyfin grupuje utwory tylko wtedy, gdy znajdują się one w folderze.",
"networkError": "wystąpił błąd sieciowy",
"openError": "nie można otworzyć pliku",
"badValue": "niewłaściwa opcja \"{{value}}\". ta wartość już nie istnieje"
"badValue": "niewłaściwa opcja \"{{value}}\". ta wartość już nie istnieje",
"notificationDenied": "odmówiono uprawnień dla powiadomień. to ustawienie nie będzie miało efektu"
},
"filter": {
"mostPlayed": "najczęściej odtwarzane",
@@ -305,6 +306,11 @@
"success": "link do udostępniania skopiowany do schowka (lub kliknij tutaj, aby otworzyć)",
"createFailed": "nie udało się utworzyć linku do udostępniania (czy udostępnianie jest włączone?)",
"expireInvalid": "ustawiony czas wygaśnięcia musi być w przyszłości"
},
"privateMode": {
"enabled": "tryb prywatny włączony, status odtwarzania jest ukryty przed usługami zewnętrznymi",
"disabled": "tryb prywatny wyłączony, status odtwarzania jest widoczny dla usług zewnętrznych",
"title": "tryb prywatny"
}
},
"page": {
@@ -341,7 +347,9 @@
"openBrowserDevtools": "otwórz narzędzia deweloperskie przeglądarki",
"quit": "$t(common.quit)",
"goBack": "do tyłu",
"goForward": "do przodu"
"goForward": "do przodu",
"privateModeOff": "wyłącz tryb prywatny",
"privateModeOn": "włącz tryb prywatny"
},
"contextMenu": {
"addToPlaylist": "$t(action.addToPlaylist)",
@@ -365,7 +373,9 @@
"download": "pobierz",
"playShuffled": "$t(player.shuffle)",
"playSimilarSongs": "$t(player.playSimilarSongs)",
"moveToNext": "$t(action.moveToNext)"
"moveToNext": "$t(action.moveToNext)",
"goToAlbum": "przejdź do $t(entity.album_one)",
"goToAlbumArtist": "przejdź do $t(entity.albumArtist_one)"
},
"albumDetail": {
"moreFromArtist": "więcej od $t(entity.artist_one)",
@@ -724,7 +734,10 @@
"lastfm_description": "pokazuj linki do last.fm na stronach artystów/albumów",
"notify": "włącz powiadomienia o piosenkach",
"musicbrainz": "pokazuj linki do musicbrainz",
"musicbrainz_description": "pokazuj linki do musicbrainz na stronach artystów/albumów, gdzie istnieje mbid"
"musicbrainz_description": "pokazuj linki do musicbrainz na stronach artystów/albumów, gdzie istnieje mbid",
"discordPausedStatus": "pokaż status podczas pauzy",
"discordServeImage": "wysyłaj obrazy dla {{discord}} z serwera",
"discordServeImage_description": "pokazuj okładki w statusie {{discord}} prosto z serwera, dostępne tylko dla jellyfin i navidrome"
},
"table": {
"config": {
+4 -2
View File
@@ -233,13 +233,15 @@
"ignoreCors": "cors'u $t(common.restartRequired) görmezden gel",
"ignoreSsl": "ssl bağlantısını görmezden gel $t(common.restartRequired)",
"input_legacyAuthentication": "eski kimlik doğrulamayı etkinleştir",
"input_name": "sucunu ismi",
"input_name": "sunucu ismi",
"input_password": "şifre",
"input_savePassword": "şifreyi kaydet",
"input_url": "URL",
"input_username": "kullanıcı ismi",
"success": "sunucu başarıyla eklendi",
"title": "sunucu ekle"
"title": "sunucu ekle",
"input_preferInstantMix": "anında mix tercih et",
"input_preferInstantMixDescription": "sadece benzer şarkılari bulmak icin anında mix kullan. Bu davranışı değiştiren eklentilere sahipseniz faydalı"
},
"addToPlaylist": {
"input_playlists": "$t(entity.playlist_other)",
+19 -4
View File
@@ -399,7 +399,7 @@
"lastfmApiKey": "{{lastfm}} API 密钥",
"lastfmApiKey_description": "{{lastfm}} 的 API 密钥。封面艺术图所需",
"discordServeImage": "从服务器提供 {{discord}} 图像",
"discordServeImage_description": "分享 {{discord}} 封面艺术图,来自 rich presence 服务器,仅适用于 jellyfin 和 navidrome",
"discordServeImage_description": "从服务器本身分享 {{discord}} rich presence 的封面艺术,仅适用于 jellyfin 和 navidrome。 {{discord}} 使用机器人来获取图像,因此您的服务器必须可通过公共互联网访问。",
"musicbrainz": "显示 musicbrainz 链接",
"musicbrainz_description": "在 mbid 的艺术家/专辑页面上显示 musicbrainz 的链接",
"lastfm": "显示 last.fm 链接",
@@ -418,7 +418,19 @@
"discordDisplayType_artistname": "艺术家名称",
"hotkey_navigateHome": "导航到主页",
"preventSleepOnPlayback": "防止播放时进入睡眠状态",
"preventSleepOnPlayback_description": "播放音乐时防止显示器进入睡眠状态"
"preventSleepOnPlayback_description": "播放音乐时防止显示器进入睡眠状态",
"discordLinkType": "{{discord}} 状态链接",
"discordLinkType_description": "在 {{discord}} 的歌曲和艺术家字段中添加 {{lastfm}} 或 {{musicbrainz}} 的外部链接。{{musicbrainz}} 最准确,但需要标签,且不提供艺术家链接,而 {{lastfm}} 则始终提供链接。无需额外的网络请求",
"discordLinkType_none": "$t(common.none)",
"discordLinkType_mbz_lastfm": "{{musicbrainz}} 和 {{lastfm}} 后备",
"artistBackground": "艺术家背景图片",
"artistBackground_description": "为包含艺术家作品的艺术家页面添加背景图片",
"artistBackgroundBlur": "艺术家背景图像模糊尺寸",
"artistBackgroundBlur_description": "调整应用于艺术家背景图像的模糊程度",
"releaseChannel_optionLatest": "stable",
"releaseChannel_optionBeta": "beta",
"releaseChannel": "发布渠道",
"releaseChannel_description": "选择稳定版本或测试版以进行自动更新"
},
"error": {
"remotePortWarning": "重启服务器使新端口生效",
@@ -548,7 +560,8 @@
"newlyAdded": "最近添加的发布",
"explore": "从库中搜索",
"recentlyPlayed": "最近播放",
"title": "$t(common.home)"
"title": "$t(common.home)",
"recentlyReleased": "最近发布"
},
"albumDetail": {
"moreFromArtist": "更多该$t(entity.artist_one)作品",
@@ -662,7 +675,9 @@
"ignoreSsl": "忽略 ssl $t(common.restartRequired)",
"ignoreCors": "忽略 cors $t(common.restartRequired)",
"error_savePassword": "保存密码时出现错误",
"input_url": "url"
"input_url": "url",
"input_preferInstantMixDescription": "仅使用即时混音来获取类似的歌曲。如果您有修改此行为的插件,则很有用",
"input_preferInstantMix": "首选即时混音"
},
"addToPlaylist": {
"success": "添加$t(entity.trackWithCount, {\"count\": {{message}} })到$t(entity.playlistWithCount, {\"count\": {{numOfPlaylists}} })",
+38 -6
View File
@@ -142,7 +142,9 @@
"playSimilarSongs": "$t(player.playSimilarSongs)",
"playShuffled": "$t(player.shuffle)",
"shareItem": "分享項目",
"showDetails": "取得資訊"
"showDetails": "取得資訊",
"goToAlbum": "前往 $t(entity.album_one)",
"goToAlbumArtist": "前往 $t(entity.albumArtist_one)"
},
"globalSearch": {
"title": "指令",
@@ -157,7 +159,8 @@
"recentlyPlayed": "最近播放",
"title": "$t(common.home)",
"mostPlayed": "最多播放",
"newlyAdded": "最近新增的發行"
"newlyAdded": "最近新增的發行",
"recentlyReleased": "最近發佈"
},
"appMenu": {
"openBrowserDevtools": "打開瀏覽器開發者工具",
@@ -169,7 +172,9 @@
"selectServer": "選擇伺服器",
"settings": "$t(common.setting_other)",
"version": "版本 {{version}}",
"manageServers": "管理伺服器"
"manageServers": "管理伺服器",
"privateModeOff": "關閉私人模式",
"privateModeOn": "開啟私人模式"
},
"fullscreenPlayer": {
"config": {
@@ -489,7 +494,7 @@
"discordListening": "將狀態設為\"正在聽\"",
"discordListening_description": "將狀態顯示為\"正在聽\"而不是\"正在玩\"",
"discordServeImage": "從伺服器提供{{discord}}圖片",
"discordServeImage_description": "從伺服器本身 {{discord}} rich presence 分享封面圖片,只在jellyfin和navidrome可用",
"discordServeImage_description": "從伺服器本身分享 {{discord}} Rich Presence封面圖片,僅支援 Jellyfin 與 Navidrome。{{discord}} 會透過機器人擷取圖片,因此您的伺服器必須能從公開網路連線。",
"doubleClickBehavior": "雙擊時將所有搜尋到的曲目加入佇列",
"doubleClickBehavior_description": "如果為 true,則歌曲搜尋中所有符合的歌曲都會被加入佇列。否則,只有被點擊的歌曲才會被加入佇列",
"externalLinks": "顯示外部連結",
@@ -544,7 +549,27 @@
"webAudio": "使用網頁音訊",
"webAudio_description": "使用網頁音訊。這將啟用重播增益等進階功能。如果您遇到其他問題,請停用",
"preservePitch": "保持音高",
"preservePitch_description": "修改播放速度時保留音調"
"preservePitch_description": "修改播放速度時保留音調",
"artistBackground": "藝人背景圖片",
"artistBackground_description": "為藝人頁面新增含藝人圖片的背景圖像",
"artistBackgroundBlur": "藝人背景圖片模糊程度",
"artistBackgroundBlur_description": "調整套用至藝人背景圖片的模糊程度",
"releaseChannel_optionLatest": "穩定版",
"releaseChannel_optionBeta": "測試版",
"releaseChannel_description": "選擇自動更新時要使用穩定版本或是測試版本",
"discordDisplayType": "{{discord}} presence 顯示類型",
"discordDisplayType_description": "變更您在狀態中正在聆聽的內容",
"discordDisplayType_songname": "歌曲名稱",
"discordDisplayType_artistname": "藝人名稱",
"discordLinkType": "{{discord}} presence 連結",
"discordLinkType_description": "在 {{discord}} Rich Presence中,為歌曲和藝人欄位新增 {{lastfm}} 或 {{musicbrainz}} 的外部連結。{{musicbrainz}} 的準確度最高,但需要標籤且不提供藝人連結;而 {{lastfm}} 通常都能提供連結。此功能不會產生額外的網路請求",
"discordLinkType_none": "$t(common.none)",
"discordLinkType_mbz_lastfm": "{{musicbrainz}} 並以 {{lastfm}} 備用",
"hotkey_navigateHome": "導航至首頁",
"preventSleepOnPlayback": "防止播放時進入睡眠狀態",
"preventSleepOnPlayback_description": "在音樂播放時防止螢幕進入睡眠狀態",
"mediaSession": "啟用Media Session",
"mediaSession_description": "啟用 Windows Media Session 整合功能,於系統音量Overlay和鎖定畫面中顯示媒體資料與控制面板(僅限 Windows)"
},
"table": {
"config": {
@@ -723,7 +748,9 @@
"title": "新增伺服器",
"error_savePassword": "儲存密碼時出現錯誤",
"ignoreCors": "忽略 cors $t(common.restartRequired)",
"ignoreSsl": "忽略 ssl $t(common.restartRequired)"
"ignoreSsl": "忽略 ssl $t(common.restartRequired)",
"input_preferInstantMix": "偏好即時混音",
"input_preferInstantMixDescription": "僅使用即時混音功能來獲取相似歌曲。若您擁有能修改此行為的外掛,此功能將相當實用"
},
"addToPlaylist": {
"input_playlists": "$t(entity.playlist_other)",
@@ -770,6 +797,11 @@
"success": "分享連結已複製到剪貼簿(或點擊此處開啟)",
"expireInvalid": "到期日必須是未來",
"createFailed": "無法建立分享(分享是否啟用?)"
},
"privateMode": {
"enabled": "已啟用私人模式,播放狀態將對外部整合隱藏",
"disabled": "已停用私人模式,播放狀態現對已啟用的外部整合可見",
"title": "私人模式"
}
}
}
@@ -0,0 +1,55 @@
import { createSocket } from 'dgram';
import { ipcMain } from 'electron';
import { DiscoveredServerItem, ServerType } from '/@/shared/types/types';
type JellyfinResponse = {
Address: string;
Id: string;
Name: string;
};
function discoverAll(reply: (server: DiscoveredServerItem) => void) {
return Promise.all([discoverJellyfin(reply)]);
}
function discoverJellyfin(reply: (server: DiscoveredServerItem) => void) {
const sock = createSocket('udp4');
sock.on('message', (msg) => {
try {
const response: JellyfinResponse = JSON.parse(msg.toString('utf-8'));
reply({
name: response.Name,
type: ServerType.JELLYFIN,
url: response.Address,
});
} catch (e) {
// Got a spurious response, ignore?
console.error(e);
}
});
sock.bind(() => {
sock.setBroadcast(true);
// Send a broadcast packet to both loopback and default route, allowing discovery of same-machine instances
sock.send('who is JellyfinServer?', 7359, '127.255.255.255');
sock.send('who is JellyfinServer?', 7359, '255.255.255.255');
});
return new Promise<void>((resolve) => {
setTimeout(() => {
sock.close();
resolve();
}, 3000);
});
}
ipcMain.on('autodiscover-ping', (ev) => {
if (ev.ports.length === 0) throw new Error('Expected a port to stream autodiscovery results');
const port = ev.ports[0];
discoverAll((result) => port.postMessage(result))
.then(() => port.close())
.catch((err) => console.error(err));
});
+1
View File
@@ -1,3 +1,4 @@
import './autodiscover';
import './lyrics';
import './player';
import './remote';
+4
View File
@@ -107,6 +107,10 @@ mprisPlayer.on('seek', (event: number) => {
});
});
mprisPlayer.on('raise', () => {
getMainWindow()?.show();
});
ipcMain.on('update-position', (_event, arg: number) => {
mprisPlayer.getPosition = () => arg * 1e6;
});
+35 -1
View File
@@ -23,6 +23,7 @@ import { access, constants, readFile, writeFile } from 'fs';
import path, { join } from 'path';
import { deflate, inflate } from 'zlib';
import packageJson from '../../package.json';
import { disableMediaKeys, enableMediaKeys } from './features/core/player/media-keys';
import { shutdownServer } from './features/core/remote';
import { store } from './features/core/settings';
@@ -43,6 +44,32 @@ export default class AppUpdater {
constructor() {
log.transports.file.level = 'info';
autoUpdater.logger = autoUpdaterLogInterface;
const isBetaVersion = packageJson.version.includes('-beta');
const releaseChannel = store.get('release_channel');
const isNotConfigured = !releaseChannel;
console.log('Release channel: ', releaseChannel);
console.log('Is beta version: ', isBetaVersion);
if (isNotConfigured) {
console.log(
'Release channel not configured, setting to ',
isBetaVersion ? 'beta' : 'latest',
);
store.set('release_channel', isBetaVersion ? 'beta' : 'latest');
}
if (releaseChannel === 'beta') {
autoUpdater.channel = 'beta';
autoUpdater.allowPrerelease = true;
autoUpdater.disableDifferentialDownload = true;
} else if (releaseChannel === 'latest') {
autoUpdater.channel = 'latest';
autoUpdater.allowDowngrade = true;
autoUpdater.allowPrerelease = false;
}
autoUpdater.checkForUpdatesAndNotify();
}
}
@@ -521,7 +548,14 @@ async function createWindow(first = true): Promise<void> {
}
}
app.commandLine.appendSwitch('disable-features', 'HardwareMediaKeyHandling,MediaSessionService');
const enableWindowsMediaSession = store.get('mediaSession', false) as boolean;
const shouldDisableMediaFeatures = process.platform !== 'win32' || !enableWindowsMediaSession;
if (shouldDisableMediaFeatures) {
app.commandLine.appendSwitch(
'disable-features',
'HardwareMediaKeyHandling,MediaSessionService',
);
}
// https://github.com/electron/electron/issues/46538#issuecomment-2808806722
app.commandLine.appendSwitch('gtk-version', '3');
+23
View File
@@ -0,0 +1,23 @@
import { ipcRenderer } from 'electron';
import { DiscoveredServerItem } from '../shared/types/types';
const discover = (onReply: (server: DiscoveredServerItem) => void): Promise<void> => {
const { port1: local, port2: remote } = new MessageChannel();
ipcRenderer.postMessage('autodiscover-ping', {}, [remote]);
local.onmessage = (ev) => {
onReply(ev.data);
};
return new Promise<void>((resolve) => {
local.addEventListener('close', () => resolve());
});
};
export const autodiscover = {
discover,
};
export type AutoDiscover = typeof autodiscover;
+2
View File
@@ -1,6 +1,7 @@
import { electronAPI } from '@electron-toolkit/preload';
import { contextBridge } from 'electron';
import { autodiscover } from './autodiscover';
import { browser } from './browser';
import { discordRpc } from './discord-rpc';
import { ipc } from './ipc';
@@ -13,6 +14,7 @@ import { utils } from './utils';
// Custom APIs for renderer
const api = {
autodiscover,
browser,
discordRpc,
ipc,
@@ -542,10 +542,6 @@ export const JellyfinController: ControllerEndpoint = {
query: {
Fields: 'Genres, DateCreated, MediaSources, UserData, ParentId, People, Tags',
IncludeItemTypes: 'Audio',
Limit: query.limit,
SortBy: query.sortBy ? songListSortMap.jellyfin[query.sortBy] : undefined,
SortOrder: query.sortOrder ? sortOrderMap.jellyfin[query.sortOrder] : undefined,
StartIndex: query.startIndex,
UserId: apiClientProps.server?.userId,
},
});
@@ -556,7 +552,7 @@ export const JellyfinController: ControllerEndpoint = {
return {
items: res.body.Items.map((item) => jfNormalize.song(item, apiClientProps.server, '')),
startIndex: query.startIndex,
startIndex: 0,
totalRecordCount: res.body.TotalRecordCount,
};
},
@@ -631,30 +627,34 @@ export const JellyfinController: ControllerEndpoint = {
getSimilarSongs: async (args) => {
const { apiClientProps, query } = args;
// Prefer getSimilarSongs, where possible. Fallback to InstantMix
// where no similar songs were found.
const res = await jfApiClient(apiClientProps).getSimilarSongs({
params: {
itemId: query.songId,
},
query: {
Fields: 'Genres, DateCreated, MediaSources, ParentId',
Limit: query.count,
UserId: apiClientProps.server?.userId || undefined,
},
});
if (apiClientProps.server?.preferInstantMix !== true) {
// Prefer getSimilarSongs, where possible, and not overridden.
// InstantMix can be overridden by plugins, so this may be preferred by the user.
// Otherwise, similarSongs may have a better output than InstantMix, if sufficient
// data exists from the server.
const res = await jfApiClient(apiClientProps).getSimilarSongs({
params: {
itemId: query.songId,
},
query: {
Fields: 'Genres, DateCreated, MediaSources, ParentId',
Limit: query.count,
UserId: apiClientProps.server?.userId || undefined,
},
});
if (res.status === 200 && res.body.Items.length) {
const results = res.body.Items.reduce<Song[]>((acc, song) => {
if (song.Id !== query.songId) {
acc.push(jfNormalize.song(song, apiClientProps.server, ''));
if (res.status === 200 && res.body.Items.length) {
const results = res.body.Items.reduce<Song[]>((acc, song) => {
if (song.Id !== query.songId) {
acc.push(jfNormalize.song(song, apiClientProps.server, ''));
}
return acc;
}, []);
if (results.length > 0) {
return results;
}
return acc;
}, []);
if (results.length > 0) {
return results;
}
}
@@ -3,9 +3,7 @@ import { ssApiClient } from '/@/renderer/api/subsonic/subsonic-api';
import { SubsonicController } from '/@/renderer/api/subsonic/subsonic-controller';
import { NDSongListSort } from '/@/shared/api/navidrome.types';
import { ndNormalize } from '/@/shared/api/navidrome/navidrome-normalize';
import { ndType } from '/@/shared/api/navidrome/navidrome-types';
import { ssNormalize } from '/@/shared/api/subsonic/subsonic-normalize';
import { SubsonicExtensions } from '/@/shared/api/subsonic/subsonic-types';
import { getFeatures, hasFeature, VersionInfo } from '/@/shared/api/utils';
import {
albumArtistListSortMap,
@@ -22,10 +20,11 @@ import {
sortOrderMap,
userListSortMap,
} from '/@/shared/types/domain-types';
import { ServerFeature, ServerFeatures } from '/@/shared/types/features-types';
import { ServerFeature } from '/@/shared/types/features-types';
const VERSION_INFO: VersionInfo = [
['0.55.0', { [ServerFeature.BFR]: [1] }],
['0.56.0', { [ServerFeature.TRACK_ALBUM_ARTIST_SEARCH]: [1] }],
['0.55.0', { [ServerFeature.BFR]: [1], [ServerFeature.TAGS]: [1] }],
['0.49.3', { [ServerFeature.SHARING_ALBUM_SONG]: [1] }],
['0.48.0', { [ServerFeature.PLAYLISTS_SMART]: [1] }],
];
@@ -56,6 +55,9 @@ const excludeMissing = (server: null | ServerListItem) => {
return undefined;
};
const getArtistSongKey = (server: null | ServerListItem) =>
hasFeature(server, ServerFeature.TRACK_ALBUM_ARTIST_SEARCH) ? 'artists_id' : 'album_artist_id';
export const NavidromeController: ControllerEndpoint = {
addToPlaylist: async (args) => {
const { apiClientProps, body, query } = args;
@@ -269,6 +271,10 @@ export const NavidromeController: ControllerEndpoint = {
getAlbumList: async (args) => {
const { apiClientProps, query } = args;
const genres = hasFeature(apiClientProps.server, ServerFeature.BFR)
? query.genres
: query.genres?.[0];
const res = await ndApiClient(apiClientProps).getAlbumList({
query: {
_end: query.startIndex + (query.limit || 0),
@@ -277,7 +283,7 @@ export const NavidromeController: ControllerEndpoint = {
_start: query.startIndex,
artist_id: query.artistIds?.[0],
compilation: query.compilation,
genre_id: query.genres?.[0],
genre_id: genres,
name: query.searchTerm,
...query._custom?.navidrome,
starred: query.favorite,
@@ -430,12 +436,9 @@ export const NavidromeController: ControllerEndpoint = {
id: query.id,
},
query: {
_end: query.startIndex + (query.limit || -1),
_order: query.sortOrder ? sortOrderMap.navidrome[query.sortOrder] : 'ASC',
_sort: query.sortBy
? songListSortMap.navidrome[query.sortBy]
: ndType._enum.songList.ID,
_start: query.startIndex,
_end: -1,
_order: 'ASC',
_start: 0,
...excludeMissing(apiClientProps.server),
},
});
@@ -446,7 +449,7 @@ export const NavidromeController: ControllerEndpoint = {
return {
items: res.body.data.map((item) => ndNormalize.song(item, apiClientProps.server)),
startIndex: query?.startIndex || 0,
startIndex: 0,
totalRecordCount: Number(res.body.headers.get('x-total-count') || 0),
};
},
@@ -467,38 +470,20 @@ export const NavidromeController: ControllerEndpoint = {
ping.body.serverVersion = '0.55.0';
}
const navidromeFeatures: Record<string, number[]> = getFeatures(
VERSION_INFO,
ping.body.serverVersion!,
);
const navidromeFeatures = getFeatures(VERSION_INFO, ping.body.serverVersion!);
const subsonicArgs = await SubsonicController.getServerInfo(args);
if (ping.body.openSubsonic) {
const res = await ssApiClient(apiClientProps).getServerInfo();
if (res.status !== 200) {
throw new Error('Failed to get server extensions');
}
// The type here isn't necessarily an array (even though it's supposed to be). This is
// an implementation detail of Navidrome 0.50. Do a type check to make sure it's actually
// an array, and not an empty object.
if (Array.isArray(res.body.openSubsonicExtensions)) {
for (const extension of res.body.openSubsonicExtensions) {
navidromeFeatures[extension.name] = extension.versions;
}
}
}
const features: ServerFeatures = {
bfr: navidromeFeatures[ServerFeature.BFR],
lyricsMultipleStructured: navidromeFeatures[SubsonicExtensions.SONG_LYRICS],
playlistsSmart: navidromeFeatures[ServerFeature.PLAYLISTS_SMART],
const features = {
...navidromeFeatures,
...subsonicArgs.features,
publicPlaylist: [1],
sharingAlbumSong: navidromeFeatures[ServerFeature.SHARING_ALBUM_SONG],
tags: navidromeFeatures[ServerFeature.BFR],
};
return { features, id: apiClientProps.server?.id, version: ping.body.serverVersion! };
return {
features,
id: apiClientProps.server?.id,
version: ping.body.serverVersion!,
};
},
getSimilarSongs: async (args) => {
const { apiClientProps, query } = args;
@@ -535,7 +520,7 @@ export const NavidromeController: ControllerEndpoint = {
_order: 'ASC',
_sort: NDSongListSort.RANDOM,
_start: 0,
album_artist_id: query.albumArtistIds,
[getArtistSongKey(apiClientProps.server)]: query.albumArtistIds,
...excludeMissing(apiClientProps.server),
},
});
@@ -576,10 +561,9 @@ export const NavidromeController: ControllerEndpoint = {
_order: sortOrderMap.navidrome[query.sortOrder],
_sort: songListSortMap.navidrome[query.sortBy],
_start: query.startIndex,
album_artist_id: query.albumArtistIds,
album_id: query.albumIds,
artist_id: query.artistIds,
genre_id: query.genreIds,
[getArtistSongKey(apiClientProps.server)]: query.artistIds ?? query.albumArtistIds,
starred: query.favorite,
title: query.searchTerm,
...query._custom?.navidrome,
+1 -26
View File
@@ -9,7 +9,6 @@ import type {
LyricsQuery,
PlaylistDetailQuery,
PlaylistListQuery,
PlaylistSongListQuery,
RandomSongListQuery,
SearchQuery,
SimilarSongsQuery,
@@ -191,21 +190,6 @@ export const queryKeys: Record<
if (id) return [serverId, 'playlists', id, 'detail'] as const;
return [serverId, 'playlists', 'detail'] as const;
},
detailSongList: (serverId: string, id: string, query?: PlaylistSongListQuery) => {
const { filter, pagination } = splitPaginatedQuery(query);
if (query && id && pagination) {
return [serverId, 'playlists', id, 'detailSongList', filter, pagination] as const;
}
if (query && id) {
return [serverId, 'playlists', id, 'detailSongList', filter] as const;
}
if (id) return [serverId, 'playlists', id, 'detailSongList'] as const;
return [serverId, 'playlists', 'detailSongList'] as const;
},
list: (serverId: string, query?: PlaylistListQuery) => {
const { filter, pagination } = splitPaginatedQuery(query);
if (query && pagination) {
@@ -219,16 +203,7 @@ export const queryKeys: Record<
return [serverId, 'playlists', 'list'] as const;
},
root: (serverId: string) => [serverId, 'playlists'] as const,
songList: (serverId: string, id?: string, query?: PlaylistSongListQuery) => {
const { filter, pagination } = splitPaginatedQuery(query);
if (query && id && pagination) {
return [serverId, 'playlists', id, 'songList', filter, pagination] as const;
}
if (query && id) {
return [serverId, 'playlists', id, 'songList', filter] as const;
}
songList: (serverId: string, id?: string) => {
if (id) return [serverId, 'playlists', id, 'songList'] as const;
return [serverId, 'playlists', 'songList'] as const;
},
@@ -41,7 +41,7 @@ const ALBUM_LIST_SORT_MAPPING: Record<AlbumListSort, AlbumListSortType | undefin
[AlbumListSort.RATING]: undefined,
[AlbumListSort.RECENTLY_ADDED]: AlbumListSortType.NEWEST,
[AlbumListSort.RECENTLY_PLAYED]: AlbumListSortType.RECENT,
[AlbumListSort.RELEASE_DATE]: undefined,
[AlbumListSort.RELEASE_DATE]: AlbumListSortType.BY_YEAR,
[AlbumListSort.SONG_COUNT]: undefined,
[AlbumListSort.YEAR]: AlbumListSortType.BY_YEAR,
};
@@ -759,18 +759,14 @@ export const SubsonicController: ControllerEndpoint = {
throw new Error('Failed to get playlist song list');
}
let results =
const items =
res.body.playlist.entry?.map((song) => ssNormalize.song(song, apiClientProps.server)) ||
[];
if (query.sortBy && query.sortOrder) {
results = sortSongList(results, query.sortBy, query.sortOrder);
}
return {
items: results,
items,
startIndex: 0,
totalRecordCount: results?.length || 0,
totalRecordCount: items.length,
};
},
getRandomSongList: async (args) => {
+10 -1
View File
@@ -190,7 +190,16 @@ export const App = () => {
return (
<MantineProvider defaultColorScheme={mode as 'dark' | 'light'} theme={theme}>
<Notifications containerWidth="300px" position="bottom-center" zIndex={50000} />
<Notifications
containerWidth="300px"
position="bottom-center"
styles={{
root: {
marginBottom: 90,
},
}}
zIndex={50000}
/>
<PlayQueueHandlerContext.Provider value={providerValue}>
<WebAudioContext.Provider value={webAudioProvider}>
<AppRouter />
@@ -233,17 +233,17 @@ export const AudioPlayer = forwardRef<AudioPlayerRef, AudioPlayerProps>((props,
}
const { error } = target;
if (error?.code !== MediaError.MEDIA_ERR_DECODE) {
console.log('Playback error occurred:', error);
if (
error?.code !== MediaError.MEDIA_ERR_DECODE &&
error?.code !== MediaError.MEDIA_ERR_SRC_NOT_SUPPORTED
) {
return;
}
const duration = player.getDuration();
const currentTime = player.getCurrentTime();
// Decode error within last second, handle as track ended
if (duration && duration - currentTime < 1) {
handleOnEnded();
}
handleOnEnded();
};
};
@@ -83,9 +83,9 @@ export const AlbumDetailHeader = forwardRef(
},
{
id: 'songCount',
value: `${detailQuery?.data?.songCount} ${t('entity.track_other', {
value: t('entity.trackWithCount', {
count: detailQuery?.data?.songCount as number,
})}`,
}),
},
{
id: 'duration',
@@ -2,12 +2,21 @@ import debounce from 'lodash/debounce';
import { ChangeEvent, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { SelectWithInvalidData } from '/@/renderer/components/select-with-invalid-data';
import {
MultiSelectWithInvalidData,
SelectWithInvalidData,
} from '/@/renderer/components/select-with-invalid-data';
import { useAlbumArtistList } from '/@/renderer/features/artists/queries/album-artist-list-query';
import { useGenreList } from '/@/renderer/features/genres';
import { useTagList } from '/@/renderer/features/tag/queries/use-tag-list';
import { AlbumListFilter, useListStoreActions, useListStoreByKey } from '/@/renderer/store';
import {
AlbumListFilter,
getServerById,
useListStoreActions,
useListStoreByKey,
} from '/@/renderer/store';
import { NDSongQueryFields } from '/@/shared/api/navidrome.types';
import { hasFeature } from '/@/shared/api/utils';
import { Divider } from '/@/shared/components/divider/divider';
import { Group } from '/@/shared/components/group/group';
import { NumberInput } from '/@/shared/components/number-input/number-input';
@@ -23,6 +32,7 @@ import {
LibraryItem,
SortOrder,
} from '/@/shared/types/domain-types';
import { ServerFeature } from '/@/shared/types/features-types';
interface NavidromeAlbumFiltersProps {
customFilters?: Partial<AlbumListFilter>;
@@ -42,6 +52,7 @@ export const NavidromeAlbumFilters = ({
const { t } = useTranslation();
const { filter } = useListStoreByKey<AlbumListQuery>({ key: pageKey });
const { setFilter } = useListStoreActions();
const server = getServerById(serverId);
const genreListQuery = useGenreList({
options: {
@@ -64,12 +75,14 @@ export const NavidromeAlbumFilters = ({
}));
}, [genreListQuery.data]);
const handleGenresFilter = debounce((e: null | string) => {
const hasBrf = hasFeature(server, ServerFeature.BFR);
const handleGenresFilter = debounce((e: null | string[]) => {
const updatedFilters = setFilter({
customFilters,
data: {
_custom: filter._custom,
genres: e ? [e] : undefined,
genres: e ? e : undefined,
},
itemType: LibraryItem.ALBUM,
key: pageKey,
@@ -269,15 +282,29 @@ export const NavidromeAlbumFilters = ({
min={0}
onChange={(e) => handleYearFilter(e)}
/>
<SelectWithInvalidData
clearable
data={genreList}
defaultValue={filter.genres && filter.genres[0]}
label={t('entity.genre', { count: 1, postProcess: 'titleCase' })}
onChange={handleGenresFilter}
searchable
/>
{!hasBrf && (
<SelectWithInvalidData
clearable
data={genreList}
defaultValue={filter.genres && filter.genres[0]}
label={t('entity.genre', { count: 1, postProcess: 'titleCase' })}
onChange={(value) => handleGenresFilter(value !== null ? [value] : null)}
searchable
/>
)}
</Group>
{hasBrf && (
<Group grow>
<MultiSelectWithInvalidData
clearable
data={genreList}
defaultValue={filter.genres}
label={t('entity.genre', { count: 2, postProcess: 'sentenceCase' })}
onChange={handleGenresFilter}
searchable
/>
</Group>
)}
<Group grow>
<SelectWithInvalidData
clearable
@@ -9,8 +9,4 @@
gap: var(--theme-spacing-lg);
padding: 1rem 2rem 5rem;
overflow: hidden;
:global(.ag-theme-alpine-dark) {
--ag-header-background-color: rgb(0 0 0 / 0%) !important;
}
}
@@ -14,12 +14,15 @@ import { Text } from '/@/shared/components/text/text';
import { LibraryItem, ServerType } from '/@/shared/types/domain-types';
interface AlbumArtistDetailHeaderProps {
background?: string;
loading: boolean;
background: {
background?: string;
blur: number;
loading: boolean;
};
}
export const AlbumArtistDetailHeader = forwardRef(
({ background, loading }: AlbumArtistDetailHeaderProps, ref: Ref<HTMLDivElement>) => {
({ background }: AlbumArtistDetailHeaderProps, ref: Ref<HTMLDivElement>) => {
const { albumArtistId, artistId } = useParams() as {
albumArtistId?: string;
artistId?: string;
@@ -76,12 +79,11 @@ export const AlbumArtistDetailHeader = forwardRef(
return (
<LibraryHeader
background={background}
imageUrl={detailQuery?.data?.imageUrl}
item={{ route: AppRoute.LIBRARY_ALBUM_ARTISTS, type: LibraryItem.ALBUM_ARTIST }}
loading={loading}
ref={ref}
title={detailQuery?.data?.name || ''}
{...background}
>
<Stack>
<Group>
@@ -9,13 +9,14 @@ import { usePlayQueueAdd } from '/@/renderer/features/player';
import { AnimatedPage, LibraryHeaderBar } from '/@/renderer/features/shared';
import { useFastAverageColor } from '/@/renderer/hooks';
import { useCurrentServer } from '/@/renderer/store';
import { usePlayButtonBehavior } from '/@/renderer/store/settings.store';
import { useGeneralSettings, usePlayButtonBehavior } from '/@/renderer/store/settings.store';
import { LibraryItem } from '/@/shared/types/domain-types';
const AlbumArtistDetailRoute = () => {
const scrollAreaRef = useRef<HTMLDivElement>(null);
const headerRef = useRef<HTMLDivElement>(null);
const server = useCurrentServer();
const { artistBackground, artistBackgroundBlur } = useGeneralSettings();
const { albumArtistId, artistId } = useParams() as {
albumArtistId?: string;
@@ -30,12 +31,16 @@ const AlbumArtistDetailRoute = () => {
query: { id: routeId },
serverId: server?.id,
});
const { background, colorId } = useFastAverageColor({
id: routeId,
const { background: backgroundColor, colorId } = useFastAverageColor({
id: artistId,
src: detailQuery.data?.imageUrl,
srcLoaded: !detailQuery.isLoading,
});
const backgroundUrl = detailQuery.data?.imageUrl || '';
const background = (artistBackground && `url(${backgroundUrl})`) || backgroundColor;
const handlePlay = () => {
handlePlayQueueAdd?.({
byItemType: {
@@ -65,8 +70,11 @@ const AlbumArtistDetailRoute = () => {
ref={scrollAreaRef}
>
<AlbumArtistDetailHeader
background={background}
loading={!background || colorId !== routeId}
background={{
background,
blur: (artistBackground && artistBackgroundBlur) || 0,
loading: !backgroundColor || colorId !== artistId,
}}
ref={headerRef}
/>
<AlbumArtistDetailContent background={background} />
@@ -541,7 +541,6 @@ export const ContextMenuProvider = ({ children }: ContextMenuProviderProps) => {
});
},
onSuccess: () => {
ctx.context?.tableRef?.current?.api?.refreshInfiniteCache();
closeAllModals();
},
},
@@ -558,7 +557,6 @@ export const ContextMenuProvider = ({ children }: ContextMenuProviderProps) => {
});
}, [
ctx.context?.playlistId,
ctx.context?.tableRef,
ctx.data,
ctx.dataNodes,
removeFromPlaylistMutation,
@@ -5,6 +5,7 @@ import { useCallback, useEffect, useState } from 'react';
import { controller } from '/@/renderer/api/controller';
import {
DiscordDisplayType,
DiscordLinkType,
getServerById,
useAppStore,
useDiscordSettings,
@@ -77,6 +78,34 @@ export const useDiscordRpc = () => {
type: discordSettings.showAsListening ? 2 : 0,
};
if (
(discordSettings.linkType == DiscordLinkType.LAST_FM ||
discordSettings.linkType == DiscordLinkType.MBZ_LAST_FM) &&
song?.artistName
) {
activity.stateUrl =
'https://www.last.fm/music/' + encodeURIComponent(song.artists[0].name);
activity.detailsUrl =
'https://www.last.fm/music/' +
encodeURIComponent(song.albumArtists[0].name) +
'/' +
encodeURIComponent(song.album || '_') +
'/' +
encodeURIComponent(song.name);
}
if (
discordSettings.linkType == DiscordLinkType.MBZ ||
discordSettings.linkType == DiscordLinkType.MBZ_LAST_FM
) {
if (song?.mbzTrackId) {
activity.detailsUrl = 'https://musicbrainz.org/track/' + song.mbzTrackId;
} else if (song?.mbzRecordingId) {
activity.detailsUrl =
'https://musicbrainz.org/recording/' + song.mbzRecordingId;
}
}
if ((current[2] as PlayerStatus) === PlayerStatus.PLAYING) {
if (start && end) {
activity.startTimestamp = start;
@@ -145,6 +174,7 @@ export const useDiscordRpc = () => {
generalSettings.lastfmApiKey,
discordSettings.clientId,
discordSettings.displayType,
discordSettings.linkType,
lastUniqueId,
],
);
@@ -1,8 +1,6 @@
import { useQueryClient } from '@tanstack/react-query';
import { useMemo, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { queryKeys } from '/@/renderer/api/query-keys';
import { FeatureCarousel } from '/@/renderer/components/feature-carousel/feature-carousel';
import { MemoizedSwiperGridCarousel } from '/@/renderer/components/grid-carousel/grid-carousel';
import { NativeScrollArea } from '/@/renderer/components/native-scroll-area/native-scroll-area';
@@ -32,12 +30,16 @@ import {
} from '/@/shared/types/domain-types';
import { Platform } from '/@/shared/types/types';
const BASE_QUERY_ARGS = {
limit: 15,
sortOrder: SortOrder.DESC,
startIndex: 0,
};
const HomeRoute = () => {
const { t } = useTranslation();
const queryClient = useQueryClient();
const scrollAreaRef = useRef<HTMLDivElement>(null);
const server = useCurrentServer();
const itemsPerPage = 15;
const { windowBarStyle } = useWindowSettings();
const { homeFeature, homeItems } = useGeneralSettings();
@@ -56,59 +58,66 @@ const HomeRoute = () => {
serverId: server?.id,
});
const isJellyfin = server?.type === ServerType.JELLYFIN;
const featureItemsWithImage = useMemo(() => {
return feature.data?.items?.filter((item) => item.imageUrl) ?? [];
}, [feature.data?.items]);
const queriesEnabled = useMemo(() => {
return homeItems.reduce(
(previous: Record<HomeItem, boolean>, current) => ({
...previous,
[current.id]: !current.disabled,
}),
{} as Record<HomeItem, boolean>,
);
}, [homeItems]);
const random = useAlbumList({
options: {
enabled: queriesEnabled[HomeItem.RANDOM],
staleTime: 1000 * 60 * 5,
},
query: {
limit: itemsPerPage,
...BASE_QUERY_ARGS,
sortBy: AlbumListSort.RANDOM,
sortOrder: SortOrder.ASC,
startIndex: 0,
},
serverId: server?.id,
});
const recentlyPlayed = useRecentlyPlayed({
options: {
enabled: queriesEnabled[HomeItem.RECENTLY_PLAYED] && !isJellyfin,
staleTime: 0,
},
query: {
limit: itemsPerPage,
...BASE_QUERY_ARGS,
sortBy: AlbumListSort.RECENTLY_PLAYED,
sortOrder: SortOrder.DESC,
startIndex: 0,
},
serverId: server?.id,
});
const recentlyAdded = useAlbumList({
options: {
enabled: queriesEnabled[HomeItem.RECENTLY_ADDED],
staleTime: 1000 * 60 * 5,
},
query: {
limit: itemsPerPage,
...BASE_QUERY_ARGS,
sortBy: AlbumListSort.RECENTLY_ADDED,
sortOrder: SortOrder.DESC,
startIndex: 0,
},
serverId: server?.id,
});
const mostPlayedAlbums = useAlbumList({
options: {
enabled: server?.type === ServerType.SUBSONIC || server?.type === ServerType.NAVIDROME,
enabled: !isJellyfin && queriesEnabled[HomeItem.MOST_PLAYED],
staleTime: 1000 * 60 * 5,
},
query: {
limit: itemsPerPage,
...BASE_QUERY_ARGS,
sortBy: AlbumListSort.PLAY_COUNT,
sortOrder: SortOrder.DESC,
startIndex: 0,
},
serverId: server?.id,
});
@@ -116,27 +125,38 @@ const HomeRoute = () => {
const mostPlayedSongs = useSongList(
{
options: {
enabled: server?.type === ServerType.JELLYFIN,
enabled: isJellyfin && queriesEnabled[HomeItem.MOST_PLAYED],
staleTime: 1000 * 60 * 5,
},
query: {
limit: itemsPerPage,
...BASE_QUERY_ARGS,
sortBy: SongListSort.PLAY_COUNT,
sortOrder: SortOrder.DESC,
startIndex: 0,
},
serverId: server?.id,
},
300,
);
const recentlyReleased = useAlbumList({
options: {
enabled: queriesEnabled[HomeItem.RECENTLY_RELEASED],
staleTime: 1000 * 60 * 5,
},
query: {
...BASE_QUERY_ARGS,
sortBy: AlbumListSort.RELEASE_DATE,
},
serverId: server?.id,
});
const isLoading =
random.isLoading ||
recentlyPlayed.isLoading ||
recentlyAdded.isLoading ||
(server?.type === ServerType.JELLYFIN && mostPlayedSongs.isLoading) ||
((server?.type === ServerType.SUBSONIC || server?.type === ServerType.NAVIDROME) &&
mostPlayedAlbums.isLoading);
(random.isLoading && queriesEnabled[HomeItem.RANDOM]) ||
(recentlyPlayed.isLoading && queriesEnabled[HomeItem.RECENTLY_PLAYED] && !isJellyfin) ||
(recentlyAdded.isLoading && queriesEnabled[HomeItem.RECENTLY_ADDED]) ||
(recentlyReleased.isLoading && queriesEnabled[HomeItem.RECENTLY_RELEASED]) ||
(((isJellyfin && mostPlayedSongs.isLoading) ||
(!isJellyfin && mostPlayedAlbums.isLoading)) &&
queriesEnabled[HomeItem.MOST_PLAYED]);
if (isLoading) {
return <Spinner container />;
@@ -144,48 +164,35 @@ const HomeRoute = () => {
const carousels = {
[HomeItem.MOST_PLAYED]: {
data:
server?.type === ServerType.JELLYFIN
? mostPlayedSongs?.data?.items
: mostPlayedAlbums?.data?.items,
itemType: server?.type === ServerType.JELLYFIN ? LibraryItem.SONG : LibraryItem.ALBUM,
pagination: {
itemsPerPage,
},
sortBy:
server?.type === ServerType.JELLYFIN
? SongListSort.PLAY_COUNT
: AlbumListSort.PLAY_COUNT,
sortOrder: SortOrder.DESC,
data: isJellyfin ? mostPlayedSongs?.data?.items : mostPlayedAlbums?.data?.items,
itemType: isJellyfin ? LibraryItem.SONG : LibraryItem.ALBUM,
query: isJellyfin ? mostPlayedSongs : mostPlayedAlbums,
title: t('page.home.mostPlayed', { postProcess: 'sentenceCase' }),
},
[HomeItem.RANDOM]: {
data: random?.data?.items,
itemType: LibraryItem.ALBUM,
sortBy: AlbumListSort.RANDOM,
sortOrder: SortOrder.ASC,
query: random,
title: t('page.home.explore', { postProcess: 'sentenceCase' }),
},
[HomeItem.RECENTLY_ADDED]: {
data: recentlyAdded?.data?.items,
itemType: LibraryItem.ALBUM,
pagination: {
itemsPerPage,
},
sortBy: AlbumListSort.RECENTLY_ADDED,
sortOrder: SortOrder.DESC,
query: recentlyAdded,
title: t('page.home.newlyAdded', { postProcess: 'sentenceCase' }),
},
[HomeItem.RECENTLY_PLAYED]: {
data: recentlyPlayed?.data?.items,
itemType: LibraryItem.ALBUM,
pagination: {
itemsPerPage,
},
sortBy: AlbumListSort.RECENTLY_PLAYED,
sortOrder: SortOrder.DESC,
query: recentlyPlayed,
title: t('page.home.recentlyPlayed', { postProcess: 'sentenceCase' }),
},
[HomeItem.RECENTLY_RELEASED]: {
data: recentlyReleased?.data?.items,
itemType: LibraryItem.ALBUM,
query: recentlyReleased,
title: t('page.home.recentlyReleased', { postProcess: 'sentenceCase' }),
},
};
const sortedCarousel = homeItems
@@ -193,7 +200,7 @@ const HomeRoute = () => {
if (item.disabled) {
return false;
}
if (server?.type === ServerType.JELLYFIN && item.id === HomeItem.RECENTLY_PLAYED) {
if (isJellyfin && item.id === HomeItem.RECENTLY_PLAYED) {
return false;
}
@@ -204,36 +211,6 @@ const HomeRoute = () => {
uniqueId: item.id,
}));
const invalidateCarouselQuery = (carousel: {
itemType: LibraryItem;
sortBy: AlbumListSort | SongListSort;
sortOrder: SortOrder;
}) => {
if (carousel.itemType === LibraryItem.ALBUM) {
queryClient.invalidateQueries({
exact: false,
queryKey: queryKeys.albums.list(server?.id, {
limit: itemsPerPage,
sortBy: carousel.sortBy,
sortOrder: carousel.sortOrder,
startIndex: 0,
}),
});
}
if (carousel.itemType === LibraryItem.SONG) {
queryClient.invalidateQueries({
exact: false,
queryKey: queryKeys.songs.list(server?.id, {
limit: itemsPerPage,
sortBy: carousel.sortBy,
sortOrder: carousel.sortOrder,
startIndex: 0,
}),
});
}
};
return (
<AnimatedPage>
<NativeScrollArea
@@ -266,7 +243,7 @@ const HomeRoute = () => {
slugs: [
{
idProperty:
server?.type === ServerType.JELLYFIN &&
isJellyfin &&
carousel.itemType === LibraryItem.SONG
? 'albumId'
: 'id',
@@ -297,8 +274,7 @@ const HomeRoute = () => {
slugs: [
{
idProperty:
server?.type === ServerType.JELLYFIN &&
carousel.itemType === LibraryItem.SONG
isJellyfin && carousel.itemType === LibraryItem.SONG
? 'albumId'
: 'id',
slugProperty: 'albumId',
@@ -310,7 +286,7 @@ const HomeRoute = () => {
<Group>
<TextTitle order={3}>{carousel.title}</TextTitle>
<ActionIcon
onClick={() => invalidateCarouselQuery(carousel)}
onClick={() => carousel.query.refetch()}
variant="transparent"
>
<Icon icon="refresh" />
@@ -81,7 +81,7 @@ const formatArtists = (artists: null | RelatedArtist[] | undefined) =>
{artist.name || '—'}
</Text>
) : (
<Text overflow="visible" size="md">
<Text component="span" overflow="visible" size="md">
{artist.name || '-'}
</Text>
)}
@@ -24,8 +24,8 @@
position: relative;
display: flex;
width: 100%;
height: 100%;
min-width: 0;
height: 100%;
overflow: hidden;
&:hover {
@@ -11,23 +11,25 @@ import { PlayButton, PlayerButton } from '/@/renderer/features/player/components
import { PlayerbarSlider } from '/@/renderer/features/player/components/playerbar-slider';
import { openShuffleAllModal } from '/@/renderer/features/player/components/shuffle-all-modal';
import { useCenterControls } from '/@/renderer/features/player/hooks/use-center-controls';
import { useMediaSession } from '/@/renderer/features/player/hooks/use-media-session';
import { usePlayQueueAdd } from '/@/renderer/features/player/hooks/use-playqueue-add';
import {
useAppStore,
useAppStoreActions,
useCurrentPlayer,
useCurrentSong,
useCurrentStatus,
useCurrentTime,
useRepeatStatus,
useSetCurrentTime,
useShuffleStatus,
} from '/@/renderer/store';
import {
useHotkeySettings,
usePlaybackType,
useRepeatStatus,
useSetCurrentTime,
useSettingsStore,
} from '/@/renderer/store/settings.store';
useShuffleStatus,
} from '/@/renderer/store';
import { Icon } from '/@/shared/components/icon/icon';
import { Text } from '/@/shared/components/text/text';
import { PlaybackSelectors } from '/@/shared/constants/playback-selectors';
import { PlaybackType, PlayerRepeat, PlayerShuffle, PlayerStatus } from '/@/shared/types/types';
interface CenterControlsProps {
@@ -37,16 +39,14 @@ interface CenterControlsProps {
export const CenterControls = ({ playersRef }: CenterControlsProps) => {
const { t } = useTranslation();
const queryClient = useQueryClient();
const [isSeeking, setIsSeeking] = useState(false);
const currentSong = useCurrentSong();
const skip = useSettingsStore((state) => state.general.skipButtons);
const buttonSize = useSettingsStore((state) => state.general.buttonSize);
const playbackType = usePlaybackType();
const player1 = playersRef?.current?.player1;
const player2 = playersRef?.current?.player2;
const status = useCurrentStatus();
const player = useCurrentPlayer();
const setCurrentTime = useSetCurrentTime();
const repeat = useRepeatStatus();
const shuffle = useShuffleStatus();
const { bindings } = useHotkeySettings();
@@ -66,31 +66,6 @@ export const CenterControls = ({ playersRef }: CenterControlsProps) => {
} = useCenterControls({ playersRef });
const handlePlayQueueAdd = usePlayQueueAdd();
const songDuration = currentSong?.duration ? currentSong.duration / 1000 : 0;
const currentTime = useCurrentTime();
const currentPlayerRef = player === 1 ? player1 : player2;
const duration = formatDuration(songDuration * 1000 || 0);
const formattedTime = formatDuration(currentTime * 1000 || 0);
useEffect(() => {
let interval: ReturnType<typeof setInterval>;
if (status === PlayerStatus.PLAYING && !isSeeking) {
if (!isElectron() || playbackType === PlaybackType.WEB) {
// Update twice a second for slightly better performance
interval = setInterval(() => {
if (currentPlayerRef) {
setCurrentTime(currentPlayerRef.getCurrentTime());
}
}, 500);
}
}
return () => clearInterval(interval);
}, [currentPlayerRef, isSeeking, setCurrentTime, playbackType, status]);
const [seekValue, setSeekValue] = useState(0);
useHotkeys([
[bindings.playPause.isGlobal ? '' : bindings.playPause.hotkey, handlePlayPause],
[bindings.play.isGlobal ? '' : bindings.play.hotkey, handlePlay],
@@ -110,9 +85,20 @@ export const CenterControls = ({ playersRef }: CenterControlsProps) => {
],
]);
useMediaSession({
handleNextTrack,
handlePause,
handlePlay,
handlePrevTrack,
handleSeekSlider,
handleSkipBackward,
handleSkipForward,
handleStop,
});
return (
<>
<div className={styles.controlsContainer}>
<div className={styles.controlsContainer} style={{ zIndex: 50001 }}>
<div className={styles.buttonsContainer}>
<PlayerButton
icon={<Icon fill="default" icon="mediaStop" size={buttonSize - 2} />}
@@ -251,41 +237,110 @@ export const CenterControls = ({ playersRef }: CenterControlsProps) => {
/>
</div>
</div>
<div className={styles.sliderContainer}>
<div className={styles.sliderValueWrapper}>
<Text fw={600} isMuted isNoSelect size="xs">
{formattedTime}
</Text>
</div>
<div className={styles.sliderWrapper}>
<PlayerbarSlider
label={(value) => formatDuration(value * 1000)}
max={songDuration}
min={0}
onChange={(e) => {
setIsSeeking(true);
setSeekValue(e);
}}
onChangeEnd={(e) => {
// There is a timing bug in Mantine in which the onChangeEnd
// event fires before onChange. Add a small delay to force
// onChangeEnd to happen after onCHange
setTimeout(() => {
handleSeekSlider(e);
setIsSeeking(false);
}, 50);
}}
size={6}
value={!isSeeking ? currentTime : seekValue}
w="100%"
/>
</div>
<div className={styles.sliderValueWrapper}>
<Text fw={600} isMuted isNoSelect size="xs">
{duration}
</Text>
</div>
</div>
<PlayerSeekSlider
handleSeekSlider={handleSeekSlider}
player1={player1}
player2={player2}
/>
</>
);
};
const PlayerSeekSlider = ({
handleSeekSlider,
player1,
player2,
}: {
handleSeekSlider: (e: any | number) => void;
player1: any;
player2: any;
}) => {
const player = useCurrentPlayer();
const playbackType = usePlaybackType();
const setCurrentTime = useSetCurrentTime();
const status = useCurrentStatus();
const currentSong = useCurrentSong();
const songDuration = currentSong?.duration ? currentSong.duration / 1000 : 0;
const currentTime = useCurrentTime();
const currentPlayerRef = player === 1 ? player1 : player2;
const [isSeeking, setIsSeeking] = useState(false);
useEffect(() => {
let interval: ReturnType<typeof setInterval>;
if (status === PlayerStatus.PLAYING && !isSeeking) {
if (!isElectron() || playbackType === PlaybackType.WEB) {
// Update twice a second for slightly better performance
interval = setInterval(() => {
if (currentPlayerRef) {
setCurrentTime(currentPlayerRef.getCurrentTime());
}
}, 500);
}
}
return () => clearInterval(interval);
}, [currentPlayerRef, isSeeking, setCurrentTime, playbackType, status]);
const { showTimeRemaining } = useAppStore();
const { setShowTimeRemaining } = useAppStoreActions();
const formattedDuration = formatDuration(songDuration * 1000 || 0);
const formattedTimeRemaining = formatDuration((currentTime - songDuration) * 1000 || 0);
const formattedTime = formatDuration(currentTime * 1000 || 0);
const [seekValue, setSeekValue] = useState(0);
return (
<div className={styles.sliderContainer}>
<div className={styles.sliderValueWrapper}>
<Text
className={PlaybackSelectors.elapsedTime}
fw={600}
isMuted
isNoSelect
size="xs"
style={{ userSelect: 'none' }}
>
{formattedTime}
</Text>
</div>
<div className={styles.sliderWrapper}>
<PlayerbarSlider
label={(value) => formatDuration(value * 1000)}
max={songDuration}
min={0}
onChange={(e) => {
setIsSeeking(true);
setSeekValue(e);
}}
onChangeEnd={(e) => {
// There is a timing bug in Mantine in which the onChangeEnd
// event fires before onChange. Add a small delay to force
// onChangeEnd to happen after onCHange
setTimeout(() => {
handleSeekSlider(e);
setIsSeeking(false);
}, 50);
}}
size={6}
value={!isSeeking ? currentTime : seekValue}
w="100%"
/>
</div>
<div className={styles.sliderValueWrapper}>
<Text
className={PlaybackSelectors.totalDuration}
fw={600}
isMuted
isNoSelect
onClick={() => setShowTimeRemaining(!showTimeRemaining)}
role="button"
size="xs"
style={{ cursor: 'pointer', userSelect: 'none' }}
>
{showTimeRemaining ? formattedTimeRemaining : formattedDuration}
</Text>
</div>
</div>
);
};
@@ -24,6 +24,7 @@ import { Image } from '/@/shared/components/image/image';
import { Separator } from '/@/shared/components/separator/separator';
import { Text } from '/@/shared/components/text/text';
import { Tooltip } from '/@/shared/components/tooltip/tooltip';
import { PlaybackSelectors } from '/@/shared/constants/playback-selectors';
import { LibraryItem } from '/@/shared/types/domain-types';
export const LeftControls = () => {
@@ -104,7 +105,10 @@ export const LeftControls = () => {
openDelay={500}
>
<Image
className={styles.playerbarImage}
className={clsx(
styles.playerbarImage,
PlaybackSelectors.playerCoverArt,
)}
loading="eager"
src={currentSong?.imageUrl ?? ''}
/>
@@ -139,6 +143,7 @@ export const LeftControls = () => {
<div className={styles.lineItem} onClick={stopPropagation}>
<Group align="center" gap="xs" wrap="nowrap">
<Text
className={PlaybackSelectors.songTitle}
component={Link}
fw={500}
isLink
@@ -164,7 +169,11 @@ export const LeftControls = () => {
</Group>
</div>
<div
className={clsx(styles.lineItem, styles.secondary)}
className={clsx(
styles.lineItem,
styles.secondary,
PlaybackSelectors.songArtist,
)}
onClick={stopPropagation}
>
{artists?.map((artist, index) => (
@@ -190,7 +199,11 @@ export const LeftControls = () => {
))}
</div>
<div
className={clsx(styles.lineItem, styles.secondary)}
className={clsx(
styles.lineItem,
styles.secondary,
PlaybackSelectors.songAlbum,
)}
onClick={stopPropagation}
>
<Text
@@ -6,6 +6,7 @@ import styles from './player-button.module.css';
import { ActionIcon, ActionIconProps } from '/@/shared/components/action-icon/action-icon';
import { Tooltip, TooltipProps } from '/@/shared/components/tooltip/tooltip';
import { PlaybackSelectors } from '/@/shared/constants/playback-selectors';
interface PlayerButtonProps extends Omit<ActionIconProps, 'icon' | 'variant'> {
icon: ReactNode;
@@ -62,9 +63,13 @@ interface PlayButtonProps extends Omit<ActionIconProps, 'icon' | 'variant'> {
export const PlayButton = forwardRef<HTMLButtonElement, PlayButtonProps>(
({ isPaused, onClick, ...props }: PlayButtonProps, ref) => {
const playerStateClass = isPaused
? PlaybackSelectors.playerStatePaused
: PlaybackSelectors.playerStatePlaying;
return (
<ActionIcon
className={styles.main}
className={clsx(styles.main, playerStateClass)}
icon={isPaused ? 'mediaPlay' : 'mediaPause'}
iconProps={{
size: 'lg',
@@ -1,3 +1,4 @@
import clsx from 'clsx';
import { MouseEvent, useCallback } from 'react';
import styles from './playerbar.module.css';
@@ -25,6 +26,7 @@ import {
usePlaybackType,
useSettingsStore,
} from '/@/renderer/store/settings.store';
import { PlaybackSelectors } from '/@/shared/constants/playback-selectors';
import { PlaybackType } from '/@/shared/types/types';
export const Playerbar = () => {
@@ -56,7 +58,7 @@ export const Playerbar = () => {
return (
<div
className={styles.container}
className={clsx(styles.container, PlaybackSelectors.mediaPlayer)}
onClick={playerbarOpenDrawer ? handleToggleFullScreenPlayer : undefined}
>
<div className={styles.controlsGrid}>
@@ -0,0 +1,139 @@
import { useEffect } from 'react';
import {
useCurrentSong,
useCurrentStatus,
usePlaybackSettings,
useSettingsStore,
} from '/@/renderer/store';
import { PlayerStatus } from '/@/shared/types/types';
export const useMediaSession = ({
handleNextTrack,
handlePause,
handlePlay,
handlePrevTrack,
handleSeekSlider,
handleSkipBackward,
handleSkipForward,
handleStop,
}: {
handleNextTrack: () => void;
handlePause: () => void;
handlePlay: () => void;
handlePrevTrack: () => void;
handleSeekSlider: (e: any | number) => void;
handleSkipBackward: (seconds: number) => void;
handleSkipForward: (seconds: number) => void;
handleStop: () => void;
}) => {
const { mediaSession: mediaSessionEnabled } = usePlaybackSettings();
const playerStatus = useCurrentStatus();
const currentSong = useCurrentSong();
const mediaSession = navigator.mediaSession;
const skip = useSettingsStore((state) => state.general.skipButtons);
useEffect(() => {
if (!mediaSessionEnabled || !mediaSession) {
return;
}
mediaSession.setActionHandler('nexttrack', () => {
console.log('nexttrack');
handleNextTrack();
});
mediaSession.setActionHandler('pause', () => {
console.log('pause');
handlePause();
});
mediaSession.setActionHandler('play', () => {
console.log('play');
handlePlay();
});
mediaSession.setActionHandler('previoustrack', () => {
console.log('previoustrack');
handlePrevTrack();
});
mediaSession.setActionHandler('seekto', (e) => {
handleSeekSlider(e.seekTime);
});
mediaSession.setActionHandler('stop', () => {
handleStop();
});
mediaSession.setActionHandler('seekbackward', (e) => {
handleSkipBackward(e.seekOffset || skip?.skipBackwardSeconds || 5);
});
mediaSession.setActionHandler('seekforward', (e) => {
handleSkipForward(e.seekOffset || skip?.skipForwardSeconds || 5);
});
return () => {
mediaSession.setActionHandler('nexttrack', null);
mediaSession.setActionHandler('pause', null);
mediaSession.setActionHandler('play', null);
mediaSession.setActionHandler('previoustrack', null);
mediaSession.setActionHandler('seekto', null);
mediaSession.setActionHandler('stop', null);
mediaSession.setActionHandler('seekbackward', null);
mediaSession.setActionHandler('seekforward', null);
};
}, [
handleNextTrack,
handlePause,
handlePlay,
handlePrevTrack,
handleSeekSlider,
handleSkipBackward,
handleSkipForward,
handleStop,
mediaSession,
mediaSessionEnabled,
skip?.skipBackwardSeconds,
skip?.skipForwardSeconds,
]);
useEffect(() => {
if (!mediaSessionEnabled || !mediaSession) {
return;
}
const updateMetadata = () => {
mediaSession.metadata = new MediaMetadata({
album: currentSong?.album ?? '',
artist: currentSong?.artistName ?? '',
artwork: currentSong?.imageUrl
? [{ src: currentSong.imageUrl, type: 'image/png' }]
: [],
title: currentSong?.name ?? '',
});
};
updateMetadata();
return () => {
mediaSession.metadata = null;
};
}, [currentSong, mediaSession, mediaSessionEnabled]);
useEffect(() => {
if (!mediaSessionEnabled || !mediaSession) {
return;
}
if (mediaSession) {
const status = playerStatus === PlayerStatus.PLAYING ? 'playing' : 'paused';
mediaSession.playbackState = status;
}
return () => {
mediaSession.playbackState = 'none';
};
}, [playerStatus, mediaSession, mediaSessionEnabled]);
};
@@ -114,6 +114,7 @@ export const useScrobble = () => {
new Notification(`${currentSong.name}`, {
body: `${artists}\n${currentSong.album}`,
icon: currentSong.imageUrl || undefined,
silent: true,
});
}
}, 1000);
+12 -6
View File
@@ -4,17 +4,19 @@ import { api } from '/@/renderer/api';
import { queryKeys } from '/@/renderer/api/query-keys';
import {
PlaylistSongListQuery,
PlaylistSongListQueryClientSide,
ServerListItem,
SongDetailQuery,
SongListQuery,
SongListResponse,
SongListSort,
SortOrder,
sortSongList,
} from '/@/shared/types/domain-types';
export const getPlaylistSongsById = async (args: {
id: string;
query?: Partial<PlaylistSongListQuery>;
query?: Partial<PlaylistSongListQueryClientSide>;
queryClient: QueryClient;
server: ServerListItem;
}) => {
@@ -22,13 +24,9 @@ export const getPlaylistSongsById = async (args: {
const queryFilter: PlaylistSongListQuery = {
id,
sortBy: SongListSort.ID,
sortOrder: SortOrder.ASC,
startIndex: 0,
...query,
};
const queryKey = queryKeys.playlists.songList(server?.id, id, queryFilter);
const queryKey = queryKeys.playlists.songList(server?.id, id);
const res = await queryClient.fetchQuery(
queryKey,
@@ -46,6 +44,14 @@ export const getPlaylistSongsById = async (args: {
},
);
if (res) {
res.items = sortSongList(
res.items,
query?.sortBy || SongListSort.ID,
query?.sortOrder || SortOrder.ASC,
);
}
return res;
};
@@ -145,12 +145,7 @@ export const AddToPlaylistContextModal = ({
const uniqueSongIds: string[] = [];
if (values.skipDuplicates) {
const query = {
id: playlistId,
startIndex: 0,
};
const queryKey = queryKeys.playlists.songList(server?.id || '', playlistId, query);
const queryKey = queryKeys.playlists.songList(server?.id || '', playlistId);
const playlistSongsRes = await queryClient.fetchQuery(queryKey, ({ signal }) => {
if (!server)
@@ -164,9 +159,6 @@ export const AddToPlaylistContextModal = ({
},
query: {
id: playlistId,
sortBy: SongListSort.ID,
sortOrder: SortOrder.ASC,
startIndex: 0,
},
});
});
@@ -2,7 +2,6 @@ import type {
BodyScrollEvent,
ColDef,
GridReadyEvent,
IDatasource,
PaginationChangedEvent,
RowDoubleClickedEvent,
RowDragEvent,
@@ -27,7 +26,6 @@ import {
} from '/@/renderer/features/context-menu/context-menu-items';
import { usePlayQueueAdd } from '/@/renderer/features/player';
import { usePlaylistDetail } from '/@/renderer/features/playlists/queries/playlist-detail-query';
import { usePlaylistSongList } from '/@/renderer/features/playlists/queries/playlist-song-list-query';
import { useAppFocus } from '/@/renderer/hooks';
import {
useCurrentServer,
@@ -42,13 +40,15 @@ import { PersistedTableColumn, usePlayButtonBehavior } from '/@/renderer/store/s
import { toast } from '/@/shared/components/toast/toast';
import {
LibraryItem,
PlaylistSongListQuery,
PlaylistSongListQueryClientSide,
QueueSong,
ServerType,
Song,
SongListResponse,
SongListSort,
SortOrder,
} from '/@/shared/types/domain-types';
import { ListDisplayType, ServerType } from '/@/shared/types/types';
import { ListDisplayType } from '/@/shared/types/types';
interface PlaylistDetailContentProps {
songs?: Song[];
@@ -63,7 +63,7 @@ export const PlaylistDetailSongListContent = ({ songs, tableRef }: PlaylistDetai
const currentSong = useCurrentSong();
const server = useCurrentServer();
const page = usePlaylistDetailStore();
const filters: Partial<PlaylistSongListQuery> = useMemo(() => {
const filters: PlaylistSongListQueryClientSide = useMemo(() => {
return {
sortBy: page?.table.id[playlistId]?.filter?.sortBy || SongListSort.ID,
sortOrder: page?.table.id[playlistId]?.filter?.sortOrder || SortOrder.ASC,
@@ -88,20 +88,6 @@ export const PlaylistDetailSongListContent = ({ songs, tableRef }: PlaylistDetai
const isPaginationEnabled = page.display === ListDisplayType.TABLE_PAGINATED;
const iSClientSide = server?.type === ServerType.SUBSONIC;
const checkPlaylistList = usePlaylistSongList({
options: {
enabled: !iSClientSide,
},
query: {
id: playlistId,
limit: 1,
startIndex: 0,
},
serverId: server?.id,
});
const columnDefs: ColDef[] = useMemo(
() => getColumnDefs(page.table.columns, false, 'generic'),
[page.table.columns],
@@ -109,51 +95,9 @@ export const PlaylistDetailSongListContent = ({ songs, tableRef }: PlaylistDetai
const onGridReady = useCallback(
(params: GridReadyEvent) => {
if (!iSClientSide) {
const dataSource: IDatasource = {
getRows: async (params) => {
const limit = params.endRow - params.startRow;
const startIndex = params.startRow;
const query: PlaylistSongListQuery = {
id: playlistId,
limit,
startIndex,
...filters,
};
const queryKey = queryKeys.playlists.songList(
server?.id || '',
playlistId,
query,
);
if (!server) return;
const songsRes = await queryClient.fetchQuery(
queryKey,
async ({ signal }) =>
api.controller.getPlaylistSongList({
apiClientProps: {
server,
signal,
},
query,
}),
);
params.successCallback(
songsRes?.items || [],
songsRes?.totalRecordCount || 0,
);
},
rowCount: undefined,
};
params.api.setDatasource(dataSource);
}
params.api?.ensureIndexVisible(pagination.scrollOffset, 'top');
},
[filters, iSClientSide, pagination.scrollOffset, playlistId, queryClient, server],
[pagination.scrollOffset],
);
const handleDragEnd = useCallback(
@@ -175,12 +119,32 @@ export const PlaylistDetailSongListContent = ({ songs, tableRef }: PlaylistDetai
},
});
setTimeout(() => {
queryClient.invalidateQueries({
queryKey: queryKeys.playlists.songList(server?.id || '', playlistId),
});
e.api.refreshInfiniteCache();
}, 200);
queryClient.setQueryData<SongListResponse>(
queryKeys.playlists.songList(server?.id || '', playlistId),
(previous) => {
if (previous?.items) {
const from = e.node.rowIndex!;
const to = e.overIndex;
const item = previous.items[from];
const remaining = previous.items.toSpliced(from, 1);
remaining.splice(to, 0, item);
return {
error: previous.error,
items: remaining,
startIndex: previous.startIndex,
totalRecordCount: previous.totalRecordCount,
};
}
return previous;
},
);
// Nodes have to be redrawn, otherwise the row indexes will be wrong
// Maybe it's possible to only redraw necessary rows to not be as expensive?
tableRef.current?.api.redrawRows();
} catch (error) {
toast.error({
message: (error as Error).message,
@@ -189,7 +153,7 @@ export const PlaylistDetailSongListContent = ({ songs, tableRef }: PlaylistDetai
}
}
},
[playlistId, queryClient, server],
[playlistId, queryClient, server, tableRef],
);
const handleGridSizeChange = () => {
@@ -286,7 +250,9 @@ export const PlaylistDetailSongListContent = ({ songs, tableRef }: PlaylistDetai
const { rowClassRules } = useCurrentSongRowStyles({ tableRef });
const canDrag =
filters.sortBy === SongListSort.ID && !detailQuery?.data?.rules && !iSClientSide;
filters.sortBy === SongListSort.ID &&
!detailQuery?.data?.rules &&
server?.type !== ServerType.SUBSONIC;
return (
<>
@@ -303,9 +269,6 @@ export const PlaylistDetailSongListContent = ({ songs, tableRef }: PlaylistDetai
status,
}}
getRowId={(data) => data.data.uniqueId}
infiniteInitialRowCount={
iSClientSide ? undefined : checkPlaylistList.data?.totalRecordCount || 100
}
// https://github.com/ag-grid/ag-grid/issues/5284
// Key is used to force remount of table when display, rowHeight, or server changes
key={`table-${page.display}-${page.table.rowHeight}-${server?.id}`}
@@ -326,7 +289,7 @@ export const PlaylistDetailSongListContent = ({ songs, tableRef }: PlaylistDetai
rowData={songs}
rowDragEntireRow={canDrag}
rowHeight={page.table.rowHeight || 40}
rowModelType={iSClientSide ? 'clientSide' : 'infinite'}
rowModelType="clientSide"
shouldUpdateSong
/>
</VirtualGridAutoSizerContainer>
@@ -1,29 +1,26 @@
import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/lib/agGridReact';
import { IDatasource } from '@ag-grid-community/core';
import { closeAllModals, openModal } from '@mantine/modals';
import { useQueryClient } from '@tanstack/react-query';
import debounce from 'lodash/debounce';
import { MouseEvent, MutableRefObject, useCallback } from 'react';
import { ChangeEvent, MouseEvent, MutableRefObject, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { useNavigate, useParams } from 'react-router';
import i18n from '/@/i18n/i18n';
import { api } from '/@/renderer/api';
import { queryKeys } from '/@/renderer/api/query-keys';
import { SONG_TABLE_COLUMNS } from '/@/renderer/components/virtual-table';
import { usePlayQueueAdd } from '/@/renderer/features/player';
import { openUpdatePlaylistModal } from '/@/renderer/features/playlists/components/update-playlist-form';
import { useDeletePlaylist } from '/@/renderer/features/playlists/mutations/delete-playlist-mutation';
import { usePlaylistDetail } from '/@/renderer/features/playlists/queries/playlist-detail-query';
import { OrderToggleButton } from '/@/renderer/features/shared';
import { ListConfigMenu } from '/@/renderer/features/shared/components/list-config-menu';
import { MoreButton } from '/@/renderer/features/shared/components/more-button';
import { SearchInput } from '/@/renderer/features/shared/components/search-input';
import { useContainerQuery } from '/@/renderer/hooks';
import { AppRoute } from '/@/renderer/router/routes';
import {
PersistedTableColumn,
SongListFilter,
useCurrentServer,
usePlaylistDetailStore,
useSetPlaylistDetailFilters,
@@ -40,13 +37,7 @@ import { Icon } from '/@/shared/components/icon/icon';
import { ConfirmModal } from '/@/shared/components/modal/modal';
import { Text } from '/@/shared/components/text/text';
import { toast } from '/@/shared/components/toast/toast';
import {
LibraryItem,
PlaylistSongListQuery,
ServerType,
SongListSort,
SortOrder,
} from '/@/shared/types/domain-types';
import { ServerType, SongListSort, SortOrder } from '/@/shared/types/domain-types';
import { ListDisplayType, Play } from '/@/shared/types/types';
const FILTERS = {
@@ -155,7 +146,7 @@ const FILTERS = {
},
{
defaultOrder: SortOrder.ASC,
name: i18n.t('filter.playCount', { postProcess: 'titleCase' }),
name: i18n.t('filter.genre', { postProcess: 'titleCase' }),
value: SongListSort.GENRE,
},
{
@@ -240,11 +231,6 @@ const FILTERS = {
name: i18n.t('filter.recentlyAdded', { postProcess: 'titleCase' }),
value: SongListSort.RECENTLY_ADDED,
},
{
defaultOrder: SortOrder.DESC,
name: i18n.t('filter.recentlyPlayed', { postProcess: 'titleCase' }),
value: SongListSort.RECENTLY_PLAYED,
},
{
defaultOrder: SortOrder.DESC,
name: i18n.t('filter.releaseYear', { postProcess: 'titleCase' }),
@@ -254,11 +240,13 @@ const FILTERS = {
};
interface PlaylistDetailSongListHeaderFiltersProps {
handlePlay: (playType: Play) => void;
handleToggleShowQueryBuilder: () => void;
tableRef: MutableRefObject<AgGridReactType | null>;
}
export const PlaylistDetailSongListHeaderFilters = ({
handlePlay,
handleToggleShowQueryBuilder,
tableRef,
}: PlaylistDetailSongListHeaderFiltersProps) => {
@@ -270,16 +258,13 @@ export const PlaylistDetailSongListHeaderFilters = ({
const setPage = useSetPlaylistStore();
const setFilter = useSetPlaylistDetailFilters();
const page = usePlaylistDetailStore();
const filters: Partial<PlaylistSongListQuery> = {
sortBy: page?.table.id[playlistId]?.filter?.sortBy || SongListSort.ID,
sortOrder: page?.table.id[playlistId]?.filter?.sortOrder || SortOrder.ASC,
};
const searchTerm = page?.table.id[playlistId]?.filter?.searchTerm;
const sortBy = page?.table.id[playlistId]?.filter?.sortBy || SongListSort.ID;
const sortOrder = page?.table.id[playlistId]?.filter?.sortOrder || SortOrder.ASC;
const detailQuery = usePlaylistDetail({ query: { id: playlistId }, serverId: server?.id });
const isSmartPlaylist = detailQuery.data?.rules;
const handlePlayQueueAdd = usePlayQueueAdd();
const cq = useContainerQuery();
const setPagination = useSetPlaylistTablePagination();
@@ -287,8 +272,7 @@ export const PlaylistDetailSongListHeaderFilters = ({
const sortByLabel =
(server?.type &&
FILTERS[server.type as keyof typeof FILTERS].find((f) => f.value === filters.sortBy)
?.name) ||
FILTERS[server.type as keyof typeof FILTERS].find((f) => f.value === sortBy)?.name) ||
'Unknown';
const handleItemSize = (e: number) => {
@@ -297,93 +281,48 @@ export const PlaylistDetailSongListHeaderFilters = ({
const debouncedHandleItemSize = debounce(handleItemSize, 20);
const handleFilterChange = useCallback(
async (filters: SongListFilter) => {
if (server?.type !== ServerType.SUBSONIC) {
const dataSource: IDatasource = {
getRows: async (params) => {
const limit = params.endRow - params.startRow;
const startIndex = params.startRow;
const handleFilterChange = useCallback(async () => {
tableRef.current?.api.redrawRows();
tableRef.current?.api.ensureIndexVisible(0, 'top');
const queryKey = queryKeys.playlists.songList(
server?.id || '',
playlistId,
{
id: playlistId,
limit,
startIndex,
...filters,
},
);
const songsRes = await queryClient.fetchQuery(
queryKey,
async ({ signal }) =>
api.controller.getPlaylistSongList({
apiClientProps: {
server,
signal,
},
query: {
id: playlistId,
limit,
startIndex,
...filters,
},
}),
{ cacheTime: 1000 * 60 * 1 },
);
params.successCallback(
songsRes?.items || [],
songsRes?.totalRecordCount || 0,
);
},
rowCount: undefined,
};
tableRef.current?.api.setDatasource(dataSource);
tableRef.current?.api.purgeInfiniteCache();
tableRef.current?.api.ensureIndexVisible(0, 'top');
} else {
tableRef.current?.api.redrawRows();
tableRef.current?.api.ensureIndexVisible(0, 'top');
}
if (page.display === ListDisplayType.TABLE_PAGINATED) {
setPagination({ data: { currentPage: 0 } });
}
},
[tableRef, page.display, server, playlistId, queryClient, setPagination],
);
if (page.display === ListDisplayType.TABLE_PAGINATED) {
setPagination({ data: { currentPage: 0 } });
}
}, [tableRef, page.display, setPagination]);
const handleRefresh = () => {
queryClient.invalidateQueries(queryKeys.albums.list(server?.id || ''));
handleFilterChange({ ...page?.table.id[playlistId].filter, ...filters });
queryClient.invalidateQueries(queryKeys.playlists.songList(server?.id || '', playlistId));
handleFilterChange();
};
const handleSetSortBy = useCallback(
(e: MouseEvent<HTMLButtonElement>) => {
if (!e.currentTarget?.value || !server?.type) return;
const sortOrder = FILTERS[server.type as keyof typeof FILTERS].find(
const newSortOrder = FILTERS[server.type as keyof typeof FILTERS].find(
(f) => f.value === e.currentTarget.value,
)?.defaultOrder;
const updatedFilters = setFilter(playlistId, {
setFilter(playlistId, {
sortBy: e.currentTarget.value as SongListSort,
sortOrder: sortOrder || SortOrder.ASC,
sortOrder: newSortOrder || SortOrder.ASC,
});
handleFilterChange(updatedFilters);
handleFilterChange();
},
[handleFilterChange, playlistId, server?.type, setFilter],
);
const handleToggleSortOrder = useCallback(() => {
const newSortOrder = filters.sortOrder === SortOrder.ASC ? SortOrder.DESC : SortOrder.ASC;
const updatedFilters = setFilter(playlistId, { sortOrder: newSortOrder });
handleFilterChange(updatedFilters);
}, [filters.sortOrder, handleFilterChange, playlistId, setFilter]);
const newSortOrder = sortOrder === SortOrder.ASC ? SortOrder.DESC : SortOrder.ASC;
setFilter(playlistId, { sortOrder: newSortOrder });
handleFilterChange();
}, [sortOrder, handleFilterChange, playlistId, setFilter]);
const handleSearch = debounce((e: ChangeEvent<HTMLInputElement>) => {
setFilter(playlistId, { searchTerm: e.target.value });
handleFilterChange();
}, 500);
const handleSetViewType = useCallback(
(displayType: ListDisplayType) => {
@@ -428,13 +367,6 @@ export const PlaylistDetailSongListHeaderFilters = ({
}
};
const handlePlay = async (playType: Play) => {
handlePlayQueueAdd?.({
byItemType: { id: [playlistId], type: LibraryItem.PLAYLIST },
playType,
});
};
const deletePlaylistMutation = useDeletePlaylist({});
const handleDeletePlaylist = useCallback(() => {
@@ -484,7 +416,7 @@ export const PlaylistDetailSongListHeaderFilters = ({
<DropdownMenu.Dropdown>
{FILTERS[server?.type as keyof typeof FILTERS].map((filter) => (
<DropdownMenu.Item
isSelected={filter.value === filters.sortBy}
isSelected={filter.value === sortBy}
key={`filter-${filter.name}`}
onClick={handleSetSortBy}
value={filter.value}
@@ -498,7 +430,7 @@ export const PlaylistDetailSongListHeaderFilters = ({
<Divider orientation="vertical" />
<OrderToggleButton
onToggle={handleToggleSortOrder}
sortOrder={filters.sortOrder || SortOrder.ASC}
sortOrder={sortOrder || SortOrder.ASC}
/>
<DropdownMenu position="bottom-start">
<DropdownMenu.Target>
@@ -560,6 +492,7 @@ export const PlaylistDetailSongListHeaderFilters = ({
)}
</DropdownMenu.Dropdown>
</DropdownMenu>
<SearchInput defaultValue={searchTerm} onChange={handleSearch} />
</Group>
<Group>
<ListConfigMenu
@@ -5,25 +5,27 @@ import { useTranslation } from 'react-i18next';
import { useParams } from 'react-router';
import { PageHeader } from '/@/renderer/components/page-header/page-header';
import { usePlayQueueAdd } from '/@/renderer/features/player';
import { PlaylistDetailSongListHeaderFilters } from '/@/renderer/features/playlists/components/playlist-detail-song-list-header-filters';
import { usePlaylistDetail } from '/@/renderer/features/playlists/queries/playlist-detail-query';
import { FilterBar, LibraryHeaderBar } from '/@/renderer/features/shared';
import { useCurrentServer } from '/@/renderer/store';
import { usePlayButtonBehavior } from '/@/renderer/store/settings.store';
import { formatDurationString } from '/@/renderer/utils';
import { Badge } from '/@/shared/components/badge/badge';
import { SpinnerIcon } from '/@/shared/components/spinner/spinner';
import { Stack } from '/@/shared/components/stack/stack';
import { LibraryItem } from '/@/shared/types/domain-types';
import { Play } from '/@/shared/types/types';
interface PlaylistDetailHeaderProps {
handlePlay: (playType: Play) => void;
handleToggleShowQueryBuilder: () => void;
itemCount?: number;
tableRef: MutableRefObject<AgGridReactType | null>;
}
export const PlaylistDetailSongListHeader = ({
handlePlay,
handleToggleShowQueryBuilder,
itemCount,
tableRef,
@@ -32,19 +34,12 @@ export const PlaylistDetailSongListHeader = ({
const { playlistId } = useParams() as { playlistId: string };
const server = useCurrentServer();
const detailQuery = usePlaylistDetail({ query: { id: playlistId }, serverId: server?.id });
const handlePlayQueueAdd = usePlayQueueAdd();
const handlePlay = async (playType: Play) => {
handlePlayQueueAdd?.({
byItemType: { id: [playlistId], type: LibraryItem.PLAYLIST },
playType,
});
};
const playButtonBehavior = usePlayButtonBehavior();
if (detailQuery.isLoading) return null;
const isSmartPlaylist = detailQuery?.data?.rules;
const playlistDuration = detailQuery?.data?.duration;
return (
<Stack gap={0}>
@@ -52,6 +47,7 @@ export const PlaylistDetailSongListHeader = ({
<LibraryHeaderBar>
<LibraryHeaderBar.PlayButton onClick={() => handlePlay(playButtonBehavior)} />
<LibraryHeaderBar.Title>{detailQuery?.data?.name}</LibraryHeaderBar.Title>
{!!playlistDuration && <Badge>{formatDurationString(playlistDuration)}</Badge>}
<Badge>
{itemCount === null || itemCount === undefined ? (
<SpinnerIcon />
@@ -64,6 +60,7 @@ export const PlaylistDetailSongListHeader = ({
</PageHeader>
<FilterBar>
<PlaylistDetailSongListHeaderFilters
handlePlay={handlePlay}
handleToggleShowQueryBuilder={handleToggleShowQueryBuilder}
tableRef={tableRef}
/>
@@ -29,9 +29,6 @@ export const useAddToPlaylist = (args: MutationHookArgs) => {
queryClient.invalidateQueries(queryKeys.playlists.list(serverId), { exact: false });
queryClient.invalidateQueries(queryKeys.playlists.detail(serverId, variables.query.id));
queryClient.invalidateQueries(
queryKeys.playlists.detailSongList(serverId, variables.query.id),
);
queryClient.invalidateQueries(
queryKeys.playlists.songList(serverId, variables.query.id),
);
@@ -29,7 +29,7 @@ export const useRemoveFromPlaylist = (options?: MutationOptions) => {
queryClient.invalidateQueries(queryKeys.playlists.list(serverId), { exact: false });
queryClient.invalidateQueries(queryKeys.playlists.detail(serverId, variables.query.id));
queryClient.invalidateQueries(
queryKeys.playlists.detailSongList(serverId, variables.query.id),
queryKeys.playlists.songList(serverId, variables.query.id),
);
},
...options,
@@ -20,7 +20,7 @@ export const usePlaylistSongList = (args: QueryHookArgs<PlaylistSongListQuery>)
query,
});
},
queryKey: queryKeys.playlists.songList(server?.id || '', query.id, query),
queryKey: queryKeys.playlists.songList(server?.id || '', query.id),
...options,
});
};
@@ -1,11 +1,13 @@
import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/lib/agGridReact';
import { closeAllModals, openModal } from '@mantine/modals';
import Fuse from 'fuse.js';
import { motion } from 'motion/react';
import { useRef, useState } from 'react';
import { useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { generatePath, useNavigate, useParams } from 'react-router';
import { useHandlePlayQueueAdd } from '/@/renderer/features/player/hooks/use-handle-playqueue-add';
import { PlaylistDetailSongListContent } from '/@/renderer/features/playlists/components/playlist-detail-song-list-content';
import { PlaylistDetailSongListHeader } from '/@/renderer/features/playlists/components/playlist-detail-song-list-header';
import { PlaylistQueryBuilder } from '/@/renderer/features/playlists/components/playlist-query-builder';
@@ -22,12 +24,8 @@ import { Box } from '/@/shared/components/box/box';
import { Group } from '/@/shared/components/group/group';
import { Text } from '/@/shared/components/text/text';
import { toast } from '/@/shared/components/toast/toast';
import {
PlaylistSongListQuery,
ServerType,
SongListSort,
SortOrder,
} from '/@/shared/types/domain-types';
import { ServerType, SongListSort, SortOrder, sortSongList } from '/@/shared/types/domain-types';
import { Play } from '/@/shared/types/types';
const PlaylistDetailSongListRoute = () => {
const { t } = useTranslation();
@@ -35,6 +33,7 @@ const PlaylistDetailSongListRoute = () => {
const tableRef = useRef<AgGridReactType | null>(null);
const { playlistId } = useParams() as { playlistId: string };
const server = useCurrentServer();
const handlePlayQueueAdd = useHandlePlayQueueAdd();
const detailQuery = usePlaylistDetail({ query: { id: playlistId }, serverId: server?.id });
const createPlaylistMutation = useCreatePlaylist({});
@@ -148,26 +147,61 @@ const PlaylistDetailSongListRoute = () => {
};
const page = usePlaylistDetailStore();
const filters: Partial<PlaylistSongListQuery> = {
sortBy: page?.table.id[playlistId]?.filter?.sortBy || SongListSort.ID,
sortOrder: page?.table.id[playlistId]?.filter?.sortOrder || SortOrder.ASC,
};
const itemCountCheck = usePlaylistSongList({
const playlistSongs = usePlaylistSongList({
query: {
id: playlistId,
limit: 1,
startIndex: 0,
...filters,
},
serverId: server?.id,
});
const itemCount = itemCountCheck.data?.totalRecordCount || itemCountCheck.data?.items.length;
const filterSortedSongs = useMemo(() => {
let items = playlistSongs.data?.items;
if (items) {
const searchTerm = page?.table.id[playlistId]?.filter?.searchTerm;
if (searchTerm) {
const fuse = new Fuse(items, {
fieldNormWeight: 1,
ignoreLocation: true,
keys: [
'name',
'album',
{
getFn: (song) => song.artists.map((artist) => artist.name),
name: 'artist',
},
],
threshold: 0,
});
items = fuse.search(searchTerm).map((item) => item.item);
}
const sortBy = page?.table.id[playlistId]?.filter?.sortBy || SongListSort.ID;
const sortOrder = page?.table.id[playlistId]?.filter?.sortOrder || SortOrder.ASC;
return sortSongList(items, sortBy, sortOrder);
} else {
return [];
}
}, [playlistSongs.data?.items, page?.table.id, playlistId]);
const itemCount =
typeof playlistSongs.data?.totalRecordCount === 'number'
? filterSortedSongs.length
: undefined;
const handlePlay = (play: Play) => {
handlePlayQueueAdd?.({
byData: filterSortedSongs,
playType: play,
});
};
return (
<AnimatedPage key={`playlist-detail-songList-${playlistId}`}>
<PlaylistDetailSongListHeader
handlePlay={handlePlay}
handleToggleShowQueryBuilder={handleToggleShowQueryBuilder}
itemCount={itemCount}
tableRef={tableRef}
@@ -203,12 +237,7 @@ const PlaylistDetailSongListRoute = () => {
</Box>
</motion.div>
)}
<PlaylistDetailSongListContent
songs={
server?.type === ServerType.SUBSONIC ? itemCountCheck.data?.items : undefined
}
tableRef={tableRef}
/>
<PlaylistDetailSongListContent songs={filterSortedSongs} tableRef={tableRef} />
</AnimatedPage>
);
};
@@ -3,7 +3,7 @@ import { useFocusTrap } from '@mantine/hooks';
import { closeAllModals } from '@mantine/modals';
import isElectron from 'is-electron';
import { nanoid } from 'nanoid/non-secure';
import { useState } from 'react';
import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { api } from '/@/renderer/api';
@@ -14,21 +14,28 @@ import { useAuthStoreActions } from '/@/renderer/store';
import { Button } from '/@/shared/components/button/button';
import { Checkbox } from '/@/shared/components/checkbox/checkbox';
import { Group } from '/@/shared/components/group/group';
import { Paper } from '/@/shared/components/paper/paper';
import { PasswordInput } from '/@/shared/components/password-input/password-input';
import { SegmentedControl } from '/@/shared/components/segmented-control/segmented-control';
import { Stack } from '/@/shared/components/stack/stack';
import { TextInput } from '/@/shared/components/text-input/text-input';
import { Text } from '/@/shared/components/text/text';
import { toast } from '/@/shared/components/toast/toast';
import { AuthenticationResponse } from '/@/shared/types/domain-types';
import { ServerType, toServerType } from '/@/shared/types/types';
import { AuthenticationResponse, ServerListItem } from '/@/shared/types/domain-types';
import { DiscoveredServerItem, ServerType, toServerType } from '/@/shared/types/types';
const autodiscover = isElectron() ? window.api.autodiscover : null;
const localSettings = isElectron() ? window.api.localSettings : null;
interface AddServerFormProps {
onCancel: (() => void) | null;
}
interface ServerDetails {
icon: string;
name: string;
}
function ServerIconWithLabel({ icon, label }: { icon: string; label: string }) {
return (
<Stack align="center" justify="center">
@@ -38,33 +45,62 @@ function ServerIconWithLabel({ icon, label }: { icon: string; label: string }) {
);
}
const SERVER_TYPES = [
{
label: <ServerIconWithLabel icon={JellyfinIcon} label="Jellyfin" />,
value: ServerType.JELLYFIN,
function useAutodiscovery() {
const [isDone, setDone] = useState(false);
const [servers, setServers] = useState<DiscoveredServerItem[]>([]);
useEffect(() => {
setServers([]);
autodiscover
?.discover((newServer) => {
setServers((tail) => [...tail, newServer]);
})
.then(() => {
setDone(true);
});
}, []);
return { isDone, servers };
}
const SERVER_TYPES: Record<ServerType, ServerDetails> = {
[ServerType.JELLYFIN]: {
icon: JellyfinIcon,
name: 'Jellyfin',
},
{
label: <ServerIconWithLabel icon={NavidromeIcon} label="Navidrome" />,
value: ServerType.NAVIDROME,
[ServerType.NAVIDROME]: {
icon: NavidromeIcon,
name: 'Navidrome',
},
{
label: <ServerIconWithLabel icon={SubsonicIcon} label="OpenSubsonic" />,
value: ServerType.SUBSONIC,
[ServerType.SUBSONIC]: {
icon: SubsonicIcon,
name: 'OpenSubsonic',
},
];
};
const ALL_SERVERS = Object.keys(SERVER_TYPES).map((serverType) => {
const info = SERVER_TYPES[serverType];
return {
label: <ServerIconWithLabel icon={info.icon} label={info.name} />,
value: serverType,
};
});
export const AddServerForm = ({ onCancel }: AddServerFormProps) => {
const { t } = useTranslation();
const focusTrapRef = useFocusTrap(true);
const [isLoading, setIsLoading] = useState(false);
const { addServer, setCurrentServer } = useAuthStoreActions();
const { servers: discovered } = useAutodiscovery();
const form = useForm({
initialValues: {
legacyAuth: false,
name: (localSettings ? localSettings.env.SERVER_NAME : window.SERVER_NAME) ?? '',
password: '',
savePassword: false,
preferInstantMix: undefined,
savePassword: undefined,
type:
(localSettings
? localSettings.env.SERVER_TYPE
@@ -85,6 +121,10 @@ export const AddServerForm = ({ onCancel }: AddServerFormProps) => {
const isSubmitDisabled = !form.values.name || !form.values.url || !form.values.username;
const fillServerDetails = (server: DiscoveredServerItem) => {
form.setValues({ ...server });
};
const handleSubmit = form.onSubmit(async (values) => {
const authFunction = api.controller.authenticate;
@@ -112,17 +152,28 @@ export const AddServerForm = ({ onCancel }: AddServerFormProps) => {
});
}
const serverItem = {
const serverItem: ServerListItem = {
credential: data.credential,
id: nanoid(),
name: values.name,
ndCredential: data.ndCredential,
type: values.type as ServerType,
url: values.url.replace(/\/$/, ''),
userId: data.userId,
username: data.username,
};
if (values.preferInstantMix !== undefined) {
serverItem.preferInstantMix = values.preferInstantMix;
}
if (values.savePassword !== undefined) {
serverItem.savePassword = values.savePassword;
}
if (data.ndCredential !== undefined) {
serverItem.ndCredential = data.ndCredential;
}
addServer(serverItem);
setCurrentServer(serverItem);
closeAllModals();
@@ -151,84 +202,119 @@ export const AddServerForm = ({ onCancel }: AddServerFormProps) => {
});
return (
<form onSubmit={handleSubmit}>
<Stack m={5} ref={focusTrapRef}>
<SegmentedControl
data={SERVER_TYPES}
disabled={Boolean(serverLock)}
p="md"
withItemsBorders={false}
{...form.getInputProps('type')}
/>
<Group grow>
<TextInput
data-autofocus
disabled={Boolean(serverLock)}
label={t('form.addServer.input', {
context: 'name',
postProcess: 'titleCase',
})}
{...form.getInputProps('name')}
/>
<TextInput
disabled={Boolean(serverLock)}
label={t('form.addServer.input', {
context: 'url',
postProcess: 'titleCase',
})}
{...form.getInputProps('url')}
/>
</Group>
<TextInput
label={t('form.addServer.input', {
context: 'username',
postProcess: 'titleCase',
})}
{...form.getInputProps('username')}
/>
<PasswordInput
label={t('form.addServer.input', {
context: 'password',
postProcess: 'titleCase',
})}
{...form.getInputProps('password')}
/>
{localSettings && form.values.type === ServerType.NAVIDROME && (
<Checkbox
label={t('form.addServer.input', {
context: 'savePassword',
postProcess: 'titleCase',
})}
{...form.getInputProps('savePassword', {
type: 'checkbox',
})}
/>
)}
{form.values.type === ServerType.SUBSONIC && (
<Checkbox
label={t('form.addServer.input', {
context: 'legacyAuthentication',
postProcess: 'titleCase',
})}
{...form.getInputProps('legacyAuth', { type: 'checkbox' })}
/>
)}
<Group grow justify="flex-end">
{onCancel && (
<Button onClick={onCancel} variant="subtle">
{t('common.cancel', { postProcess: 'titleCase' })}
</Button>
)}
<Button
disabled={isSubmitDisabled}
loading={isLoading}
type="submit"
variant="filled"
>
{t('common.add', { postProcess: 'titleCase' })}
</Button>
</Group>
<>
<Stack>
{discovered.map((server) => (
<Paper key={server.url} p="10px">
<Group>
<img height="32" src={SERVER_TYPES[server.type].icon} width="32" />
<div
onClick={() => fillServerDetails(server)}
style={{ cursor: 'pointer' }}
>
<Text fw={700}>{server.name}</Text>
<Text>
{SERVER_TYPES[server.type].name} server at {server.url}
</Text>
</div>
</Group>
</Paper>
))}
</Stack>
</form>
<form onSubmit={handleSubmit}>
<Stack m={5} ref={focusTrapRef}>
<SegmentedControl
data={ALL_SERVERS}
disabled={Boolean(serverLock)}
p="md"
withItemsBorders={false}
{...form.getInputProps('type')}
/>
<Group grow>
<TextInput
data-autofocus
disabled={Boolean(serverLock)}
label={t('form.addServer.input', {
context: 'name',
postProcess: 'titleCase',
})}
{...form.getInputProps('name')}
/>
<TextInput
disabled={Boolean(serverLock)}
label={t('form.addServer.input', {
context: 'url',
postProcess: 'titleCase',
})}
{...form.getInputProps('url')}
/>
</Group>
<TextInput
label={t('form.addServer.input', {
context: 'username',
postProcess: 'titleCase',
})}
{...form.getInputProps('username')}
/>
<PasswordInput
label={t('form.addServer.input', {
context: 'password',
postProcess: 'titleCase',
})}
{...form.getInputProps('password')}
/>
{localSettings && form.values.type === ServerType.NAVIDROME && (
<Checkbox
label={t('form.addServer.input', {
context: 'savePassword',
postProcess: 'titleCase',
})}
{...form.getInputProps('savePassword', {
type: 'checkbox',
})}
/>
)}
{form.values.type === ServerType.SUBSONIC && (
<Checkbox
label={t('form.addServer.input', {
context: 'legacyAuthentication',
postProcess: 'titleCase',
})}
{...form.getInputProps('legacyAuth', { type: 'checkbox' })}
/>
)}
{form.values.type === ServerType.JELLYFIN && (
<Checkbox
description={t('form.addServer.input', {
context: 'preferInstantMixDescription',
postProcess: 'sentenceCase',
})}
label={t('form.addServer.input', {
context: 'preferInstantMix',
postProcess: 'titleCase',
})}
{...form.getInputProps('preferInstantMix', {
type: 'checkbox',
})}
/>
)}
<Group grow justify="flex-end">
{onCancel && (
<Button onClick={onCancel} variant="subtle">
{t('common.cancel', { postProcess: 'titleCase' })}
</Button>
)}
<Button
disabled={isSubmitDisabled}
loading={isLoading}
type="submit"
variant="filled"
>
{t('common.add', { postProcess: 'titleCase' })}
</Button>
</Group>
</Stack>
</form>
</>
);
};
@@ -7,7 +7,6 @@ import { useTranslation } from 'react-i18next';
import i18n from '/@/i18n/i18n';
import { api } from '/@/renderer/api';
import { queryKeys } from '/@/renderer/api/query-keys';
import { queryClient } from '/@/renderer/lib/react-query';
import { useAuthStoreActions } from '/@/renderer/store';
import { Button } from '/@/shared/components/button/button';
@@ -49,7 +48,8 @@ export const EditServerForm = ({ isUpdate, onCancel, password, server }: EditSer
legacyAuth: false,
name: server?.name,
password: password || '',
savePassword: server.savePassword || false,
preferInstantMix: server.preferInstantMix,
savePassword: server.savePassword,
type: server?.type,
url: server?.url,
username: server?.username,
@@ -86,17 +86,28 @@ export const EditServerForm = ({ isUpdate, onCancel, password, server }: EditSer
});
}
const serverItem = {
const serverItem: ServerListItem = {
credential: data.credential,
id: server.id,
name: values.name,
ndCredential: data.ndCredential,
savePassword: values.savePassword,
type: values.type,
url: values.url,
userId: data.userId,
username: data.username,
};
if (values.preferInstantMix !== undefined) {
serverItem.preferInstantMix = values.preferInstantMix;
}
if (values.savePassword !== undefined) {
serverItem.savePassword = values.savePassword;
}
if (data.ndCredential !== undefined) {
serverItem.ndCredential = data.ndCredential;
}
updateServer(server.id, serverItem);
toast.success({
message: t('form.updateServer.title', { postProcess: 'sentenceCase' }),
@@ -118,7 +129,7 @@ export const EditServerForm = ({ isUpdate, onCancel, password, server }: EditSer
}
}
queryClient.invalidateQueries({ queryKey: queryKeys.server.root(server.id) });
queryClient.removeQueries();
} catch (err: any) {
setIsLoading(false);
return toast.error({ message: err?.message });
@@ -189,6 +200,21 @@ export const EditServerForm = ({ isUpdate, onCancel, password, server }: EditSer
})}
/>
)}
{form.values.type === ServerType.JELLYFIN && (
<Checkbox
description={t('form.addServer.input', {
context: 'preferInstantMixDescription',
postProcess: 'sentenceCase',
})}
label={t('form.addServer.input', {
context: 'preferInstantMix',
postProcess: 'titleCase',
})}
{...form.getInputProps('preferInstantMix', {
type: 'checkbox',
})}
/>
)}
<Group justify="flex-end">
<Button onClick={onCancel} variant="subtle">
{t('common.cancel', { postProcess: 'titleCase' })}
@@ -1,9 +1,11 @@
import { StylesSettings } from '/@/renderer/features/settings/components/advanced/styles-settings';
import { UpdateSettings } from '/@/renderer/features/settings/components/window/update-settings';
import { Stack } from '/@/shared/components/stack/stack';
export const AdvancedTab = () => {
return (
<Stack gap="md">
<UpdateSettings />
<StylesSettings />
</Stack>
);
@@ -548,9 +548,57 @@ export const ControlSettings = () => {
context: 'description',
postProcess: 'sentenceCase',
}),
isHidden: false,
isHidden: !settings.albumBackground,
title: t('setting.albumBackgroundBlur', { postProcess: 'sentenceCase' }),
},
{
control: (
<Switch
aria-label={t('setting.artistBackground', { postProcess: 'sentenceCase' })}
defaultChecked={settings.artistBackground}
onChange={(e) =>
setSettings({
general: {
...settings,
artistBackground: e.currentTarget.checked,
},
})
}
/>
),
description: t('setting.artistBackground', {
context: 'description',
postProcess: 'sentenceCase',
}),
isHidden: false,
title: t('setting.artistBackground', { postProcess: 'sentenceCase' }),
},
{
control: (
<Slider
defaultValue={settings.artistBackgroundBlur}
label={(e) => `${e} rem`}
max={6}
min={0}
onChangeEnd={(e) => {
setSettings({
general: {
...settings,
artistBackgroundBlur: e,
},
});
}}
step={0.5}
w={100}
/>
),
description: t('setting.artistBackgroundBlur', {
context: 'description',
postProcess: 'sentenceCase',
}),
isHidden: !settings.artistBackground,
title: t('setting.artistBackgroundBlur', { postProcess: 'sentenceCase' }),
},
{
control: (
<Switch
@@ -5,6 +5,7 @@ const HOME_ITEMS: Array<[string, string]> = [
[HomeItem.RANDOM, 'page.home.explore'],
[HomeItem.RECENTLY_PLAYED, 'page.home.recentlyPlayed'],
[HomeItem.RECENTLY_ADDED, 'page.home.newlyAdded'],
[HomeItem.RECENTLY_RELEASED, 'page.home.recentlyReleased'],
[HomeItem.MOST_PLAYED, 'page.home.mostPlayed'],
];
@@ -0,0 +1,45 @@
import isElectron from 'is-electron';
import { useTranslation } from 'react-i18next';
import {
SettingOption,
SettingsSection,
} from '/@/renderer/features/settings/components/settings-section';
import { usePlaybackSettings, useSettingsStoreActions } from '/@/renderer/store/settings.store';
import { Switch } from '/@/shared/components/switch/switch';
const isWindows = window.api.utils.isWindows();
const isDesktop = isElectron();
export const MediaSessionSettings = () => {
const { t } = useTranslation();
const { mediaSession } = usePlaybackSettings();
const { toggleMediaSession } = useSettingsStoreActions();
function handleMediaSessionChange() {
const current = mediaSession;
toggleMediaSession();
window.api.ipc.send('settings-set', { property: 'mediaSession', value: !current });
}
const mediaSessionOptions: SettingOption[] = [
{
control: (
<Switch
aria-label="Toggle media Session"
defaultChecked={mediaSession}
onChange={handleMediaSessionChange}
/>
),
description: t('setting.mediaSession', {
context: 'description',
postProcess: 'sentenceCase',
}),
isHidden: isDesktop && !isWindows,
note: t('common.restartRequired', { postProcess: 'sentenceCase' }),
title: t('setting.mediaSession', { postProcess: 'sentenceCase' }),
},
];
return <SettingsSection divider options={mediaSessionOptions} />;
};
@@ -3,6 +3,7 @@ import { lazy, Suspense, useMemo } from 'react';
import { AudioSettings } from '/@/renderer/features/settings/components/playback/audio-settings';
import { LyricSettings } from '/@/renderer/features/settings/components/playback/lyric-settings';
import { MediaSessionSettings } from '/@/renderer/features/settings/components/playback/media-session-settings';
import { ScrobbleSettings } from '/@/renderer/features/settings/components/playback/scrobble-settings';
import { TranscodeSettings } from '/@/renderer/features/settings/components/playback/transcode-settings';
import { useSettingsStore } from '/@/renderer/store';
@@ -31,6 +32,7 @@ export const PlaybackTab = () => {
<AudioSettings hasFancyAudio={hasFancyAudio} />
<Suspense fallback={<></>}>{hasFancyAudio && <MpvSettings />}</Suspense>
<TranscodeSettings />
<MediaSessionSettings />
<ScrobbleSettings />
<LyricSettings />
</Stack>
@@ -7,6 +7,7 @@ import {
} from '/@/renderer/features/settings/components/settings-section';
import {
DiscordDisplayType,
DiscordLinkType,
useDiscordSettings,
useGeneralSettings,
useSettingsStoreActions,
@@ -162,6 +163,54 @@ export const DiscordSettings = () => {
}),
isHidden: !isElectron(),
title: t('setting.discordDisplayType', {
discord: 'Discord',
musicbrainz: 'musicbrainz',
postProcess: 'sentenceCase',
}),
},
{
control: (
<Select
aria-label={t('setting.discordLinkType')}
clearable={false}
data={[
{
label: t('setting.discordLinkType_none', {
postProcess: 'sentenceCase',
}),
value: DiscordLinkType.NONE,
},
{ label: 'last.fm', value: DiscordLinkType.LAST_FM },
{ label: 'musicbrainz', value: DiscordLinkType.MBZ },
{
label: t('setting.discordLinkType_mbz_lastfm', {
lastfm: 'last.fm',
musicbrainz: 'musicbrainz',
}),
value: DiscordLinkType.MBZ_LAST_FM,
},
]}
defaultValue={settings.linkType}
onChange={(e) => {
if (!e) return;
setSettings({
discord: {
...settings,
linkType: e as DiscordLinkType,
},
});
}}
/>
),
description: t('setting.discordLinkType', {
context: 'description',
discord: 'Discord',
lastfm: 'last.fm',
musicbrainz: 'musicbrainz',
postProcess: 'sentenceCase',
}),
isHidden: !isElectron(),
title: t('setting.discordLinkType', {
discord: 'Discord',
postProcess: 'sentenceCase',
}),
@@ -6,10 +6,10 @@ import {
SettingsSection,
} from '/@/renderer/features/settings/components/settings-section';
import { useSettingsStoreActions, useWindowSettings } from '/@/renderer/store';
import { Select } from '/@/shared/components/select/select';
import { Switch } from '/@/shared/components/switch/switch';
const localSettings = isElectron() ? window.api.localSettings : null;
const utils = isElectron() ? window.api.utils : null;
export const UpdateSettings = () => {
const { t } = useTranslation();
@@ -17,6 +17,47 @@ export const UpdateSettings = () => {
const { setSettings } = useSettingsStoreActions();
const updateOptions: SettingOption[] = [
{
control: (
<Select
data={[
{
label: t('setting.releaseChannel', {
context: 'optionLatest',
postProcess: 'titleCase',
}),
value: 'latest',
},
{
label: t('setting.releaseChannel', {
context: 'optionBeta',
postProcess: 'titleCase',
}),
value: 'beta',
},
]}
defaultValue={
(localSettings?.get('release_channel') as string | undefined) || 'latest'
}
onChange={(value) => {
if (!value) return;
localSettings?.set('release_channel', value);
setSettings({
window: {
...settings,
releaseChannel: value as 'beta' | 'latest',
},
});
}}
/>
),
description: t('setting.releaseChannel', {
context: 'description',
postProcess: 'sentenceCase',
}),
isHidden: !isElectron(),
title: t('setting.releaseChannel', { postProcess: 'sentenceCase' }),
},
{
control: (
<Switch
@@ -44,5 +85,5 @@ export const UpdateSettings = () => {
},
];
return <SettingsSection divider={utils?.isLinux()} options={updateOptions} />;
return <SettingsSection divider={true} options={updateOptions} />;
};
@@ -2,7 +2,6 @@ import isElectron from 'is-electron';
import { DiscordSettings } from '/@/renderer/features/settings/components/window/discord-settings';
import { PasswordSettings } from '/@/renderer/features/settings/components/window/password-settings';
import { UpdateSettings } from '/@/renderer/features/settings/components/window/update-settings';
import { WindowSettings } from '/@/renderer/features/settings/components/window/window-settings';
import { Stack } from '/@/shared/components/stack/stack';
@@ -13,7 +12,6 @@ export const WindowTab = () => {
<Stack gap="md">
<WindowSettings />
<DiscordSettings />
<UpdateSettings />
{utils?.isLinux() && (
<>
<PasswordSettings />
@@ -2,11 +2,20 @@ import debounce from 'lodash/debounce';
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { SelectWithInvalidData } from '/@/renderer/components/select-with-invalid-data';
import {
MultiSelectWithInvalidData,
SelectWithInvalidData,
} from '/@/renderer/components/select-with-invalid-data';
import { useGenreList } from '/@/renderer/features/genres';
import { useTagList } from '/@/renderer/features/tag/queries/use-tag-list';
import { SongListFilter, useListFilterByKey, useListStoreActions } from '/@/renderer/store';
import {
getServerById,
SongListFilter,
useListFilterByKey,
useListStoreActions,
} from '/@/renderer/store';
import { NDSongQueryFields } from '/@/shared/api/navidrome.types';
import { hasFeature } from '/@/shared/api/utils';
import { Divider } from '/@/shared/components/divider/divider';
import { Group } from '/@/shared/components/group/group';
import { NumberInput } from '/@/shared/components/number-input/number-input';
@@ -14,6 +23,7 @@ import { Stack } from '/@/shared/components/stack/stack';
import { Text } from '/@/shared/components/text/text';
import { YesNoSelect } from '/@/shared/components/yes-no-select/yes-no-select';
import { GenreListSort, LibraryItem, SongListQuery, SortOrder } from '/@/shared/types/domain-types';
import { ServerFeature } from '/@/shared/types/features-types';
interface NavidromeSongFiltersProps {
customFilters?: Partial<SongListFilter>;
@@ -31,6 +41,7 @@ export const NavidromeSongFilters = ({
const { t } = useTranslation();
const { setFilter } = useListStoreActions();
const filter = useListFilterByKey<SongListQuery>({ key: pageKey });
const server = getServerById(serverId);
const isGenrePage = customFilters?.genreIds !== undefined;
@@ -58,12 +69,14 @@ export const NavidromeSongFilters = ({
}));
}, [genreListQuery.data]);
const handleGenresFilter = debounce((e: null | string) => {
const hasBrf = hasFeature(server, ServerFeature.BFR);
const handleGenresFilter = debounce((e: null | string[]) => {
const updatedFilters = setFilter({
customFilters,
data: {
_custom: filter._custom,
genreIds: e ? [e] : undefined,
genreIds: e ? e : undefined,
},
itemType: LibraryItem.SONG,
key: pageKey,
@@ -148,18 +161,30 @@ export const NavidromeSongFilters = ({
value={filter._custom?.navidrome?.year}
width={50}
/>
{!isGenrePage && (
{!isGenrePage && !hasBrf && (
<SelectWithInvalidData
clearable
data={genreList}
defaultValue={filter.genreIds ? filter.genreIds[0] : undefined}
label={t('entity.genre', { count: 1, postProcess: 'titleCase' })}
onChange={handleGenresFilter}
onChange={(value) => handleGenresFilter(value !== null ? [value] : null)}
searchable
width={150}
/>
)}
</Group>
{!isGenrePage && hasBrf && (
<Group grow>
<MultiSelectWithInvalidData
clearable
data={genreList}
defaultValue={filter.genreIds}
label={t('entity.genre', { count: 2, postProcess: 'sentenceCase' })}
onChange={handleGenresFilter}
searchable
/>
</Group>
)}
{tagsQuery.data?.enumTags?.length &&
tagsQuery.data.enumTags.length > 0 &&
tagsQuery.data.enumTags.map((tag) => (
+1 -1
View File
@@ -12,7 +12,7 @@
<% } %>
</head>
<body>
<body style="background-color: #000;">
<div id="root">
<script type="module" src="main.tsx"></script>
</div>
+25 -4
View File
@@ -1,7 +1,7 @@
import { HotkeyItem, useHotkeys } from '@mantine/hooks';
import clsx from 'clsx';
import isElectron from 'is-electron';
import { lazy } from 'react';
import { lazy, useMemo } from 'react';
import { useNavigate } from 'react-router';
import styles from './default-layout.module.css';
@@ -11,7 +11,12 @@ import { CommandPalette } from '/@/renderer/features/search/components/command-p
import { MainContent } from '/@/renderer/layouts/default-layout/main-content';
import { PlayerBar } from '/@/renderer/layouts/default-layout/player-bar';
import { AppRoute } from '/@/renderer/router/routes';
import { useCommandPalette } from '/@/renderer/store';
import {
useAppStore,
useCommandPalette,
useCurrentStatus,
useQueueStatus,
} from '/@/renderer/store';
import {
useGeneralSettings,
useHotkeySettings,
@@ -19,7 +24,7 @@ import {
useSettingsStoreActions,
useWindowSettings,
} from '/@/renderer/store/settings.store';
import { Platform, PlaybackType } from '/@/shared/types/types';
import { Platform, PlaybackType, PlayerStatus } from '/@/shared/types/types';
if (!isElectron()) {
useSettingsStore.getState().actions.setSettings({
@@ -48,6 +53,9 @@ export const DefaultLayout = ({ shell }: DefaultLayoutProps) => {
const localSettings = isElectron() ? window.api.localSettings : null;
const settings = useGeneralSettings();
const { setSettings } = useSettingsStoreActions();
const playerStatus = useCurrentStatus();
const { currentSong, index, length } = useQueueStatus();
const { privateMode } = useAppStore();
const updateZoom = (increase: number) => {
const newVal = settings.zoomFactor + increase;
@@ -75,6 +83,19 @@ export const DefaultLayout = ({ shell }: DefaultLayoutProps) => {
...(isElectron() ? zoomHotkeys : []),
]);
const title = useMemo(() => {
const statusString = playerStatus === PlayerStatus.PAUSED ? '(Paused) ' : '';
const queueString = length ? `(${index + 1} / ${length}) ` : '';
const privateModeString = privateMode ? '(Private mode)' : '';
const title = `${
length
? `${statusString}${queueString}${currentSong?.name}${currentSong?.artistName ? `${currentSong?.artistName} — Feishin` : ''}`
: 'Feishin'
}${privateMode ? ` ${privateModeString}` : ''}`;
document.title = title;
return title;
}, [currentSong?.artistName, currentSong?.name, index, length, playerStatus, privateMode]);
return (
<ContextMenuProvider>
<div
@@ -84,7 +105,7 @@ export const DefaultLayout = ({ shell }: DefaultLayoutProps) => {
})}
id="default-layout"
>
{windowBarStyle !== Platform.WEB && <WindowBar />}
{windowBarStyle !== Platform.WEB && <WindowBar title={title} />}
<MainContent shell={shell} />
<PlayerBar />
</div>
@@ -5,6 +5,7 @@
grid-template-areas: 'sidebar . right-sidebar';
grid-template-rows: 1fr;
gap: 0;
background: var(--theme-colors-background);
}
.main-content-container.shell {
+8 -23
View File
@@ -12,14 +12,9 @@ import macMinHover from './assets/min-mac-hover.png';
import macMin from './assets/min-mac.png';
import styles from './window-bar.module.css';
import {
useAppStore,
useCurrentStatus,
useQueueStatus,
useWindowSettings,
} from '/@/renderer/store';
import { useWindowSettings } from '/@/renderer/store';
import { Text } from '/@/shared/components/text/text';
import { Platform, PlayerStatus } from '/@/shared/types/types';
import { Platform } from '/@/shared/types/types';
const localSettings = isElectron() ? window.api.localSettings : null;
@@ -126,26 +121,16 @@ const MacOsControls = ({ controls, title }: WindowBarControlsProps) => {
);
};
export const WindowBar = () => {
const playerStatus = useCurrentStatus();
const { currentSong, index, length } = useQueueStatus();
const { windowBarStyle } = useWindowSettings();
const { privateMode } = useAppStore();
interface WindowBarProps {
title: string;
}
const statusString = playerStatus === PlayerStatus.PAUSED ? '(Paused) ' : '';
const queueString = length ? `(${index + 1} / ${length}) ` : '';
const privateModeString = privateMode ? '(Private mode)' : '';
const title = `${
length
? `${statusString}${queueString}${currentSong?.name}${currentSong?.artistName ? `${currentSong?.artistName}` : ''}`
: 'Feishin'
}${privateMode ? ` ${privateModeString}` : ''}`;
document.title = title;
export const WindowBar = ({ title }: WindowBarProps) => {
const { windowBarStyle } = useWindowSettings();
const handleMinimize = () => minimize();
const [max, setMax] = useState(localSettings?.env.START_MAXIMIZED || false);
const handleMinimize = () => minimize();
const handleMaximize = useCallback(() => {
if (max) {
unmaximize();
+8
View File
@@ -9,6 +9,7 @@ export interface AppSlice extends AppState {
actions: {
setAppStore: (data: Partial<AppSlice>) => void;
setPrivateMode: (enabled: boolean) => void;
setShowTimeRemaining: (enabled: boolean) => void;
setSideBar: (options: Partial<SidebarProps>) => void;
setTitleBar: (options: Partial<TitlebarProps>) => void;
};
@@ -19,6 +20,7 @@ export interface AppState {
isReorderingQueue: boolean;
platform: Platform;
privateMode: boolean;
showTimeRemaining: boolean;
sidebar: SidebarProps;
titlebar: TitlebarProps;
}
@@ -57,6 +59,11 @@ export const useAppStore = createWithEqualityFn<AppSlice>()(
state.privateMode = privateMode;
});
},
setShowTimeRemaining: (showTimeRemaining) => {
set((state) => {
state.showTimeRemaining = showTimeRemaining;
});
},
setSideBar: (options) => {
set((state) => {
state.sidebar = { ...state.sidebar, ...options };
@@ -89,6 +96,7 @@ export const useAppStore = createWithEqualityFn<AppSlice>()(
isReorderingQueue: false,
platform: Platform.WINDOWS,
privateMode: false,
showTimeRemaining: false,
sidebar: {
collapsed: false,
expanded: [],
+24
View File
@@ -96,6 +96,7 @@ export enum HomeItem {
RANDOM = 'random',
RECENTLY_ADDED = 'recentlyAdded',
RECENTLY_PLAYED = 'recentlyPlayed',
RECENTLY_RELEASED = 'recentlyReleased',
}
export type SortableItem<T> = {
@@ -164,6 +165,13 @@ export enum DiscordDisplayType {
SONG_NAME = 'song',
}
export enum DiscordLinkType {
LAST_FM = 'last_fm',
MBZ = 'musicbrainz',
MBZ_LAST_FM = 'musicbrainz_last_fm',
NONE = 'none',
}
export enum GenreTarget {
ALBUM = 'album',
TRACK = 'track',
@@ -194,6 +202,7 @@ export interface SettingsSlice extends SettingsState {
setTable: (type: TableType, data: DataTableProps) => void;
setTranscodingConfig: (config: TranscodingConfig) => void;
toggleContextMenuItem: (item: ContextMenuItemType) => void;
toggleMediaSession: () => void;
toggleSidebarCollapseShare: () => void;
};
}
@@ -207,6 +216,7 @@ export interface SettingsState {
clientId: string;
displayType: DiscordDisplayType;
enabled: boolean;
linkType: DiscordLinkType;
showAsListening: boolean;
showPaused: boolean;
showServerImage: boolean;
@@ -222,6 +232,8 @@ export interface SettingsState {
albumArtRes?: null | number;
albumBackground: boolean;
albumBackgroundBlur: number;
artistBackground: boolean;
artistBackgroundBlur: number;
artistItems: SortableItem<ArtistItem>[];
buttonSize: number;
disabledContextMenu: { [k in ContextMenuItemType]?: boolean };
@@ -287,6 +299,7 @@ export interface SettingsState {
audioDeviceId?: null | string;
crossfadeDuration: number;
crossfadeStyle: CrossfadeStyle;
mediaSession: boolean;
mpvExtraParameters: string[];
mpvProperties: MpvSettings;
muted: boolean;
@@ -322,6 +335,7 @@ export interface SettingsState {
exitToTray: boolean;
minimizeToTray: boolean;
preventSleepOnPlayback: boolean;
releaseChannel: 'beta' | 'latest';
startMinimized: boolean;
tray: boolean;
windowBarStyle: Platform;
@@ -364,6 +378,7 @@ const initialState: SettingsState = {
clientId: '1165957668758900787',
displayType: DiscordDisplayType.FEISHIN,
enabled: false,
linkType: DiscordLinkType.NONE,
showAsListening: false,
showPaused: true,
showServerImage: false,
@@ -379,6 +394,8 @@ const initialState: SettingsState = {
albumArtRes: undefined,
albumBackground: false,
albumBackgroundBlur: 6,
artistBackground: false,
artistBackgroundBlur: 6,
artistItems,
buttonSize: 15,
disabledContextMenu: {},
@@ -476,6 +493,7 @@ const initialState: SettingsState = {
audioDeviceId: undefined,
crossfadeDuration: 5,
crossfadeStyle: CrossfadeStyle.EQUALPOWER,
mediaSession: false,
mpvExtraParameters: [],
mpvProperties: {
audioExclusiveMode: 'no',
@@ -668,6 +686,7 @@ const initialState: SettingsState = {
exitToTray: false,
minimizeToTray: false,
preventSleepOnPlayback: false,
releaseChannel: 'latest',
startMinimized: false,
tray: true,
windowBarStyle: platformDefaultWindowBarStyle,
@@ -736,6 +755,11 @@ export const useSettingsStore = createWithEqualityFn<SettingsSlice>()(
!state.general.disabledContextMenu[item];
});
},
toggleMediaSession: () => {
set((state) => {
state.playback.mediaSession = !state.playback.mediaSession;
});
},
toggleSidebarCollapseShare: () => {
set((state) => {
state.general.sidebarCollapseShared =
+12 -1
View File
@@ -88,7 +88,10 @@ const getSongCoverArtUrl = (args: {
`/${args.item.Id}` +
'/Images/Primary' +
`?width=${size}` +
'&quality=96'
'&quality=96' +
// Invalidate the cache if the image chances. This appears to be
// how Jellyfin Web does it as well
`&tag=${args.item.ImageTags.Primary}`
);
}
@@ -124,12 +127,18 @@ const getPlaylistCoverArtUrl = (args: { baseUrl: string; item: JFPlaylist; size:
type AlbumOrSong = z.infer<typeof jfType._response.album> | z.infer<typeof jfType._response.song>;
const KEYS_TO_OMIT = new Set(['AlbumArtist', 'Artist']);
const getPeople = (item: AlbumOrSong): null | Record<string, RelatedArtist[]> => {
if (item.People) {
const participants: Record<string, RelatedArtist[]> = {};
for (const person of item.People) {
const key = person.Type || '';
if (KEYS_TO_OMIT.has(key)) {
continue;
}
const item: RelatedArtist = {
// for other roles, we just want to display this and not filter.
// filtering (and links) would require a separate field, PersonIds
@@ -258,6 +267,8 @@ const normalizeSong = (
itemType: LibraryItem.SONG,
lastPlayedAt: null,
lyrics: null,
mbzRecordingId: null,
mbzTrackId: item.ProviderIds?.MusicBrainzTrack || null,
name: item.Name,
participants: getPeople(item),
path,
+7 -5
View File
@@ -393,6 +393,12 @@ const participant = z.object({
Type: z.string().optional(),
});
const providerIds = z.object({
MusicBrainzAlbum: z.string().optional(),
MusicBrainzArtist: z.string().optional(),
MusicBrainzTrack: z.string().optional(),
});
const songDetailParameters = baseParameters;
const song = z.object({
@@ -425,6 +431,7 @@ const song = z.object({
PlaylistItemId: z.string().optional(),
PremiereDate: z.string().optional(),
ProductionYear: z.number(),
ProviderIds: providerIds.optional(),
RunTimeTicks: z.number(),
ServerId: z.string(),
SortName: z.string(),
@@ -433,11 +440,6 @@ const song = z.object({
UserData: userData.optional(),
});
const providerIds = z.object({
MusicBrainzAlbum: z.string().optional(),
MusicBrainzArtist: z.string().optional(),
});
const albumArtist = z.object({
AlbumCount: z.number().optional(),
BackdropImageTags: z.array(z.string()),
@@ -30,6 +30,7 @@ const getCoverArtUrl = (args: {
coverArtId: string;
credential: string | undefined;
size: number;
updated: string;
}) => {
const size = args.size ? args.size : 250;
@@ -43,7 +44,10 @@ const getCoverArtUrl = (args: {
`&${args.credential}` +
'&v=1.13.0' +
'&c=Feishin' +
`&size=${size}`
`&size=${size}` +
// A dummy variable to invalidate the cached image if the item is updated
// This is adapted from how Navidrome web does it
`&_=${args.updated}`
);
};
@@ -140,6 +144,7 @@ const normalizeSong = (
coverArtId: id,
credential: server?.credential,
size: imageSize || 100,
updated: item.updatedAt,
});
const imagePlaceholderUrl = null;
@@ -175,6 +180,8 @@ const normalizeSong = (
itemType: LibraryItem.SONG,
lastPlayedAt: normalizePlayDate(item),
lyrics: item.lyrics ? item.lyrics : null,
mbzRecordingId: item.mbzReleaseTrackId || null,
mbzTrackId: item.mbzReleaseTrackId || null,
name: item.title,
// Thankfully, Windows is merciful and allows a mix of separators. So, we can use the
// POSIX separator here instead
@@ -216,6 +223,7 @@ const normalizeAlbum = (
coverArtId: item.coverArtId || item.id,
credential: server?.credential,
size: imageSize || 300,
updated: item.updatedAt,
});
const imagePlaceholderUrl = null;
@@ -282,6 +290,7 @@ const normalizeAlbumArtist = (
coverArtId: `ar-${item.id}`,
credential: server?.credential,
size: 300,
updated: item.updatedAt || '',
});
}
@@ -344,6 +353,7 @@ const normalizePlaylist = (
coverArtId: item.id,
credential: server?.credential,
size: imageSize || 300,
updated: item.updatedAt,
});
const imagePlaceholderUrl = null;
+6 -2
View File
@@ -80,6 +80,7 @@ const stats = z.object({
const albumArtist = z.object({
albumCount: z.number(),
biography: z.string(),
createdAt: z.string().optional(),
externalInfoUpdatedAt: z.string(),
externalUrl: z.string(),
fullText: z.string(),
@@ -99,6 +100,7 @@ const albumArtist = z.object({
starred: z.boolean(),
starredAt: z.string(),
stats: z.record(z.string(), stats).optional(),
updatedAt: z.string().optional(),
});
const albumArtistList = z.array(albumArtist);
@@ -167,7 +169,8 @@ const albumListParameters = paginationParameters.extend({
album_id: z.string().optional(),
artist_id: z.string().optional(),
compilation: z.boolean().optional(),
genre_id: z.string().optional(),
// in older versions, this was a single string. post BFR, you can repeat it multiple times
genre_id: z.union([z.string(), z.string().array()]).optional(),
has_rating: z.boolean().optional(),
id: z.string().optional(),
name: z.string().optional(),
@@ -211,7 +214,7 @@ const song = z.object({
mbzAlbumArtistId: z.string().optional(),
mbzAlbumId: z.string().optional(),
mbzArtistId: z.string().optional(),
mbzTrackId: z.string().optional(),
mbzReleaseTrackId: z.string().optional(),
mediumImageUrl: z.string().optional(),
orderAlbumArtistName: z.string(),
orderAlbumName: z.string(),
@@ -249,6 +252,7 @@ const songListParameters = paginationParameters.extend({
album_artist_id: z.array(z.string()).optional(),
album_id: z.array(z.string()).optional(),
artist_id: z.array(z.string()).optional(),
artists_id: z.array(z.string()).optional(),
genre_id: z.array(z.string()).optional(),
path: z.string().optional(),
starred: z.boolean().optional(),
@@ -160,6 +160,8 @@ const normalizeSong = (
itemType: LibraryItem.SONG,
lastPlayedAt: null,
lyrics: null,
mbzRecordingId: item.musicBrainzId || null,
mbzTrackId: null,
name: item.title,
participants: getParticipants(item),
path: item.path,
+5 -3
View File
@@ -36,7 +36,9 @@ export const hasFeature = (server: null | ServerListItem, feature: ServerFeature
return (server.features[feature]?.length || 0) > 0;
};
export type VersionInfo = ReadonlyArray<[string, Record<string, readonly number[]>]>;
export type VersionInfo = ReadonlyArray<
[string, Partial<Record<ServerFeature, readonly number[]>>]
>;
/**
* Returns the available server features given the version string.
@@ -61,9 +63,9 @@ export type VersionInfo = ReadonlyArray<[string, Record<string, readonly number[
export const getFeatures = (
versionInfo: VersionInfo,
version: string,
): Record<string, number[]> => {
): Partial<Record<ServerFeature, number[]>> => {
const cleanVersion = semverCoerce(version);
const features: Record<string, number[]> = {};
const features: Partial<Record<ServerFeature, number[]>> = {};
let matched = cleanVersion === null;
for (const [version, supportedFeatures] of versionInfo) {
@@ -1,5 +1,4 @@
.root {
bottom: 90px;
background-color: var(--theme-colors-surface);
}
@@ -0,0 +1,14 @@
// Defines the selectors used to identify playback-related elements in the UI.
// Can be used by browser extensions for accessing meta data around currently playing media.
export const PlaybackSelectors = {
elapsedTime: 'elapsed-time',
mediaPlayer: 'media-player',
playerCoverArt: 'player-cover-art',
playerStatePaused: 'player-state-paused',
playerStatePlaying: 'player-state-playing',
songAlbum: 'song-album',
songArtist: 'song-artist',
songTitle: 'song-title',
totalDuration: 'total-duration',
} as const;
+31 -7
View File
@@ -89,6 +89,7 @@ export type ServerListItem = {
id: string;
name: string;
ndCredential?: string;
preferInstantMix?: boolean;
savePassword?: boolean;
type: ServerType;
url: string;
@@ -339,6 +340,8 @@ export type Song = {
itemType: LibraryItem.SONG;
lastPlayedAt: null | string;
lyrics: null | string;
mbzRecordingId: null | string;
mbzTrackId: null | string;
name: string;
participants: null | Record<string, RelatedArtist[]>;
path: null | string;
@@ -988,10 +991,11 @@ export type PlaylistSongListArgs = BaseEndpointArgs & { query: PlaylistSongListQ
export type PlaylistSongListQuery = {
id: string;
limit?: number;
};
export type PlaylistSongListQueryClientSide = {
sortBy?: SongListSort;
sortOrder?: SortOrder;
startIndex: number;
};
// Playlist Songs
@@ -1400,7 +1404,7 @@ export const sortSongList = (songs: QueueSong[], sortBy: SongListSort, sortOrder
case SongListSort.ALBUM_ARTIST:
results = orderBy(
results,
['albumArtist', (v) => v.album?.toLowerCase(), 'discNumber', 'trackNumber'],
[(v) => v.albumArtists[0]?.name.toLowerCase(), 'discNumber', 'trackNumber'],
[order, order, 'asc', 'asc'],
);
break;
@@ -1408,11 +1412,23 @@ export const sortSongList = (songs: QueueSong[], sortBy: SongListSort, sortOrder
case SongListSort.ARTIST:
results = orderBy(
results,
['artist', (v) => v.album?.toLowerCase(), 'discNumber', 'trackNumber'],
[(v) => v.artistName?.toLowerCase(), 'discNumber', 'trackNumber'],
[order, order, 'asc', 'asc'],
);
break;
case SongListSort.BPM:
results = orderBy(results, ['bpm'], [order]);
break;
case SongListSort.CHANNELS:
results = orderBy(results, ['channels'], [order]);
break;
case SongListSort.COMMENT:
results = orderBy(results, ['comment'], [order]);
break;
case SongListSort.DURATION:
results = orderBy(results, ['duration'], [order]);
break;
@@ -1425,7 +1441,7 @@ export const sortSongList = (songs: QueueSong[], sortBy: SongListSort, sortOrder
results = orderBy(
results,
[
(v) => v.genres?.[0].name.toLowerCase(),
(v) => v.genres?.[0]?.name.toLowerCase(),
(v) => v.album?.toLowerCase(),
'discNumber',
'trackNumber',
@@ -1457,13 +1473,21 @@ export const sortSongList = (songs: QueueSong[], sortBy: SongListSort, sortOrder
break;
case SongListSort.RECENTLY_ADDED:
results = orderBy(results, ['created'], [order]);
results = orderBy(results, ['createdAt'], [order]);
break;
case SongListSort.RECENTLY_PLAYED:
results = orderBy(results, ['lastPlayedAt'], [order]);
break;
case SongListSort.RELEASE_DATE:
results = orderBy(results, ['releaseDate'], [order]);
break;
case SongListSort.YEAR:
results = orderBy(
results,
['year', (v) => v.album?.toLowerCase(), 'discNumber', 'track'],
['releaseYear', (v) => v.album?.toLowerCase(), 'discNumber', 'track'],
[order, 'asc', 'asc', 'asc'],
);
break;
+1
View File
@@ -8,6 +8,7 @@ export enum ServerFeature {
PUBLIC_PLAYLIST = 'publicPlaylist',
SHARING_ALBUM_SONG = 'sharingAlbumSong',
TAGS = 'tags',
TRACK_ALBUM_ARTIST_SEARCH = 'trackAlbumArtistSearch',
}
export type ServerFeatures = Partial<Record<ServerFeature, number[]>>;
+6
View File
@@ -166,6 +166,12 @@ export enum TableColumn {
YEAR = 'releaseYear',
}
export type DiscoveredServerItem = {
name: string;
type: ServerType;
url: string;
};
export type GridCardData = {
cardControls: any;
cardRows: CardRow<Album | AlbumArtist | Artist | Playlist | Song>[];
+76
View File
@@ -2,6 +2,7 @@ import react from '@vitejs/plugin-react';
import path from 'path';
import { defineConfig, normalizePath } from 'vite';
import { ViteEjsPlugin } from 'vite-plugin-ejs';
import { VitePWA } from 'vite-plugin-pwa';
export default defineConfig({
base: './',
@@ -10,8 +11,17 @@ export default defineConfig({
outDir: path.resolve(__dirname, './out/web'),
rollupOptions: {
input: {
'32x32': normalizePath(path.resolve(__dirname, './assets/icons/32x32.png')),
'64x64': normalizePath(path.resolve(__dirname, './assets/icons/64x64.png')),
'128x128': normalizePath(path.resolve(__dirname, './assets/icons/128x128.png')),
'256x256': normalizePath(path.resolve(__dirname, './assets/icons/256x256.png')),
'512x512': normalizePath(path.resolve(__dirname, './assets/icons/512x512.png')),
'1024x1024': normalizePath(path.resolve(__dirname, './assets/icons/1024x1024.png')),
favicon: normalizePath(path.resolve(__dirname, './assets/icons/favicon.ico')),
index: normalizePath(path.resolve(__dirname, './src/renderer/index.html')),
preview_full_screen_player: normalizePath(
path.resolve(__dirname, './media/preview_full_screen_player.png'),
),
},
output: {
assetFileNames: 'assets/[name].[ext]',
@@ -39,6 +49,72 @@ export default defineConfig({
root: normalizePath(path.resolve(__dirname, './src/renderer')),
web: true,
}),
VitePWA({
devOptions: {
// The PWA will not be shown during development
enabled: false,
},
filename: 'assets/sw.js',
injectRegister: 'inline',
manifest: {
background_color: '#FFDCB5',
display: 'standalone',
icons: [
{
sizes: '32x32',
src: '32x32.png',
type: 'image/png',
},
{
sizes: '64x64',
src: '64x64.png',
type: 'image/png',
},
{
sizes: '128x128',
src: '128x128.png',
type: 'image/png',
},
{
sizes: '256x256',
src: '256x256.png',
type: 'image/png',
},
{
purpose: 'any',
sizes: '512x512',
src: '512x512.png',
type: 'image/png',
},
{
sizes: '1024x1024',
src: '1024x1024.png',
type: 'image/png',
},
],
name: 'Feishin',
orientation: 'portrait',
screenshots: [
{
form_factor: 'wide',
label: 'Full screen player showing music player and lyrics',
sizes: '1440x900',
src: 'preview_full_screen_player.png',
type: 'image/png',
},
],
short_name: 'Feishin',
start_url: '/',
theme_color: '#1E003D',
},
manifestFilename: 'assets/manifest.webmanifest',
outDir: path.resolve(__dirname, './out/web/'),
registerType: 'autoUpdate',
scope: '/assets/',
workbox: {
maximumFileSizeToCacheInBytes: 1000000 * 5, // 5 MB
},
}),
],
resolve: {
alias: {