mirror of
https://github.com/jeffvli/feishin.git
synced 2026-05-07 20:40:15 +02:00
Compare commits
72 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 | |||
| 4134af0340 | |||
| 29a43ca185 | |||
| e452f86170 | |||
| ec765dca6a | |||
| 64a3752b54 | |||
| 24069d285f | |||
| 77fe886da4 | |||
| ef16e1403d | |||
| ff6dda7b06 | |||
| 479aa2e22d | |||
| ab8c3ad0ec | |||
| dc03a432fe | |||
| 751ad55d02 | |||
| 78dc89303d | |||
| 4328d8860e | |||
| 58a36b3bba | |||
| 618e5d8da8 | |||
| be6ec49cfa | |||
| 65ecdc7666 | |||
| da42fd78d2 | |||
| c36735575f |
@@ -0,0 +1,329 @@
|
||||
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 }}
|
||||
EP_PRE_RELEASE: true
|
||||
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 }}
|
||||
EP_PRE_RELEASE: true
|
||||
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 }}
|
||||
EP_PRE_RELEASE: true
|
||||
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 }}
|
||||
EP_PRE_RELEASE: true
|
||||
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 } | 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"
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -157,14 +157,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,57 @@
|
||||
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
|
||||
@@ -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,10 @@ 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
|
||||
|
||||
+15
-10
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "feishin",
|
||||
"version": "0.20.0",
|
||||
"version": "0.21.0",
|
||||
"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
@@ -75,7 +75,9 @@
|
||||
"download": "descarregar",
|
||||
"showDetails": "informació",
|
||||
"numberSelected": "{{count}} seleccionat",
|
||||
"shareItem": "comparteix l'element"
|
||||
"shareItem": "comparteix l'element",
|
||||
"goToAlbumArtist": "Ves a $t(entity.albumArtist_one)",
|
||||
"goToAlbum": "ves a $t(entity.album_one)"
|
||||
},
|
||||
"genreList": {
|
||||
"title": "$t(entity.genre_other)",
|
||||
@@ -641,7 +643,10 @@
|
||||
"discordDisplayType": "tipus de pantalla d'activitat de {{discord}}",
|
||||
"discordDisplayType_description": "canvia què escolteu al vostre estat",
|
||||
"discordDisplayType_songname": "nom de la cançó",
|
||||
"discordDisplayType_artistname": "nom de l'artista"
|
||||
"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"
|
||||
},
|
||||
"table": {
|
||||
"column": {
|
||||
|
||||
@@ -277,7 +277,10 @@
|
||||
"discordDisplayType": "typ zobrazení stavu {{discord}}",
|
||||
"discordDisplayType_description": "změní, co posloucháte, ve vašem stavu",
|
||||
"discordDisplayType_songname": "název skladby",
|
||||
"discordDisplayType_artistname": "jména umělců"
|
||||
"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"
|
||||
},
|
||||
"action": {
|
||||
"editPlaylist": "upravit $t(entity.playlist_one)",
|
||||
@@ -625,7 +628,9 @@
|
||||
"playSimilarSongs": "$t(player.playSimilarSongs)",
|
||||
"download": "stáhnout",
|
||||
"playShuffled": "$t(player.shuffle)",
|
||||
"moveToNext": "$t(action.moveToNext)"
|
||||
"moveToNext": "$t(action.moveToNext)",
|
||||
"goToAlbum": "přejít na $t(entity.album_one)",
|
||||
"goToAlbumArtist": "přejít na $t(entity.albumArtist_one)"
|
||||
},
|
||||
"home": {
|
||||
"mostPlayed": "nejpřehrávanější",
|
||||
|
||||
@@ -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": "latest",
|
||||
"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",
|
||||
|
||||
@@ -243,7 +243,7 @@
|
||||
"artists": "$t(entity.artist_other)",
|
||||
"albumArtists": "$t(entity.albumArtist_other)",
|
||||
"shared": "partagé $t(entity.playlist_other)",
|
||||
"myLibrary": "ma bibliothèque"
|
||||
"myLibrary": "Bibliothèque"
|
||||
},
|
||||
"fullscreenPlayer": {
|
||||
"config": {
|
||||
@@ -331,7 +331,9 @@
|
||||
"showDetails": "obtenir des informations",
|
||||
"download": "télécharger",
|
||||
"playShuffled": "$t(player.shuffle)",
|
||||
"moveToNext": "$t(action.moveToNext)"
|
||||
"moveToNext": "$t(action.moveToNext)",
|
||||
"goToAlbumArtist": "aller à l'$t(entity.albumArtist_one)",
|
||||
"goToAlbum": "aller à l'$t(entity.album_one)"
|
||||
},
|
||||
"albumArtistList": {
|
||||
"title": "$t(entity.albumArtist_other)"
|
||||
@@ -499,7 +501,7 @@
|
||||
"sidebarCollapsedNavigation_description": "affiche ou cache la navigation dans la barre latérale réduite",
|
||||
"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 de listes de lecture de la barre latérale",
|
||||
"sidebarPlaylistList": "liste des listes de lecture de la barre latérale",
|
||||
"sidebarCollapsedNavigation": "navigation de la barre latéral (réduite)",
|
||||
"skipDuration": "durée de l'avance rapide",
|
||||
"sidePlayQueueStyle_optionAttached": "attaché",
|
||||
@@ -546,7 +548,7 @@
|
||||
"clearQueryCache": "vide le cache de feishin",
|
||||
"clearCache": "vider le cache navigateur",
|
||||
"buttonSize_description": "la taille des boutons de la barre de lecture",
|
||||
"clearQueryCache_description": "un 'soft clear' de Feishin. Cela actualisera les liste de lecture, les métadonnées des titres, et réinitialisera les paroles enregistrées. Les paramètres, identifiants du serveur et images mises en cache seront conservés",
|
||||
"clearQueryCache_description": "un 'soft clear' de Feishin. cela actualisera les liste de lecture, les métadonnées des titres, et réinitialisera les paroles enregistrées. les paramètres, identifiants du serveur et images mises en cache seront conservés",
|
||||
"clearCache_description": "un 'hard clear' de feishin. en plus de vider le cache de feishin, vide le cache du navigateur (images sauvegardées et autres ressources). les identifiants serveurs et paramètres sont conservés",
|
||||
"buttonSize": "taille des boutons du lecteur",
|
||||
"clearCacheSuccess": "le cache a été vidé",
|
||||
@@ -627,7 +629,10 @@
|
||||
"discordDisplayType": "type d'affichage du status {{discord}}",
|
||||
"discordDisplayType_description": "change ce que vous écoutez dans votre statut",
|
||||
"discordDisplayType_songname": "nom du morceau",
|
||||
"discordDisplayType_artistname": "nom(s) d’artiste"
|
||||
"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"
|
||||
},
|
||||
"form": {
|
||||
"deletePlaylist": {
|
||||
|
||||
+31
-10
@@ -114,12 +114,14 @@
|
||||
"codec": "codec",
|
||||
"mbid": "MusicBrainz ID",
|
||||
"preview": "anteprima",
|
||||
"reload": "ricarica",
|
||||
"reload": "aggiorna",
|
||||
"share": "condividi",
|
||||
"tags": "tags",
|
||||
"trackGain": "normalizzazione (gain) del brano",
|
||||
"trackPeak": "picco di volume del brano",
|
||||
"translation": "traduzione"
|
||||
"translation": "traduzione",
|
||||
"bitDepth": "bit depth (profondità di bit)",
|
||||
"sampleRate": "sample rate (frequenza di campionamento)"
|
||||
},
|
||||
"player": {
|
||||
"repeat_all": "ripeti coda",
|
||||
@@ -231,7 +233,7 @@
|
||||
"hotkey_toggleShuffle": "attiva/disattiva mescolamento",
|
||||
"theme": "tema",
|
||||
"playbackStyle_description": "selezione lo stile di riproduzione da usare per il player audio",
|
||||
"discordRichPresence_description": "abilita lo status del playback nello stato attività di {{discord}}. Le chiavi immagine sono: {{icon}}, {{playing}} e {{paused}}",
|
||||
"discordRichPresence_description": "abilita lo stato di riproduzione nello stato attività di {{discord}}. Le chiavi immagine sono: {{icon}}, {{playing}} e {{paused}}",
|
||||
"mpvExecutablePath": "percorso eseguibile mpv",
|
||||
"audioDevice": "device audio",
|
||||
"hotkey_rate2": "voto 2 stelle",
|
||||
@@ -266,7 +268,7 @@
|
||||
"customFontPath": "percorso font personalizzato",
|
||||
"followLyric": "segui testo corrente",
|
||||
"crossfadeDuration": "durata dissolvenza",
|
||||
"discordIdleStatus": "visualizza lo stato attività in stato inattivo",
|
||||
"discordIdleStatus": "mostra lo stato attività di Discord quando non stai riproducendo",
|
||||
"audioPlayer": "player audio",
|
||||
"hotkey_zoomOut": "rimpicciolisci layout",
|
||||
"hotkey_rate0": "rimuovi voto",
|
||||
@@ -331,12 +333,12 @@
|
||||
"customCssNotice": "Attenzione: sebbene ci sia una certa sanitizzazione (vengono bloccati url() e content:), l’uso di CSS personalizzati può comunque comportare dei rischi modificando l’interfaccia.",
|
||||
"customCss": "css personalizzato",
|
||||
"customCss_description": "contenuto CSS personalizzato. Nota: le proprietà content e gli URL remoti non sono consentiti. Di seguito è mostrata un’anteprima del tuo contenuto. Sono presenti anche altri campi non impostati da te a causa della sanitizzazione.",
|
||||
"discordPausedStatus": "mostra rich presence di Discord quando la riproduzione è in pausa",
|
||||
"discordPausedStatus": "mostra lo stato attività di Discord quando la riproduzione è in pausa",
|
||||
"discordPausedStatus_description": "quando abilitato, verrà mostrato lo stato del lettore in standby/pausa (nessun brano in riproduzione)",
|
||||
"discordListening": "mostra stato come in ascolto",
|
||||
"discordListening_description": "mostra lo stato come in ascolto invece che in riproduzione",
|
||||
"discordServeImage": "recupera le immagini di {{discord}} dal server",
|
||||
"discordServeImage_description": "condividi la copertina per la rich presence di {{discord}} direttamente dal server, disponibile solo per Jellyfin e Navidrome",
|
||||
"discordServeImage_description": "condividi la copertina per lo stato attività di {{discord}} direttamente dal server, disponibile solo per Jellyfin e Navidrome",
|
||||
"doubleClickBehavior": "aggiungi alla coda tutte le tracce cercate, con un doppio clic",
|
||||
"doubleClickBehavior_description": "se attivato, tutte le tracce corrispondenti alla ricerca verranno aggiunte alla coda. altrimenti, verrà aggiunta alla coda solo la traccia selezionata",
|
||||
"externalLinks": "mostra link esterni",
|
||||
@@ -393,7 +395,16 @@
|
||||
"webAudio_description": "usa audio web. abilita funzionalità avanzate come ReplayGain. disabilita se riscontri problemi",
|
||||
"preservePitch": "mantieni tono (pitch)",
|
||||
"preservePitch_description": "mantiene il tono (pitch) durante la modifica della velocità di riproduzione",
|
||||
"volumeWidth_description": "larghezza del cursore del volume"
|
||||
"volumeWidth_description": "larghezza del cursore del volume",
|
||||
"discordDisplayType_description": "modifica cosa stai ascoltando nel tuo stato",
|
||||
"discordDisplayType_songname": "titolo traccia",
|
||||
"discordDisplayType_artistname": "nome artisti",
|
||||
"hotkey_navigateHome": "vai alla schermata iniziale",
|
||||
"notify": "abilita notifiche delle tracce",
|
||||
"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}}"
|
||||
},
|
||||
"error": {
|
||||
"remotePortWarning": "riavvia il server per applicare la nuova porta",
|
||||
@@ -418,7 +429,8 @@
|
||||
"badAlbum": "stai visualizzando questa pagina perché questa canzone non fa parte di un album. probabilmente vedi questo messaggio perché hai una canzone posizionata direttamente nella cartella principale della tua libreria musicale. jellyfin raggruppa le tracce solo se si trovano all’interno di una cartella.",
|
||||
"badValue": "opzione non valida \"{{value}}\". valore inesistente",
|
||||
"networkError": "si è verificato un errore di rete",
|
||||
"openError": "impossibile aprire il file"
|
||||
"openError": "impossibile aprire il file",
|
||||
"notificationDenied": "i permessi per le notifiche non sono stati concessi. questa configurazione non ha effetto"
|
||||
},
|
||||
"filter": {
|
||||
"mostPlayed": "più riprodotti",
|
||||
@@ -513,7 +525,9 @@
|
||||
"openBrowserDevtools": "apri devtools browser",
|
||||
"quit": "$t(common.quit)",
|
||||
"goBack": "torna indietro",
|
||||
"goForward": "vai avanti"
|
||||
"goForward": "vai avanti",
|
||||
"privateModeOff": "disabilita modalità privata",
|
||||
"privateModeOn": "abilita modalità privata"
|
||||
},
|
||||
"contextMenu": {
|
||||
"addToPlaylist": "$t(action.addToPlaylist)",
|
||||
@@ -537,7 +551,9 @@
|
||||
"playSimilarSongs": "$t(player.playSimilarSongs)",
|
||||
"playShuffled": "$t(player.shuffle)",
|
||||
"shareItem": "condividi elemento",
|
||||
"showDetails": "mostra info"
|
||||
"showDetails": "mostra info",
|
||||
"goToAlbum": "vai a $t(entity.album_one)",
|
||||
"goToAlbumArtist": "vai a $t(entity.albumArtist_one)"
|
||||
},
|
||||
"home": {
|
||||
"mostPlayed": "più riprodotti",
|
||||
@@ -674,6 +690,11 @@
|
||||
"success": "link di condivisione copiato negli appunti (o clicca qui per aprirlo)",
|
||||
"expireInvalid": "la scadenza deve essere nel futuro",
|
||||
"createFailed": "condivisione fallita (è abilitata la condivisione?)"
|
||||
},
|
||||
"privateMode": {
|
||||
"enabled": "la modalità privata è abilitata: lo stato di riproduzione viene ora nascosto alle integrazioni esterne",
|
||||
"disabled": "la modalità privata è disabilitata: lo stato di riproduzione è ora visibile alle integrazioni esterne abilitate",
|
||||
"title": "modalità privata"
|
||||
}
|
||||
},
|
||||
"table": {
|
||||
|
||||
+239
-2
@@ -93,7 +93,7 @@
|
||||
"path": "cesta",
|
||||
"playerMustBePaused": "prehrávač musí byť pozastavený",
|
||||
"preview": "náhľad",
|
||||
"previousSong": "predchádzajúci $t(entity.track_one)",
|
||||
"previousSong": "predchádzajúca $t(entity.track_one)",
|
||||
"quit": "ukončiť",
|
||||
"random": "náhodne",
|
||||
"rating": "hodnotenie",
|
||||
@@ -589,6 +589,243 @@
|
||||
"globalMediaHotkeys_description": "povoliť alebo zakázať použitie vašich klávesových skratiek médií na ovládanie prehrávania",
|
||||
"homeConfiguration": "konfigurácia domovskej stránky",
|
||||
"homeConfiguration_description": "konfigurovať, aké položky sú zobrazené a v akom poradí na domovskej stránke",
|
||||
"homeFeature": "carousel odporúčania na domovskej stránke"
|
||||
"homeFeature": "carousel odporúčania na domovskej stránke",
|
||||
"homeFeature_description": "povoľuje zobrazenie veľkoformátového odporúčaného carouselu na domovskej stránke",
|
||||
"hotkey_browserBack": "naspäť v prehliadači",
|
||||
"hotkey_browserForward": "dopredu v prehliadači",
|
||||
"hotkey_favoriteCurrentSong": "obľúbené $t(common.currentSong)",
|
||||
"hotkey_favoritePreviousSong": "obľúbené $t(common.previousSong)",
|
||||
"hotkey_globalSearch": "globálne vyhľadávanie",
|
||||
"hotkey_localSearch": "vyhľadávanie na stránke",
|
||||
"hotkey_navigateHome": "navigovať domov",
|
||||
"hotkey_playbackNext": "nasledujúca skladba",
|
||||
"hotkey_playbackPause": "pozastaviť",
|
||||
"hotkey_playbackPlay": "prehrať",
|
||||
"hotkey_playbackPlayPause": "hrať / pozastaviť",
|
||||
"hotkey_playbackPrevious": "predchádzajúca skladba",
|
||||
"hotkey_playbackStop": "zastaviť",
|
||||
"hotkey_rate0": "bez hodnotenia",
|
||||
"hotkey_rate1": "hodnotené 1 hviezdou",
|
||||
"hotkey_rate2": "hodnotené 2 hviezdami",
|
||||
"hotkey_rate3": "hodnotené 3 hviezdami",
|
||||
"hotkey_rate4": "hodotené 4 hviezdami",
|
||||
"hotkey_rate5": "hodnotené 5 hviezdami",
|
||||
"hotkey_skipBackward": "preskočiť dozadu",
|
||||
"hotkey_skipForward": "preskočiť dopredu",
|
||||
"hotkey_toggleCurrentSongFavorite": "prepnúť $t(common.currentSong) obľúbené",
|
||||
"hotkey_toggleFullScreenPlayer": "prepnúť prehrávač na celú obrazovku",
|
||||
"hotkey_togglePreviousSongFavorite": "prepnúť $t(common.previousSong) obľúbené",
|
||||
"hotkey_toggleQueue": "prepnúť frontu",
|
||||
"hotkey_toggleRepeat": "prepnúť opakovanie",
|
||||
"hotkey_toggleShuffle": "prepnúť náhodné prehrávanie",
|
||||
"hotkey_unfavoriteCurrentSong": "odobrať z obľúbených $t(common.currentSong)",
|
||||
"hotkey_unfavoritePreviousSong": "odobrať z obľúbených $t(common.previousSong)",
|
||||
"hotkey_volumeDown": "znížiť hlasitosť",
|
||||
"hotkey_volumeMute": "stíšiť hlasitosť",
|
||||
"hotkey_volumeUp": "zvýšiť hlasitosť",
|
||||
"hotkey_zoomIn": "priblížiť",
|
||||
"hotkey_zoomOut": "vzdialiť",
|
||||
"imageAspectRatio": "použiť pôvodný pomer strán obalu albumu",
|
||||
"language": "jazyk",
|
||||
"language_description": "nastaví jazyk aplikácie ($t(common.restartRequired))",
|
||||
"lastfm": "zobraziť last.fm odkazy",
|
||||
"lastfm_description": "zobraziť last.fm odkazy na stránky interpreta/albumu",
|
||||
"lastfmApiKey": "{{lastfm}} API kľúč",
|
||||
"lastfmApiKey_description": "API kľúč pre {{lastfm}}. vyžaduje sa obálky albumov",
|
||||
"lyricFetch": "stiahnuť texty skladieb z internetu",
|
||||
"lyricFetch_description": "stiahnuť texty skladieb z rôznych internetových zdrojov",
|
||||
"lyricFetchProvider": "poskytovatelia pre sťahovanie textov skladieb",
|
||||
"lyricFetchProvider_description": "vybrať poskytovateľov pre sťahovanie textov skladieb. poradie poskytovateľov určuje poradie, v ktorom sa budú používať",
|
||||
"lyricOffset": "posunutie textu skladieb (ms)",
|
||||
"lyricOffset_description": "posunutie textu voči skladbe vyjadrené v milisekundách",
|
||||
"notify": "povoliť notifikácie o skladbách",
|
||||
"notify_description": "zobraziť notifikácie pri zmene aktuálnej skladby",
|
||||
"minimizeToTray": "minimalizovať do lišty",
|
||||
"minimizeToTray_description": "minimalizovať aplikáciu do systémovej lišty",
|
||||
"minimumScrobblePercentage": "minimálna dĺžka pre skroblovanie (percentá)",
|
||||
"minimumScrobblePercentage_description": "minimálna časť skladby v percentách, ktorá musí byť prehraná pred tým, než je skroblovaná",
|
||||
"minimumScrobbleSeconds": "minimálna dĺžka skroblovania (sekundy)",
|
||||
"minimumScrobbleSeconds_description": "minimálna dĺžka časti skladby, ktorá musí byť prehraná pred tým, než je skladba skroblovaná",
|
||||
"mpvExecutablePath": "cesta k spustiteľnému súboru mpv",
|
||||
"mpvExecutablePath_description": "nastavuje cestu k spustiteľnému súboru mpv. ak je prázdna, použije sa predvolená cesta",
|
||||
"mpvExtraParameters": "parametre mpv",
|
||||
"mpvExtraParameters_help": "jeden na riadok",
|
||||
"musicbrainz": "zobraziť linky na musicbrainz",
|
||||
"musicbrainz_description": "zobrazí linky na stránky interpreta/albumu na musicbrainz, ak je vyplnené mbid",
|
||||
"neteaseTranslation": "Povoliť NetEasy preklady",
|
||||
"neteaseTranslation_description": "Ak sú povolené, aplikácia stiahne a zobrazí preložené texty skladieb z NetEasy, ak sú dostupné.",
|
||||
"passwordStore": "ukladanie hesiel/utajených údajov",
|
||||
"passwordStore_description": "aký spôsob ukladania hesiel/utajených údajov použiť. ak máte problém s ukladaním hesiel, skúste zmeniť nastavenie.",
|
||||
"playbackStyle": "štýl prehrávania",
|
||||
"playbackStyle_description": "vyberte štýl prehrávania pre prehrávač skladieb",
|
||||
"playbackStyle_optionCrossFade": "crossfade",
|
||||
"playbackStyle_optionNormal": "normálny",
|
||||
"playButtonBehavior": "správanie sa tlačidla prehrávania",
|
||||
"playButtonBehavior_description": "nastaví predvolené správanie sa tlačidla prehrávania pri pridávaní skladieb do fronty",
|
||||
"playButtonBehavior_optionAddLast": "$t(player.addLast)",
|
||||
"playButtonBehavior_optionAddNext": "$t(player.addNext)",
|
||||
"playButtonBehavior_optionPlay": "$t(player.play)",
|
||||
"playButtonBehavior_optionPlayShuffled": "$t(player.shuffle)",
|
||||
"playerAlbumArtResolution": "rozlíšenie obrázka albumu",
|
||||
"playerAlbumArtResolution_description": "rozlíšenie zobrazenia náhľadu veľkých obrázkov albumov. pri väčšom rozlíšení budú krajšie, ale môže sa spomaliť ich načítavanie. predvolené je 0, čo znamená automatické",
|
||||
"playerbarOpenDrawer": "zobrazenie na celú obrazovku panelom prehrávača",
|
||||
"playerbarOpenDrawer_description": "umožní kliknutím na panel prehrávača prepnúť zobrazenie prehrávača na celú obrazovku",
|
||||
"remotePassword": "heslo servera vzdialeného ovládania",
|
||||
"remotePassword_description": "nastaví heslo pre server diaľkového ovládania. Jeho obsah je odosielaný bez zabezpečenia, preto by ste si mali zvoliť jedinečné heslo, ktoré pre vás nie je dôležité",
|
||||
"remotePort": "port servera diaľkového ovládania",
|
||||
"remotePort_description": "nastaví port servera diaľkového ovládania",
|
||||
"remoteUsername": "používateľské meno servera diaľkového ovládania",
|
||||
"remoteUsername_description": "nasstaví používateľské meno servera diaľkového ovládania. v prípade, ak sú používateľské meno aj heslo prázdne, je overovanie pri prihlásení vypnuté",
|
||||
"replayGainClipping": "clipping {{ReplayGain}}",
|
||||
"replayGainClipping_description": "Zabraňuje clipping-u spôsobenému {{ReplayGain}} automatickým znížením zosilenia",
|
||||
"replayGainFallback": "fallback {{ReplayGain}}",
|
||||
"replayGainFallback_description": "zosilenie v db, ktoré sa aplikuje, ak súbor nemá {{ReplayGain}} štítky",
|
||||
"replayGainMode": "{{ReplayGain}} režim",
|
||||
"replayGainMode_description": "pozmení zosilenie hlasitosti podľa hodnôt {{ReplayGain}} uložených v metadátach súboru",
|
||||
"replayGainMode_optionAlbum": "$t(entity.album_one)",
|
||||
"replayGainMode_optionNone": "$t(common.none)",
|
||||
"replayGainMode_optionTrack": "$t(entity.track_one)",
|
||||
"replayGainPreamp": "predzosilenie {{ReplayGain}} dB",
|
||||
"replayGainPreamp_description": "pozmení predzosilenie použité na hodnoty {{ReplayGain}}",
|
||||
"sampleRate": "vzorkovacia frekvencia",
|
||||
"sidePlayQueueStyle_optionAttached": "pripojené",
|
||||
"sidePlayQueueStyle_optionDetached": "odpojené",
|
||||
"skipDuration": "dĺžka preskočenia",
|
||||
"skipDuration_description": "určuje časovú dĺžku posunu pri stlačení tlačidla preskočiť na lište prehrávača",
|
||||
"skipPlaylistPage": "preskočiť stránku playlistu",
|
||||
"skipPlaylistPage_description": "pri navigácii v playliste, idete na výber stránky playlistu namiesto predvolenej stránky",
|
||||
"startMinimized": "spistiť mnimalizované",
|
||||
"startMinimized_description": "spustí aplikáciu minimalizovanú do systémovej lišty",
|
||||
"preventSleepOnPlayback": "zabrániť spánku pri prehrávaní",
|
||||
"preventSleepOnPlayback_description": "pri prehávaní hudby zabráni obrazovke v prechode do spánku",
|
||||
"theme": "téma",
|
||||
"theme_description": "nastaví tému aplikácie",
|
||||
"themeDark": "téma (tmavá)",
|
||||
"themeDark_description": "nastaví tmavú tému aplikácie",
|
||||
"themeLight": "téma (svetlá)",
|
||||
"themeLight_description": "nastaví svetlú tému aplikácie",
|
||||
"transcodeNote": "zmena sa prejaví po 1 (web) - 2 (mpv) skladbách",
|
||||
"transcode": "povoliť prekódovanie",
|
||||
"transcode_description": "umožňuje prekódovanie do rôznych formátov",
|
||||
"transcodeBitrate": "bitová frekvencia prekódovania",
|
||||
"transcodeBitrate_description": "určuje bitovú frekvenciu, pri ktorej sa použije prekódovanie. 0 znamená ponechať rozhodnutie na server",
|
||||
"transcodeFormat": "formát prekódovania",
|
||||
"transcodeFormat_description": "učuje výstupný formát prekódovania. ak chcete ponechať rozhodnutie na server, nechajte políčko prázdne",
|
||||
"translationApiProvider": "api poskytovateľa prekladu",
|
||||
"translationApiProvider_description": "api poskytovateľa prekladu",
|
||||
"translationApiKey": "api kľúč prekladu",
|
||||
"translationApiKey_description": "api kľúč pre preklad (Podporuje iba koncové body globálnych služieb)",
|
||||
"translationTargetLanguage": "cieľový jazyk prekladu",
|
||||
"translationTargetLanguage_description": "cieľový jazyk, do ktorého sa prekladá",
|
||||
"trayEnabled": "zobraziť lištu",
|
||||
"trayEnabled_description": "zobraziť/skryť ikonu/ponuku lišty. ak nie je povolené, taktiež vypne minimalizovanie/zavretie do lišty",
|
||||
"useSystemTheme": "použiť systémovú tému",
|
||||
"useSystemTheme_description": "prispôsobiť výber svetlej, či tmavej témy aktuálnej systémovej téme",
|
||||
"volumeWheelStep": "krok zmeny hlasitosti",
|
||||
"volumeWheelStep_description": "veľkosť zmeny hlasitosti pri otočení kolieskom myši o jeden krok na ovládači hlasitosti",
|
||||
"volumeWidth": "šírka posuvného ovládača hlasitosti",
|
||||
"volumeWidth_description": "šírka ovládača hlasitosti",
|
||||
"webAudio": "používať webový výstup",
|
||||
"webAudio_description": "bude sa používať webový výstup, čím povolíte pokročilé funkcie ako replaygain. v prípade problémov voľbu vypnite",
|
||||
"preservePitch": "zachovať výšku",
|
||||
"preservePitch_description": "pri zmene rýchlosti prehrávania zostane výška zachovaná",
|
||||
"windowBarStyle": "štýl okna",
|
||||
"windowBarStyle_description": "vyberte štýl okna",
|
||||
"zoom": "percento priblíženia",
|
||||
"zoom_description": "nastaví percento priblíženia pre aplikáciu",
|
||||
"sampleRate_description": "vyberte výstupnú vzorkovaciu frekvenciu, ktorá sa použije v prípade, ak je vybraná vzorkovacia frekvencia iná ako je u aktuálnej skladby. pri hodnote menšej ako 8000 sa použije predvolená frekvencia",
|
||||
"savePlayQueue": "uložiť frontu prehrávania",
|
||||
"savePlayQueue_description": "uloží frontu prehrávania pri ukončení aplikácie a obnoví ju opäť po jej otvorení",
|
||||
"scrobble": "skroblovať",
|
||||
"scrobble_description": "scroblovať vaše prehrávanie na medálny server",
|
||||
"showSkipButton": "zobraziť tlačítka preskočenia",
|
||||
"showSkipButton_description": "zobrazí alebo skryje tlačítka preskočenia na lište prehrávača",
|
||||
"showSkipButtons": "zobraziť tlačítka preskočenia",
|
||||
"showSkipButtons_description": "zobraziť alebo skryť tlačítka preskočenia na lište prehrávača",
|
||||
"sidebarCollapsedNavigation": "navigácia bočnej lišty (zasunutá)",
|
||||
"sidebarCollapsedNavigation_description": "zobraziť alebo skryť navigovanie na zasunutej bočnej lište",
|
||||
"sidebarConfiguration": "nastavenie bočnej lišty",
|
||||
"sidebarConfiguration_description": "zvoľte položky a ich poradie, v akom sa zabrazia na bočnej lište",
|
||||
"sidebarPlaylistList": "playlist bočnej lišty",
|
||||
"sidebarPlaylistList_description": "zobraziť alebo skryť playlist na bočnej lište",
|
||||
"sidePlayQueueStyle": "štýl bočnej fronty prehrávania",
|
||||
"sidePlayQueueStyle_description": "nastaví štýl bočnej fronty prehrávania"
|
||||
},
|
||||
"table": {
|
||||
"column": {
|
||||
"album": "album",
|
||||
"albumArtist": "interpret albumu",
|
||||
"albumCount": "$t(entity.album_other)",
|
||||
"artist": "$t(entity.artist_one)",
|
||||
"biography": "životopis",
|
||||
"bitrate": "bitrate",
|
||||
"bpm": "bpm",
|
||||
"channels": "$t(common.channel_other)",
|
||||
"codec": "$t(common.codec)",
|
||||
"comment": "komentár",
|
||||
"dateAdded": "dátum pridania",
|
||||
"discNumber": "disk",
|
||||
"favorite": "obľúbené",
|
||||
"genre": "$t(entity.genre_one)",
|
||||
"lastPlayed": "posledne hraný",
|
||||
"path": "cesta",
|
||||
"playCount": "prehratí",
|
||||
"rating": "hodnotenie",
|
||||
"releaseDate": "dátum vydania",
|
||||
"releaseYear": "rok",
|
||||
"size": "$t(common.size)",
|
||||
"songCount": "$t(entity.track_other)",
|
||||
"title": "názov",
|
||||
"trackNumber": "skladba"
|
||||
},
|
||||
"config": {
|
||||
"general": {
|
||||
"autoFitColumns": "automatická šírka stĺpcov",
|
||||
"followCurrentSong": "nasledovať aktuálnu skladbu",
|
||||
"displayType": "typ zobrazenia",
|
||||
"gap": "$t(common.gap)",
|
||||
"itemGap": "medzera položky (px)",
|
||||
"itemSize": "veľkosť položky (px)",
|
||||
"size": "$t(common.size)",
|
||||
"tableColumns": "stĺpce tabuľky"
|
||||
},
|
||||
"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)",
|
||||
"dateAdded": "dátum pridania",
|
||||
"discNumber": "číslo disku",
|
||||
"duration": "$t(common.duration)",
|
||||
"favorite": "$t(common.favorite)",
|
||||
"genre": "$t(entity.genre_one)",
|
||||
"lastPlayed": "posledne prehraté",
|
||||
"note": "$t(common.note)",
|
||||
"owner": "$t(common.owner)",
|
||||
"path": "$t(common.path)",
|
||||
"playCount": "počet prehraní",
|
||||
"rating": "$t(common.rating)",
|
||||
"releaseDate": "dátum vydania",
|
||||
"rowIndex": "číslo riadku",
|
||||
"size": "$t(common.size)",
|
||||
"songCount": "$t(entity.track_other)",
|
||||
"title": "$t(common.title)",
|
||||
"titleCombined": "$t(common.title) (kombinovaný)",
|
||||
"trackNumber": "číslo skladby",
|
||||
"year": "$t(common.year)"
|
||||
},
|
||||
"view": {
|
||||
"card": "karta",
|
||||
"grid": "mriežka",
|
||||
"list": "zoznam",
|
||||
"poster": "plagát",
|
||||
"table": "tabuľka"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -286,6 +286,11 @@
|
||||
"updateServer": {
|
||||
"success": "sunucu başarıyla güncellendi",
|
||||
"title": "sunucuyu güncelle"
|
||||
},
|
||||
"privateMode": {
|
||||
"enabled": "gizli mod etkinleştirildi, oynatma durumu artık harici eklentilerden gizlendi",
|
||||
"disabled": "gizli mod devre dışı bırakıldı, oynatma durumu artık etkinleştirilmiş harici eklentiler tarafından görülebilir",
|
||||
"title": "gizli mod"
|
||||
}
|
||||
},
|
||||
"page": {
|
||||
@@ -322,7 +327,9 @@
|
||||
"addFavorite": "$t(action.addToFavorites)",
|
||||
"playShuffled": "$t(player.shuffle)",
|
||||
"shareItem": "öğeyi paylaş",
|
||||
"showDetails": "bilgi al"
|
||||
"showDetails": "bilgi al",
|
||||
"goToAlbum": "$t(entity.album_one) sayfasına git",
|
||||
"goToAlbumArtist": "$t(entity.albumArtist_one) sayfasına git"
|
||||
},
|
||||
"manageServers": {
|
||||
"url": "URL",
|
||||
@@ -436,7 +443,9 @@
|
||||
"quit": "$t(common.quit)",
|
||||
"selectServer": "sunucu seç",
|
||||
"settings": "$t(common.setting_other)",
|
||||
"version": "{{version}} sürümü"
|
||||
"version": "{{version}} sürümü",
|
||||
"privateModeOff": "gizli modu kapat",
|
||||
"privateModeOn": "gizli modu aç"
|
||||
}
|
||||
},
|
||||
"player": {
|
||||
@@ -713,7 +722,14 @@
|
||||
"themeLight": "tema (açık)",
|
||||
"themeLight_description": "uygulama için kullanılacak açık temayı ayarlar",
|
||||
"transcodeNote": "1 (web) - 2 (mpv) şarkıdan sonra etkili olur",
|
||||
"transcode": "kod dönüştürmeyi etkinleştir"
|
||||
"transcode": "kod dönüştürmeyi etkinleştir",
|
||||
"discordDisplayType": "{{discord}} varlık gösterge türü",
|
||||
"discordDisplayType_description": "durumunuzda dinlediğiniz şarkı olarak değiştirir",
|
||||
"discordDisplayType_songname": "şarkı ismi",
|
||||
"discordDisplayType_artistname": "Sanatçı adı(ları)",
|
||||
"hotkey_navigateHome": "ana sayfaya git",
|
||||
"preventSleepOnPlayback": "oynatma sırasında uykuyu önle",
|
||||
"preventSleepOnPlayback_description": "müzik çalarken ekranın uyku moduna geçmesini önle"
|
||||
},
|
||||
"table": {
|
||||
"column": {
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
"editPlaylist": "编辑$t(entity.playlist_one)",
|
||||
"moveToTop": "移至顶部",
|
||||
"clearQueue": "清空播放队列",
|
||||
"addToFavorites": "添加到$t(entity.favorite_other)",
|
||||
"addToPlaylist": "添加到$t(entity.playlist_one)",
|
||||
"addToFavorites": "添加到 $t(entity.favorite_other)",
|
||||
"addToPlaylist": "添加到 $t(entity.playlist_one)",
|
||||
"createPlaylist": "创建$t(entity.playlist_one)",
|
||||
"removeFromPlaylist": "从$t(entity.playlist_one)移除",
|
||||
"viewPlaylists": "查看$t(entity.playlist_other)",
|
||||
@@ -127,13 +127,13 @@
|
||||
"playlist_other": "播放列表",
|
||||
"artist_other": "艺术家",
|
||||
"folderWithCount_other": "{{count}} 个文件夹",
|
||||
"track_other": "乐曲",
|
||||
"track_other": "曲目",
|
||||
"favorite_other": "收藏",
|
||||
"artistWithCount_other": "{{count}} 位艺术家",
|
||||
"folder_other": "文件夹",
|
||||
"smartPlaylist": "智能$t(entity.playlist_one)",
|
||||
"genreWithCount_other": "{{count}} 种流派",
|
||||
"trackWithCount_other": "{{count}} 首乐曲",
|
||||
"trackWithCount_other": "{{count}} 首曲目",
|
||||
"play_other": "{{count}} 次播放",
|
||||
"song_other": "歌曲"
|
||||
},
|
||||
@@ -167,7 +167,7 @@
|
||||
"skip_forward": "向前跳过",
|
||||
"playbackSpeed": "播放速度",
|
||||
"pause": "暂停",
|
||||
"playSimilarSongs": "播放类似的曲目",
|
||||
"playSimilarSongs": "播放类似的歌曲",
|
||||
"viewQueue": "查看播放队列"
|
||||
},
|
||||
"setting": {
|
||||
@@ -205,7 +205,7 @@
|
||||
"enableRemote_description": "启用远程控制服务器,以允许其他设备控制此应用",
|
||||
"remotePort_description": "设置远程服务器端口",
|
||||
"hotkey_skipBackward": "向后跳过",
|
||||
"replayGainMode_description": "根据乐曲元数据中存储的{{ReplayGain}}值调整音量增益",
|
||||
"replayGainMode_description": "根据文件元数据中存储的 {{ReplayGain}} 值调整音量增益",
|
||||
"volumeWheelStep_description": "在音量滑块上滚动鼠标滚轮时要更改的音量大小",
|
||||
"theme_description": "设置应用的主题",
|
||||
"hotkey_playbackPause": "暂停",
|
||||
@@ -290,7 +290,7 @@
|
||||
"playbackStyle_optionNormal": "正常",
|
||||
"windowBarStyle": "窗口顶栏风格",
|
||||
"floatingQueueArea": "显示浮动队列悬停区域",
|
||||
"replayGainFallback_description": "乐曲没有{{ReplayGain}}标签时应用的增益(以分贝为单位)",
|
||||
"replayGainFallback_description": "如果文件没有 {{ReplayGain}} 标签,则在数据库中应用增益",
|
||||
"hotkey_toggleRepeat": "切换循环",
|
||||
"lyricOffset_description": "将歌词偏移指定的毫秒数",
|
||||
"sidebarConfiguration_description": "选择侧边栏包含的项目与顺序",
|
||||
@@ -464,7 +464,7 @@
|
||||
"albumArtist": "$t(entity.albumArtist_one)",
|
||||
"releaseYear": "发布年份",
|
||||
"biography": "个人简介",
|
||||
"songCount": "曲目数量",
|
||||
"songCount": "歌曲数量",
|
||||
"random": "随机",
|
||||
"lastPlayed": "上次播放过",
|
||||
"toYear": "从年份",
|
||||
@@ -599,7 +599,7 @@
|
||||
"trackList": {
|
||||
"title": "$t(entity.track_other)",
|
||||
"genreTracks": "\"{{genre}}\" $t(entity.track_other)",
|
||||
"artistTracks": "{{artist}}的曲目"
|
||||
"artistTracks": "{{artist}} 的曲目"
|
||||
},
|
||||
"albumArtistList": {
|
||||
"title": "$t(entity.albumArtist_other)"
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
|
||||
+8
-1
@@ -44,6 +44,10 @@ export default class AppUpdater {
|
||||
log.transports.file.level = 'info';
|
||||
autoUpdater.logger = autoUpdaterLogInterface;
|
||||
autoUpdater.checkForUpdatesAndNotify();
|
||||
|
||||
if (store.get('release_channel') === 'beta') {
|
||||
autoUpdater.channel = 'beta';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -489,7 +493,10 @@ async function createWindow(first = true): Promise<void> {
|
||||
|
||||
const menuBuilder = new MenuBuilder(mainWindow);
|
||||
menuBuilder.buildMenu();
|
||||
Menu.setApplicationMenu(null);
|
||||
|
||||
if (process.platform !== 'darwin') {
|
||||
Menu.setApplicationMenu(null);
|
||||
}
|
||||
|
||||
// Open URLs in the user's browser
|
||||
mainWindow.webContents.setWindowOpenHandler((edata) => {
|
||||
|
||||
@@ -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();
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -103,19 +103,7 @@ export const ContextMenuProvider = ({ children }: ContextMenuProviderProps) => {
|
||||
const { t } = useTranslation();
|
||||
const [opened, setOpened] = useState(false);
|
||||
|
||||
const [contextMenuRef, setContextMenuRef] = useState<HTMLDivElement | null>(null);
|
||||
const [ratingsRef, setRatingsRef] = useState<HTMLDivElement | null>(null);
|
||||
const [rating, setRating] = useState<number>(0);
|
||||
|
||||
useEffect(() => {
|
||||
setRating(0);
|
||||
}, [opened]);
|
||||
|
||||
const clickOutsideRef = useClickOutside(
|
||||
() => setOpened(false),
|
||||
['mousedown', 'touchstart'],
|
||||
[contextMenuRef, ratingsRef],
|
||||
);
|
||||
const clickOutsideRef = useClickOutside(() => setOpened(false), ['mousedown', 'touchstart']);
|
||||
|
||||
const viewport = useViewportSize();
|
||||
const server = useCurrentServer();
|
||||
@@ -132,6 +120,24 @@ export const ContextMenuProvider = ({ children }: ContextMenuProviderProps) => {
|
||||
yPos: 0,
|
||||
});
|
||||
|
||||
const [rating, setRating] = useState<number>(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (opened && ctx.data.length > 0) {
|
||||
if (ctx.data.length === 1) {
|
||||
setRating(ctx.data[0].userRating ?? 0);
|
||||
} else {
|
||||
const firstRating = ctx.data[0].userRating ?? 0;
|
||||
const allSameRating = ctx.data.every(
|
||||
(item) => (item.userRating ?? 0) === firstRating,
|
||||
);
|
||||
setRating(allSameRating ? firstRating : 0);
|
||||
}
|
||||
} else {
|
||||
setRating(0);
|
||||
}
|
||||
}, [ctx.data, opened]);
|
||||
|
||||
const handlePlayQueueAdd = usePlayQueueAdd();
|
||||
const navigate = useNavigate();
|
||||
|
||||
@@ -535,7 +541,6 @@ export const ContextMenuProvider = ({ children }: ContextMenuProviderProps) => {
|
||||
});
|
||||
},
|
||||
onSuccess: () => {
|
||||
ctx.context?.tableRef?.current?.api?.refreshInfiniteCache();
|
||||
closeAllModals();
|
||||
},
|
||||
},
|
||||
@@ -552,7 +557,6 @@ export const ContextMenuProvider = ({ children }: ContextMenuProviderProps) => {
|
||||
});
|
||||
}, [
|
||||
ctx.context?.playlistId,
|
||||
ctx.context?.tableRef,
|
||||
ctx.data,
|
||||
ctx.dataNodes,
|
||||
removeFromPlaylistMutation,
|
||||
@@ -882,7 +886,7 @@ export const ContextMenuProvider = ({ children }: ContextMenuProviderProps) => {
|
||||
leftIcon: <Icon icon="star" />,
|
||||
onClick: () => {},
|
||||
rightIcon: (
|
||||
<Group ref={setRatingsRef as any}>
|
||||
<Group>
|
||||
<Rating
|
||||
onChange={(e) => {
|
||||
handleUpdateRating(e);
|
||||
@@ -950,7 +954,7 @@ export const ContextMenuProvider = ({ children }: ContextMenuProviderProps) => {
|
||||
<AnimatePresence>
|
||||
{opened && (
|
||||
<ContextMenu minWidth={125} ref={mergedRef} xPos={ctx.xPos} yPos={ctx.yPos}>
|
||||
<Stack gap={0} ref={setContextMenuRef}>
|
||||
<Stack gap={0}>
|
||||
<Stack gap={0} onClick={closeContextMenu}>
|
||||
{ctx.menuItems?.map((item) => {
|
||||
return (
|
||||
|
||||
@@ -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,7 +24,9 @@
|
||||
position: relative;
|
||||
display: flex;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
|
||||
&:hover {
|
||||
.actions-container {
|
||||
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
} from '/@/renderer/store/settings.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 {
|
||||
@@ -112,7 +113,7 @@ export const CenterControls = ({ playersRef }: CenterControlsProps) => {
|
||||
|
||||
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} />}
|
||||
@@ -253,7 +254,13 @@ export const CenterControls = ({ playersRef }: CenterControlsProps) => {
|
||||
</div>
|
||||
<div className={styles.sliderContainer}>
|
||||
<div className={styles.sliderValueWrapper}>
|
||||
<Text fw={600} isMuted isNoSelect size="xs">
|
||||
<Text
|
||||
className={PlaybackSelectors.elapsedTime}
|
||||
fw={600}
|
||||
isMuted
|
||||
isNoSelect
|
||||
size="xs"
|
||||
>
|
||||
{formattedTime}
|
||||
</Text>
|
||||
</div>
|
||||
@@ -281,7 +288,13 @@ export const CenterControls = ({ playersRef }: CenterControlsProps) => {
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.sliderValueWrapper}>
|
||||
<Text fw={600} isMuted isNoSelect size="xs">
|
||||
<Text
|
||||
className={PlaybackSelectors.totalDuration}
|
||||
fw={600}
|
||||
isMuted
|
||||
isNoSelect
|
||||
size="xs"
|
||||
>
|
||||
{duration}
|
||||
</Text>
|
||||
</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 = () => {
|
||||
@@ -46,6 +47,11 @@ export const LeftControls = () => {
|
||||
);
|
||||
|
||||
const handleToggleFullScreenPlayer = (e?: KeyboardEvent | MouseEvent<HTMLDivElement>) => {
|
||||
// don't toggle if right click
|
||||
if (e && 'button' in e && e.button === 2) {
|
||||
return;
|
||||
}
|
||||
|
||||
e?.stopPropagation();
|
||||
setFullScreenPlayerStore({ expanded: !isFullScreenPlayerExpanded });
|
||||
};
|
||||
@@ -55,6 +61,15 @@ export const LeftControls = () => {
|
||||
setSideBar({ image: true });
|
||||
};
|
||||
|
||||
const handleToggleContextMenu = (e: MouseEvent<HTMLDivElement>) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
if (isSongDefined && !isFullScreenPlayerExpanded) {
|
||||
handleGeneralContextMenu(e, [currentSong!]);
|
||||
}
|
||||
};
|
||||
|
||||
const stopPropagation = (e?: MouseEvent) => e?.stopPropagation();
|
||||
|
||||
useHotkeys([
|
||||
@@ -79,6 +94,7 @@ export const LeftControls = () => {
|
||||
initial={{ opacity: 0, x: -50 }}
|
||||
key="playerbar-image"
|
||||
onClick={handleToggleFullScreenPlayer}
|
||||
onContextMenu={handleToggleContextMenu}
|
||||
role="button"
|
||||
transition={{ duration: 0.2, ease: 'easeIn' }}
|
||||
>
|
||||
@@ -89,7 +105,10 @@ export const LeftControls = () => {
|
||||
openDelay={500}
|
||||
>
|
||||
<Image
|
||||
className={styles.playerbarImage}
|
||||
className={clsx(
|
||||
styles.playerbarImage,
|
||||
PlaybackSelectors.playerCoverArt,
|
||||
)}
|
||||
loading="eager"
|
||||
src={currentSong?.imageUrl ?? ''}
|
||||
/>
|
||||
@@ -124,9 +143,11 @@ 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
|
||||
onContextMenu={handleToggleContextMenu} // Ajout du clic droit
|
||||
overflow="hidden"
|
||||
to={AppRoute.NOW_PLAYING}
|
||||
>
|
||||
@@ -148,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) => (
|
||||
@@ -174,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}>
|
||||
|
||||
@@ -115,7 +115,9 @@ export const RightControls = () => {
|
||||
};
|
||||
|
||||
const isSongDefined = Boolean(currentSong?.id);
|
||||
const showRating = isSongDefined && server?.type === ServerType.NAVIDROME;
|
||||
const showRating =
|
||||
isSongDefined &&
|
||||
(server?.type === ServerType.NAVIDROME || server?.type === ServerType.SUBSONIC);
|
||||
|
||||
useHotkeys([
|
||||
[bindings.volumeDown.isGlobal ? '' : bindings.volumeDown.hotkey, handleVolumeDown],
|
||||
|
||||
@@ -108,12 +108,13 @@ export const useScrobble = () => {
|
||||
) {
|
||||
const artists =
|
||||
currentSong.artists?.length > 0
|
||||
? currentSong.artists.map((artist) => artist.name).join(', ')
|
||||
? currentSong.artists.map((artist) => artist.name).join(' · ')
|
||||
: currentSong.artistName;
|
||||
|
||||
new Notification(`Now playing ${currentSong.name}`, {
|
||||
body: `by ${artists} on ${currentSong.album}`,
|
||||
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' })}
|
||||
|
||||
@@ -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'],
|
||||
];
|
||||
|
||||
|
||||
@@ -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,6 +6,7 @@ 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;
|
||||
@@ -17,6 +18,46 @@ 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={'latest'}
|
||||
onChange={(value) => {
|
||||
if (!value) return;
|
||||
localSettings?.set('release_channel', value);
|
||||
setSettings({
|
||||
window: {
|
||||
...settings,
|
||||
releaseChannel: value as 'beta' | 'latest',
|
||||
},
|
||||
});
|
||||
}}
|
||||
value={settings.releaseChannel}
|
||||
/>
|
||||
),
|
||||
description: t('setting.releaseChannel', {
|
||||
context: 'description',
|
||||
postProcess: 'sentenceCase',
|
||||
}),
|
||||
isHidden: !isElectron(),
|
||||
title: t('setting.releaseChannel', { postProcess: 'sentenceCase' }),
|
||||
},
|
||||
{
|
||||
control: (
|
||||
<Switch
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import clsx from 'clsx';
|
||||
import { AnimatePresence, motion } from 'motion/react';
|
||||
import { CSSProperties, useMemo } from 'react';
|
||||
import { CSSProperties, MouseEvent, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
|
||||
import styles from './sidebar.module.css';
|
||||
|
||||
import { SONG_CONTEXT_MENU_ITEMS } from '/@/renderer/features/context-menu/context-menu-items';
|
||||
import { useHandleGeneralContextMenu } from '/@/renderer/features/context-menu/hooks/use-handle-context-menu';
|
||||
import { ActionBar } from '/@/renderer/features/sidebar/components/action-bar';
|
||||
import { SidebarIcon } from '/@/renderer/features/sidebar/components/sidebar-icon';
|
||||
import { SidebarItem } from '/@/renderer/features/sidebar/components/sidebar-item';
|
||||
@@ -31,6 +33,7 @@ import { Group } from '/@/shared/components/group/group';
|
||||
import { ScrollArea } from '/@/shared/components/scroll-area/scroll-area';
|
||||
import { Text } from '/@/shared/components/text/text';
|
||||
import { Tooltip } from '/@/shared/components/tooltip/tooltip';
|
||||
import { LibraryItem } from '/@/shared/types/domain-types';
|
||||
import { Platform } from '/@/shared/types/types';
|
||||
|
||||
export const Sidebar = () => {
|
||||
@@ -39,7 +42,7 @@ export const Sidebar = () => {
|
||||
const sidebar = useSidebarStore();
|
||||
const { setSideBar } = useAppStoreActions();
|
||||
const { sidebarPlaylistList } = useGeneralSettings();
|
||||
const imageUrl = useCurrentSong()?.imageUrl;
|
||||
const currentSong = useCurrentSong();
|
||||
|
||||
const translatedSidebarItemMap = useMemo(
|
||||
() => ({
|
||||
@@ -56,12 +59,13 @@ export const Sidebar = () => {
|
||||
}),
|
||||
[t],
|
||||
);
|
||||
const upsizedImageUrl = imageUrl
|
||||
const upsizedImageUrl = currentSong?.imageUrl
|
||||
?.replace(/size=\d+/, 'size=450')
|
||||
.replace(/width=\d+/, 'width=450')
|
||||
.replace(/height=\d+/, 'height=450');
|
||||
|
||||
const showImage = sidebar.image;
|
||||
const isSongDefined = Boolean(currentSong?.id);
|
||||
|
||||
const setFullScreenPlayerStore = useSetFullScreenPlayerStore();
|
||||
const { expanded: isFullScreenPlayerExpanded } = useFullScreenPlayerStore();
|
||||
@@ -69,6 +73,20 @@ export const Sidebar = () => {
|
||||
setFullScreenPlayerStore({ expanded: !isFullScreenPlayerExpanded });
|
||||
};
|
||||
|
||||
const handleGeneralContextMenu = useHandleGeneralContextMenu(
|
||||
LibraryItem.SONG,
|
||||
SONG_CONTEXT_MENU_ITEMS,
|
||||
);
|
||||
|
||||
const handleToggleContextMenu = (e: MouseEvent<HTMLDivElement>) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
if (isSongDefined && !isFullScreenPlayerExpanded) {
|
||||
handleGeneralContextMenu(e, [currentSong!]);
|
||||
}
|
||||
};
|
||||
|
||||
const { sidebarItems } = useGeneralSettings();
|
||||
const { windowBarStyle } = useWindowSettings();
|
||||
|
||||
@@ -167,6 +185,7 @@ export const Sidebar = () => {
|
||||
initial={{ opacity: 0, y: 200 }}
|
||||
key="sidebar-image"
|
||||
onClick={expandFullScreenPlayer}
|
||||
onContextMenu={handleToggleContextMenu}
|
||||
role="button"
|
||||
style={
|
||||
{
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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',
|
||||
@@ -207,6 +215,7 @@ export interface SettingsState {
|
||||
clientId: string;
|
||||
displayType: DiscordDisplayType;
|
||||
enabled: boolean;
|
||||
linkType: DiscordLinkType;
|
||||
showAsListening: boolean;
|
||||
showPaused: boolean;
|
||||
showServerImage: boolean;
|
||||
@@ -222,6 +231,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 };
|
||||
@@ -322,6 +333,7 @@ export interface SettingsState {
|
||||
exitToTray: boolean;
|
||||
minimizeToTray: boolean;
|
||||
preventSleepOnPlayback: boolean;
|
||||
releaseChannel: 'beta' | 'latest';
|
||||
startMinimized: boolean;
|
||||
tray: boolean;
|
||||
windowBarStyle: Platform;
|
||||
@@ -364,6 +376,7 @@ const initialState: SettingsState = {
|
||||
clientId: '1165957668758900787',
|
||||
displayType: DiscordDisplayType.FEISHIN,
|
||||
enabled: false,
|
||||
linkType: DiscordLinkType.NONE,
|
||||
showAsListening: false,
|
||||
showPaused: true,
|
||||
showServerImage: false,
|
||||
@@ -379,6 +392,8 @@ const initialState: SettingsState = {
|
||||
albumArtRes: undefined,
|
||||
albumBackground: false,
|
||||
albumBackgroundBlur: 6,
|
||||
artistBackground: false,
|
||||
artistBackgroundBlur: 6,
|
||||
artistItems,
|
||||
buttonSize: 15,
|
||||
disabledContextMenu: {},
|
||||
@@ -668,6 +683,7 @@ const initialState: SettingsState = {
|
||||
exitToTray: false,
|
||||
minimizeToTray: false,
|
||||
preventSleepOnPlayback: false,
|
||||
releaseChannel: 'latest',
|
||||
startMinimized: false,
|
||||
tray: true,
|
||||
windowBarStyle: platformDefaultWindowBarStyle,
|
||||
|
||||
@@ -7,7 +7,7 @@ const SANITIZE_OPTIONS: Config = {
|
||||
ALLOWED_URI_REGEXP: /^(http(s?):)?\/\/.+/i,
|
||||
};
|
||||
|
||||
const regex = /(url\("?)(?!data:)/gim;
|
||||
const regex = /(url\(["'](?!data:))/gim;
|
||||
|
||||
const addStyles = (output: string[], styles: CSSStyleDeclaration) => {
|
||||
for (let prop = styles.length - 1; prop >= 0; prop -= 1) {
|
||||
|
||||
@@ -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