Compare commits

..

76 Commits

Author SHA1 Message Date
jeffvli e6b77e5883 adjust top songs / favorite songs sections
- use table row height configuration for container calculation
- add wrapper component and use for both Top Songs and Favorite Songs
- remove the view more button, show all items by default
2026-02-13 20:07:37 -08:00
Kendall Garner d54baae3d9 improve album artist favorite performance and search 2026-02-13 09:29:00 -08:00
Kendall Garner 78875572e9 add explicit blurring to left expanded image and full screen (#1701)
* add explicit blurring to left expanded image and full screen
2026-02-12 18:49:57 -08:00
jeffvli f487560ec5 fix vite web build again for hashed assets and PWA cache clear 2026-02-12 18:21:27 -08:00
jeffvli f752090c78 Revert "attempt fix for web/docker cache busting on new release"
This reverts commit 91e7c7434c.
2026-02-12 18:17:49 -08:00
jeffvli 96f5b2b82a Revert "fix vite web build to work with subpath"
This reverts commit 1a9f36ce9e.
2026-02-12 18:17:44 -08:00
jeffvli 80292ae579 fix alpha autoupdater logic to use correct config for latest 2026-02-12 18:17:08 -08:00
Hosted Weblate 1d156ac506 Translated using Weblate
Currently translated at 100.0% (1154 of 1154 strings) (Czech)
Translation: feishin/Translation
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/cs/

Co-authored-by: Fjuro <fjuro@alius.cz>
2026-02-13 00:09:58 +00:00
jeffvli dc5586f859 adjuat audioDevice description to remove web player disclaimer 2026-02-12 11:18:55 -08:00
jeffvli 1a9f36ce9e fix vite web build to work with subpath 2026-02-12 11:18:55 -08:00
York 203c8a6588 fix: restore original macOS squircle icon and regenerate icns properly (#1703) 2026-02-12 10:03:23 -08:00
Hosted Weblate 2e6cf8d869 Translated using Weblate
Currently translated at 100.0% (1154 of 1154 strings) (Spanish)
Translation: feishin/Translation
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/es/

Co-authored-by: Fordas <fordas15@gmail.com>
2026-02-12 17:09:48 +00:00
Hosted Weblate b1827dd352 Translated using Weblate
Currently translated at 100.0% (1154 of 1154 strings) (Polish)
Translation: feishin/Translation
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/pl/

Translated using Weblate

Currently translated at 100.0% (1154 of 1154 strings) (Chinese (Traditional Han script))
Translation: feishin/Translation
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/zh_Hant/

Co-authored-by: York <goog10216922@gmail.com>
Co-authored-by: skajmer <skajmer@protonmail.com>
2026-02-12 11:09:50 +01:00
jeffvli 0d2dddddbc fix invalid comparison on detail rating column hide condition 2026-02-12 01:40:01 -08:00
jeffvli 1d8e1957ba handle image drag from item detail list 2026-02-12 01:37:59 -08:00
jeffvli dc957cb3cc hide detail rating column on zero value 2026-02-12 00:57:24 -08:00
jeffvli c314fa0bf3 properly handle context menu in playlist album view 2026-02-12 00:54:45 -08:00
jeffvli c5ebfac647 fix playlist grid view itemType to PLAYLIST_SONG to support remove from playlist 2026-02-12 00:28:39 -08:00
jeffvli 4adea11a93 support select all hotkey in detail list view 2026-02-12 00:13:32 -08:00
Jeff e6f49b9f1f Add album view for playlists (#1700)
* update client side song ordering to include album order

* add compact styling to LibraryHeader

* move search button to top right of LibraryHeader
2026-02-11 21:48:25 -08:00
York 9cde569c7d Add option to show playing icon in Discord RPC (#1699)
* feat: add option to show playing/paused icon in Discord RPC
2026-02-11 21:14:58 -08:00
jeffvli 91e7c7434c attempt fix for web/docker cache busting on new release
- remove static asset filenames
- add cache clear config to PWA
- move PWA to base dir instead of assets
2026-02-11 20:52:28 -08:00
Yoshua Wakeham ffef5dfdee fix: actually show Jellyfin recently played songs carousel (#1697) 2026-02-11 20:35:23 -08:00
Kendall Garner 409dd69fcb reduce explicit indicator spacing slightly 2026-02-11 19:34:20 -08:00
Hosted Weblate 064cf5103a Translated using Weblate
Currently translated at 100.0% (1152 of 1152 strings)

Co-authored-by: Ondo <SparkyOndo@proton.me>
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/ca/
Translation: feishin/Translation
2026-02-11 23:09:47 +01:00
Hosted Weblate 7e3a613a93 Translated using Weblate
Currently translated at 100.0% (1152 of 1152 strings)

Translated using Weblate

Currently translated at 100.0% (1152 of 1152 strings)

Translated using Weblate

Currently translated at 99.9% (1151 of 1152 strings)

Co-authored-by: Fjuro <fjuro@alius.cz>
Co-authored-by: Fordas <fordas15@gmail.com>
Co-authored-by: skajmer <skajmer@protonmail.com>
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/cs/
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/es/
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/pl/
Translation: feishin/Translation
2026-02-11 16:09:52 +01:00
Hosted Weblate e7c49f6d67 Translated using Weblate
Currently translated at 36.6% (422 of 1152 strings)

Translated using Weblate

Currently translated at 33.5% (387 of 1152 strings)

Translated using Weblate

Currently translated at 100.0% (1152 of 1152 strings)

Translated using Weblate (Chinese (Simplified Han script))

Currently translated at 96.3% (1101 of 1143 strings)

Translated using Weblate (Ukrainian)

Currently translated at 32.1% (368 of 1143 strings)

Translated using Weblate (French)

Currently translated at 91.3% (1044 of 1143 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (1143 of 1143 strings)

Translated using Weblate (Chinese (Traditional Han script))

Currently translated at 100.0% (1143 of 1143 strings)

Co-authored-by: Fordas <fordas15@gmail.com>
Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Raphael <raphael.margueron@gmail.com>
Co-authored-by: York <goog10216922@gmail.com>
Co-authored-by: Yurii <04_hours.lambing@icloud.com>
Co-authored-by: linger <linger0517@gmail.com>
Co-authored-by: 無情天 <kofzhanganguo@126.com>
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/es/
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/fr/
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/uk/
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/zh_Hans/
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/zh_Hant/
Translation: feishin/Translation
2026-02-11 11:02:22 +01:00
jeffvli 022b83ab32 fix playlist add returning zero results on modal menu (#1695) 2026-02-11 00:35:22 -08:00
jeffvli 551d705ee1 adjust fixed-width columns on the Item Detail list and prevent text wrapping 2026-02-10 21:52:22 -08:00
jeffvli 83f73c7fa9 remove unused enableAnimation from ImageContainer 2026-02-10 21:46:54 -08:00
York cc8cb4f4f1 Add sleep timer to player bar (#1671)
* feat: add sleep timer to player bar

- Add sleep timer button in player bar right controls
- Preset options: End of song, 5/10/15/30/45 min, 1 hr, 2 hrs
- Custom timer with HH:MM:SS input fields
- Timer only counts down while music is playing
- Timer pauses playback when it expires
- End-of-song mode pauses at the next track change
- Uses theme-aware styling (--theme-colors-surface)
- Add sleepTimer/sleepTimerOff icons (LuTimer/LuTimerOff)
- Add i18n strings for sleep timer UI

---------

Co-authored-by: York <york@BonecharMac.local>
Co-authored-by: jeffvli <jeffvictorli@gmail.com>
2026-02-10 21:19:37 -08:00
York 496eab7d09 fix: regenerate macOS icon (.icns) to fix glitched small icons (#1688)
Co-authored-by: York <york@BonecharMac.local>
2026-02-10 21:11:10 -08:00
York 5197c967c2 fix: use theme mode property for macOS native window theme (#1685)
Co-authored-by: York <york@BonecharMac.local>
2026-02-10 21:09:32 -08:00
jeffvli 74b615dba7 include stable version check on alpha update 2026-02-10 20:20:37 -08:00
jeffvli b67ee797cb move arm64 build configuration to electron-builder config (#1689) 2026-02-10 19:25:26 -08:00
Hosted Weblate 4228084810 Update translation files
Updated by "Cleanup translation files" hook in Weblate.

Translated using Weblate (French)

Currently translated at 90.7% (1034 of 1139 strings)

Translated using Weblate (Ukrainian)

Currently translated at 29.0% (331 of 1139 strings)

Translated using Weblate (Danish)

Currently translated at 100.0% (1139 of 1139 strings)

Translated using Weblate (Ukrainian)

Currently translated at 26.5% (302 of 1139 strings)

Translated using Weblate (Ukrainian)

Currently translated at 22.0% (251 of 1139 strings)

Translated using Weblate (Danish)

Currently translated at 90.0% (1026 of 1139 strings)

Translated using Weblate (Danish)

Currently translated at 28.1% (321 of 1139 strings)

Translated using Weblate (Ukrainian)

Currently translated at 9.5% (109 of 1139 strings)

Translated using Weblate (Ukrainian)

Currently translated at 5.7% (66 of 1139 strings)

Translated using Weblate (Czech)

Currently translated at 100.0% (1139 of 1139 strings)

Translated using Weblate (Czech)

Currently translated at 99.7% (1136 of 1139 strings)

Translated using Weblate (Danish)

Currently translated at 4.8% (55 of 1139 strings)

Translated using Weblate (Chinese (Traditional Han script))

Currently translated at 100.0% (1139 of 1139 strings)

Translated using Weblate (Danish)

Currently translated at 0.4% (5 of 1139 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (1139 of 1139 strings)

Translated using Weblate (Polish)

Currently translated at 99.9% (1138 of 1139 strings)

Translated using Weblate (Czech)

Currently translated at 100.0% (1135 of 1135 strings)

Translated using Weblate (Dutch)

Currently translated at 99.8% (1133 of 1135 strings)

Translated using Weblate (Dutch)

Currently translated at 84.4% (959 of 1135 strings)

Translated using Weblate (Polish)

Currently translated at 100.0% (1135 of 1135 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (1135 of 1135 strings)

Translated using Weblate (Chinese (Traditional Han script))

Currently translated at 100.0% (1135 of 1135 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (1131 of 1131 strings)

Translated using Weblate (German)

Currently translated at 78.9% (893 of 1131 strings)

Translated using Weblate (Chinese (Traditional Han script))

Currently translated at 100.0% (1131 of 1131 strings)

Translated using Weblate (Chinese (Traditional Han script))

Currently translated at 92.7% (1047 of 1129 strings)

Translated using Weblate (Polish)

Currently translated at 100.0% (1129 of 1129 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (1129 of 1129 strings)

Translated using Weblate (Japanese)

Currently translated at 85.1% (961 of 1129 strings)

Translated using Weblate (Catalan)

Currently translated at 100.0% (1129 of 1129 strings)

Translated using Weblate (Polish)

Currently translated at 100.0% (1129 of 1129 strings)

Co-authored-by: Alexander Welsing <kontakt@a-wels.de>
Co-authored-by: Denisa Alicia Rissa <denisarissa@gmail.com>
Co-authored-by: Fjuro <fjuro@alius.cz>
Co-authored-by: Fordas <fordas15@gmail.com>
Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: KosmoMoustache <kosmomoustache@users.noreply.hosted.weblate.org>
Co-authored-by: Ondo <SparkyOndo@proton.me>
Co-authored-by: York <goog10216922@gmail.com>
Co-authored-by: Yurii <04_hours.lambing@icloud.com>
Co-authored-by: bokse <weblate@bokse.nl>
Co-authored-by: haha4ni <haha4ni@hotmail.com>
Co-authored-by: karigane <169052233+karigane-cha@users.noreply.github.com>
Co-authored-by: skajmer <skajmer@protonmail.com>
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/ca/
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/cs/
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/da/
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/de/
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/es/
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/fr/
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/ja/
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/nl/
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/pl/
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/uk/
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/zh_Hant/
Translation: feishin/Translation
2026-02-10 09:22:15 +01:00
jeffvli b514c7972d update to v1.5.0 2026-02-10 00:22:00 -08:00
jeffvli 83d9042a47 decouple playlist song sort order from search results (#1650) 2026-02-09 22:28:37 -08:00
jeffvli a28c403093 adjust wording and toggles for analytics/updates (#1654) 2026-02-09 22:19:49 -08:00
jeffvli 2927fa5ff7 set default font to Inter
- prefer to use monospace font due to new item detail columns
2026-02-09 22:00:46 -08:00
Jeff f39a7f8d6f Add album detail list view (#1681) 2026-02-09 21:56:08 -08:00
Kai Gritun 397610d8ab fix: remove duplicate CommandPalette in mobile layout (#1669)
The CommandPalette component was being rendered twice when in mobile view:
1. In ResponsiveLayout via LayoutHotkeys (which handles all layouts)
2. In MobileLayout directly

This caused two overlapping command menus to open when pressing Ctrl+K
in mobile view, with keyboard input going to the background menu.

The fix removes the duplicate CommandPalette from MobileLayout since
LayoutHotkeys already provides it for all layouts (both desktop and mobile).

Fixes #1666

Co-authored-by: Kai Gritun <kai@kaigritun.com>
2026-02-07 19:22:46 -08:00
Ahmed ElSayed fb170bb7c4 Add win-arm64 target (#1665) 2026-02-07 15:39:57 -08:00
Mateleo d93f6e8720 feat: enable scrobbling on song repeat and fix package name typo (#1662)
- Add `handleScrobbleFromRepeat` callback to reset scrobble state and send 'start' event when player repeats a song, ensuring accurate scrobbling in repeat mode.
- Fix typo in `web.vite.config.ts` by correcting '@tanstack_react-query-persist-client' to '@tanstack/react-query-persist-client' for proper package reference.
2026-02-07 15:25:29 -08:00
Martín González Gómez 668de93829 Open settings with shortcut (#1655)
* Open settings with shortcut. Also add settings to menubar.
2026-02-07 15:19:05 -08:00
jeffvli 7cecd859ae add mbzReleaseGroupId to Album type 2026-02-06 05:22:56 -08:00
jeffvli fea2966f62 refactor jellyfin field properties and include ProviderIds 2026-02-06 05:17:40 -08:00
jeffvli 6efa308e85 fix release channel input value 2026-02-06 04:13:53 -08:00
Pyx 82b50a60bc Implement Glassy Dark theme (#1388)
* implement theme

* refactor theme stylesheets to load inline to simplify vite bundling

* add missing css module scope name for web build

---------

Co-authored-by: jeffvli <jeffvictorli@gmail.com>
2026-02-06 02:01:32 -08:00
jeffvli f52c4f7900 fix alpha release notes to compare to development instead of alpha tag 2026-02-06 00:16:11 -08:00
jeffvli 2fb621993d rename electron-builder config to alpha instead of nightly 2026-02-05 23:52:38 -08:00
jeffvli cf663de2fc add handlers and setting for nightly release 2026-02-05 23:45:32 -08:00
jeffvli 65c215fa9c fix R2_ENDPOINT_URL reference 2026-02-05 20:58:15 -08:00
jeffvli 8af972c20b add nightly publish build 2026-02-05 20:48:05 -08:00
jeffvli 027e4046a2 handle radio metadata in discord rpc / fullscreen player (#1649) 2026-02-05 19:14:30 -08:00
jeffvli 4c256348fc add configuration to blur explicit album/song art 2026-02-04 01:20:31 -08:00
jeffvli 6e3275c05c add explicit / clean indicators for album and song titles (#1634) 2026-02-04 00:35:35 -08:00
Jake Klingler 3518a3f3b6 populate bit depth from jellyfin (#1648) 2026-02-04 00:10:28 -08:00
jeffvli 2b6b0cb38b fix artist favorite songs (subsonic) 2026-02-03 23:58:44 -08:00
jeffvli f56a836ffd add personal/community toggle for artist top songs (#1372) 2026-02-03 23:58:44 -08:00
jeffvli 2d963a9d23 use correct filters for album song sort options 2026-02-03 23:58:44 -08:00
rushii 4423b06807 fix(Settings): mpv path selector (#1641)
An unnecessary default value appears to be stringifying a Promise when a separate useEffect hook is supposed to properly load the setting value.
2026-02-03 22:56:09 -08:00
T 1f9223b476 Fix: ratings display on player bar and mobile player (#1646)
* fix(playerbar): use settings to display ratings

* fix(mobile player): use settings to display ratings
2026-02-03 22:50:43 -08:00
Alexander Welsing b4ecf5d257 Update instant mix to use the new items endpoint instead of songs (#1642)
songs/:itemId:/InstantMix -> items/:itemId/InstantMix
2026-02-03 22:34:34 -08:00
jeffvli 0dd13cbab1 add release notes modal to appmenu 2026-02-03 01:06:52 -08:00
jeffvli 48e50430fe add play button group to artist top / favorite song sections 2026-02-02 22:54:10 -08:00
jeffvli ac5611fdca add favorite songs section to artist page (#1604) 2026-02-02 22:23:38 -08:00
jeffvli 50c3dbc0a0 set first item of track radio to the triggering item 2026-02-02 21:18:29 -08:00
jeffvli ddd840d2df fix inconsistent size of musicbrainz icon on album page 2026-02-02 21:14:03 -08:00
jeffvli c0c9878fad add cors / ssl ignore switches to all login components (#1606) 2026-02-02 21:05:01 -08:00
jeffvli c4fc8a8aef fix overlayscrollbars init on loading state 2026-02-02 20:53:11 -08:00
Kendall Garner 0620b096db fix(mpv): only check player time when there is an item in the track (#1639) 2026-02-02 20:49:34 -08:00
Kendall Garner f998491beb fix(playlist): optimistically update rating for playlist song list 2026-02-02 20:48:44 -08:00
Damien Erambert 55a6ea4fca Prevent double fetching when force refreshing paginated views (#1637)
* Prevent double fetching when force refreshing paginated views

* remove await from infinite list loader query invalidation

* add mutation and loading state to list refresh

* add non-suspense query to list genre filters to add loading state

* remove list count data set on random queries

---------

Co-authored-by: jeffvli <jeffvictorli@gmail.com>
2026-02-02 20:25:19 -08:00
Damien Erambert 72fc5beb98 Use a re-usable Intl.Collator instance for locale compare when possible (#1638)
* Use a re-usable Intl.Collator instance for locale compare
2026-02-02 18:28:01 -08:00
jeffvli a45b607fe7 add Noto Sans Hebrew to default font configuration 2026-02-02 18:14:19 -08:00
224 changed files with 10695 additions and 1238 deletions
+189
View File
@@ -0,0 +1,189 @@
# Alpha builds published to Cloudflare R2 with date versioning (e.g. 1.0.0-alpha-20260205).
# Required repo secrets: R2_ACCESS_KEY_ID, R2_SECRET_ACCESS_KEY (from R2 API token in Cloudflare dashboard).
name: Publish Alpha
on:
workflow_dispatch:
inputs:
version:
description: 'Semantic version number (e.g., 1.0.0) - alpha suffix will be added automatically'
required: false
type: string
schedule:
# Run at 3:00 AM PST daily (11:00 UTC; PST = UTC-8)
- cron: '0 11 * * *'
jobs:
check-new-commits:
runs-on: ubuntu-latest
outputs:
has_new_commits: ${{ steps.manual.outputs.has_new_commits || steps.check.outputs['has-new-commits'] }}
steps:
- name: Set has new commits (manual trigger)
id: manual
if: github.event_name == 'workflow_dispatch'
run: echo "has_new_commits=true" >> "$GITHUB_OUTPUT"
- name: Check for new commits (24 hr interval)
id: check
if: github.event_name != 'workflow_dispatch'
uses: adriangl/check-new-commits-action@v1
with:
token: ${{ secrets.GITHUB_TOKEN }}
seconds: 86400
prepare:
needs: check-new-commits
if: needs.check-new-commits.outputs.has_new_commits == 'true'
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: Set date-based alpha version
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 (scheduled run or manual without input), auto-increment patch version
Write-Host "No version provided, auto-incrementing patch version..."
$currentVersion = (Get-Content package.json | ConvertFrom-Json).version
Write-Host "Current version: $currentVersion"
$cleanVersion = $currentVersion -replace '-.*$', ''
$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]
$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
}
}
# Date in YYYYMMDD (PST / America/Los_Angeles)
$pst = [TimeZoneInfo]::FindSystemTimeZoneById('America/Los_Angeles')
$dateInPst = [TimeZoneInfo]::ConvertTimeFromUtc([DateTime]::UtcNow, $pst)
$dateStr = $dateInPst.ToString("yyyyMMdd")
$alphaVersion = "$inputVersion-alpha-$dateStr"
Write-Host "Alpha version: $alphaVersion"
# Update package.json
$packageJson = Get-Content package.json | ConvertFrom-Json
$packageJson.version = $alphaVersion
$packageJson | ConvertTo-Json -Depth 10 | Set-Content package.json
echo "version=$alphaVersion" >> $env:GITHUB_OUTPUT
cleanup:
needs: prepare
runs-on: ubuntu-latest
env:
AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
R2_ENDPOINT_URL: ${{ secrets.R2_ENDPOINT_URL }}
steps:
- name: Delete all objects in R2 bucket
run: |
aws s3 rm s3://feishin-nightly --recursive --endpoint-url $R2_ENDPOINT_URL
publish:
needs: [prepare, cleanup]
runs-on: ${{ matrix.os }}
env:
AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
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: |
$version = "${{ needs.prepare.outputs.version }}"
Write-Host "Setting version: $version"
$packageJson = Get-Content package.json | ConvertFrom-Json
$packageJson.version = $version
$packageJson | ConvertTo-Json -Depth 10 | Set-Content package.json
- name: Build and Publish to R2 (Windows)
if: matrix.os == 'windows-latest'
uses: nick-invision/retry@v2.8.2
with:
timeout_minutes: 30
max_attempts: 3
retry_on: error
command: |
pnpm run publish:win:alpha
on_retry_command: pnpm cache delete
- name: Build and Publish to R2 (macOS)
if: matrix.os == 'macos-latest'
uses: nick-invision/retry@v2.8.2
with:
timeout_minutes: 30
max_attempts: 3
retry_on: error
command: |
pnpm run publish:mac:alpha
on_retry_command: pnpm cache delete
- name: Build and Publish to R2 (Linux)
if: matrix.os == 'ubuntu-latest'
uses: nick-invision/retry@v2.8.2
with:
timeout_minutes: 30
max_attempts: 3
retry_on: error
command: |
pnpm run publish:linux:alpha
on_retry_command: pnpm cache delete
- name: Build and Publish to R2 (Linux ARM64)
if: matrix.os == 'ubuntu-latest'
uses: nick-invision/retry@v2.8.2
with:
timeout_minutes: 30
max_attempts: 3
retry_on: error
command: |
pnpm run publish:linux-arm64:alpha
on_retry_command: pnpm cache delete
+1 -2
View File
@@ -16,6 +16,5 @@ jobs:
- uses: vedantmgoyal9/winget-releaser@main
with:
identifier: jeffvli.Feishin
installers-regex: 'Feishin-*-win-x64\.exe'
installers-regex: 'Feishin-*-win-(x64|arm64)\.exe'
token: ${{ secrets.WINGET_ACC_TOKEN }}
Binary file not shown.
+65
View File
@@ -0,0 +1,65 @@
appId: org.jeffvli.feishin
productName: Feishin
artifactName: ${productName}-${version}-${os}-${arch}.${ext}
electronVersion: 39.4.0
directories:
buildResources: assets
files:
- 'out/**/*'
- 'package.json'
extraResources:
- assets/**
asarUnpack:
- resources/**
win:
target:
- target: zip
arch:
- x64
- arm64
- target: nsis
arch:
- x64
- arm64
icon: assets/icons/icon.ico
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
- deb
- tar.xz
category: AudioVideo;Audio;Player
icon: assets/icons/icon.png
artifactName: ${productName}-${os}-${arch}.${ext}
npmRebuild: false
publish:
provider: s3
bucket: feishin-nightly
channel: alpha
endpoint: https://065f090c64de2dc707dd70ac72db9669.r2.cloudflarestorage.com
+9 -3
View File
@@ -13,9 +13,15 @@ asarUnpack:
- resources/**
win:
target:
- zip
- nsis
icon: assets/icons/icon.png
- target: zip
arch:
- x64
- arm64
- target: nsis
arch:
- x64
- arm64
icon: assets/icons/icon.ico
nsis:
allowToChangeInstallationDirectory: true
+8 -2
View File
@@ -13,8 +13,14 @@ asarUnpack:
- resources/**
win:
target:
- zip
- nsis
- target: zip
arch:
- x64
- arm64
- target: nsis
arch:
- x64
- arm64
icon: assets/icons/icon.ico
nsis:
+9 -1
View File
@@ -1,6 +1,6 @@
{
"name": "feishin",
"version": "1.4.2",
"version": "1.5.0",
"description": "A modern self-hosted music player.",
"keywords": [
"subsonic",
@@ -44,14 +44,22 @@
"package:mac": "pnpm run build && electron-builder --mac",
"package:mac:pr": "pnpm run build && electron-builder --mac --publish never",
"package:win": "pnpm run build && electron-builder --win",
"package:win-arm64:pr": "pnpm run build && electron-builder --win --arm64 --publish never",
"package:win:pr": "pnpm run build && electron-builder --win --publish never",
"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:alpha": "pnpm run build && electron-builder --config electron-builder-alpha.yml --publish always --linux --arm64",
"publish:linux-arm64:beta": "pnpm run build && electron-builder --config electron-builder-beta.yml --publish always --linux --arm64",
"publish:linux:alpha": "pnpm run build && electron-builder --config electron-builder-alpha.yml --publish always --linux",
"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:alpha": "pnpm run build && electron-builder --config electron-builder-alpha.yml --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-arm64": "pnpm run build && electron-builder --publish always --win --arm64",
"publish:win-arm64:alpha": "pnpm run build && electron-builder --config electron-builder-alpha.yml --publish always --win --arm64",
"publish:win-arm64:beta": "pnpm run build && electron-builder --config electron-builder-beta.yml --publish always --win --arm64",
"publish:win:alpha": "pnpm run build && electron-builder --config electron-builder-alpha.yml --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",
+56 -12
View File
@@ -15,7 +15,8 @@
"nowPlaying": "ara sona",
"shared": "$t(entity.playlist, {\"count\": 2}) compartides",
"favorites": "$t(entity.favorite, {\"count\": 2})",
"radio": "$t(entity.radioStation, {\"count\": 2})"
"radio": "$t(entity.radioStation, {\"count\": 2})",
"collections": "col·leccions"
},
"albumArtistDetail": {
"relatedArtists": "$t(entity.artist, {\"count\": 2}) similars",
@@ -28,7 +29,11 @@
"topSongsFrom": "les millors cançons de {{title}}",
"viewAll": "mostra-ho tot",
"groupingTypeAll": "tots els tipus de llançaments",
"groupingTypePrimary": "tipus principals de llançament"
"groupingTypePrimary": "tipus principals de llançament",
"favoriteSongs": "Cançons preferides",
"topSongsCommunity": "comunitat",
"topSongsPersonal": "personal",
"favoriteSongsFrom": "cançons preferides de {{title}}"
},
"albumArtistList": {
"title": "$t(entity.albumArtist, {\"count\": 2})"
@@ -191,6 +196,19 @@
},
"radioList": {
"title": "emissores de ràdio"
},
"windowBar": {
"paused": "(en pausa) ",
"privateMode": "(mode privat)"
},
"collections": {
"overrideExisting": "sobreescriu existents",
"saveAsCollection": "desa com a col·lecció"
},
"releasenotes": {
"commitsSinceStable": "commits des de {{stable}}",
"noNewCommits": "no hi ha hagut commits en aquest període",
"noStableReleaseToCompare": "no hi ha actualitzacions disponibles amb les quals comparar"
}
},
"common": {
@@ -289,8 +307,8 @@
"restartRequired": "cal reiniciar",
"sampleRate": "freqüència de mostreig",
"setting_one": "configuració",
"setting_many": "",
"setting_other": "",
"setting_many": "configuracions",
"setting_other": "configuracions",
"trackGain": "guany de pista",
"trackPeak": "pic de pista",
"gap": "espera",
@@ -316,7 +334,8 @@
"example": "exemple",
"mood": "estat d'ànim",
"filter_single": "senzill",
"filter_multiple": "multi"
"filter_multiple": "multi",
"rename": "reanomena"
},
"entity": {
"album_one": "àlbum",
@@ -540,7 +559,6 @@
"fontType_optionBuiltIn": "tipus de lletra integrats",
"fontType_optionCustom": "tipus de lletra personalitzats",
"fontType_optionSystem": "tipus de lletra del sistema",
"disableAutomaticUpdates": "desactivar les actualitzacions automàtiques",
"disableLibraryUpdateOnStartup": "desactiva la comprovació de noves versions a l'inici",
"homeConfiguration": "configuració de la pàgina d'inici",
"sidebarConfiguration": "configuració de la barra lateral",
@@ -770,7 +788,7 @@
"releaseChannel_optionLatest": "última versió",
"releaseChannel_optionBeta": "beta",
"releaseChannel": "canal de versions",
"releaseChannel_description": "tria entre versions estables i versions beta per les actualitzacions automàtiques",
"releaseChannel_description": "trieu entre versions estables i beta o alfa (diàries) per les actualitzacions automàtiques",
"mediaSession": "activa Media Session",
"mediaSession_description": "activa la integració amb Media Session per mostrar els controls multimèdia i les metadades a l'indicador de volum del sistema i la pantalla de bloqueig",
"crossfadeStyle": "estil de fosa encadenada",
@@ -862,7 +880,21 @@
"homeFeatureStyle_description": "controla l'estil del carrusel de destacats de l'inici",
"homeFeatureStyle": "estil del carrusel de destacats de l'inici",
"homeFeatureStyle_optionMultiple": "múltiple",
"homeFeatureStyle_optionSingle": "simple"
"homeFeatureStyle_optionSingle": "simple",
"enableGridMultiSelect": "activa la selecció múltiple de quadrícula",
"enableGridMultiSelect_description": "quan està activada, podeu seleccionar més d'un element en la vista de quadrícula; si feu clic en la imatge d'un element de la quadrícula, accedireu a la pàgina de l'element",
"sidebarPlaylistSorting_description": "permet ordenar manualment les llistes de reproducció a la barra lateral arrossegant amb el ratolí en comptes de seguir l'ordre predeterminat del servidor",
"sidebarPlaylistSorting": "ordenació de llistes de reproducció de la barra lateral",
"sidebarPlaylistListFilterRegex_description": "amaga les llistes de reproducció de la barra lateral que coincideixin amb aquesta expressió regular",
"sidebarPlaylistListFilterRegex_placeholder": "ex. ^Mescla diària.*",
"sidebarPlaylistListFilterRegex": "regex pel filtre de llistes",
"analyticsEnable": "envia analítiques basades en l'ús",
"analyticsEnable_description": "s'envien dades d'ús anonimitzades al desenvolupar per ajudar a millorar l'aplicació",
"automaticUpdates": "actualitzacions automàtiques",
"automaticUpdates_description": "cerca i instal·la actualitzacions automàticament",
"releaseChannel_optionAlpha": "alfa (diària)",
"blurExplicitImages": "desenfoca imatges explícites",
"blurExplicitImages_description": "les caràtules d'àlbums i cançons marcades com a explícites quedaran desenfocades"
},
"table": {
"column": {
@@ -924,7 +956,8 @@
"alternateRowColors": "colors de fila alternants",
"horizontalBorders": "vores de fila",
"rowHoverHighlight": "ressalta en passar el cursor per la fila",
"verticalBorders": "vores de columna"
"verticalBorders": "vores de columna",
"showHeader": "mostra l'encapçalament"
},
"label": {
"actions": "$t(common.action, {\"count\": 2})",
@@ -966,7 +999,8 @@
"view": {
"table": "taula",
"grid": "quadrícula",
"list": "llista"
"list": "llista",
"detail": "detall"
}
}
},
@@ -1013,7 +1047,8 @@
"lastPlayed": "última reproducció",
"path": "ruta",
"songCount": "nombre de cançons",
"explicitStatus": "$t(common.explicitStatus)"
"explicitStatus": "$t(common.explicitStatus)",
"sortName": "ordena per nom"
},
"player": {
"muted": "silenciat",
@@ -1054,7 +1089,16 @@
"restoreQueueFromServer": "restaura la cua del servidor",
"saveQueueToServer": "desa la cua al servidor",
"artistRadio": "ràdio de l'artista",
"trackRadio": "ràdio de la pista"
"trackRadio": "ràdio de la pista",
"sleepTimer": "temporitzador d'adormir",
"sleepTimer_endOfSong": "final de la cançó actual",
"sleepTimer_minutes": "{{count}} min",
"sleepTimer_hours": "{{count}} h",
"sleepTimer_custom": "personalitzat",
"sleepTimer_off": "apagat",
"sleepTimer_timeRemaining": "queden {{time}}",
"sleepTimer_setCustom": "configura el temporitzador",
"sleepTimer_cancel": "cancel·la el temporitzador"
},
"error": {
"credentialsRequired": "credencials requerides",
+38 -8
View File
@@ -38,7 +38,16 @@
"restoreQueueFromServer": "obnovit frontu ze serveru",
"saveQueueToServer": "uložit frontu na server",
"artistRadio": "rádio umělce",
"trackRadio": "rádio skladby"
"trackRadio": "rádio skladby",
"sleepTimer": "časovač spánku",
"sleepTimer_endOfSong": "konec aktuální skladby",
"sleepTimer_minutes": "{{count}} min.",
"sleepTimer_hours": "{{count}} hod.",
"sleepTimer_custom": "vlastní",
"sleepTimer_off": "vypnuto",
"sleepTimer_timeRemaining": "zbývá {{time}}",
"sleepTimer_setCustom": "nastavit časovač",
"sleepTimer_cancel": "zrušit časovač"
},
"setting": {
"crossfadeStyle_description": "vyberte způsob prolnutí u přehrávače zvuku",
@@ -46,7 +55,7 @@
"hotkey_skipBackward": "přeskočení zpět",
"replayGainMode_description": "úprava zesílení hlasitosti podle hodnot {{ReplayGain}} uložených v metadatech souborů",
"volumeWheelStep_description": "počet procent, o které má být hlasitost posunuta při přejetí kolečkem myši na posuvníku hlasitosti",
"audioDevice_description": "vyberte zvukové zařízení k přehrávání (pouze webový přehrávač)",
"audioDevice_description": "vyberte zvukové zařízení k přehrávání",
"theme_description": "nastavení motivu použitého v aplikaci",
"hotkey_playbackPause": "pozastavení",
"replayGainFallback": "fallback {{ReplayGain}}",
@@ -98,7 +107,6 @@
"hotkey_globalSearch": "globální vyhledávání",
"gaplessAudio_description": "nastavení přehrávače mpv pro přehrávání bez mezer",
"remoteUsername_description": "nastavení uživatelského jména pro server vzdáleného ovládání. pokud je jméno i heslo prázdné, bude autentifikace zakázána",
"disableAutomaticUpdates": "vypnout automatické aktualizace",
"exitToTray_description": "ukončit aplikaci do systémové lišty",
"followLyric_description": "přesouvat texty s aktuální pozicí přehrávání",
"hotkey_favoritePreviousSong": "oblíbit $t(common.previousSong)",
@@ -262,7 +270,7 @@
"neteaseTranslation_description": "Pokud je povoleno, načte a zobrazí přeložené texty ze služby NetEase, pokud jsou dostupné",
"preferLocalLyrics": "preferovat místní texty",
"preferLocalLyrics_description": "preferovat místní texty před vzdálenými, pokud jsou dostupné",
"discordPausedStatus": "zobrazit rich presence při pozastavení",
"discordPausedStatus": "zobrazit stav při pozastavení",
"discordPausedStatus_description": "pokud je povoleno, bude při pozastavení přehrávače zobrazen stav",
"preservePitch": "zachovat výšku",
"preservePitch_description": "zachová výšku při úpravě rychlosti přehrávání",
@@ -284,7 +292,7 @@
"releaseChannel_optionLatest": "nejnovější",
"releaseChannel_optionBeta": "beta",
"releaseChannel": "kanál vydání",
"releaseChannel_description": "vyberte si mezi stabilními vydáními nebo beta vydáními pro automatické aktualizace",
"releaseChannel_description": "vyberte si mezi stabilními, beta nebo alpha (nočními) vydáními pro automatické aktualizace",
"mediaSession": "povolit relaci médií",
"mediaSession_description": "povolí integraci do služby Media Session, což zobrazí ovládání a metadata médií v překrytí systémové hlasitosti a na zamykací obrazovce",
"exportImportSettings_control_description": "exportovat a importovat nastavení pomocí souboru JSON",
@@ -380,7 +388,19 @@
"enableGridMultiSelect": "povolit vícenásobný výběr v mřížce",
"enableGridMultiSelect_description": "pokud je povoleno, umožňuje vybrat několik položek v zobrazení mřížky. pokud je zakázáno, kliknutím na obrázek položky mřížky přejdete na stránku položky",
"sidebarPlaylistSorting_description": "umožňuje ruční řazení seznamů skladeb v postranní liště pomocí přetažení namísto výchozího pořadí serveru",
"sidebarPlaylistSorting": "řazení seznamů skladeb v postranní liště"
"sidebarPlaylistSorting": "řazení seznamů skladeb v postranní liště",
"blurExplicitImages": "rozostřit explicitní obrázky",
"blurExplicitImages_description": "obaly alb a skladeb označené jako explicitní budou rozostřeny",
"sidebarPlaylistListFilterRegex_description": "v postranní liště skrýt seznamy skladeb, které odpovídají tomuto regulárnímu výrazu",
"sidebarPlaylistListFilterRegex_placeholder": "např. ^Denní mix.*",
"sidebarPlaylistListFilterRegex": "regulární výraz filtru seznamů skladeb",
"releaseChannel_optionAlpha": "alpha (noční)",
"analyticsEnable": "Posílat analytiku založenou na využití",
"analyticsEnable_description": "Anonymizovaná data o používání jsou odesílána vývojáři za účelem zlepšení aplikace",
"automaticUpdates": "Automatické aktualizace",
"automaticUpdates_description": "Kontrolovat a automaticky instalovat aktualizace",
"discordStateIcon": "zobrazit ikonu přehrávání",
"discordStateIcon_description": "zobrazit malou ikonu přehrávání ve stavu na Discordu. ikona pozastavení bude zobrazena vždy, když je povolena možnost „Zobrazit stav při pozastavení“"
},
"action": {
"editPlaylist": "upravit $t(entity.playlist, {\"count\": 1})",
@@ -553,7 +573,8 @@
"view": {
"table": "tabulka",
"list": "seznam",
"grid": "mřížka"
"grid": "mřížka",
"detail": "podrobnosti"
},
"general": {
"displayType": "typ zobrazení",
@@ -899,7 +920,11 @@
"viewAllTracks": "zobrazit všechny $t(entity.track, {\"count\": 2})",
"viewAll": "zobrazit vše",
"groupingTypeAll": "všechny typy vydání",
"groupingTypePrimary": "primární typy vydání"
"groupingTypePrimary": "primární typy vydání",
"favoriteSongs": "oblíbené skladby",
"topSongsCommunity": "komunita",
"topSongsPersonal": "osobní",
"favoriteSongsFrom": "oblíbené skladby od umělce {{title}}"
},
"itemDetail": {
"copiedPath": "cesta úspěšně zkopírována",
@@ -933,6 +958,11 @@
"collections": {
"overrideExisting": "nahradit existující",
"saveAsCollection": "uložit jako sbírku"
},
"releasenotes": {
"commitsSinceStable": "revize od {{stable}}",
"noNewCommits": "žádné nové revize v tomto období",
"noStableReleaseToCompare": "není dostupné žádné stabilní vydání k porovnání"
}
},
"form": {
+1299 -2
View File
File diff suppressed because it is too large Load Diff
+22 -6
View File
@@ -33,7 +33,8 @@
"createRadioStation": "$t(entity.radioStation, {\"count\": 1}) erstellen",
"deleteRadioStation": "$t(entity.radioStation, {\"count\": 1}) löschen",
"selectAll": "alle auswählen",
"openApplicationDirectory": "Anwendungsverzeichnis öffnen"
"openApplicationDirectory": "Anwendungsverzeichnis öffnen",
"addOrRemoveFromSelection": "Zur Auswahl hinzufügen oder entfernen"
},
"common": {
"backward": "zurück",
@@ -571,7 +572,8 @@
"shared": "$t(entity.playlist, {\"count\": 2}) geteilt",
"myLibrary": "meine bibliothek",
"favorites": "$t(entity.favorite, {\"count\": 2})",
"radio": "$t(entity.radioStation, {\"count\": 2})"
"radio": "$t(entity.radioStation, {\"count\": 2})",
"collections": "Sammlungen"
},
"setting": {
"playbackTab": "Wiedergabe",
@@ -629,7 +631,9 @@
"topSongs": "Toplieder",
"relatedArtists": "ähnliche $t(entity.artist, {\"count\": 2})",
"groupingTypeAll": "alle Veröffentlichungsformate",
"groupingTypePrimary": "primäre Veröffentlichungsformate"
"groupingTypePrimary": "primäre Veröffentlichungsformate",
"favoriteSongs": "Lieblingssongs",
"favoriteSongsFrom": "Liebslingssongs von {{title}}"
},
"manageServers": {
"title": "Servers verwalten",
@@ -655,6 +659,13 @@
},
"radioList": {
"title": "Radiosender"
},
"windowBar": {
"paused": "(Pausiert) ",
"privateMode": "(Privater Modus)"
},
"collections": {
"saveAsCollection": "Als Sammlung speichern"
}
},
"player": {
@@ -693,7 +704,8 @@
"addNextShuffled": "als Nächstes (zufällige Wiedergabe)",
"holdToShuffle": "Halten für Zufallswiedergabe",
"restoreQueueFromServer": "Wiedergabeliste von Server wiederherstellen",
"saveQueueToServer": "Wiedergabeliste auf Server speichern"
"saveQueueToServer": "Wiedergabeliste auf Server speichern",
"lyrics": "Songtexte"
},
"setting": {
"audioDevice_description": "wählen Sie das Audiogerät aus, das für die Wiedergabe verwendet werden soll (nur Webplayer)",
@@ -709,7 +721,6 @@
"disableLibraryUpdateOnStartup": "beim Start nicht nach neuen Versionen suchen",
"discordApplicationId_description": "die Application-ID für {{discord}} Rich Presence (Standard: {{defaultId}})",
"audioPlayer_description": "Wählen Sie den Audioplayer aus, der für die Wiedergabe verwendet werden soll",
"disableAutomaticUpdates": "Automatische Updates deaktivieren",
"crossfadeDuration_description": "Legt die Dauer der Überblendung fest",
"customFontPath": "Benutzerdefinierter Pfad für Schriftarten",
"crossfadeDuration": "Dauer der Überblendung",
@@ -974,7 +985,12 @@
"translationTargetLanguage_description": "die gewünschte Sprache der Übersetzung",
"translationTargetLanguage": "Zielsprache der Übersetzung",
"queryBuilderCustomFields": "benutzerdefiniertes Feld",
"queryBuilderCustomFields_inputTag": "Tag"
"queryBuilderCustomFields_inputTag": "Tag",
"homeFeatureStyle_optionMultiple": "mehrere",
"imageResolution": "Bildauflösung",
"imageResolution_optionTable": "Tabelle",
"imageResolution_optionSidebar": "Seitenleiste",
"preservePitch": "Tonhöhe erhalten"
},
"dragDropZone": {
"error_oneFileOnly": "Bitte wähle nur 1 Datei",
Regular → Executable
+31 -4
View File
@@ -418,13 +418,17 @@
"albumArtistDetail": {
"about": "About {{artist}}",
"appearsOn": "appears on",
"favoriteSongs": "favorite songs",
"groupingTypeAll": "all release types",
"groupingTypePrimary": "primary release types",
"recentReleases": "recent releases",
"viewDiscography": "view discography",
"relatedArtists": "related $t(entity.artist, {\"count\": 2})",
"topSongs": "top songs",
"topSongsCommunity": "community",
"topSongsFrom": "top songs from {{title}}",
"topSongsPersonal": "personal",
"favoriteSongsFrom": "favorite songs from {{title}}",
"viewAll": "view all",
"viewAllTracks": "view all $t(entity.track, {\"count\": 2})"
},
@@ -444,6 +448,11 @@
"radioList": {
"title": "radio stations"
},
"releasenotes": {
"commitsSinceStable": "commits since {{stable}}",
"noNewCommits": "no new commits in this range",
"noStableReleaseToCompare": "no stable release available to compare with"
},
"favorites": {
"title": "$t(entity.favorite, {\"count\": 2})"
},
@@ -658,7 +667,16 @@
"trackRadio": "track radio",
"unfavorite": "unfavorite",
"pause": "pause",
"viewQueue": "view queue"
"viewQueue": "view queue",
"sleepTimer": "sleep timer",
"sleepTimer_endOfSong": "end of current song",
"sleepTimer_minutes": "{{count}} min",
"sleepTimer_hours": "{{count}} hr",
"sleepTimer_custom": "custom",
"sleepTimer_off": "off",
"sleepTimer_timeRemaining": "{{time}} remaining",
"sleepTimer_setCustom": "set timer",
"sleepTimer_cancel": "cancel timer"
},
"queryBuilder": {
"standardTags": "standard tags",
@@ -704,6 +722,8 @@
"albumBackgroundBlur": "album background image blur size",
"analyticsDisable": "Opt-out of usage based analytics",
"analyticsDisable_description": "Anonymized usage data is sent to the developer to help improve the application",
"analyticsEnable": "Send usage-based analytics",
"analyticsEnable_description": "Anonymized usage data is sent to the developer to help improve the application",
"applicationHotkeys_description": "configure application hotkeys. toggle the checkbox to set as a global hotkey (desktop only)",
"applicationHotkeys": "application hotkeys",
"artistBackground": "artist background image",
@@ -714,7 +734,7 @@
"artistConfiguration_description": "configure what items are shown, and in what order, on the album artist page",
"artistReleaseTypeConfiguration": "artist release type configuration",
"artistReleaseTypeConfiguration_description": "configure what release types are shown, and in what order, on the album artist page",
"audioDevice_description": "select the audio device to use for playback (web player only)",
"audioDevice_description": "select the audio device to use for playback",
"audioDevice": "audio device",
"audioExclusiveMode_description": "enable exclusive output mode. In this mode, the system is usually locked out, and only mpv will be able to output audio",
"audioExclusiveMode": "audio exclusive mode",
@@ -740,11 +760,13 @@
"customCssNotice": "Warning: while there is some sanitization (disallowing url() and content:), using custom css can still pose risks by changing the interface",
"customFontPath_description": "sets the path to the custom font to use for the application",
"customFontPath": "custom font path",
"disableAutomaticUpdates": "disable automatic updates",
"automaticUpdates": "Automatic updates",
"automaticUpdates_description": "Check for and install updates automatically",
"releaseChannel_optionAlpha": "alpha (nightly)",
"releaseChannel_optionBeta": "beta",
"releaseChannel_optionLatest": "latest",
"releaseChannel": "release channel",
"releaseChannel_description": "choose between stable releases or beta releases for automatic updates",
"releaseChannel_description": "choose between stable, beta, or alpha (nightly) releases for automatic updates",
"disableLibraryUpdateOnStartup": "disable checking for new versions on startup",
"discordApplicationId_description": "the application id for {{discord}} rich presence (defaults to {{defaultId}})",
"discordApplicationId": "{{discord}} application id",
@@ -766,6 +788,8 @@
"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. {{discord}} uses a bot to fetch images, so your server must be reachable from the public internet",
"discordStateIcon": "show playing icon",
"discordStateIcon_description": "show a small playing icon in the rich presence status. the paused icon is always shown when \"Show rich presence when paused\" is enabled",
"discordUpdateInterval": "{{discord}} rich presence update interval",
"discordUpdateInterval_description": "the time in seconds between each update (minimum 15 seconds)",
"enableAutoTranslation_description": "enable translation automatically when lyrics are loaded",
@@ -932,6 +956,8 @@
"showLyricsInSidebar": "show lyrics in player sidebar",
"showRatings_description": "controls if the star ratings feature shows up in the interface",
"showRatings": "show star ratings",
"blurExplicitImages": "blur explicit images",
"blurExplicitImages_description": "album and song artwork tagged as explicit will be blurred",
"enableGridMultiSelect": "enable grid multi-select",
"enableGridMultiSelect_description": "when enabled, allows selecting multiple items in grid views. when disabled, clicking grid item images will navigate to the item page",
"showVisualizerInSidebar_description": "a panel will be added to the player sidebar that displays the visualizer",
@@ -1133,6 +1159,7 @@
"year": "$t(common.year)"
},
"view": {
"detail": "detail",
"grid": "grid",
"list": "list",
"table": "table"
+54 -24
View File
@@ -32,13 +32,22 @@
"playSimilarSongs": "Reproducir canciones similares",
"viewQueue": "ver cola",
"addLastShuffled": "Al final (mezclado)",
"addNextShuffled": "Al siguiente (mezclado)",
"addNextShuffled": "Siguiente (mezclado)",
"holdToShuffle": "Mantener para mezclar",
"lyrics": "Letras",
"restoreQueueFromServer": "Restaurar cola del servidor",
"saveQueueToServer": "Guardar cola en el servidor",
"artistRadio": "Radio de artista",
"trackRadio": "Radio de pista"
"trackRadio": "Radio de pista",
"sleepTimer_minutes": "{{count}} min",
"sleepTimer_hours": "{{count}} h",
"sleepTimer_custom": "Personalizado",
"sleepTimer_setCustom": "Configurar temporizador",
"sleepTimer_cancel": "Cancelar temporizador",
"sleepTimer_timeRemaining": "{{time}} restante",
"sleepTimer_off": "Apagado",
"sleepTimer_endOfSong": "Fin de la canción actual",
"sleepTimer": "Temporizador de apagado"
},
"setting": {
"crossfadeStyle_description": "selecciona el estilo de crossfade a usar por el reproductor de audio",
@@ -95,7 +104,6 @@
"hotkey_globalSearch": "búsqueda global",
"gaplessAudio_description": "establece la configuración de audio sin pausas para mpv",
"remoteUsername_description": "establece el nombre de usuario para el control remoto del servidor. si el usuario y la contraseña están vacíos, la autenticación será deshabilitada",
"disableAutomaticUpdates": "desactiva las actualizaciones automáticas",
"exitToTray_description": "sale de la aplicación a la bandeja del sistema",
"followLyric_description": "desplaza la letra a la posición de reproducción actual",
"hotkey_favoritePreviousSong": "$t(common.previousSong) favorita",
@@ -160,7 +168,7 @@
"customFontPath": "ruta de fuente personalizada",
"followLyric": "seguir la letra actual",
"crossfadeDuration": "duración del crossfade",
"discordIdleStatus": "mostrar el estado inactivo en el estado de actividad",
"discordIdleStatus": "mostrar estado inactivo en el estado de actividad",
"sidePlayQueueStyle_optionDetached": "separada",
"audioPlayer": "reproductor de audio",
"hotkey_zoomOut": "reducir",
@@ -283,7 +291,7 @@
"releaseChannel_optionLatest": "Última versión",
"releaseChannel_optionBeta": "Beta",
"releaseChannel": "Canal de lanzamiento",
"releaseChannel_description": "Elige entre lanzamientos estables o beta para las actualizaciones automáticas",
"releaseChannel_description": "Elige entre lanzamientos estables, beta, o alpha (nightly) para las actualizaciones automáticas",
"artistBackground_description": "Añade una imagen de fondo para las páginas de artista que contienen el arte del artista",
"mediaSession": "Activar sesión de medios",
"mediaSession_description": "Activa la integración de la sesión de medios, mostrando los controles de medios y los metadatos en la superposición del volumen del sistema y en la pantalla de bloqueo",
@@ -319,8 +327,8 @@
"playerbarWaveformRadius": "Radio de la forma de onda",
"showLyricsInSidebar_description": "Se añadirá un panel a la cola de reproducción acoplada que muestra las letras",
"showLyricsInSidebar": "Mostrar letras en la barra lateral del reproductor",
"showVisualizerInSidebar_description": "Se añadirá un panel a la barra lateral de reproducción que muestra el visualizador",
"showVisualizerInSidebar": "Mostrar visualizador en la barra lateral de reproducción",
"showVisualizerInSidebar_description": "Se añadirá un panel a la barra lateral del reproductor que muestra el visualizador",
"showVisualizerInSidebar": "Mostrar visualizador en la barra lateral del reproductor",
"queryBuilder": "Generador de consultas",
"queryBuilderCustomFields_inputTag": "Etiqueta",
"queryBuilderCustomFields": "Campos personalizados",
@@ -380,7 +388,19 @@
"enableGridMultiSelect": "Activar selección múltiple de rejilla",
"enableGridMultiSelect_description": "Cuando está activo, permite seleccionar múltiples elementos en las vistas de rejilla. Cuando está desactivado, hacer clic en las imágenes de los elementos de la rejilla navegará a la página del elemento",
"sidebarPlaylistSorting": "Ordenación de la lista de reproducción de la barra lateral",
"sidebarPlaylistSorting_description": "Permite la ordenación manual de la lista de reproducción en la barra lateral usando arrastrar y soltar en lugar del orden predeterminado del servidor"
"sidebarPlaylistSorting_description": "Permite la ordenación manual de la lista de reproducción en la barra lateral usando arrastrar y soltar en lugar del orden predeterminado del servidor",
"sidebarPlaylistListFilterRegex": "Expresión regular de filtrado de listas de reproducción",
"sidebarPlaylistListFilterRegex_description": "Esconde las listas de reproducción en la barra lateral que coincidan con esta expresión regular",
"sidebarPlaylistListFilterRegex_placeholder": "p. ej. ^Mezcla diaria.*",
"blurExplicitImages": "Desenfocar imágenes explícitas",
"blurExplicitImages_description": "El álbum y la carátula de la canción etiquetados como explícitos serán desenfocados",
"releaseChannel_optionAlpha": "Alpha (nightly)",
"analyticsEnable": "Enviar analíticas basadas en el uso",
"analyticsEnable_description": "Se envían datos de uso anonimizados al desarrollador para ayudar a mejorar la aplicación",
"automaticUpdates": "Actualizaciones automáticas",
"automaticUpdates_description": "Busca e instala actualizaciones automáticamente",
"discordStateIcon": "Mostrar icono de reproducción",
"discordStateIcon_description": "Muestra un icono pequeño de reproducción en el estado de actividad. El icono de pausa se muestra siempre cuando \"Mostrar estado de actividad cuando esté en pausa\" esté activado"
},
"action": {
"editPlaylist": "editar $t(entity.playlist, {\"count\": 1})",
@@ -426,7 +446,7 @@
"backward": "hacia atrás",
"increase": "aumentar",
"rating": "calificación",
"bpm": "lpm",
"bpm": "bpm",
"refresh": "actualizar",
"unknown": "desconocido",
"areYouSure": "seguro?",
@@ -438,7 +458,7 @@
"currentSong": "$t(entity.track, {\"count\": 1}) actual",
"collapse": "contraer",
"trackNumber": "pista",
"descending": "descendiente",
"descending": "descendente",
"add": "añadir",
"ascending": "ascendente",
"dismiss": "descartar",
@@ -465,8 +485,8 @@
"cancel": "cancelar",
"forceRestartRequired": "reiniciar para aplicar cambios... cerrar la notificación para reiniciar",
"setting_one": "configuración",
"setting_many": "configuraciones",
"setting_other": "configuraciones",
"setting_many": "configuración",
"setting_other": "configuración",
"version": "versión",
"title": "título",
"filters": "filtros",
@@ -580,10 +600,10 @@
"noNetworkDescription": "No se pudo conectar a este servidor"
},
"filter": {
"mostPlayed": "más reproducido",
"mostPlayed": "más reproducidos",
"isCompilation": "es una compilación",
"recentlyPlayed": "recientemente reproducido",
"isRated": "es clasificado",
"isRated": "Está calificado",
"title": "título",
"rating": "calificación",
"search": "buscar",
@@ -599,7 +619,7 @@
"albumArtist": "$t(entity.albumArtist, {\"count\": 1})",
"isRecentlyPlayed": "reproducido recientemente",
"isFavorited": "es favorito",
"bpm": "lpm",
"bpm": "bpm",
"releaseYear": "año de lanzamiento",
"disc": "disco",
"biography": "biografía",
@@ -618,10 +638,10 @@
"owner": "$t(common.owner)",
"genre": "$t(entity.genre, {\"count\": 1})",
"id": "id",
"songCount": "número de canción",
"songCount": "número de canciones",
"isPublic": "es público",
"album": "$t(entity.album, {\"count\": 1})",
"albumCount": "Contar $t(entity.album, {\"count\": 2})",
"albumCount": "Número de $t(entity.album, {\"count\": 2})",
"explicitStatus": "$t(common.explicitStatus)",
"sortName": "Ordenar por nombre"
},
@@ -792,7 +812,11 @@
"about": "Sobre {{artist}}",
"appearsOn": "Aparece en",
"groupingTypeAll": "Todos los tipos de lanzamiento",
"groupingTypePrimary": "Tipos de lanzamiento principales"
"groupingTypePrimary": "Tipos de lanzamiento principales",
"favoriteSongs": "Canciones favoritas",
"favoriteSongsFrom": "Canciones favoritas de {{title}}",
"topSongsPersonal": "Personal",
"topSongsCommunity": "Comunidad"
},
"itemDetail": {
"copiedPath": "Ruta copiada correctamente",
@@ -826,6 +850,11 @@
"collections": {
"overrideExisting": "Sobreescribir existente",
"saveAsCollection": "Guardar como colección"
},
"releasenotes": {
"commitsSinceStable": "Actualizaciones desde {{stable}}",
"noNewCommits": "Ninguna nueva actualización en este rango",
"noStableReleaseToCompare": "Ningún lanzamiento estable disponible con el que comparar"
}
},
"form": {
@@ -851,8 +880,8 @@
"input_name": "nombre del servidor",
"success": "servidor añadido correctamente",
"input_savePassword": "guardar contraseña",
"ignoreSsl": "ignorar ssl ($t(common.restartRequired))",
"ignoreCors": "ignorar cors ($t(common.restartRequired))",
"ignoreSsl": "Ignorar SSL ($t(common.restartRequired))",
"ignoreCors": "Ignorar CORS ($t(common.restartRequired))",
"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",
@@ -950,7 +979,7 @@
"releaseDate": "fecha de lanzamiento",
"bitrate": "tasa de bits",
"title": "título",
"bpm": "lpm",
"bpm": "bpm",
"dateAdded": "fecha de adición",
"artist": "$t(entity.artist, {\"count\": 1})",
"songCount": "$t(entity.track, {\"count\": 2})",
@@ -1015,8 +1044,8 @@
"followCurrentSong": "seguir la canción actual",
"advancedSettings": "Opciones avanzadas",
"autosize": "Autodimensionar",
"moveUp": "Ascender",
"moveDown": "Descender",
"moveUp": "Subir",
"moveDown": "Bajar",
"pinToLeft": "Anclar a la izquierda",
"pinToRight": "Anclar a la derecha",
"alignLeft": "Alinear a la izquierda",
@@ -1039,7 +1068,8 @@
"view": {
"table": "tabla",
"list": "Lista",
"grid": "Cuadrícula"
"grid": "Cuadrícula",
"detail": "Detalle"
}
}
},
-1
View File
@@ -419,7 +419,6 @@
"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",
-1
View File
@@ -76,7 +76,6 @@
"hotkey_volumeDown": "کم کردن صدا",
"audioPlayer_description": "پخش‌کنندهٔ صدا را برای پخش انتخاب کنید",
"hotkey_globalSearch": "جست و جوی سراسری",
"disableAutomaticUpdates": "غیرفعال کردن به‌‌روزرسانی خودکار",
"exitToTray_description": "خروج از اپلیکیشن به system tray",
"replayGainMode_optionAlbum": "$t(entity.album, {\"count\": 1})",
"discordUpdateInterval_description": "فاصلهٔ بین هر به روزرسانی به ثانیه (حداقل ۱۵ ثانیه)",
-1
View File
@@ -375,7 +375,6 @@
"customCss_description": "mukautettu CSS-sisältö. Huomautus: content- ja etä-URL-osoitteet ovat estettyjä ominaisuuksia. Esikatselu sisällöstäsi on alla. Lisäkenttiä, joita et ole määrittänyt, on näkyvissä puhdistuksen vuoksi",
"customCssNotice": "Varoitus: vaikka jonkinlainen puhdistus onkin tehty (url()- ja content:-komentojen estäminen), mukautetun css:n käyttäminen voi silti aiheuttaa riskejä muuttamalla käyttöliittymää",
"disableLibraryUpdateOnStartup": "poista uusimman version tarkistus käynnistyksen yhteydessä käytöstä",
"disableAutomaticUpdates": "poista automaattiset päivitykset käytöstä",
"discordIdleStatus": "näytä rich presencen käyttämätön tila",
"discordIdleStatus_description": "kun käytössä, päivitä tila kun soitin on käyttämättömänä",
"discordUpdateInterval_description": "päivitysväli sekunnteina (vähintään 15 sekunttia)",
+27 -11
View File
@@ -125,7 +125,7 @@
"forceRestartRequired": "redémarrer pour appliquer les changements… fermer la notification pour redémarrer",
"setting": "paramètre",
"setting_one": "paramètre",
"setting_many": "",
"setting_many": "paramètres",
"setting_other": "paramètres",
"version": "version",
"title": "titre",
@@ -202,7 +202,10 @@
"countSelected": "{{count}} sélectionnée",
"example": "exemple",
"mood": "humeur",
"retry": "réessayer"
"retry": "réessayer",
"filter_single": "unique",
"filter_multiple": "multiple",
"rename": "renommer"
},
"error": {
"remotePortWarning": "redémarrer le serveur pour appliquer le nouveau port",
@@ -270,7 +273,7 @@
"albumArtist": "$t(entity.albumArtist, {\"count\": 1})",
"comment": "commentaire",
"recentlyUpdated": "mis à jour récemment",
"channels": "$t(common.channel_other)",
"channels": "$t(common.channel, {\"count\": 2})",
"owner": "$t(common.owner)",
"genre": "$t(entity.genre, {\"count\": 1})",
"albumCount": "$t(entity.album, {\"count\": 2}) total",
@@ -278,7 +281,8 @@
"artist": "$t(entity.artist, {\"count\": 1})",
"isPublic": "est public",
"album": "$t(entity.album, {\"count\": 1})",
"explicitStatus": "$t(common.explicitStatus)"
"explicitStatus": "$t(common.explicitStatus)",
"sortName": "tri par nom"
},
"page": {
"sidebar": {
@@ -425,7 +429,7 @@
"trackList": {
"title": "$t(entity.track, {\"count\": 2})",
"artistTracks": "pistes par {{artist}}",
"genreTracks": "'{{genre}}' $t(entity.track, {\"count\": 2})"
"genreTracks": "\"{{genre}}\" $t(entity.track, {\"count\": 2})"
},
"playlistList": {
"title": "$t(entity.playlist, {\"count\": 2})"
@@ -445,7 +449,12 @@
"viewDiscography": "voir la discographie",
"relatedArtists": "$t(entity.artist, {\"count\": 2}) similaires",
"topSongs": "meilleurs titres",
"groupingTypeAll": "toutes les types de sortie"
"groupingTypeAll": "toutes les types de sortie",
"favoriteSongs": "titres préférées",
"groupingTypePrimary": "types de parution principale",
"topSongsCommunity": "communauté",
"topSongsPersonal": "personnel",
"favoriteSongsFrom": "meilleurs titres de {{title}}"
},
"itemDetail": {
"copyPath": "copier le chemin dans le presse-papiers",
@@ -471,6 +480,14 @@
},
"radioList": {
"title": "stations radio"
},
"releasenotes": {
"commitsSinceStable": "commits depuis {{stable}}",
"noNewCommits": "pas de nouveaux commits dans cette plage"
},
"windowBar": {
"paused": "(Pause) ",
"privateMode": "(Mode Privé)"
}
},
"setting": {
@@ -487,7 +504,6 @@
"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 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",
@@ -717,7 +733,7 @@
"releaseChannel_optionLatest": "dernière",
"releaseChannel_optionBeta": "bêta",
"releaseChannel": "canal de diffusion",
"releaseChannel_description": "choisissez entre les versions stables ou les versions bêta pour les mises à jour automatiques",
"releaseChannel_description": "choisissez entre les versions stables, bêta, ou alpha (nightly) pour les mises à jour automatiques",
"mediaSession": "activer media session",
"mediaSession_description": "active l'intégration Media Session, affichant les commandes multimédias et les métadonnées dans la superposition du volume du système et l'écran de verrouillage",
"enableAutoTranslation_description": "activer la traduction automatiquement lorsque les paroles sont chargées",
@@ -1022,9 +1038,9 @@
"artist": "$t(entity.artist, {\"count\": 1})",
"album": "$t(entity.album, {\"count\": 1})",
"biography": "$t(common.biography)",
"channels": "$t(common.channel_other)",
"channels": "$t(common.channel, {\"count\": 2})",
"bitrate": "$t(common.bitrate)",
"actions": "$t(common.action_other)",
"actions": "$t(common.action, {\"count\": 2})",
"favorite": "$t(common.favorite)",
"albumArtist": "$t(entity.albumArtist, {\"count\": 1})",
"rating": "$t(common.rating)",
@@ -1066,7 +1082,7 @@
"artist": "$t(entity.artist, {\"count\": 1})",
"genre": "$t(entity.genre, {\"count\": 1})",
"songCount": "$t(entity.track, {\"count\": 2})",
"channels": "$t(common.channel_other)",
"channels": "$t(common.channel, {\"count\": 2})",
"size": "$t(common.size)",
"codec": "$t(common.codec)",
"owner": "propriétaire",
-1
View File
@@ -666,7 +666,6 @@
"customCss": "egyéni css",
"customCssEnable_description": "lehetővé teszi az egyéni css írását",
"customCssEnable": "egyéni css engedélyezése",
"disableAutomaticUpdates": "automatikus frissítés kikapcsolása",
"customFontPath": "egyéni betűtípus elérési út",
"customCss_description": "egyéni css tartalom. Megjegyzés: a tartalom és a távoli URL-ek nem megengedett tulajdonságok. A tartalom előnézete az alábbiakban látható. A tisztítás miatt további mezők is megjelennek, amelyeket te nem állítottál be",
"customCssNotice": "Figyelem: bár van némi tisztítás (az url() és a content: használata nem engedélyezett), az egyéni css használata továbbra is kockázatot jelenthet, mivel megváltoztatja a felületet",
-1
View File
@@ -648,7 +648,6 @@
"customCss_description": "konten CSS kustom. Catatan: properti content dan URL jarak jauh tidak diizinkan. Pratinjau konten Anda ditampilkan di bawah. Kolom tambahan yang tidak Anda atur ada karena sanitasi",
"customFontPath": "jalur font kustom",
"customFontPath_description": "tentukan jalur font kustom yang akan digunakan aplikasi",
"disableAutomaticUpdates": "nonaktifkan pembaruan otomatis",
"discordApplicationId": "ID aplikasi {{discord}}",
"discordApplicationId_description": "id aplikasi untuk rich presence {{discord}} (default: {{defaultId}})",
"discordIdleStatus": "tampilkan status tidak aktif dalam status aktivitas",
-1
View File
@@ -202,7 +202,6 @@
"hotkey_globalSearch": "ricerca globale",
"gaplessAudio_description": "imposta l'audio gapless per mpv",
"remoteUsername_description": "imposta l'username del server di controllo remoto. Se username e password sono vuoti, l'autenticazione sarà disattivata",
"disableAutomaticUpdates": "disabilita aggiornamenti automatici",
"exitToTray_description": "riduce a icona nella barra di sistema all'uscita",
"followLyric_description": "scorre il testo alla posizione di riproduzione corrente",
"hotkey_favoritePreviousSong": "$t(common.previousSong) preferita",
+4 -5
View File
@@ -95,7 +95,6 @@
"hotkey_globalSearch": "グローバル検索",
"gaplessAudio_description": "MPV 向けのギャップレス再生を設定します",
"remoteUsername_description": "リモートコントロール サーバーのユーザ名を設定します。 ユーザー名とパスワードの両方が空の場合、認証は無効になります",
"disableAutomaticUpdates": "自動更新を無効化",
"exitToTray_description": "アプリケーション終了ボタンが押された際、システムトレイに格納します",
"followLyric_description": "現在の再生位置に歌詞をスクロールします",
"hotkey_favoritePreviousSong": "$t(common.previousSong) をお気に入り",
@@ -631,10 +630,10 @@
"genericError": "エラーが発生しました",
"credentialsRequired": "ログイン情報が必要です",
"sessionExpiredError": "セッションの有効期限が切れました",
"remoteEnableError": "リモートサーバーを $t(common.enable) にする際にエラーが発生しました",
"remoteEnableError": "リモートサーバーを$t(common.enable)にする際にエラーが発生しました",
"localFontAccessDenied": "ローカルフォントへのアクセスが拒否されました",
"serverNotSelectedError": "サーバーが選択されていません",
"remoteDisableError": "リモートサーバーを $t(common.disable) にする際にエラーが発生しました",
"remoteDisableError": "リモートサーバーを$t(common.disable)にする際にエラーが発生しました",
"mpvRequired": "MPV が必要です",
"audioDeviceFetchError": "オーディオデバイスの取得時にエラーが発生しました",
"invalidServer": "無効なサーバー",
@@ -923,8 +922,8 @@
"input_name": "サーバー名",
"success": "サーバーが追加されました",
"input_savePassword": "パスワードを保存",
"ignoreSsl": "SSL を無視 ($t(common.restartRequired))",
"ignoreCors": "CORSを無視 ($t(common.restartRequired))",
"ignoreSsl": "SSL を無視します ($t(common.restartRequired))",
"ignoreCors": "CORS を無視します ($t(common.restartRequired))",
"error_savePassword": "パスワードを保存する際にエラーが発生しました",
"input_preferInstantMixDescription": "類似曲を取得するにはインスタントミックスのみを使用してください。この動作を変更するプラグインがある場合に役立ちます",
"input_preferInstantMix": "インスタントミックスを優先する",
+348 -17
View File
@@ -119,7 +119,7 @@
"size": "grootte",
"reload": "herlaad",
"setting_one": "instelling",
"setting_other": "",
"setting_other": "instellingen",
"close": "sluiten",
"additionalParticipants": "andere deelnemers",
"newVersion": "een nieuwe versie is geïnstalleerd ({{version}})",
@@ -157,7 +157,9 @@
"example": "voorbeeld",
"mood": "stemming",
"retry": "opnieuw proberen",
"filter_single": "single"
"filter_single": "single",
"rename": "hernoemen",
"filter_multiple": "meerdere"
},
"filter": {
"rating": "rating",
@@ -202,7 +204,8 @@
"songCount": "aantal nummers",
"toYear": "tot jaar",
"trackNumber": "track",
"explicitStatus": "$t(common.explicitStatus)"
"explicitStatus": "$t(common.explicitStatus)",
"sortName": "sorteernaam"
},
"page": {
"contextMenu": {
@@ -238,8 +241,8 @@
"version": "versie {{version}}",
"settings": "$t(common.setting, {\"count\": 2})",
"manageServers": "beheer servers",
"expandSidebar": "sidebar uitklappen",
"collapseSidebar": "sidebar inklappen",
"expandSidebar": "zijbalk uitklappen",
"collapseSidebar": "zijbalk inklappen",
"openBrowserDevtools": "open browser devtools",
"quit": "$t(common.quit)",
"goBack": "terug",
@@ -298,7 +301,11 @@
"viewAllTracks": "bekijk alle $t(entity.track, {\"count\": 2})",
"recentReleases": "recente uitgaven",
"groupingTypeAll": "alle soorten publicaties",
"groupingTypePrimary": "primaire publicatiesoorten"
"groupingTypePrimary": "primaire publicatiesoorten",
"favoriteSongs": "favoriete nummers",
"topSongsCommunity": "community",
"topSongsPersonal": "persoonlijk",
"favoriteSongsFrom": "favoriete nummers van {{title}}"
},
"manageServers": {
"title": "beheer servers",
@@ -384,7 +391,8 @@
"settings": "$t(common.setting, {\"count\": 2})",
"shared": "$t(entity.playlist, {\"count\": 2}) gedeeld",
"tracks": "$t(entity.track, {\"count\": 2})",
"radio": "$t(entity.radioStation, {\"count\": 2})"
"radio": "$t(entity.radioStation, {\"count\": 2})",
"collections": "verzamelingen"
},
"trackList": {
"artistTracks": "nummers van {{artist}}",
@@ -396,6 +404,14 @@
},
"folderList": {
"title": "$t(entity.folder, {\"count\": 2})"
},
"windowBar": {
"paused": "(Gepauzeerd) ",
"privateMode": "(Privémodus)"
},
"collections": {
"overrideExisting": "bestaande overschrijven",
"saveAsCollection": "sla op als verzameling"
}
},
"error": {
@@ -440,12 +456,12 @@
"artist_other": "artiesten",
"folderWithCount_one": "{{count}} folder",
"folderWithCount_other": "{{count}} folders",
"albumArtist_one": "album artiest",
"albumArtist_other": "album artiesten",
"albumArtist_one": "albumartiest",
"albumArtist_other": "albumartiesten",
"track_one": "track",
"track_other": "tracks",
"albumArtistCount_one": "{{count}} album artiest",
"albumArtistCount_other": "{{count}} album artiesten",
"albumArtistCount_one": "{{count}} albumartiest",
"albumArtistCount_other": "{{count}} albumartiesten",
"albumWithCount_one": "{{count}} album",
"albumWithCount_other": "{{count}} albums",
"favorite_one": "favoriet",
@@ -473,11 +489,107 @@
"table": {
"column": {
"rating": "rating",
"size": "$t(common.size)"
"size": "$t(common.size)",
"albumArtist": "albumartiest",
"biography": "biografie",
"bitrate": "bitsnelheid",
"comment": "opmerking",
"dateAdded": "datum toegevoegd",
"favorite": "favoriet",
"discNumber": "disc",
"bpm": "bpm",
"album": "album",
"lastPlayed": "laatst gespeeld",
"path": "pad",
"playCount": "keren gespeeld",
"releaseDate": "uitgavedatum",
"releaseYear": "jaar",
"title": "titel",
"trackNumber": "nummer",
"owner": "eigenaar",
"albumCount": "$t(entity.album, {\"count\": 2})",
"artist": "$t(entity.artist, {\"count\": 1})",
"bitDepth": "$t(common.bitDepth)",
"channels": "$t(common.channel, {\"count\": 2})",
"codec": "$t(common.codec)",
"genre": "$t(entity.genre, {\"count\": 1})",
"sampleRate": "$t(common.sampleRate)",
"songCount": "$t(entity.track, {\"count\": 2})"
},
"config": {
"label": {
"rating": "$t(common.rating)"
"rating": "$t(common.rating)",
"composer": "componist",
"dateAdded": "datum toegevoegd",
"discNumber": "discnummer",
"image": "afbeelding",
"lastPlayed": "laatst gespeeld",
"playCount": "keren afgespeeld",
"releaseDate": "uitgavedatum",
"rowIndex": "rij-index",
"trackNumber": "nummer",
"actions": "$t(common.action, {\"count\": 2})",
"album": "$t(entity.album, {\"count\": 1})",
"albumCount": "$t(entity.album, {\"count\": 2})",
"albumArtist": "$t(entity.albumArtist, {\"count\": 1})",
"artist": "$t(entity.artist, {\"count\": 1})",
"biography": "$t(common.biography)",
"bitDepth": "$t(common.bitDepth)",
"bitrate": "$t(common.bitrate)",
"bpm": "$t(common.bpm)",
"channels": "$t(common.channel, {\"count\": 2})",
"codec": "$t(common.codec)",
"duration": "$t(common.duration)",
"favorite": "$t(common.favorite)",
"genre": "$t(entity.genre, {\"count\": 1})",
"genreBadge": "$t(entity.genre, {\"count\": 1}) (badges)",
"note": "$t(common.note)",
"owner": "$t(common.owner)",
"path": "$t(common.path)",
"sampleRate": "$t(common.sampleRate)",
"size": "$t(common.size)",
"songCount": "$t(entity.track, {\"count\": 2})",
"title": "$t(common.title)",
"titleArtist": "$t(common.title) (artiest)",
"titleCombined": "$t(common.title) (gecombineerd)",
"year": "$t(common.year)"
},
"general": {
"advancedSettings": "geavanceerde instellingen",
"autoFitColumns": "kolommen automatisch passend maken",
"autosize": "automatische afmetingen",
"moveUp": "omhoog verplaatsen",
"moveDown": "omlaag verplaatsen",
"pinToLeft": "links vastpinnen",
"pinToRight": "rechts vastpinnen",
"alignLeft": "links uitlijnen",
"alignCenter": "centreren",
"alignRight": "rechts uitlijnen",
"followCurrentSong": "huidige nummer volgen",
"displayType": "weergavesoort",
"itemGap": "ruimte tussen items (px)",
"itemSize": "grootte item (px)",
"itemsPerRow": "items per rij",
"size_default": "standaard",
"size_compact": "compact",
"size_large": "groot",
"tableColumns": "kolommen",
"pagination": "paginering",
"pagination_itemsPerPage": "items per pagina",
"pagination_infinite": "oneindig",
"pagination_paginate": "gepagineerd",
"alternateRowColors": "afwisselende rijkleuren",
"horizontalBorders": "randen om rijen",
"rowHoverHighlight": "oplichtende rijen bij zweven met de muis",
"showHeader": "toon kop",
"verticalBorders": "randen om kolommen",
"gap": "$t(common.gap)",
"size": "$t(common.size)"
},
"view": {
"grid": "grid",
"list": "lijst",
"table": "tabel"
}
}
},
@@ -503,8 +615,8 @@
"exportImportSettings_importBtn": "importeer instellingen",
"exportImportSettings_importModalTitle": "importeer feishing-instellingen",
"exportImportSettings_importSuccess": "instellingen zijn succesvol geïmporteerd!",
"exportImportSettings_notValidJSON": "het ingevoerde bestand is geen valide JSON",
"exportImportSettings_offendingKeyError": "\"{{offendingKey}}\" is incorrect - {{reason}}",
"exportImportSettings_notValidJSON": "het ingevoerde bestand is geen geldige JSON",
"exportImportSettings_offendingKeyError": "\"{{offendingKey}}\" is onjuist - {{reason}}",
"externalLinks_description": "maakt het mogelijk om externe links (Last.fm, MusicBrainz) te tonen op artiesten-/albumpagina's",
"externalLinks": "toon externe links",
"followLyric_description": "scroll de songtekst naar de huidige positie",
@@ -573,7 +685,6 @@
"customCssNotice": "Waarschuwing: ondanks sanering (het niet toestaan van url() en content:) brengt aangepaste css nog steeds risico's met zich mee omdat de interface wordt gewijzigd",
"customFontPath_description": "bepaal het pad naar het aangepaste lettertype voor gebruik in de applicatie",
"customFontPath": "aangepaste lettertypelocatie",
"disableAutomaticUpdates": "automatische updates uitschakelen",
"releaseChannel_optionBeta": "beta",
"releaseChannel_optionLatest": "meest recente",
"releaseChannel": "releasekanaal",
@@ -760,7 +871,73 @@
"sidebarPlaylistList": "afspeellijsten zijbalk",
"sidePlayQueueStyle_description": "de stijl van de wachtrij aan de zijkant",
"sidePlayQueueStyle_optionAttached": "aangekoppeld",
"sidePlayQueueStyle_optionDetached": "afgekoppeld"
"sidePlayQueueStyle_optionDetached": "afgekoppeld",
"homeFeatureStyle_description": "bepaalt de stijl van de uitgelicht-carrousel op de homepagina",
"homeFeatureStyle": "stijl uitgelicht-carrousel",
"homeFeatureStyle_optionMultiple": "meervoudig",
"homeFeatureStyle_optionSingle": "enkelvoudig",
"blurExplicitImages": "vervaag expliciete afbeeldingen",
"blurExplicitImages_description": "hoezen van albums en nummers die getagd zijn als expliciet zullen worden vervaagd",
"mediaSession": "mediasessie inschakelen",
"sidePlayQueueStyle": "stijl van zijwachtrij",
"skipDuration_description": "de tijdsduur die wordt doorgespoeld bij gebruik van de spoelknoppen in de afspeelbalk",
"skipDuration": "doorspoelduur",
"startMinimized_description": "start de applicatie in het systeemvak",
"startMinimized": "start geminimaliseerd",
"theme": "thema",
"theme_description": "het visuele thema dat de applicatie gebruikt",
"themeDark_description": "het donkere thema dat de applicatie gebruikt",
"themeDark": "thema (donker)",
"themeLight_description": "het lichte thema dat de applicatie gebruikt",
"themeLight": "thema (licht)",
"transcode": "transcoderen inschakelen",
"transcode_description": "schakel transcoderen naar andere formaten in",
"transcodeBitrate_description": "de bitsnelheid waarnaar wordt getranscodeerd. bij 0 bepaalt de server de waarde",
"transcodeBitrate": "transcodeerbitsnelheid",
"transcodeFormat_description": "het formaat waarnaar wordt getranscodeerd. laat leeg om de server te laten bepalen",
"transcodeFormat": "transcodeerformaat",
"translationApiKey_description": "api-sleutel voor vertaling (enkel globaal service-eindpunt)",
"translationApiKey": "vertalings-api-sleutel",
"translationApiProvider_description": "api-provider voor vertalingen",
"translationApiProvider": "vertalings-api-provider",
"translationTargetLanguage_description": "doeltaal voor vertalingen",
"translationTargetLanguage": "doeltaal vertaling",
"trayEnabled_description": "toon/verstop het systeemvakicoon/-menu. indien uitgeschakeld wordt het minimaliseren/sluiten naar het systeemvak ook uitgeschakeld",
"trayEnabled": "toon systeemvak",
"useSystemTheme_description": "volg de systeemvoorkeur voor licht of donker thema",
"useSystemTheme": "gebruik systeemthema",
"volumeWheelStep_description": "de hoeveelheid volume die gewijzigd wordt bij het scrollen met het muiswiel op de volumebalk",
"volumeWheelStep": "volumestap muiswiel",
"volumeWidth_description": "de breedte van de volumebalk",
"volumeWidth": "volumebalkbreedte",
"webAudio_description": "gebruik web-audio. dit schakeld geavanceerde mogelijkheden als replaygain in. schakel uit als dit niet werkt",
"webAudio": "gebruik web-audio",
"windowBarStyle": "vensterbalkstijl",
"windowBarStyle_description": "kies de stijl van de vensterbalk",
"zoom": "zoompercentage",
"zoom_description": "het zoompercentage van de applicatie",
"queryBuilder": "opdrachtbouwer",
"queryBuilderCustomFields": "aangepaste velden",
"queryBuilderCustomFields_description": "voeg aangepaste velden voor gebruik in opdrachtbouwers toe",
"enableGridMultiSelect": "meervoudig selecteren in grid",
"enableGridMultiSelect_description": "staat toe meerdere items in gridweergaven te selecteren. indien uitgeschakeld zal het klikken op een item in een gridweergave naar diens pagina navigeren",
"sidebarPlaylistSorting": "afspeellijstsortering in zijbalk",
"sidebarPlaylistSorting_description": "activeert het handmatig sorteren van de afspeellijst in de zijbalk door middel van slepen in plaats van het gebruiken van de servervolgorde",
"sidebarPlaylistListFilterRegex_description": "verberg afspeellijsten in de zijbalk die overeenkomen met deze reguliere expressie",
"sidebarPlaylistListFilterRegex_placeholder": "bijv. ^Daily Mix.*",
"sidebarPlaylistListFilterRegex": "regex afspeellijstfilter",
"mediaSession_description": "schakelt mediasessie-integratie in, waarbij mediabesturing en metadata in het volumeweergave en het lock-scherm worden weergegeven",
"skipPlaylistPage": "sla afspeellijstpagina over",
"skipPlaylistPage_description": "ga naar de nummerlijst in plaats van de standaard pagina bij het navigeren naar een afspeellijst",
"queryBuilderCustomFields_inputLabel": "label",
"queryBuilderCustomFields_inputTag": "tag",
"playButtonBehavior_optionAddLast": "$t(player.addLast)",
"playButtonBehavior_optionAddNext": "$t(player.addNext)",
"playButtonBehavior_optionPlay": "$t(player.play)",
"playButtonBehavior_optionPlayShuffled": "$t(player.shuffle)",
"replayGainMode_optionAlbum": "$t(entity.album, {\"count\": 1})",
"replayGainMode_optionNone": "$t(common.none)",
"replayGainMode_optionTrack": "$t(entity.track, {\"count\": 1})"
},
"form": {
"addServer": {
@@ -964,5 +1141,159 @@
"soundtrack": "soundtrack",
"spokenWord": "gesproken woord"
}
},
"dragDropZone": {
"error_oneFileOnly": "Kies één bestand",
"error_readingFile": "probleem opgetreden bij het lezen van het bestand: {{errorMessage}}",
"mainText": "sleep hier een bestand naartoe"
},
"visualizer": {
"visualizerType": "Type Visualiseerder",
"cyclePresets": "Doorloop Voorinstellingen",
"cycleTime": "Cyclustijd (seconden)",
"ignoredPresets": "Genegeerde Voorinstellingen",
"selectedPresets": "Gekozen Voorinstellingen",
"includeAllPresets": "Alle Voorinstellingen Opnemen",
"randomizeNextPreset": "Willekeurige Volgende Voorinstelling",
"blendTime": "Mengtijd",
"presets": "Voorinstellingen",
"selectPreset": "Kies Voorinstelling",
"applyPreset": "Voorinstelling Toepassen",
"saveAsPreset": "Opslaan als Voorinstelling",
"updatePreset": "Voorinstelling Bijwerken",
"copyConfiguration": "Kopieer Configuratie",
"pasteConfiguration": "Plak Configuratie",
"pasteConfigurationPlaceholder": "Plak JSON-configuratie hier...",
"pasteFromClipboard": "Plakken vanaf Klembord",
"applyConfiguration": "Configuratie Toepassen",
"configCopied": "Configuratie gekopieerd naar het klembord",
"configCopyFailed": "Kopiëren van configuratie is mislukt",
"configPasted": "Configuratie succesvol toegepast",
"configPasteFailed": "Toepassen configuratie mislukt. Controleer het formaat.",
"configPasteReadFailed": "Lezen van het klembord mislukt",
"presetName": "Naam Voorinstelling",
"presetNamePlaceholder": "Voer de naam van de voorinstelling in",
"general": "Algemeen",
"mode": "Modus",
"mode1To8": "Modus 1-8",
"mode10": "Modus 10",
"barSpace": "Balkruimte",
"lineWidth": "Lijnbreedte",
"fillAlpha": "Alfavulling",
"channelLayout": "Kanaalindeling",
"maxFPS": "Max FPS",
"opacity": "Opaciteit",
"customGradients": "Aangepaste Kleurverlopen",
"addCustomGradient": "Voeg Aangepast Kleurverloop Toe",
"gradientName": "Naam Kleurverloop",
"gradientNamePlaceholder": "Naam Kleurverloop",
"vertical": "Verticaal",
"horizontal": "Horizontaal",
"colorStops": "Kleurstop",
"addColor": "Voeg Kleur Toe",
"position": "Positie",
"level": "Niveau",
"remove": "Verwijder",
"pasteGradient": "Plak Kleurverloop",
"pasteGradientPlaceholder": "Plak JSON van kleurverloop hier...",
"custom": "Aangepast",
"builtIn": "Ingebouwd",
"colors": "Kleuren",
"colorMode": "Kleurmodus",
"gradient": "Kleurverloop",
"gradientLeft": "Kleurverloop Links",
"gradientRight": "Kleurverloop Rechts",
"fft": "FFT",
"fftSize": "FFT-grootte",
"smoothing": "Gladstrijken",
"frequencyRangeAndScaling": "Frequentiebereik en -schaling",
"minimumFrequency": "Minimumfrequentie",
"maximumFrequency": "Maximumfrequentie",
"frequencyScale": "Frequentieschaal",
"sensitivity": "Gevoeligheid",
"weightingFilter": "Gewichtsfilter",
"minimumDecibels": "Minimum aantal decibel",
"maximumDecibels": "Maximum aantal decibel",
"linearAmplitude": "Lineaire Amplitude",
"linearBoost": "Lineaire Versterking",
"peakBehavior": "Piekgedrag",
"showPeaks": "Toon Pieken",
"fadePeaks": "Vervaag Pieken",
"peakLine": "Pieklijn",
"gravity": "Zwaartekracht",
"peakFadeTime": "Piekvervagingstijd (ms)",
"peakHoldTime": "Piekvasthoudtijd (ms)",
"radialSpectrum": "Radiaal Spectrum",
"radial": "Radiaal",
"radialInvert": "Geïnverteerde Radiaal",
"spinSpeed": "Draaisnelheid",
"radius": "Radius",
"reflexMirror": "Reflexspiegel",
"reflexRatio": "Reflexverhouding",
"reflexAlpha": "Reflex-alfa",
"reflexFit": "Reflex-inpassing",
"reflexBrightness": "Reflex-helderheid",
"mirror": "Spiegel",
"miscellaneousSettings": "Diverse Instellingen",
"alphaBars": "Alfabalken",
"ansiBands": "ANSI-banden",
"ledBars": "LED-balken",
"trueLeds": "Ware LEDs",
"lumiBars": "Lumi-balken",
"outlineBars": "Uitgelijnde balken",
"roundBars": "Ronde Balken",
"lowResolution": "Lage Resolutie",
"splitGradient": "Gescheiden Kleurverloop",
"showFPS": "Toon FPS",
"showScaleX": "Toon X-schaal",
"showScaleY": "Toon Y-schaal",
"noteLabels": "Nootlabels",
"options": {
"mode": {
"0": "[0] Discrete Frequenties",
"1": "[1] 1/24e octaaf / 240 bands",
"2": "[2] 1/12e octaaf / 120 bands",
"3": "[3] 1/8e octaaf / 80 bands",
"4": "[4] 1/6e octaaf / 60 bands",
"5": "[5] 1/4e octaaf / 40 bands",
"6": "[6] 1/3e octaaf / 30 bands",
"7": "[7] Half octaaf / 20 bands",
"8": "[8] Volledig octaaf / 10 bands",
"10": "[10] Lijn- / Gebiedsgrafiek"
},
"colorMode": {
"gradient": "Kleurverloop",
"barIndex": "Balk-index",
"barLevel": "Balkniveau"
},
"gradient": {
"classic": "Klassiek",
"prism": "Prisma",
"rainbow": "Regenboog",
"steelblue": "Staalblauw",
"orangered": "Oranjerood"
},
"channelLayout": {
"single": "Enkelvoudig",
"dualCombined": "Duaalgecombineerd",
"dualHorizontal": "Duaalhorizontaal",
"dualVertical": "Duaalvertikaal"
},
"frequencyScale": {
"none": "Geen",
"bark": "Bark-schaal",
"linear": "Lineaire Schaal",
"log": "Log-schaal",
"mel": "Mel-schaal"
},
"weightingFilter": {
"none": "Geen",
"a": "A",
"b": "B",
"c": "C",
"d": "D",
"z": "Z"
}
}
}
}
+40 -13
View File
@@ -73,9 +73,9 @@
"delete": "usuń",
"cancel": "anuluj",
"forceRestartRequired": "zrestartuj aby zastosować zmiany... zamknij powiadomienie aby zrestartować",
"setting_one": "ustawienia",
"setting_few": "",
"setting_many": "",
"setting_one": "ustawienie",
"setting_few": "ustawienia",
"setting_many": "ustawień",
"version": "wersja",
"title": "tytuł",
"filter_one": "filtr",
@@ -176,14 +176,14 @@
"playlist_few": "playlisty",
"playlist_many": "playlist",
"artist_one": "wykonawca",
"artist_few": "wykonawców",
"artist_few": "wykonawcy",
"artist_many": "wykonawców",
"folderWithCount_one": "{{count}} katalog",
"folderWithCount_few": "{{count}} katalogi",
"folderWithCount_many": "{{count}} katalogów",
"albumArtist_one": "wykonawca albumu",
"albumArtist_few": "wykonawcy albumu",
"albumArtist_many": "wykonawców albumu",
"albumArtist_many": "wykonawcy albumów",
"track_one": "utwór",
"track_few": "utwory",
"track_many": "utworów",
@@ -428,7 +428,7 @@
"dynamicIsImage": "włącz obraz w tle",
"lyricOffset": "opóźnienie tekstów (ms)"
},
"upNext": "następny",
"upNext": "następne",
"lyrics": "tekst",
"related": "powiązane",
"visualizer": "wizualizer",
@@ -577,7 +577,11 @@
"appearsOn": "pojawia się na",
"viewAllTracks": "zobacz wszystko $t(entity.track, {\"count\": 2})",
"groupingTypeAll": "wszystkie typy wydań",
"groupingTypePrimary": "główne typy wydań"
"groupingTypePrimary": "główne typy wydań",
"favoriteSongs": "ulubione piosenki",
"topSongsCommunity": "społeczność",
"topSongsPersonal": "osobiste",
"favoriteSongsFrom": "ulubione piosenki z {{title}}"
},
"itemDetail": {
"copyPath": "kopiuj ścieżkę do schowka",
@@ -611,6 +615,11 @@
"collections": {
"overrideExisting": "nadpisz istniejące",
"saveAsCollection": "zapisz jako kolekcję"
},
"releasenotes": {
"commitsSinceStable": "commity od {{stable}}",
"noNewCommits": "brak nowych commitów w tym zakresie",
"noStableReleaseToCompare": "brak dostępnego stabilnego wydania do porównania"
}
},
"player": {
@@ -652,7 +661,16 @@
"restoreQueueFromServer": "przywróć kolejkę z serwera",
"saveQueueToServer": "zapisz kolejkę na serwerze",
"artistRadio": "radio wykonawcy",
"trackRadio": "radio utworu"
"trackRadio": "radio utworu",
"sleepTimer": "wyłącznik czasowy",
"sleepTimer_endOfSong": "do końca aktualnej piosenki",
"sleepTimer_minutes": "{{count}} min",
"sleepTimer_hours": "{{count}} godz",
"sleepTimer_custom": "niestandardowy",
"sleepTimer_off": "wyłączony",
"sleepTimer_timeRemaining": "pozostało {{time}}",
"sleepTimer_setCustom": "ustaw wyłącznik",
"sleepTimer_cancel": "anuluj wyłączanie"
},
"setting": {
"crossfadeStyle_description": "wybierz styl przenikania, który ma być używany do odtwarzania dźwięku",
@@ -684,7 +702,6 @@
"globalMediaHotkeys": "globalne skróty klawiszowe multimediów",
"hotkey_globalSearch": "globalne wyszukiwanie",
"gaplessAudio_description": "ustaw dźwięk bez przerw dla mpv",
"disableAutomaticUpdates": "wyłącz automatyczne aktualizacje",
"exitToTray_description": "zamknij aplikację do zasobnika systemowego",
"followLyric_description": "przewiń tekst do obecnego momentu",
"hotkey_favoritePreviousSong": "ulubiona $t(common.previousSong)",
@@ -728,7 +745,7 @@
"hotkey_zoomOut": "oddal",
"hotkey_unfavoriteCurrentSong": "usuń $t(common.currentSong) z ulubionych",
"hotkey_rate0": "wyczyść oceny",
"discordApplicationId": "ID aplikacji {{discord}}",
"discordApplicationId": "id aplikacji {{discord}}",
"applicationHotkeys_description": "ustaw skróty klawiszowe aplikacji. przełącz pole wyboru aby ustawić skrót globalny (tylko komputery)",
"hotkey_volumeMute": "wycisz",
"hotkey_toggleCurrentSongFavorite": "dodaj $t(common.currentSong) do ulubionych",
@@ -886,7 +903,7 @@
"releaseChannel_optionBeta": "beta",
"releaseChannel_optionLatest": "najnowsza",
"releaseChannel": "kanał wydań",
"releaseChannel_description": "wybieraj pomiędzy stabilnymi wydaniami a wydaniami beta dla automatycznych aktualizacji",
"releaseChannel_description": "wybieraj pomiędzy wydaniami stabilnymi, beta lub alpha (nightly) dla automatycznych aktualizacji",
"discordDisplayType_artistname": "nazwa(y) wykonawców",
"discordDisplayType_description": "zmienia co jest pokazywane jako słuchane w twoim statusie",
"discordDisplayType_songname": "nazwa piosenki",
@@ -997,14 +1014,24 @@
"sidebarPlaylistSorting": "sortowanie playlist w bocznym pasku",
"sidebarPlaylistListFilterRegex_description": "ukryj playlisty w pasku bocznym pasujące do wyrażenia regularnego",
"sidebarPlaylistListFilterRegex_placeholder": "np. ^Miks codzienny.^",
"sidebarPlaylistListFilterRegex": "filtr playlist regex"
"sidebarPlaylistListFilterRegex": "filtr playlist regex",
"blurExplicitImages": "rozmazuj nieodpowiednie obrazy",
"blurExplicitImages_description": "obrazy piosenek oraz albumów oznaczone jako nieodpowiednie będą rozmazywane",
"releaseChannel_optionAlpha": "alpha (nightly)",
"analyticsEnable": "Wysyłaj statystyki na podstawie użytkowania",
"analyticsEnable_description": "Zanonimizowane statystki użytkowania będą wysyłane do twórcy, aby pomóc w poprawie aplikacji",
"automaticUpdates": "Aktualizacje automatyczne",
"automaticUpdates_description": "Sprawdzaj i instaluj aktualizacje automatycznie",
"discordStateIcon": "pokaż ikonę odtwarzania",
"discordStateIcon_description": "pokazuje małą ikonę odtwarzania w statusie. ikona pauzy jest zawsze pokazywana gdy \"Pokaż status podczas pauzy\" jest włączone"
},
"table": {
"config": {
"view": {
"table": "tabela",
"grid": "siatka",
"list": "lista"
"list": "lista",
"detail": "szczegół"
},
"general": {
"displayType": "typ wyświetlania",
-1
View File
@@ -230,7 +230,6 @@
"crossfadeDuration_description": "define a duração do efeito crossfade",
"customCssNotice": "Atenção: embora haja alguma sanitização (proibindo url() e content:), usar CSS personalizado ainda pode representar riscos ao alterar a interface",
"crossfadeStyle_description": "seleciona qual estilo de crossfade usado no player de áudio",
"disableAutomaticUpdates": "desabilitar atualizações automáticas",
"disableLibraryUpdateOnStartup": "desabilitar a verificação de novas versões na inicialização",
"artistBackground": "Imagem de fundo do artista",
"artistBackground_description": "Adiciona uma imagem de fundo às páginas do artista contendo a arte do artista",
-1
View File
@@ -523,7 +523,6 @@
"customCssEnable_description": "permite escrever css customizado",
"customCssNotice": "Aviso: apesar de existir alguma higienização (url() e content: não são permitidas), o uso de css personalizado ainda pode representar riscos ao alterar a interface",
"customCss": "css customizado",
"disableAutomaticUpdates": "desativar atualizações automáticas",
"disableLibraryUpdateOnStartup": "desativar a verificação de novas versões na inicialização",
"discordApplicationId": "{{discord}} ID da aplicação",
"discordIdleStatus_description": "quando ativado, atualiza o estado enquanto o player está ocioso",
-1
View File
@@ -727,7 +727,6 @@
"disableLibraryUpdateOnStartup": "отключить проверку новых версий при запуске приложения",
"minimizeToTray_description": "сворачивать приложение в панель уведомлений",
"audioPlayer_description": "укажите, какой аудиоплеер использовать для воспроизведения",
"disableAutomaticUpdates": "отключить проверку обновлений",
"exitToTray_description": "При закрытии приложения - оно останется в панели уведомлений",
"fontType_optionCustom": "пользовательский",
"remotePassword": "пароль к серверу удалённого управления",
-1
View File
@@ -541,7 +541,6 @@
"customCss_description": "vlastný css obsah. Poznámka: obsah a vzdialené url linky sú defaultne deaktivované.Náhľad vášho obsahu je zobrazený nižšie. Pridané polia, ktoré ste nenastavovali boli pridané pri sanitizácii",
"customFontPath": "cesta k vlastným fontom",
"customFontPath_description": "Nastaví cestu k vlastným fontom na použitie aplikáciou",
"disableAutomaticUpdates": "vypnúť automatické aktualizácie",
"disableLibraryUpdateOnStartup": "vypnúť kontrolu nových verzií pri štarte",
"discordApplicationId": "id aplikácie {{discord}}",
"discordApplicationId_description": "aplikačné id pre plnohodnotné prepojenie s {{discord}} (predvolená hodnota {{defaultId}})",
-1
View File
@@ -548,7 +548,6 @@
"customCss_description": "vsebina css po meri. Opomba: vsebina in oddaljeni url-ji so prepovedane lastnosti. Spodaj je prikazan predogled vaše vsebine. Dodatna polja, ki jih niste nastavili, so prisotna zaradi prečiščevanja",
"customFontPath": "pot za pisavo po meri",
"customFontPath_description": "nastavi pot do pisave po meri",
"disableAutomaticUpdates": "onemogoči samodejne posodobitve",
"disableLibraryUpdateOnStartup": "onemogoči prevejranje novih verzij ob zagonu",
"discordApplicationId": "{{discord}} identifikator aplikacije",
"discordApplicationId_description": "identifikator aplikacije za {{discord}} bogato prezenco (privzeto {{defaultId}})",
-1
View File
@@ -88,7 +88,6 @@
"hotkey_globalSearch": "globalno pretraživanje",
"gaplessAudio_description": "postavlja opciju bez pauze zvuka za mpv (preporučeno: slabo)",
"remoteUsername_description": "postavlja korisničko ime za daljinsku kontrolu servera. Ako su i korisničko ime i lozinka prazni, autentifikacija će biti onemogućena",
"disableAutomaticUpdates": "onemogući automatsko ažuriranje",
"exitToTray_description": "izlazak aplikacije u sistemsku traku",
"followLyric_description": "pomera tekst pesme na trenutnu poziciju reprodukcije",
"hotkey_favoritePreviousSong": "omiljena $t(common.previousSong)",
-1
View File
@@ -609,7 +609,6 @@
"customCssEnable": "தனிப்பயன் சிஎச்எச் ஐ இயக்கவும்",
"customCssNotice": "எச்சரிக்கை: சில சுத்திகரிப்பு (URL () மற்றும் உள்ளடக்கத்தை அனுமதிக்காதது :) இருக்கும்போது, தனிப்பயன் சிஎச்எச் ஐப் பயன்படுத்துவது இடைமுகத்தை மாற்றுவதன் மூலம் ஆபத்துக்களை ஏற்படுத்தக்கூடும்",
"contextMenu_description": "நீங்கள் ஒரு உருப்படியை வலது சொடுக்கு செய்யும் போது பட்டியலில் காட்டப்பட்டுள்ள உருப்படிகளை மறைக்க உங்களை அனுமதிக்கிறது. சரிபார்க்கப்படாத உருப்படிகள் மறைக்கப்படும்",
"disableAutomaticUpdates": "தானியங்கி புதுப்பிப்புகளை முடக்கு",
"discordApplicationId_description": "{{discord}} பணக்கார இருப்புக்கான பயன்பாட்டு ஐடி (இயல்புநிலை {{defaultId}})",
"discordIdleStatus": "பணக்கார இருப்பு செயலற்ற நிலையைக் காட்டுங்கள்",
"discordIdleStatus_description": "இயக்கப்பட்டால், பிளேயர் சும்மா இருக்கும்போது நிலையைப் புதுப்பிக்கவும்",
-1
View File
@@ -534,7 +534,6 @@
"customCss_description": "özel css içeriği. Not: içerik ve uzaktan URL'ler izin verilmeyen özelliklerdir. İçeriğinizin önizlemesi aşağıda gösterilmektedir. Ayarlamadığınız ek alanlar sterilleme nedeniyle mevcuttur",
"customFontPath": "özel yazı tipi yolu",
"customFontPath_description": "uygulama için kullanılacak özel yazı tipinin yolunu ayarlar",
"disableAutomaticUpdates": "otomatik güncellemeleri devre dışı bırak",
"disableLibraryUpdateOnStartup": "başlangıçta yeni sürümler için denetimi devre dışı bırak",
"discordApplicationId": "{{discord}} uygulama kimliği",
"discordApplicationId_description": "{{discord}} \"Rich Presence\" için uygulama kimliği (varsayılan olarak {{defaultId}})",
+540 -1
View File
@@ -1,5 +1,544 @@
{
"action": {
"addToFavorites": "додати до $t(entity.favorite, {\"count\": 2})"
"addToFavorites": "додати до $t(entity.favorite, {\"count\": 2})",
"addOrRemoveFromSelection": "додати або видалити з вибору",
"selectRangeOfItems": "вибрати діапазон елементів",
"addToPlaylist": "додати до $t(entity.playlist, {\"count\": 1})",
"clearQueue": "очистити чергу",
"createPlaylist": "створити $t(entity.playlist, {\"count\": 1})",
"createRadioStation": "створити $t(entity.radioStation, {\"count\": 1})",
"deletePlaylist": "видалити $t(entity.playlist, {\"count\": 1})",
"deleteRadioStation": "видалити $t(entity.radioStation, {\"count\": 1})",
"selectAll": "вибрати все",
"deselectAll": "скасувати вибір усього",
"downloadStarted": "почато завантаження {{count}} елементів",
"editPlaylist": "редагувати $t(entity.playlist, {\"count\": 1})",
"goToPage": "перейти на сторінку",
"moveToNext": "перейти до наступного",
"moveToBottom": "перемістити вниз",
"moveToTop": "перемістити вгору",
"moveUp": "перемістити вище",
"moveDown": "перемістити нижче",
"holdToMoveToTop": "утримуйте, щоб перемістити вгору",
"holdToMoveToBottom": "утримувати, щоб перемістити вниз",
"moveItems": "перемістити елементи",
"shuffle": "перемішати",
"shuffleAll": "все випадково",
"shuffleSelected": "вибране випадково",
"refresh": "$t(common.refresh)",
"removeFromFavorites": "видалити з $t(entity.favorite, {\"count\": 2})",
"removeFromPlaylist": "видалити з $t(entity.playlist, {\"count\": 1})",
"removeFromQueue": "видалити з черги",
"setRating": "встановити рейтинг",
"toggleSmartPlaylistEditor": "перемикати редактор $t(entity.smartPlaylist)",
"viewPlaylists": "показати $t(entity.playlist, {\"count\": 2})",
"viewMore": "переглянути більше",
"openApplicationDirectory": "відкрити каталог додатків",
"openIn": {
"lastfm": "Відкрити в Last.fm",
"musicbrainz": "Відкрити в MusicBrainz"
}
},
"common": {
"countSelected": "вибрано {{count}}",
"explicitStatus": "явний статус",
"action_one": "дія",
"action_few": "дії",
"action_many": "дій",
"add": "додати",
"additionalParticipants": "додаткові учасники",
"newVersion": "встановлено нову версію ({{version}})",
"viewReleaseNotes": "переглянути список змін",
"albumGain": "підсилення альбому",
"albumPeak": "піковий рівень альбому",
"areYouSure": "ви впевнені?",
"ascending": "зростаючи",
"backward": "назад",
"biography": "біографія",
"bitDepth": "розрядність",
"bitrate": "бітрейт",
"bpm": "уд/хв",
"cancel": "скасувати",
"center": "посередині",
"channel_one": "канал",
"channel_few": "канали",
"channel_many": "каналів",
"clear": "очистити",
"close": "закрити",
"codec": "кодек",
"collapse": "згорнути",
"comingSoon": "скоро…",
"configure": "налаштувати",
"confirm": "підтвердити",
"create": "створити",
"currentSong": "поточний $t(entity.track, {\"count\": 1})",
"decrease": "знизити",
"delete": "видалити",
"descending": "за спаданням",
"description": "опис",
"disable": "вимкнути",
"disc": "диск",
"dismiss": "відхилити",
"doNotShowAgain": "не показувати це знову",
"duration": "тривалість",
"view": "показати",
"edit": "змінити",
"enable": "увімкнути",
"expand": "розширити",
"example": "приклад",
"externalLinks": "зовнішні посилання",
"faster": "швидше",
"favorite": "улюблений",
"filter_one": "фільтр",
"filter_few": "фільтри",
"filter_many": "фільтрів",
"filters": "фільтри",
"filter_single": "одиночний",
"filter_multiple": "кілька",
"forceRestartRequired": "перезапустіть, щоб застосувати зміни… закрийте повідомлення, щоб перезапустити",
"forward": "уперед",
"gap": "прогалина",
"home": "додому",
"increase": "збільшити",
"left": "ліво",
"limit": "ліміт",
"manage": "управління",
"maximize": "максимізувати",
"menu": "меню",
"minimize": "мінімізувати",
"modified": "відредаговано",
"mbid": "MusicBrainz ID",
"mood": "настрій",
"name": "назва",
"no": "ні",
"none": "жоден",
"noResultsFromQuery": "запит не дав результатів",
"noFilters": "фільтри не налаштовані",
"note": "примітка",
"ok": "ок",
"owner": "власник",
"path": "шлях",
"playerMustBePaused": "плеєр повинен бути призупинений",
"preview": "перегляд",
"previousSong": "минулий $t(entity.track, {\"count\": 1})",
"private": "приватний",
"public": "публічний",
"quit": "вийти",
"random": "випадково",
"rating": "рейтинг",
"retry": "повторити спробу",
"recordLabel": "лейбл звукозапису",
"releaseType": "тип випуску",
"refresh": "оновити",
"reload": "перезавантажити",
"rename": "перейменувати",
"reset": "скинути",
"resetToDefault": "скинути до заводських налаштувань",
"restartRequired": "необхідний перезапуск",
"right": "право",
"clean": "чистo",
"sampleRate": "частота дискретизації",
"save": "зберегти",
"saveAndReplace": "зберегти та замінити",
"saveAs": "зберегти як",
"search": "пошук",
"setting_one": "налаштування",
"setting_few": "налаштування",
"setting_many": "налаштувань",
"slower": "повільніше",
"share": "поділитися",
"size": "розмір",
"sort": "впорядкувати",
"sortOrder": "порядок",
"tags": "теги",
"title": "назва",
"trackNumber": "трек",
"trackGain": "підсилення треку",
"trackPeak": "піковий рівень треку",
"translation": "переклад",
"unknown": "невідомий",
"version": "версія",
"year": "рік",
"yes": "так",
"explicit": "Експліцитний зміст",
"gridRows": "рядки сітки",
"tableColumns": "стовпці таблиці",
"itemsMore": "{{count}} більше"
},
"entity": {
"album_one": "альбом",
"album_few": "альбоми",
"album_many": "альбомів",
"albumArtist_one": "виконавець альбому",
"albumArtist_few": "виконавці альбому",
"albumArtist_many": "виконавців альбому",
"albumArtistCount_one": "{{count}} виконавець альбому",
"albumArtistCount_few": "{{count}} виконавці альбому",
"albumArtistCount_many": "{{count}} виконавців альбому",
"albumWithCount_one": "{{count}} альбом",
"albumWithCount_few": "{{count}} альбоми",
"albumWithCount_many": "{{count}} альбомів",
"radioStation_one": "радіостанція",
"radioStation_few": "радіостанції",
"radioStation_many": "радіостанцій",
"radioStationWithCount_one": "{{count}} радіостанція",
"radioStationWithCount_few": "{{count}} радіостанції",
"radioStationWithCount_many": "{{count}} радіостанцій",
"artist_one": "виконавець",
"artist_few": "виконавці",
"artist_many": "виконавців",
"artistWithCount_one": "{{count}} виконавець",
"artistWithCount_few": "{{count}} виконавці",
"artistWithCount_many": "{{count}} виконавців",
"favorite_one": "улюблений",
"favorite_few": "улюблені",
"favorite_many": "улюблених",
"folder_one": "папка",
"folder_few": "папки",
"folder_many": "папок",
"folderWithCount_one": "{{count}} папка",
"folderWithCount_few": "{{count}} папки",
"folderWithCount_many": "{{count}} папок",
"genre_one": "жанр",
"genre_few": "жанри",
"genre_many": "жанрів",
"genreWithCount_one": "{{count}} жанр",
"genreWithCount_few": "{{count}} жанри",
"genreWithCount_many": "{{count}} жанрів",
"playlist_one": "плейлист",
"playlist_few": "плейлисти",
"playlist_many": "плейлистів",
"play_one": "{{count}} відтворення",
"play_few": "{{count}} відтворення",
"play_many": "{{count}} відтворень",
"playlistWithCount_one": "{{count}} плейлист",
"playlistWithCount_few": "{{count}} плейлисти",
"playlistWithCount_many": "{{count}} плейлистів",
"smartPlaylist": "розумний $t(entity.playlist, {\"count\": 1})",
"track_one": "трек",
"track_few": "треки",
"track_many": "треків",
"song_one": "пісня",
"song_few": "пісні",
"song_many": "пісень",
"trackWithCount_one": "{{count}} трек",
"trackWithCount_few": "{{count}} треки",
"trackWithCount_many": "{{count}} треків"
},
"error": {
"apiRouteError": "неможливо виконати запит",
"audioDeviceFetchError": "сталася помилка під час спроби отримати аудіопристрої",
"authenticationFailed": "аутентифікація не вдалася",
"badAlbum": "ви бачите цю сторінку, тому що ця пісня не входить до альбому. найімовірніше, ця проблема виникає, якщо у верхньому рівні вашої музичної папки знаходиться пісня. Jellyfin групує треки тільки в тому випадку, якщо вони знаходяться в папці",
"badValue": "недійсний параметр \"{{value}}\". це значення більше не існує",
"credentialsRequired": "необхідні дані для входу",
"endpointNotImplementedError": "кінцева точка {{endpoint}} не реалізована для {{serverType}}",
"genericError": "сталася помилка",
"invalidServer": "недійсний сервер",
"localFontAccessDenied": "відмова в доступі до локальних шрифтів",
"loginRateError": "занадто багато спроб входу, спробуйте ще раз через кілька секунд",
"mpvRequired": "необхідний MPV",
"multipleServerSaveQueueError": "у черзі відтворення є одна або кілька пісень, які не належать до поточного сервера. це не підтримується",
"networkError": "сталася мережева помилка",
"noNetwork": "сервер недоступний",
"noNetworkDescription": "не вдалося підключитися до цього сервера",
"notificationDenied": "дозвіл на сповіщення було відхилено. це налаштування не має впливу",
"openError": "не вдалося відкрити файл",
"playbackError": "сталася помилка під час спроби відтворити медіафайл",
"remoteDisableError": "сталася помилка під час спроби $t(common.disable) віддаленого сервера",
"remoteEnableError": "сталася помилка під час спроби $t(common.enable) віддаленого сервера",
"remotePortError": "сталася помилка під час спроби налаштувати порт віддаленого сервера",
"remotePortWarning": "перезапустіть сервер щоб застосувати новий порт",
"saveQueueFailed": "не вдалося зберегти чергу",
"serverNotSelectedError": "не вибрано жодного сервера",
"serverRequired": "потрібен сервер",
"sessionExpiredError": "ваша сесія закінчилася",
"systemFontError": "сталася помилка під час спроби отримати системні шрифти",
"settingsSyncError": "виявлено розбіжності між налаштуваннями в рендерері та основним процесом. перезапустіть програму, щоб застосувати зміни"
},
"filter": {
"album": "$t(entity.album, {\"count\": 1})",
"albumArtist": "$t(entity.albumArtist, {\"count\": 1})",
"albumCount": "кількість $t(entity.album, {\"count\": 2})",
"artist": "$t(entity.artist, {\"count\": 1})",
"biography": "біографія",
"bitrate": "бітрейт",
"bpm": "уд/хв",
"channels": "$t(common.channel, {\"count\": 2})",
"comment": "коментар",
"communityRating": "рейтинг спільноти",
"criticRating": "рейтинг критиків",
"dateAdded": "дата додавання",
"disc": "диск",
"duration": "тривалість",
"favorited": "улюблене",
"fromYear": "з року",
"genre": "$t(entity.genre, {\"count\": 1})",
"id": "id",
"isCompilation": "є компіляцією",
"isFavorited": "є улюбленим",
"isPublic": "є публічним",
"isRated": "є оціненим",
"isRecentlyPlayed": "нещодавно відтворено",
"lastPlayed": "нещодавно відтворені",
"mostPlayed": "найбільш відтворювані",
"name": "назва",
"note": "примітка",
"owner": "$t(common.owner)",
"path": "шлях",
"playCount": "кількість відтворень",
"random": "випадково",
"rating": "рейтинг",
"recentlyAdded": "нещодавно додано",
"recentlyPlayed": "нещодавно відтворено",
"recentlyUpdated": "нещодавно оновлено",
"releaseDate": "дата випуску",
"releaseYear": "рік випуску",
"search": "шукати",
"songCount": "кількість пісень",
"sortName": "сортування за назвою",
"title": "назва",
"toYear": "до року",
"trackNumber": "трек",
"explicitStatus": "$t(common.explicitStatus)"
},
"datetime": {
"minuteShort": "хв.",
"secondShort": "сек.",
"hourShort": "год",
"dayShort": "дн."
},
"filterOperator": {
"after": "є після",
"afterDate": "після (дата)",
"before": "є перед",
"beforeDate": "є перед (дата)",
"contains": "містить",
"endsWith": "закінчується на",
"inPlaylist": "є в",
"inTheLast": "є в останньому",
"inTheRange": "є в межах",
"inTheRangeDate": "є в межах (дата)",
"is": "є",
"isNot": "не є",
"isGreaterThan": "більше ніж",
"isLessThan": "менше ніж",
"matchesRegex": "відповідає регулярному виразу",
"notContains": "не містить",
"notInPlaylist": "немає в",
"notInTheLast": "не є в останньому",
"startsWith": "починається з"
},
"form": {
"addServer": {
"error_savePassword": "сталася помилка під час спроби зберегти пароль",
"ignoreCors": "ігнорувати cors ($t(common.restartRequired))",
"ignoreSsl": "ігнорувати ssl ($t(common.restartRequired)}",
"input_legacyAuthentication": "увімкнути застарілу автентифікацію",
"input_name": "назва сервера",
"input_password": "пароль",
"input_preferInstantMix": "віддавати перевагу миттєвому міксу",
"input_preferInstantMixDescription": "використовувати тільки миттєвий мікс щоб отримати подібні пісні. корисно, коли у вас є плагіни, які змінюють цю поведінку",
"input_preferRemoteUrl": "віддавати перевагу публічній URL-адресі",
"input_remoteUrl": "публічна URL-адреса",
"input_remoteUrlPlaceholder": "опціонально: публічна URL-адреса для зовнішніх функцій",
"input_savePassword": "зберегти пароль",
"input_url": "URL-адреса",
"input_username": "Ім'я користувача",
"success": "сервер додано успішно",
"title": "додати сервер"
},
"largeFetchConfirmation": {
"title": "додати елементи до черги",
"description": "Ця дія додасть усі елементи в поточний відфільтрований перегляд"
},
"addToPlaylist": {
"create": "створити $t(entity.playlist, {\"count\": 1}) {{playlist}}",
"input_playlists": "$t(entity.playlist, {\"count\": 2})",
"input_skipDuplicates": "пропустити дублікати",
"searchOrCreate": "шукайте $t(entity.playlist, {\"count\": 2}) або пишіть, щоб створити новий",
"success": "додано $t(entity.trackWithCount, {\"count\": {{message}} }) до $t(entity.playlistWithCount, {\"count\": {{numOfPlaylists}} })",
"title": "додати до $t(entity.playlist, {\"count\": 1})"
},
"createPlaylist": {
"input_description": "$t(common.description)",
"input_name": "$t(common.name)",
"input_owner": "$t(common.owner)",
"input_public": "публічний",
"success": "$t(entity.playlist, {\"count\": 1}) стрворено успішно",
"title": "створити $t(entity.playlist, {\"count\": 1})"
},
"createRadioStation": {
"success": "радіостанція створена успішно",
"title": "створити радіостанцію",
"input_homepageUrl": "адреса домашньої сторінки",
"input_name": "назва",
"input_streamUrl": "URL-адреса потоку"
},
"deletePlaylist": {
"input_confirm": "введіть ім'я $t(entity.playlist, {\"count\": 1}) для підтвердження",
"success": "$t(entity.playlist, {\"count\": 1}) успішно видалено",
"title": "видалити $t(entity.playlist, {\"count\": 1})"
},
"editPlaylist": {
"publicJellyfinNote": "Jellyfin з якоїсь причини не показує, чи є плейлист публічним чи ні. Якщо ви хочете, щоб він залишався публічним, виберіть варіант нижче",
"editNote": "ручне редагування не рекомендується для великих плейлистів. ви впевнені, що готові прийняти ризик втрати даних, який виникає при перезапису існуючого плейлисту?",
"success": "$t(entity.playlist, {\"count\": 1}) успішно оновлено",
"title": "змінити $t(entity.playlist, {\"count\": 1})"
},
"lyricsExport": {
"export": "експортувати тексти пісень",
"input_synced": "експортувати синхронізовані тексти пісень",
"input_offset": "$t(setting.lyricOffset)"
},
"lyricSearch": {
"input_artist": "$t(entity.artist, {\"count\": 1})",
"input_name": "$t(common.name)",
"title": "шукати тексти пісень"
},
"queryEditor": {
"title": "редактор запитів",
"input_optionMatchAll": "збіг за всіма",
"input_optionMatchAny": "збіг за будь-яким",
"addRuleGroup": "додати групу правил",
"removeRuleGroup": "видалити групу правил",
"resetToDefault": "скинути до заводських налаштувань",
"clearFilters": "очистити фільтри"
},
"saveQueue": {
"success": "черга відтворення збережена на сервері"
},
"shareItem": {
"allowDownloading": "дозволити завантаження",
"description": "опис",
"setExpiration": "встановити термін дії",
"success": "посилання для спільного використання скопійовано в буфер обміну (натисніть тут, щоб відкрити)",
"expireInvalid": "термін дії повинен бути в майбутньому",
"createFailed": "не вдалося створити спільний доступ (чи ввімкнено спільний доступ?)"
},
"shuffleAll": {
"title": "відтворити випадково",
"input_genre": "$t(entity.genre, {\"count\": 1})",
"input_limit": "скільки пісень?",
"input_minYear": "від року",
"input_maxYear": "до року",
"input_played": "відтворити фільтр",
"input_played_optionAll": "всі треки",
"input_played_optionUnplayed": "тільки не відтворені треки",
"input_played_optionPlayed": "тільки відтворені треки"
},
"updateServer": {
"success": "сервер успішно оновлено",
"title": "оновити сервер"
},
"privateMode": {
"enabled": "приватний режим увімкнено, стан відтворення тепер приховано від зовнішніх інтеграцій",
"disabled": "приватний режим вимкнено, стан відтворення тепер видно для увімкнених зовнішніх інтеграцій",
"title": "приватний режим"
}
},
"player": {
"skip": "пропустити"
},
"page": {
"albumArtistDetail": {
"about": "Про {{artist}}",
"appearsOn": "з'являється на",
"favoriteSongs": "улюблені пісні",
"groupingTypeAll": "всі типи випуску",
"groupingTypePrimary": "основні типи випуску",
"recentReleases": "останні випуски",
"viewDiscography": "переглянути дискографію",
"relatedArtists": "подібні $t(entity.artist, {\"count\": 2})",
"topSongs": "найкращі пісні",
"topSongsCommunity": "спільнота",
"topSongsFrom": "найкращі пісні від {{title}}",
"topSongsPersonal": "особисте",
"favoriteSongsFrom": "улюблені пісні від {{title}}",
"viewAll": "показати все",
"viewAllTracks": "показати усі $t(entity.track, {\"count\": 2})"
},
"albumArtistList": {
"title": "$t(entity.albumArtist, {\"count\": 2})"
},
"albumDetail": {
"moreFromArtist": "більше від цього $t(entity.artist, {\"count\": 1})",
"moreFromGeneric": "більше від {{item}}",
"released": "видано"
},
"albumList": {
"artistAlbums": "альбоми виконавця {{artist}}",
"genreAlbums": "\"{{genre}}\" $t(entity.album, {\"count\": 2})",
"title": "$t(entity.album, {\"count\": 2})"
},
"radioList": {
"title": "радіостанції"
},
"releasenotes": {
"commitsSinceStable": "комміти від {{stable}}",
"noNewCommits": "немає нових коммітів у цьому періоді",
"noStableReleaseToCompare": "немає доступної стабільної версії для порівняння"
},
"favorites": {
"title": "$t(entity.favorite, {\"count\": 2})"
},
"windowBar": {
"paused": "(Призупинено) ",
"privateMode": "(Приватний режим)"
},
"appMenu": {
"collapseSidebar": "згорнути бічну панель",
"commandPalette": "відкрити палітру команд",
"expandSidebar": "розгорнути бічну панель",
"goBack": "повернутися назад",
"goForward": "перейти вперед",
"manageServers": "управління серверами",
"privateModeOff": "вимкнути приватний режим",
"privateModeOn": "увімкнути приватний режим",
"openBrowserDevtools": "відкрити інструменти розробника",
"quit": "$t(common.quit)",
"selectServer": "вибрати сервер",
"selectMusicFolder": "вибрати папку з музикою",
"noMusicFolder": "не вибрано папку з музикою",
"multipleMusicFolders": "Вибрано {{count}} папок з музикою",
"settings": "$t(common.setting, {\"count\": 2})",
"version": "версія {{version}}"
},
"manageServers": {
"title": "управління серверами",
"serverDetails": "інформація про сервер",
"url": "URL-адреса",
"username": "Ім'я користувача",
"editServerDetailsTooltip": "редагувати дані сервера",
"removeServer": "видалити сервер"
},
"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": "завантажити",
"moveItems": "$t(action.moveItems)",
"moveToNext": "$t(action.moveToNext)",
"moveToBottom": "$t(action.moveToBottom)",
"moveToTop": "$t(action.moveToTop)",
"numberSelected": "{{count}} вибрано",
"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)",
"shareItem": "поділитися елементом",
"goTo": "перейти до",
"goToAlbum": "перейти до $t(entity.album, {\"count\": 1})",
"goToAlbumArtist": "перейти до $t(entity.albumArtist, {\"count\": 1})",
"showDetails": "отримати інформацію"
}
}
}
+1 -2
View File
@@ -229,7 +229,6 @@
"audioPlayer_description": "选择用于播放的音频播放器",
"globalMediaHotkeys": "全局媒体快捷键",
"gaplessAudio_description": "调整 mpv 无缝音频设置",
"disableAutomaticUpdates": "禁用自动更新",
"followLyric_description": "滚动歌词到当前播放位置",
"audioExclusiveMode": "音频独占模式",
"font": "字体",
@@ -464,7 +463,7 @@
"releaseChannel_optionLatest": "最新的",
"releaseChannel_optionBeta": "测试版",
"releaseChannel": "发布通道",
"releaseChannel_description": "选择稳定版本或测试版以进行自动更新",
"releaseChannel_description": "选择稳定版测试版或 Alpha(夜间构建版)以启用自动更新",
"mediaSession": "启用媒体会话",
"mediaSession_description": "启用媒体会话集成,在系统音量叠加层和锁屏界面显示媒体控件和元数据",
"exportImportSettings_control_description": "通过 JSON 导出和导入设置",
+143 -18
View File
@@ -296,7 +296,8 @@
"myLibrary": "我的媒體庫",
"shared": "已分享 $t(entity.playlist, {\"count\": 2})",
"favorites": "$t(entity.favorite, {\"count\": 2})",
"radio": "$t(entity.radioStation, {\"count\": 2})"
"radio": "$t(entity.radioStation, {\"count\": 2})",
"collections": "收藏"
},
"trackList": {
"title": "$t(entity.track, {\"count\": 2})",
@@ -314,7 +315,11 @@
"viewAll": "檢視所有",
"viewAllTracks": "檢視所有$t(entity.track, {\"count\": 2})",
"groupingTypeAll": "所有發佈類型",
"groupingTypePrimary": "主要發佈類型"
"groupingTypePrimary": "主要發佈類型",
"favoriteSongs": "最愛歌曲",
"favoriteSongsFrom": "{{title}} 的最愛歌曲",
"topSongsCommunity": "社群",
"topSongsPersonal": "個人"
},
"manageServers": {
"title": "管理伺服器",
@@ -342,7 +347,17 @@
"title": "電台"
},
"windowBar": {
"paused": "(暫停) "
"paused": "(暫停) ",
"privateMode": "(私人模式)"
},
"collections": {
"overrideExisting": "複寫現有的",
"saveAsCollection": "儲存為收藏"
},
"releasenotes": {
"commitsSinceStable": "提交自 {{stable}}",
"noNewCommits": "在此區間內沒有新的提交",
"noStableReleaseToCompare": "沒有穩定的發行可供比較"
}
},
"player": {
@@ -384,7 +399,16 @@
"restoreQueueFromServer": "從伺服器還原播放佇列",
"saveQueueToServer": "將播放佇列儲存至伺服器",
"artistRadio": "藝人電台",
"trackRadio": "曲目電台"
"trackRadio": "曲目電台",
"sleepTimer": "睡眠定時器",
"sleepTimer_endOfSong": "歌曲播完時",
"sleepTimer_minutes": "{{count}} 分鐘",
"sleepTimer_hours": "{{count}} 小時",
"sleepTimer_custom": "自訂",
"sleepTimer_off": "關閉",
"sleepTimer_timeRemaining": "剩餘 {{time}}",
"sleepTimer_setCustom": "設定定時器",
"sleepTimer_cancel": "取消定時器"
},
"setting": {
"audioPlayer_description": "選擇用於播放的音訊播放器",
@@ -398,7 +422,7 @@
"accentColor": "強調色",
"accentColor_description": "設定應用程式的強調色",
"applicationHotkeys": "應用程式快捷鍵",
"applicationHotkeys_description": "設定應用程式快捷鍵。切換勾選框來設為全快捷鍵(僅桌面端)",
"applicationHotkeys_description": "設定應用程式快捷鍵。切換勾選框來設為全快捷鍵(僅桌面端)",
"audioDevice": "音訊設備",
"audioDevice_description": "選擇用於播放的音訊設備(僅 web 播放器)",
"audioExclusiveMode": "音訊獨占模式",
@@ -409,7 +433,6 @@
"crossfadeStyle_description": "選擇用於音訊播放器的淡入淡出風格",
"customFontPath": "自定字體路徑",
"customFontPath_description": "設定應用程式使用的自定字體路徑",
"disableAutomaticUpdates": "禁用自動更新",
"disableLibraryUpdateOnStartup": "禁用啟動時檢查新版本",
"discordApplicationId": "{{discord}} 應用程式 id",
"discordApplicationId_description": "{{discord}} rich presence 應用程式 id(預設為 {{defaultId}}",
@@ -431,10 +454,10 @@
"gaplessAudio": "無間隔音訊",
"gaplessAudio_description": "調整 mpv 無間隔音訊設定",
"gaplessAudio_optionWeak": "弱(建議)",
"globalMediaHotkeys": "全媒體快捷鍵",
"globalMediaHotkeys": "全媒體快捷鍵",
"hotkey_browserForward": "瀏覽器往前",
"hotkey_favoritePreviousSong": "收藏 $t(common.previousSong)",
"hotkey_globalSearch": "全搜尋",
"hotkey_globalSearch": "全搜尋",
"hotkey_localSearch": "頁面內搜尋",
"hotkey_playbackNext": "下一首",
"hotkey_playbackPause": "暫停",
@@ -558,7 +581,7 @@
"contextMenu_description": "允許您隱藏在右鍵選單項目時顯示的項目。未選取的項目將被隱藏",
"customCssEnable": "啟用自訂CSS",
"customCssEnable_description": "允許撰寫自訂CSS",
"customCssNotice": "警告:雖然有一些清理措施(不允許 url() 和 content:),但使用自訂 CSS 仍然會透過更改介面帶來風險",
"customCssNotice": "警告:即使已限制某些用法(不允許 url() 和 content:),但使用自訂 CSS 仍然會透過更改介面帶來風險",
"customCss": "自訂CSS",
"customCss_description": "自訂 CSS 內容。注意:內容和遠端 URL 是不允許使用的屬性。您的內容預覽如下所示。由於需要進行清理,因此存在一些您未設定的其他欄位",
"discordPausedStatus": "暫停時顯示 rich presence",
@@ -618,7 +641,7 @@
"artistBackgroundBlur_description": "調整套用至藝人背景圖片的模糊程度",
"releaseChannel_optionLatest": "最新版本",
"releaseChannel_optionBeta": "測試版",
"releaseChannel_description": "選擇自動更新時要使用穩定版本或是測試版本",
"releaseChannel_description": "選擇自動更新時要使用穩定、測試或是 alpha (每日建構版) 版本",
"discordDisplayType": "{{discord}} presence 顯示類型",
"discordDisplayType_description": "變更您在狀態中正在聆聽的內容",
"discordDisplayType_songname": "歌曲名稱",
@@ -630,8 +653,8 @@
"hotkey_navigateHome": "導航至首頁",
"preventSleepOnPlayback": "防止播放時進入睡眠狀態",
"preventSleepOnPlayback_description": "在音樂播放時防止螢幕進入睡眠狀態",
"mediaSession": "啟用Media Session",
"mediaSession_description": "啟用 Media Session 整合功能,於系統音量Overlay和鎖定畫面中顯示媒體資料與控制面板",
"mediaSession": "啟用 Media Session",
"mediaSession_description": "啟用 Media Session 整合功能,於系統音量 Overlay 和鎖定畫面中顯示媒體資料與控制面板",
"releaseChannel": "發佈通道",
"analyticsDisable": "選擇退出使用情況分析",
"analyticsDisable_description": "經過匿名處理的使用情況資料將傳送給開發者,以協助改進應用程式",
@@ -715,7 +738,30 @@
"pathReplace_description": "替換您伺服器的預設檔案路徑",
"pathReplace_optionRemovePrefix": "移除前綴",
"pathReplace_optionAddPrefix": "增加前綴",
"sidebarPlaylistSorting": "側邊欄播放清單排序"
"sidebarPlaylistSorting": "側邊欄播放清單排序",
"homeFeatureStyle_description": "控制首頁輪播的樣式",
"homeFeatureStyle": "首頁特色輪播樣式",
"homeFeatureStyle_optionMultiple": "多重",
"homeFeatureStyle_optionSingle": "單一",
"hotkey_listPlayDefault": "清單播放",
"hotkey_listPlayLast": "清單尾端播放",
"hotkey_listPlayNext": "清單下一項播放",
"hotkey_listPlayNow": "清單立即播放",
"enableGridMultiSelect": "啟用網格多選",
"enableGridMultiSelect_description": "啟用時,允許在網格檢視中選擇多項。停用時,單擊網格項目圖片將導航到項目頁面",
"sidebarPlaylistSorting_description": "允許在側邊欄中使用拖放手動對播放清單進行排序,而不是預設的伺服器排序",
"sidebarPlaylistListFilterRegex_description": "在側邊欄中隱藏與此正規表達式匹配的播放清單",
"sidebarPlaylistListFilterRegex_placeholder": "範例: ^Daily Mix.*",
"sidebarPlaylistListFilterRegex": "播放清單過濾器正規表達式",
"blurExplicitImages": "模糊露骨圖片",
"blurExplicitImages_description": "標記為露骨的專輯和歌曲封面將被模糊",
"releaseChannel_optionAlpha": "alpha (每日建構版)",
"analyticsEnable": "傳送基於使用情況的分析報告",
"analyticsEnable_description": "匿名化的使用情況資料會傳送給開發者,以協助改進應用程式",
"automaticUpdates": "自動更新",
"automaticUpdates_description": "自動檢查並安裝更新",
"discordStateIcon": "顯示播放中圖示",
"discordStateIcon_description": "在 rich presence 狀態中顯示一個小的播放圖示。啟用「暫停時顯示 rich presence」時,會始終顯示暫停的圖示"
},
"table": {
"config": {
@@ -748,7 +794,8 @@
"alternateRowColors": "隔行上色",
"horizontalBorders": "行邊框線",
"rowHoverHighlight": "滑鼠懸停Highlight",
"verticalBorders": "列邊框線"
"verticalBorders": "列邊框線",
"showHeader": "顯示標題"
},
"label": {
"actions": "$t(common.action, {\"count\": 2})",
@@ -783,12 +830,15 @@
"genreBadge": "$t(entity.genre, {\"count\": 1}) (徽章)",
"image": "圖片",
"bitDepth": "$t(common.bitDepth)",
"sampleRate": "$t(common.sampleRate)"
"sampleRate": "$t(common.sampleRate)",
"composer": "作曲者",
"titleArtist": "$t(common.title) (藝人)"
},
"view": {
"table": "表格",
"grid": "網格",
"list": "列表"
"list": "列表",
"detail": "詳情"
}
},
"column": {
@@ -926,7 +976,8 @@
"title": "標題",
"toYear": "從年份",
"trackNumber": "曲目",
"explicitStatus": "$t(common.explicitStatus)"
"explicitStatus": "$t(common.explicitStatus)",
"sortName": "排序名稱"
},
"form": {
"addServer": {
@@ -1168,6 +1219,80 @@
"gravity": "重力",
"peakFadeTime": "峰值淡出時間 (毫秒)",
"peakHoldTime": "峰值停留時間 (毫秒)",
"radialSpectrum": "圓形頻譜"
"radialSpectrum": "圓形頻譜",
"level": "層級",
"pasteGradient": "貼上漸層",
"pasteGradientPlaceholder": "在這裡貼上漸層JSON...",
"radial": "放射",
"radialInvert": "反轉放射",
"spinSpeed": "旋轉速度",
"radius": "半徑",
"reflexMirror": "反射鏡像",
"reflexFit": "反射貼齊",
"reflexRatio": "反射比例",
"reflexAlpha": "反射 Alpha",
"reflexBrightness": "反射亮度",
"mirror": "鏡像",
"miscellaneousSettings": "雜項設定",
"alphaBars": "Alpha 條",
"ansiBands": "ASNI 波段",
"ledBars": "LED 條",
"trueLeds": "真實 LED",
"lumiBars": "輝光條",
"outlineBars": "外框條",
"roundBars": "圓角條",
"lowResolution": "低解析",
"splitGradient": "分割漸層",
"showFPS": "顯示 FPS",
"showScaleX": "顯示 X 軸比例",
"noteLabels": "音符標籤",
"showScaleY": "顯示Y軸比例",
"options": {
"mode": {
"0": "[0] 離散頻率",
"1": "[1] 1/24th 八度音 / 240 頻段",
"2": "[2] 1/12th 八度音 / 120 頻段",
"3": "[3] 1/8th 八度音 / 80 頻段",
"4": "[4] 1/6th 八度音 / 60 頻段",
"5": "[5] 1/4th 八度音 / 40 頻段",
"6": "[6] 1/3rd 八度音 / 30 頻段",
"7": "[7] 一半八度音 / 20 頻段",
"8": "[8] 完整八度音 / 10 頻段",
"10": "[10] 線 / 區域圖表"
},
"colorMode": {
"gradient": "梯度",
"barIndex": "條-指數",
"barLevel": "條-高度"
},
"gradient": {
"classic": "經典",
"prism": "菱鏡",
"rainbow": "彩虹",
"steelblue": "鋼藍",
"orangered": "橙紅色"
},
"channelLayout": {
"single": "單一",
"dualCombined": "雙重-合併",
"dualHorizontal": "雙重-水平",
"dualVertical": "雙重-垂直"
},
"frequencyScale": {
"none": "無",
"bark": "比例刻度",
"linear": "線性比例",
"log": "Log 比例",
"mel": "Mel 比例"
},
"weightingFilter": {
"none": "無",
"a": "A",
"b": "B",
"c": "C",
"d": "D",
"z": "Z"
}
}
}
}
+227 -28
View File
@@ -1,3 +1,5 @@
import type { UpdateCheckResult } from 'electron-updater';
import { is } from '@electron-toolkit/utils';
import {
app,
@@ -18,9 +20,10 @@ import {
} from 'electron';
import electronLocalShortcut from 'electron-localshortcut';
import log from 'electron-log/main';
import { autoUpdater } from 'electron-updater';
import { AppImageUpdater, autoUpdater, MacUpdater, NsisUpdater } from 'electron-updater';
import { access, constants } from 'fs';
import path, { join } from 'path';
import semver from 'semver';
import packageJson from '../../package.json';
import { disableMediaKeys, enableMediaKeys } from './features/core/player/media-keys';
@@ -40,40 +43,195 @@ import './features';
import { PlayerType, TitleTheme } from '/@/shared/types/types';
export default class AppUpdater {
const ALPHA_UPDATER_CONFIG: {
bucket: string;
channel: string;
endpoint: string;
provider: 's3';
} = {
bucket: '',
channel: 'alpha',
endpoint: 'https://feishin-nightly-bucket.jeffvli.org',
provider: 's3',
};
const GITHUB_UPDATER_CONFIG = {
owner: 'jeffvli',
provider: 'github' as const,
repo: 'feishin',
};
type UpdaterInstance = AppImageUpdater | MacUpdater | NsisUpdater | typeof autoUpdater;
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;
const effectiveChannel = store.get('release_channel') as string;
console.log('Effective update channel:', effectiveChannel);
if (effectiveChannel === 'alpha') {
checkAllChannelsAndGetBest().then(({ updater: updaterInstance }) => {
updaterInstance.autoInstallOnAppQuit = true;
updaterInstance.autoRunAppAfterInstall = true;
updaterInstance.checkForUpdatesAndNotify();
});
return;
}
configureAndGetUpdater();
autoUpdater.checkForUpdatesAndNotify();
}
}
// When release channel is alpha, check alpha and latest for updates and return
// the updater + result for the newest version found (so alpha users can receive
// latest updates when they are newer than the current alpha).
async function checkAllChannelsAndGetBest(): Promise<{
result: null | UpdateCheckResult;
updater: UpdaterInstance;
}> {
const currentVersion = packageJson.version;
const candidates: Array<{
channel: 'alpha' | 'beta' | 'latest';
result: UpdateCheckResult;
updater: UpdaterInstance;
}> = [];
const alphaUpdater = createAlphaUpdaterInstance();
alphaUpdater.logger = autoUpdaterLogInterface;
alphaUpdater.channel = ALPHA_UPDATER_CONFIG.channel;
alphaUpdater.allowPrerelease = true;
alphaUpdater.disableDifferentialDownload = true;
alphaUpdater.allowDowngrade = true;
try {
console.log('Checking for updates on alpha channel');
const alphaResult = await alphaUpdater.checkForUpdates();
if (
alphaResult?.updateInfo?.version &&
alphaResult.isUpdateAvailable &&
semver.valid(alphaResult.updateInfo.version) &&
semver.gt(alphaResult.updateInfo.version, currentVersion)
) {
candidates.push({ channel: 'alpha', result: alphaResult, updater: alphaUpdater });
}
} catch (e) {
log.warn('Alpha channel check failed', e);
}
try {
autoUpdater.setFeedURL(GITHUB_UPDATER_CONFIG);
configureAutoUpdaterForChannel('latest');
console.log('Checking for updates on latest channel (GitHub)');
const latestResult = await autoUpdater.checkForUpdates();
if (
latestResult?.updateInfo?.version &&
latestResult.isUpdateAvailable &&
semver.valid(latestResult.updateInfo.version) &&
semver.gt(latestResult.updateInfo.version, currentVersion)
) {
candidates.push({ channel: 'latest', result: latestResult, updater: autoUpdater });
}
} catch (e) {
log.warn('Latest channel check failed', e);
}
if (candidates.length === 0) {
return { result: null, updater: alphaUpdater };
}
const best = candidates.reduce((a, b) =>
semver.gt(a.result.updateInfo.version, b.result.updateInfo.version) ? a : b,
);
if (best.channel === 'latest') {
configureAutoUpdaterForChannel('latest');
}
return { result: best.result, updater: best.updater };
}
function configureAndGetUpdater(): UpdaterInstance {
const isBetaVersion = packageJson.version.includes('-beta');
const isAlphaVersion = packageJson.version.includes('-alpha');
let releaseChannel = store.get('release_channel');
const isNotConfigured = !releaseChannel;
console.log('Release channel:', releaseChannel);
console.log('Is beta version:', isBetaVersion);
console.log('Is alpha version:', isAlphaVersion);
console.log('Is not configured:', isNotConfigured);
if (isNotConfigured) {
console.log('Release channel not configured, setting default channel');
const defaultChannel = isAlphaVersion ? 'alpha' : isBetaVersion ? 'beta' : 'latest';
store.set('release_channel', defaultChannel);
releaseChannel = defaultChannel;
}
const effectiveChannel = store.get('release_channel') as string;
if (effectiveChannel === 'alpha') {
const updater = createAlphaUpdaterInstance();
log.transports.file.level = 'info';
updater.logger = autoUpdaterLogInterface;
updater.channel = ALPHA_UPDATER_CONFIG.channel;
updater.allowPrerelease = true;
updater.disableDifferentialDownload = true;
updater.allowDowngrade = true;
updater.autoInstallOnAppQuit = true;
updater.autoRunAppAfterInstall = true;
return updater;
}
log.transports.file.level = 'info';
autoUpdater.logger = autoUpdaterLogInterface;
autoUpdater.autoInstallOnAppQuit = true;
autoUpdater.autoRunAppAfterInstall = true;
if (effectiveChannel === 'beta') {
autoUpdater.channel = 'beta';
autoUpdater.allowDowngrade = true;
autoUpdater.allowPrerelease = true;
autoUpdater.disableDifferentialDownload = true;
} else {
autoUpdater.channel = 'latest';
autoUpdater.allowPrerelease = false;
}
return autoUpdater;
}
/**
* Configures the global autoUpdater for a specific GitHub channel (beta or latest).
* Used when checking multiple channels or when the winning channel is beta/latest.
*/
function configureAutoUpdaterForChannel(channel: 'beta' | 'latest'): void {
log.transports.file.level = 'info';
autoUpdater.logger = autoUpdaterLogInterface;
autoUpdater.autoInstallOnAppQuit = true;
autoUpdater.autoRunAppAfterInstall = true;
if (channel === 'beta') {
autoUpdater.channel = 'beta';
autoUpdater.allowDowngrade = true;
autoUpdater.allowPrerelease = true;
autoUpdater.disableDifferentialDownload = true;
} else {
autoUpdater.channel = 'latest';
autoUpdater.allowPrerelease = false;
}
}
function createAlphaUpdaterInstance(): AppImageUpdater | MacUpdater | NsisUpdater {
if (isMacOS()) {
return new MacUpdater(ALPHA_UPDATER_CONFIG);
}
if (isLinux()) {
return new AppImageUpdater(ALPHA_UPDATER_CONFIG);
}
return new NsisUpdater(ALPHA_UPDATER_CONFIG);
}
protocol.registerSchemesAsPrivileged([{ privileges: { bypassCSP: true }, scheme: 'feishin' }]);
process.on('uncaughtException', (error: any) => {
@@ -359,6 +517,47 @@ async function createWindow(first = true): Promise<void> {
return mainWindow?.webContents.session.clearCache();
});
ipcMain.handle(
'app-check-for-updates',
async (): Promise<{ updateAvailable: boolean; version?: string }> => {
if (disableAutoUpdates()) {
console.log('Auto updates are disabled');
return { updateAvailable: false };
}
try {
console.log('Checking for updates');
const effectiveChannel = store.get('release_channel') as string;
let result: null | UpdateCheckResult;
let updater: UpdaterInstance;
if (effectiveChannel === 'alpha') {
const best = await checkAllChannelsAndGetBest();
result = best.result;
updater = best.updater;
} else {
updater = configureAndGetUpdater();
result = await updater.checkForUpdates();
}
const updateAvailable = result?.isUpdateAvailable ?? false;
console.log('Update available:', updateAvailable);
if (updateAvailable && store.get('disable_auto_updates') !== true) {
console.log('Downloading update');
updater.downloadUpdate();
}
return {
updateAvailable,
version: result?.updateInfo?.version,
};
} catch {
console.log('Error checking for updates');
return { updateAvailable: false };
}
},
);
ipcMain.on('app-restart', () => {
// Fix for .AppImage
if (process.env.APPIMAGE) {
+18 -2
View File
@@ -21,6 +21,14 @@ export default class MenuBuilder {
selector: 'orderFrontStandardAboutPanel:',
},
{ type: 'separator' },
{
accelerator: 'Command+,',
click: () => {
this.mainWindow.webContents.send('renderer-open-settings');
},
label: 'Settings',
},
{ type: 'separator' },
{ label: 'Services', submenu: [] },
{ type: 'separator' },
{
@@ -151,8 +159,8 @@ export default class MenuBuilder {
return [subMenuAbout, subMenuEdit, subMenuView, subMenuWindow, subMenuHelp];
}
buildDefaultTemplate() {
const templateDefault = [
buildDefaultTemplate(): MenuItemConstructorOptions[] {
const templateDefault: MenuItemConstructorOptions[] = [
{
label: '&File',
submenu: [
@@ -160,6 +168,14 @@ export default class MenuBuilder {
accelerator: 'Ctrl+O',
label: '&Open',
},
{
accelerator: 'Ctrl+,',
click: () => {
this.mainWindow.webContents.send('renderer-open-settings');
},
label: '&Settings...',
},
{ type: 'separator' },
{
accelerator: 'Ctrl+W',
click: () => {
+10
View File
@@ -39,6 +39,10 @@ const download = (url: string) => {
ipcRenderer.send('download-url', url);
};
const checkForUpdates = (): Promise<{ updateAvailable: boolean; version?: string }> => {
return ipcRenderer.invoke('app-check-for-updates');
};
const forceGarbageCollection = (): boolean => {
try {
if (typeof global.gc === 'function') {
@@ -57,7 +61,12 @@ const forceGarbageCollection = (): boolean => {
}
};
const rendererOpenSettings = (cb: (event: IpcRendererEvent) => void) => {
ipcRenderer.on('renderer-open-settings', cb);
};
export const utils = {
checkForUpdates,
disableAutoUpdates,
download,
forceGarbageCollection,
@@ -69,6 +78,7 @@ export const utils = {
openApplicationDirectory,
openItem,
playerErrorListener,
rendererOpenSettings,
};
export type Utils = typeof utils;
+1 -1
View File
@@ -137,7 +137,7 @@ export const contract = c.router({
},
getInstantMix: {
method: 'GET',
path: 'songs/:itemId/InstantMix',
path: 'items/:itemId/InstantMix',
query: jfType._parameters.similarSongs,
responses: {
200: jfType._response.songList,
@@ -38,6 +38,10 @@ const formatCommaDelimitedString = (value: string[]) => {
// not the POST body
const MAX_ITEMS_PER_PLAYLIST_ADD = 50;
// Defining a re-usable Collator instance for performance reasons.
const numericSortCollator = new Intl.Collator(undefined, { numeric: true });
const collator = new Intl.Collator();
const VERSION_INFO: VersionInfo = [
[
'10.9.0',
@@ -49,6 +53,18 @@ const VERSION_INFO: VersionInfo = [
['10.0.0', { [ServerFeature.TAGS]: [1] }],
];
const JF_FIELDS = {
ALBUM_ARTIST_DETAIL: 'Genres, Overview, SortName, ProviderIds',
ALBUM_ARTIST_LIST: 'Genres, DateCreated, ExternalUrls, Overview, SortName, ProviderIds',
ALBUM_DETAIL: 'Genres, DateCreated, ChildCount, People, Tags, ProviderIds',
ALBUM_LIST: 'People, Tags, Studios, SortName, UserData, ProviderIds, ChildCount',
FOLDER: 'Genres, DateCreated, MediaSources, UserData, ParentId',
GENRE: 'ItemCounts',
PLAYLIST_DETAIL: 'Genres, DateCreated, MediaSources, ChildCount, ParentId, SortName',
PLAYLIST_LIST: 'ChildCount, Genres, DateCreated, ParentId, Overview',
SONG: 'Genres, DateCreated, MediaSources, ParentId, People, Tags, SortName, UserData, ProviderIds',
} as const;
export const JellyfinController: InternalControllerEndpoint = {
addToPlaylist: async (args) => {
const { apiClientProps, body, query } = args;
@@ -226,7 +242,7 @@ export const JellyfinController: InternalControllerEndpoint = {
userId: apiClientProps.server?.userId,
},
query: {
Fields: 'Genres, Overview, SortName',
Fields: JF_FIELDS.ALBUM_ARTIST_DETAIL,
},
}),
jfApiClient(apiClientProps).getSimilarArtistList({
@@ -253,7 +269,7 @@ export const JellyfinController: InternalControllerEndpoint = {
const res = await jfApiClient(apiClientProps).getAlbumArtistList({
query: {
Fields: 'Genres, DateCreated, ExternalUrls, Overview, SortName',
Fields: JF_FIELDS.ALBUM_ARTIST_LIST,
ImageTypeLimit: 1,
Limit: query.limit,
ParentId: getLibraryId(query.musicFolderId),
@@ -296,7 +312,7 @@ export const JellyfinController: InternalControllerEndpoint = {
userId: apiClientProps.server.userId,
},
query: {
Fields: 'Genres, DateCreated, ChildCount, People, Tags',
Fields: JF_FIELDS.ALBUM_DETAIL,
},
});
@@ -305,7 +321,7 @@ export const JellyfinController: InternalControllerEndpoint = {
userId: apiClientProps.server.userId,
},
query: {
Fields: 'Genres, DateCreated, MediaSources, ParentId, People, Tags, SortName',
Fields: JF_FIELDS.SONG,
IncludeItemTypes: 'Audio',
ParentId: query.id,
SortBy: 'ParentIndexNumber,IndexNumber,SortName',
@@ -363,7 +379,7 @@ export const JellyfinController: InternalControllerEndpoint = {
},
query: {
...artistQuery,
Fields: 'People, Tags, Studios, SortName',
Fields: JF_FIELDS.ALBUM_LIST,
GenreIds: query.genreIds ? query.genreIds.join(',') : undefined,
IncludeItemTypes: 'MusicAlbum',
IsFavorite: query.favorite,
@@ -399,7 +415,7 @@ export const JellyfinController: InternalControllerEndpoint = {
const res = await jfApiClient(apiClientProps).getArtistList({
query: {
Fields: 'Genres, DateCreated, ExternalUrls, Overview, SortName',
Fields: JF_FIELDS.ALBUM_ARTIST_LIST,
ImageTypeLimit: 1,
Limit: query.limit,
ParentId: getLibraryId(query.musicFolderId),
@@ -438,7 +454,7 @@ export const JellyfinController: InternalControllerEndpoint = {
itemId: query.artistId,
},
query: {
Fields: 'Genres, DateCreated, MediaSources, ParentId, SortName',
Fields: JF_FIELDS.SONG,
Limit: query.count,
UserId: apiClientProps.server?.userId || undefined,
},
@@ -563,7 +579,7 @@ export const JellyfinController: InternalControllerEndpoint = {
userId,
},
query: {
Fields: 'Genres, DateCreated, MediaSources, UserData, ParentId',
Fields: JF_FIELDS.FOLDER,
ParentId: query.id,
SortBy: query.sortBy
? (songListSortMap.jellyfin[query.sortBy] as string) || 'SortName'
@@ -592,7 +608,7 @@ export const JellyfinController: InternalControllerEndpoint = {
userId,
},
query: {
Fields: 'Genres, DateCreated, MediaSources, UserData, ParentId',
Fields: JF_FIELDS.FOLDER,
ParentId: parentId,
},
});
@@ -679,7 +695,7 @@ export const JellyfinController: InternalControllerEndpoint = {
const res = await jfApiClient(apiClientProps).getGenreList({
query: {
EnableTotalRecordCount: true,
Fields: 'ItemCounts',
Fields: JF_FIELDS.GENRE,
Limit: query.limit === -1 ? undefined : query.limit,
ParentId: getLibraryId(query.musicFolderId),
Recursive: true,
@@ -794,7 +810,7 @@ export const JellyfinController: InternalControllerEndpoint = {
userId: apiClientProps.server?.userId,
},
query: {
Fields: 'Genres, DateCreated, MediaSources, ChildCount, ParentId, SortName',
Fields: JF_FIELDS.PLAYLIST_DETAIL,
Ids: query.id,
},
});
@@ -817,7 +833,7 @@ export const JellyfinController: InternalControllerEndpoint = {
userId: apiClientProps.server?.userId,
},
query: {
Fields: 'ChildCount, Genres, DateCreated, ParentId, Overview',
Fields: JF_FIELDS.PLAYLIST_LIST,
IncludeItemTypes: 'Playlist',
Limit: query.limit,
Recursive: true,
@@ -855,7 +871,7 @@ export const JellyfinController: InternalControllerEndpoint = {
id: query.id,
},
query: {
Fields: 'Genres, DateCreated, MediaSources, UserData, ParentId, People, Tags, SortName',
Fields: JF_FIELDS.SONG,
IncludeItemTypes: 'Audio',
UserId: apiClientProps.server?.userId,
},
@@ -902,7 +918,7 @@ export const JellyfinController: InternalControllerEndpoint = {
userId: apiClientProps.server?.userId,
},
query: {
Fields: 'Genres, DateCreated, MediaSources, ParentId, People, Tags, SortName',
Fields: JF_FIELDS.SONG,
GenreIds: query.genre ? query.genre : undefined,
IncludeItemTypes: 'Audio',
IsPlayed:
@@ -974,7 +990,7 @@ export const JellyfinController: InternalControllerEndpoint = {
itemId: query.songId,
},
query: {
Fields: 'Genres, DateCreated, MediaSources, ParentId, SortName',
Fields: JF_FIELDS.SONG,
Limit: query.count,
UserId: apiClientProps.server?.userId || undefined,
},
@@ -1007,7 +1023,7 @@ export const JellyfinController: InternalControllerEndpoint = {
itemId: query.songId,
},
query: {
Fields: 'Genres, DateCreated, MediaSources, ParentId, SortName',
Fields: JF_FIELDS.SONG,
Limit: query.count,
UserId: apiClientProps.server?.userId || undefined,
},
@@ -1092,11 +1108,11 @@ export const JellyfinController: InternalControllerEndpoint = {
query: {
AlbumIds: albumIdsFilter,
ArtistIds: artistIdsFilter,
Fields: 'Genres, DateCreated, MediaSources, ParentId, People, Tags, SortName',
Fields: JF_FIELDS.SONG,
GenreIds: query.genreIds?.join(','),
IncludeItemTypes: 'Audio',
IsFavorite: query.favorite,
Limit: query.limit,
Limit: query.limit === -1 ? undefined : query.limit,
ParentId: getLibraryId(query.musicFolderId),
Recursive: true,
SearchTerm: query.searchTerm,
@@ -1127,11 +1143,11 @@ export const JellyfinController: InternalControllerEndpoint = {
query: {
AlbumIds: albumIdsFilter,
ArtistIds: artistIdsFilter,
Fields: 'Genres, DateCreated, MediaSources, ParentId, People, Tags, SortName',
Fields: JF_FIELDS.SONG,
GenreIds: query.genreIds?.join(','),
IncludeItemTypes: 'Audio',
IsFavorite: query.favorite,
Limit: query.limit,
Limit: query.limit === -1 ? undefined : query.limit,
ParentId: getLibraryId(query.musicFolderId),
Recursive: true,
SearchTerm: query.searchTerm,
@@ -1250,11 +1266,12 @@ export const JellyfinController: InternalControllerEndpoint = {
if (res.body.Tags?.length) {
tags.push({
name: 'Tags',
options: res.body.Tags.sort((a, b) =>
a
.toLocaleLowerCase()
.localeCompare(b.toLocaleLowerCase(), undefined, { numeric: true }),
).map((tag) => ({ id: tag, name: tag })),
options: res.body.Tags.sort((a, b) => {
return numericSortCollator.compare(
a.toLocaleLowerCase(),
b.toLocaleLowerCase(),
);
}).map((tag) => ({ id: tag, name: tag })),
});
}
@@ -1262,7 +1279,7 @@ export const JellyfinController: InternalControllerEndpoint = {
tags.push({
name: 'Studios',
options: studioRes.body.Items.sort((a, b) =>
a.Name.toLocaleLowerCase().localeCompare(b.Name.toLocaleLowerCase()),
collator.compare(a.Name.toLocaleLowerCase(), b.Name.toLocaleLowerCase()),
).map((option) => ({ id: option.Name, name: option.Name })),
});
}
@@ -1276,17 +1293,22 @@ export const JellyfinController: InternalControllerEndpoint = {
throw new Error('No userId found');
}
const type = query.type === 'personal' ? 'personal' : 'community';
const res = await jfApiClient(apiClientProps).getTopSongsList({
params: {
userId: apiClientProps.server?.userId,
},
query: {
ArtistIds: query.artistId,
Fields: 'Genres, DateCreated, MediaSources, ParentId, SortName',
Fields: JF_FIELDS.SONG,
IncludeItemTypes: 'Audio',
Limit: query.limit,
Recursive: true,
SortBy: 'CommunityRating,SortName',
SortBy:
type === 'personal'
? JFSongListSort.PLAY_COUNT
: JFSongListSort.COMMUNITY_RATING,
SortOrder: 'Descending',
UserId: apiClientProps.server?.userId,
},
@@ -1296,15 +1318,31 @@ export const JellyfinController: InternalControllerEndpoint = {
throw new Error('Failed to get top song list');
}
return {
items: res.body.Items.map((item) =>
jfNormalize.song(
item,
apiClientProps.server,
args.context?.pathReplace,
args.context?.pathReplaceWith,
),
const items = res.body.Items.map((item) =>
jfNormalize.song(
item,
apiClientProps.server,
args.context?.pathReplace,
args.context?.pathReplaceWith,
),
);
if (type === 'personal') {
const sorted = orderBy(
items,
['playCount', 'albumId', 'trackNumber'],
['desc', 'asc', 'asc'],
);
return {
items: sorted,
startIndex: 0,
totalRecordCount: res.body.TotalRecordCount,
};
}
return {
items,
startIndex: 0,
totalRecordCount: res.body.TotalRecordCount,
};
@@ -1378,7 +1416,7 @@ export const JellyfinController: InternalControllerEndpoint = {
id: query.id,
},
query: {
Fields: 'Genres, DateCreated, MediaSources, UserData, ParentId, People, Tags, SortName',
Fields: JF_FIELDS.SONG,
IncludeItemTypes: 'Audio',
UserId: apiClientProps.server?.userId,
},
@@ -1404,7 +1442,7 @@ export const JellyfinController: InternalControllerEndpoint = {
userId: apiClientProps.server?.userId,
},
query: {
Fields: 'Genres, DateCreated, MediaSources, ChildCount, ParentId, SortName',
Fields: JF_FIELDS.PLAYLIST_DETAIL,
Ids: query.id,
},
});
@@ -1562,7 +1600,7 @@ export const JellyfinController: InternalControllerEndpoint = {
},
query: {
EnableTotalRecordCount: true,
Fields: 'People, Tags, SortName',
Fields: JF_FIELDS.ALBUM_LIST,
ImageTypeLimit: 1,
IncludeItemTypes: 'MusicAlbum',
Limit: query.albumLimit,
@@ -1585,7 +1623,7 @@ export const JellyfinController: InternalControllerEndpoint = {
const res = await jfApiClient(apiClientProps).getAlbumArtistList({
query: {
EnableTotalRecordCount: true,
Fields: 'Genres, DateCreated, ExternalUrls, Overview',
Fields: JF_FIELDS.ALBUM_ARTIST_LIST,
ImageTypeLimit: 1,
IncludeArtists: true,
Limit: query.albumArtistLimit,
@@ -1610,7 +1648,7 @@ export const JellyfinController: InternalControllerEndpoint = {
},
query: {
EnableTotalRecordCount: true,
Fields: 'Genres, DateCreated, MediaSources, ParentId, People, Tags, SortName',
Fields: JF_FIELDS.SONG,
IncludeItemTypes: 'Audio',
Limit: query.songLimit,
Recursive: true,
@@ -1,4 +1,5 @@
import { set } from 'idb-keyval';
import orderBy from 'lodash/orderBy';
import { ndApiClient } from '/@/renderer/api/navidrome/navidrome-api';
import { ssApiClient } from '/@/renderer/api/subsonic/subsonic-api';
@@ -17,7 +18,9 @@ import {
PlaylistSongListArgs,
PlaylistSongListResponse,
ServerListItemWithCredential,
SongListSort,
songListSortMap,
SortOrder,
sortOrderMap,
tagListSortMap,
userListSortMap,
@@ -71,6 +74,10 @@ const EXCLUDED_ALBUM_TAGS = new Set<string>([
const EXCLUDED_SONG_TAGS = new Set<string>(['disctotal', 'tracktotal']);
// Defining a re-usable Collator instance for performance reasons.
const numericSortCollator = new Intl.Collator(undefined, { numeric: true });
const collator = new Intl.Collator();
// Tags that use IDs as values as opposed to the tag value
const ID_TAGS = new Set<string>(['albumversion', 'mood']);
@@ -780,16 +787,17 @@ export const NavidromeController: InternalControllerEndpoint = {
.map((data) => ({
name: data[0],
options: data[1]
.sort((a, b) =>
a.name
.toLocaleLowerCase()
.localeCompare(b.name.toLocaleLowerCase(), undefined, {
numeric: true,
}),
)
.sort((a, b) => {
return numericSortCollator.compare(
a.name.toLocaleLowerCase(),
b.name.toLocaleLowerCase(),
);
})
.map((option) => ({ id: option.id, name: option.name })),
}))
.sort((a, b) => a.name.toLocaleLowerCase().localeCompare(b.name.toLocaleLowerCase()));
.sort((a, b) =>
collator.compare(a.name.toLocaleLowerCase(), b.name.toLocaleLowerCase()),
);
const excludedAlbumTags = Array.from(EXCLUDED_ALBUM_TAGS.values());
const excludedSongTags = Array.from(EXCLUDED_SONG_TAGS.values());
@@ -802,7 +810,59 @@ export const NavidromeController: InternalControllerEndpoint = {
tags,
};
},
getTopSongs: SubsonicController.getTopSongs,
getTopSongs: async (args) => {
const { apiClientProps, query } = args;
const type = query.type === 'personal' ? 'personal' : 'community';
if (type === 'community') {
const res = await ssApiClient(apiClientProps).getTopSongsList({
query: {
artist: query.artist,
count: query.limit,
},
});
if (res.status !== 200) {
throw new Error('Failed to get top songs');
}
return {
items: (res.body.topSongs?.song || []).map((song) =>
ssNormalize.song(
song,
apiClientProps.server,
args.context?.pathReplace,
args.context?.pathReplaceWith,
),
),
startIndex: 0,
totalRecordCount: res.body.topSongs?.song?.length || 0,
};
}
const res = await NavidromeController.getSongList({
apiClientProps,
query: {
artistIds: [query.artistId],
sortBy: SongListSort.PLAY_COUNT,
sortOrder: SortOrder.DESC,
startIndex: 0,
},
});
const songsWithPlayCount = orderBy(
res.items.filter((song) => song.playCount > 0),
['playCount', 'albumId', 'trackNumber'],
['desc', 'asc', 'asc'],
);
return {
items: songsWithPlayCount,
startIndex: 0,
totalRecordCount: res.totalRecordCount,
};
},
getUserInfo: SubsonicController.getUserInfo,
getUserList: async (args) => {
const { apiClientProps, query } = args;
+7
View File
@@ -73,6 +73,13 @@ export const queryKeys: Record<
return [serverId, 'albumArtists', 'detail'] as const;
},
favoriteSongs: (serverId: string, artistId?: string) => {
if (artistId) {
return [serverId, 'albumArtists', 'favoriteSongs', artistId] as const;
}
return [serverId, 'albumArtists', 'favoriteSongs'] as const;
},
infiniteList: (serverId: string, query?: AlbumArtistListQuery) => {
const { filter, pagination } = splitPaginatedQuery(query);
if (query && pagination) {
@@ -38,6 +38,7 @@ const ALBUM_LIST_SORT_MAPPING: Record<AlbumListSort, AlbumListSortType | undefin
[AlbumListSort.DURATION]: undefined,
[AlbumListSort.EXPLICIT_STATUS]: undefined,
[AlbumListSort.FAVORITED]: AlbumListSortType.STARRED,
[AlbumListSort.ID]: undefined,
[AlbumListSort.NAME]: AlbumListSortType.ALPHABETICAL_BY_NAME,
[AlbumListSort.PLAY_COUNT]: AlbumListSortType.FREQUENT,
[AlbumListSort.RANDOM]: AlbumListSortType.RANDOM,
@@ -1361,7 +1362,7 @@ export const SubsonicController: InternalControllerEndpoint = {
throw new Error('Failed to get song list');
}
const allResults =
let allResults =
(res.body.starred?.song || []).map((song) =>
ssNormalize.song(
song,
@@ -1371,6 +1372,15 @@ export const SubsonicController: InternalControllerEndpoint = {
),
) || [];
const filterArtistIds = query.albumArtistIds || query.artistIds;
if (filterArtistIds?.length) {
const idSet = new Set(filterArtistIds);
allResults = allResults.filter((song) =>
song.albumArtists?.some((aa) => idSet.has(aa.id)),
);
}
return sortAndPaginate(allResults, {
limit: query.limit,
sortBy: query.sortBy,
@@ -1794,29 +1804,54 @@ export const SubsonicController: InternalControllerEndpoint = {
getTopSongs: async (args) => {
const { apiClientProps, context, query } = args;
const res = await ssApiClient(apiClientProps).getTopSongsList({
query: {
artist: query.artist,
count: query.limit,
},
});
const type = query.type === 'personal' ? 'personal' : 'community';
if (res.status !== 200) {
throw new Error('Failed to get top songs');
}
if (type === 'community') {
const res = await ssApiClient(apiClientProps).getTopSongsList({
query: {
artist: query.artist,
count: query.limit,
},
});
return {
items:
res.body.topSongs?.song?.map((song) =>
if (res.status !== 200) {
throw new Error('Failed to get top songs');
}
return {
items: (res.body.topSongs?.song || []).map((song) =>
ssNormalize.song(
song,
apiClientProps.server,
context?.pathReplace,
context?.pathReplaceWith,
),
) || [],
),
startIndex: 0,
totalRecordCount: res.body.topSongs?.song?.length || 0,
};
}
const res = await SubsonicController.getSongList({
apiClientProps,
query: {
artistIds: [query.artistId],
sortBy: SongListSort.PLAY_COUNT,
sortOrder: SortOrder.DESC,
startIndex: 0,
},
});
const songsWithPlayCount = orderBy(
res.items.filter((song) => song.playCount > 0),
['playCount', 'albumId', 'trackNumber'],
['desc', 'asc', 'asc'],
);
return {
items: songsWithPlayCount,
startIndex: 0,
totalRecordCount: res.body.topSongs?.song?.length || 0,
totalRecordCount: res.totalRecordCount,
};
},
getUserInfo: async (args) => {
+5 -1
View File
@@ -62,7 +62,11 @@ export const getOptimizedListCount = async <
query: pageQuery,
});
client.setQueryData(pageQueryKey, pageResult);
const keyContainsRandom = JSON.stringify(pageQueryKey).toLowerCase().includes('random');
if (!keyContainsRandom) {
client.setQueryData(pageQueryKey, pageResult);
}
return pageResult.totalRecordCount ?? 0;
};
+16
View File
@@ -10,7 +10,9 @@ import isElectron from 'is-electron';
import { lazy, Suspense, useEffect, useMemo, useRef, useState } from 'react';
import i18n from '/@/i18n/i18n';
import { openSettingsModal } from '/@/renderer/features/settings/utils/open-settings-modal';
import { WebAudioContext } from '/@/renderer/features/player/context/webaudio-context';
import { useCheckForUpdates } from '/@/renderer/hooks/use-check-for-updates';
import { useSyncSettingsToMain } from '/@/renderer/hooks/use-sync-settings-to-main';
import { AppRouter } from '/@/renderer/router/app-router';
import { useCssSettings, useHotkeySettings, useLanguage } from '/@/renderer/store';
@@ -38,6 +40,7 @@ export const App = () => {
const cssRef = useRef<HTMLStyleElement | null>(null);
useSyncSettingsToMain();
useCheckForUpdates();
const [webAudio, setWebAudio] = useState<WebAudio>();
@@ -77,6 +80,19 @@ export const App = () => {
}
}, [language]);
useEffect(() => {
if (isElectron()) {
window.api.utils.rendererOpenSettings(() => {
openSettingsModal();
});
return () => {
ipc?.removeAllListeners('renderer-open-settings');
};
}
return undefined;
}, []);
const notificationStyles = useMemo(
() => ({
root: {
@@ -121,6 +121,7 @@ const CarouselItem = ({ album }: CarouselItemProps) => {
containerClassName={styles.albumImageContainer}
enableDebounce={false}
enableViewport={false}
explicitStatus={album.explicitStatus}
fetchPriority="high"
id={album.imageId}
itemType={LibraryItem.ALBUM}
@@ -118,6 +118,7 @@ const CarouselItem = ({ album }: CarouselItemProps) => {
containerClassName={styles.albumImageContainer}
enableDebounce={false}
enableViewport={false}
explicitStatus={album.explicitStatus}
fetchPriority="high"
id={album.imageId}
itemType={LibraryItem.ALBUM}
@@ -34,6 +34,7 @@
position: absolute;
top: 0;
left: 0;
z-index: 5;
width: 100%;
height: 100%;
pointer-events: none;
@@ -28,6 +28,7 @@ import {
formatRating,
} from '/@/renderer/utils/format';
import { SEPARATOR_STRING } from '/@/shared/api/utils';
import { ExplicitIndicator } from '/@/shared/components/explicit-indicator/explicit-indicator';
import { Group } from '/@/shared/components/group/group';
import { Icon } from '/@/shared/components/icon/icon';
import { Separator } from '/@/shared/components/separator/separator';
@@ -361,6 +362,9 @@ const CompactItemCard = ({
[styles.isRound]: isRound,
})}
enableDebounce={false}
explicitStatus={
'explicitStatus' in data && data ? data.explicitStatus : null
}
id={data?.imageId}
itemType={itemType}
src={(data as Album | AlbumArtist | Playlist | Song)?.imageUrl}
@@ -595,6 +599,9 @@ const DefaultItemCard = ({
<ItemImage
className={clsx(styles.image, { [styles.isRound]: isRound })}
enableDebounce={false}
explicitStatus={
'explicitStatus' in data && data ? data.explicitStatus : null
}
id={data?.imageId}
itemType={itemType}
src={(data as Album | AlbumArtist | Playlist | Song)?.imageUrl}
@@ -892,6 +899,9 @@ const PosterItemCard = ({
<ItemImage
className={clsx(styles.image, { [styles.isRound]: isRound })}
enableDebounce={false}
explicitStatus={
'explicitStatus' in data && data ? data.explicitStatus : null
}
id={(data as { imageId: string })?.imageId}
itemType={itemType}
src={(data as { imageUrl: string })?.imageUrl}
@@ -1010,6 +1020,7 @@ export const getDataRows = (type?: 'compact' | 'default' | 'poster'): DataRow[]
return [
{
format: (data) => {
const explicitStatus = 'explicitStatus' in data ? data.explicitStatus : null;
if ('name' in data && data.name) {
if ('id' in data && data.id) {
if ('_itemType' in data) {
@@ -1022,6 +1033,7 @@ export const getDataRows = (type?: 'compact' | 'default' | 'poster'): DataRow[]
albumId: data.id,
})}
>
<ExplicitIndicator explicitStatus={explicitStatus} />
{data.name}
</Link>
);
@@ -1036,6 +1048,7 @@ export const getDataRows = (type?: 'compact' | 'default' | 'poster'): DataRow[]
},
)}
>
<ExplicitIndicator explicitStatus={explicitStatus} />
{data.name}
</Link>
);
@@ -1062,11 +1075,21 @@ export const getDataRows = (type?: 'compact' | 'default' | 'poster'): DataRow[]
</Link>
);
default:
return data.name;
return (
<>
<ExplicitIndicator explicitStatus={explicitStatus} />
{data.name}
</>
);
}
}
}
return data.name;
return (
<>
<ExplicitIndicator explicitStatus={explicitStatus} />
{data.name}
</>
);
}
return '';
},
@@ -1,84 +0,0 @@
.container {
display: grid;
grid-template-rows: 1fr;
grid-template-columns: auto minmax(0, 1fr);
gap: var(--theme-spacing-sm);
width: 100%;
height: 100%;
padding: var(--theme-spacing-sm);
container-type: inline-size;
background: var(--theme-colors-surface);
border-radius: var(--theme-radius-md);
@container (min-width: 500px) {
grid-template-columns: minmax(0, 1fr);
}
}
.image-container {
position: relative;
display: none;
height: 100%;
min-height: 0;
aspect-ratio: 1/1;
&::before {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
content: '';
background-color: rgb(0 0 0);
opacity: 0;
transition: all 0.2s ease-in-out;
}
&:hover {
&::before {
opacity: 0.6;
}
}
@container (min-width: 500px) {
display: block;
}
}
.image {
aspect-ratio: 1/1;
}
.metadata-container {
display: flex;
flex-direction: column;
gap: var(--theme-spacing-sm);
width: 100%;
height: 100%;
padding: var(--theme-spacing-xs) 0;
overflow: hidden;
}
.metadata-container .header {
display: flex;
align-items: center;
justify-content: space-between;
font-weight: 600;
line-height: 1.2;
}
.metadata-container .header .title {
max-width: 70%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.metadata-container .content {
display: flex;
flex-direction: column;
gap: var(--theme-spacing-xs);
}
.metadata-container .content .tags {
}
@@ -1,146 +0,0 @@
// import { AnimatePresence } from 'motion/react';
// import { MouseEvent, useMemo, useState } from 'react';
// import { Link } from 'react-router';
// import styles from './item-detail.module.css';
// import { ItemCardControls } from '/@/renderer/components/item-card/item-card-controls';
// import { useFastAverageColor } from '/@/renderer/hooks';
// import { ActionIcon } from '/@/shared/components/action-icon/action-icon';
// import { Badge } from '/@/shared/components/badge/badge';
// import { Divider } from '/@/shared/components/divider/divider';
// import { Group } from '/@/shared/components/group/group';
// import { Image } from '/@/shared/components/image/image';
// import { Rating } from '/@/shared/components/rating/rating';
// import { Text } from '/@/shared/components/text/text';
// import {
// Album,
// AlbumArtist,
// Artist,
// LibraryItem,
// Playlist,
// Song,
// } from '/@/shared/types/domain-types';
// import { stringToColor } from '/@/shared/utils/string-to-color';
// interface ItemDetailProps {
// data: Album | AlbumArtist | Artist | Playlist | Song | undefined;
// itemHeight: number;
// itemType: LibraryItem;
// onClick?: (e: MouseEvent<HTMLDivElement>, item: unknown, itemType: LibraryItem) => void;
// withControls?: boolean;
// }
// export const ItemDetail = ({ data, itemType, onClick, withControls }: ItemDetailProps) => {
// const imageUrl = getImageUrl(data);
// const [showControls, setShowControls] = useState(false);
// const { background } = useFastAverageColor({
// algorithm: 'simple',
// src: imageUrl,
// srcLoaded: false,
// });
// // const tags = [...(data?.genres ?? [])];
// const tags = useMemo(() => {
// if (!data) {
// return [];
// }
// const items: {
// color?: string;
// id: string;
// isLight?: boolean;
// itemType: LibraryItem;
// name: string;
// }[] = [];
// if ('albumArtists' in data && Array.isArray(data.albumArtists)) {
// data.albumArtists?.forEach((tag: { id: string; name: string }) => {
// items.push({ id: tag.id, itemType: LibraryItem.ALBUM_ARTIST, name: tag.name });
// });
// }
// if ('genres' in data && Array.isArray(data.genres)) {
// data.genres?.forEach((tag: { id: string; itemType: LibraryItem; name: string }) => {
// const { color, isLight } = stringToColor(tag.name);
// items.push({ ...tag, color, isLight });
// });
// }
// // if ('tags' in data && typeof data.tags === 'object') {
// // console.log('data.tags :>> ', data.tags);
// // Object.entries(data.tags).forEach(([key, value]) => {
// // items.push({ id: key, itemType: LibraryItem.TAG, name: value });
// // });
// // }
// return items;
// }, [data]);
// return (
// <div
// className={styles.container}
// onClick={(e) => onClick?.(e, data, itemType)}
// style={{ backgroundColor: background }}
// >
// <div
// className={styles.imageContainer}
// onMouseEnter={() => withControls && setShowControls(true)}
// onMouseLeave={() => withControls && setShowControls(false)}
// >
// <Image alt={data?.name} src={imageUrl} />
// <AnimatePresence>
// {withControls && showControls && <ItemCardControls type="compact" />}
// </AnimatePresence>
// </div>
// <div className={styles.metadataContainer}>
// <div className={styles.header}>
// <Text className={styles.title} component={Link} isLink size="lg" weight={500}>
// {data?.name}
// </Text>
// <Group>
// {data && 'userRating' in data && (
// <Rating size="xs" value={data?.userRating ?? 0} />
// )}
// {data && 'userFavorite' in data && (
// <ActionIcon
// icon="favorite"
// iconProps={{
// fill: data?.userFavorite ? 'primary' : 'default',
// }}
// size="xs"
// />
// )}
// </Group>
// </div>
// <Divider />
// <div className={styles.content}>
// <Group className={styles.tags} gap="xs">
// {tags.map((tag) => (
// <Badge
// key={tag.id}
// style={{
// backgroundColor: tag.color,
// color: tag.isLight ? 'black' : 'white',
// }}
// >
// {tag.name}
// </Badge>
// ))}
// </Group>
// </div>
// </div>
// </div>
// );
// };
// const getImageUrl = (data: Album | AlbumArtist | Artist | Playlist | Song | undefined) => {
// if (data && 'imageUrl' in data) {
// return data.imageUrl || undefined;
// }
// return undefined;
// };
@@ -7,11 +7,12 @@ import {
getServerById,
useAuthStore,
useCurrentServerId,
useGeneralSettings,
useImageRes,
useSettingsStore,
} from '/@/renderer/store';
import { BaseImage, ImageProps } from '/@/shared/components/image/image';
import { LibraryItem } from '/@/shared/types/domain-types';
import { ExplicitStatus, LibraryItem } from '/@/shared/types/domain-types';
const getUnloaderIcon = (itemType: LibraryItem) => {
switch (itemType) {
@@ -34,6 +35,7 @@ const getUnloaderIcon = (itemType: LibraryItem) => {
const BaseItemImage = (
props: Omit<ImageProps, 'id' | 'src'> & {
explicitStatus?: ExplicitStatus | null;
id?: null | string;
itemType: LibraryItem;
serverId?: null | string;
@@ -41,7 +43,8 @@ const BaseItemImage = (
type?: keyof z.infer<typeof GeneralSettingsSchema>['imageRes'];
},
) => {
const { serverId, src, ...rest } = props;
const { explicitStatus, serverId, src, ...rest } = props;
const { blurExplicitImages } = useGeneralSettings();
const imageUrl = useItemImageUrl({
id: props.id,
@@ -51,8 +54,11 @@ const BaseItemImage = (
type: props.type,
});
const isExplicit = blurExplicitImages && explicitStatus === ExplicitStatus.EXPLICIT;
return (
<BaseImage
isExplicit={isExplicit}
src={imageUrl}
unloaderIcon={getUnloaderIcon(props.itemType)}
{...rest}
@@ -192,9 +192,10 @@ export const useDefaultItemListControls = (args?: UseDefaultItemListControlsArgs
onColumnReordered?.(columnIdFrom, columnIdTo, edge);
},
onColumnResized: ({ columnId, width }: { columnId: TableColumn; width: number }) => {
onColumnResized?.(columnId, width);
},
onColumnResized: onColumnResized
? ({ columnId, width }: { columnId: TableColumn; width: number }) =>
onColumnResized(columnId, width)
: undefined,
onDoubleClick: ({ internalState, item, itemType, meta }: DefaultItemControlProps) => {
if (!item || !internalState) {
@@ -241,11 +242,11 @@ export const useDefaultItemListControls = (args?: UseDefaultItemListControlsArgs
}
const playType = (meta?.playType as Play) || Play.NOW;
const singleSongOnly = meta?.singleSongOnly === true;
// For NEXT, LAST, NEXT_SHUFFLE, and LAST_SHUFFLE, only add the clicked song
// For NOW and SHUFFLE, add a range of songs around the clicked song
let songsToAdd: Song[];
if (
singleSongOnly ||
playType === Play.NEXT ||
playType === Play.LAST ||
playType === Play.NEXT_SHUFFLE ||
@@ -1,4 +1,5 @@
import {
useMutation,
useQuery,
useQueryClient,
useSuspenseQuery,
@@ -11,6 +12,7 @@ import { queryKeys } from '/@/renderer/api/query-keys';
import { useListContext } from '/@/renderer/context/list-context';
import { eventEmitter } from '/@/renderer/events/event-emitter';
import { UserFavoriteEventPayload, UserRatingEventPayload } from '/@/renderer/events/events';
import { getListRefreshMutationKey } from '/@/renderer/features/shared/components/list-refresh-button';
import { LibraryItem } from '/@/shared/types/domain-types';
export const getListQueryKeyName = (itemType: LibraryItem): string => {
@@ -293,10 +295,10 @@ export const useItemListInfiniteLoader = ({
[onRangeChangedBase],
);
const refresh = useCallback(
async (force?: boolean) => {
const refreshMutation = useMutation({
mutationFn: async (force?: boolean) => {
// Invalidate all queries to ensure fresh data
await queryClient.invalidateQueries();
queryClient.invalidateQueries();
// Reset the infinite list data
const currentData = queryClient.getQueryData<{
@@ -320,7 +322,7 @@ export const useItemListInfiniteLoader = ({
}
// Add a delay to make the refresh visually clear
await new Promise((resolve) => setTimeout(resolve, 150));
// await new Promise((resolve) => setTimeout(resolve, 150));
// Determine which page to refetch based on current visible range
let pageToFetch = 0;
@@ -344,7 +346,12 @@ export const useItemListInfiniteLoader = ({
stopIndex,
});
},
[queryClient, itemsPerPage, onRangeChangedBase, dataQueryKey, totalItemCount, fetchPage],
mutationKey: getListRefreshMutationKey(eventKey),
});
const refresh = useCallback(
async (force?: boolean) => refreshMutation.mutateAsync(force),
[refreshMutation],
);
const updateItems = useCallback(
@@ -376,7 +383,7 @@ export const useItemListInfiniteLoader = ({
return;
}
return refresh(true);
refreshMutation.mutate(true);
};
eventEmitter.on('ITEM_LIST_REFRESH', handleRefresh);
@@ -384,7 +391,7 @@ export const useItemListInfiniteLoader = ({
return () => {
eventEmitter.off('ITEM_LIST_REFRESH', handleRefresh);
};
}, [eventKey, refresh]);
}, [eventKey, refreshMutation]);
useEffect(() => {
const handleFavorite = (payload: UserFavoriteEventPayload) => {
@@ -1,4 +1,5 @@
import {
useMutation,
useQuery,
useQueryClient,
useSuspenseQuery,
@@ -10,6 +11,7 @@ import { queryKeys } from '/@/renderer/api/query-keys';
import { useListContext } from '/@/renderer/context/list-context';
import { eventEmitter } from '/@/renderer/events/event-emitter';
import { UserFavoriteEventPayload, UserRatingEventPayload } from '/@/renderer/events/events';
import { getListRefreshMutationKey } from '/@/renderer/features/shared/components/list-refresh-button';
import { LibraryItem } from '/@/shared/types/domain-types';
const getQueryKeyName = (itemType: LibraryItem): string => {
@@ -83,7 +85,7 @@ export const useItemListPaginatedLoader = ({
[itemsPerPage, startIndex, query],
);
const { data, refetch: queryRefetch } = useQuery({
const { data } = useQuery({
gcTime: 1000 * 15,
placeholderData: { items: getInitialData(itemsPerPage) },
queryFn: async ({ signal }) => {
@@ -98,22 +100,20 @@ export const useItemListPaginatedLoader = ({
staleTime: 1000 * 15,
});
const refresh = useCallback(
async (force?: boolean) => {
const refreshMutation = useMutation({
mutationFn: async (force?: boolean) => {
const queryKey = queryKeys[getQueryKeyName(itemType)].list(serverId, queryParams);
await queryClient.invalidateQueries();
if (force) {
queryClient.setQueryData(queryKey, {
items: getInitialData(itemsPerPage),
});
}
return queryRefetch();
await queryClient.invalidateQueries();
},
[queryClient, queryRefetch, queryParams, serverId, itemType, itemsPerPage],
);
mutationKey: getListRefreshMutationKey(eventKey ?? 'paginated'),
});
const updateItems = useCallback(
(indexes: number[], value: object) => {
@@ -153,7 +153,7 @@ export const useItemListPaginatedLoader = ({
return;
}
return refresh(true);
refreshMutation.mutate(true);
};
const handleFavorite = (payload: UserFavoriteEventPayload) => {
@@ -220,7 +220,7 @@ export const useItemListPaginatedLoader = ({
eventEmitter.off('USER_FAVORITE', handleFavorite);
eventEmitter.off('USER_RATING', handleRating);
};
}, [data, eventKey, itemType, serverId, refresh, updateItems]);
}, [data, eventKey, itemType, refreshMutation, serverId, updateItems]);
return { data: data?.items || [], pageCount, totalItemCount };
};
@@ -7,14 +7,19 @@ import { ItemListKey, TableColumn } from '/@/shared/types/types';
interface UseItemListColumnReorderProps {
itemListKey: ItemListKey;
tableKey?: 'detail' | 'main';
}
export const useItemListColumnReorder = ({ itemListKey }: UseItemListColumnReorderProps) => {
export const useItemListColumnReorder = ({
itemListKey,
tableKey = 'main',
}: UseItemListColumnReorderProps) => {
const { setList } = useSettingsStoreActions();
const handleColumnReordered = useCallback(
(columnIdFrom: TableColumn, columnIdTo: TableColumn, edge: Edge | null) => {
const columns = useSettingsStore.getState().lists[itemListKey]?.table.columns;
const list = useSettingsStore.getState().lists[itemListKey];
const columns = tableKey === 'detail' ? list?.detail?.columns : list?.table?.columns;
if (!columns) {
return;
@@ -83,13 +88,20 @@ export const useItemListColumnReorder = ({ itemListKey }: UseItemListColumnReord
// Insert the column at the new position
newColumns.splice(newIndex, 0, updatedMovedColumn);
setList(itemListKey, {
table: {
columns: newColumns,
},
});
if (tableKey === 'detail') {
type SetListData = Parameters<
ReturnType<typeof useSettingsStoreActions>['setList']
>[1];
setList(itemListKey, { detail: { columns: newColumns } } as SetListData);
} else {
setList(itemListKey, {
table: {
columns: newColumns,
},
});
}
},
[itemListKey, setList],
[itemListKey, setList, tableKey],
);
return { handleColumnReordered };
@@ -5,11 +5,18 @@ import { ItemListKey, TableColumn } from '/@/shared/types/types';
interface UseItemListColumnResizeProps {
itemListKey: ItemListKey;
tableKey?: 'detail' | 'main';
}
export const useItemListColumnResize = ({ itemListKey }: UseItemListColumnResizeProps) => {
export const useItemListColumnResize = ({
itemListKey,
tableKey = 'main',
}: UseItemListColumnResizeProps) => {
const { setList } = useSettingsStoreActions();
const columns = useSettingsStore((state) => state.lists[itemListKey]?.table.columns);
const columns = useSettingsStore((state) => {
const list = state.lists[itemListKey];
return tableKey === 'detail' ? list?.detail?.columns : list?.table?.columns;
});
const handleColumnResized = useCallback(
(columnId: TableColumn, width: number) => {
@@ -19,13 +26,20 @@ export const useItemListColumnResize = ({ itemListKey }: UseItemListColumnResize
column.id === columnId ? { ...column, width } : column,
);
setList(itemListKey, {
table: {
columns: updatedColumns,
},
});
if (tableKey === 'detail') {
type SetListData = Parameters<
ReturnType<typeof useSettingsStoreActions>['setList']
>[1];
setList(itemListKey, { detail: { columns: updatedColumns } } as SetListData);
} else {
setList(itemListKey, {
table: {
columns: updatedColumns,
},
});
}
},
[columns, itemListKey, setList],
[columns, itemListKey, setList, tableKey],
);
return { handleColumnResized };
@@ -1,26 +1,29 @@
import { useCallback, useMemo } from 'react';
import { useSearchParams } from 'react-router';
import { useLocation, useNavigationType } from 'react-router';
import { parseIntParam, setSearchParam } from '/@/renderer/utils/query-params';
import { useScrollStore } from '/@/renderer/store/scroll.store';
interface UseItemListScrollPersistProps {
enabled: boolean;
}
export const useItemListScrollPersist = ({ enabled }: UseItemListScrollPersistProps) => {
const [searchParams, setSearchParams] = useSearchParams();
const location = useLocation();
const navigationType = useNavigationType();
const setOffset = useScrollStore((s) => s.setOffset);
const getOffset = useScrollStore((s) => s.getOffset);
const scrollOffset = useMemo(() => parseIntParam(searchParams, 'scrollOffset'), [searchParams]);
const scrollOffset = useMemo(() => {
if (navigationType !== 'POP') return undefined;
return getOffset(location.key);
}, [getOffset, location.key, navigationType]);
const handleOnScrollEnd = useCallback(
(offset: number) => {
if (!enabled) return;
setSearchParams((prev) => setSearchParam(prev, 'scrollOffset', offset), {
replace: true,
});
setOffset(location.key, offset);
},
[enabled, setSearchParams],
[enabled, location.key, setOffset],
);
return { handleOnScrollEnd, scrollOffset };
@@ -0,0 +1,38 @@
import { ItemDetailListCellProps } from './types';
import { ActionIcon } from '/@/shared/components/action-icon/action-icon';
import { LibraryItem } from '/@/shared/types/domain-types';
export const ActionsColumn = ({ controls, internalState, song }: ItemDetailListCellProps) => {
const handleClick = (event: React.MouseEvent<HTMLButtonElement>) => {
event.stopPropagation();
event.preventDefault();
const index = internalState?.findItemIndex(song.id) ?? -1;
controls?.onMore?.({
event,
index,
internalState: internalState ?? undefined,
item: song,
itemType: LibraryItem.SONG,
});
};
const handleDoubleClick = (event: React.MouseEvent<HTMLButtonElement>) => {
event.stopPropagation();
event.preventDefault();
};
return (
<ActionIcon
icon="ellipsisHorizontal"
iconProps={{
color: 'muted',
size: 'xs',
}}
onClick={handleClick}
onDoubleClick={handleDoubleClick}
size="xs"
variant="subtle"
/>
);
};
@@ -0,0 +1,23 @@
import { ItemDetailListCellProps } from './types';
import {
JOINED_ARTISTS_MUTED_PROPS,
JoinedArtists,
} from '/@/renderer/features/albums/components/joined-artists';
export const AlbumArtistColumn = ({ isRowHovered, song }: ItemDetailListCellProps) => {
const name = song.albumArtistName?.trim() ?? '';
const hasArtists = name.length > 0 || (song.albumArtists?.length ?? 0) > 0;
if (!hasArtists) return <>&nbsp;</>;
return (
<JoinedArtists
artistName={song.albumArtistName ?? ''}
artists={song.albumArtists ?? []}
linkProps={JOINED_ARTISTS_MUTED_PROPS.linkProps}
readOnly={!isRowHovered}
rootTextProps={JOINED_ARTISTS_MUTED_PROPS.rootTextProps}
/>
);
};
@@ -0,0 +1,3 @@
import { ItemDetailListCellProps } from './types';
export const AlbumColumn = ({ song }: ItemDetailListCellProps) => song.album ?? <>&nbsp;</>;
@@ -0,0 +1,23 @@
import { ItemDetailListCellProps } from './types';
import {
JOINED_ARTISTS_MUTED_PROPS,
JoinedArtists,
} from '/@/renderer/features/albums/components/joined-artists';
export const ArtistColumn = ({ isRowHovered, song }: ItemDetailListCellProps) => {
const name = song.artistName?.trim() ?? '';
const hasArtists = name.length > 0 || (song.artists?.length ?? 0) > 0;
if (!hasArtists) return <>&nbsp;</>;
return (
<JoinedArtists
artistName={song.artistName ?? ''}
artists={song.artists ?? []}
linkProps={JOINED_ARTISTS_MUTED_PROPS.linkProps}
readOnly={!isRowHovered}
rootTextProps={JOINED_ARTISTS_MUTED_PROPS.rootTextProps}
/>
);
};
@@ -0,0 +1,3 @@
import { ItemDetailListCellProps } from './types';
export const BitDepthColumn = ({ song }: ItemDetailListCellProps) => song.bitDepth;
@@ -0,0 +1,4 @@
import { ItemDetailListCellProps } from './types';
export const BitRateColumn = ({ song }: ItemDetailListCellProps) =>
song.bitRate != null ? `${song.bitRate} kbps` : <>&nbsp;</>;
@@ -0,0 +1,3 @@
import { ItemDetailListCellProps } from './types';
export const BpmColumn = ({ song }: ItemDetailListCellProps) => song.bpm ?? <>&nbsp;</>;
@@ -0,0 +1,4 @@
import { ItemDetailListCellProps } from './types';
export const ChannelsColumn = ({ song }: ItemDetailListCellProps) =>
song.channels != null ? String(song.channels) : <>&nbsp;</>;
@@ -0,0 +1,3 @@
import { ItemDetailListCellProps } from './types';
export const CodecColumn = ({ song }: ItemDetailListCellProps) => song.container ?? <>&nbsp;</>;
@@ -0,0 +1,3 @@
import { ItemDetailListCellProps } from './types';
export const CommentColumn = ({ song }: ItemDetailListCellProps) => song.comment ?? <>&nbsp;</>;
@@ -0,0 +1,7 @@
import { ItemDetailListCellProps } from './types';
export const ComposerColumn = ({ song }: ItemDetailListCellProps) => {
const composers = song.participants?.composer;
if (!composers?.length) return <>&nbsp;</>;
return composers.map((a) => a.name).join(', ');
};
@@ -0,0 +1,6 @@
import { ItemDetailListCellProps } from './types';
import { formatDateAbsolute } from '/@/renderer/utils/format';
export const DateAddedColumn = ({ song }: ItemDetailListCellProps) =>
song.createdAt ? formatDateAbsolute(song.createdAt) : <>&nbsp;</>;
@@ -0,0 +1,11 @@
import { ItemDetailListCellProps } from './types';
interface DefaultColumnProps extends ItemDetailListCellProps {
columnId: string;
}
export const DefaultColumn = ({ columnId, song }: DefaultColumnProps) => {
const raw = (song as Record<string, unknown>)[columnId];
if (raw === undefined || raw === null || typeof raw === 'object') return <>&nbsp;</>;
return String(raw);
};
@@ -0,0 +1,3 @@
import { ItemDetailListCellProps } from './types';
export const DiscNumberColumn = ({ song }: ItemDetailListCellProps) => String(song.discNumber ?? 1);
@@ -0,0 +1,5 @@
import formatDuration from 'format-duration';
import { ItemDetailListCellProps } from './types';
export const DurationColumn = ({ song }: ItemDetailListCellProps) => formatDuration(song.duration);
@@ -0,0 +1,54 @@
import { ItemDetailListCellProps } from './types';
import { useIsMutatingCreateFavorite } from '/@/renderer/features/shared/mutations/create-favorite-mutation';
import { useIsMutatingDeleteFavorite } from '/@/renderer/features/shared/mutations/delete-favorite-mutation';
import { ActionIcon } from '/@/shared/components/action-icon/action-icon';
import { LibraryItem } from '/@/shared/types/domain-types';
export const FavoriteColumn = ({
controls,
internalState,
isMutatingFavorite,
onFavoriteClick,
song,
}: ItemDetailListCellProps) => {
const isMutatingCreateFavorite = useIsMutatingCreateFavorite();
const isMutatingDeleteFavorite = useIsMutatingDeleteFavorite();
const isMutating = isMutatingFavorite ?? (isMutatingCreateFavorite || isMutatingDeleteFavorite);
const isFavorite = song.userFavorite ?? false;
return (
<ActionIcon
disabled={isMutating}
icon="favorite"
iconProps={{
color: isFavorite ? 'primary' : 'muted',
fill: isFavorite ? 'primary' : undefined,
size: 'xs',
}}
onClick={(event) => {
event.stopPropagation();
event.preventDefault();
const index = internalState?.findItemIndex(song.id) ?? -1;
if (controls?.onFavorite) {
controls.onFavorite({
event,
favorite: !isFavorite,
index,
internalState: internalState ?? undefined,
item: song,
itemType: LibraryItem.SONG,
});
} else {
onFavoriteClick?.(song);
}
}}
onDoubleClick={(event) => {
event.stopPropagation();
event.preventDefault();
}}
size="xs"
variant="subtle"
/>
);
};
@@ -0,0 +1,12 @@
.group {
flex-wrap: nowrap;
gap: var(--theme-spacing-sm) var(--theme-spacing-xs);
min-width: 0;
padding: var(--theme-spacing-xs) 0;
overflow: hidden;
}
.group a {
cursor: pointer;
user-select: none;
}
@@ -0,0 +1,46 @@
import { useMemo } from 'react';
import { generatePath, Link } from 'react-router';
import styles from './genre-badge-column.module.css';
import { ItemDetailListCellProps } from './types';
import { AppRoute } from '/@/renderer/router/routes';
import { Badge } from '/@/shared/components/badge/badge';
import { Group } from '/@/shared/components/group/group';
import { stringToColor } from '/@/shared/utils/string-to-color';
const MAX_GENRES = 4;
export const GenreBadgeColumn = ({ song }: ItemDetailListCellProps) => {
const genres = song.genres;
const genresWithStyle = useMemo(() => {
if (!genres) return [];
return genres.slice(0, MAX_GENRES).map((genre) => {
const { color, isLight } = stringToColor(genre.name);
const path = generatePath(AppRoute.LIBRARY_GENRES_DETAIL, { genreId: genre.id });
return { ...genre, color, isLight, path };
});
}, [genres]);
if (!genresWithStyle.length) return <>&nbsp;</>;
return (
<Group className={styles.group} wrap="nowrap">
{genresWithStyle.map((genre) => (
<Badge
component={Link}
key={genre.id}
state={{ item: genre }}
style={{
backgroundColor: genre.color,
color: genre.isLight ? 'black' : 'white',
}}
to={genre.path}
>
{genre.name}
</Badge>
))}
</Group>
);
};
@@ -0,0 +1,40 @@
import { Fragment } from 'react';
import { generatePath, Link } from 'react-router';
import { ItemDetailListCellProps } from '/@/renderer/components/item-list/item-detail-list/columns/types';
import { AppRoute } from '/@/renderer/router/routes';
import { Text } from '/@/shared/components/text/text';
const TEXT_PROPS = { isMuted: true, isNoSelect: true, size: 'sm' as const } as const;
export const GenreColumn = ({ isRowHovered, song }: ItemDetailListCellProps) => {
const genres = song.genres ?? [];
if (!genres.length) return <>&nbsp;</>;
return (
<>
{genres.map((genre, index) => (
<Fragment key={genre.id}>
{isRowHovered ? (
<Text
component={Link}
isLink
state={{ item: genre }}
to={generatePath(AppRoute.LIBRARY_GENRES_DETAIL, {
genreId: genre.id,
})}
{...TEXT_PROPS}
>
{genre.name}
</Text>
) : (
<Text component="span" {...TEXT_PROPS}>
{genre.name}
</Text>
)}
{index < genres.length - 1 && ', '}
</Fragment>
))}
</>
);
};
@@ -0,0 +1,50 @@
.image-container {
position: relative;
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
}
.compact-container {
flex: 1 1 0;
width: 100%;
min-width: 0;
height: 100%;
min-height: 0;
max-height: 100%;
aspect-ratio: unset;
padding-top: var(--theme-spacing-xs);
padding-bottom: var(--theme-spacing-xs);
overflow: hidden;
border-radius: var(--theme-radius-md);
}
.play-button-overlay {
position: absolute;
top: 50%;
left: 50%;
z-index: 10;
opacity: 0.6;
transform: translate(-50%, -50%);
transition: opacity 0.2s ease-in-out;
}
.play-button-overlay:hover {
opacity: 1;
}
.play-button-overlay button {
width: 24px;
height: 24px;
}
.compact-image {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
object-position: center;
border-radius: var(--theme-radius-md);
}
@@ -0,0 +1,71 @@
import clsx from 'clsx';
import { useState } from 'react';
import styles from './image-column.module.css';
import { ItemDetailListCellProps } from './types';
import { ItemImage } from '/@/renderer/components/item-image/item-image';
import { PlayButton } from '/@/renderer/features/shared/components/play-button';
import {
LONG_PRESS_PLAY_BEHAVIOR,
PlayTooltip,
} from '/@/renderer/features/shared/components/play-button-group';
import { usePlayButtonBehavior } from '/@/renderer/store';
import { LibraryItem } from '/@/shared/types/domain-types';
import { Play } from '/@/shared/types/types';
export const ImageColumn = ({
controls,
internalState,
rowIndex = 0,
song,
}: ItemDetailListCellProps) => {
const playButtonBehavior = usePlayButtonBehavior();
const [isHovered, setIsHovered] = useState(false);
const handlePlay = (playType: Play) => {
if (!song || !controls?.onDoubleClick) {
return;
}
controls.onDoubleClick({
event: null,
index: rowIndex,
internalState,
item: song,
itemType: LibraryItem.SONG,
meta: { playType, singleSongOnly: true },
});
};
return (
<div
className={styles.imageContainer}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
>
<ItemImage
className={styles.compactImage}
containerClassName={styles.compactContainer}
explicitStatus={song.explicitStatus}
id={song.imageId}
itemType={LibraryItem.SONG}
serverId={song._serverId}
type="table"
/>
{isHovered && (
<div className={clsx(styles.playButtonOverlay)}>
<PlayTooltip disabled={false} type={playButtonBehavior}>
<PlayButton
fill
onClick={() => handlePlay(playButtonBehavior)}
onLongPress={() =>
handlePlay(LONG_PRESS_PLAY_BEHAVIOR[playButtonBehavior])
}
/>
</PlayTooltip>
</div>
)}
</div>
);
};
@@ -0,0 +1,127 @@
import React, { type ReactNode } from 'react';
import type { ItemDetailListCellProps } from './types';
import { ActionsColumn } from './actions-column';
import { AlbumArtistColumn } from './album-artist-column';
import { AlbumColumn } from './album-column';
import { ArtistColumn } from './artist-column';
import { BitDepthColumn } from './bit-depth-column';
import { BitRateColumn } from './bit-rate-column';
import { BpmColumn } from './bpm-column';
import { ChannelsColumn } from './channels-column';
import { CodecColumn } from './codec-column';
import { CommentColumn } from './comment-column';
import { ComposerColumn } from './composer-column';
import { DateAddedColumn } from './date-added-column';
import { DefaultColumn } from './default-column';
import { DiscNumberColumn } from './disc-number-column';
import { DurationColumn } from './duration-column';
import { FavoriteColumn } from './favorite-column';
import { GenreBadgeColumn } from './genre-badge-column';
import { GenreColumn } from './genre-column';
import { ImageColumn } from './image-column';
import { LastPlayedColumn } from './last-played-column';
import { PathColumn } from './path-column';
import { PlayCountColumn } from './play-count-column';
import { RatingColumn } from './rating-column';
import { ReleaseDateColumn } from './release-date-column';
import { RowIndexColumn } from './row-index-column';
import { SampleRateColumn } from './sample-rate-column';
import { SizeColumn } from './size-column';
import { TitleArtistColumn } from './title-artist-column';
import { TitleColumn } from './title-column';
import { TitleCombinedColumn } from './title-combined-column';
import { TrackNumberColumn } from './track-number-column';
import { YearColumn } from './year-column';
import { TableColumn } from '/@/shared/types/types';
type CellComponent = (props: ItemDetailListCellProps) => ReactNode;
const COLUMN_MAP: Partial<Record<TableColumn, CellComponent>> = {
[TableColumn.ACTIONS]: ActionsColumn,
[TableColumn.ALBUM]: AlbumColumn,
[TableColumn.ALBUM_ARTIST]: AlbumArtistColumn,
[TableColumn.ARTIST]: ArtistColumn,
[TableColumn.BIT_DEPTH]: BitDepthColumn,
[TableColumn.BIT_RATE]: BitRateColumn,
[TableColumn.BPM]: BpmColumn,
[TableColumn.CHANNELS]: ChannelsColumn,
[TableColumn.CODEC]: CodecColumn,
[TableColumn.COMMENT]: CommentColumn,
[TableColumn.COMPOSER]: ComposerColumn,
[TableColumn.DATE_ADDED]: DateAddedColumn,
[TableColumn.DISC_NUMBER]: DiscNumberColumn,
[TableColumn.DURATION]: DurationColumn,
[TableColumn.GENRE]: GenreColumn,
[TableColumn.GENRE_BADGE]: GenreBadgeColumn,
[TableColumn.IMAGE]: ImageColumn,
[TableColumn.LAST_PLAYED]: LastPlayedColumn,
[TableColumn.PATH]: PathColumn,
[TableColumn.PLAY_COUNT]: PlayCountColumn,
[TableColumn.RELEASE_DATE]: ReleaseDateColumn,
[TableColumn.ROW_INDEX]: RowIndexColumn,
[TableColumn.SAMPLE_RATE]: SampleRateColumn,
[TableColumn.SIZE]: SizeColumn,
[TableColumn.TITLE]: TitleColumn,
[TableColumn.TITLE_ARTIST]: TitleArtistColumn,
[TableColumn.TITLE_COMBINED]: TitleCombinedColumn,
[TableColumn.TRACK_NUMBER]: TrackNumberColumn,
[TableColumn.USER_FAVORITE]: FavoriteColumn,
[TableColumn.USER_RATING]: RatingColumn,
[TableColumn.YEAR]: YearColumn,
};
export type DetailListCellComponentProps = ItemDetailListCellProps & { columnId?: string };
export function getDetailListCellComponent(
columnId: string | TableColumn,
): (props: DetailListCellComponentProps) => ReactNode {
const Component = COLUMN_MAP[columnId as TableColumn];
if (Component) {
return Component as (props: DetailListCellComponentProps) => ReactNode;
}
return (props: DetailListCellComponentProps) =>
React.createElement(DefaultColumn, {
columnId: props.columnId ?? (columnId as string),
song: props.song,
});
}
export type { ItemDetailListCellProps } from './types';
export {
ActionsColumn,
AlbumArtistColumn,
AlbumColumn,
ArtistColumn,
BitDepthColumn,
BitRateColumn,
BpmColumn,
ChannelsColumn,
CodecColumn,
CommentColumn,
ComposerColumn,
DateAddedColumn,
DefaultColumn,
DiscNumberColumn,
DurationColumn,
FavoriteColumn,
GenreBadgeColumn,
GenreColumn,
ImageColumn,
LastPlayedColumn,
PathColumn,
PlayCountColumn,
RatingColumn,
ReleaseDateColumn,
RowIndexColumn,
SampleRateColumn,
SizeColumn,
TitleArtistColumn,
TitleColumn,
TitleCombinedColumn,
TrackNumberColumn,
YearColumn,
};
@@ -0,0 +1,6 @@
import { ItemDetailListCellProps } from './types';
import { formatDateRelative } from '/@/renderer/utils/format';
export const LastPlayedColumn = ({ song }: ItemDetailListCellProps) =>
song.lastPlayedAt ? formatDateRelative(song.lastPlayedAt) : <>&nbsp;</>;
@@ -0,0 +1,3 @@
import { ItemDetailListCellProps } from './types';
export const PathColumn = ({ song }: ItemDetailListCellProps) => song.path ?? <>&nbsp;</>;
@@ -0,0 +1,4 @@
import { ItemDetailListCellProps } from './types';
export const PlayCountColumn = ({ song }: ItemDetailListCellProps) =>
song.playCount ? String(song.playCount) : <>&nbsp;</>;
@@ -0,0 +1,29 @@
import { ItemDetailListCellProps } from './types';
import { useIsMutatingRating } from '/@/renderer/features/shared/mutations/set-rating-mutation';
import { Rating } from '/@/shared/components/rating/rating';
import { LibraryItem } from '/@/shared/types/domain-types';
export const RatingColumn = ({ controls, internalState, song }: ItemDetailListCellProps) => {
const isMutatingRating = useIsMutatingRating();
const value = song.userRating ?? 0;
return (
<Rating
onChange={(rating) => {
const index = internalState?.findItemIndex(song.id) ?? -1;
controls?.onRating?.({
event: null,
index,
internalState: internalState ?? undefined,
item: song,
itemType: LibraryItem.SONG,
rating,
});
}}
readOnly={isMutatingRating}
size="xs"
value={value}
/>
);
};
@@ -0,0 +1,6 @@
import { ItemDetailListCellProps } from './types';
import { formatDateAbsoluteUTC } from '/@/renderer/utils/format';
export const ReleaseDateColumn = ({ song }: ItemDetailListCellProps) =>
song.releaseDate ? formatDateAbsoluteUTC(song.releaseDate) : <>&nbsp;</>;
@@ -0,0 +1,5 @@
.icon-wrapper {
display: flex;
align-items: center;
justify-content: center;
}
@@ -0,0 +1,23 @@
import styles from './row-index-column.module.css';
import { ItemDetailListCellProps } from './types';
import { useIsCurrentSong } from '/@/renderer/features/player/hooks/use-is-current-song';
import { usePlayerStatus } from '/@/renderer/store';
import { Icon } from '/@/shared/components/icon/icon';
import { PlayerStatus } from '/@/shared/types/types';
export const RowIndexColumn = ({ rowIndex, song }: ItemDetailListCellProps) => {
const status = usePlayerStatus();
const { isActive } = useIsCurrentSong(song);
const isPlaying = isActive && status === PlayerStatus.PLAYING;
if (isActive) {
return (
<div className={styles.iconWrapper}>
<Icon fill="primary" icon={isPlaying ? 'mediaPlay' : 'mediaPause'} />
</div>
);
}
return <>{String((rowIndex ?? 0) + 1)}</>;
};
@@ -0,0 +1,4 @@
import { ItemDetailListCellProps } from './types';
export const SampleRateColumn = ({ song }: ItemDetailListCellProps) =>
song.sampleRate ? `${song.sampleRate} Hz` : <>&nbsp;</>;
@@ -0,0 +1,6 @@
import { ItemDetailListCellProps } from './types';
import { formatSizeString } from '/@/renderer/utils/format';
export const SizeColumn = ({ song }: ItemDetailListCellProps) =>
song.size ? formatSizeString(song.size) : <>&nbsp;</>;
@@ -0,0 +1,18 @@
import clsx from 'clsx';
import styles from './title-column.module.css';
import { ItemDetailListCellProps } from '/@/renderer/components/item-list/item-detail-list/columns/types';
import { useIsCurrentSong } from '/@/renderer/features/player/hooks/use-is-current-song';
import { ExplicitIndicator } from '/@/shared/components/explicit-indicator/explicit-indicator';
export const TitleArtistColumn = ({ song }: ItemDetailListCellProps) => {
const { isActive } = useIsCurrentSong(song);
return (
<span className={clsx({ [styles.active]: isActive })}>
<ExplicitIndicator explicitStatus={song.explicitStatus} />
{[song.name, song.artistName].filter(Boolean).join(' — ') ?? <>&nbsp;</>}
</span>
);
};
@@ -0,0 +1,3 @@
.active {
color: var(--theme-colors-primary);
}
@@ -0,0 +1,18 @@
import clsx from 'clsx';
import styles from './title-column.module.css';
import { ItemDetailListCellProps } from '/@/renderer/components/item-list/item-detail-list/columns/types';
import { useIsCurrentSong } from '/@/renderer/features/player/hooks/use-is-current-song';
import { ExplicitIndicator } from '/@/shared/components/explicit-indicator/explicit-indicator';
export const TitleColumn = ({ song }: ItemDetailListCellProps) => {
const { isActive } = useIsCurrentSong(song);
return (
<span className={clsx({ [styles.active]: isActive })}>
<ExplicitIndicator explicitStatus={song.explicitStatus} />
{song.name ?? <>&nbsp;</>}
</span>
);
};
@@ -0,0 +1,18 @@
import clsx from 'clsx';
import styles from './title-column.module.css';
import { ItemDetailListCellProps } from '/@/renderer/components/item-list/item-detail-list/columns/types';
import { useIsCurrentSong } from '/@/renderer/features/player/hooks/use-is-current-song';
import { ExplicitIndicator } from '/@/shared/components/explicit-indicator/explicit-indicator';
export const TitleCombinedColumn = ({ song }: ItemDetailListCellProps) => {
const { isActive } = useIsCurrentSong(song);
return (
<span className={clsx({ [styles.active]: isActive })}>
<ExplicitIndicator explicitStatus={song.explicitStatus} />
{[song.name, song.artistName].filter(Boolean).join(' — ') ?? <>&nbsp;</>}
</span>
);
};
@@ -0,0 +1,7 @@
import { ItemDetailListCellProps } from './types';
export const TrackNumberColumn = ({ song }: ItemDetailListCellProps) => {
const disc = song.discNumber ?? 1;
const track = song.trackNumber.toString().padStart(2, '0');
return `${disc}-${track}`;
};
@@ -0,0 +1,14 @@
import { ItemListStateActions } from '/@/renderer/components/item-list/helpers/item-list-state';
import { ItemControls } from '/@/renderer/components/item-list/types';
import { Song } from '/@/shared/types/domain-types';
export interface ItemDetailListCellProps {
controls?: ItemControls;
internalState?: ItemListStateActions;
isMutatingFavorite?: boolean;
isRowHovered?: boolean;
onFavoriteClick?: (song: Song) => void;
rowIndex?: number;
size?: 'compact' | 'default' | 'large';
song: Song;
}
@@ -0,0 +1,4 @@
import { ItemDetailListCellProps } from './types';
export const YearColumn = ({ song }: ItemDetailListCellProps) =>
song.releaseYear ? String(song.releaseYear) : <>&nbsp;</>;
@@ -0,0 +1,570 @@
.container {
position: relative;
flex: 1 1 auto;
width: 100%;
min-height: 0;
overflow: auto;
}
.placeholder {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
}
.wrapper {
display: flex;
flex-direction: column;
width: 100%;
height: 100%;
min-height: 0;
}
.detail-list-header {
display: grid;
flex-shrink: 0;
grid-template-columns: 240px 1fr;
gap: var(--theme-spacing-md);
padding: 0 var(--theme-spacing-md);
font-size: var(--theme-font-size-sm);
user-select: none;
background-color: var(--theme-colors-background);
border-bottom: 1px solid var(--theme-colors-border);
}
.header-left {
display: flex;
align-items: center;
min-width: 0;
overflow: hidden;
}
.header-left-album-name {
overflow: hidden;
text-overflow: ellipsis;
font-size: var(--theme-font-size-sm);
font-weight: 500;
color: var(--theme-colors-foreground);
white-space: nowrap;
}
.header-right {
min-width: 0;
overflow: hidden;
}
.tracks-table-header {
display: flex;
flex-shrink: 0;
flex-wrap: nowrap;
align-items: center;
width: 100%;
min-width: 0;
}
.tracks-table-header-size-compact {
height: 32px;
min-height: 32px;
}
.tracks-table-header-size-default {
height: 40px;
min-height: 40px;
}
.tracks-table-header-size-large {
height: 48px;
min-height: 48px;
}
.track-header-cell {
position: relative;
display: flex;
flex-wrap: nowrap;
align-items: center;
min-width: 0;
min-height: 60%;
padding-right: var(--theme-spacing-sm);
padding-left: var(--theme-spacing-sm);
overflow: visible;
white-space: nowrap;
}
.track-header-cell-no-h-padding {
padding-right: 0;
padding-left: 0;
}
.track-header-cell-with-vertical-border {
border-right: 1px solid var(--theme-colors-border);
}
.track-header-cell-dragging {
cursor: grabbing;
opacity: 0.5;
}
.track-header-cell-dragged-over-left::before {
position: absolute;
top: 0;
bottom: 0;
left: 0;
z-index: 10;
width: 3px;
content: '';
background-color: var(--theme-colors-primary);
}
.track-header-cell-dragged-over-right::after {
position: absolute;
top: 0;
right: 0;
bottom: 0;
z-index: 10;
width: 3px;
content: '';
background-color: var(--theme-colors-primary);
}
.track-header-cell:hover .resize-handle {
opacity: 1;
}
.track-header-cell:hover .resize-handle::before {
background-color: var(--theme-colors-border);
}
.resize-handle {
position: absolute;
top: 0;
bottom: 0;
z-index: 10;
width: 2px;
margin-right: -4px;
cursor: col-resize;
background: var(--theme-colors-border);
opacity: 0;
transition: opacity 0.3s ease;
}
/* .resize-handle::before {
position: absolute;
top: 0;
right: 0;
bottom: 0;
width: 2px;
content: '';
background-color: transparent;
transition: background-color 0.15s ease;
} */
.resize-handle-left {
left: 0;
margin-right: 0;
margin-left: -4px;
}
.resize-handle-left::before {
right: auto;
left: 0;
}
.resize-handle-right {
right: 0;
margin-right: 0;
}
.resize-handle-dragging {
opacity: 1;
}
.resize-handle:hover {
opacity: 1;
}
.row {
display: grid;
grid-template-columns: 240px 1fr;
gap: var(--theme-spacing-md);
padding: var(--theme-spacing-md);
border-bottom: 1px solid var(--theme-colors-border);
}
.skeleton-column-wrapper {
box-sizing: border-box;
min-width: 0;
}
.image-wrapper-outer {
position: relative;
display: block;
width: 100%;
aspect-ratio: 1;
}
.image-wrapper-outer.image-wrapper-dragging {
opacity: 0.5;
}
.image-wrapper {
position: relative;
display: block;
width: 100%;
aspect-ratio: 1;
overflow: hidden;
color: inherit;
text-decoration: none;
border-radius: var(--theme-radius-md);
&::before {
position: absolute;
top: 0;
left: 0;
z-index: 5;
width: 100%;
height: 100%;
pointer-events: none;
content: '';
background-color: rgb(0 0 0);
opacity: 0;
transition: opacity 0.2s ease-in-out;
}
&:hover {
@mixin dark {
&::before {
opacity: 0.7;
}
}
@mixin light {
&::before {
opacity: 0.5;
}
}
}
&:hover .favorite-badge,
&:hover .rating-badge {
opacity: 0;
}
}
.favorite-badge {
position: absolute;
top: -50px;
left: -50px;
z-index: 1;
width: 80px;
height: 80px;
pointer-events: none;
background-color: var(--theme-colors-primary);
box-shadow: 0 0 10px 8px rgb(0 0 0 / 80%);
opacity: 1;
transform: rotate(-45deg);
transition: opacity 0.2s ease-in-out;
}
.rating-badge {
position: absolute;
top: var(--theme-spacing-sm);
right: var(--theme-spacing-sm);
z-index: 1;
display: flex;
align-items: center;
justify-content: center;
padding: var(--theme-spacing-xs) var(--theme-spacing-sm);
font-size: var(--theme-font-size-md);
font-weight: 600;
color: var(--theme-colors-foreground);
text-shadow: 0 1px 2px rgb(0 0 0 / 80%);
pointer-events: none;
background-color: var(--theme-colors-primary);
border-radius: var(--theme-radius-md);
box-shadow: 0 2px 8px rgb(0 0 0 / 50%);
opacity: 1;
transition: opacity 0.2s ease-in-out;
}
.row .image {
object-fit: var(--theme-image-fit);
border-radius: var(--theme-radius-md);
}
.row .metadata {
display: flex;
flex-direction: column;
gap: var(--theme-spacing-xs);
align-items: center;
overflow: hidden;
text-overflow: ellipsis;
font-size: var(--theme-font-size-md);
text-align: center;
}
.row .title {
font-weight: 500;
color: inherit;
text-decoration: none;
}
.row .title:hover {
text-decoration: underline;
}
.row .artist {
font-size: var(--theme-font-size-sm);
color: var(--theme-colors-foreground-muted);
text-decoration: none;
}
.row .artist-plain-text:hover {
text-decoration: underline;
}
.row .metadata-link {
color: inherit;
text-decoration: none;
}
.row .metadata-link:hover {
color: var(--theme-colors-foreground);
text-decoration: underline;
}
.row .metadata-extra {
display: flex;
flex-direction: column;
gap: var(--theme-spacing-xs);
align-items: center;
width: 100%;
font-size: var(--theme-font-size-sm);
color: var(--theme-colors-foreground-muted);
text-align: center;
}
.row .metadata-line {
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
text-wrap-style: balance;
white-space: nowrap;
}
.row .metadata-line-clamp-2 {
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
white-space: normal;
}
.row .right {
min-width: 0;
overflow: hidden;
}
.row .tracks-table {
display: flex;
flex-direction: column;
width: 100%;
font-size: var(--theme-font-size-sm);
}
.row .track-row {
display: flex;
flex-wrap: nowrap;
align-items: center;
min-width: 0;
overflow: hidden;
}
.row .track-header-cell {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.row .track-cell {
min-width: 0;
padding-right: var(--theme-spacing-sm);
padding-left: var(--theme-spacing-sm);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.row .track-row-size-compact {
height: 32px;
min-height: 32px;
max-height: 32px;
}
.row .track-row-size-default {
height: 40px;
min-height: 40px;
max-height: 40px;
}
.row .track-row-size-large {
height: 48px;
min-height: 48px;
max-height: 48px;
}
.row .track-cell-muted {
color: var(--theme-colors-foreground-muted);
}
.row .track-cell-with-vertical-border {
border-right: 1px solid transparent;
}
.row .track-cell-vertical-border-visible {
border-right-color: var(--theme-colors-border);
}
.row .track-cell-image {
display: flex;
align-self: stretch;
min-height: 0;
max-height: 100%;
padding-right: var(--theme-spacing-sm);
padding-left: var(--theme-spacing-sm);
overflow: hidden;
}
.row .track-cell-no-h-padding {
padding-right: 0;
padding-left: 0;
}
.track-row-dragging {
opacity: 0.5;
}
.track-row.track-row-alternate-even {
background-color: var(--theme-colors-background);
}
.track-row.track-row-alternate-odd {
@mixin dark {
background-color: darken(var(--theme-colors-background), 30%);
}
@mixin light {
background-color: darken(var(--theme-colors-background), 2%);
}
}
.track-row.track-row-selected {
@mixin dark {
background-color: lighten(var(--theme-colors-surface), 5%);
}
@mixin light {
background-color: darken(var(--theme-colors-surface), 5%);
}
}
.track-row.track-row-with-horizontal-border {
border-top: 1px solid transparent;
}
.track-row.track-row-horizontal-border-visible {
border-top-color: var(--theme-colors-border);
}
.track-row.track-row-hover-highlight-enabled {
position: relative;
}
.track-row.track-row-hover-highlight-enabled .track-cell {
position: relative;
z-index: 2;
}
.track-row.track-row-hover-highlight-enabled:hover::before {
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
z-index: 1;
pointer-events: none;
content: '';
background-color: var(--theme-colors-surface);
opacity: 0.7;
}
.skeleton-image-container {
justify-content: center;
}
.skeleton-image {
width: 100%;
aspect-ratio: 1;
border-radius: var(--theme-radius-md);
}
.skeleton-title-container {
justify-content: center;
}
.skeleton-title {
width: 75%;
height: 1.25rem;
}
.skeleton-artist-container {
justify-content: center;
}
.skeleton-artist {
width: 50%;
height: 1rem;
}
.skeleton-tracks {
display: flex;
flex-direction: column;
gap: 0;
}
.skeleton-track-row {
display: grid;
grid-template-columns: 40px 1fr 8rem;
gap: var(--theme-spacing-sm);
align-items: center;
padding-right: var(--theme-spacing-sm);
padding-left: var(--theme-spacing-sm);
}
.skeleton-tracks-size-compact .skeleton-track-row {
height: 32px;
padding-top: 0;
padding-bottom: 0;
}
.skeleton-tracks-size-default .skeleton-track-row {
height: 40px;
padding-top: 0;
padding-bottom: 0;
}
.skeleton-tracks-size-large .skeleton-track-row {
height: 48px;
padding-top: 0;
padding-bottom: 0;
}
.skeleton-track-cell {
width: 100%;
height: 1rem;
}
.skeleton-track-cell-title {
width: 100%;
min-width: 0;
height: 1rem;
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,65 @@
import { TableColumn } from '/@/shared/types/types';
const FIXED_TRACK_COLUMN_WIDTHS: Partial<Record<TableColumn, number>> = {
[TableColumn.ACTIONS]: 32,
[TableColumn.BIT_DEPTH]: 88,
[TableColumn.BIT_RATE]: 88,
[TableColumn.BPM]: 56,
[TableColumn.CHANNELS]: 80,
[TableColumn.CODEC]: 80,
[TableColumn.DATE_ADDED]: 128,
[TableColumn.DISC_NUMBER]: 36,
[TableColumn.DURATION]: 72,
[TableColumn.RELEASE_DATE]: 128,
[TableColumn.SAMPLE_RATE]: 112,
[TableColumn.TRACK_NUMBER]: 64,
[TableColumn.USER_FAVORITE]: 32,
[TableColumn.USER_RATING]: 64,
[TableColumn.YEAR]: 56,
};
const HOVER_ONLY_COLUMNS: TableColumn[] = [
TableColumn.ACTIONS,
TableColumn.USER_FAVORITE,
TableColumn.USER_RATING,
];
const NO_HORIZONTAL_PADDING_COLUMNS: TableColumn[] = [
TableColumn.ACTIONS,
TableColumn.USER_FAVORITE,
TableColumn.USER_RATING,
];
export function getTrackColumnFixed(columnId: TableColumn): {
fixedWidth: number;
isFixedColumn: boolean;
} {
const width = FIXED_TRACK_COLUMN_WIDTHS[columnId];
return width !== undefined
? { fixedWidth: width, isFixedColumn: true }
: { fixedWidth: 0, isFixedColumn: false };
}
export function isNoHorizontalPaddingColumn(columnId: TableColumn): boolean {
return NO_HORIZONTAL_PADDING_COLUMNS.includes(columnId);
}
export function isTrackColumnHoverOnly(columnId: TableColumn): boolean {
return HOVER_ONLY_COLUMNS.includes(columnId);
}
export function shouldShowHoverOnlyColumnContent(
columnId: TableColumn,
isRowHovered: boolean,
song: { userFavorite?: boolean | null; userRating?: null | number },
): boolean {
if (!HOVER_ONLY_COLUMNS.includes(columnId)) {
return true;
}
return (
isRowHovered ||
(columnId === TableColumn.USER_FAVORITE && song.userFavorite !== false) ||
(columnId === TableColumn.USER_RATING && song.userRating !== null && song.userRating !== 0)
);
}
@@ -54,6 +54,7 @@ const ImageColumnBase = (props: ItemTableListInnerColumn) => {
itemType: props.itemType,
meta: {
playType,
singleSongOnly: true,
},
});
return;
@@ -90,6 +91,7 @@ const ImageColumnBase = (props: ItemTableListInnerColumn) => {
})}
enableDebounce={true}
enableViewport={false}
explicitStatus={item?.explicitStatus}
id={item?.imageId}
itemType={item?._itemType}
src={item?.imageUrl}
@@ -13,6 +13,7 @@ import {
} from '/@/renderer/components/item-list/item-table-list/item-table-list-column';
import { useIsActiveRow } from '/@/renderer/components/item-list/item-table-list/item-table-list-context';
import { JoinedArtists } from '/@/renderer/features/albums/components/joined-artists';
import { ExplicitIndicator } from '/@/shared/components/explicit-indicator/explicit-indicator';
import { Icon } from '/@/shared/components/icon/icon';
import { Text } from '/@/shared/components/text/text';
import { Folder, LibraryItem, QueueSong } from '/@/shared/types/domain-types';
@@ -52,6 +53,7 @@ export const DefaultTitleArtistColumn = (props: ItemTableListInnerColumn) => {
})}
>
<Text className={styles.title} isNoSelect size="md" {...titleLinkProps}>
<ExplicitIndicator explicitStatus={item?.explicitStatus} />
{item.name as string}
</Text>
<div className={styles.artists}>
@@ -120,6 +122,7 @@ export const QueueSongTitleArtistColumn = (props: ItemTableListInnerColumn) => {
size="md"
{...titleLinkProps}
>
<ExplicitIndicator explicitStatus={song?.explicitStatus} />
{row.name as string}
{song?.trackSubtitle && props.itemType !== LibraryItem.QUEUE_SONG && (
<Text

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