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