mirror of
https://github.com/jeffvli/feishin.git
synced 2026-05-07 04:20:12 +02:00
Compare commits
51 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 |
@@ -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.1",
|
||||
"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
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -541,7 +541,6 @@ export const ContextMenuProvider = ({ children }: ContextMenuProviderProps) => {
|
||||
});
|
||||
},
|
||||
onSuccess: () => {
|
||||
ctx.context?.tableRef?.current?.api?.refreshInfiniteCache();
|
||||
closeAllModals();
|
||||
},
|
||||
},
|
||||
@@ -558,7 +557,6 @@ export const ContextMenuProvider = ({ children }: ContextMenuProviderProps) => {
|
||||
});
|
||||
}, [
|
||||
ctx.context?.playlistId,
|
||||
ctx.context?.tableRef,
|
||||
ctx.data,
|
||||
ctx.dataNodes,
|
||||
removeFromPlaylistMutation,
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useCallback, useEffect, useState } from 'react';
|
||||
import { controller } from '/@/renderer/api/controller';
|
||||
import {
|
||||
DiscordDisplayType,
|
||||
DiscordLinkType,
|
||||
getServerById,
|
||||
useAppStore,
|
||||
useDiscordSettings,
|
||||
@@ -77,6 +78,34 @@ export const useDiscordRpc = () => {
|
||||
type: discordSettings.showAsListening ? 2 : 0,
|
||||
};
|
||||
|
||||
if (
|
||||
(discordSettings.linkType == DiscordLinkType.LAST_FM ||
|
||||
discordSettings.linkType == DiscordLinkType.MBZ_LAST_FM) &&
|
||||
song?.artistName
|
||||
) {
|
||||
activity.stateUrl =
|
||||
'https://www.last.fm/music/' + encodeURIComponent(song.artists[0].name);
|
||||
activity.detailsUrl =
|
||||
'https://www.last.fm/music/' +
|
||||
encodeURIComponent(song.albumArtists[0].name) +
|
||||
'/' +
|
||||
encodeURIComponent(song.album || '_') +
|
||||
'/' +
|
||||
encodeURIComponent(song.name);
|
||||
}
|
||||
|
||||
if (
|
||||
discordSettings.linkType == DiscordLinkType.MBZ ||
|
||||
discordSettings.linkType == DiscordLinkType.MBZ_LAST_FM
|
||||
) {
|
||||
if (song?.mbzTrackId) {
|
||||
activity.detailsUrl = 'https://musicbrainz.org/track/' + song.mbzTrackId;
|
||||
} else if (song?.mbzRecordingId) {
|
||||
activity.detailsUrl =
|
||||
'https://musicbrainz.org/recording/' + song.mbzRecordingId;
|
||||
}
|
||||
}
|
||||
|
||||
if ((current[2] as PlayerStatus) === PlayerStatus.PLAYING) {
|
||||
if (start && end) {
|
||||
activity.startTimestamp = start;
|
||||
@@ -145,6 +174,7 @@ export const useDiscordRpc = () => {
|
||||
generalSettings.lastfmApiKey,
|
||||
discordSettings.clientId,
|
||||
discordSettings.displayType,
|
||||
discordSettings.linkType,
|
||||
lastUniqueId,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { useMemo, useRef } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { queryKeys } from '/@/renderer/api/query-keys';
|
||||
import { FeatureCarousel } from '/@/renderer/components/feature-carousel/feature-carousel';
|
||||
import { MemoizedSwiperGridCarousel } from '/@/renderer/components/grid-carousel/grid-carousel';
|
||||
import { NativeScrollArea } from '/@/renderer/components/native-scroll-area/native-scroll-area';
|
||||
@@ -32,12 +30,16 @@ import {
|
||||
} from '/@/shared/types/domain-types';
|
||||
import { Platform } from '/@/shared/types/types';
|
||||
|
||||
const BASE_QUERY_ARGS = {
|
||||
limit: 15,
|
||||
sortOrder: SortOrder.DESC,
|
||||
startIndex: 0,
|
||||
};
|
||||
|
||||
const HomeRoute = () => {
|
||||
const { t } = useTranslation();
|
||||
const queryClient = useQueryClient();
|
||||
const scrollAreaRef = useRef<HTMLDivElement>(null);
|
||||
const server = useCurrentServer();
|
||||
const itemsPerPage = 15;
|
||||
const { windowBarStyle } = useWindowSettings();
|
||||
const { homeFeature, homeItems } = useGeneralSettings();
|
||||
|
||||
@@ -56,59 +58,66 @@ const HomeRoute = () => {
|
||||
serverId: server?.id,
|
||||
});
|
||||
|
||||
const isJellyfin = server?.type === ServerType.JELLYFIN;
|
||||
|
||||
const featureItemsWithImage = useMemo(() => {
|
||||
return feature.data?.items?.filter((item) => item.imageUrl) ?? [];
|
||||
}, [feature.data?.items]);
|
||||
|
||||
const queriesEnabled = useMemo(() => {
|
||||
return homeItems.reduce(
|
||||
(previous: Record<HomeItem, boolean>, current) => ({
|
||||
...previous,
|
||||
[current.id]: !current.disabled,
|
||||
}),
|
||||
{} as Record<HomeItem, boolean>,
|
||||
);
|
||||
}, [homeItems]);
|
||||
|
||||
const random = useAlbumList({
|
||||
options: {
|
||||
enabled: queriesEnabled[HomeItem.RANDOM],
|
||||
staleTime: 1000 * 60 * 5,
|
||||
},
|
||||
query: {
|
||||
limit: itemsPerPage,
|
||||
...BASE_QUERY_ARGS,
|
||||
sortBy: AlbumListSort.RANDOM,
|
||||
sortOrder: SortOrder.ASC,
|
||||
startIndex: 0,
|
||||
},
|
||||
serverId: server?.id,
|
||||
});
|
||||
|
||||
const recentlyPlayed = useRecentlyPlayed({
|
||||
options: {
|
||||
enabled: queriesEnabled[HomeItem.RECENTLY_PLAYED] && !isJellyfin,
|
||||
staleTime: 0,
|
||||
},
|
||||
query: {
|
||||
limit: itemsPerPage,
|
||||
...BASE_QUERY_ARGS,
|
||||
sortBy: AlbumListSort.RECENTLY_PLAYED,
|
||||
sortOrder: SortOrder.DESC,
|
||||
startIndex: 0,
|
||||
},
|
||||
serverId: server?.id,
|
||||
});
|
||||
|
||||
const recentlyAdded = useAlbumList({
|
||||
options: {
|
||||
enabled: queriesEnabled[HomeItem.RECENTLY_ADDED],
|
||||
staleTime: 1000 * 60 * 5,
|
||||
},
|
||||
query: {
|
||||
limit: itemsPerPage,
|
||||
...BASE_QUERY_ARGS,
|
||||
sortBy: AlbumListSort.RECENTLY_ADDED,
|
||||
sortOrder: SortOrder.DESC,
|
||||
startIndex: 0,
|
||||
},
|
||||
serverId: server?.id,
|
||||
});
|
||||
|
||||
const mostPlayedAlbums = useAlbumList({
|
||||
options: {
|
||||
enabled: server?.type === ServerType.SUBSONIC || server?.type === ServerType.NAVIDROME,
|
||||
enabled: !isJellyfin && queriesEnabled[HomeItem.MOST_PLAYED],
|
||||
staleTime: 1000 * 60 * 5,
|
||||
},
|
||||
query: {
|
||||
limit: itemsPerPage,
|
||||
...BASE_QUERY_ARGS,
|
||||
sortBy: AlbumListSort.PLAY_COUNT,
|
||||
sortOrder: SortOrder.DESC,
|
||||
startIndex: 0,
|
||||
},
|
||||
serverId: server?.id,
|
||||
});
|
||||
@@ -116,27 +125,38 @@ const HomeRoute = () => {
|
||||
const mostPlayedSongs = useSongList(
|
||||
{
|
||||
options: {
|
||||
enabled: server?.type === ServerType.JELLYFIN,
|
||||
enabled: isJellyfin && queriesEnabled[HomeItem.MOST_PLAYED],
|
||||
staleTime: 1000 * 60 * 5,
|
||||
},
|
||||
query: {
|
||||
limit: itemsPerPage,
|
||||
...BASE_QUERY_ARGS,
|
||||
sortBy: SongListSort.PLAY_COUNT,
|
||||
sortOrder: SortOrder.DESC,
|
||||
startIndex: 0,
|
||||
},
|
||||
serverId: server?.id,
|
||||
},
|
||||
300,
|
||||
);
|
||||
|
||||
const recentlyReleased = useAlbumList({
|
||||
options: {
|
||||
enabled: queriesEnabled[HomeItem.RECENTLY_RELEASED],
|
||||
staleTime: 1000 * 60 * 5,
|
||||
},
|
||||
query: {
|
||||
...BASE_QUERY_ARGS,
|
||||
sortBy: AlbumListSort.RELEASE_DATE,
|
||||
},
|
||||
serverId: server?.id,
|
||||
});
|
||||
|
||||
const isLoading =
|
||||
random.isLoading ||
|
||||
recentlyPlayed.isLoading ||
|
||||
recentlyAdded.isLoading ||
|
||||
(server?.type === ServerType.JELLYFIN && mostPlayedSongs.isLoading) ||
|
||||
((server?.type === ServerType.SUBSONIC || server?.type === ServerType.NAVIDROME) &&
|
||||
mostPlayedAlbums.isLoading);
|
||||
(random.isLoading && queriesEnabled[HomeItem.RANDOM]) ||
|
||||
(recentlyPlayed.isLoading && queriesEnabled[HomeItem.RECENTLY_PLAYED] && !isJellyfin) ||
|
||||
(recentlyAdded.isLoading && queriesEnabled[HomeItem.RECENTLY_ADDED]) ||
|
||||
(recentlyReleased.isLoading && queriesEnabled[HomeItem.RECENTLY_RELEASED]) ||
|
||||
(((isJellyfin && mostPlayedSongs.isLoading) ||
|
||||
(!isJellyfin && mostPlayedAlbums.isLoading)) &&
|
||||
queriesEnabled[HomeItem.MOST_PLAYED]);
|
||||
|
||||
if (isLoading) {
|
||||
return <Spinner container />;
|
||||
@@ -144,48 +164,35 @@ const HomeRoute = () => {
|
||||
|
||||
const carousels = {
|
||||
[HomeItem.MOST_PLAYED]: {
|
||||
data:
|
||||
server?.type === ServerType.JELLYFIN
|
||||
? mostPlayedSongs?.data?.items
|
||||
: mostPlayedAlbums?.data?.items,
|
||||
itemType: server?.type === ServerType.JELLYFIN ? LibraryItem.SONG : LibraryItem.ALBUM,
|
||||
pagination: {
|
||||
itemsPerPage,
|
||||
},
|
||||
sortBy:
|
||||
server?.type === ServerType.JELLYFIN
|
||||
? SongListSort.PLAY_COUNT
|
||||
: AlbumListSort.PLAY_COUNT,
|
||||
sortOrder: SortOrder.DESC,
|
||||
data: isJellyfin ? mostPlayedSongs?.data?.items : mostPlayedAlbums?.data?.items,
|
||||
itemType: isJellyfin ? LibraryItem.SONG : LibraryItem.ALBUM,
|
||||
query: isJellyfin ? mostPlayedSongs : mostPlayedAlbums,
|
||||
title: t('page.home.mostPlayed', { postProcess: 'sentenceCase' }),
|
||||
},
|
||||
[HomeItem.RANDOM]: {
|
||||
data: random?.data?.items,
|
||||
itemType: LibraryItem.ALBUM,
|
||||
sortBy: AlbumListSort.RANDOM,
|
||||
sortOrder: SortOrder.ASC,
|
||||
query: random,
|
||||
title: t('page.home.explore', { postProcess: 'sentenceCase' }),
|
||||
},
|
||||
[HomeItem.RECENTLY_ADDED]: {
|
||||
data: recentlyAdded?.data?.items,
|
||||
itemType: LibraryItem.ALBUM,
|
||||
pagination: {
|
||||
itemsPerPage,
|
||||
},
|
||||
sortBy: AlbumListSort.RECENTLY_ADDED,
|
||||
sortOrder: SortOrder.DESC,
|
||||
query: recentlyAdded,
|
||||
title: t('page.home.newlyAdded', { postProcess: 'sentenceCase' }),
|
||||
},
|
||||
[HomeItem.RECENTLY_PLAYED]: {
|
||||
data: recentlyPlayed?.data?.items,
|
||||
itemType: LibraryItem.ALBUM,
|
||||
pagination: {
|
||||
itemsPerPage,
|
||||
},
|
||||
sortBy: AlbumListSort.RECENTLY_PLAYED,
|
||||
sortOrder: SortOrder.DESC,
|
||||
query: recentlyPlayed,
|
||||
title: t('page.home.recentlyPlayed', { postProcess: 'sentenceCase' }),
|
||||
},
|
||||
[HomeItem.RECENTLY_RELEASED]: {
|
||||
data: recentlyReleased?.data?.items,
|
||||
itemType: LibraryItem.ALBUM,
|
||||
query: recentlyReleased,
|
||||
title: t('page.home.recentlyReleased', { postProcess: 'sentenceCase' }),
|
||||
},
|
||||
};
|
||||
|
||||
const sortedCarousel = homeItems
|
||||
@@ -193,7 +200,7 @@ const HomeRoute = () => {
|
||||
if (item.disabled) {
|
||||
return false;
|
||||
}
|
||||
if (server?.type === ServerType.JELLYFIN && item.id === HomeItem.RECENTLY_PLAYED) {
|
||||
if (isJellyfin && item.id === HomeItem.RECENTLY_PLAYED) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -204,36 +211,6 @@ const HomeRoute = () => {
|
||||
uniqueId: item.id,
|
||||
}));
|
||||
|
||||
const invalidateCarouselQuery = (carousel: {
|
||||
itemType: LibraryItem;
|
||||
sortBy: AlbumListSort | SongListSort;
|
||||
sortOrder: SortOrder;
|
||||
}) => {
|
||||
if (carousel.itemType === LibraryItem.ALBUM) {
|
||||
queryClient.invalidateQueries({
|
||||
exact: false,
|
||||
queryKey: queryKeys.albums.list(server?.id, {
|
||||
limit: itemsPerPage,
|
||||
sortBy: carousel.sortBy,
|
||||
sortOrder: carousel.sortOrder,
|
||||
startIndex: 0,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
if (carousel.itemType === LibraryItem.SONG) {
|
||||
queryClient.invalidateQueries({
|
||||
exact: false,
|
||||
queryKey: queryKeys.songs.list(server?.id, {
|
||||
limit: itemsPerPage,
|
||||
sortBy: carousel.sortBy,
|
||||
sortOrder: carousel.sortOrder,
|
||||
startIndex: 0,
|
||||
}),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<AnimatedPage>
|
||||
<NativeScrollArea
|
||||
@@ -266,7 +243,7 @@ const HomeRoute = () => {
|
||||
slugs: [
|
||||
{
|
||||
idProperty:
|
||||
server?.type === ServerType.JELLYFIN &&
|
||||
isJellyfin &&
|
||||
carousel.itemType === LibraryItem.SONG
|
||||
? 'albumId'
|
||||
: 'id',
|
||||
@@ -297,8 +274,7 @@ const HomeRoute = () => {
|
||||
slugs: [
|
||||
{
|
||||
idProperty:
|
||||
server?.type === ServerType.JELLYFIN &&
|
||||
carousel.itemType === LibraryItem.SONG
|
||||
isJellyfin && carousel.itemType === LibraryItem.SONG
|
||||
? 'albumId'
|
||||
: 'id',
|
||||
slugProperty: 'albumId',
|
||||
@@ -310,7 +286,7 @@ const HomeRoute = () => {
|
||||
<Group>
|
||||
<TextTitle order={3}>{carousel.title}</TextTitle>
|
||||
<ActionIcon
|
||||
onClick={() => invalidateCarouselQuery(carousel)}
|
||||
onClick={() => carousel.query.refetch()}
|
||||
variant="transparent"
|
||||
>
|
||||
<Icon icon="refresh" />
|
||||
|
||||
@@ -81,7 +81,7 @@ const formatArtists = (artists: null | RelatedArtist[] | undefined) =>
|
||||
{artist.name || '—'}
|
||||
</Text>
|
||||
) : (
|
||||
<Text overflow="visible" size="md">
|
||||
<Text component="span" overflow="visible" size="md">
|
||||
{artist.name || '-'}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
@@ -24,8 +24,8 @@
|
||||
position: relative;
|
||||
display: flex;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-width: 0;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
|
||||
&:hover {
|
||||
|
||||
@@ -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 = () => {
|
||||
@@ -104,7 +105,10 @@ export const LeftControls = () => {
|
||||
openDelay={500}
|
||||
>
|
||||
<Image
|
||||
className={styles.playerbarImage}
|
||||
className={clsx(
|
||||
styles.playerbarImage,
|
||||
PlaybackSelectors.playerCoverArt,
|
||||
)}
|
||||
loading="eager"
|
||||
src={currentSong?.imageUrl ?? ''}
|
||||
/>
|
||||
@@ -139,6 +143,7 @@ export const LeftControls = () => {
|
||||
<div className={styles.lineItem} onClick={stopPropagation}>
|
||||
<Group align="center" gap="xs" wrap="nowrap">
|
||||
<Text
|
||||
className={PlaybackSelectors.songTitle}
|
||||
component={Link}
|
||||
fw={500}
|
||||
isLink
|
||||
@@ -164,7 +169,11 @@ export const LeftControls = () => {
|
||||
</Group>
|
||||
</div>
|
||||
<div
|
||||
className={clsx(styles.lineItem, styles.secondary)}
|
||||
className={clsx(
|
||||
styles.lineItem,
|
||||
styles.secondary,
|
||||
PlaybackSelectors.songArtist,
|
||||
)}
|
||||
onClick={stopPropagation}
|
||||
>
|
||||
{artists?.map((artist, index) => (
|
||||
@@ -190,7 +199,11 @@ export const LeftControls = () => {
|
||||
))}
|
||||
</div>
|
||||
<div
|
||||
className={clsx(styles.lineItem, styles.secondary)}
|
||||
className={clsx(
|
||||
styles.lineItem,
|
||||
styles.secondary,
|
||||
PlaybackSelectors.songAlbum,
|
||||
)}
|
||||
onClick={stopPropagation}
|
||||
>
|
||||
<Text
|
||||
|
||||
@@ -6,6 +6,7 @@ import styles from './player-button.module.css';
|
||||
|
||||
import { ActionIcon, ActionIconProps } from '/@/shared/components/action-icon/action-icon';
|
||||
import { Tooltip, TooltipProps } from '/@/shared/components/tooltip/tooltip';
|
||||
import { PlaybackSelectors } from '/@/shared/constants/playback-selectors';
|
||||
|
||||
interface PlayerButtonProps extends Omit<ActionIconProps, 'icon' | 'variant'> {
|
||||
icon: ReactNode;
|
||||
@@ -62,9 +63,13 @@ interface PlayButtonProps extends Omit<ActionIconProps, 'icon' | 'variant'> {
|
||||
|
||||
export const PlayButton = forwardRef<HTMLButtonElement, PlayButtonProps>(
|
||||
({ isPaused, onClick, ...props }: PlayButtonProps, ref) => {
|
||||
const playerStateClass = isPaused
|
||||
? PlaybackSelectors.playerStatePaused
|
||||
: PlaybackSelectors.playerStatePlaying;
|
||||
|
||||
return (
|
||||
<ActionIcon
|
||||
className={styles.main}
|
||||
className={clsx(styles.main, playerStateClass)}
|
||||
icon={isPaused ? 'mediaPlay' : 'mediaPause'}
|
||||
iconProps={{
|
||||
size: 'lg',
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import clsx from 'clsx';
|
||||
import { MouseEvent, useCallback } from 'react';
|
||||
|
||||
import styles from './playerbar.module.css';
|
||||
@@ -25,6 +26,7 @@ import {
|
||||
usePlaybackType,
|
||||
useSettingsStore,
|
||||
} from '/@/renderer/store/settings.store';
|
||||
import { PlaybackSelectors } from '/@/shared/constants/playback-selectors';
|
||||
import { PlaybackType } from '/@/shared/types/types';
|
||||
|
||||
export const Playerbar = () => {
|
||||
@@ -56,7 +58,7 @@ export const Playerbar = () => {
|
||||
|
||||
return (
|
||||
<div
|
||||
className={styles.container}
|
||||
className={clsx(styles.container, PlaybackSelectors.mediaPlayer)}
|
||||
onClick={playerbarOpenDrawer ? handleToggleFullScreenPlayer : undefined}
|
||||
>
|
||||
<div className={styles.controlsGrid}>
|
||||
|
||||
@@ -114,6 +114,7 @@ export const useScrobble = () => {
|
||||
new Notification(`${currentSong.name}`, {
|
||||
body: `${artists}\n${currentSong.album}`,
|
||||
icon: currentSong.imageUrl || undefined,
|
||||
silent: true,
|
||||
});
|
||||
}
|
||||
}, 1000);
|
||||
|
||||
@@ -4,17 +4,19 @@ import { api } from '/@/renderer/api';
|
||||
import { queryKeys } from '/@/renderer/api/query-keys';
|
||||
import {
|
||||
PlaylistSongListQuery,
|
||||
PlaylistSongListQueryClientSide,
|
||||
ServerListItem,
|
||||
SongDetailQuery,
|
||||
SongListQuery,
|
||||
SongListResponse,
|
||||
SongListSort,
|
||||
SortOrder,
|
||||
sortSongList,
|
||||
} from '/@/shared/types/domain-types';
|
||||
|
||||
export const getPlaylistSongsById = async (args: {
|
||||
id: string;
|
||||
query?: Partial<PlaylistSongListQuery>;
|
||||
query?: Partial<PlaylistSongListQueryClientSide>;
|
||||
queryClient: QueryClient;
|
||||
server: ServerListItem;
|
||||
}) => {
|
||||
@@ -22,13 +24,9 @@ export const getPlaylistSongsById = async (args: {
|
||||
|
||||
const queryFilter: PlaylistSongListQuery = {
|
||||
id,
|
||||
sortBy: SongListSort.ID,
|
||||
sortOrder: SortOrder.ASC,
|
||||
startIndex: 0,
|
||||
...query,
|
||||
};
|
||||
|
||||
const queryKey = queryKeys.playlists.songList(server?.id, id, queryFilter);
|
||||
const queryKey = queryKeys.playlists.songList(server?.id, id);
|
||||
|
||||
const res = await queryClient.fetchQuery(
|
||||
queryKey,
|
||||
@@ -46,6 +44,14 @@ export const getPlaylistSongsById = async (args: {
|
||||
},
|
||||
);
|
||||
|
||||
if (res) {
|
||||
res.items = sortSongList(
|
||||
res.items,
|
||||
query?.sortBy || SongListSort.ID,
|
||||
query?.sortOrder || SortOrder.ASC,
|
||||
);
|
||||
}
|
||||
|
||||
return res;
|
||||
};
|
||||
|
||||
|
||||
@@ -145,12 +145,7 @@ export const AddToPlaylistContextModal = ({
|
||||
const uniqueSongIds: string[] = [];
|
||||
|
||||
if (values.skipDuplicates) {
|
||||
const query = {
|
||||
id: playlistId,
|
||||
startIndex: 0,
|
||||
};
|
||||
|
||||
const queryKey = queryKeys.playlists.songList(server?.id || '', playlistId, query);
|
||||
const queryKey = queryKeys.playlists.songList(server?.id || '', playlistId);
|
||||
|
||||
const playlistSongsRes = await queryClient.fetchQuery(queryKey, ({ signal }) => {
|
||||
if (!server)
|
||||
@@ -164,9 +159,6 @@ export const AddToPlaylistContextModal = ({
|
||||
},
|
||||
query: {
|
||||
id: playlistId,
|
||||
sortBy: SongListSort.ID,
|
||||
sortOrder: SortOrder.ASC,
|
||||
startIndex: 0,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,7 +2,6 @@ import type {
|
||||
BodyScrollEvent,
|
||||
ColDef,
|
||||
GridReadyEvent,
|
||||
IDatasource,
|
||||
PaginationChangedEvent,
|
||||
RowDoubleClickedEvent,
|
||||
RowDragEvent,
|
||||
@@ -27,7 +26,6 @@ import {
|
||||
} from '/@/renderer/features/context-menu/context-menu-items';
|
||||
import { usePlayQueueAdd } from '/@/renderer/features/player';
|
||||
import { usePlaylistDetail } from '/@/renderer/features/playlists/queries/playlist-detail-query';
|
||||
import { usePlaylistSongList } from '/@/renderer/features/playlists/queries/playlist-song-list-query';
|
||||
import { useAppFocus } from '/@/renderer/hooks';
|
||||
import {
|
||||
useCurrentServer,
|
||||
@@ -42,13 +40,15 @@ import { PersistedTableColumn, usePlayButtonBehavior } from '/@/renderer/store/s
|
||||
import { toast } from '/@/shared/components/toast/toast';
|
||||
import {
|
||||
LibraryItem,
|
||||
PlaylistSongListQuery,
|
||||
PlaylistSongListQueryClientSide,
|
||||
QueueSong,
|
||||
ServerType,
|
||||
Song,
|
||||
SongListResponse,
|
||||
SongListSort,
|
||||
SortOrder,
|
||||
} from '/@/shared/types/domain-types';
|
||||
import { ListDisplayType, ServerType } from '/@/shared/types/types';
|
||||
import { ListDisplayType } from '/@/shared/types/types';
|
||||
|
||||
interface PlaylistDetailContentProps {
|
||||
songs?: Song[];
|
||||
@@ -63,7 +63,7 @@ export const PlaylistDetailSongListContent = ({ songs, tableRef }: PlaylistDetai
|
||||
const currentSong = useCurrentSong();
|
||||
const server = useCurrentServer();
|
||||
const page = usePlaylistDetailStore();
|
||||
const filters: Partial<PlaylistSongListQuery> = useMemo(() => {
|
||||
const filters: PlaylistSongListQueryClientSide = useMemo(() => {
|
||||
return {
|
||||
sortBy: page?.table.id[playlistId]?.filter?.sortBy || SongListSort.ID,
|
||||
sortOrder: page?.table.id[playlistId]?.filter?.sortOrder || SortOrder.ASC,
|
||||
@@ -88,20 +88,6 @@ export const PlaylistDetailSongListContent = ({ songs, tableRef }: PlaylistDetai
|
||||
|
||||
const isPaginationEnabled = page.display === ListDisplayType.TABLE_PAGINATED;
|
||||
|
||||
const iSClientSide = server?.type === ServerType.SUBSONIC;
|
||||
|
||||
const checkPlaylistList = usePlaylistSongList({
|
||||
options: {
|
||||
enabled: !iSClientSide,
|
||||
},
|
||||
query: {
|
||||
id: playlistId,
|
||||
limit: 1,
|
||||
startIndex: 0,
|
||||
},
|
||||
serverId: server?.id,
|
||||
});
|
||||
|
||||
const columnDefs: ColDef[] = useMemo(
|
||||
() => getColumnDefs(page.table.columns, false, 'generic'),
|
||||
[page.table.columns],
|
||||
@@ -109,51 +95,9 @@ export const PlaylistDetailSongListContent = ({ songs, tableRef }: PlaylistDetai
|
||||
|
||||
const onGridReady = useCallback(
|
||||
(params: GridReadyEvent) => {
|
||||
if (!iSClientSide) {
|
||||
const dataSource: IDatasource = {
|
||||
getRows: async (params) => {
|
||||
const limit = params.endRow - params.startRow;
|
||||
const startIndex = params.startRow;
|
||||
|
||||
const query: PlaylistSongListQuery = {
|
||||
id: playlistId,
|
||||
limit,
|
||||
startIndex,
|
||||
...filters,
|
||||
};
|
||||
|
||||
const queryKey = queryKeys.playlists.songList(
|
||||
server?.id || '',
|
||||
playlistId,
|
||||
query,
|
||||
);
|
||||
|
||||
if (!server) return;
|
||||
|
||||
const songsRes = await queryClient.fetchQuery(
|
||||
queryKey,
|
||||
async ({ signal }) =>
|
||||
api.controller.getPlaylistSongList({
|
||||
apiClientProps: {
|
||||
server,
|
||||
signal,
|
||||
},
|
||||
query,
|
||||
}),
|
||||
);
|
||||
|
||||
params.successCallback(
|
||||
songsRes?.items || [],
|
||||
songsRes?.totalRecordCount || 0,
|
||||
);
|
||||
},
|
||||
rowCount: undefined,
|
||||
};
|
||||
params.api.setDatasource(dataSource);
|
||||
}
|
||||
params.api?.ensureIndexVisible(pagination.scrollOffset, 'top');
|
||||
},
|
||||
[filters, iSClientSide, pagination.scrollOffset, playlistId, queryClient, server],
|
||||
[pagination.scrollOffset],
|
||||
);
|
||||
|
||||
const handleDragEnd = useCallback(
|
||||
@@ -175,12 +119,32 @@ export const PlaylistDetailSongListContent = ({ songs, tableRef }: PlaylistDetai
|
||||
},
|
||||
});
|
||||
|
||||
setTimeout(() => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.playlists.songList(server?.id || '', playlistId),
|
||||
});
|
||||
e.api.refreshInfiniteCache();
|
||||
}, 200);
|
||||
queryClient.setQueryData<SongListResponse>(
|
||||
queryKeys.playlists.songList(server?.id || '', playlistId),
|
||||
(previous) => {
|
||||
if (previous?.items) {
|
||||
const from = e.node.rowIndex!;
|
||||
const to = e.overIndex;
|
||||
|
||||
const item = previous.items[from];
|
||||
const remaining = previous.items.toSpliced(from, 1);
|
||||
remaining.splice(to, 0, item);
|
||||
|
||||
return {
|
||||
error: previous.error,
|
||||
items: remaining,
|
||||
startIndex: previous.startIndex,
|
||||
totalRecordCount: previous.totalRecordCount,
|
||||
};
|
||||
}
|
||||
|
||||
return previous;
|
||||
},
|
||||
);
|
||||
|
||||
// Nodes have to be redrawn, otherwise the row indexes will be wrong
|
||||
// Maybe it's possible to only redraw necessary rows to not be as expensive?
|
||||
tableRef.current?.api.redrawRows();
|
||||
} catch (error) {
|
||||
toast.error({
|
||||
message: (error as Error).message,
|
||||
@@ -189,7 +153,7 @@ export const PlaylistDetailSongListContent = ({ songs, tableRef }: PlaylistDetai
|
||||
}
|
||||
}
|
||||
},
|
||||
[playlistId, queryClient, server],
|
||||
[playlistId, queryClient, server, tableRef],
|
||||
);
|
||||
|
||||
const handleGridSizeChange = () => {
|
||||
@@ -286,7 +250,9 @@ export const PlaylistDetailSongListContent = ({ songs, tableRef }: PlaylistDetai
|
||||
const { rowClassRules } = useCurrentSongRowStyles({ tableRef });
|
||||
|
||||
const canDrag =
|
||||
filters.sortBy === SongListSort.ID && !detailQuery?.data?.rules && !iSClientSide;
|
||||
filters.sortBy === SongListSort.ID &&
|
||||
!detailQuery?.data?.rules &&
|
||||
server?.type !== ServerType.SUBSONIC;
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -303,9 +269,6 @@ export const PlaylistDetailSongListContent = ({ songs, tableRef }: PlaylistDetai
|
||||
status,
|
||||
}}
|
||||
getRowId={(data) => data.data.uniqueId}
|
||||
infiniteInitialRowCount={
|
||||
iSClientSide ? undefined : checkPlaylistList.data?.totalRecordCount || 100
|
||||
}
|
||||
// https://github.com/ag-grid/ag-grid/issues/5284
|
||||
// Key is used to force remount of table when display, rowHeight, or server changes
|
||||
key={`table-${page.display}-${page.table.rowHeight}-${server?.id}`}
|
||||
@@ -326,7 +289,7 @@ export const PlaylistDetailSongListContent = ({ songs, tableRef }: PlaylistDetai
|
||||
rowData={songs}
|
||||
rowDragEntireRow={canDrag}
|
||||
rowHeight={page.table.rowHeight || 40}
|
||||
rowModelType={iSClientSide ? 'clientSide' : 'infinite'}
|
||||
rowModelType="clientSide"
|
||||
shouldUpdateSong
|
||||
/>
|
||||
</VirtualGridAutoSizerContainer>
|
||||
|
||||
+35
-102
@@ -1,29 +1,26 @@
|
||||
import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/lib/agGridReact';
|
||||
|
||||
import { IDatasource } from '@ag-grid-community/core';
|
||||
import { closeAllModals, openModal } from '@mantine/modals';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import debounce from 'lodash/debounce';
|
||||
import { MouseEvent, MutableRefObject, useCallback } from 'react';
|
||||
import { ChangeEvent, MouseEvent, MutableRefObject, useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useNavigate, useParams } from 'react-router';
|
||||
|
||||
import i18n from '/@/i18n/i18n';
|
||||
import { api } from '/@/renderer/api';
|
||||
import { queryKeys } from '/@/renderer/api/query-keys';
|
||||
import { SONG_TABLE_COLUMNS } from '/@/renderer/components/virtual-table';
|
||||
import { usePlayQueueAdd } from '/@/renderer/features/player';
|
||||
import { openUpdatePlaylistModal } from '/@/renderer/features/playlists/components/update-playlist-form';
|
||||
import { useDeletePlaylist } from '/@/renderer/features/playlists/mutations/delete-playlist-mutation';
|
||||
import { usePlaylistDetail } from '/@/renderer/features/playlists/queries/playlist-detail-query';
|
||||
import { OrderToggleButton } from '/@/renderer/features/shared';
|
||||
import { ListConfigMenu } from '/@/renderer/features/shared/components/list-config-menu';
|
||||
import { MoreButton } from '/@/renderer/features/shared/components/more-button';
|
||||
import { SearchInput } from '/@/renderer/features/shared/components/search-input';
|
||||
import { useContainerQuery } from '/@/renderer/hooks';
|
||||
import { AppRoute } from '/@/renderer/router/routes';
|
||||
import {
|
||||
PersistedTableColumn,
|
||||
SongListFilter,
|
||||
useCurrentServer,
|
||||
usePlaylistDetailStore,
|
||||
useSetPlaylistDetailFilters,
|
||||
@@ -40,13 +37,7 @@ import { Icon } from '/@/shared/components/icon/icon';
|
||||
import { ConfirmModal } from '/@/shared/components/modal/modal';
|
||||
import { Text } from '/@/shared/components/text/text';
|
||||
import { toast } from '/@/shared/components/toast/toast';
|
||||
import {
|
||||
LibraryItem,
|
||||
PlaylistSongListQuery,
|
||||
ServerType,
|
||||
SongListSort,
|
||||
SortOrder,
|
||||
} from '/@/shared/types/domain-types';
|
||||
import { ServerType, SongListSort, SortOrder } from '/@/shared/types/domain-types';
|
||||
import { ListDisplayType, Play } from '/@/shared/types/types';
|
||||
|
||||
const FILTERS = {
|
||||
@@ -155,7 +146,7 @@ const FILTERS = {
|
||||
},
|
||||
{
|
||||
defaultOrder: SortOrder.ASC,
|
||||
name: i18n.t('filter.playCount', { postProcess: 'titleCase' }),
|
||||
name: i18n.t('filter.genre', { postProcess: 'titleCase' }),
|
||||
value: SongListSort.GENRE,
|
||||
},
|
||||
{
|
||||
@@ -240,11 +231,6 @@ const FILTERS = {
|
||||
name: i18n.t('filter.recentlyAdded', { postProcess: 'titleCase' }),
|
||||
value: SongListSort.RECENTLY_ADDED,
|
||||
},
|
||||
{
|
||||
defaultOrder: SortOrder.DESC,
|
||||
name: i18n.t('filter.recentlyPlayed', { postProcess: 'titleCase' }),
|
||||
value: SongListSort.RECENTLY_PLAYED,
|
||||
},
|
||||
{
|
||||
defaultOrder: SortOrder.DESC,
|
||||
name: i18n.t('filter.releaseYear', { postProcess: 'titleCase' }),
|
||||
@@ -254,11 +240,13 @@ const FILTERS = {
|
||||
};
|
||||
|
||||
interface PlaylistDetailSongListHeaderFiltersProps {
|
||||
handlePlay: (playType: Play) => void;
|
||||
handleToggleShowQueryBuilder: () => void;
|
||||
tableRef: MutableRefObject<AgGridReactType | null>;
|
||||
}
|
||||
|
||||
export const PlaylistDetailSongListHeaderFilters = ({
|
||||
handlePlay,
|
||||
handleToggleShowQueryBuilder,
|
||||
tableRef,
|
||||
}: PlaylistDetailSongListHeaderFiltersProps) => {
|
||||
@@ -270,16 +258,13 @@ export const PlaylistDetailSongListHeaderFilters = ({
|
||||
const setPage = useSetPlaylistStore();
|
||||
const setFilter = useSetPlaylistDetailFilters();
|
||||
const page = usePlaylistDetailStore();
|
||||
const filters: Partial<PlaylistSongListQuery> = {
|
||||
sortBy: page?.table.id[playlistId]?.filter?.sortBy || SongListSort.ID,
|
||||
sortOrder: page?.table.id[playlistId]?.filter?.sortOrder || SortOrder.ASC,
|
||||
};
|
||||
const searchTerm = page?.table.id[playlistId]?.filter?.searchTerm;
|
||||
const sortBy = page?.table.id[playlistId]?.filter?.sortBy || SongListSort.ID;
|
||||
const sortOrder = page?.table.id[playlistId]?.filter?.sortOrder || SortOrder.ASC;
|
||||
|
||||
const detailQuery = usePlaylistDetail({ query: { id: playlistId }, serverId: server?.id });
|
||||
const isSmartPlaylist = detailQuery.data?.rules;
|
||||
|
||||
const handlePlayQueueAdd = usePlayQueueAdd();
|
||||
|
||||
const cq = useContainerQuery();
|
||||
|
||||
const setPagination = useSetPlaylistTablePagination();
|
||||
@@ -287,8 +272,7 @@ export const PlaylistDetailSongListHeaderFilters = ({
|
||||
|
||||
const sortByLabel =
|
||||
(server?.type &&
|
||||
FILTERS[server.type as keyof typeof FILTERS].find((f) => f.value === filters.sortBy)
|
||||
?.name) ||
|
||||
FILTERS[server.type as keyof typeof FILTERS].find((f) => f.value === sortBy)?.name) ||
|
||||
'Unknown';
|
||||
|
||||
const handleItemSize = (e: number) => {
|
||||
@@ -297,93 +281,48 @@ export const PlaylistDetailSongListHeaderFilters = ({
|
||||
|
||||
const debouncedHandleItemSize = debounce(handleItemSize, 20);
|
||||
|
||||
const handleFilterChange = useCallback(
|
||||
async (filters: SongListFilter) => {
|
||||
if (server?.type !== ServerType.SUBSONIC) {
|
||||
const dataSource: IDatasource = {
|
||||
getRows: async (params) => {
|
||||
const limit = params.endRow - params.startRow;
|
||||
const startIndex = params.startRow;
|
||||
const handleFilterChange = useCallback(async () => {
|
||||
tableRef.current?.api.redrawRows();
|
||||
tableRef.current?.api.ensureIndexVisible(0, 'top');
|
||||
|
||||
const queryKey = queryKeys.playlists.songList(
|
||||
server?.id || '',
|
||||
playlistId,
|
||||
{
|
||||
id: playlistId,
|
||||
limit,
|
||||
startIndex,
|
||||
...filters,
|
||||
},
|
||||
);
|
||||
|
||||
const songsRes = await queryClient.fetchQuery(
|
||||
queryKey,
|
||||
async ({ signal }) =>
|
||||
api.controller.getPlaylistSongList({
|
||||
apiClientProps: {
|
||||
server,
|
||||
signal,
|
||||
},
|
||||
query: {
|
||||
id: playlistId,
|
||||
limit,
|
||||
startIndex,
|
||||
...filters,
|
||||
},
|
||||
}),
|
||||
{ cacheTime: 1000 * 60 * 1 },
|
||||
);
|
||||
|
||||
params.successCallback(
|
||||
songsRes?.items || [],
|
||||
songsRes?.totalRecordCount || 0,
|
||||
);
|
||||
},
|
||||
rowCount: undefined,
|
||||
};
|
||||
tableRef.current?.api.setDatasource(dataSource);
|
||||
tableRef.current?.api.purgeInfiniteCache();
|
||||
tableRef.current?.api.ensureIndexVisible(0, 'top');
|
||||
} else {
|
||||
tableRef.current?.api.redrawRows();
|
||||
tableRef.current?.api.ensureIndexVisible(0, 'top');
|
||||
}
|
||||
|
||||
if (page.display === ListDisplayType.TABLE_PAGINATED) {
|
||||
setPagination({ data: { currentPage: 0 } });
|
||||
}
|
||||
},
|
||||
[tableRef, page.display, server, playlistId, queryClient, setPagination],
|
||||
);
|
||||
if (page.display === ListDisplayType.TABLE_PAGINATED) {
|
||||
setPagination({ data: { currentPage: 0 } });
|
||||
}
|
||||
}, [tableRef, page.display, setPagination]);
|
||||
|
||||
const handleRefresh = () => {
|
||||
queryClient.invalidateQueries(queryKeys.albums.list(server?.id || ''));
|
||||
handleFilterChange({ ...page?.table.id[playlistId].filter, ...filters });
|
||||
queryClient.invalidateQueries(queryKeys.playlists.songList(server?.id || '', playlistId));
|
||||
handleFilterChange();
|
||||
};
|
||||
|
||||
const handleSetSortBy = useCallback(
|
||||
(e: MouseEvent<HTMLButtonElement>) => {
|
||||
if (!e.currentTarget?.value || !server?.type) return;
|
||||
|
||||
const sortOrder = FILTERS[server.type as keyof typeof FILTERS].find(
|
||||
const newSortOrder = FILTERS[server.type as keyof typeof FILTERS].find(
|
||||
(f) => f.value === e.currentTarget.value,
|
||||
)?.defaultOrder;
|
||||
|
||||
const updatedFilters = setFilter(playlistId, {
|
||||
setFilter(playlistId, {
|
||||
sortBy: e.currentTarget.value as SongListSort,
|
||||
sortOrder: sortOrder || SortOrder.ASC,
|
||||
sortOrder: newSortOrder || SortOrder.ASC,
|
||||
});
|
||||
|
||||
handleFilterChange(updatedFilters);
|
||||
handleFilterChange();
|
||||
},
|
||||
[handleFilterChange, playlistId, server?.type, setFilter],
|
||||
);
|
||||
|
||||
const handleToggleSortOrder = useCallback(() => {
|
||||
const newSortOrder = filters.sortOrder === SortOrder.ASC ? SortOrder.DESC : SortOrder.ASC;
|
||||
const updatedFilters = setFilter(playlistId, { sortOrder: newSortOrder });
|
||||
handleFilterChange(updatedFilters);
|
||||
}, [filters.sortOrder, handleFilterChange, playlistId, setFilter]);
|
||||
const newSortOrder = sortOrder === SortOrder.ASC ? SortOrder.DESC : SortOrder.ASC;
|
||||
setFilter(playlistId, { sortOrder: newSortOrder });
|
||||
handleFilterChange();
|
||||
}, [sortOrder, handleFilterChange, playlistId, setFilter]);
|
||||
|
||||
const handleSearch = debounce((e: ChangeEvent<HTMLInputElement>) => {
|
||||
setFilter(playlistId, { searchTerm: e.target.value });
|
||||
handleFilterChange();
|
||||
}, 500);
|
||||
|
||||
const handleSetViewType = useCallback(
|
||||
(displayType: ListDisplayType) => {
|
||||
@@ -428,13 +367,6 @@ export const PlaylistDetailSongListHeaderFilters = ({
|
||||
}
|
||||
};
|
||||
|
||||
const handlePlay = async (playType: Play) => {
|
||||
handlePlayQueueAdd?.({
|
||||
byItemType: { id: [playlistId], type: LibraryItem.PLAYLIST },
|
||||
playType,
|
||||
});
|
||||
};
|
||||
|
||||
const deletePlaylistMutation = useDeletePlaylist({});
|
||||
|
||||
const handleDeletePlaylist = useCallback(() => {
|
||||
@@ -484,7 +416,7 @@ export const PlaylistDetailSongListHeaderFilters = ({
|
||||
<DropdownMenu.Dropdown>
|
||||
{FILTERS[server?.type as keyof typeof FILTERS].map((filter) => (
|
||||
<DropdownMenu.Item
|
||||
isSelected={filter.value === filters.sortBy}
|
||||
isSelected={filter.value === sortBy}
|
||||
key={`filter-${filter.name}`}
|
||||
onClick={handleSetSortBy}
|
||||
value={filter.value}
|
||||
@@ -498,7 +430,7 @@ export const PlaylistDetailSongListHeaderFilters = ({
|
||||
<Divider orientation="vertical" />
|
||||
<OrderToggleButton
|
||||
onToggle={handleToggleSortOrder}
|
||||
sortOrder={filters.sortOrder || SortOrder.ASC}
|
||||
sortOrder={sortOrder || SortOrder.ASC}
|
||||
/>
|
||||
<DropdownMenu position="bottom-start">
|
||||
<DropdownMenu.Target>
|
||||
@@ -560,6 +492,7 @@ export const PlaylistDetailSongListHeaderFilters = ({
|
||||
)}
|
||||
</DropdownMenu.Dropdown>
|
||||
</DropdownMenu>
|
||||
<SearchInput defaultValue={searchTerm} onChange={handleSearch} />
|
||||
</Group>
|
||||
<Group>
|
||||
<ListConfigMenu
|
||||
|
||||
@@ -5,25 +5,27 @@ import { useTranslation } from 'react-i18next';
|
||||
import { useParams } from 'react-router';
|
||||
|
||||
import { PageHeader } from '/@/renderer/components/page-header/page-header';
|
||||
import { usePlayQueueAdd } from '/@/renderer/features/player';
|
||||
import { PlaylistDetailSongListHeaderFilters } from '/@/renderer/features/playlists/components/playlist-detail-song-list-header-filters';
|
||||
import { usePlaylistDetail } from '/@/renderer/features/playlists/queries/playlist-detail-query';
|
||||
import { FilterBar, LibraryHeaderBar } from '/@/renderer/features/shared';
|
||||
import { useCurrentServer } from '/@/renderer/store';
|
||||
import { usePlayButtonBehavior } from '/@/renderer/store/settings.store';
|
||||
import { formatDurationString } from '/@/renderer/utils';
|
||||
import { Badge } from '/@/shared/components/badge/badge';
|
||||
import { SpinnerIcon } from '/@/shared/components/spinner/spinner';
|
||||
import { Stack } from '/@/shared/components/stack/stack';
|
||||
import { LibraryItem } from '/@/shared/types/domain-types';
|
||||
import { Play } from '/@/shared/types/types';
|
||||
|
||||
interface PlaylistDetailHeaderProps {
|
||||
handlePlay: (playType: Play) => void;
|
||||
|
||||
handleToggleShowQueryBuilder: () => void;
|
||||
itemCount?: number;
|
||||
tableRef: MutableRefObject<AgGridReactType | null>;
|
||||
}
|
||||
|
||||
export const PlaylistDetailSongListHeader = ({
|
||||
handlePlay,
|
||||
handleToggleShowQueryBuilder,
|
||||
itemCount,
|
||||
tableRef,
|
||||
@@ -32,19 +34,12 @@ export const PlaylistDetailSongListHeader = ({
|
||||
const { playlistId } = useParams() as { playlistId: string };
|
||||
const server = useCurrentServer();
|
||||
const detailQuery = usePlaylistDetail({ query: { id: playlistId }, serverId: server?.id });
|
||||
const handlePlayQueueAdd = usePlayQueueAdd();
|
||||
|
||||
const handlePlay = async (playType: Play) => {
|
||||
handlePlayQueueAdd?.({
|
||||
byItemType: { id: [playlistId], type: LibraryItem.PLAYLIST },
|
||||
playType,
|
||||
});
|
||||
};
|
||||
|
||||
const playButtonBehavior = usePlayButtonBehavior();
|
||||
|
||||
if (detailQuery.isLoading) return null;
|
||||
const isSmartPlaylist = detailQuery?.data?.rules;
|
||||
const playlistDuration = detailQuery?.data?.duration;
|
||||
|
||||
return (
|
||||
<Stack gap={0}>
|
||||
@@ -52,6 +47,7 @@ export const PlaylistDetailSongListHeader = ({
|
||||
<LibraryHeaderBar>
|
||||
<LibraryHeaderBar.PlayButton onClick={() => handlePlay(playButtonBehavior)} />
|
||||
<LibraryHeaderBar.Title>{detailQuery?.data?.name}</LibraryHeaderBar.Title>
|
||||
{!!playlistDuration && <Badge>{formatDurationString(playlistDuration)}</Badge>}
|
||||
<Badge>
|
||||
{itemCount === null || itemCount === undefined ? (
|
||||
<SpinnerIcon />
|
||||
@@ -64,6 +60,7 @@ export const PlaylistDetailSongListHeader = ({
|
||||
</PageHeader>
|
||||
<FilterBar>
|
||||
<PlaylistDetailSongListHeaderFilters
|
||||
handlePlay={handlePlay}
|
||||
handleToggleShowQueryBuilder={handleToggleShowQueryBuilder}
|
||||
tableRef={tableRef}
|
||||
/>
|
||||
|
||||
@@ -29,9 +29,6 @@ export const useAddToPlaylist = (args: MutationHookArgs) => {
|
||||
|
||||
queryClient.invalidateQueries(queryKeys.playlists.list(serverId), { exact: false });
|
||||
queryClient.invalidateQueries(queryKeys.playlists.detail(serverId, variables.query.id));
|
||||
queryClient.invalidateQueries(
|
||||
queryKeys.playlists.detailSongList(serverId, variables.query.id),
|
||||
);
|
||||
queryClient.invalidateQueries(
|
||||
queryKeys.playlists.songList(serverId, variables.query.id),
|
||||
);
|
||||
|
||||
@@ -29,7 +29,7 @@ export const useRemoveFromPlaylist = (options?: MutationOptions) => {
|
||||
queryClient.invalidateQueries(queryKeys.playlists.list(serverId), { exact: false });
|
||||
queryClient.invalidateQueries(queryKeys.playlists.detail(serverId, variables.query.id));
|
||||
queryClient.invalidateQueries(
|
||||
queryKeys.playlists.detailSongList(serverId, variables.query.id),
|
||||
queryKeys.playlists.songList(serverId, variables.query.id),
|
||||
);
|
||||
},
|
||||
...options,
|
||||
|
||||
@@ -20,7 +20,7 @@ export const usePlaylistSongList = (args: QueryHookArgs<PlaylistSongListQuery>)
|
||||
query,
|
||||
});
|
||||
},
|
||||
queryKey: queryKeys.playlists.songList(server?.id || '', query.id, query),
|
||||
queryKey: queryKeys.playlists.songList(server?.id || '', query.id),
|
||||
...options,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/lib/agGridReact';
|
||||
|
||||
import { closeAllModals, openModal } from '@mantine/modals';
|
||||
import Fuse from 'fuse.js';
|
||||
import { motion } from 'motion/react';
|
||||
import { useRef, useState } from 'react';
|
||||
import { useMemo, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { generatePath, useNavigate, useParams } from 'react-router';
|
||||
|
||||
import { useHandlePlayQueueAdd } from '/@/renderer/features/player/hooks/use-handle-playqueue-add';
|
||||
import { PlaylistDetailSongListContent } from '/@/renderer/features/playlists/components/playlist-detail-song-list-content';
|
||||
import { PlaylistDetailSongListHeader } from '/@/renderer/features/playlists/components/playlist-detail-song-list-header';
|
||||
import { PlaylistQueryBuilder } from '/@/renderer/features/playlists/components/playlist-query-builder';
|
||||
@@ -22,12 +24,8 @@ import { Box } from '/@/shared/components/box/box';
|
||||
import { Group } from '/@/shared/components/group/group';
|
||||
import { Text } from '/@/shared/components/text/text';
|
||||
import { toast } from '/@/shared/components/toast/toast';
|
||||
import {
|
||||
PlaylistSongListQuery,
|
||||
ServerType,
|
||||
SongListSort,
|
||||
SortOrder,
|
||||
} from '/@/shared/types/domain-types';
|
||||
import { ServerType, SongListSort, SortOrder, sortSongList } from '/@/shared/types/domain-types';
|
||||
import { Play } from '/@/shared/types/types';
|
||||
|
||||
const PlaylistDetailSongListRoute = () => {
|
||||
const { t } = useTranslation();
|
||||
@@ -35,6 +33,7 @@ const PlaylistDetailSongListRoute = () => {
|
||||
const tableRef = useRef<AgGridReactType | null>(null);
|
||||
const { playlistId } = useParams() as { playlistId: string };
|
||||
const server = useCurrentServer();
|
||||
const handlePlayQueueAdd = useHandlePlayQueueAdd();
|
||||
|
||||
const detailQuery = usePlaylistDetail({ query: { id: playlistId }, serverId: server?.id });
|
||||
const createPlaylistMutation = useCreatePlaylist({});
|
||||
@@ -148,26 +147,61 @@ const PlaylistDetailSongListRoute = () => {
|
||||
};
|
||||
|
||||
const page = usePlaylistDetailStore();
|
||||
const filters: Partial<PlaylistSongListQuery> = {
|
||||
sortBy: page?.table.id[playlistId]?.filter?.sortBy || SongListSort.ID,
|
||||
sortOrder: page?.table.id[playlistId]?.filter?.sortOrder || SortOrder.ASC,
|
||||
};
|
||||
|
||||
const itemCountCheck = usePlaylistSongList({
|
||||
const playlistSongs = usePlaylistSongList({
|
||||
query: {
|
||||
id: playlistId,
|
||||
limit: 1,
|
||||
startIndex: 0,
|
||||
...filters,
|
||||
},
|
||||
serverId: server?.id,
|
||||
});
|
||||
|
||||
const itemCount = itemCountCheck.data?.totalRecordCount || itemCountCheck.data?.items.length;
|
||||
const filterSortedSongs = useMemo(() => {
|
||||
let items = playlistSongs.data?.items;
|
||||
|
||||
if (items) {
|
||||
const searchTerm = page?.table.id[playlistId]?.filter?.searchTerm;
|
||||
|
||||
if (searchTerm) {
|
||||
const fuse = new Fuse(items, {
|
||||
fieldNormWeight: 1,
|
||||
ignoreLocation: true,
|
||||
keys: [
|
||||
'name',
|
||||
'album',
|
||||
{
|
||||
getFn: (song) => song.artists.map((artist) => artist.name),
|
||||
name: 'artist',
|
||||
},
|
||||
],
|
||||
threshold: 0,
|
||||
});
|
||||
items = fuse.search(searchTerm).map((item) => item.item);
|
||||
}
|
||||
|
||||
const sortBy = page?.table.id[playlistId]?.filter?.sortBy || SongListSort.ID;
|
||||
const sortOrder = page?.table.id[playlistId]?.filter?.sortOrder || SortOrder.ASC;
|
||||
return sortSongList(items, sortBy, sortOrder);
|
||||
} else {
|
||||
return [];
|
||||
}
|
||||
}, [playlistSongs.data?.items, page?.table.id, playlistId]);
|
||||
|
||||
const itemCount =
|
||||
typeof playlistSongs.data?.totalRecordCount === 'number'
|
||||
? filterSortedSongs.length
|
||||
: undefined;
|
||||
|
||||
const handlePlay = (play: Play) => {
|
||||
handlePlayQueueAdd?.({
|
||||
byData: filterSortedSongs,
|
||||
playType: play,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<AnimatedPage key={`playlist-detail-songList-${playlistId}`}>
|
||||
<PlaylistDetailSongListHeader
|
||||
handlePlay={handlePlay}
|
||||
handleToggleShowQueryBuilder={handleToggleShowQueryBuilder}
|
||||
itemCount={itemCount}
|
||||
tableRef={tableRef}
|
||||
@@ -203,12 +237,7 @@ const PlaylistDetailSongListRoute = () => {
|
||||
</Box>
|
||||
</motion.div>
|
||||
)}
|
||||
<PlaylistDetailSongListContent
|
||||
songs={
|
||||
server?.type === ServerType.SUBSONIC ? itemCountCheck.data?.items : undefined
|
||||
}
|
||||
tableRef={tableRef}
|
||||
/>
|
||||
<PlaylistDetailSongListContent songs={filterSortedSongs} tableRef={tableRef} />
|
||||
</AnimatedPage>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useFocusTrap } from '@mantine/hooks';
|
||||
import { closeAllModals } from '@mantine/modals';
|
||||
import isElectron from 'is-electron';
|
||||
import { nanoid } from 'nanoid/non-secure';
|
||||
import { useState } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { api } from '/@/renderer/api';
|
||||
@@ -14,21 +14,28 @@ import { useAuthStoreActions } from '/@/renderer/store';
|
||||
import { Button } from '/@/shared/components/button/button';
|
||||
import { Checkbox } from '/@/shared/components/checkbox/checkbox';
|
||||
import { Group } from '/@/shared/components/group/group';
|
||||
import { Paper } from '/@/shared/components/paper/paper';
|
||||
import { PasswordInput } from '/@/shared/components/password-input/password-input';
|
||||
import { SegmentedControl } from '/@/shared/components/segmented-control/segmented-control';
|
||||
import { Stack } from '/@/shared/components/stack/stack';
|
||||
import { TextInput } from '/@/shared/components/text-input/text-input';
|
||||
import { Text } from '/@/shared/components/text/text';
|
||||
import { toast } from '/@/shared/components/toast/toast';
|
||||
import { AuthenticationResponse } from '/@/shared/types/domain-types';
|
||||
import { ServerType, toServerType } from '/@/shared/types/types';
|
||||
import { AuthenticationResponse, ServerListItem } from '/@/shared/types/domain-types';
|
||||
import { DiscoveredServerItem, ServerType, toServerType } from '/@/shared/types/types';
|
||||
|
||||
const autodiscover = isElectron() ? window.api.autodiscover : null;
|
||||
const localSettings = isElectron() ? window.api.localSettings : null;
|
||||
|
||||
interface AddServerFormProps {
|
||||
onCancel: (() => void) | null;
|
||||
}
|
||||
|
||||
interface ServerDetails {
|
||||
icon: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
function ServerIconWithLabel({ icon, label }: { icon: string; label: string }) {
|
||||
return (
|
||||
<Stack align="center" justify="center">
|
||||
@@ -38,33 +45,62 @@ function ServerIconWithLabel({ icon, label }: { icon: string; label: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
const SERVER_TYPES = [
|
||||
{
|
||||
label: <ServerIconWithLabel icon={JellyfinIcon} label="Jellyfin" />,
|
||||
value: ServerType.JELLYFIN,
|
||||
function useAutodiscovery() {
|
||||
const [isDone, setDone] = useState(false);
|
||||
const [servers, setServers] = useState<DiscoveredServerItem[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
setServers([]);
|
||||
|
||||
autodiscover
|
||||
?.discover((newServer) => {
|
||||
setServers((tail) => [...tail, newServer]);
|
||||
})
|
||||
.then(() => {
|
||||
setDone(true);
|
||||
});
|
||||
}, []);
|
||||
|
||||
return { isDone, servers };
|
||||
}
|
||||
|
||||
const SERVER_TYPES: Record<ServerType, ServerDetails> = {
|
||||
[ServerType.JELLYFIN]: {
|
||||
icon: JellyfinIcon,
|
||||
name: 'Jellyfin',
|
||||
},
|
||||
{
|
||||
label: <ServerIconWithLabel icon={NavidromeIcon} label="Navidrome" />,
|
||||
value: ServerType.NAVIDROME,
|
||||
[ServerType.NAVIDROME]: {
|
||||
icon: NavidromeIcon,
|
||||
name: 'Navidrome',
|
||||
},
|
||||
{
|
||||
label: <ServerIconWithLabel icon={SubsonicIcon} label="OpenSubsonic" />,
|
||||
value: ServerType.SUBSONIC,
|
||||
[ServerType.SUBSONIC]: {
|
||||
icon: SubsonicIcon,
|
||||
name: 'OpenSubsonic',
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
const ALL_SERVERS = Object.keys(SERVER_TYPES).map((serverType) => {
|
||||
const info = SERVER_TYPES[serverType];
|
||||
return {
|
||||
label: <ServerIconWithLabel icon={info.icon} label={info.name} />,
|
||||
value: serverType,
|
||||
};
|
||||
});
|
||||
|
||||
export const AddServerForm = ({ onCancel }: AddServerFormProps) => {
|
||||
const { t } = useTranslation();
|
||||
const focusTrapRef = useFocusTrap(true);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const { addServer, setCurrentServer } = useAuthStoreActions();
|
||||
const { servers: discovered } = useAutodiscovery();
|
||||
|
||||
const form = useForm({
|
||||
initialValues: {
|
||||
legacyAuth: false,
|
||||
name: (localSettings ? localSettings.env.SERVER_NAME : window.SERVER_NAME) ?? '',
|
||||
password: '',
|
||||
savePassword: false,
|
||||
preferInstantMix: undefined,
|
||||
savePassword: undefined,
|
||||
type:
|
||||
(localSettings
|
||||
? localSettings.env.SERVER_TYPE
|
||||
@@ -85,6 +121,10 @@ export const AddServerForm = ({ onCancel }: AddServerFormProps) => {
|
||||
|
||||
const isSubmitDisabled = !form.values.name || !form.values.url || !form.values.username;
|
||||
|
||||
const fillServerDetails = (server: DiscoveredServerItem) => {
|
||||
form.setValues({ ...server });
|
||||
};
|
||||
|
||||
const handleSubmit = form.onSubmit(async (values) => {
|
||||
const authFunction = api.controller.authenticate;
|
||||
|
||||
@@ -112,17 +152,28 @@ export const AddServerForm = ({ onCancel }: AddServerFormProps) => {
|
||||
});
|
||||
}
|
||||
|
||||
const serverItem = {
|
||||
const serverItem: ServerListItem = {
|
||||
credential: data.credential,
|
||||
id: nanoid(),
|
||||
name: values.name,
|
||||
ndCredential: data.ndCredential,
|
||||
type: values.type as ServerType,
|
||||
url: values.url.replace(/\/$/, ''),
|
||||
userId: data.userId,
|
||||
username: data.username,
|
||||
};
|
||||
|
||||
if (values.preferInstantMix !== undefined) {
|
||||
serverItem.preferInstantMix = values.preferInstantMix;
|
||||
}
|
||||
|
||||
if (values.savePassword !== undefined) {
|
||||
serverItem.savePassword = values.savePassword;
|
||||
}
|
||||
|
||||
if (data.ndCredential !== undefined) {
|
||||
serverItem.ndCredential = data.ndCredential;
|
||||
}
|
||||
|
||||
addServer(serverItem);
|
||||
setCurrentServer(serverItem);
|
||||
closeAllModals();
|
||||
@@ -151,84 +202,119 @@ export const AddServerForm = ({ onCancel }: AddServerFormProps) => {
|
||||
});
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit}>
|
||||
<Stack m={5} ref={focusTrapRef}>
|
||||
<SegmentedControl
|
||||
data={SERVER_TYPES}
|
||||
disabled={Boolean(serverLock)}
|
||||
p="md"
|
||||
withItemsBorders={false}
|
||||
{...form.getInputProps('type')}
|
||||
/>
|
||||
<Group grow>
|
||||
<TextInput
|
||||
data-autofocus
|
||||
disabled={Boolean(serverLock)}
|
||||
label={t('form.addServer.input', {
|
||||
context: 'name',
|
||||
postProcess: 'titleCase',
|
||||
})}
|
||||
{...form.getInputProps('name')}
|
||||
/>
|
||||
<TextInput
|
||||
disabled={Boolean(serverLock)}
|
||||
label={t('form.addServer.input', {
|
||||
context: 'url',
|
||||
postProcess: 'titleCase',
|
||||
})}
|
||||
{...form.getInputProps('url')}
|
||||
/>
|
||||
</Group>
|
||||
<TextInput
|
||||
label={t('form.addServer.input', {
|
||||
context: 'username',
|
||||
postProcess: 'titleCase',
|
||||
})}
|
||||
{...form.getInputProps('username')}
|
||||
/>
|
||||
<PasswordInput
|
||||
label={t('form.addServer.input', {
|
||||
context: 'password',
|
||||
postProcess: 'titleCase',
|
||||
})}
|
||||
{...form.getInputProps('password')}
|
||||
/>
|
||||
{localSettings && form.values.type === ServerType.NAVIDROME && (
|
||||
<Checkbox
|
||||
label={t('form.addServer.input', {
|
||||
context: 'savePassword',
|
||||
postProcess: 'titleCase',
|
||||
})}
|
||||
{...form.getInputProps('savePassword', {
|
||||
type: 'checkbox',
|
||||
})}
|
||||
/>
|
||||
)}
|
||||
{form.values.type === ServerType.SUBSONIC && (
|
||||
<Checkbox
|
||||
label={t('form.addServer.input', {
|
||||
context: 'legacyAuthentication',
|
||||
postProcess: 'titleCase',
|
||||
})}
|
||||
{...form.getInputProps('legacyAuth', { type: 'checkbox' })}
|
||||
/>
|
||||
)}
|
||||
<Group grow justify="flex-end">
|
||||
{onCancel && (
|
||||
<Button onClick={onCancel} variant="subtle">
|
||||
{t('common.cancel', { postProcess: 'titleCase' })}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
disabled={isSubmitDisabled}
|
||||
loading={isLoading}
|
||||
type="submit"
|
||||
variant="filled"
|
||||
>
|
||||
{t('common.add', { postProcess: 'titleCase' })}
|
||||
</Button>
|
||||
</Group>
|
||||
<>
|
||||
<Stack>
|
||||
{discovered.map((server) => (
|
||||
<Paper key={server.url} p="10px">
|
||||
<Group>
|
||||
<img height="32" src={SERVER_TYPES[server.type].icon} width="32" />
|
||||
<div
|
||||
onClick={() => fillServerDetails(server)}
|
||||
style={{ cursor: 'pointer' }}
|
||||
>
|
||||
<Text fw={700}>{server.name}</Text>
|
||||
<Text>
|
||||
{SERVER_TYPES[server.type].name} server at {server.url}
|
||||
</Text>
|
||||
</div>
|
||||
</Group>
|
||||
</Paper>
|
||||
))}
|
||||
</Stack>
|
||||
</form>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<Stack m={5} ref={focusTrapRef}>
|
||||
<SegmentedControl
|
||||
data={ALL_SERVERS}
|
||||
disabled={Boolean(serverLock)}
|
||||
p="md"
|
||||
withItemsBorders={false}
|
||||
{...form.getInputProps('type')}
|
||||
/>
|
||||
<Group grow>
|
||||
<TextInput
|
||||
data-autofocus
|
||||
disabled={Boolean(serverLock)}
|
||||
label={t('form.addServer.input', {
|
||||
context: 'name',
|
||||
postProcess: 'titleCase',
|
||||
})}
|
||||
{...form.getInputProps('name')}
|
||||
/>
|
||||
<TextInput
|
||||
disabled={Boolean(serverLock)}
|
||||
label={t('form.addServer.input', {
|
||||
context: 'url',
|
||||
postProcess: 'titleCase',
|
||||
})}
|
||||
{...form.getInputProps('url')}
|
||||
/>
|
||||
</Group>
|
||||
<TextInput
|
||||
label={t('form.addServer.input', {
|
||||
context: 'username',
|
||||
postProcess: 'titleCase',
|
||||
})}
|
||||
{...form.getInputProps('username')}
|
||||
/>
|
||||
<PasswordInput
|
||||
label={t('form.addServer.input', {
|
||||
context: 'password',
|
||||
postProcess: 'titleCase',
|
||||
})}
|
||||
{...form.getInputProps('password')}
|
||||
/>
|
||||
{localSettings && form.values.type === ServerType.NAVIDROME && (
|
||||
<Checkbox
|
||||
label={t('form.addServer.input', {
|
||||
context: 'savePassword',
|
||||
postProcess: 'titleCase',
|
||||
})}
|
||||
{...form.getInputProps('savePassword', {
|
||||
type: 'checkbox',
|
||||
})}
|
||||
/>
|
||||
)}
|
||||
{form.values.type === ServerType.SUBSONIC && (
|
||||
<Checkbox
|
||||
label={t('form.addServer.input', {
|
||||
context: 'legacyAuthentication',
|
||||
postProcess: 'titleCase',
|
||||
})}
|
||||
{...form.getInputProps('legacyAuth', { type: 'checkbox' })}
|
||||
/>
|
||||
)}
|
||||
{form.values.type === ServerType.JELLYFIN && (
|
||||
<Checkbox
|
||||
description={t('form.addServer.input', {
|
||||
context: 'preferInstantMixDescription',
|
||||
postProcess: 'sentenceCase',
|
||||
})}
|
||||
label={t('form.addServer.input', {
|
||||
context: 'preferInstantMix',
|
||||
postProcess: 'titleCase',
|
||||
})}
|
||||
{...form.getInputProps('preferInstantMix', {
|
||||
type: 'checkbox',
|
||||
})}
|
||||
/>
|
||||
)}
|
||||
<Group grow justify="flex-end">
|
||||
{onCancel && (
|
||||
<Button onClick={onCancel} variant="subtle">
|
||||
{t('common.cancel', { postProcess: 'titleCase' })}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
disabled={isSubmitDisabled}
|
||||
loading={isLoading}
|
||||
type="submit"
|
||||
variant="filled"
|
||||
>
|
||||
{t('common.add', { postProcess: 'titleCase' })}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</form>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -7,7 +7,6 @@ import { useTranslation } from 'react-i18next';
|
||||
|
||||
import i18n from '/@/i18n/i18n';
|
||||
import { api } from '/@/renderer/api';
|
||||
import { queryKeys } from '/@/renderer/api/query-keys';
|
||||
import { queryClient } from '/@/renderer/lib/react-query';
|
||||
import { useAuthStoreActions } from '/@/renderer/store';
|
||||
import { Button } from '/@/shared/components/button/button';
|
||||
@@ -49,7 +48,8 @@ export const EditServerForm = ({ isUpdate, onCancel, password, server }: EditSer
|
||||
legacyAuth: false,
|
||||
name: server?.name,
|
||||
password: password || '',
|
||||
savePassword: server.savePassword || false,
|
||||
preferInstantMix: server.preferInstantMix,
|
||||
savePassword: server.savePassword,
|
||||
type: server?.type,
|
||||
url: server?.url,
|
||||
username: server?.username,
|
||||
@@ -86,17 +86,28 @@ export const EditServerForm = ({ isUpdate, onCancel, password, server }: EditSer
|
||||
});
|
||||
}
|
||||
|
||||
const serverItem = {
|
||||
const serverItem: ServerListItem = {
|
||||
credential: data.credential,
|
||||
id: server.id,
|
||||
name: values.name,
|
||||
ndCredential: data.ndCredential,
|
||||
savePassword: values.savePassword,
|
||||
type: values.type,
|
||||
url: values.url,
|
||||
userId: data.userId,
|
||||
username: data.username,
|
||||
};
|
||||
|
||||
if (values.preferInstantMix !== undefined) {
|
||||
serverItem.preferInstantMix = values.preferInstantMix;
|
||||
}
|
||||
|
||||
if (values.savePassword !== undefined) {
|
||||
serverItem.savePassword = values.savePassword;
|
||||
}
|
||||
|
||||
if (data.ndCredential !== undefined) {
|
||||
serverItem.ndCredential = data.ndCredential;
|
||||
}
|
||||
|
||||
updateServer(server.id, serverItem);
|
||||
toast.success({
|
||||
message: t('form.updateServer.title', { postProcess: 'sentenceCase' }),
|
||||
@@ -118,7 +129,7 @@ export const EditServerForm = ({ isUpdate, onCancel, password, server }: EditSer
|
||||
}
|
||||
}
|
||||
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.server.root(server.id) });
|
||||
queryClient.removeQueries();
|
||||
} catch (err: any) {
|
||||
setIsLoading(false);
|
||||
return toast.error({ message: err?.message });
|
||||
@@ -189,6 +200,21 @@ export const EditServerForm = ({ isUpdate, onCancel, password, server }: EditSer
|
||||
})}
|
||||
/>
|
||||
)}
|
||||
{form.values.type === ServerType.JELLYFIN && (
|
||||
<Checkbox
|
||||
description={t('form.addServer.input', {
|
||||
context: 'preferInstantMixDescription',
|
||||
postProcess: 'sentenceCase',
|
||||
})}
|
||||
label={t('form.addServer.input', {
|
||||
context: 'preferInstantMix',
|
||||
postProcess: 'titleCase',
|
||||
})}
|
||||
{...form.getInputProps('preferInstantMix', {
|
||||
type: 'checkbox',
|
||||
})}
|
||||
/>
|
||||
)}
|
||||
<Group justify="flex-end">
|
||||
<Button onClick={onCancel} variant="subtle">
|
||||
{t('common.cancel', { postProcess: 'titleCase' })}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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