mirror of
https://github.com/jeffvli/feishin.git
synced 2026-05-07 04:20:12 +02:00
Compare commits
82 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b44e16708d | |||
| 946d9d92f9 | |||
| 5e28dc597c | |||
| 6462d46c79 | |||
| 1a51d52047 | |||
| d82ded479e | |||
| 2e2233cb7e | |||
| 7344758114 | |||
| ac40949572 | |||
| c7c72d27db | |||
| b41a91c178 | |||
| 86f158ee5f | |||
| 86f6cc9cef | |||
| f15e399ddc | |||
| 8efb32407d | |||
| c9223c402a | |||
| 78d6e5b1d1 | |||
| d0067c5dbf | |||
| 9f73cfdda1 | |||
| 95dee5f4ee | |||
| c7509472c1 | |||
| feca53a53d | |||
| ab52693092 | |||
| 9a2540f954 | |||
| b4c45f0956 | |||
| 0ab2d89c58 | |||
| 817e1dc7ba | |||
| e34282338d | |||
| ba4b07614c | |||
| 72b2dca759 | |||
| 42b51f104c | |||
| d99ecd485f | |||
| bec1e35faf | |||
| cb6c2092e5 | |||
| 2d01b8e3f7 | |||
| 775cb6be07 | |||
| de6cd7d0dc | |||
| 9e448f7266 | |||
| 7bb54f9fa0 | |||
| 332fc5f9f9 | |||
| d4c0754bd2 | |||
| 177bb156cb | |||
| 31c3f1b062 | |||
| 5421182cc1 | |||
| 3d67b02724 | |||
| b8aa006b1c | |||
| a16f43c427 | |||
| 397610d8ab | |||
| fb170bb7c4 | |||
| d93f6e8720 | |||
| 668de93829 | |||
| 7cecd859ae | |||
| fea2966f62 | |||
| 6efa308e85 | |||
| 82b50a60bc | |||
| f52c4f7900 | |||
| 2fb621993d | |||
| cf663de2fc | |||
| 65c215fa9c | |||
| 8af972c20b | |||
| 027e4046a2 | |||
| 4c256348fc | |||
| 6e3275c05c | |||
| 3518a3f3b6 | |||
| 2b6b0cb38b | |||
| f56a836ffd | |||
| 2d963a9d23 | |||
| 4423b06807 | |||
| 1f9223b476 | |||
| b4ecf5d257 | |||
| 0dd13cbab1 | |||
| 48e50430fe | |||
| ac5611fdca | |||
| 50c3dbc0a0 | |||
| ddd840d2df | |||
| c0c9878fad | |||
| c4fc8a8aef | |||
| 0620b096db | |||
| f998491beb | |||
| 55a6ea4fca | |||
| 72fc5beb98 | |||
| a45b607fe7 |
@@ -0,0 +1,200 @@
|
||||
# Alpha builds published to Cloudflare R2 with date versioning (e.g. 1.0.0-alpha-20260205).
|
||||
# Required repo secrets: R2_ACCESS_KEY_ID, R2_SECRET_ACCESS_KEY (from R2 API token in Cloudflare dashboard).
|
||||
name: Publish Alpha
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: 'Semantic version number (e.g., 1.0.0) - alpha suffix will be added automatically'
|
||||
required: false
|
||||
type: string
|
||||
schedule:
|
||||
# Run at 3:00 AM PST daily (11:00 UTC; PST = UTC-8)
|
||||
- cron: '0 11 * * *'
|
||||
|
||||
jobs:
|
||||
check-new-commits:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
has_new_commits: ${{ steps.manual.outputs.has_new_commits || steps.check.outputs['has-new-commits'] }}
|
||||
steps:
|
||||
- name: Set has new commits (manual trigger)
|
||||
id: manual
|
||||
if: github.event_name == 'workflow_dispatch'
|
||||
run: echo "has_new_commits=true" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Check for new commits (24 hr interval)
|
||||
id: check
|
||||
if: github.event_name != 'workflow_dispatch'
|
||||
uses: adriangl/check-new-commits-action@v1
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
seconds: 86400
|
||||
|
||||
prepare:
|
||||
needs: check-new-commits
|
||||
if: needs.check-new-commits.outputs.has_new_commits == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
version: ${{ steps.version.outputs.version }}
|
||||
steps:
|
||||
- name: Checkout git repo
|
||||
uses: actions/checkout@v1
|
||||
|
||||
- name: Install Node and PNPM
|
||||
uses: pnpm/action-setup@v4.1.0
|
||||
with:
|
||||
version: 9
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install
|
||||
|
||||
- name: Set date-based alpha version
|
||||
id: version
|
||||
shell: pwsh
|
||||
run: |
|
||||
$inputVersion = "${{ github.event.inputs.version }}"
|
||||
Write-Host "Input version: $inputVersion"
|
||||
|
||||
if ($inputVersion -eq "" -or $inputVersion -eq "null") {
|
||||
# No input version provided (scheduled run or manual without input), auto-increment patch version
|
||||
Write-Host "No version provided, auto-incrementing patch version..."
|
||||
|
||||
$currentVersion = (Get-Content package.json | ConvertFrom-Json).version
|
||||
Write-Host "Current version: $currentVersion"
|
||||
|
||||
$cleanVersion = $currentVersion -replace '-.*$', ''
|
||||
$versionParts = $cleanVersion.Split('.')
|
||||
if ($versionParts.Length -ne 3) {
|
||||
Write-Error "Current version format is invalid: $cleanVersion"
|
||||
exit 1
|
||||
}
|
||||
|
||||
$major = [int]$versionParts[0]
|
||||
$minor = [int]$versionParts[1]
|
||||
$patch = [int]$versionParts[2]
|
||||
$newPatch = $patch + 1
|
||||
$inputVersion = "$major.$minor.$newPatch"
|
||||
Write-Host "Auto-generated version: $inputVersion"
|
||||
} else {
|
||||
# Validate semantic version format (major.minor.patch)
|
||||
$versionPattern = '^\d+\.\d+\.\d+$'
|
||||
if ($inputVersion -notmatch $versionPattern) {
|
||||
Write-Error "Invalid version format. Expected semantic version (e.g., 1.0.0), got: $inputVersion"
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
|
||||
# Date in YYYYMMDD (PST / America/Los_Angeles)
|
||||
$pst = [TimeZoneInfo]::FindSystemTimeZoneById('America/Los_Angeles')
|
||||
$dateInPst = [TimeZoneInfo]::ConvertTimeFromUtc([DateTime]::UtcNow, $pst)
|
||||
$dateStr = $dateInPst.ToString("yyyyMMdd")
|
||||
$alphaVersion = "$inputVersion-alpha-$dateStr"
|
||||
Write-Host "Alpha version: $alphaVersion"
|
||||
|
||||
# Update package.json
|
||||
$packageJson = Get-Content package.json | ConvertFrom-Json
|
||||
$packageJson.version = $alphaVersion
|
||||
$packageJson | ConvertTo-Json -Depth 10 | Set-Content package.json
|
||||
|
||||
echo "version=$alphaVersion" >> $env:GITHUB_OUTPUT
|
||||
|
||||
cleanup:
|
||||
needs: prepare
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
|
||||
R2_ENDPOINT_URL: ${{ secrets.R2_ENDPOINT_URL }}
|
||||
steps:
|
||||
- name: Delete all objects in R2 bucket
|
||||
run: |
|
||||
aws s3 rm s3://feishin-nightly --recursive --endpoint-url $R2_ENDPOINT_URL
|
||||
|
||||
publish:
|
||||
needs: [prepare, cleanup]
|
||||
runs-on: ${{ matrix.os }}
|
||||
env:
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
|
||||
|
||||
strategy:
|
||||
matrix:
|
||||
os: [windows-latest, macos-latest, ubuntu-latest]
|
||||
|
||||
steps:
|
||||
- name: Checkout git repo
|
||||
uses: actions/checkout@v1
|
||||
|
||||
- name: Install Node and PNPM
|
||||
uses: pnpm/action-setup@v4.1.0
|
||||
with:
|
||||
version: 9
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install
|
||||
|
||||
- name: Set version from prepare job
|
||||
shell: pwsh
|
||||
run: |
|
||||
$version = "${{ needs.prepare.outputs.version }}"
|
||||
Write-Host "Setting version: $version"
|
||||
$packageJson = Get-Content package.json | ConvertFrom-Json
|
||||
$packageJson.version = $version
|
||||
$packageJson | ConvertTo-Json -Depth 10 | Set-Content package.json
|
||||
|
||||
- name: Build and Publish to R2 (Windows)
|
||||
if: matrix.os == 'windows-latest'
|
||||
uses: nick-invision/retry@v2.8.2
|
||||
with:
|
||||
timeout_minutes: 30
|
||||
max_attempts: 3
|
||||
retry_on: error
|
||||
command: |
|
||||
pnpm run publish:win:alpha
|
||||
on_retry_command: pnpm cache delete
|
||||
|
||||
- name: Build and Publish to R2 (Windows ARM64)
|
||||
if: matrix.os == 'windows-latest'
|
||||
uses: nick-invision/retry@v2.8.2
|
||||
with:
|
||||
timeout_minutes: 30
|
||||
max_attempts: 3
|
||||
retry_on: error
|
||||
command: |
|
||||
pnpm run publish:win-arm64:alpha
|
||||
on_retry_command: pnpm cache delete
|
||||
|
||||
- name: Build and Publish to R2 (macOS)
|
||||
if: matrix.os == 'macos-latest'
|
||||
uses: nick-invision/retry@v2.8.2
|
||||
with:
|
||||
timeout_minutes: 30
|
||||
max_attempts: 3
|
||||
retry_on: error
|
||||
command: |
|
||||
pnpm run publish:mac:alpha
|
||||
on_retry_command: pnpm cache delete
|
||||
|
||||
- name: Build and Publish to R2 (Linux)
|
||||
if: matrix.os == 'ubuntu-latest'
|
||||
uses: nick-invision/retry@v2.8.2
|
||||
with:
|
||||
timeout_minutes: 30
|
||||
max_attempts: 3
|
||||
retry_on: error
|
||||
command: |
|
||||
pnpm run publish:linux:alpha
|
||||
on_retry_command: pnpm cache delete
|
||||
|
||||
- name: Build and Publish to R2 (Linux ARM64)
|
||||
if: matrix.os == 'ubuntu-latest'
|
||||
uses: nick-invision/retry@v2.8.2
|
||||
with:
|
||||
timeout_minutes: 30
|
||||
max_attempts: 3
|
||||
retry_on: error
|
||||
command: |
|
||||
pnpm run publish:linux-arm64:alpha
|
||||
on_retry_command: pnpm cache delete
|
||||
@@ -155,6 +155,19 @@ jobs:
|
||||
pnpm run publish:win:beta
|
||||
on_retry_command: pnpm cache delete
|
||||
|
||||
- name: Build and Publish releases (Windows ARM64)
|
||||
if: matrix.os == 'windows-latest'
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
uses: nick-invision/retry@v2.8.2
|
||||
with:
|
||||
timeout_minutes: 30
|
||||
max_attempts: 3
|
||||
retry_on: error
|
||||
command: |
|
||||
pnpm run publish:win-arm64:beta
|
||||
on_retry_command: pnpm cache delete
|
||||
|
||||
- name: Build and Publish releases (macOS)
|
||||
if: matrix.os == 'macos-latest'
|
||||
env:
|
||||
|
||||
@@ -50,6 +50,16 @@ jobs:
|
||||
command: |
|
||||
pnpm run package:win:pr
|
||||
|
||||
- name: Build for Windows (ARM64)
|
||||
if: ${{ matrix.os == 'windows-latest' }}
|
||||
uses: nick-invision/retry@v2.8.2
|
||||
with:
|
||||
timeout_minutes: 30
|
||||
max_attempts: 3
|
||||
retry_on: error
|
||||
command: |
|
||||
pnpm run package:win-arm64:pr
|
||||
|
||||
- name: Build for Linux
|
||||
if: ${{ matrix.os == 'ubuntu-latest' }}
|
||||
uses: nick-invision/retry@v2.8.2
|
||||
|
||||
@@ -33,3 +33,15 @@ jobs:
|
||||
command: |
|
||||
pnpm run publish:win
|
||||
on_retry_command: pnpm cache delete
|
||||
|
||||
- name: Build and Publish releases (ARM64)
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
uses: nick-invision/retry@v2.8.2
|
||||
with:
|
||||
timeout_minutes: 30
|
||||
max_attempts: 3
|
||||
retry_on: error
|
||||
command: |
|
||||
pnpm run publish:win-arm64
|
||||
on_retry_command: pnpm cache delete
|
||||
|
||||
@@ -16,6 +16,5 @@ jobs:
|
||||
- uses: vedantmgoyal9/winget-releaser@main
|
||||
with:
|
||||
identifier: jeffvli.Feishin
|
||||
installers-regex: 'Feishin-*-win-x64\.exe'
|
||||
installers-regex: 'Feishin-*-win-(x64|arm64)\.exe'
|
||||
token: ${{ secrets.WINGET_ACC_TOKEN }}
|
||||
|
||||
|
||||
@@ -35,6 +35,19 @@ jobs:
|
||||
pnpm run publish:win
|
||||
on_retry_command: pnpm cache delete
|
||||
|
||||
- name: Build and Publish releases (Windows ARM64)
|
||||
if: matrix.os == 'windows-latest'
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
uses: nick-invision/retry@v2.8.2
|
||||
with:
|
||||
timeout_minutes: 30
|
||||
max_attempts: 3
|
||||
retry_on: error
|
||||
command: |
|
||||
pnpm run publish:win-arm64
|
||||
on_retry_command: pnpm cache delete
|
||||
|
||||
- name: Build and Publish releases (macOS)
|
||||
if: matrix.os == 'macos-latest'
|
||||
env:
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
appId: org.jeffvli.feishin
|
||||
productName: Feishin
|
||||
artifactName: ${productName}-${version}-${os}-${arch}.${ext}
|
||||
electronVersion: 39.4.0
|
||||
directories:
|
||||
buildResources: assets
|
||||
files:
|
||||
- 'out/**/*'
|
||||
- 'package.json'
|
||||
extraResources:
|
||||
- assets/**
|
||||
asarUnpack:
|
||||
- resources/**
|
||||
win:
|
||||
target:
|
||||
- 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
|
||||
- deb
|
||||
- tar.xz
|
||||
category: AudioVideo;Audio;Player
|
||||
icon: assets/icons/icon.png
|
||||
artifactName: ${productName}-${os}-${arch}.${ext}
|
||||
|
||||
npmRebuild: false
|
||||
|
||||
publish:
|
||||
provider: s3
|
||||
bucket: feishin-nightly
|
||||
channel: alpha
|
||||
endpoint: https://065f090c64de2dc707dd70ac72db9669.r2.cloudflarestorage.com
|
||||
@@ -44,14 +44,22 @@
|
||||
"package:mac": "pnpm run build && electron-builder --mac",
|
||||
"package:mac:pr": "pnpm run build && electron-builder --mac --publish never",
|
||||
"package:win": "pnpm run build && electron-builder --win",
|
||||
"package:win-arm64:pr": "pnpm run build && electron-builder --win --arm64 --publish never",
|
||||
"package:win:pr": "pnpm run build && electron-builder --win --publish never",
|
||||
"publish:linux": "pnpm run build && electron-builder --publish always --linux",
|
||||
"publish:linux-arm64": "pnpm run build && electron-builder --publish always --linux --arm64",
|
||||
"publish:linux-arm64:alpha": "pnpm run build && electron-builder --config electron-builder-alpha.yml --publish always --linux --arm64",
|
||||
"publish:linux-arm64:beta": "pnpm run build && electron-builder --config electron-builder-beta.yml --publish always --linux --arm64",
|
||||
"publish:linux:alpha": "pnpm run build && electron-builder --config electron-builder-alpha.yml --publish always --linux",
|
||||
"publish:linux:beta": "pnpm run build && electron-builder --config electron-builder-beta.yml --publish always --linux",
|
||||
"publish:mac": "pnpm run build && electron-builder --publish always --mac",
|
||||
"publish:mac:alpha": "pnpm run build && electron-builder --config electron-builder-alpha.yml --publish always --mac",
|
||||
"publish:mac:beta": "pnpm run build && electron-builder --config electron-builder-beta.yml --publish always --mac",
|
||||
"publish:win": "pnpm run build && electron-builder --publish always --win",
|
||||
"publish:win-arm64": "pnpm run build && electron-builder --publish always --win --arm64",
|
||||
"publish:win-arm64:alpha": "pnpm run build && electron-builder --config electron-builder-alpha.yml --publish always --win --arm64",
|
||||
"publish:win-arm64:beta": "pnpm run build && electron-builder --config electron-builder-beta.yml --publish always --win --arm64",
|
||||
"publish:win:alpha": "pnpm run build && electron-builder --config electron-builder-alpha.yml --publish always --win",
|
||||
"publish:win:beta": "pnpm run build && electron-builder --config electron-builder-beta.yml --publish always --win",
|
||||
"start": "electron-vite preview",
|
||||
"typecheck": "pnpm run typecheck:node && pnpm run typecheck:web",
|
||||
|
||||
@@ -418,13 +418,17 @@
|
||||
"albumArtistDetail": {
|
||||
"about": "About {{artist}}",
|
||||
"appearsOn": "appears on",
|
||||
"favoriteSongs": "favorite songs",
|
||||
"groupingTypeAll": "all release types",
|
||||
"groupingTypePrimary": "primary release types",
|
||||
"recentReleases": "recent releases",
|
||||
"viewDiscography": "view discography",
|
||||
"relatedArtists": "related $t(entity.artist, {\"count\": 2})",
|
||||
"topSongs": "top songs",
|
||||
"topSongsCommunity": "community",
|
||||
"topSongsFrom": "top songs from {{title}}",
|
||||
"topSongsPersonal": "personal",
|
||||
"favoriteSongsFrom": "favorite songs from {{title}}",
|
||||
"viewAll": "view all",
|
||||
"viewAllTracks": "view all $t(entity.track, {\"count\": 2})"
|
||||
},
|
||||
@@ -444,6 +448,11 @@
|
||||
"radioList": {
|
||||
"title": "radio stations"
|
||||
},
|
||||
"releasenotes": {
|
||||
"commitsSinceStable": "commits since {{stable}}",
|
||||
"noNewCommits": "no new commits in this range",
|
||||
"noStableReleaseToCompare": "no stable release available to compare with"
|
||||
},
|
||||
"favorites": {
|
||||
"title": "$t(entity.favorite, {\"count\": 2})"
|
||||
},
|
||||
@@ -741,10 +750,11 @@
|
||||
"customFontPath_description": "sets the path to the custom font to use for the application",
|
||||
"customFontPath": "custom font path",
|
||||
"disableAutomaticUpdates": "disable automatic updates",
|
||||
"releaseChannel_optionAlpha": "alpha (nightly)",
|
||||
"releaseChannel_optionBeta": "beta",
|
||||
"releaseChannel_optionLatest": "latest",
|
||||
"releaseChannel": "release channel",
|
||||
"releaseChannel_description": "choose between stable releases or beta releases for automatic updates",
|
||||
"releaseChannel_description": "choose between stable, beta, or alpha (nightly) releases for automatic updates",
|
||||
"disableLibraryUpdateOnStartup": "disable checking for new versions on startup",
|
||||
"discordApplicationId_description": "the application id for {{discord}} rich presence (defaults to {{defaultId}})",
|
||||
"discordApplicationId": "{{discord}} application id",
|
||||
@@ -932,6 +942,8 @@
|
||||
"showLyricsInSidebar": "show lyrics in player sidebar",
|
||||
"showRatings_description": "controls if the star ratings feature shows up in the interface",
|
||||
"showRatings": "show star ratings",
|
||||
"blurExplicitImages": "blur explicit images",
|
||||
"blurExplicitImages_description": "album and song artwork tagged as explicit will be blurred",
|
||||
"enableGridMultiSelect": "enable grid multi-select",
|
||||
"enableGridMultiSelect_description": "when enabled, allows selecting multiple items in grid views. when disabled, clicking grid item images will navigate to the item page",
|
||||
"showVisualizerInSidebar_description": "a panel will be added to the player sidebar that displays the visualizer",
|
||||
@@ -1133,6 +1145,7 @@
|
||||
"year": "$t(common.year)"
|
||||
},
|
||||
"view": {
|
||||
"detail": "detail",
|
||||
"grid": "grid",
|
||||
"list": "list",
|
||||
"table": "table"
|
||||
|
||||
+127
-26
@@ -18,7 +18,7 @@ import {
|
||||
} from 'electron';
|
||||
import electronLocalShortcut from 'electron-localshortcut';
|
||||
import log from 'electron-log/main';
|
||||
import { autoUpdater } from 'electron-updater';
|
||||
import { AppImageUpdater, autoUpdater, MacUpdater, NsisUpdater } from 'electron-updater';
|
||||
import { access, constants } from 'fs';
|
||||
import path, { join } from 'path';
|
||||
|
||||
@@ -40,40 +40,111 @@ import './features';
|
||||
|
||||
import { PlayerType, TitleTheme } from '/@/shared/types/types';
|
||||
|
||||
export default class AppUpdater {
|
||||
const ALPHA_UPDATER_CONFIG: {
|
||||
bucket: string;
|
||||
channel: string;
|
||||
endpoint: string;
|
||||
provider: 's3';
|
||||
} = {
|
||||
bucket: '',
|
||||
channel: 'alpha',
|
||||
endpoint: 'https://feishin-nightly-bucket.jeffvli.org',
|
||||
provider: 's3',
|
||||
};
|
||||
|
||||
type UpdaterInstance = AppImageUpdater | MacUpdater | NsisUpdater | typeof autoUpdater;
|
||||
|
||||
class AlphaAppUpdater {
|
||||
constructor() {
|
||||
const updater = createAlphaUpdaterInstance();
|
||||
log.transports.file.level = 'info';
|
||||
autoUpdater.logger = autoUpdaterLogInterface;
|
||||
updater.logger = autoUpdaterLogInterface;
|
||||
updater.channel = ALPHA_UPDATER_CONFIG.channel;
|
||||
updater.allowPrerelease = true;
|
||||
updater.disableDifferentialDownload = true;
|
||||
updater.allowDowngrade = true;
|
||||
updater.autoInstallOnAppQuit = true;
|
||||
updater.autoRunAppAfterInstall = true;
|
||||
updater.checkForUpdatesAndNotify();
|
||||
}
|
||||
}
|
||||
|
||||
const isBetaVersion = packageJson.version.includes('-beta');
|
||||
const releaseChannel = store.get('release_channel');
|
||||
const isNotConfigured = !releaseChannel;
|
||||
|
||||
console.log('Release channel: ', releaseChannel);
|
||||
console.log('Is beta version: ', isBetaVersion);
|
||||
|
||||
if (isNotConfigured) {
|
||||
console.log(
|
||||
'Release channel not configured, setting to ',
|
||||
isBetaVersion ? 'beta' : 'latest',
|
||||
);
|
||||
store.set('release_channel', isBetaVersion ? 'beta' : 'latest');
|
||||
}
|
||||
|
||||
if (releaseChannel === 'beta') {
|
||||
autoUpdater.channel = 'beta';
|
||||
autoUpdater.allowPrerelease = true;
|
||||
autoUpdater.disableDifferentialDownload = true;
|
||||
} else if (releaseChannel === 'latest') {
|
||||
autoUpdater.channel = 'latest';
|
||||
autoUpdater.allowDowngrade = true;
|
||||
autoUpdater.allowPrerelease = false;
|
||||
class AppUpdater {
|
||||
constructor() {
|
||||
const effectiveChannel = store.get('release_channel') as string;
|
||||
console.log('Effective update channel:', effectiveChannel);
|
||||
if (effectiveChannel === 'alpha') {
|
||||
return new AlphaAppUpdater();
|
||||
}
|
||||
|
||||
configureAndGetUpdater();
|
||||
autoUpdater.checkForUpdatesAndNotify();
|
||||
}
|
||||
}
|
||||
|
||||
function configureAndGetUpdater(): UpdaterInstance {
|
||||
const isBetaVersion = packageJson.version.includes('-beta');
|
||||
const isAlphaVersion = packageJson.version.includes('-alpha');
|
||||
let releaseChannel = store.get('release_channel');
|
||||
const isNotConfigured = !releaseChannel;
|
||||
|
||||
console.log('Release channel:', releaseChannel);
|
||||
console.log('Is beta version:', isBetaVersion);
|
||||
console.log('Is alpha version:', isAlphaVersion);
|
||||
console.log('Is not configured:', isNotConfigured);
|
||||
|
||||
if (isNotConfigured) {
|
||||
console.log('Release channel not configured, setting default channel');
|
||||
const defaultChannel = isAlphaVersion ? 'alpha' : isBetaVersion ? 'beta' : 'latest';
|
||||
store.set('release_channel', defaultChannel);
|
||||
releaseChannel = defaultChannel;
|
||||
}
|
||||
|
||||
const effectiveChannel = store.get('release_channel') as string;
|
||||
|
||||
if (effectiveChannel === 'alpha') {
|
||||
const updater = createAlphaUpdaterInstance();
|
||||
log.transports.file.level = 'info';
|
||||
updater.logger = autoUpdaterLogInterface;
|
||||
updater.channel = ALPHA_UPDATER_CONFIG.channel;
|
||||
updater.allowPrerelease = true;
|
||||
updater.disableDifferentialDownload = true;
|
||||
updater.allowDowngrade = true;
|
||||
updater.autoInstallOnAppQuit = true;
|
||||
updater.autoRunAppAfterInstall = true;
|
||||
return updater;
|
||||
}
|
||||
|
||||
log.transports.file.level = 'info';
|
||||
autoUpdater.logger = autoUpdaterLogInterface;
|
||||
autoUpdater.autoInstallOnAppQuit = true;
|
||||
autoUpdater.autoRunAppAfterInstall = true;
|
||||
|
||||
if (effectiveChannel === 'beta') {
|
||||
autoUpdater.channel = 'beta';
|
||||
autoUpdater.allowPrerelease = true;
|
||||
autoUpdater.disableDifferentialDownload = true;
|
||||
} else {
|
||||
autoUpdater.channel = 'latest';
|
||||
autoUpdater.allowDowngrade = true;
|
||||
autoUpdater.allowPrerelease = false;
|
||||
}
|
||||
|
||||
return autoUpdater;
|
||||
}
|
||||
|
||||
function createAlphaUpdaterInstance(): AppImageUpdater | MacUpdater | NsisUpdater {
|
||||
if (isMacOS()) {
|
||||
return new MacUpdater(ALPHA_UPDATER_CONFIG);
|
||||
}
|
||||
|
||||
if (isLinux()) {
|
||||
return new AppImageUpdater(ALPHA_UPDATER_CONFIG);
|
||||
}
|
||||
|
||||
return new NsisUpdater(ALPHA_UPDATER_CONFIG);
|
||||
}
|
||||
|
||||
protocol.registerSchemesAsPrivileged([{ privileges: { bypassCSP: true }, scheme: 'feishin' }]);
|
||||
|
||||
process.on('uncaughtException', (error: any) => {
|
||||
@@ -359,6 +430,36 @@ async function createWindow(first = true): Promise<void> {
|
||||
return mainWindow?.webContents.session.clearCache();
|
||||
});
|
||||
|
||||
ipcMain.handle(
|
||||
'app-check-for-updates',
|
||||
async (): Promise<{ updateAvailable: boolean; version?: string }> => {
|
||||
if (disableAutoUpdates()) {
|
||||
console.log('Auto updates are disabled');
|
||||
return { updateAvailable: false };
|
||||
}
|
||||
|
||||
try {
|
||||
console.log('Checking for updates');
|
||||
const updater = configureAndGetUpdater();
|
||||
const result = await updater.checkForUpdates();
|
||||
const updateAvailable = result?.isUpdateAvailable ?? false;
|
||||
console.log('Update available:', updateAvailable);
|
||||
if (updateAvailable && store.get('disable_auto_updates') !== true) {
|
||||
console.log('Downloading update');
|
||||
updater.downloadUpdate();
|
||||
}
|
||||
|
||||
return {
|
||||
updateAvailable,
|
||||
version: result?.updateInfo?.version,
|
||||
};
|
||||
} catch {
|
||||
console.log('Error checking for updates');
|
||||
return { updateAvailable: false };
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
ipcMain.on('app-restart', () => {
|
||||
// Fix for .AppImage
|
||||
if (process.env.APPIMAGE) {
|
||||
|
||||
+18
-2
@@ -21,6 +21,14 @@ export default class MenuBuilder {
|
||||
selector: 'orderFrontStandardAboutPanel:',
|
||||
},
|
||||
{ type: 'separator' },
|
||||
{
|
||||
accelerator: 'Command+,',
|
||||
click: () => {
|
||||
this.mainWindow.webContents.send('renderer-open-settings');
|
||||
},
|
||||
label: 'Settings',
|
||||
},
|
||||
{ type: 'separator' },
|
||||
{ label: 'Services', submenu: [] },
|
||||
{ type: 'separator' },
|
||||
{
|
||||
@@ -151,8 +159,8 @@ export default class MenuBuilder {
|
||||
return [subMenuAbout, subMenuEdit, subMenuView, subMenuWindow, subMenuHelp];
|
||||
}
|
||||
|
||||
buildDefaultTemplate() {
|
||||
const templateDefault = [
|
||||
buildDefaultTemplate(): MenuItemConstructorOptions[] {
|
||||
const templateDefault: MenuItemConstructorOptions[] = [
|
||||
{
|
||||
label: '&File',
|
||||
submenu: [
|
||||
@@ -160,6 +168,14 @@ export default class MenuBuilder {
|
||||
accelerator: 'Ctrl+O',
|
||||
label: '&Open',
|
||||
},
|
||||
{
|
||||
accelerator: 'Ctrl+,',
|
||||
click: () => {
|
||||
this.mainWindow.webContents.send('renderer-open-settings');
|
||||
},
|
||||
label: '&Settings...',
|
||||
},
|
||||
{ type: 'separator' },
|
||||
{
|
||||
accelerator: 'Ctrl+W',
|
||||
click: () => {
|
||||
|
||||
@@ -39,6 +39,10 @@ const download = (url: string) => {
|
||||
ipcRenderer.send('download-url', url);
|
||||
};
|
||||
|
||||
const checkForUpdates = (): Promise<{ updateAvailable: boolean; version?: string }> => {
|
||||
return ipcRenderer.invoke('app-check-for-updates');
|
||||
};
|
||||
|
||||
const forceGarbageCollection = (): boolean => {
|
||||
try {
|
||||
if (typeof global.gc === 'function') {
|
||||
@@ -57,7 +61,12 @@ const forceGarbageCollection = (): boolean => {
|
||||
}
|
||||
};
|
||||
|
||||
const rendererOpenSettings = (cb: (event: IpcRendererEvent) => void) => {
|
||||
ipcRenderer.on('renderer-open-settings', cb);
|
||||
};
|
||||
|
||||
export const utils = {
|
||||
checkForUpdates,
|
||||
disableAutoUpdates,
|
||||
download,
|
||||
forceGarbageCollection,
|
||||
@@ -69,6 +78,7 @@ export const utils = {
|
||||
openApplicationDirectory,
|
||||
openItem,
|
||||
playerErrorListener,
|
||||
rendererOpenSettings,
|
||||
};
|
||||
|
||||
export type Utils = typeof utils;
|
||||
|
||||
@@ -137,7 +137,7 @@ export const contract = c.router({
|
||||
},
|
||||
getInstantMix: {
|
||||
method: 'GET',
|
||||
path: 'songs/:itemId/InstantMix',
|
||||
path: 'items/:itemId/InstantMix',
|
||||
query: jfType._parameters.similarSongs,
|
||||
responses: {
|
||||
200: jfType._response.songList,
|
||||
|
||||
@@ -38,6 +38,10 @@ const formatCommaDelimitedString = (value: string[]) => {
|
||||
// not the POST body
|
||||
const MAX_ITEMS_PER_PLAYLIST_ADD = 50;
|
||||
|
||||
// Defining a re-usable Collator instance for performance reasons.
|
||||
const numericSortCollator = new Intl.Collator(undefined, { numeric: true });
|
||||
const collator = new Intl.Collator();
|
||||
|
||||
const VERSION_INFO: VersionInfo = [
|
||||
[
|
||||
'10.9.0',
|
||||
@@ -49,6 +53,18 @@ const VERSION_INFO: VersionInfo = [
|
||||
['10.0.0', { [ServerFeature.TAGS]: [1] }],
|
||||
];
|
||||
|
||||
const JF_FIELDS = {
|
||||
ALBUM_ARTIST_DETAIL: 'Genres, Overview, SortName, ProviderIds',
|
||||
ALBUM_ARTIST_LIST: 'Genres, DateCreated, ExternalUrls, Overview, SortName, ProviderIds',
|
||||
ALBUM_DETAIL: 'Genres, DateCreated, ChildCount, People, Tags, ProviderIds',
|
||||
ALBUM_LIST: 'People, Tags, Studios, SortName, UserData, ProviderIds, ChildCount',
|
||||
FOLDER: 'Genres, DateCreated, MediaSources, UserData, ParentId',
|
||||
GENRE: 'ItemCounts',
|
||||
PLAYLIST_DETAIL: 'Genres, DateCreated, MediaSources, ChildCount, ParentId, SortName',
|
||||
PLAYLIST_LIST: 'ChildCount, Genres, DateCreated, ParentId, Overview',
|
||||
SONG: 'Genres, DateCreated, MediaSources, ParentId, People, Tags, SortName, UserData, ProviderIds',
|
||||
} as const;
|
||||
|
||||
export const JellyfinController: InternalControllerEndpoint = {
|
||||
addToPlaylist: async (args) => {
|
||||
const { apiClientProps, body, query } = args;
|
||||
@@ -226,7 +242,7 @@ export const JellyfinController: InternalControllerEndpoint = {
|
||||
userId: apiClientProps.server?.userId,
|
||||
},
|
||||
query: {
|
||||
Fields: 'Genres, Overview, SortName',
|
||||
Fields: JF_FIELDS.ALBUM_ARTIST_DETAIL,
|
||||
},
|
||||
}),
|
||||
jfApiClient(apiClientProps).getSimilarArtistList({
|
||||
@@ -253,7 +269,7 @@ export const JellyfinController: InternalControllerEndpoint = {
|
||||
|
||||
const res = await jfApiClient(apiClientProps).getAlbumArtistList({
|
||||
query: {
|
||||
Fields: 'Genres, DateCreated, ExternalUrls, Overview, SortName',
|
||||
Fields: JF_FIELDS.ALBUM_ARTIST_LIST,
|
||||
ImageTypeLimit: 1,
|
||||
Limit: query.limit,
|
||||
ParentId: getLibraryId(query.musicFolderId),
|
||||
@@ -296,7 +312,7 @@ export const JellyfinController: InternalControllerEndpoint = {
|
||||
userId: apiClientProps.server.userId,
|
||||
},
|
||||
query: {
|
||||
Fields: 'Genres, DateCreated, ChildCount, People, Tags',
|
||||
Fields: JF_FIELDS.ALBUM_DETAIL,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -305,7 +321,7 @@ export const JellyfinController: InternalControllerEndpoint = {
|
||||
userId: apiClientProps.server.userId,
|
||||
},
|
||||
query: {
|
||||
Fields: 'Genres, DateCreated, MediaSources, ParentId, People, Tags, SortName',
|
||||
Fields: JF_FIELDS.SONG,
|
||||
IncludeItemTypes: 'Audio',
|
||||
ParentId: query.id,
|
||||
SortBy: 'ParentIndexNumber,IndexNumber,SortName',
|
||||
@@ -363,7 +379,7 @@ export const JellyfinController: InternalControllerEndpoint = {
|
||||
},
|
||||
query: {
|
||||
...artistQuery,
|
||||
Fields: 'People, Tags, Studios, SortName',
|
||||
Fields: JF_FIELDS.ALBUM_LIST,
|
||||
GenreIds: query.genreIds ? query.genreIds.join(',') : undefined,
|
||||
IncludeItemTypes: 'MusicAlbum',
|
||||
IsFavorite: query.favorite,
|
||||
@@ -399,7 +415,7 @@ export const JellyfinController: InternalControllerEndpoint = {
|
||||
|
||||
const res = await jfApiClient(apiClientProps).getArtistList({
|
||||
query: {
|
||||
Fields: 'Genres, DateCreated, ExternalUrls, Overview, SortName',
|
||||
Fields: JF_FIELDS.ALBUM_ARTIST_LIST,
|
||||
ImageTypeLimit: 1,
|
||||
Limit: query.limit,
|
||||
ParentId: getLibraryId(query.musicFolderId),
|
||||
@@ -438,7 +454,7 @@ export const JellyfinController: InternalControllerEndpoint = {
|
||||
itemId: query.artistId,
|
||||
},
|
||||
query: {
|
||||
Fields: 'Genres, DateCreated, MediaSources, ParentId, SortName',
|
||||
Fields: JF_FIELDS.SONG,
|
||||
Limit: query.count,
|
||||
UserId: apiClientProps.server?.userId || undefined,
|
||||
},
|
||||
@@ -563,7 +579,7 @@ export const JellyfinController: InternalControllerEndpoint = {
|
||||
userId,
|
||||
},
|
||||
query: {
|
||||
Fields: 'Genres, DateCreated, MediaSources, UserData, ParentId',
|
||||
Fields: JF_FIELDS.FOLDER,
|
||||
ParentId: query.id,
|
||||
SortBy: query.sortBy
|
||||
? (songListSortMap.jellyfin[query.sortBy] as string) || 'SortName'
|
||||
@@ -592,7 +608,7 @@ export const JellyfinController: InternalControllerEndpoint = {
|
||||
userId,
|
||||
},
|
||||
query: {
|
||||
Fields: 'Genres, DateCreated, MediaSources, UserData, ParentId',
|
||||
Fields: JF_FIELDS.FOLDER,
|
||||
ParentId: parentId,
|
||||
},
|
||||
});
|
||||
@@ -679,7 +695,7 @@ export const JellyfinController: InternalControllerEndpoint = {
|
||||
const res = await jfApiClient(apiClientProps).getGenreList({
|
||||
query: {
|
||||
EnableTotalRecordCount: true,
|
||||
Fields: 'ItemCounts',
|
||||
Fields: JF_FIELDS.GENRE,
|
||||
Limit: query.limit === -1 ? undefined : query.limit,
|
||||
ParentId: getLibraryId(query.musicFolderId),
|
||||
Recursive: true,
|
||||
@@ -794,7 +810,7 @@ export const JellyfinController: InternalControllerEndpoint = {
|
||||
userId: apiClientProps.server?.userId,
|
||||
},
|
||||
query: {
|
||||
Fields: 'Genres, DateCreated, MediaSources, ChildCount, ParentId, SortName',
|
||||
Fields: JF_FIELDS.PLAYLIST_DETAIL,
|
||||
Ids: query.id,
|
||||
},
|
||||
});
|
||||
@@ -817,7 +833,7 @@ export const JellyfinController: InternalControllerEndpoint = {
|
||||
userId: apiClientProps.server?.userId,
|
||||
},
|
||||
query: {
|
||||
Fields: 'ChildCount, Genres, DateCreated, ParentId, Overview',
|
||||
Fields: JF_FIELDS.PLAYLIST_LIST,
|
||||
IncludeItemTypes: 'Playlist',
|
||||
Limit: query.limit,
|
||||
Recursive: true,
|
||||
@@ -855,7 +871,7 @@ export const JellyfinController: InternalControllerEndpoint = {
|
||||
id: query.id,
|
||||
},
|
||||
query: {
|
||||
Fields: 'Genres, DateCreated, MediaSources, UserData, ParentId, People, Tags, SortName',
|
||||
Fields: JF_FIELDS.SONG,
|
||||
IncludeItemTypes: 'Audio',
|
||||
UserId: apiClientProps.server?.userId,
|
||||
},
|
||||
@@ -902,7 +918,7 @@ export const JellyfinController: InternalControllerEndpoint = {
|
||||
userId: apiClientProps.server?.userId,
|
||||
},
|
||||
query: {
|
||||
Fields: 'Genres, DateCreated, MediaSources, ParentId, People, Tags, SortName',
|
||||
Fields: JF_FIELDS.SONG,
|
||||
GenreIds: query.genre ? query.genre : undefined,
|
||||
IncludeItemTypes: 'Audio',
|
||||
IsPlayed:
|
||||
@@ -974,7 +990,7 @@ export const JellyfinController: InternalControllerEndpoint = {
|
||||
itemId: query.songId,
|
||||
},
|
||||
query: {
|
||||
Fields: 'Genres, DateCreated, MediaSources, ParentId, SortName',
|
||||
Fields: JF_FIELDS.SONG,
|
||||
Limit: query.count,
|
||||
UserId: apiClientProps.server?.userId || undefined,
|
||||
},
|
||||
@@ -1007,7 +1023,7 @@ export const JellyfinController: InternalControllerEndpoint = {
|
||||
itemId: query.songId,
|
||||
},
|
||||
query: {
|
||||
Fields: 'Genres, DateCreated, MediaSources, ParentId, SortName',
|
||||
Fields: JF_FIELDS.SONG,
|
||||
Limit: query.count,
|
||||
UserId: apiClientProps.server?.userId || undefined,
|
||||
},
|
||||
@@ -1092,11 +1108,11 @@ export const JellyfinController: InternalControllerEndpoint = {
|
||||
query: {
|
||||
AlbumIds: albumIdsFilter,
|
||||
ArtistIds: artistIdsFilter,
|
||||
Fields: 'Genres, DateCreated, MediaSources, ParentId, People, Tags, SortName',
|
||||
Fields: JF_FIELDS.SONG,
|
||||
GenreIds: query.genreIds?.join(','),
|
||||
IncludeItemTypes: 'Audio',
|
||||
IsFavorite: query.favorite,
|
||||
Limit: query.limit,
|
||||
Limit: query.limit === -1 ? undefined : query.limit,
|
||||
ParentId: getLibraryId(query.musicFolderId),
|
||||
Recursive: true,
|
||||
SearchTerm: query.searchTerm,
|
||||
@@ -1127,11 +1143,11 @@ export const JellyfinController: InternalControllerEndpoint = {
|
||||
query: {
|
||||
AlbumIds: albumIdsFilter,
|
||||
ArtistIds: artistIdsFilter,
|
||||
Fields: 'Genres, DateCreated, MediaSources, ParentId, People, Tags, SortName',
|
||||
Fields: JF_FIELDS.SONG,
|
||||
GenreIds: query.genreIds?.join(','),
|
||||
IncludeItemTypes: 'Audio',
|
||||
IsFavorite: query.favorite,
|
||||
Limit: query.limit,
|
||||
Limit: query.limit === -1 ? undefined : query.limit,
|
||||
ParentId: getLibraryId(query.musicFolderId),
|
||||
Recursive: true,
|
||||
SearchTerm: query.searchTerm,
|
||||
@@ -1250,11 +1266,12 @@ export const JellyfinController: InternalControllerEndpoint = {
|
||||
if (res.body.Tags?.length) {
|
||||
tags.push({
|
||||
name: 'Tags',
|
||||
options: res.body.Tags.sort((a, b) =>
|
||||
a
|
||||
.toLocaleLowerCase()
|
||||
.localeCompare(b.toLocaleLowerCase(), undefined, { numeric: true }),
|
||||
).map((tag) => ({ id: tag, name: tag })),
|
||||
options: res.body.Tags.sort((a, b) => {
|
||||
return numericSortCollator.compare(
|
||||
a.toLocaleLowerCase(),
|
||||
b.toLocaleLowerCase(),
|
||||
);
|
||||
}).map((tag) => ({ id: tag, name: tag })),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1262,7 +1279,7 @@ export const JellyfinController: InternalControllerEndpoint = {
|
||||
tags.push({
|
||||
name: 'Studios',
|
||||
options: studioRes.body.Items.sort((a, b) =>
|
||||
a.Name.toLocaleLowerCase().localeCompare(b.Name.toLocaleLowerCase()),
|
||||
collator.compare(a.Name.toLocaleLowerCase(), b.Name.toLocaleLowerCase()),
|
||||
).map((option) => ({ id: option.Name, name: option.Name })),
|
||||
});
|
||||
}
|
||||
@@ -1276,17 +1293,22 @@ export const JellyfinController: InternalControllerEndpoint = {
|
||||
throw new Error('No userId found');
|
||||
}
|
||||
|
||||
const type = query.type === 'personal' ? 'personal' : 'community';
|
||||
|
||||
const res = await jfApiClient(apiClientProps).getTopSongsList({
|
||||
params: {
|
||||
userId: apiClientProps.server?.userId,
|
||||
},
|
||||
query: {
|
||||
ArtistIds: query.artistId,
|
||||
Fields: 'Genres, DateCreated, MediaSources, ParentId, SortName',
|
||||
Fields: JF_FIELDS.SONG,
|
||||
IncludeItemTypes: 'Audio',
|
||||
Limit: query.limit,
|
||||
Recursive: true,
|
||||
SortBy: 'CommunityRating,SortName',
|
||||
SortBy:
|
||||
type === 'personal'
|
||||
? JFSongListSort.PLAY_COUNT
|
||||
: JFSongListSort.COMMUNITY_RATING,
|
||||
SortOrder: 'Descending',
|
||||
UserId: apiClientProps.server?.userId,
|
||||
},
|
||||
@@ -1296,15 +1318,31 @@ export const JellyfinController: InternalControllerEndpoint = {
|
||||
throw new Error('Failed to get top song list');
|
||||
}
|
||||
|
||||
return {
|
||||
items: res.body.Items.map((item) =>
|
||||
jfNormalize.song(
|
||||
item,
|
||||
apiClientProps.server,
|
||||
args.context?.pathReplace,
|
||||
args.context?.pathReplaceWith,
|
||||
),
|
||||
const items = res.body.Items.map((item) =>
|
||||
jfNormalize.song(
|
||||
item,
|
||||
apiClientProps.server,
|
||||
args.context?.pathReplace,
|
||||
args.context?.pathReplaceWith,
|
||||
),
|
||||
);
|
||||
|
||||
if (type === 'personal') {
|
||||
const sorted = orderBy(
|
||||
items,
|
||||
['playCount', 'albumId', 'trackNumber'],
|
||||
['desc', 'asc', 'asc'],
|
||||
);
|
||||
|
||||
return {
|
||||
items: sorted,
|
||||
startIndex: 0,
|
||||
totalRecordCount: res.body.TotalRecordCount,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
items,
|
||||
startIndex: 0,
|
||||
totalRecordCount: res.body.TotalRecordCount,
|
||||
};
|
||||
@@ -1378,7 +1416,7 @@ export const JellyfinController: InternalControllerEndpoint = {
|
||||
id: query.id,
|
||||
},
|
||||
query: {
|
||||
Fields: 'Genres, DateCreated, MediaSources, UserData, ParentId, People, Tags, SortName',
|
||||
Fields: JF_FIELDS.SONG,
|
||||
IncludeItemTypes: 'Audio',
|
||||
UserId: apiClientProps.server?.userId,
|
||||
},
|
||||
@@ -1404,7 +1442,7 @@ export const JellyfinController: InternalControllerEndpoint = {
|
||||
userId: apiClientProps.server?.userId,
|
||||
},
|
||||
query: {
|
||||
Fields: 'Genres, DateCreated, MediaSources, ChildCount, ParentId, SortName',
|
||||
Fields: JF_FIELDS.PLAYLIST_DETAIL,
|
||||
Ids: query.id,
|
||||
},
|
||||
});
|
||||
@@ -1562,7 +1600,7 @@ export const JellyfinController: InternalControllerEndpoint = {
|
||||
},
|
||||
query: {
|
||||
EnableTotalRecordCount: true,
|
||||
Fields: 'People, Tags, SortName',
|
||||
Fields: JF_FIELDS.ALBUM_LIST,
|
||||
ImageTypeLimit: 1,
|
||||
IncludeItemTypes: 'MusicAlbum',
|
||||
Limit: query.albumLimit,
|
||||
@@ -1585,7 +1623,7 @@ export const JellyfinController: InternalControllerEndpoint = {
|
||||
const res = await jfApiClient(apiClientProps).getAlbumArtistList({
|
||||
query: {
|
||||
EnableTotalRecordCount: true,
|
||||
Fields: 'Genres, DateCreated, ExternalUrls, Overview',
|
||||
Fields: JF_FIELDS.ALBUM_ARTIST_LIST,
|
||||
ImageTypeLimit: 1,
|
||||
IncludeArtists: true,
|
||||
Limit: query.albumArtistLimit,
|
||||
@@ -1610,7 +1648,7 @@ export const JellyfinController: InternalControllerEndpoint = {
|
||||
},
|
||||
query: {
|
||||
EnableTotalRecordCount: true,
|
||||
Fields: 'Genres, DateCreated, MediaSources, ParentId, People, Tags, SortName',
|
||||
Fields: JF_FIELDS.SONG,
|
||||
IncludeItemTypes: 'Audio',
|
||||
Limit: query.songLimit,
|
||||
Recursive: true,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { set } from 'idb-keyval';
|
||||
import orderBy from 'lodash/orderBy';
|
||||
|
||||
import { ndApiClient } from '/@/renderer/api/navidrome/navidrome-api';
|
||||
import { ssApiClient } from '/@/renderer/api/subsonic/subsonic-api';
|
||||
@@ -17,7 +18,9 @@ import {
|
||||
PlaylistSongListArgs,
|
||||
PlaylistSongListResponse,
|
||||
ServerListItemWithCredential,
|
||||
SongListSort,
|
||||
songListSortMap,
|
||||
SortOrder,
|
||||
sortOrderMap,
|
||||
tagListSortMap,
|
||||
userListSortMap,
|
||||
@@ -71,6 +74,10 @@ const EXCLUDED_ALBUM_TAGS = new Set<string>([
|
||||
|
||||
const EXCLUDED_SONG_TAGS = new Set<string>(['disctotal', 'tracktotal']);
|
||||
|
||||
// Defining a re-usable Collator instance for performance reasons.
|
||||
const numericSortCollator = new Intl.Collator(undefined, { numeric: true });
|
||||
const collator = new Intl.Collator();
|
||||
|
||||
// Tags that use IDs as values as opposed to the tag value
|
||||
const ID_TAGS = new Set<string>(['albumversion', 'mood']);
|
||||
|
||||
@@ -780,16 +787,17 @@ export const NavidromeController: InternalControllerEndpoint = {
|
||||
.map((data) => ({
|
||||
name: data[0],
|
||||
options: data[1]
|
||||
.sort((a, b) =>
|
||||
a.name
|
||||
.toLocaleLowerCase()
|
||||
.localeCompare(b.name.toLocaleLowerCase(), undefined, {
|
||||
numeric: true,
|
||||
}),
|
||||
)
|
||||
.sort((a, b) => {
|
||||
return numericSortCollator.compare(
|
||||
a.name.toLocaleLowerCase(),
|
||||
b.name.toLocaleLowerCase(),
|
||||
);
|
||||
})
|
||||
.map((option) => ({ id: option.id, name: option.name })),
|
||||
}))
|
||||
.sort((a, b) => a.name.toLocaleLowerCase().localeCompare(b.name.toLocaleLowerCase()));
|
||||
.sort((a, b) =>
|
||||
collator.compare(a.name.toLocaleLowerCase(), b.name.toLocaleLowerCase()),
|
||||
);
|
||||
|
||||
const excludedAlbumTags = Array.from(EXCLUDED_ALBUM_TAGS.values());
|
||||
const excludedSongTags = Array.from(EXCLUDED_SONG_TAGS.values());
|
||||
@@ -802,7 +810,59 @@ export const NavidromeController: InternalControllerEndpoint = {
|
||||
tags,
|
||||
};
|
||||
},
|
||||
getTopSongs: SubsonicController.getTopSongs,
|
||||
getTopSongs: async (args) => {
|
||||
const { apiClientProps, query } = args;
|
||||
|
||||
const type = query.type === 'personal' ? 'personal' : 'community';
|
||||
|
||||
if (type === 'community') {
|
||||
const res = await ssApiClient(apiClientProps).getTopSongsList({
|
||||
query: {
|
||||
artist: query.artist,
|
||||
count: query.limit,
|
||||
},
|
||||
});
|
||||
|
||||
if (res.status !== 200) {
|
||||
throw new Error('Failed to get top songs');
|
||||
}
|
||||
|
||||
return {
|
||||
items: (res.body.topSongs?.song || []).map((song) =>
|
||||
ssNormalize.song(
|
||||
song,
|
||||
apiClientProps.server,
|
||||
args.context?.pathReplace,
|
||||
args.context?.pathReplaceWith,
|
||||
),
|
||||
),
|
||||
startIndex: 0,
|
||||
totalRecordCount: res.body.topSongs?.song?.length || 0,
|
||||
};
|
||||
}
|
||||
|
||||
const res = await NavidromeController.getSongList({
|
||||
apiClientProps,
|
||||
query: {
|
||||
artistIds: [query.artistId],
|
||||
sortBy: SongListSort.PLAY_COUNT,
|
||||
sortOrder: SortOrder.DESC,
|
||||
startIndex: 0,
|
||||
},
|
||||
});
|
||||
|
||||
const songsWithPlayCount = orderBy(
|
||||
res.items.filter((song) => song.playCount > 0),
|
||||
['playCount', 'albumId', 'trackNumber'],
|
||||
['desc', 'asc', 'asc'],
|
||||
);
|
||||
|
||||
return {
|
||||
items: songsWithPlayCount,
|
||||
startIndex: 0,
|
||||
totalRecordCount: res.totalRecordCount,
|
||||
};
|
||||
},
|
||||
getUserInfo: SubsonicController.getUserInfo,
|
||||
getUserList: async (args) => {
|
||||
const { apiClientProps, query } = args;
|
||||
|
||||
@@ -73,6 +73,13 @@ export const queryKeys: Record<
|
||||
|
||||
return [serverId, 'albumArtists', 'detail'] as const;
|
||||
},
|
||||
favoriteSongs: (serverId: string, artistId?: string) => {
|
||||
if (artistId) {
|
||||
return [serverId, 'albumArtists', 'favoriteSongs', artistId] as const;
|
||||
}
|
||||
|
||||
return [serverId, 'albumArtists', 'favoriteSongs'] as const;
|
||||
},
|
||||
infiniteList: (serverId: string, query?: AlbumArtistListQuery) => {
|
||||
const { filter, pagination } = splitPaginatedQuery(query);
|
||||
if (query && pagination) {
|
||||
|
||||
@@ -1361,7 +1361,7 @@ export const SubsonicController: InternalControllerEndpoint = {
|
||||
throw new Error('Failed to get song list');
|
||||
}
|
||||
|
||||
const allResults =
|
||||
let allResults =
|
||||
(res.body.starred?.song || []).map((song) =>
|
||||
ssNormalize.song(
|
||||
song,
|
||||
@@ -1371,6 +1371,15 @@ export const SubsonicController: InternalControllerEndpoint = {
|
||||
),
|
||||
) || [];
|
||||
|
||||
const filterArtistIds = query.albumArtistIds || query.artistIds;
|
||||
|
||||
if (filterArtistIds?.length) {
|
||||
const idSet = new Set(filterArtistIds);
|
||||
allResults = allResults.filter((song) =>
|
||||
song.albumArtists?.some((aa) => idSet.has(aa.id)),
|
||||
);
|
||||
}
|
||||
|
||||
return sortAndPaginate(allResults, {
|
||||
limit: query.limit,
|
||||
sortBy: query.sortBy,
|
||||
@@ -1794,29 +1803,54 @@ export const SubsonicController: InternalControllerEndpoint = {
|
||||
getTopSongs: async (args) => {
|
||||
const { apiClientProps, context, query } = args;
|
||||
|
||||
const res = await ssApiClient(apiClientProps).getTopSongsList({
|
||||
query: {
|
||||
artist: query.artist,
|
||||
count: query.limit,
|
||||
},
|
||||
});
|
||||
const type = query.type === 'personal' ? 'personal' : 'community';
|
||||
|
||||
if (res.status !== 200) {
|
||||
throw new Error('Failed to get top songs');
|
||||
}
|
||||
if (type === 'community') {
|
||||
const res = await ssApiClient(apiClientProps).getTopSongsList({
|
||||
query: {
|
||||
artist: query.artist,
|
||||
count: query.limit,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
items:
|
||||
res.body.topSongs?.song?.map((song) =>
|
||||
if (res.status !== 200) {
|
||||
throw new Error('Failed to get top songs');
|
||||
}
|
||||
|
||||
return {
|
||||
items: (res.body.topSongs?.song || []).map((song) =>
|
||||
ssNormalize.song(
|
||||
song,
|
||||
apiClientProps.server,
|
||||
context?.pathReplace,
|
||||
context?.pathReplaceWith,
|
||||
),
|
||||
) || [],
|
||||
),
|
||||
startIndex: 0,
|
||||
totalRecordCount: res.body.topSongs?.song?.length || 0,
|
||||
};
|
||||
}
|
||||
|
||||
const res = await SubsonicController.getSongList({
|
||||
apiClientProps,
|
||||
query: {
|
||||
artistIds: [query.artistId],
|
||||
sortBy: SongListSort.PLAY_COUNT,
|
||||
sortOrder: SortOrder.DESC,
|
||||
startIndex: 0,
|
||||
},
|
||||
});
|
||||
|
||||
const songsWithPlayCount = orderBy(
|
||||
res.items.filter((song) => song.playCount > 0),
|
||||
['playCount', 'albumId', 'trackNumber'],
|
||||
['desc', 'asc', 'asc'],
|
||||
);
|
||||
|
||||
return {
|
||||
items: songsWithPlayCount,
|
||||
startIndex: 0,
|
||||
totalRecordCount: res.body.topSongs?.song?.length || 0,
|
||||
totalRecordCount: res.totalRecordCount,
|
||||
};
|
||||
},
|
||||
getUserInfo: async (args) => {
|
||||
|
||||
@@ -62,7 +62,11 @@ export const getOptimizedListCount = async <
|
||||
query: pageQuery,
|
||||
});
|
||||
|
||||
client.setQueryData(pageQueryKey, pageResult);
|
||||
const keyContainsRandom = JSON.stringify(pageQueryKey).toLowerCase().includes('random');
|
||||
|
||||
if (!keyContainsRandom) {
|
||||
client.setQueryData(pageQueryKey, pageResult);
|
||||
}
|
||||
|
||||
return pageResult.totalRecordCount ?? 0;
|
||||
};
|
||||
|
||||
@@ -10,7 +10,9 @@ import isElectron from 'is-electron';
|
||||
import { lazy, Suspense, useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import i18n from '/@/i18n/i18n';
|
||||
import { openSettingsModal } from '/@/renderer/features/settings/utils/open-settings-modal';
|
||||
import { WebAudioContext } from '/@/renderer/features/player/context/webaudio-context';
|
||||
import { useCheckForUpdates } from '/@/renderer/hooks/use-check-for-updates';
|
||||
import { useSyncSettingsToMain } from '/@/renderer/hooks/use-sync-settings-to-main';
|
||||
import { AppRouter } from '/@/renderer/router/app-router';
|
||||
import { useCssSettings, useHotkeySettings, useLanguage } from '/@/renderer/store';
|
||||
@@ -38,6 +40,7 @@ export const App = () => {
|
||||
const cssRef = useRef<HTMLStyleElement | null>(null);
|
||||
|
||||
useSyncSettingsToMain();
|
||||
useCheckForUpdates();
|
||||
|
||||
const [webAudio, setWebAudio] = useState<WebAudio>();
|
||||
|
||||
@@ -77,6 +80,19 @@ export const App = () => {
|
||||
}
|
||||
}, [language]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isElectron()) {
|
||||
window.api.utils.rendererOpenSettings(() => {
|
||||
openSettingsModal();
|
||||
});
|
||||
|
||||
return () => {
|
||||
ipc?.removeAllListeners('renderer-open-settings');
|
||||
};
|
||||
}
|
||||
return undefined;
|
||||
}, []);
|
||||
|
||||
const notificationStyles = useMemo(
|
||||
() => ({
|
||||
root: {
|
||||
|
||||
@@ -121,6 +121,7 @@ const CarouselItem = ({ album }: CarouselItemProps) => {
|
||||
containerClassName={styles.albumImageContainer}
|
||||
enableDebounce={false}
|
||||
enableViewport={false}
|
||||
explicitStatus={album.explicitStatus}
|
||||
fetchPriority="high"
|
||||
id={album.imageId}
|
||||
itemType={LibraryItem.ALBUM}
|
||||
|
||||
@@ -118,6 +118,7 @@ const CarouselItem = ({ album }: CarouselItemProps) => {
|
||||
containerClassName={styles.albumImageContainer}
|
||||
enableDebounce={false}
|
||||
enableViewport={false}
|
||||
explicitStatus={album.explicitStatus}
|
||||
fetchPriority="high"
|
||||
id={album.imageId}
|
||||
itemType={LibraryItem.ALBUM}
|
||||
|
||||
@@ -34,6 +34,7 @@
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
z-index: 5;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
pointer-events: none;
|
||||
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
formatRating,
|
||||
} from '/@/renderer/utils/format';
|
||||
import { SEPARATOR_STRING } from '/@/shared/api/utils';
|
||||
import { ExplicitIndicator } from '/@/shared/components/explicit-indicator/explicit-indicator';
|
||||
import { Group } from '/@/shared/components/group/group';
|
||||
import { Icon } from '/@/shared/components/icon/icon';
|
||||
import { Separator } from '/@/shared/components/separator/separator';
|
||||
@@ -361,6 +362,9 @@ const CompactItemCard = ({
|
||||
[styles.isRound]: isRound,
|
||||
})}
|
||||
enableDebounce={false}
|
||||
explicitStatus={
|
||||
'explicitStatus' in data && data ? data.explicitStatus : null
|
||||
}
|
||||
id={data?.imageId}
|
||||
itemType={itemType}
|
||||
src={(data as Album | AlbumArtist | Playlist | Song)?.imageUrl}
|
||||
@@ -595,6 +599,9 @@ const DefaultItemCard = ({
|
||||
<ItemImage
|
||||
className={clsx(styles.image, { [styles.isRound]: isRound })}
|
||||
enableDebounce={false}
|
||||
explicitStatus={
|
||||
'explicitStatus' in data && data ? data.explicitStatus : null
|
||||
}
|
||||
id={data?.imageId}
|
||||
itemType={itemType}
|
||||
src={(data as Album | AlbumArtist | Playlist | Song)?.imageUrl}
|
||||
@@ -892,6 +899,9 @@ const PosterItemCard = ({
|
||||
<ItemImage
|
||||
className={clsx(styles.image, { [styles.isRound]: isRound })}
|
||||
enableDebounce={false}
|
||||
explicitStatus={
|
||||
'explicitStatus' in data && data ? data.explicitStatus : null
|
||||
}
|
||||
id={(data as { imageId: string })?.imageId}
|
||||
itemType={itemType}
|
||||
src={(data as { imageUrl: string })?.imageUrl}
|
||||
@@ -1010,6 +1020,7 @@ export const getDataRows = (type?: 'compact' | 'default' | 'poster'): DataRow[]
|
||||
return [
|
||||
{
|
||||
format: (data) => {
|
||||
const explicitStatus = 'explicitStatus' in data ? data.explicitStatus : null;
|
||||
if ('name' in data && data.name) {
|
||||
if ('id' in data && data.id) {
|
||||
if ('_itemType' in data) {
|
||||
@@ -1022,6 +1033,7 @@ export const getDataRows = (type?: 'compact' | 'default' | 'poster'): DataRow[]
|
||||
albumId: data.id,
|
||||
})}
|
||||
>
|
||||
<ExplicitIndicator explicitStatus={explicitStatus} />
|
||||
{data.name}
|
||||
</Link>
|
||||
);
|
||||
@@ -1036,6 +1048,7 @@ export const getDataRows = (type?: 'compact' | 'default' | 'poster'): DataRow[]
|
||||
},
|
||||
)}
|
||||
>
|
||||
<ExplicitIndicator explicitStatus={explicitStatus} />
|
||||
{data.name}
|
||||
</Link>
|
||||
);
|
||||
@@ -1062,11 +1075,21 @@ export const getDataRows = (type?: 'compact' | 'default' | 'poster'): DataRow[]
|
||||
</Link>
|
||||
);
|
||||
default:
|
||||
return data.name;
|
||||
return (
|
||||
<>
|
||||
<ExplicitIndicator explicitStatus={explicitStatus} />
|
||||
{data.name}
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
return data.name;
|
||||
return (
|
||||
<>
|
||||
<ExplicitIndicator explicitStatus={explicitStatus} />
|
||||
{data.name}
|
||||
</>
|
||||
);
|
||||
}
|
||||
return '';
|
||||
},
|
||||
|
||||
@@ -1,84 +0,0 @@
|
||||
.container {
|
||||
display: grid;
|
||||
grid-template-rows: 1fr;
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
gap: var(--theme-spacing-sm);
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: var(--theme-spacing-sm);
|
||||
container-type: inline-size;
|
||||
background: var(--theme-colors-surface);
|
||||
border-radius: var(--theme-radius-md);
|
||||
|
||||
@container (min-width: 500px) {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
.image-container {
|
||||
position: relative;
|
||||
display: none;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
aspect-ratio: 1/1;
|
||||
|
||||
&::before {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
content: '';
|
||||
background-color: rgb(0 0 0);
|
||||
opacity: 0;
|
||||
transition: all 0.2s ease-in-out;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
&::before {
|
||||
opacity: 0.6;
|
||||
}
|
||||
}
|
||||
|
||||
@container (min-width: 500px) {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
.image {
|
||||
aspect-ratio: 1/1;
|
||||
}
|
||||
|
||||
.metadata-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--theme-spacing-sm);
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: var(--theme-spacing-xs) 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.metadata-container .header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
font-weight: 600;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.metadata-container .header .title {
|
||||
max-width: 70%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.metadata-container .content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--theme-spacing-xs);
|
||||
}
|
||||
|
||||
.metadata-container .content .tags {
|
||||
}
|
||||
@@ -1,146 +0,0 @@
|
||||
// import { AnimatePresence } from 'motion/react';
|
||||
// import { MouseEvent, useMemo, useState } from 'react';
|
||||
// import { Link } from 'react-router';
|
||||
|
||||
// import styles from './item-detail.module.css';
|
||||
|
||||
// import { ItemCardControls } from '/@/renderer/components/item-card/item-card-controls';
|
||||
// import { useFastAverageColor } from '/@/renderer/hooks';
|
||||
// import { ActionIcon } from '/@/shared/components/action-icon/action-icon';
|
||||
// import { Badge } from '/@/shared/components/badge/badge';
|
||||
// import { Divider } from '/@/shared/components/divider/divider';
|
||||
// import { Group } from '/@/shared/components/group/group';
|
||||
// import { Image } from '/@/shared/components/image/image';
|
||||
// import { Rating } from '/@/shared/components/rating/rating';
|
||||
// import { Text } from '/@/shared/components/text/text';
|
||||
// import {
|
||||
// Album,
|
||||
// AlbumArtist,
|
||||
// Artist,
|
||||
// LibraryItem,
|
||||
// Playlist,
|
||||
// Song,
|
||||
// } from '/@/shared/types/domain-types';
|
||||
// import { stringToColor } from '/@/shared/utils/string-to-color';
|
||||
|
||||
// interface ItemDetailProps {
|
||||
// data: Album | AlbumArtist | Artist | Playlist | Song | undefined;
|
||||
// itemHeight: number;
|
||||
// itemType: LibraryItem;
|
||||
// onClick?: (e: MouseEvent<HTMLDivElement>, item: unknown, itemType: LibraryItem) => void;
|
||||
// withControls?: boolean;
|
||||
// }
|
||||
|
||||
// export const ItemDetail = ({ data, itemType, onClick, withControls }: ItemDetailProps) => {
|
||||
// const imageUrl = getImageUrl(data);
|
||||
|
||||
// const [showControls, setShowControls] = useState(false);
|
||||
|
||||
// const { background } = useFastAverageColor({
|
||||
// algorithm: 'simple',
|
||||
// src: imageUrl,
|
||||
// srcLoaded: false,
|
||||
// });
|
||||
|
||||
// // const tags = [...(data?.genres ?? [])];
|
||||
|
||||
// const tags = useMemo(() => {
|
||||
// if (!data) {
|
||||
// return [];
|
||||
// }
|
||||
|
||||
// const items: {
|
||||
// color?: string;
|
||||
// id: string;
|
||||
// isLight?: boolean;
|
||||
// itemType: LibraryItem;
|
||||
// name: string;
|
||||
// }[] = [];
|
||||
|
||||
// if ('albumArtists' in data && Array.isArray(data.albumArtists)) {
|
||||
// data.albumArtists?.forEach((tag: { id: string; name: string }) => {
|
||||
// items.push({ id: tag.id, itemType: LibraryItem.ALBUM_ARTIST, name: tag.name });
|
||||
// });
|
||||
// }
|
||||
|
||||
// if ('genres' in data && Array.isArray(data.genres)) {
|
||||
// data.genres?.forEach((tag: { id: string; itemType: LibraryItem; name: string }) => {
|
||||
// const { color, isLight } = stringToColor(tag.name);
|
||||
// items.push({ ...tag, color, isLight });
|
||||
// });
|
||||
// }
|
||||
|
||||
// // if ('tags' in data && typeof data.tags === 'object') {
|
||||
// // console.log('data.tags :>> ', data.tags);
|
||||
// // Object.entries(data.tags).forEach(([key, value]) => {
|
||||
// // items.push({ id: key, itemType: LibraryItem.TAG, name: value });
|
||||
// // });
|
||||
// // }
|
||||
|
||||
// return items;
|
||||
// }, [data]);
|
||||
|
||||
// return (
|
||||
// <div
|
||||
// className={styles.container}
|
||||
// onClick={(e) => onClick?.(e, data, itemType)}
|
||||
// style={{ backgroundColor: background }}
|
||||
// >
|
||||
// <div
|
||||
// className={styles.imageContainer}
|
||||
// onMouseEnter={() => withControls && setShowControls(true)}
|
||||
// onMouseLeave={() => withControls && setShowControls(false)}
|
||||
// >
|
||||
// <Image alt={data?.name} src={imageUrl} />
|
||||
// <AnimatePresence>
|
||||
// {withControls && showControls && <ItemCardControls type="compact" />}
|
||||
// </AnimatePresence>
|
||||
// </div>
|
||||
// <div className={styles.metadataContainer}>
|
||||
// <div className={styles.header}>
|
||||
// <Text className={styles.title} component={Link} isLink size="lg" weight={500}>
|
||||
// {data?.name}
|
||||
// </Text>
|
||||
// <Group>
|
||||
// {data && 'userRating' in data && (
|
||||
// <Rating size="xs" value={data?.userRating ?? 0} />
|
||||
// )}
|
||||
// {data && 'userFavorite' in data && (
|
||||
// <ActionIcon
|
||||
// icon="favorite"
|
||||
// iconProps={{
|
||||
// fill: data?.userFavorite ? 'primary' : 'default',
|
||||
// }}
|
||||
// size="xs"
|
||||
// />
|
||||
// )}
|
||||
// </Group>
|
||||
// </div>
|
||||
// <Divider />
|
||||
// <div className={styles.content}>
|
||||
// <Group className={styles.tags} gap="xs">
|
||||
// {tags.map((tag) => (
|
||||
// <Badge
|
||||
// key={tag.id}
|
||||
// style={{
|
||||
// backgroundColor: tag.color,
|
||||
// color: tag.isLight ? 'black' : 'white',
|
||||
// }}
|
||||
// >
|
||||
// {tag.name}
|
||||
// </Badge>
|
||||
// ))}
|
||||
// </Group>
|
||||
// </div>
|
||||
// </div>
|
||||
// </div>
|
||||
// );
|
||||
// };
|
||||
|
||||
// const getImageUrl = (data: Album | AlbumArtist | Artist | Playlist | Song | undefined) => {
|
||||
// if (data && 'imageUrl' in data) {
|
||||
// return data.imageUrl || undefined;
|
||||
// }
|
||||
|
||||
// return undefined;
|
||||
// };
|
||||
@@ -7,11 +7,12 @@ import {
|
||||
getServerById,
|
||||
useAuthStore,
|
||||
useCurrentServerId,
|
||||
useGeneralSettings,
|
||||
useImageRes,
|
||||
useSettingsStore,
|
||||
} from '/@/renderer/store';
|
||||
import { BaseImage, ImageProps } from '/@/shared/components/image/image';
|
||||
import { LibraryItem } from '/@/shared/types/domain-types';
|
||||
import { ExplicitStatus, LibraryItem } from '/@/shared/types/domain-types';
|
||||
|
||||
const getUnloaderIcon = (itemType: LibraryItem) => {
|
||||
switch (itemType) {
|
||||
@@ -34,6 +35,7 @@ const getUnloaderIcon = (itemType: LibraryItem) => {
|
||||
|
||||
const BaseItemImage = (
|
||||
props: Omit<ImageProps, 'id' | 'src'> & {
|
||||
explicitStatus?: ExplicitStatus | null;
|
||||
id?: null | string;
|
||||
itemType: LibraryItem;
|
||||
serverId?: null | string;
|
||||
@@ -41,7 +43,8 @@ const BaseItemImage = (
|
||||
type?: keyof z.infer<typeof GeneralSettingsSchema>['imageRes'];
|
||||
},
|
||||
) => {
|
||||
const { serverId, src, ...rest } = props;
|
||||
const { explicitStatus, serverId, src, ...rest } = props;
|
||||
const { blurExplicitImages } = useGeneralSettings();
|
||||
|
||||
const imageUrl = useItemImageUrl({
|
||||
id: props.id,
|
||||
@@ -51,8 +54,11 @@ const BaseItemImage = (
|
||||
type: props.type,
|
||||
});
|
||||
|
||||
const isExplicit = blurExplicitImages && explicitStatus === ExplicitStatus.EXPLICIT;
|
||||
|
||||
return (
|
||||
<BaseImage
|
||||
isExplicit={isExplicit}
|
||||
src={imageUrl}
|
||||
unloaderIcon={getUnloaderIcon(props.itemType)}
|
||||
{...rest}
|
||||
|
||||
@@ -192,9 +192,10 @@ export const useDefaultItemListControls = (args?: UseDefaultItemListControlsArgs
|
||||
onColumnReordered?.(columnIdFrom, columnIdTo, edge);
|
||||
},
|
||||
|
||||
onColumnResized: ({ columnId, width }: { columnId: TableColumn; width: number }) => {
|
||||
onColumnResized?.(columnId, width);
|
||||
},
|
||||
onColumnResized: onColumnResized
|
||||
? ({ columnId, width }: { columnId: TableColumn; width: number }) =>
|
||||
onColumnResized(columnId, width)
|
||||
: undefined,
|
||||
|
||||
onDoubleClick: ({ internalState, item, itemType, meta }: DefaultItemControlProps) => {
|
||||
if (!item || !internalState) {
|
||||
@@ -241,11 +242,13 @@ export const useDefaultItemListControls = (args?: UseDefaultItemListControlsArgs
|
||||
}
|
||||
|
||||
const playType = (meta?.playType as Play) || Play.NOW;
|
||||
const singleSongOnly = meta?.singleSongOnly === true;
|
||||
|
||||
// For NEXT, LAST, NEXT_SHUFFLE, and LAST_SHUFFLE, only add the clicked song
|
||||
// For NOW and SHUFFLE, add a range of songs around the clicked song
|
||||
// For single-song actions (e.g. image play button), or NEXT/LAST/..., only add the clicked song
|
||||
// For row double-click with NOW/SHUFFLE, add a range of songs around the clicked song
|
||||
let songsToAdd: Song[];
|
||||
if (
|
||||
singleSongOnly ||
|
||||
playType === Play.NEXT ||
|
||||
playType === Play.LAST ||
|
||||
playType === Play.NEXT_SHUFFLE ||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import {
|
||||
useMutation,
|
||||
useQuery,
|
||||
useQueryClient,
|
||||
useSuspenseQuery,
|
||||
@@ -11,6 +12,7 @@ import { queryKeys } from '/@/renderer/api/query-keys';
|
||||
import { useListContext } from '/@/renderer/context/list-context';
|
||||
import { eventEmitter } from '/@/renderer/events/event-emitter';
|
||||
import { UserFavoriteEventPayload, UserRatingEventPayload } from '/@/renderer/events/events';
|
||||
import { getListRefreshMutationKey } from '/@/renderer/features/shared/components/list-refresh-button';
|
||||
import { LibraryItem } from '/@/shared/types/domain-types';
|
||||
|
||||
export const getListQueryKeyName = (itemType: LibraryItem): string => {
|
||||
@@ -293,10 +295,10 @@ export const useItemListInfiniteLoader = ({
|
||||
[onRangeChangedBase],
|
||||
);
|
||||
|
||||
const refresh = useCallback(
|
||||
async (force?: boolean) => {
|
||||
const refreshMutation = useMutation({
|
||||
mutationFn: async (force?: boolean) => {
|
||||
// Invalidate all queries to ensure fresh data
|
||||
await queryClient.invalidateQueries();
|
||||
queryClient.invalidateQueries();
|
||||
|
||||
// Reset the infinite list data
|
||||
const currentData = queryClient.getQueryData<{
|
||||
@@ -320,7 +322,7 @@ export const useItemListInfiniteLoader = ({
|
||||
}
|
||||
|
||||
// Add a delay to make the refresh visually clear
|
||||
await new Promise((resolve) => setTimeout(resolve, 150));
|
||||
// await new Promise((resolve) => setTimeout(resolve, 150));
|
||||
|
||||
// Determine which page to refetch based on current visible range
|
||||
let pageToFetch = 0;
|
||||
@@ -344,7 +346,12 @@ export const useItemListInfiniteLoader = ({
|
||||
stopIndex,
|
||||
});
|
||||
},
|
||||
[queryClient, itemsPerPage, onRangeChangedBase, dataQueryKey, totalItemCount, fetchPage],
|
||||
mutationKey: getListRefreshMutationKey(eventKey),
|
||||
});
|
||||
|
||||
const refresh = useCallback(
|
||||
async (force?: boolean) => refreshMutation.mutateAsync(force),
|
||||
[refreshMutation],
|
||||
);
|
||||
|
||||
const updateItems = useCallback(
|
||||
@@ -376,7 +383,7 @@ export const useItemListInfiniteLoader = ({
|
||||
return;
|
||||
}
|
||||
|
||||
return refresh(true);
|
||||
refreshMutation.mutate(true);
|
||||
};
|
||||
|
||||
eventEmitter.on('ITEM_LIST_REFRESH', handleRefresh);
|
||||
@@ -384,7 +391,7 @@ export const useItemListInfiniteLoader = ({
|
||||
return () => {
|
||||
eventEmitter.off('ITEM_LIST_REFRESH', handleRefresh);
|
||||
};
|
||||
}, [eventKey, refresh]);
|
||||
}, [eventKey, refreshMutation]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleFavorite = (payload: UserFavoriteEventPayload) => {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import {
|
||||
useMutation,
|
||||
useQuery,
|
||||
useQueryClient,
|
||||
useSuspenseQuery,
|
||||
@@ -10,6 +11,7 @@ import { queryKeys } from '/@/renderer/api/query-keys';
|
||||
import { useListContext } from '/@/renderer/context/list-context';
|
||||
import { eventEmitter } from '/@/renderer/events/event-emitter';
|
||||
import { UserFavoriteEventPayload, UserRatingEventPayload } from '/@/renderer/events/events';
|
||||
import { getListRefreshMutationKey } from '/@/renderer/features/shared/components/list-refresh-button';
|
||||
import { LibraryItem } from '/@/shared/types/domain-types';
|
||||
|
||||
const getQueryKeyName = (itemType: LibraryItem): string => {
|
||||
@@ -83,7 +85,7 @@ export const useItemListPaginatedLoader = ({
|
||||
[itemsPerPage, startIndex, query],
|
||||
);
|
||||
|
||||
const { data, refetch: queryRefetch } = useQuery({
|
||||
const { data } = useQuery({
|
||||
gcTime: 1000 * 15,
|
||||
placeholderData: { items: getInitialData(itemsPerPage) },
|
||||
queryFn: async ({ signal }) => {
|
||||
@@ -98,22 +100,20 @@ export const useItemListPaginatedLoader = ({
|
||||
staleTime: 1000 * 15,
|
||||
});
|
||||
|
||||
const refresh = useCallback(
|
||||
async (force?: boolean) => {
|
||||
const refreshMutation = useMutation({
|
||||
mutationFn: async (force?: boolean) => {
|
||||
const queryKey = queryKeys[getQueryKeyName(itemType)].list(serverId, queryParams);
|
||||
|
||||
await queryClient.invalidateQueries();
|
||||
|
||||
if (force) {
|
||||
queryClient.setQueryData(queryKey, {
|
||||
items: getInitialData(itemsPerPage),
|
||||
});
|
||||
}
|
||||
|
||||
return queryRefetch();
|
||||
await queryClient.invalidateQueries();
|
||||
},
|
||||
[queryClient, queryRefetch, queryParams, serverId, itemType, itemsPerPage],
|
||||
);
|
||||
mutationKey: getListRefreshMutationKey(eventKey ?? 'paginated'),
|
||||
});
|
||||
|
||||
const updateItems = useCallback(
|
||||
(indexes: number[], value: object) => {
|
||||
@@ -153,7 +153,7 @@ export const useItemListPaginatedLoader = ({
|
||||
return;
|
||||
}
|
||||
|
||||
return refresh(true);
|
||||
refreshMutation.mutate(true);
|
||||
};
|
||||
|
||||
const handleFavorite = (payload: UserFavoriteEventPayload) => {
|
||||
@@ -220,7 +220,7 @@ export const useItemListPaginatedLoader = ({
|
||||
eventEmitter.off('USER_FAVORITE', handleFavorite);
|
||||
eventEmitter.off('USER_RATING', handleRating);
|
||||
};
|
||||
}, [data, eventKey, itemType, serverId, refresh, updateItems]);
|
||||
}, [data, eventKey, itemType, refreshMutation, serverId, updateItems]);
|
||||
|
||||
return { data: data?.items || [], pageCount, totalItemCount };
|
||||
};
|
||||
|
||||
@@ -7,14 +7,19 @@ import { ItemListKey, TableColumn } from '/@/shared/types/types';
|
||||
|
||||
interface UseItemListColumnReorderProps {
|
||||
itemListKey: ItemListKey;
|
||||
tableKey?: 'detail' | 'main';
|
||||
}
|
||||
|
||||
export const useItemListColumnReorder = ({ itemListKey }: UseItemListColumnReorderProps) => {
|
||||
export const useItemListColumnReorder = ({
|
||||
itemListKey,
|
||||
tableKey = 'main',
|
||||
}: UseItemListColumnReorderProps) => {
|
||||
const { setList } = useSettingsStoreActions();
|
||||
|
||||
const handleColumnReordered = useCallback(
|
||||
(columnIdFrom: TableColumn, columnIdTo: TableColumn, edge: Edge | null) => {
|
||||
const columns = useSettingsStore.getState().lists[itemListKey]?.table.columns;
|
||||
const list = useSettingsStore.getState().lists[itemListKey];
|
||||
const columns = tableKey === 'detail' ? list?.detail?.columns : list?.table?.columns;
|
||||
|
||||
if (!columns) {
|
||||
return;
|
||||
@@ -83,13 +88,20 @@ export const useItemListColumnReorder = ({ itemListKey }: UseItemListColumnReord
|
||||
// Insert the column at the new position
|
||||
newColumns.splice(newIndex, 0, updatedMovedColumn);
|
||||
|
||||
setList(itemListKey, {
|
||||
table: {
|
||||
columns: newColumns,
|
||||
},
|
||||
});
|
||||
if (tableKey === 'detail') {
|
||||
type SetListData = Parameters<
|
||||
ReturnType<typeof useSettingsStoreActions>['setList']
|
||||
>[1];
|
||||
setList(itemListKey, { detail: { columns: newColumns } } as SetListData);
|
||||
} else {
|
||||
setList(itemListKey, {
|
||||
table: {
|
||||
columns: newColumns,
|
||||
},
|
||||
});
|
||||
}
|
||||
},
|
||||
[itemListKey, setList],
|
||||
[itemListKey, setList, tableKey],
|
||||
);
|
||||
|
||||
return { handleColumnReordered };
|
||||
|
||||
@@ -5,11 +5,18 @@ import { ItemListKey, TableColumn } from '/@/shared/types/types';
|
||||
|
||||
interface UseItemListColumnResizeProps {
|
||||
itemListKey: ItemListKey;
|
||||
tableKey?: 'detail' | 'main';
|
||||
}
|
||||
|
||||
export const useItemListColumnResize = ({ itemListKey }: UseItemListColumnResizeProps) => {
|
||||
export const useItemListColumnResize = ({
|
||||
itemListKey,
|
||||
tableKey = 'main',
|
||||
}: UseItemListColumnResizeProps) => {
|
||||
const { setList } = useSettingsStoreActions();
|
||||
const columns = useSettingsStore((state) => state.lists[itemListKey]?.table.columns);
|
||||
const columns = useSettingsStore((state) => {
|
||||
const list = state.lists[itemListKey];
|
||||
return tableKey === 'detail' ? list?.detail?.columns : list?.table?.columns;
|
||||
});
|
||||
|
||||
const handleColumnResized = useCallback(
|
||||
(columnId: TableColumn, width: number) => {
|
||||
@@ -19,13 +26,20 @@ export const useItemListColumnResize = ({ itemListKey }: UseItemListColumnResize
|
||||
column.id === columnId ? { ...column, width } : column,
|
||||
);
|
||||
|
||||
setList(itemListKey, {
|
||||
table: {
|
||||
columns: updatedColumns,
|
||||
},
|
||||
});
|
||||
if (tableKey === 'detail') {
|
||||
type SetListData = Parameters<
|
||||
ReturnType<typeof useSettingsStoreActions>['setList']
|
||||
>[1];
|
||||
setList(itemListKey, { detail: { columns: updatedColumns } } as SetListData);
|
||||
} else {
|
||||
setList(itemListKey, {
|
||||
table: {
|
||||
columns: updatedColumns,
|
||||
},
|
||||
});
|
||||
}
|
||||
},
|
||||
[columns, itemListKey, setList],
|
||||
[columns, itemListKey, setList, tableKey],
|
||||
);
|
||||
|
||||
return { handleColumnResized };
|
||||
|
||||
@@ -1,26 +1,29 @@
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { useSearchParams } from 'react-router';
|
||||
import { useLocation, useNavigationType } from 'react-router';
|
||||
|
||||
import { parseIntParam, setSearchParam } from '/@/renderer/utils/query-params';
|
||||
import { useScrollStore } from '/@/renderer/store/scroll.store';
|
||||
|
||||
interface UseItemListScrollPersistProps {
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
export const useItemListScrollPersist = ({ enabled }: UseItemListScrollPersistProps) => {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const location = useLocation();
|
||||
const navigationType = useNavigationType();
|
||||
const setOffset = useScrollStore((s) => s.setOffset);
|
||||
const getOffset = useScrollStore((s) => s.getOffset);
|
||||
|
||||
const scrollOffset = useMemo(() => parseIntParam(searchParams, 'scrollOffset'), [searchParams]);
|
||||
const scrollOffset = useMemo(() => {
|
||||
if (navigationType !== 'POP') return undefined;
|
||||
return getOffset(location.key);
|
||||
}, [getOffset, location.key, navigationType]);
|
||||
|
||||
const handleOnScrollEnd = useCallback(
|
||||
(offset: number) => {
|
||||
if (!enabled) return;
|
||||
|
||||
setSearchParams((prev) => setSearchParam(prev, 'scrollOffset', offset), {
|
||||
replace: true,
|
||||
});
|
||||
setOffset(location.key, offset);
|
||||
},
|
||||
[enabled, setSearchParams],
|
||||
[enabled, location.key, setOffset],
|
||||
);
|
||||
|
||||
return { handleOnScrollEnd, scrollOffset };
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { ItemDetailListCellProps } from './types';
|
||||
|
||||
import { ActionIcon } from '/@/shared/components/action-icon/action-icon';
|
||||
import { LibraryItem } from '/@/shared/types/domain-types';
|
||||
|
||||
export const ActionsColumn = ({ controls, internalState, song }: ItemDetailListCellProps) => {
|
||||
const handleClick = (event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
const index = internalState?.findItemIndex(song.id) ?? -1;
|
||||
controls?.onMore?.({
|
||||
event,
|
||||
index,
|
||||
internalState: internalState ?? undefined,
|
||||
item: song,
|
||||
itemType: LibraryItem.SONG,
|
||||
});
|
||||
};
|
||||
|
||||
const handleDoubleClick = (event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
};
|
||||
|
||||
return (
|
||||
<ActionIcon
|
||||
icon="ellipsisHorizontal"
|
||||
iconProps={{
|
||||
color: 'muted',
|
||||
size: 'xs',
|
||||
}}
|
||||
onClick={handleClick}
|
||||
onDoubleClick={handleDoubleClick}
|
||||
size="xs"
|
||||
variant="subtle"
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,23 @@
|
||||
import { ItemDetailListCellProps } from './types';
|
||||
|
||||
import {
|
||||
JOINED_ARTISTS_MUTED_PROPS,
|
||||
JoinedArtists,
|
||||
} from '/@/renderer/features/albums/components/joined-artists';
|
||||
|
||||
export const AlbumArtistColumn = ({ isRowHovered, song }: ItemDetailListCellProps) => {
|
||||
const name = song.albumArtistName?.trim() ?? '';
|
||||
const hasArtists = name.length > 0 || (song.albumArtists?.length ?? 0) > 0;
|
||||
|
||||
if (!hasArtists) return <> </>;
|
||||
|
||||
return (
|
||||
<JoinedArtists
|
||||
artistName={song.albumArtistName ?? ''}
|
||||
artists={song.albumArtists ?? []}
|
||||
linkProps={JOINED_ARTISTS_MUTED_PROPS.linkProps}
|
||||
readOnly={!isRowHovered}
|
||||
rootTextProps={JOINED_ARTISTS_MUTED_PROPS.rootTextProps}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,3 @@
|
||||
import { ItemDetailListCellProps } from './types';
|
||||
|
||||
export const AlbumColumn = ({ song }: ItemDetailListCellProps) => song.album ?? <> </>;
|
||||
@@ -0,0 +1,23 @@
|
||||
import { ItemDetailListCellProps } from './types';
|
||||
|
||||
import {
|
||||
JOINED_ARTISTS_MUTED_PROPS,
|
||||
JoinedArtists,
|
||||
} from '/@/renderer/features/albums/components/joined-artists';
|
||||
|
||||
export const ArtistColumn = ({ isRowHovered, song }: ItemDetailListCellProps) => {
|
||||
const name = song.artistName?.trim() ?? '';
|
||||
const hasArtists = name.length > 0 || (song.artists?.length ?? 0) > 0;
|
||||
|
||||
if (!hasArtists) return <> </>;
|
||||
|
||||
return (
|
||||
<JoinedArtists
|
||||
artistName={song.artistName ?? ''}
|
||||
artists={song.artists ?? []}
|
||||
linkProps={JOINED_ARTISTS_MUTED_PROPS.linkProps}
|
||||
readOnly={!isRowHovered}
|
||||
rootTextProps={JOINED_ARTISTS_MUTED_PROPS.rootTextProps}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,3 @@
|
||||
import { ItemDetailListCellProps } from './types';
|
||||
|
||||
export const BitDepthColumn = ({ song }: ItemDetailListCellProps) => song.bitDepth;
|
||||
@@ -0,0 +1,4 @@
|
||||
import { ItemDetailListCellProps } from './types';
|
||||
|
||||
export const BitRateColumn = ({ song }: ItemDetailListCellProps) =>
|
||||
song.bitRate != null ? `${song.bitRate} kbps` : <> </>;
|
||||
@@ -0,0 +1,3 @@
|
||||
import { ItemDetailListCellProps } from './types';
|
||||
|
||||
export const BpmColumn = ({ song }: ItemDetailListCellProps) => song.bpm ?? <> </>;
|
||||
@@ -0,0 +1,4 @@
|
||||
import { ItemDetailListCellProps } from './types';
|
||||
|
||||
export const ChannelsColumn = ({ song }: ItemDetailListCellProps) =>
|
||||
song.channels != null ? String(song.channels) : <> </>;
|
||||
@@ -0,0 +1,3 @@
|
||||
import { ItemDetailListCellProps } from './types';
|
||||
|
||||
export const CodecColumn = ({ song }: ItemDetailListCellProps) => song.container ?? <> </>;
|
||||
@@ -0,0 +1,3 @@
|
||||
import { ItemDetailListCellProps } from './types';
|
||||
|
||||
export const CommentColumn = ({ song }: ItemDetailListCellProps) => song.comment ?? <> </>;
|
||||
@@ -0,0 +1,7 @@
|
||||
import { ItemDetailListCellProps } from './types';
|
||||
|
||||
export const ComposerColumn = ({ song }: ItemDetailListCellProps) => {
|
||||
const composers = song.participants?.composer;
|
||||
if (!composers?.length) return <> </>;
|
||||
return composers.map((a) => a.name).join(', ');
|
||||
};
|
||||
@@ -0,0 +1,6 @@
|
||||
import { ItemDetailListCellProps } from './types';
|
||||
|
||||
import { formatDateAbsolute } from '/@/renderer/utils/format';
|
||||
|
||||
export const DateAddedColumn = ({ song }: ItemDetailListCellProps) =>
|
||||
song.createdAt ? formatDateAbsolute(song.createdAt) : <> </>;
|
||||
@@ -0,0 +1,11 @@
|
||||
import { ItemDetailListCellProps } from './types';
|
||||
|
||||
interface DefaultColumnProps extends ItemDetailListCellProps {
|
||||
columnId: string;
|
||||
}
|
||||
|
||||
export const DefaultColumn = ({ columnId, song }: DefaultColumnProps) => {
|
||||
const raw = (song as Record<string, unknown>)[columnId];
|
||||
if (raw === undefined || raw === null || typeof raw === 'object') return <> </>;
|
||||
return String(raw);
|
||||
};
|
||||
@@ -0,0 +1,3 @@
|
||||
import { ItemDetailListCellProps } from './types';
|
||||
|
||||
export const DiscNumberColumn = ({ song }: ItemDetailListCellProps) => String(song.discNumber ?? 1);
|
||||
@@ -0,0 +1,5 @@
|
||||
import formatDuration from 'format-duration';
|
||||
|
||||
import { ItemDetailListCellProps } from './types';
|
||||
|
||||
export const DurationColumn = ({ song }: ItemDetailListCellProps) => formatDuration(song.duration);
|
||||
@@ -0,0 +1,54 @@
|
||||
import { ItemDetailListCellProps } from './types';
|
||||
|
||||
import { useIsMutatingCreateFavorite } from '/@/renderer/features/shared/mutations/create-favorite-mutation';
|
||||
import { useIsMutatingDeleteFavorite } from '/@/renderer/features/shared/mutations/delete-favorite-mutation';
|
||||
import { ActionIcon } from '/@/shared/components/action-icon/action-icon';
|
||||
import { LibraryItem } from '/@/shared/types/domain-types';
|
||||
|
||||
export const FavoriteColumn = ({
|
||||
controls,
|
||||
internalState,
|
||||
isMutatingFavorite,
|
||||
onFavoriteClick,
|
||||
song,
|
||||
}: ItemDetailListCellProps) => {
|
||||
const isMutatingCreateFavorite = useIsMutatingCreateFavorite();
|
||||
const isMutatingDeleteFavorite = useIsMutatingDeleteFavorite();
|
||||
const isMutating = isMutatingFavorite ?? (isMutatingCreateFavorite || isMutatingDeleteFavorite);
|
||||
const isFavorite = song.userFavorite ?? false;
|
||||
|
||||
return (
|
||||
<ActionIcon
|
||||
disabled={isMutating}
|
||||
icon="favorite"
|
||||
iconProps={{
|
||||
color: isFavorite ? 'primary' : 'muted',
|
||||
fill: isFavorite ? 'primary' : undefined,
|
||||
size: 'xs',
|
||||
}}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
const index = internalState?.findItemIndex(song.id) ?? -1;
|
||||
if (controls?.onFavorite) {
|
||||
controls.onFavorite({
|
||||
event,
|
||||
favorite: !isFavorite,
|
||||
index,
|
||||
internalState: internalState ?? undefined,
|
||||
item: song,
|
||||
itemType: LibraryItem.SONG,
|
||||
});
|
||||
} else {
|
||||
onFavoriteClick?.(song);
|
||||
}
|
||||
}}
|
||||
onDoubleClick={(event) => {
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
}}
|
||||
size="xs"
|
||||
variant="subtle"
|
||||
/>
|
||||
);
|
||||
};
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
.group {
|
||||
flex-wrap: nowrap;
|
||||
gap: var(--theme-spacing-sm) var(--theme-spacing-xs);
|
||||
min-width: 0;
|
||||
padding: var(--theme-spacing-xs) 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.group a {
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { useMemo } from 'react';
|
||||
import { generatePath, Link } from 'react-router';
|
||||
|
||||
import styles from './genre-badge-column.module.css';
|
||||
import { ItemDetailListCellProps } from './types';
|
||||
|
||||
import { AppRoute } from '/@/renderer/router/routes';
|
||||
import { Badge } from '/@/shared/components/badge/badge';
|
||||
import { Group } from '/@/shared/components/group/group';
|
||||
import { stringToColor } from '/@/shared/utils/string-to-color';
|
||||
|
||||
const MAX_GENRES = 4;
|
||||
|
||||
export const GenreBadgeColumn = ({ song }: ItemDetailListCellProps) => {
|
||||
const genres = song.genres;
|
||||
|
||||
const genresWithStyle = useMemo(() => {
|
||||
if (!genres) return [];
|
||||
return genres.slice(0, MAX_GENRES).map((genre) => {
|
||||
const { color, isLight } = stringToColor(genre.name);
|
||||
const path = generatePath(AppRoute.LIBRARY_GENRES_DETAIL, { genreId: genre.id });
|
||||
return { ...genre, color, isLight, path };
|
||||
});
|
||||
}, [genres]);
|
||||
|
||||
if (!genresWithStyle.length) return <> </>;
|
||||
|
||||
return (
|
||||
<Group className={styles.group} wrap="nowrap">
|
||||
{genresWithStyle.map((genre) => (
|
||||
<Badge
|
||||
component={Link}
|
||||
key={genre.id}
|
||||
state={{ item: genre }}
|
||||
style={{
|
||||
backgroundColor: genre.color,
|
||||
color: genre.isLight ? 'black' : 'white',
|
||||
}}
|
||||
to={genre.path}
|
||||
>
|
||||
{genre.name}
|
||||
</Badge>
|
||||
))}
|
||||
</Group>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,40 @@
|
||||
import { Fragment } from 'react';
|
||||
import { generatePath, Link } from 'react-router';
|
||||
|
||||
import { ItemDetailListCellProps } from '/@/renderer/components/item-list/item-detail-list/columns/types';
|
||||
import { AppRoute } from '/@/renderer/router/routes';
|
||||
import { Text } from '/@/shared/components/text/text';
|
||||
|
||||
const TEXT_PROPS = { isMuted: true, isNoSelect: true, size: 'sm' as const } as const;
|
||||
|
||||
export const GenreColumn = ({ isRowHovered, song }: ItemDetailListCellProps) => {
|
||||
const genres = song.genres ?? [];
|
||||
if (!genres.length) return <> </>;
|
||||
|
||||
return (
|
||||
<>
|
||||
{genres.map((genre, index) => (
|
||||
<Fragment key={genre.id}>
|
||||
{isRowHovered ? (
|
||||
<Text
|
||||
component={Link}
|
||||
isLink
|
||||
state={{ item: genre }}
|
||||
to={generatePath(AppRoute.LIBRARY_GENRES_DETAIL, {
|
||||
genreId: genre.id,
|
||||
})}
|
||||
{...TEXT_PROPS}
|
||||
>
|
||||
{genre.name}
|
||||
</Text>
|
||||
) : (
|
||||
<Text component="span" {...TEXT_PROPS}>
|
||||
{genre.name}
|
||||
</Text>
|
||||
)}
|
||||
{index < genres.length - 1 && ', '}
|
||||
</Fragment>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,50 @@
|
||||
.image-container {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.compact-container {
|
||||
flex: 1 1 0;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
max-height: 100%;
|
||||
aspect-ratio: unset;
|
||||
padding-top: var(--theme-spacing-xs);
|
||||
padding-bottom: var(--theme-spacing-xs);
|
||||
overflow: hidden;
|
||||
border-radius: var(--theme-radius-md);
|
||||
}
|
||||
|
||||
.play-button-overlay {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
z-index: 10;
|
||||
opacity: 0.6;
|
||||
transform: translate(-50%, -50%);
|
||||
transition: opacity 0.2s ease-in-out;
|
||||
}
|
||||
|
||||
.play-button-overlay:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.play-button-overlay button {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
.compact-image {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
object-position: center;
|
||||
border-radius: var(--theme-radius-md);
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import clsx from 'clsx';
|
||||
import { useState } from 'react';
|
||||
|
||||
import styles from './image-column.module.css';
|
||||
import { ItemDetailListCellProps } from './types';
|
||||
|
||||
import { ItemImage } from '/@/renderer/components/item-image/item-image';
|
||||
import { PlayButton } from '/@/renderer/features/shared/components/play-button';
|
||||
import {
|
||||
LONG_PRESS_PLAY_BEHAVIOR,
|
||||
PlayTooltip,
|
||||
} from '/@/renderer/features/shared/components/play-button-group';
|
||||
import { usePlayButtonBehavior } from '/@/renderer/store';
|
||||
import { LibraryItem } from '/@/shared/types/domain-types';
|
||||
import { Play } from '/@/shared/types/types';
|
||||
|
||||
export const ImageColumn = ({
|
||||
controls,
|
||||
internalState,
|
||||
rowIndex = 0,
|
||||
song,
|
||||
}: ItemDetailListCellProps) => {
|
||||
const playButtonBehavior = usePlayButtonBehavior();
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
|
||||
const handlePlay = (playType: Play) => {
|
||||
if (!song || !controls?.onDoubleClick) {
|
||||
return;
|
||||
}
|
||||
|
||||
controls.onDoubleClick({
|
||||
event: null,
|
||||
index: rowIndex,
|
||||
internalState,
|
||||
item: song,
|
||||
itemType: LibraryItem.SONG,
|
||||
meta: { playType, singleSongOnly: true },
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={styles.imageContainer}
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
>
|
||||
<ItemImage
|
||||
className={styles.compactImage}
|
||||
containerClassName={styles.compactContainer}
|
||||
explicitStatus={song.explicitStatus}
|
||||
id={song.imageId}
|
||||
itemType={LibraryItem.SONG}
|
||||
serverId={song._serverId}
|
||||
type="table"
|
||||
/>
|
||||
{isHovered && (
|
||||
<div className={clsx(styles.playButtonOverlay)}>
|
||||
<PlayTooltip disabled={false} type={playButtonBehavior}>
|
||||
<PlayButton
|
||||
fill
|
||||
onClick={() => handlePlay(playButtonBehavior)}
|
||||
onLongPress={() =>
|
||||
handlePlay(LONG_PRESS_PLAY_BEHAVIOR[playButtonBehavior])
|
||||
}
|
||||
/>
|
||||
</PlayTooltip>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,127 @@
|
||||
import React, { type ReactNode } from 'react';
|
||||
|
||||
import type { ItemDetailListCellProps } from './types';
|
||||
|
||||
import { ActionsColumn } from './actions-column';
|
||||
import { AlbumArtistColumn } from './album-artist-column';
|
||||
import { AlbumColumn } from './album-column';
|
||||
import { ArtistColumn } from './artist-column';
|
||||
import { BitDepthColumn } from './bit-depth-column';
|
||||
import { BitRateColumn } from './bit-rate-column';
|
||||
import { BpmColumn } from './bpm-column';
|
||||
import { ChannelsColumn } from './channels-column';
|
||||
import { CodecColumn } from './codec-column';
|
||||
import { CommentColumn } from './comment-column';
|
||||
import { ComposerColumn } from './composer-column';
|
||||
import { DateAddedColumn } from './date-added-column';
|
||||
import { DefaultColumn } from './default-column';
|
||||
import { DiscNumberColumn } from './disc-number-column';
|
||||
import { DurationColumn } from './duration-column';
|
||||
import { FavoriteColumn } from './favorite-column';
|
||||
import { GenreBadgeColumn } from './genre-badge-column';
|
||||
import { GenreColumn } from './genre-column';
|
||||
import { ImageColumn } from './image-column';
|
||||
import { LastPlayedColumn } from './last-played-column';
|
||||
import { PathColumn } from './path-column';
|
||||
import { PlayCountColumn } from './play-count-column';
|
||||
import { RatingColumn } from './rating-column';
|
||||
import { ReleaseDateColumn } from './release-date-column';
|
||||
import { RowIndexColumn } from './row-index-column';
|
||||
import { SampleRateColumn } from './sample-rate-column';
|
||||
import { SizeColumn } from './size-column';
|
||||
import { TitleArtistColumn } from './title-artist-column';
|
||||
import { TitleColumn } from './title-column';
|
||||
import { TitleCombinedColumn } from './title-combined-column';
|
||||
import { TrackNumberColumn } from './track-number-column';
|
||||
import { YearColumn } from './year-column';
|
||||
|
||||
import { TableColumn } from '/@/shared/types/types';
|
||||
|
||||
type CellComponent = (props: ItemDetailListCellProps) => ReactNode;
|
||||
|
||||
const COLUMN_MAP: Partial<Record<TableColumn, CellComponent>> = {
|
||||
[TableColumn.ACTIONS]: ActionsColumn,
|
||||
[TableColumn.ALBUM]: AlbumColumn,
|
||||
[TableColumn.ALBUM_ARTIST]: AlbumArtistColumn,
|
||||
[TableColumn.ARTIST]: ArtistColumn,
|
||||
[TableColumn.BIT_DEPTH]: BitDepthColumn,
|
||||
[TableColumn.BIT_RATE]: BitRateColumn,
|
||||
[TableColumn.BPM]: BpmColumn,
|
||||
[TableColumn.CHANNELS]: ChannelsColumn,
|
||||
[TableColumn.CODEC]: CodecColumn,
|
||||
[TableColumn.COMMENT]: CommentColumn,
|
||||
[TableColumn.COMPOSER]: ComposerColumn,
|
||||
[TableColumn.DATE_ADDED]: DateAddedColumn,
|
||||
[TableColumn.DISC_NUMBER]: DiscNumberColumn,
|
||||
[TableColumn.DURATION]: DurationColumn,
|
||||
[TableColumn.GENRE]: GenreColumn,
|
||||
[TableColumn.GENRE_BADGE]: GenreBadgeColumn,
|
||||
[TableColumn.IMAGE]: ImageColumn,
|
||||
[TableColumn.LAST_PLAYED]: LastPlayedColumn,
|
||||
[TableColumn.PATH]: PathColumn,
|
||||
[TableColumn.PLAY_COUNT]: PlayCountColumn,
|
||||
[TableColumn.RELEASE_DATE]: ReleaseDateColumn,
|
||||
[TableColumn.ROW_INDEX]: RowIndexColumn,
|
||||
[TableColumn.SAMPLE_RATE]: SampleRateColumn,
|
||||
[TableColumn.SIZE]: SizeColumn,
|
||||
[TableColumn.TITLE]: TitleColumn,
|
||||
[TableColumn.TITLE_ARTIST]: TitleArtistColumn,
|
||||
[TableColumn.TITLE_COMBINED]: TitleCombinedColumn,
|
||||
[TableColumn.TRACK_NUMBER]: TrackNumberColumn,
|
||||
[TableColumn.USER_FAVORITE]: FavoriteColumn,
|
||||
[TableColumn.USER_RATING]: RatingColumn,
|
||||
[TableColumn.YEAR]: YearColumn,
|
||||
};
|
||||
|
||||
export type DetailListCellComponentProps = ItemDetailListCellProps & { columnId?: string };
|
||||
|
||||
export function getDetailListCellComponent(
|
||||
columnId: string | TableColumn,
|
||||
): (props: DetailListCellComponentProps) => ReactNode {
|
||||
const Component = COLUMN_MAP[columnId as TableColumn];
|
||||
if (Component) {
|
||||
return Component as (props: DetailListCellComponentProps) => ReactNode;
|
||||
}
|
||||
return (props: DetailListCellComponentProps) =>
|
||||
React.createElement(DefaultColumn, {
|
||||
columnId: props.columnId ?? (columnId as string),
|
||||
song: props.song,
|
||||
});
|
||||
}
|
||||
|
||||
export type { ItemDetailListCellProps } from './types';
|
||||
|
||||
export {
|
||||
ActionsColumn,
|
||||
AlbumArtistColumn,
|
||||
AlbumColumn,
|
||||
ArtistColumn,
|
||||
BitDepthColumn,
|
||||
BitRateColumn,
|
||||
BpmColumn,
|
||||
ChannelsColumn,
|
||||
CodecColumn,
|
||||
CommentColumn,
|
||||
ComposerColumn,
|
||||
DateAddedColumn,
|
||||
DefaultColumn,
|
||||
DiscNumberColumn,
|
||||
DurationColumn,
|
||||
FavoriteColumn,
|
||||
GenreBadgeColumn,
|
||||
GenreColumn,
|
||||
ImageColumn,
|
||||
LastPlayedColumn,
|
||||
PathColumn,
|
||||
PlayCountColumn,
|
||||
RatingColumn,
|
||||
ReleaseDateColumn,
|
||||
RowIndexColumn,
|
||||
SampleRateColumn,
|
||||
SizeColumn,
|
||||
TitleArtistColumn,
|
||||
TitleColumn,
|
||||
TitleCombinedColumn,
|
||||
TrackNumberColumn,
|
||||
YearColumn,
|
||||
};
|
||||
@@ -0,0 +1,6 @@
|
||||
import { ItemDetailListCellProps } from './types';
|
||||
|
||||
import { formatDateRelative } from '/@/renderer/utils/format';
|
||||
|
||||
export const LastPlayedColumn = ({ song }: ItemDetailListCellProps) =>
|
||||
song.lastPlayedAt ? formatDateRelative(song.lastPlayedAt) : <> </>;
|
||||
@@ -0,0 +1,3 @@
|
||||
import { ItemDetailListCellProps } from './types';
|
||||
|
||||
export const PathColumn = ({ song }: ItemDetailListCellProps) => song.path ?? <> </>;
|
||||
@@ -0,0 +1,4 @@
|
||||
import { ItemDetailListCellProps } from './types';
|
||||
|
||||
export const PlayCountColumn = ({ song }: ItemDetailListCellProps) =>
|
||||
song.playCount ? String(song.playCount) : <> </>;
|
||||
@@ -0,0 +1,29 @@
|
||||
import { ItemDetailListCellProps } from './types';
|
||||
|
||||
import { useIsMutatingRating } from '/@/renderer/features/shared/mutations/set-rating-mutation';
|
||||
import { Rating } from '/@/shared/components/rating/rating';
|
||||
import { LibraryItem } from '/@/shared/types/domain-types';
|
||||
|
||||
export const RatingColumn = ({ controls, internalState, song }: ItemDetailListCellProps) => {
|
||||
const isMutatingRating = useIsMutatingRating();
|
||||
const value = song.userRating ?? 0;
|
||||
|
||||
return (
|
||||
<Rating
|
||||
onChange={(rating) => {
|
||||
const index = internalState?.findItemIndex(song.id) ?? -1;
|
||||
controls?.onRating?.({
|
||||
event: null,
|
||||
index,
|
||||
internalState: internalState ?? undefined,
|
||||
item: song,
|
||||
itemType: LibraryItem.SONG,
|
||||
rating,
|
||||
});
|
||||
}}
|
||||
readOnly={isMutatingRating}
|
||||
size="xs"
|
||||
value={value}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,6 @@
|
||||
import { ItemDetailListCellProps } from './types';
|
||||
|
||||
import { formatDateAbsoluteUTC } from '/@/renderer/utils/format';
|
||||
|
||||
export const ReleaseDateColumn = ({ song }: ItemDetailListCellProps) =>
|
||||
song.releaseDate ? formatDateAbsoluteUTC(song.releaseDate) : <> </>;
|
||||
@@ -0,0 +1,5 @@
|
||||
.icon-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import styles from './row-index-column.module.css';
|
||||
import { ItemDetailListCellProps } from './types';
|
||||
|
||||
import { useIsCurrentSong } from '/@/renderer/features/player/hooks/use-is-current-song';
|
||||
import { usePlayerStatus } from '/@/renderer/store';
|
||||
import { Icon } from '/@/shared/components/icon/icon';
|
||||
import { PlayerStatus } from '/@/shared/types/types';
|
||||
|
||||
export const RowIndexColumn = ({ rowIndex, song }: ItemDetailListCellProps) => {
|
||||
const status = usePlayerStatus();
|
||||
const { isActive } = useIsCurrentSong(song);
|
||||
const isPlaying = isActive && status === PlayerStatus.PLAYING;
|
||||
|
||||
if (isActive) {
|
||||
return (
|
||||
<div className={styles.iconWrapper}>
|
||||
<Icon fill="primary" icon={isPlaying ? 'mediaPlay' : 'mediaPause'} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <>{String((rowIndex ?? 0) + 1)}</>;
|
||||
};
|
||||
@@ -0,0 +1,4 @@
|
||||
import { ItemDetailListCellProps } from './types';
|
||||
|
||||
export const SampleRateColumn = ({ song }: ItemDetailListCellProps) =>
|
||||
song.sampleRate ? `${song.sampleRate} Hz` : <> </>;
|
||||
@@ -0,0 +1,6 @@
|
||||
import { ItemDetailListCellProps } from './types';
|
||||
|
||||
import { formatSizeString } from '/@/renderer/utils/format';
|
||||
|
||||
export const SizeColumn = ({ song }: ItemDetailListCellProps) =>
|
||||
song.size ? formatSizeString(song.size) : <> </>;
|
||||
@@ -0,0 +1,18 @@
|
||||
import clsx from 'clsx';
|
||||
|
||||
import styles from './title-column.module.css';
|
||||
|
||||
import { ItemDetailListCellProps } from '/@/renderer/components/item-list/item-detail-list/columns/types';
|
||||
import { useIsCurrentSong } from '/@/renderer/features/player/hooks/use-is-current-song';
|
||||
import { ExplicitIndicator } from '/@/shared/components/explicit-indicator/explicit-indicator';
|
||||
|
||||
export const TitleArtistColumn = ({ song }: ItemDetailListCellProps) => {
|
||||
const { isActive } = useIsCurrentSong(song);
|
||||
|
||||
return (
|
||||
<span className={clsx({ [styles.active]: isActive })}>
|
||||
<ExplicitIndicator explicitStatus={song.explicitStatus} />
|
||||
{[song.name, song.artistName].filter(Boolean).join(' — ') ?? <> </>}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,3 @@
|
||||
.active {
|
||||
color: var(--theme-colors-primary);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import clsx from 'clsx';
|
||||
|
||||
import styles from './title-column.module.css';
|
||||
|
||||
import { ItemDetailListCellProps } from '/@/renderer/components/item-list/item-detail-list/columns/types';
|
||||
import { useIsCurrentSong } from '/@/renderer/features/player/hooks/use-is-current-song';
|
||||
import { ExplicitIndicator } from '/@/shared/components/explicit-indicator/explicit-indicator';
|
||||
|
||||
export const TitleColumn = ({ song }: ItemDetailListCellProps) => {
|
||||
const { isActive } = useIsCurrentSong(song);
|
||||
|
||||
return (
|
||||
<span className={clsx({ [styles.active]: isActive })}>
|
||||
<ExplicitIndicator explicitStatus={song.explicitStatus} />
|
||||
{song.name ?? <> </>}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,18 @@
|
||||
import clsx from 'clsx';
|
||||
|
||||
import styles from './title-column.module.css';
|
||||
|
||||
import { ItemDetailListCellProps } from '/@/renderer/components/item-list/item-detail-list/columns/types';
|
||||
import { useIsCurrentSong } from '/@/renderer/features/player/hooks/use-is-current-song';
|
||||
import { ExplicitIndicator } from '/@/shared/components/explicit-indicator/explicit-indicator';
|
||||
|
||||
export const TitleCombinedColumn = ({ song }: ItemDetailListCellProps) => {
|
||||
const { isActive } = useIsCurrentSong(song);
|
||||
|
||||
return (
|
||||
<span className={clsx({ [styles.active]: isActive })}>
|
||||
<ExplicitIndicator explicitStatus={song.explicitStatus} />
|
||||
{[song.name, song.artistName].filter(Boolean).join(' — ') ?? <> </>}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,7 @@
|
||||
import { ItemDetailListCellProps } from './types';
|
||||
|
||||
export const TrackNumberColumn = ({ song }: ItemDetailListCellProps) => {
|
||||
const disc = song.discNumber ?? 1;
|
||||
const track = song.trackNumber.toString().padStart(2, '0');
|
||||
return `${disc}-${track}`;
|
||||
};
|
||||
@@ -0,0 +1,14 @@
|
||||
import { ItemListStateActions } from '/@/renderer/components/item-list/helpers/item-list-state';
|
||||
import { ItemControls } from '/@/renderer/components/item-list/types';
|
||||
import { Song } from '/@/shared/types/domain-types';
|
||||
|
||||
export interface ItemDetailListCellProps {
|
||||
controls?: ItemControls;
|
||||
internalState?: ItemListStateActions;
|
||||
isMutatingFavorite?: boolean;
|
||||
isRowHovered?: boolean;
|
||||
onFavoriteClick?: (song: Song) => void;
|
||||
rowIndex?: number;
|
||||
size?: 'compact' | 'default' | 'large';
|
||||
song: Song;
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
import { ItemDetailListCellProps } from './types';
|
||||
|
||||
export const YearColumn = ({ song }: ItemDetailListCellProps) =>
|
||||
song.releaseYear ? String(song.releaseYear) : <> </>;
|
||||
@@ -0,0 +1,556 @@
|
||||
.container {
|
||||
position: relative;
|
||||
flex: 1 1 auto;
|
||||
width: 100%;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.placeholder {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.detail-list-header {
|
||||
display: grid;
|
||||
flex-shrink: 0;
|
||||
grid-template-columns: 240px 1fr;
|
||||
gap: var(--theme-spacing-md);
|
||||
padding: 0 var(--theme-spacing-md);
|
||||
font-size: var(--theme-font-size-sm);
|
||||
user-select: none;
|
||||
background-color: var(--theme-colors-background);
|
||||
border-bottom: 1px solid var(--theme-colors-border);
|
||||
}
|
||||
|
||||
.header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.header-left-album-name {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
font-size: var(--theme-font-size-sm);
|
||||
font-weight: 500;
|
||||
color: var(--theme-colors-foreground);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.header-right {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.tracks-table-header {
|
||||
display: flex;
|
||||
flex-wrap: nowrap;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.tracks-table-header-size-compact {
|
||||
height: 32px;
|
||||
min-height: 32px;
|
||||
}
|
||||
|
||||
.tracks-table-header-size-default {
|
||||
height: 40px;
|
||||
min-height: 40px;
|
||||
}
|
||||
|
||||
.tracks-table-header-size-large {
|
||||
height: 48px;
|
||||
min-height: 48px;
|
||||
}
|
||||
|
||||
.track-header-cell {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
min-height: 60%;
|
||||
padding-right: var(--theme-spacing-sm);
|
||||
padding-left: var(--theme-spacing-sm);
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.track-header-cell-no-h-padding {
|
||||
padding-right: 0;
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
.track-header-cell-with-vertical-border {
|
||||
border-right: 1px solid var(--theme-colors-border);
|
||||
}
|
||||
|
||||
.track-header-cell-dragging {
|
||||
cursor: grabbing;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.track-header-cell-dragged-over-left::before {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
z-index: 10;
|
||||
width: 3px;
|
||||
content: '';
|
||||
background-color: var(--theme-colors-primary);
|
||||
}
|
||||
|
||||
.track-header-cell-dragged-over-right::after {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 10;
|
||||
width: 3px;
|
||||
content: '';
|
||||
background-color: var(--theme-colors-primary);
|
||||
}
|
||||
|
||||
.track-header-cell:hover .resize-handle {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.track-header-cell:hover .resize-handle::before {
|
||||
background-color: var(--theme-colors-border);
|
||||
}
|
||||
|
||||
.resize-handle {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
z-index: 10;
|
||||
width: 2px;
|
||||
margin-right: -4px;
|
||||
cursor: col-resize;
|
||||
background: var(--theme-colors-border);
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
|
||||
/* .resize-handle::before {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
width: 2px;
|
||||
content: '';
|
||||
background-color: transparent;
|
||||
transition: background-color 0.15s ease;
|
||||
} */
|
||||
|
||||
.resize-handle-left {
|
||||
left: 0;
|
||||
margin-right: 0;
|
||||
margin-left: -4px;
|
||||
}
|
||||
|
||||
.resize-handle-left::before {
|
||||
right: auto;
|
||||
left: 0;
|
||||
}
|
||||
|
||||
.resize-handle-right {
|
||||
right: 0;
|
||||
margin-right: 0;
|
||||
}
|
||||
|
||||
.resize-handle-dragging {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.resize-handle:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.row {
|
||||
display: grid;
|
||||
grid-template-columns: 240px 1fr;
|
||||
gap: var(--theme-spacing-md);
|
||||
padding: var(--theme-spacing-md);
|
||||
border-bottom: 1px solid var(--theme-colors-border);
|
||||
}
|
||||
|
||||
.skeleton-column-wrapper {
|
||||
box-sizing: border-box;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.image-wrapper {
|
||||
position: relative;
|
||||
display: block;
|
||||
width: 100%;
|
||||
aspect-ratio: 1;
|
||||
overflow: hidden;
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
border-radius: var(--theme-radius-md);
|
||||
|
||||
&::before {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
z-index: 5;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
pointer-events: none;
|
||||
content: '';
|
||||
background-color: rgb(0 0 0);
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease-in-out;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
@mixin dark {
|
||||
&::before {
|
||||
opacity: 0.7;
|
||||
}
|
||||
}
|
||||
|
||||
@mixin light {
|
||||
&::before {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&:hover .favorite-badge,
|
||||
&:hover .rating-badge {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.favorite-badge {
|
||||
position: absolute;
|
||||
top: -50px;
|
||||
left: -50px;
|
||||
z-index: 1;
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
pointer-events: none;
|
||||
background-color: var(--theme-colors-primary);
|
||||
box-shadow: 0 0 10px 8px rgb(0 0 0 / 80%);
|
||||
opacity: 1;
|
||||
transform: rotate(-45deg);
|
||||
transition: opacity 0.2s ease-in-out;
|
||||
}
|
||||
|
||||
.rating-badge {
|
||||
position: absolute;
|
||||
top: var(--theme-spacing-sm);
|
||||
right: var(--theme-spacing-sm);
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: var(--theme-spacing-xs) var(--theme-spacing-sm);
|
||||
font-size: var(--theme-font-size-md);
|
||||
font-weight: 600;
|
||||
color: var(--theme-colors-foreground);
|
||||
text-shadow: 0 1px 2px rgb(0 0 0 / 80%);
|
||||
pointer-events: none;
|
||||
background-color: var(--theme-colors-primary);
|
||||
border-radius: var(--theme-radius-md);
|
||||
box-shadow: 0 2px 8px rgb(0 0 0 / 50%);
|
||||
opacity: 1;
|
||||
transition: opacity 0.2s ease-in-out;
|
||||
}
|
||||
|
||||
.row .image {
|
||||
object-fit: var(--theme-image-fit);
|
||||
border-radius: var(--theme-radius-md);
|
||||
}
|
||||
|
||||
.row .metadata {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--theme-spacing-xs);
|
||||
align-items: center;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
font-size: var(--theme-font-size-md);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.row .title {
|
||||
font-weight: 500;
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.row .title:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.row .artist {
|
||||
font-size: var(--theme-font-size-sm);
|
||||
color: var(--theme-colors-foreground-muted);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.row .artist-plain-text:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.row .metadata-link {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.row .metadata-link:hover {
|
||||
color: var(--theme-colors-foreground);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.row .metadata-extra {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--theme-spacing-xs);
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
font-size: var(--theme-font-size-sm);
|
||||
color: var(--theme-colors-foreground-muted);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.row .metadata-line {
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
text-wrap-style: balance;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.row .metadata-line-clamp-2 {
|
||||
display: -webkit-box;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 2;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.row .right {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.row .tracks-table {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
font-size: var(--theme-font-size-sm);
|
||||
}
|
||||
|
||||
.row .track-row {
|
||||
display: flex;
|
||||
flex-wrap: nowrap;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.row .track-header-cell {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.row .track-cell {
|
||||
min-width: 0;
|
||||
padding-right: var(--theme-spacing-sm);
|
||||
padding-left: var(--theme-spacing-sm);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.row .track-row-size-compact {
|
||||
height: 32px;
|
||||
min-height: 32px;
|
||||
max-height: 32px;
|
||||
}
|
||||
|
||||
.row .track-row-size-default {
|
||||
height: 40px;
|
||||
min-height: 40px;
|
||||
max-height: 40px;
|
||||
}
|
||||
|
||||
.row .track-row-size-large {
|
||||
height: 48px;
|
||||
min-height: 48px;
|
||||
max-height: 48px;
|
||||
}
|
||||
|
||||
.row .track-cell-muted {
|
||||
color: var(--theme-colors-foreground-muted);
|
||||
}
|
||||
|
||||
.row .track-cell-with-vertical-border {
|
||||
border-right: 1px solid transparent;
|
||||
}
|
||||
|
||||
.row .track-cell-vertical-border-visible {
|
||||
border-right-color: var(--theme-colors-border);
|
||||
}
|
||||
|
||||
.row .track-cell-image {
|
||||
display: flex;
|
||||
align-self: stretch;
|
||||
min-height: 0;
|
||||
max-height: 100%;
|
||||
padding-right: var(--theme-spacing-sm);
|
||||
padding-left: var(--theme-spacing-sm);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.row .track-cell-no-h-padding {
|
||||
padding-right: 0;
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
.track-row-dragging {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.track-row.track-row-alternate-even {
|
||||
background-color: var(--theme-colors-background);
|
||||
}
|
||||
|
||||
.track-row.track-row-alternate-odd {
|
||||
@mixin dark {
|
||||
background-color: darken(var(--theme-colors-background), 30%);
|
||||
}
|
||||
|
||||
@mixin light {
|
||||
background-color: darken(var(--theme-colors-background), 2%);
|
||||
}
|
||||
}
|
||||
|
||||
.track-row.track-row-selected {
|
||||
@mixin dark {
|
||||
background-color: lighten(var(--theme-colors-surface), 5%);
|
||||
}
|
||||
|
||||
@mixin light {
|
||||
background-color: darken(var(--theme-colors-surface), 5%);
|
||||
}
|
||||
}
|
||||
|
||||
.track-row.track-row-with-horizontal-border {
|
||||
border-top: 1px solid transparent;
|
||||
}
|
||||
|
||||
.track-row.track-row-horizontal-border-visible {
|
||||
border-top-color: var(--theme-colors-border);
|
||||
}
|
||||
|
||||
.track-row.track-row-hover-highlight-enabled {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.track-row.track-row-hover-highlight-enabled .track-cell {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.track-row.track-row-hover-highlight-enabled:hover::before {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
z-index: 1;
|
||||
pointer-events: none;
|
||||
content: '';
|
||||
background-color: var(--theme-colors-surface);
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.skeleton-image-container {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.skeleton-image {
|
||||
width: 100%;
|
||||
aspect-ratio: 1;
|
||||
border-radius: var(--theme-radius-md);
|
||||
}
|
||||
|
||||
.skeleton-title-container {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.skeleton-title {
|
||||
width: 75%;
|
||||
height: 1.25rem;
|
||||
}
|
||||
|
||||
.skeleton-artist-container {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.skeleton-artist {
|
||||
width: 50%;
|
||||
height: 1rem;
|
||||
}
|
||||
|
||||
.skeleton-tracks {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.skeleton-track-row {
|
||||
display: grid;
|
||||
grid-template-columns: 40px 1fr 8rem;
|
||||
gap: var(--theme-spacing-sm);
|
||||
align-items: center;
|
||||
padding-right: var(--theme-spacing-sm);
|
||||
padding-left: var(--theme-spacing-sm);
|
||||
}
|
||||
|
||||
.skeleton-tracks-size-compact .skeleton-track-row {
|
||||
height: 32px;
|
||||
padding-top: 0;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.skeleton-tracks-size-default .skeleton-track-row {
|
||||
height: 40px;
|
||||
padding-top: 0;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.skeleton-tracks-size-large .skeleton-track-row {
|
||||
height: 48px;
|
||||
padding-top: 0;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.skeleton-track-cell {
|
||||
width: 100%;
|
||||
height: 1rem;
|
||||
}
|
||||
|
||||
.skeleton-track-cell-title {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
height: 1rem;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,65 @@
|
||||
import { TableColumn } from '/@/shared/types/types';
|
||||
|
||||
const FIXED_TRACK_COLUMN_WIDTHS: Partial<Record<TableColumn, number>> = {
|
||||
[TableColumn.ACTIONS]: 32,
|
||||
[TableColumn.BIT_DEPTH]: 80,
|
||||
[TableColumn.BIT_RATE]: 80,
|
||||
[TableColumn.BPM]: 56,
|
||||
[TableColumn.CHANNELS]: 80,
|
||||
[TableColumn.CODEC]: 80,
|
||||
[TableColumn.DATE_ADDED]: 128,
|
||||
[TableColumn.DISC_NUMBER]: 36,
|
||||
[TableColumn.DURATION]: 72,
|
||||
[TableColumn.RELEASE_DATE]: 128,
|
||||
[TableColumn.SAMPLE_RATE]: 90,
|
||||
[TableColumn.TRACK_NUMBER]: 56,
|
||||
[TableColumn.USER_FAVORITE]: 32,
|
||||
[TableColumn.USER_RATING]: 64,
|
||||
[TableColumn.YEAR]: 56,
|
||||
};
|
||||
|
||||
const HOVER_ONLY_COLUMNS: TableColumn[] = [
|
||||
TableColumn.ACTIONS,
|
||||
TableColumn.USER_FAVORITE,
|
||||
TableColumn.USER_RATING,
|
||||
];
|
||||
|
||||
const NO_HORIZONTAL_PADDING_COLUMNS: TableColumn[] = [
|
||||
TableColumn.ACTIONS,
|
||||
TableColumn.USER_FAVORITE,
|
||||
TableColumn.USER_RATING,
|
||||
];
|
||||
|
||||
export function getTrackColumnFixed(columnId: TableColumn): {
|
||||
fixedWidth: number;
|
||||
isFixedColumn: boolean;
|
||||
} {
|
||||
const width = FIXED_TRACK_COLUMN_WIDTHS[columnId];
|
||||
return width !== undefined
|
||||
? { fixedWidth: width, isFixedColumn: true }
|
||||
: { fixedWidth: 0, isFixedColumn: false };
|
||||
}
|
||||
|
||||
export function isNoHorizontalPaddingColumn(columnId: TableColumn): boolean {
|
||||
return NO_HORIZONTAL_PADDING_COLUMNS.includes(columnId);
|
||||
}
|
||||
|
||||
export function isTrackColumnHoverOnly(columnId: TableColumn): boolean {
|
||||
return HOVER_ONLY_COLUMNS.includes(columnId);
|
||||
}
|
||||
|
||||
export function shouldShowHoverOnlyColumnContent(
|
||||
columnId: TableColumn,
|
||||
isRowHovered: boolean,
|
||||
song: { userFavorite?: boolean | null; userRating?: null | number },
|
||||
): boolean {
|
||||
if (!HOVER_ONLY_COLUMNS.includes(columnId)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return (
|
||||
isRowHovered ||
|
||||
(columnId === TableColumn.USER_FAVORITE && song.userFavorite !== false) ||
|
||||
(columnId === TableColumn.USER_RATING && song.userRating != null)
|
||||
);
|
||||
}
|
||||
@@ -54,6 +54,7 @@ const ImageColumnBase = (props: ItemTableListInnerColumn) => {
|
||||
itemType: props.itemType,
|
||||
meta: {
|
||||
playType,
|
||||
singleSongOnly: true,
|
||||
},
|
||||
});
|
||||
return;
|
||||
@@ -90,6 +91,7 @@ const ImageColumnBase = (props: ItemTableListInnerColumn) => {
|
||||
})}
|
||||
enableDebounce={true}
|
||||
enableViewport={false}
|
||||
explicitStatus={item?.explicitStatus}
|
||||
id={item?.imageId}
|
||||
itemType={item?._itemType}
|
||||
src={item?.imageUrl}
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
} from '/@/renderer/components/item-list/item-table-list/item-table-list-column';
|
||||
import { useIsActiveRow } from '/@/renderer/components/item-list/item-table-list/item-table-list-context';
|
||||
import { JoinedArtists } from '/@/renderer/features/albums/components/joined-artists';
|
||||
import { ExplicitIndicator } from '/@/shared/components/explicit-indicator/explicit-indicator';
|
||||
import { Icon } from '/@/shared/components/icon/icon';
|
||||
import { Text } from '/@/shared/components/text/text';
|
||||
import { Folder, LibraryItem, QueueSong } from '/@/shared/types/domain-types';
|
||||
@@ -52,6 +53,7 @@ export const DefaultTitleArtistColumn = (props: ItemTableListInnerColumn) => {
|
||||
})}
|
||||
>
|
||||
<Text className={styles.title} isNoSelect size="md" {...titleLinkProps}>
|
||||
<ExplicitIndicator explicitStatus={item?.explicitStatus} />
|
||||
{item.name as string}
|
||||
</Text>
|
||||
<div className={styles.artists}>
|
||||
@@ -120,6 +122,7 @@ export const QueueSongTitleArtistColumn = (props: ItemTableListInnerColumn) => {
|
||||
size="md"
|
||||
{...titleLinkProps}
|
||||
>
|
||||
<ExplicitIndicator explicitStatus={song?.explicitStatus} />
|
||||
{row.name as string}
|
||||
{song?.trackSubtitle && props.itemType !== LibraryItem.QUEUE_SONG && (
|
||||
<Text
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
TableColumnContainer,
|
||||
} from '/@/renderer/components/item-list/item-table-list/item-table-list-column';
|
||||
import { useIsActiveRow } from '/@/renderer/components/item-list/item-table-list/item-table-list-context';
|
||||
import { ExplicitIndicator } from '/@/shared/components/explicit-indicator/explicit-indicator';
|
||||
import { Text } from '/@/shared/components/text/text';
|
||||
import { LibraryItem, QueueSong } from '/@/shared/types/domain-types';
|
||||
|
||||
@@ -58,6 +59,7 @@ function DefaultTitleColumn(props: ItemTableListInnerColumn) {
|
||||
isNoSelect
|
||||
{...titleLinkProps}
|
||||
>
|
||||
<ExplicitIndicator explicitStatus={item?.explicitStatus} />
|
||||
{row}
|
||||
</Text>
|
||||
</TableColumnContainer>
|
||||
@@ -103,6 +105,7 @@ function QueueSongTitleColumn(props: ItemTableListInnerColumn) {
|
||||
isNoSelect
|
||||
{...titleLinkProps}
|
||||
>
|
||||
<ExplicitIndicator explicitStatus={song?.explicitStatus} />
|
||||
{row}
|
||||
{song?.trackSubtitle && props.itemType !== LibraryItem.QUEUE_SONG && (
|
||||
<Text
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
PlayTooltip,
|
||||
} from '/@/renderer/features/shared/components/play-button-group';
|
||||
import { usePlayButtonBehavior } from '/@/renderer/store';
|
||||
import { ExplicitIndicator } from '/@/shared/components/explicit-indicator/explicit-indicator';
|
||||
import { Icon } from '/@/shared/components/icon/icon';
|
||||
import { Text } from '/@/shared/components/text/text';
|
||||
import { Folder, LibraryItem, QueueSong } from '/@/shared/types/domain-types';
|
||||
@@ -57,6 +58,7 @@ export const DefaultTitleCombinedColumn = (props: ItemTableListInnerColumn) => {
|
||||
itemType: props.itemType,
|
||||
meta: {
|
||||
playType,
|
||||
singleSongOnly: true,
|
||||
},
|
||||
});
|
||||
return;
|
||||
@@ -105,6 +107,7 @@ export const DefaultTitleCombinedColumn = (props: ItemTableListInnerColumn) => {
|
||||
containerClassName={styles.image}
|
||||
enableDebounce={true}
|
||||
enableViewport={false}
|
||||
explicitStatus={item?.explicitStatus}
|
||||
id={item?.imageId}
|
||||
itemType={item?._itemType}
|
||||
src={item?.imageUrl}
|
||||
@@ -140,6 +143,7 @@ export const DefaultTitleCombinedColumn = (props: ItemTableListInnerColumn) => {
|
||||
})}
|
||||
>
|
||||
<Text className={styles.title} isNoSelect size="md" {...titleLinkProps}>
|
||||
<ExplicitIndicator explicitStatus={item?.explicitStatus} />
|
||||
{item.name as string}
|
||||
</Text>
|
||||
<div className={styles.artists}>
|
||||
@@ -197,6 +201,7 @@ export const QueueSongTitleCombinedColumn = (props: ItemTableListInnerColumn) =>
|
||||
itemType: props.itemType,
|
||||
meta: {
|
||||
playType,
|
||||
singleSongOnly: true,
|
||||
},
|
||||
});
|
||||
return;
|
||||
@@ -244,6 +249,7 @@ export const QueueSongTitleCombinedColumn = (props: ItemTableListInnerColumn) =>
|
||||
>
|
||||
<ItemImage
|
||||
containerClassName={styles.image}
|
||||
explicitStatus={item?.explicitStatus}
|
||||
id={item?.imageId}
|
||||
itemType={item?._itemType}
|
||||
serverId={item?._serverId}
|
||||
@@ -289,6 +295,7 @@ export const QueueSongTitleCombinedColumn = (props: ItemTableListInnerColumn) =>
|
||||
size="md"
|
||||
{...titleLinkProps}
|
||||
>
|
||||
<ExplicitIndicator explicitStatus={song?.explicitStatus} />
|
||||
{row.name as string}
|
||||
{song?.trackSubtitle && props.itemType !== LibraryItem.QUEUE_SONG && (
|
||||
<Text
|
||||
|
||||
+5
-5
@@ -7,8 +7,8 @@ import { useDragDrop } from '/@/renderer/hooks/use-drag-drop';
|
||||
import { Folder, LibraryItem, QueueSong, Song } from '/@/shared/types/domain-types';
|
||||
import { DragOperation, DragTarget, DragTargetMap } from '/@/shared/types/drag-and-drop';
|
||||
|
||||
interface DragDropState {
|
||||
dragRef: null | React.Ref<HTMLDivElement>;
|
||||
interface DragDropState<TElement extends HTMLElement = HTMLDivElement> {
|
||||
dragRef: null | React.Ref<TElement>;
|
||||
isDraggedOver: 'bottom' | 'top' | null;
|
||||
isDragging: boolean;
|
||||
}
|
||||
@@ -23,7 +23,7 @@ interface UseItemDragDropStateProps {
|
||||
playlistId?: string;
|
||||
}
|
||||
|
||||
export const useItemDragDropState = ({
|
||||
export const useItemDragDropState = <TElement extends HTMLElement = HTMLDivElement>({
|
||||
enableDrag,
|
||||
internalState,
|
||||
isDataRow,
|
||||
@@ -31,14 +31,14 @@ export const useItemDragDropState = ({
|
||||
itemType,
|
||||
playerContext,
|
||||
playlistId,
|
||||
}: UseItemDragDropStateProps): DragDropState => {
|
||||
}: UseItemDragDropStateProps): DragDropState<TElement> => {
|
||||
const shouldEnableDrag = enableDrag && isDataRow && !!item;
|
||||
|
||||
const {
|
||||
isDraggedOver,
|
||||
isDragging: isDraggingLocal,
|
||||
ref: dragRef,
|
||||
} = useDragDrop<HTMLDivElement>({
|
||||
} = useDragDrop<TElement>({
|
||||
drag: {
|
||||
getId: () => {
|
||||
if (!item || !isDataRow) {
|
||||
|
||||
@@ -26,6 +26,11 @@
|
||||
padding: var(--theme-spacing-xs) var(--theme-spacing-xl);
|
||||
}
|
||||
|
||||
.container.no-horizontal-padding {
|
||||
padding-right: 0;
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
.container.center {
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
@@ -205,6 +210,11 @@
|
||||
padding: 0 var(--theme-spacing-xl);
|
||||
}
|
||||
|
||||
.header-container.no-horizontal-padding {
|
||||
padding-right: 0;
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
.header-dragging {
|
||||
cursor: grabbing;
|
||||
opacity: 0.5;
|
||||
|
||||
@@ -26,6 +26,7 @@ import styles from './item-table-list-column.module.css';
|
||||
|
||||
import i18n from '/@/i18n/i18n';
|
||||
import { useItemSelectionState } from '/@/renderer/components/item-list/helpers/item-list-state';
|
||||
import { isNoHorizontalPaddingColumn } from '/@/renderer/components/item-list/item-detail-list/utils';
|
||||
import { ActionsColumn } from '/@/renderer/components/item-list/item-table-list/columns/actions-column';
|
||||
import { AlbumArtistsColumn } from '/@/renderer/components/item-list/item-table-list/columns/album-artists-column';
|
||||
import { AlbumColumn } from '/@/renderer/components/item-list/item-table-list/columns/album-column';
|
||||
@@ -479,6 +480,7 @@ export const TableColumnTextContainer = (
|
||||
[styles.dragging]: isDataRow && isDragging,
|
||||
[styles.large]: props.size === 'large',
|
||||
[styles.left]: props.columns[props.columnIndex].align === 'start',
|
||||
[styles.noHorizontalPadding]: isNoHorizontalPaddingColumn(props.type),
|
||||
[styles.paddingLg]: props.cellPadding === 'lg',
|
||||
[styles.paddingMd]: props.cellPadding === 'md',
|
||||
[styles.paddingSm]: props.cellPadding === 'sm',
|
||||
@@ -632,6 +634,7 @@ export const TableColumnContainer = (
|
||||
[styles.dragging]: isDataRow && isDragging,
|
||||
[styles.large]: props.size === 'large',
|
||||
[styles.left]: props.columns[props.columnIndex].align === 'start',
|
||||
[styles.noHorizontalPadding]: isNoHorizontalPaddingColumn(props.type),
|
||||
[styles.paddingLg]: props.cellPadding === 'lg',
|
||||
[styles.paddingMd]: props.cellPadding === 'md',
|
||||
[styles.paddingSm]: props.cellPadding === 'sm',
|
||||
@@ -850,6 +853,7 @@ export const TableColumnHeaderContainer = (
|
||||
[styles.headerDraggedOverLeft]: isDraggedOver === 'left',
|
||||
[styles.headerDraggedOverRight]: isDraggedOver === 'right',
|
||||
[styles.headerDragging]: isDragging,
|
||||
[styles.noHorizontalPadding]: isNoHorizontalPaddingColumn(props.type),
|
||||
[styles.paddingLg]: props.cellPadding === 'lg',
|
||||
[styles.paddingMd]: props.cellPadding === 'md',
|
||||
[styles.paddingSm]: props.cellPadding === 'sm',
|
||||
@@ -881,7 +885,7 @@ export const TableColumnHeaderContainer = (
|
||||
);
|
||||
};
|
||||
|
||||
const columnLabelMap: Record<TableColumn, ReactNode | string> = {
|
||||
export const columnLabelMap: Record<TableColumn, ReactNode | string> = {
|
||||
[TableColumn.ACTIONS]: (
|
||||
<Flex className={styles.headerIconWrapper}>
|
||||
<Icon fill="default" icon="ellipsisHorizontal" />
|
||||
|
||||
@@ -98,7 +98,7 @@ export interface ItemListTableComponentProps<TQuery> extends ItemListComponentPr
|
||||
enableRowHoverHighlight?: boolean;
|
||||
enableSelection?: boolean;
|
||||
enableVerticalBorders?: boolean;
|
||||
size?: 'compact' | 'default';
|
||||
size?: 'compact' | 'default' | 'large';
|
||||
}
|
||||
|
||||
export interface ItemTableListColumnConfig {
|
||||
|
||||
@@ -22,7 +22,10 @@ import { albumQueries } from '/@/renderer/features/albums/api/album-api';
|
||||
import { AlbumInfiniteCarousel } from '/@/renderer/features/albums/components/album-infinite-carousel';
|
||||
import { usePlayer } from '/@/renderer/features/player/context/player-context';
|
||||
import { ListConfigMenu } from '/@/renderer/features/shared/components/list-config-menu';
|
||||
import { ListSortByDropdownControlled } from '/@/renderer/features/shared/components/list-sort-by-dropdown';
|
||||
import {
|
||||
CLIENT_SIDE_SONG_FILTERS,
|
||||
ListSortByDropdownControlled,
|
||||
} from '/@/renderer/features/shared/components/list-sort-by-dropdown';
|
||||
import { ListSortOrderToggleButtonControlled } from '/@/renderer/features/shared/components/list-sort-order-toggle-button';
|
||||
import { FILTER_KEYS, searchLibraryItems } from '/@/renderer/features/shared/utils';
|
||||
import { AppRoute } from '/@/renderer/router/routes';
|
||||
@@ -345,7 +348,6 @@ const AlbumMetadataExternalLinks = ({
|
||||
}}
|
||||
radius="md"
|
||||
rel="noopener noreferrer"
|
||||
size="md"
|
||||
target="_blank"
|
||||
tooltip={{
|
||||
label: t('action.openIn.musicbrainz'),
|
||||
@@ -743,7 +745,8 @@ const AlbumDetailSongsTable = ({ songs }: AlbumDetailSongsTableProps) => {
|
||||
value={searchTerm}
|
||||
/>
|
||||
<ListSortByDropdownControlled
|
||||
itemType={LibraryItem.PLAYLIST_SONG}
|
||||
filters={CLIENT_SIDE_SONG_FILTERS}
|
||||
itemType={LibraryItem.SONG}
|
||||
setSortBy={(value) => setSortBy(value as SongListSort)}
|
||||
sortBy={sortBy}
|
||||
/>
|
||||
|
||||
@@ -220,6 +220,7 @@ export const AlbumDetailHeader = forwardRef<HTMLDivElement>((_props, ref) => {
|
||||
<LibraryHeader
|
||||
item={{
|
||||
children: headerItem,
|
||||
explicitStatus: detailQuery?.data?.explicitStatus ?? null,
|
||||
imageId: detailQuery?.data?.imageId,
|
||||
imageUrl: detailQuery?.data?.imageUrl,
|
||||
route: AppRoute.LIBRARY_ALBUMS,
|
||||
@@ -232,8 +233,8 @@ export const AlbumDetailHeader = forwardRef<HTMLDivElement>((_props, ref) => {
|
||||
{metadataItems.map((item, index) => (
|
||||
<Fragment key={item.id}>
|
||||
{index > 0 && (
|
||||
<Text fw={400} isMuted isNoSelect>
|
||||
•
|
||||
<Text isMuted isNoSelect>
|
||||
<Separator />
|
||||
</Text>
|
||||
)}
|
||||
<Text fw={400}>{item.value}</Text>
|
||||
|
||||
@@ -36,6 +36,18 @@ const AlbumListPaginatedTable = lazy(() =>
|
||||
})),
|
||||
);
|
||||
|
||||
const AlbumListInfiniteDetail = lazy(() =>
|
||||
import('/@/renderer/features/albums/components/album-list-infinite-detail').then((module) => ({
|
||||
default: module.AlbumListInfiniteDetail,
|
||||
})),
|
||||
);
|
||||
|
||||
const AlbumListPaginatedDetail = lazy(() =>
|
||||
import('/@/renderer/features/albums/components/album-list-paginated-detail').then((module) => ({
|
||||
default: module.AlbumListPaginatedDetail,
|
||||
})),
|
||||
);
|
||||
|
||||
const AlbumListFilters = () => {
|
||||
return (
|
||||
<ListWithSidebarContainer.SidebarPortal>
|
||||
@@ -62,13 +74,16 @@ export const AlbumListContent = () => {
|
||||
};
|
||||
|
||||
const AlbumListSuspenseContainer = () => {
|
||||
const { display, grid, itemsPerPage, pagination, table } = useListSettings(ItemListKey.ALBUM);
|
||||
const { detail, display, grid, itemsPerPage, pagination, table } = useListSettings(
|
||||
ItemListKey.ALBUM,
|
||||
);
|
||||
|
||||
const { customFilters } = useListContext();
|
||||
|
||||
return (
|
||||
<Suspense fallback={<Spinner container />}>
|
||||
<AlbumListView
|
||||
detail={detail}
|
||||
display={display}
|
||||
grid={grid}
|
||||
itemsPerPage={itemsPerPage}
|
||||
@@ -83,13 +98,17 @@ const AlbumListSuspenseContainer = () => {
|
||||
export type OverrideAlbumListQuery = Omit<Partial<AlbumListQuery>, 'limit' | 'startIndex'>;
|
||||
|
||||
export const AlbumListView = ({
|
||||
detail,
|
||||
display,
|
||||
grid,
|
||||
itemsPerPage,
|
||||
overrideQuery,
|
||||
pagination,
|
||||
table,
|
||||
}: ItemListSettings & { overrideQuery?: OverrideAlbumListQuery }) => {
|
||||
}: ItemListSettings & {
|
||||
detail?: ItemListSettings['detail'];
|
||||
overrideQuery?: OverrideAlbumListQuery;
|
||||
}) => {
|
||||
const server = useCurrentServer();
|
||||
const { pageKey } = useListContext();
|
||||
|
||||
@@ -179,6 +198,32 @@ export const AlbumListView = ({
|
||||
return null;
|
||||
}
|
||||
}
|
||||
case ListDisplayType.DETAIL: {
|
||||
switch (pagination) {
|
||||
case ListPaginationType.INFINITE: {
|
||||
return (
|
||||
<AlbumListInfiniteDetail
|
||||
enableHeader={detail?.enableHeader}
|
||||
itemsPerPage={itemsPerPage}
|
||||
query={mergedQuery}
|
||||
serverId={server.id}
|
||||
/>
|
||||
);
|
||||
}
|
||||
case ListPaginationType.PAGINATED: {
|
||||
return (
|
||||
<AlbumListPaginatedDetail
|
||||
enableHeader={detail?.enableHeader}
|
||||
itemsPerPage={itemsPerPage}
|
||||
query={mergedQuery}
|
||||
serverId={server.id}
|
||||
/>
|
||||
);
|
||||
}
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { ALBUM_TABLE_COLUMNS } from '/@/renderer/components/item-list/item-table-list/default-columns';
|
||||
import {
|
||||
ALBUM_TABLE_COLUMNS,
|
||||
SONG_TABLE_COLUMNS,
|
||||
} from '/@/renderer/components/item-list/item-table-list/default-columns';
|
||||
import { useListContext } from '/@/renderer/context/list-context';
|
||||
import { useAlbumListFilters } from '/@/renderer/features/albums/hooks/use-album-list-filters';
|
||||
import { ListConfigMenu } from '/@/renderer/features/shared/components/list-config-menu';
|
||||
@@ -92,8 +95,15 @@ export const AlbumListHeaderFilters = ({ toggleGenreTarget }: { toggleGenreTarge
|
||||
<ListRefreshButton listKey={pageKey as ItemListKey} />
|
||||
</Group>
|
||||
<Group gap="sm" wrap="nowrap">
|
||||
<ListDisplayTypeToggleButton listKey={ItemListKey.ALBUM} />
|
||||
<ListDisplayTypeToggleButton enableDetail listKey={ItemListKey.ALBUM} />
|
||||
<ListConfigMenu
|
||||
detailConfig={{
|
||||
optionsConfig: {
|
||||
autoFitColumns: { hidden: true },
|
||||
},
|
||||
tableColumnsData: SONG_TABLE_COLUMNS,
|
||||
tableKey: 'detail',
|
||||
}}
|
||||
listKey={ItemListKey.ALBUM}
|
||||
tableColumnsData={ALBUM_TABLE_COLUMNS}
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import { UseSuspenseQueryOptions } from '@tanstack/react-query';
|
||||
|
||||
import { api } from '/@/renderer/api';
|
||||
import { useItemListInfiniteLoader } from '/@/renderer/components/item-list/helpers/item-list-infinite-loader';
|
||||
import { useItemListColumnReorder } from '/@/renderer/components/item-list/helpers/use-item-list-column-reorder';
|
||||
import { useItemListColumnResize } from '/@/renderer/components/item-list/helpers/use-item-list-column-resize';
|
||||
import { ItemDetailList } from '/@/renderer/components/item-list/item-detail-list/item-detail-list';
|
||||
import { ItemListComponentProps } from '/@/renderer/components/item-list/types';
|
||||
import { albumQueries } from '/@/renderer/features/albums/api/album-api';
|
||||
import {
|
||||
AlbumListQuery,
|
||||
AlbumListSort,
|
||||
LibraryItem,
|
||||
SortOrder,
|
||||
} from '/@/shared/types/domain-types';
|
||||
import { ItemListKey } from '/@/shared/types/types';
|
||||
|
||||
interface AlbumListInfiniteDetailProps extends ItemListComponentProps<AlbumListQuery> {
|
||||
enableHeader?: boolean;
|
||||
}
|
||||
|
||||
export const AlbumListInfiniteDetail = ({
|
||||
enableHeader = true,
|
||||
itemsPerPage = 100,
|
||||
query = {
|
||||
sortBy: AlbumListSort.NAME,
|
||||
sortOrder: SortOrder.ASC,
|
||||
},
|
||||
serverId,
|
||||
}: AlbumListInfiniteDetailProps) => {
|
||||
const listCountQuery = albumQueries.listCount({
|
||||
query: { ...query },
|
||||
serverId: serverId,
|
||||
}) as UseSuspenseQueryOptions<number, Error, number, readonly unknown[]>;
|
||||
|
||||
const listQueryFn = api.controller.getAlbumList;
|
||||
|
||||
const { handleColumnReordered } = useItemListColumnReorder({
|
||||
itemListKey: ItemListKey.ALBUM,
|
||||
tableKey: 'detail',
|
||||
});
|
||||
|
||||
const { handleColumnResized } = useItemListColumnResize({
|
||||
itemListKey: ItemListKey.ALBUM,
|
||||
tableKey: 'detail',
|
||||
});
|
||||
|
||||
const { getItem, itemCount, loadedItems, onRangeChanged } = useItemListInfiniteLoader({
|
||||
eventKey: ItemListKey.ALBUM,
|
||||
itemsPerPage,
|
||||
itemType: LibraryItem.ALBUM,
|
||||
listCountQuery,
|
||||
listQueryFn,
|
||||
query,
|
||||
serverId,
|
||||
});
|
||||
|
||||
return (
|
||||
<ItemDetailList
|
||||
data={loadedItems}
|
||||
enableHeader={enableHeader}
|
||||
getItem={getItem}
|
||||
itemCount={itemCount}
|
||||
onColumnReordered={handleColumnReordered}
|
||||
onColumnResized={handleColumnResized}
|
||||
onRangeChanged={onRangeChanged}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,80 @@
|
||||
import { UseSuspenseQueryOptions } from '@tanstack/react-query';
|
||||
|
||||
import { api } from '/@/renderer/api';
|
||||
import { useItemListPaginatedLoader } from '/@/renderer/components/item-list/helpers/item-list-paginated-loader';
|
||||
import { useItemListColumnReorder } from '/@/renderer/components/item-list/helpers/use-item-list-column-reorder';
|
||||
import { useItemListColumnResize } from '/@/renderer/components/item-list/helpers/use-item-list-column-resize';
|
||||
import { ItemDetailList } from '/@/renderer/components/item-list/item-detail-list/item-detail-list';
|
||||
import { ItemListWithPagination } from '/@/renderer/components/item-list/item-list-pagination/item-list-pagination';
|
||||
import { useItemListPagination } from '/@/renderer/components/item-list/item-list-pagination/use-item-list-pagination';
|
||||
import { ItemListComponentProps } from '/@/renderer/components/item-list/types';
|
||||
import { albumQueries } from '/@/renderer/features/albums/api/album-api';
|
||||
import {
|
||||
AlbumListQuery,
|
||||
AlbumListSort,
|
||||
LibraryItem,
|
||||
SortOrder,
|
||||
} from '/@/shared/types/domain-types';
|
||||
import { ItemListKey } from '/@/shared/types/types';
|
||||
|
||||
interface AlbumListPaginatedDetailProps extends ItemListComponentProps<AlbumListQuery> {
|
||||
enableHeader?: boolean;
|
||||
}
|
||||
|
||||
export const AlbumListPaginatedDetail = ({
|
||||
enableHeader = true,
|
||||
itemsPerPage = 100,
|
||||
query = {
|
||||
sortBy: AlbumListSort.NAME,
|
||||
sortOrder: SortOrder.ASC,
|
||||
},
|
||||
serverId,
|
||||
}: AlbumListPaginatedDetailProps) => {
|
||||
const listCountQuery = albumQueries.listCount({
|
||||
query: { ...query },
|
||||
serverId: serverId,
|
||||
}) as UseSuspenseQueryOptions<number, Error, number, readonly unknown[]>;
|
||||
|
||||
const listQueryFn = api.controller.getAlbumList;
|
||||
|
||||
const { handleColumnReordered } = useItemListColumnReorder({
|
||||
itemListKey: ItemListKey.ALBUM,
|
||||
tableKey: 'detail',
|
||||
});
|
||||
|
||||
const { handleColumnResized } = useItemListColumnResize({
|
||||
itemListKey: ItemListKey.ALBUM,
|
||||
tableKey: 'detail',
|
||||
});
|
||||
|
||||
const { currentPage, onChange } = useItemListPagination();
|
||||
|
||||
const { data, pageCount, totalItemCount } = useItemListPaginatedLoader({
|
||||
currentPage,
|
||||
eventKey: ItemListKey.ALBUM,
|
||||
itemsPerPage,
|
||||
itemType: LibraryItem.ALBUM,
|
||||
listCountQuery,
|
||||
listQueryFn,
|
||||
query,
|
||||
serverId,
|
||||
});
|
||||
|
||||
return (
|
||||
<ItemListWithPagination
|
||||
currentPage={currentPage}
|
||||
itemsPerPage={itemsPerPage}
|
||||
onChange={onChange}
|
||||
pageCount={pageCount}
|
||||
totalItemCount={totalItemCount}
|
||||
>
|
||||
<ItemDetailList
|
||||
currentPage={currentPage}
|
||||
enableHeader={enableHeader}
|
||||
items={data || []}
|
||||
onColumnReordered={handleColumnReordered}
|
||||
onColumnResized={handleColumnResized}
|
||||
/>
|
||||
</ItemListWithPagination>
|
||||
);
|
||||
};
|
||||
@@ -52,9 +52,12 @@ export const JellyfinAlbumFilters = ({
|
||||
setMinYear,
|
||||
} = useAlbumListFilters();
|
||||
|
||||
// TODO - eventually replace with /items/filters endpoint to fetch genres and tags specific to the selected library
|
||||
const genreListQuery = useQuery(
|
||||
genresQueries.list({
|
||||
options: {
|
||||
gcTime: 1000 * 60 * 2,
|
||||
staleTime: 1000 * 60 * 1,
|
||||
},
|
||||
query: {
|
||||
sortBy: GenreListSort.NAME,
|
||||
sortOrder: SortOrder.ASC,
|
||||
|
||||
@@ -1,21 +1,28 @@
|
||||
import { Fragment } from 'react';
|
||||
import { Fragment, memo } from 'react';
|
||||
import { generatePath, Link } from 'react-router';
|
||||
|
||||
import { AppRoute } from '/@/renderer/router/routes';
|
||||
import { Text, TextProps } from '/@/shared/components/text/text';
|
||||
import { AlbumArtist, RelatedAlbumArtist, RelatedArtist } from '/@/shared/types/domain-types';
|
||||
|
||||
export const JOINED_ARTISTS_MUTED_PROPS = {
|
||||
linkProps: { fw: 400, isMuted: true },
|
||||
rootTextProps: { fw: 400, isMuted: true, size: 'sm' as const },
|
||||
} as const;
|
||||
|
||||
interface JoinedArtistsProps {
|
||||
artistName: string;
|
||||
artists: AlbumArtist[] | RelatedAlbumArtist[] | RelatedArtist[];
|
||||
linkProps?: Partial<Omit<TextProps, 'children' | 'component' | 'to'>>;
|
||||
readOnly?: boolean;
|
||||
rootTextProps?: Partial<Omit<TextProps, 'children' | 'component'>>;
|
||||
}
|
||||
|
||||
export const JoinedArtists = ({
|
||||
const JoinedArtistsComponent = ({
|
||||
artistName,
|
||||
artists,
|
||||
linkProps,
|
||||
readOnly = false,
|
||||
rootTextProps,
|
||||
}: JoinedArtistsProps) => {
|
||||
const parts: (
|
||||
@@ -111,7 +118,7 @@ export const JoinedArtists = ({
|
||||
{artists.map((artist, index) => (
|
||||
<Fragment key={artist.id || `artist-${index}`}>
|
||||
{index > 0 && ', '}
|
||||
{artist.id ? (
|
||||
{artist.id && !readOnly ? (
|
||||
<Text
|
||||
component={Link}
|
||||
fw={500}
|
||||
@@ -124,7 +131,7 @@ export const JoinedArtists = ({
|
||||
{artist.name}
|
||||
</Text>
|
||||
) : (
|
||||
<Text fw={500} {...linkProps}>
|
||||
<Text component="span" fw={500} {...linkProps}>
|
||||
{artist.name}
|
||||
</Text>
|
||||
)}
|
||||
@@ -152,7 +159,7 @@ export const JoinedArtists = ({
|
||||
|
||||
const { artist, text } = part;
|
||||
|
||||
if (artist.id) {
|
||||
if (artist.id && !readOnly) {
|
||||
return (
|
||||
<Text
|
||||
component={Link}
|
||||
@@ -169,7 +176,7 @@ export const JoinedArtists = ({
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Text fw={500} key={`${artist.name}-${index}`} {...linkProps}>
|
||||
<Text component="span" fw={500} key={`${artist.name}-${index}`} {...linkProps}>
|
||||
{text}
|
||||
</Text>
|
||||
);
|
||||
@@ -180,7 +187,7 @@ export const JoinedArtists = ({
|
||||
{unmatchedArtists.map((artist, index) => (
|
||||
<Fragment key={artist.id}>
|
||||
{index > 0 && ', '}
|
||||
{artist.id ? (
|
||||
{artist.id && !readOnly ? (
|
||||
<Text
|
||||
component={Link}
|
||||
fw={500}
|
||||
@@ -192,6 +199,10 @@ export const JoinedArtists = ({
|
||||
>
|
||||
{artist.name}
|
||||
</Text>
|
||||
) : artist.id ? (
|
||||
<Text component="span" fw={500} {...linkProps}>
|
||||
{artist.name}
|
||||
</Text>
|
||||
) : (
|
||||
<Text component="span" isMuted>
|
||||
{artist.name}
|
||||
@@ -205,6 +216,8 @@ export const JoinedArtists = ({
|
||||
);
|
||||
};
|
||||
|
||||
export const JoinedArtists = memo(JoinedArtistsComponent);
|
||||
|
||||
function escapeRegex(str: string): string {
|
||||
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useTranslation } from 'react-i18next';
|
||||
import { getItemImageUrl } from '/@/renderer/components/item-image/item-image';
|
||||
import { useAlbumListFilters } from '/@/renderer/features/albums/hooks/use-album-list-filters';
|
||||
import { artistsQueries } from '/@/renderer/features/artists/api/artists-api';
|
||||
import { useGenreList } from '/@/renderer/features/genres/api/genres-api';
|
||||
import { genresQueries } from '/@/renderer/features/genres/api/genres-api';
|
||||
import {
|
||||
ArtistMultiSelectRow,
|
||||
GenreMultiSelectRow,
|
||||
@@ -22,7 +22,12 @@ import { Stack } from '/@/shared/components/stack/stack';
|
||||
import { Switch } from '/@/shared/components/switch/switch';
|
||||
import { Text } from '/@/shared/components/text/text';
|
||||
import { useDebouncedCallback } from '/@/shared/hooks/use-debounced-callback';
|
||||
import { AlbumArtistListSort, LibraryItem, SortOrder } from '/@/shared/types/domain-types';
|
||||
import {
|
||||
AlbumArtistListSort,
|
||||
GenreListSort,
|
||||
LibraryItem,
|
||||
SortOrder,
|
||||
} from '/@/shared/types/domain-types';
|
||||
|
||||
interface NavidromeAlbumFiltersProps {
|
||||
disableArtistFilter?: boolean;
|
||||
@@ -54,7 +59,20 @@ export const NavidromeAlbumFilters = ({
|
||||
setRecentlyPlayed,
|
||||
} = useAlbumListFilters();
|
||||
|
||||
const genreListQuery = useGenreList();
|
||||
const genreListQuery = useQuery(
|
||||
genresQueries.list({
|
||||
options: {
|
||||
gcTime: 1000 * 60 * 2,
|
||||
staleTime: 1000 * 60 * 1,
|
||||
},
|
||||
query: {
|
||||
sortBy: GenreListSort.NAME,
|
||||
sortOrder: SortOrder.ASC,
|
||||
startIndex: 0,
|
||||
},
|
||||
serverId,
|
||||
}),
|
||||
);
|
||||
|
||||
const genreList = useMemo(() => {
|
||||
if (!genreListQuery?.data) return [];
|
||||
@@ -333,6 +351,7 @@ export const NavidromeAlbumFilters = ({
|
||||
<VirtualMultiSelect
|
||||
displayCountType="album"
|
||||
height={220}
|
||||
isLoading={genreListQuery.isFetching}
|
||||
label={genreFilterLabel}
|
||||
onChange={handleGenreChange}
|
||||
options={genreList}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { useSuspenseQuery } from '@tanstack/react-query';
|
||||
import { useQuery, useSuspenseQuery } from '@tanstack/react-query';
|
||||
import { ChangeEvent, useCallback, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { getItemImageUrl } from '/@/renderer/components/item-image/item-image';
|
||||
import { useAlbumListFilters } from '/@/renderer/features/albums/hooks/use-album-list-filters';
|
||||
import { artistsQueries } from '/@/renderer/features/artists/api/artists-api';
|
||||
import { useGenreList } from '/@/renderer/features/genres/api/genres-api';
|
||||
import { genresQueries } from '/@/renderer/features/genres/api/genres-api';
|
||||
import {
|
||||
ArtistMultiSelectRow,
|
||||
GenreMultiSelectRow,
|
||||
@@ -21,7 +21,12 @@ import { Stack } from '/@/shared/components/stack/stack';
|
||||
import { Switch } from '/@/shared/components/switch/switch';
|
||||
import { Text } from '/@/shared/components/text/text';
|
||||
import { useDebouncedCallback } from '/@/shared/hooks/use-debounced-callback';
|
||||
import { AlbumArtistListSort, LibraryItem, SortOrder } from '/@/shared/types/domain-types';
|
||||
import {
|
||||
AlbumArtistListSort,
|
||||
GenreListSort,
|
||||
LibraryItem,
|
||||
SortOrder,
|
||||
} from '/@/shared/types/domain-types';
|
||||
|
||||
interface SubsonicAlbumFiltersProps {
|
||||
disableArtistFilter?: boolean;
|
||||
@@ -90,7 +95,20 @@ export const SubsonicAlbumFilters = ({
|
||||
[isArtistDisabled, setAlbumArtist],
|
||||
);
|
||||
|
||||
const genreListQuery = useGenreList();
|
||||
const genreListQuery = useQuery(
|
||||
genresQueries.list({
|
||||
options: {
|
||||
gcTime: 1000 * 60 * 2,
|
||||
staleTime: 1000 * 60 * 1,
|
||||
},
|
||||
query: {
|
||||
sortBy: GenreListSort.NAME,
|
||||
sortOrder: SortOrder.ASC,
|
||||
startIndex: 0,
|
||||
},
|
||||
serverId,
|
||||
}),
|
||||
);
|
||||
|
||||
const genreList = useMemo(() => {
|
||||
if (!genreListQuery?.data) return [];
|
||||
@@ -252,6 +270,7 @@ export const SubsonicAlbumFilters = ({
|
||||
disabled={isArtistDisabled}
|
||||
displayCountType="album"
|
||||
height={300}
|
||||
isLoading={albumArtistListQuery.isFetching}
|
||||
label={artistFilterLabel}
|
||||
onChange={handleAlbumArtistFilter}
|
||||
options={selectableAlbumArtists}
|
||||
@@ -268,6 +287,7 @@ export const SubsonicAlbumFilters = ({
|
||||
disabled={isGenreDisabled}
|
||||
displayCountType="album"
|
||||
height={220}
|
||||
isLoading={genreListQuery.isFetching}
|
||||
label={genreFilterLabel}
|
||||
onChange={handleGenresFilter}
|
||||
options={genreList}
|
||||
|
||||
@@ -127,6 +127,7 @@ const DummyAlbumDetailRoute = () => {
|
||||
<LibraryHeader
|
||||
imageUrl={imageUrl}
|
||||
item={{
|
||||
explicitStatus: detailQuery?.data?.explicitStatus ?? null,
|
||||
imageId: detailQuery?.data?.imageId,
|
||||
imageUrl: detailQuery?.data?.imageUrl,
|
||||
route: AppRoute.LIBRARY_SONGS,
|
||||
|
||||
@@ -10,6 +10,8 @@ import {
|
||||
AlbumArtistListQuery,
|
||||
ArtistListQuery,
|
||||
ListCountQuery,
|
||||
SongListSort,
|
||||
SortOrder,
|
||||
TopSongListQuery,
|
||||
} from '/@/shared/types/domain-types';
|
||||
|
||||
@@ -120,6 +122,24 @@ export const artistsQueries = {
|
||||
...args.options,
|
||||
});
|
||||
},
|
||||
favoriteSongs: (args: QueryHookArgs<{ artistId: string }>) => {
|
||||
return queryOptions({
|
||||
queryFn: ({ signal }) => {
|
||||
return api.controller.getSongList({
|
||||
apiClientProps: { serverId: args.serverId, signal },
|
||||
query: {
|
||||
artistIds: [args.query.artistId],
|
||||
favorite: true,
|
||||
limit: -1,
|
||||
sortBy: SongListSort.RELEASE_DATE,
|
||||
sortOrder: SortOrder.ASC,
|
||||
startIndex: 0,
|
||||
},
|
||||
});
|
||||
},
|
||||
queryKey: queryKeys.albumArtists.favoriteSongs(args.serverId, args.query.artistId),
|
||||
});
|
||||
},
|
||||
topSongs: (args: QueryHookArgs<TopSongListQuery>) => {
|
||||
return queryOptions({
|
||||
queryFn: ({ signal }) => {
|
||||
|
||||
@@ -66,6 +66,7 @@ import { DropdownMenu } from '/@/shared/components/dropdown-menu/dropdown-menu';
|
||||
import { Grid } from '/@/shared/components/grid/grid';
|
||||
import { Group } from '/@/shared/components/group/group';
|
||||
import { Icon } from '/@/shared/components/icon/icon';
|
||||
import { SegmentedControl } from '/@/shared/components/segmented-control/segmented-control';
|
||||
import { Spinner } from '/@/shared/components/spinner/spinner';
|
||||
import { Spoiler } from '/@/shared/components/spoiler/spoiler';
|
||||
import { Stack } from '/@/shared/components/stack/stack';
|
||||
@@ -74,6 +75,7 @@ import { TextTitle } from '/@/shared/components/text-title/text-title';
|
||||
import { Text } from '/@/shared/components/text/text';
|
||||
import { useDebouncedValue } from '/@/shared/hooks/use-debounced-value';
|
||||
import { useHotkeys } from '/@/shared/hooks/use-hotkeys';
|
||||
import { useLocalStorage } from '/@/shared/hooks/use-local-storage';
|
||||
import {
|
||||
Album,
|
||||
AlbumArtist,
|
||||
@@ -88,6 +90,8 @@ import {
|
||||
} from '/@/shared/types/domain-types';
|
||||
import { ItemListKey, ListDisplayType, Play } from '/@/shared/types/types';
|
||||
|
||||
const collator = new Intl.Collator();
|
||||
|
||||
interface AlbumArtistActionButtonsProps {
|
||||
artistDiscographyLink: string;
|
||||
artistSongsLink: string;
|
||||
@@ -234,6 +238,10 @@ const AlbumArtistMetadataTopSongsContent = ({
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [debouncedSearchTerm] = useDebouncedValue(searchTerm, 300);
|
||||
const [showAll, setShowAll] = useState(false);
|
||||
const [topSongsQueryType, setTopSongsQueryType] = useLocalStorage<'community' | 'personal'>({
|
||||
defaultValue: 'community',
|
||||
key: 'album-artist-top-songs-query-type',
|
||||
});
|
||||
const tableConfig = useSettingsStore((state) => state.lists[ItemListKey.SONG]?.table);
|
||||
const currentSong = usePlayerSong();
|
||||
const player = usePlayer();
|
||||
@@ -247,6 +255,7 @@ const AlbumArtistMetadataTopSongsContent = ({
|
||||
query: {
|
||||
artist: detailQuery.data?.name || '',
|
||||
artistId: routeId,
|
||||
type: topSongsQueryType,
|
||||
},
|
||||
serverId: serverId,
|
||||
}),
|
||||
@@ -293,41 +302,30 @@ const AlbumArtistMetadataTopSongsContent = ({
|
||||
};
|
||||
}, [player]);
|
||||
|
||||
if (topSongsQuery.isLoading || !topSongsQuery.data) {
|
||||
return null;
|
||||
}
|
||||
const handlePlay = useCallback(
|
||||
(playType: Play) => {
|
||||
if (songs.length === 0) return;
|
||||
player.addToQueueByData(songs, playType);
|
||||
},
|
||||
[songs, player],
|
||||
);
|
||||
|
||||
if (!topSongsQuery?.data?.items?.length) return null;
|
||||
const handlePlayNext = usePlayButtonClick({
|
||||
onClick: () => handlePlay(Play.NEXT),
|
||||
onLongPress: () => handlePlay(LONG_PRESS_PLAY_BEHAVIOR[Play.NEXT]),
|
||||
});
|
||||
const handlePlayNow = usePlayButtonClick({
|
||||
onClick: () => handlePlay(Play.NOW),
|
||||
onLongPress: () => handlePlay(LONG_PRESS_PLAY_BEHAVIOR[Play.NOW]),
|
||||
});
|
||||
const handlePlayLast = usePlayButtonClick({
|
||||
onClick: () => handlePlay(Play.LAST),
|
||||
onLongPress: () => handlePlay(LONG_PRESS_PLAY_BEHAVIOR[Play.LAST]),
|
||||
});
|
||||
|
||||
if (!tableConfig || columns.length === 0) {
|
||||
return (
|
||||
<section>
|
||||
<div className={styles.albumSectionTitle}>
|
||||
<TextTitle fw={700} order={3}>
|
||||
{t('page.albumArtistDetail.topSongs', {
|
||||
postProcess: 'sentenceCase',
|
||||
})}
|
||||
</TextTitle>
|
||||
<div className={styles.albumSectionDividerContainer}>
|
||||
<div className={styles.albumSectionDivider} />
|
||||
<Button
|
||||
component={Link}
|
||||
size="compact-md"
|
||||
to={generatePath(AppRoute.LIBRARY_ALBUM_ARTISTS_DETAIL_TOP_SONGS, {
|
||||
albumArtistId: routeId,
|
||||
})}
|
||||
uppercase
|
||||
variant="subtle"
|
||||
>
|
||||
{t('page.albumArtistDetail.viewAll', {
|
||||
postProcess: 'sentenceCase',
|
||||
})}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
const isLoading = topSongsQuery.isLoading || !topSongsQuery.data;
|
||||
|
||||
if (!isLoading && !tableConfig) return null;
|
||||
|
||||
const currentSongId = currentSong?.id;
|
||||
|
||||
@@ -335,11 +333,14 @@ const AlbumArtistMetadataTopSongsContent = ({
|
||||
<section>
|
||||
<Stack gap="md">
|
||||
<div className={styles.albumSectionTitle}>
|
||||
<TextTitle fw={700} order={3}>
|
||||
{t('page.albumArtistDetail.topSongs', {
|
||||
postProcess: 'sentenceCase',
|
||||
})}
|
||||
</TextTitle>
|
||||
<Group>
|
||||
<TextTitle fw={700} order={3}>
|
||||
{t('page.albumArtistDetail.topSongs', {
|
||||
postProcess: 'sentenceCase',
|
||||
})}
|
||||
</TextTitle>
|
||||
{!isLoading && <Badge>{songs.length}</Badge>}
|
||||
</Group>
|
||||
<div className={styles.albumSectionDividerContainer}>
|
||||
<div className={styles.albumSectionDivider} />
|
||||
<Button
|
||||
@@ -355,74 +356,140 @@ const AlbumArtistMetadataTopSongsContent = ({
|
||||
postProcess: 'sentenceCase',
|
||||
})}
|
||||
</Button>
|
||||
{songs.length > 0 && (
|
||||
<ActionIconGroup>
|
||||
<PlayTooltip type={Play.NOW}>
|
||||
<ActionIcon
|
||||
icon="mediaPlay"
|
||||
iconProps={{ size: 'md' }}
|
||||
size="xs"
|
||||
variant="subtle"
|
||||
{...handlePlayNow.handlers}
|
||||
{...handlePlayNow.props}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</PlayTooltip>
|
||||
<PlayTooltip type={Play.NEXT}>
|
||||
<ActionIcon
|
||||
icon="mediaPlayNext"
|
||||
iconProps={{ size: 'md' }}
|
||||
size="xs"
|
||||
variant="subtle"
|
||||
{...handlePlayNext.handlers}
|
||||
{...handlePlayNext.props}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</PlayTooltip>
|
||||
<PlayTooltip type={Play.LAST}>
|
||||
<ActionIcon
|
||||
icon="mediaPlayLast"
|
||||
iconProps={{ size: 'md' }}
|
||||
size="xs"
|
||||
variant="subtle"
|
||||
{...handlePlayLast.handlers}
|
||||
{...handlePlayLast.props}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</PlayTooltip>
|
||||
</ActionIconGroup>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<Group gap="sm" w="100%">
|
||||
<TextInput
|
||||
flex={1}
|
||||
leftSection={<Icon icon="search" />}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
placeholder={t('common.search', { postProcess: 'sentenceCase' })}
|
||||
radius="xl"
|
||||
rightSection={
|
||||
searchTerm ? (
|
||||
<ActionIcon
|
||||
icon="x"
|
||||
onClick={() => setSearchTerm('')}
|
||||
size="sm"
|
||||
variant="transparent"
|
||||
/>
|
||||
) : null
|
||||
}
|
||||
styles={{
|
||||
input: {
|
||||
background: 'transparent',
|
||||
border: '1px solid rgba(255, 255, 255, 0.05)',
|
||||
},
|
||||
}}
|
||||
value={searchTerm}
|
||||
/>
|
||||
<ListConfigMenu
|
||||
displayTypes={[{ hidden: true, value: ListDisplayType.GRID }]}
|
||||
listKey={ItemListKey.SONG}
|
||||
optionsConfig={{
|
||||
table: {
|
||||
itemsPerPage: { hidden: true },
|
||||
pagination: { hidden: true },
|
||||
},
|
||||
}}
|
||||
tableColumnsData={SONG_TABLE_COLUMNS}
|
||||
/>
|
||||
</Group>
|
||||
<ItemTableList
|
||||
activeRowId={currentSongId}
|
||||
autoFitColumns={tableConfig.autoFitColumns}
|
||||
CellComponent={ItemTableListColumn}
|
||||
columns={columns}
|
||||
data={filteredSongs}
|
||||
enableAlternateRowColors={tableConfig.enableAlternateRowColors}
|
||||
enableDrag
|
||||
enableDragScroll={false}
|
||||
enableExpansion={false}
|
||||
enableHeader={tableConfig.enableHeader}
|
||||
enableHorizontalBorders={tableConfig.enableHorizontalBorders}
|
||||
enableRowHoverHighlight={tableConfig.enableRowHoverHighlight}
|
||||
enableSelection
|
||||
enableSelectionDialog={false}
|
||||
enableVerticalBorders={tableConfig.enableVerticalBorders}
|
||||
itemType={LibraryItem.SONG}
|
||||
onColumnReordered={handleColumnReordered}
|
||||
onColumnResized={handleColumnResized}
|
||||
overrideControls={overrideControls}
|
||||
size={tableConfig.size}
|
||||
/>
|
||||
{!searchTerm.trim() && songs.length > 5 && !showAll && (
|
||||
<Group justify="center" w="100%">
|
||||
<Button onClick={() => setShowAll(true)} variant="subtle">
|
||||
{t('action.viewMore', { postProcess: 'sentenceCase' })}
|
||||
</Button>
|
||||
{isLoading ? (
|
||||
<Group justify="center" py="md">
|
||||
<Spinner container />
|
||||
</Group>
|
||||
)}
|
||||
) : tableConfig ? (
|
||||
<>
|
||||
<Group gap="sm" w="100%">
|
||||
<TextInput
|
||||
flex={1}
|
||||
leftSection={<Icon icon="search" />}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
placeholder={t('common.search', { postProcess: 'sentenceCase' })}
|
||||
radius="xl"
|
||||
rightSection={
|
||||
searchTerm ? (
|
||||
<ActionIcon
|
||||
icon="x"
|
||||
onClick={() => setSearchTerm('')}
|
||||
size="sm"
|
||||
variant="transparent"
|
||||
/>
|
||||
) : null
|
||||
}
|
||||
styles={{
|
||||
input: {
|
||||
background: 'transparent',
|
||||
border: '1px solid rgba(255, 255, 255, 0.05)',
|
||||
},
|
||||
}}
|
||||
value={searchTerm}
|
||||
/>
|
||||
<SegmentedControl
|
||||
data={[
|
||||
{
|
||||
label: t('page.albumArtistDetail.topSongsCommunity', {
|
||||
postProcess: 'sentenceCase',
|
||||
}),
|
||||
value: 'community',
|
||||
},
|
||||
{
|
||||
label: t('page.albumArtistDetail.topSongsPersonal', {
|
||||
postProcess: 'sentenceCase',
|
||||
}),
|
||||
value: 'personal',
|
||||
},
|
||||
]}
|
||||
onChange={(value) =>
|
||||
setTopSongsQueryType(value as 'community' | 'personal')
|
||||
}
|
||||
size="xs"
|
||||
value={topSongsQueryType}
|
||||
/>
|
||||
<ListConfigMenu
|
||||
displayTypes={[{ hidden: true, value: ListDisplayType.GRID }]}
|
||||
listKey={ItemListKey.SONG}
|
||||
optionsConfig={{
|
||||
table: {
|
||||
itemsPerPage: { hidden: true },
|
||||
pagination: { hidden: true },
|
||||
},
|
||||
}}
|
||||
tableColumnsData={SONG_TABLE_COLUMNS}
|
||||
/>
|
||||
</Group>
|
||||
<ItemTableList
|
||||
activeRowId={currentSongId}
|
||||
autoFitColumns={tableConfig.autoFitColumns}
|
||||
CellComponent={ItemTableListColumn}
|
||||
columns={columns}
|
||||
data={filteredSongs}
|
||||
enableAlternateRowColors={tableConfig.enableAlternateRowColors}
|
||||
enableDrag
|
||||
enableDragScroll={false}
|
||||
enableExpansion={false}
|
||||
enableHeader={tableConfig.enableHeader}
|
||||
enableHorizontalBorders={tableConfig.enableHorizontalBorders}
|
||||
enableRowHoverHighlight={tableConfig.enableRowHoverHighlight}
|
||||
enableSelection
|
||||
enableSelectionDialog={false}
|
||||
enableVerticalBorders={tableConfig.enableVerticalBorders}
|
||||
itemType={LibraryItem.SONG}
|
||||
onColumnReordered={handleColumnReordered}
|
||||
onColumnResized={handleColumnResized}
|
||||
overrideControls={overrideControls}
|
||||
size={tableConfig.size}
|
||||
/>
|
||||
{!searchTerm.trim() && songs.length > 5 && !showAll && (
|
||||
<Group justify="center" w="100%">
|
||||
<Button onClick={() => setShowAll(true)} variant="subtle">
|
||||
{t('action.viewMore', { postProcess: 'sentenceCase' })}
|
||||
</Button>
|
||||
</Group>
|
||||
)}
|
||||
</>
|
||||
) : null}
|
||||
</Stack>
|
||||
</section>
|
||||
);
|
||||
@@ -448,6 +515,244 @@ const AlbumArtistMetadataTopSongs = ({
|
||||
);
|
||||
};
|
||||
|
||||
interface AlbumArtistMetadataFavoriteSongsProps {
|
||||
routeId: string;
|
||||
}
|
||||
|
||||
const AlbumArtistMetadataFavoriteSongs = ({ routeId }: AlbumArtistMetadataFavoriteSongsProps) => {
|
||||
const { t } = useTranslation();
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [debouncedSearchTerm] = useDebouncedValue(searchTerm, 300);
|
||||
const [showAll, setShowAll] = useState(false);
|
||||
const tableConfig = useSettingsStore((state) => state.lists[ItemListKey.SONG]?.table);
|
||||
const currentSong = usePlayerSong();
|
||||
const player = usePlayer();
|
||||
const serverId = useCurrentServerId();
|
||||
|
||||
const favoriteSongsQuery = useQuery({
|
||||
...artistsQueries.favoriteSongs({
|
||||
query: {
|
||||
artistId: routeId,
|
||||
},
|
||||
serverId: serverId,
|
||||
}),
|
||||
});
|
||||
|
||||
const songs = useMemo(
|
||||
() => favoriteSongsQuery.data?.items || [],
|
||||
[favoriteSongsQuery.data?.items],
|
||||
);
|
||||
|
||||
const columns = useMemo(() => {
|
||||
return tableConfig?.columns || [];
|
||||
}, [tableConfig?.columns]);
|
||||
|
||||
const filteredSongs = useMemo(() => {
|
||||
const filtered = searchLibraryItems(songs, debouncedSearchTerm, LibraryItem.SONG);
|
||||
// When searching, show all results. Otherwise, limit to 5 if not showing all
|
||||
if (debouncedSearchTerm?.trim() || showAll) {
|
||||
return filtered;
|
||||
}
|
||||
return filtered.slice(0, 5);
|
||||
}, [songs, debouncedSearchTerm, showAll]);
|
||||
|
||||
const { handleColumnReordered } = useItemListColumnReorder({
|
||||
itemListKey: ItemListKey.SONG,
|
||||
});
|
||||
|
||||
const { handleColumnResized } = useItemListColumnResize({
|
||||
itemListKey: ItemListKey.SONG,
|
||||
});
|
||||
|
||||
const overrideControls: Partial<ItemControls> = useMemo(() => {
|
||||
return {
|
||||
onDoubleClick: ({ index, internalState, item, meta }) => {
|
||||
if (!item) {
|
||||
return;
|
||||
}
|
||||
|
||||
const playType = (meta?.playType as Play) || Play.NOW;
|
||||
const items = internalState?.getData() as Song[];
|
||||
|
||||
if (index !== undefined) {
|
||||
player.addToQueueByData(items, playType, item.id);
|
||||
}
|
||||
},
|
||||
};
|
||||
}, [player]);
|
||||
|
||||
const handlePlay = useCallback(
|
||||
(playType: Play) => {
|
||||
if (songs.length === 0) return;
|
||||
player.addToQueueByData(songs, playType);
|
||||
},
|
||||
[songs, player],
|
||||
);
|
||||
|
||||
const handlePlayNext = usePlayButtonClick({
|
||||
onClick: () => handlePlay(Play.NEXT),
|
||||
onLongPress: () => handlePlay(LONG_PRESS_PLAY_BEHAVIOR[Play.NEXT]),
|
||||
});
|
||||
const handlePlayNow = usePlayButtonClick({
|
||||
onClick: () => handlePlay(Play.NOW),
|
||||
onLongPress: () => handlePlay(LONG_PRESS_PLAY_BEHAVIOR[Play.NOW]),
|
||||
});
|
||||
const handlePlayLast = usePlayButtonClick({
|
||||
onClick: () => handlePlay(Play.LAST),
|
||||
onLongPress: () => handlePlay(LONG_PRESS_PLAY_BEHAVIOR[Play.LAST]),
|
||||
});
|
||||
|
||||
const isLoading = favoriteSongsQuery.isLoading || !favoriteSongsQuery.data;
|
||||
|
||||
if (!isLoading && !tableConfig) return null;
|
||||
|
||||
const currentSongId = currentSong?.id;
|
||||
|
||||
return (
|
||||
<section>
|
||||
<Stack gap="md">
|
||||
<div className={styles.albumSectionTitle}>
|
||||
<Group>
|
||||
<TextTitle fw={700} order={3}>
|
||||
{t('page.albumArtistDetail.favoriteSongs', {
|
||||
postProcess: 'sentenceCase',
|
||||
})}
|
||||
</TextTitle>
|
||||
{!isLoading && <Badge>{songs.length}</Badge>}
|
||||
</Group>
|
||||
<div className={styles.albumSectionDividerContainer}>
|
||||
<div className={styles.albumSectionDivider} />
|
||||
<Button
|
||||
component={Link}
|
||||
size="compact-md"
|
||||
to={generatePath(AppRoute.LIBRARY_ALBUM_ARTISTS_DETAIL_FAVORITE_SONGS, {
|
||||
albumArtistId: routeId,
|
||||
})}
|
||||
uppercase
|
||||
variant="subtle"
|
||||
>
|
||||
{t('page.albumArtistDetail.viewAll', {
|
||||
postProcess: 'sentenceCase',
|
||||
})}
|
||||
</Button>
|
||||
{songs.length > 0 && (
|
||||
<ActionIconGroup>
|
||||
<PlayTooltip type={Play.NOW}>
|
||||
<ActionIcon
|
||||
icon="mediaPlay"
|
||||
iconProps={{ size: 'md' }}
|
||||
size="xs"
|
||||
variant="subtle"
|
||||
{...handlePlayNow.handlers}
|
||||
{...handlePlayNow.props}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</PlayTooltip>
|
||||
<PlayTooltip type={Play.NEXT}>
|
||||
<ActionIcon
|
||||
icon="mediaPlayNext"
|
||||
iconProps={{ size: 'md' }}
|
||||
size="xs"
|
||||
variant="subtle"
|
||||
{...handlePlayNext.handlers}
|
||||
{...handlePlayNext.props}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</PlayTooltip>
|
||||
<PlayTooltip type={Play.LAST}>
|
||||
<ActionIcon
|
||||
icon="mediaPlayLast"
|
||||
iconProps={{ size: 'md' }}
|
||||
size="xs"
|
||||
variant="subtle"
|
||||
{...handlePlayLast.handlers}
|
||||
{...handlePlayLast.props}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</PlayTooltip>
|
||||
</ActionIconGroup>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{isLoading ? (
|
||||
<Group justify="center" py="md">
|
||||
<Spinner />
|
||||
</Group>
|
||||
) : tableConfig ? (
|
||||
<>
|
||||
<Group gap="sm" w="100%">
|
||||
<TextInput
|
||||
flex={1}
|
||||
leftSection={<Icon icon="search" />}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
placeholder={t('common.search', { postProcess: 'sentenceCase' })}
|
||||
radius="xl"
|
||||
rightSection={
|
||||
searchTerm ? (
|
||||
<ActionIcon
|
||||
icon="x"
|
||||
onClick={() => setSearchTerm('')}
|
||||
size="sm"
|
||||
variant="transparent"
|
||||
/>
|
||||
) : null
|
||||
}
|
||||
styles={{
|
||||
input: {
|
||||
background: 'transparent',
|
||||
border: '1px solid rgba(255, 255, 255, 0.05)',
|
||||
},
|
||||
}}
|
||||
value={searchTerm}
|
||||
/>
|
||||
<ListConfigMenu
|
||||
displayTypes={[{ hidden: true, value: ListDisplayType.GRID }]}
|
||||
listKey={ItemListKey.SONG}
|
||||
optionsConfig={{
|
||||
table: {
|
||||
itemsPerPage: { hidden: true },
|
||||
pagination: { hidden: true },
|
||||
},
|
||||
}}
|
||||
tableColumnsData={SONG_TABLE_COLUMNS}
|
||||
/>
|
||||
</Group>
|
||||
<ItemTableList
|
||||
activeRowId={currentSongId}
|
||||
autoFitColumns={tableConfig.autoFitColumns}
|
||||
CellComponent={ItemTableListColumn}
|
||||
columns={columns}
|
||||
data={filteredSongs}
|
||||
enableAlternateRowColors={tableConfig.enableAlternateRowColors}
|
||||
enableDrag
|
||||
enableDragScroll={false}
|
||||
enableExpansion={false}
|
||||
enableHeader={tableConfig.enableHeader}
|
||||
enableHorizontalBorders={tableConfig.enableHorizontalBorders}
|
||||
enableRowHoverHighlight={tableConfig.enableRowHoverHighlight}
|
||||
enableSelection
|
||||
enableSelectionDialog={false}
|
||||
enableVerticalBorders={tableConfig.enableVerticalBorders}
|
||||
itemType={LibraryItem.SONG}
|
||||
onColumnReordered={handleColumnReordered}
|
||||
onColumnResized={handleColumnResized}
|
||||
overrideControls={overrideControls}
|
||||
size={tableConfig.size}
|
||||
/>
|
||||
{!searchTerm.trim() && songs.length > 5 && !showAll && (
|
||||
<Group justify="center" w="100%">
|
||||
<Button onClick={() => setShowAll(true)} variant="subtle">
|
||||
{t('action.viewMore', { postProcess: 'sentenceCase' })}
|
||||
</Button>
|
||||
</Group>
|
||||
)}
|
||||
</>
|
||||
) : null}
|
||||
</Stack>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
interface AlbumArtistMetadataExternalLinksProps {
|
||||
artistName?: string;
|
||||
externalLinks: boolean;
|
||||
@@ -726,6 +1031,11 @@ export const AlbumArtistDetailContent = ({
|
||||
/>
|
||||
</Grid.Col>
|
||||
)}
|
||||
{enabledItem.favoriteSongs && (
|
||||
<Grid.Col order={itemOrder.favoriteSongs} span={12}>
|
||||
<AlbumArtistMetadataFavoriteSongs routeId={routeId} />
|
||||
</Grid.Col>
|
||||
)}
|
||||
</Grid>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1251,12 +1561,12 @@ const ArtistAlbums = ({ albumsQuery }: ArtistAlbumsProps) => {
|
||||
const secondaryKeyB = getSecondaryTypePriorityKey(b.releaseType);
|
||||
|
||||
if (secondaryKeyA && secondaryKeyB) {
|
||||
return secondaryKeyA.localeCompare(secondaryKeyB);
|
||||
return collator.compare(secondaryKeyA, secondaryKeyB);
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to alphabetical for non-combined types or if weighted comparison isn't applicable
|
||||
return a.releaseType.localeCompare(b.releaseType);
|
||||
return collator.compare(a.releaseType, b.releaseType);
|
||||
});
|
||||
}, [albumsByReleaseType, artistReleaseTypeItems, t]);
|
||||
|
||||
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { PageHeader } from '/@/renderer/components/page-header/page-header';
|
||||
import { LibraryHeaderBar } from '/@/renderer/features/shared/components/library-header-bar';
|
||||
import { Badge } from '/@/shared/components/badge/badge';
|
||||
import { SpinnerIcon } from '/@/shared/components/spinner/spinner';
|
||||
import { LibraryItem, Song } from '/@/shared/types/domain-types';
|
||||
|
||||
interface AlbumArtistDetailFavoriteSongsListHeaderProps {
|
||||
data: Song[];
|
||||
itemCount?: number;
|
||||
title: string;
|
||||
}
|
||||
|
||||
export const AlbumArtistDetailFavoriteSongsListHeader = ({
|
||||
data,
|
||||
itemCount,
|
||||
title,
|
||||
}: AlbumArtistDetailFavoriteSongsListHeaderProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<PageHeader>
|
||||
<LibraryHeaderBar ignoreMaxWidth>
|
||||
<LibraryHeaderBar.PlayButton itemType={LibraryItem.SONG} songs={data} />
|
||||
<LibraryHeaderBar.Title order={2}>
|
||||
{t('page.albumArtistDetail.favoriteSongsFrom', {
|
||||
postProcess: 'titleCase',
|
||||
title,
|
||||
})}
|
||||
</LibraryHeaderBar.Title>
|
||||
<Badge>
|
||||
{itemCount === null || itemCount === undefined ? <SpinnerIcon /> : itemCount}
|
||||
</Badge>
|
||||
</LibraryHeaderBar>
|
||||
</PageHeader>
|
||||
);
|
||||
};
|
||||
@@ -19,6 +19,7 @@ import { AppRoute } from '/@/renderer/router/routes';
|
||||
import { useCurrentServer, useShowRatings } from '/@/renderer/store';
|
||||
import { usePlayButtonBehavior } from '/@/renderer/store/settings.store';
|
||||
import { formatDurationString } from '/@/renderer/utils';
|
||||
import { SEPARATOR_STRING } from '/@/shared/api/utils';
|
||||
import { Group } from '/@/shared/components/group/group';
|
||||
import { Stack } from '/@/shared/components/stack/stack';
|
||||
import { Text } from '/@/shared/components/text/text';
|
||||
@@ -160,7 +161,11 @@ export const AlbumArtistDetailHeader = forwardRef((_props, ref: Ref<HTMLDivEleme
|
||||
.filter((i) => i.enabled)
|
||||
.map((item, index) => (
|
||||
<Fragment key={`item-${item.id}-${index}`}>
|
||||
{index > 0 && <Text isNoSelect>•</Text>}
|
||||
{index > 0 && (
|
||||
<Text isMuted isNoSelect>
|
||||
{SEPARATOR_STRING}
|
||||
</Text>
|
||||
)}
|
||||
<Text isMuted={item.secondary}>{item.value}</Text>
|
||||
</Fragment>
|
||||
))}
|
||||
|
||||
+3
-3
@@ -20,10 +20,10 @@ export const AlbumArtistDetailTopSongsListHeader = ({
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<PageHeader p="1rem">
|
||||
<LibraryHeaderBar>
|
||||
<PageHeader>
|
||||
<LibraryHeaderBar ignoreMaxWidth>
|
||||
<LibraryHeaderBar.PlayButton itemType={LibraryItem.SONG} songs={data} />
|
||||
<LibraryHeaderBar.Title>
|
||||
<LibraryHeaderBar.Title order={2}>
|
||||
{t('page.albumArtistDetail.topSongsFrom', {
|
||||
postProcess: 'titleCase',
|
||||
title,
|
||||
|
||||
+150
@@ -0,0 +1,150 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useMemo } from 'react';
|
||||
import { useParams } from 'react-router';
|
||||
|
||||
import { useItemListColumnReorder } from '/@/renderer/components/item-list/helpers/use-item-list-column-reorder';
|
||||
import { useItemListColumnResize } from '/@/renderer/components/item-list/helpers/use-item-list-column-resize';
|
||||
import { ItemTableList } from '/@/renderer/components/item-list/item-table-list/item-table-list';
|
||||
import { ItemTableListColumn } from '/@/renderer/components/item-list/item-table-list/item-table-list-column';
|
||||
import { ItemControls } from '/@/renderer/components/item-list/types';
|
||||
import { ListContext } from '/@/renderer/context/list-context';
|
||||
import { artistsQueries } from '/@/renderer/features/artists/api/artists-api';
|
||||
import { AlbumArtistDetailFavoriteSongsListHeader } from '/@/renderer/features/artists/components/album-artist-detail-favorite-songs-list-header';
|
||||
import { usePlayer } from '/@/renderer/features/player/context/player-context';
|
||||
import { AnimatedPage } from '/@/renderer/features/shared/components/animated-page';
|
||||
import { PageErrorBoundary } from '/@/renderer/features/shared/components/page-error-boundary';
|
||||
import { usePlayerSong } from '/@/renderer/store';
|
||||
import { useCurrentServer } from '/@/renderer/store/auth.store';
|
||||
import { useSettingsStore } from '/@/renderer/store/settings.store';
|
||||
import { LibraryItem, Song } from '/@/shared/types/domain-types';
|
||||
import { ItemListKey, Play } from '/@/shared/types/types';
|
||||
|
||||
const AlbumArtistDetailFavoriteSongsListRoute = () => {
|
||||
const { albumArtistId, artistId } = useParams() as {
|
||||
albumArtistId?: string;
|
||||
artistId?: string;
|
||||
};
|
||||
const routeId = (artistId || albumArtistId) as string;
|
||||
const server = useCurrentServer();
|
||||
const pageKey = LibraryItem.SONG;
|
||||
|
||||
const detailQuery = useQuery(
|
||||
artistsQueries.albumArtistDetail({
|
||||
query: { id: routeId },
|
||||
serverId: server?.id,
|
||||
}),
|
||||
);
|
||||
|
||||
const favoriteSongsQuery = useQuery(
|
||||
artistsQueries.favoriteSongs({
|
||||
options: { enabled: !!detailQuery?.data?.name },
|
||||
query: { artistId: routeId },
|
||||
serverId: server?.id,
|
||||
}),
|
||||
);
|
||||
|
||||
const itemCount = favoriteSongsQuery?.data?.items?.length || 0;
|
||||
const songs = useMemo(
|
||||
() => favoriteSongsQuery?.data?.items || [],
|
||||
[favoriteSongsQuery?.data?.items],
|
||||
);
|
||||
|
||||
const tableConfig = useSettingsStore((state) => state.lists[ItemListKey.SONG]?.table);
|
||||
const currentSong = usePlayerSong();
|
||||
const player = usePlayer();
|
||||
|
||||
const columns = useMemo(() => {
|
||||
return tableConfig?.columns || [];
|
||||
}, [tableConfig?.columns]);
|
||||
|
||||
const { handleColumnReordered } = useItemListColumnReorder({
|
||||
itemListKey: ItemListKey.SONG,
|
||||
});
|
||||
|
||||
const { handleColumnResized } = useItemListColumnResize({
|
||||
itemListKey: ItemListKey.SONG,
|
||||
});
|
||||
|
||||
const overrideControls: Partial<ItemControls> = useMemo(() => {
|
||||
return {
|
||||
onDoubleClick: ({ index, internalState, item, meta }) => {
|
||||
if (!item) {
|
||||
return;
|
||||
}
|
||||
|
||||
const playType = (meta?.playType as Play) || Play.NOW;
|
||||
const items = internalState?.getData() as Song[];
|
||||
|
||||
if (index !== undefined) {
|
||||
player.addToQueueByData(items, playType, item.id);
|
||||
}
|
||||
},
|
||||
};
|
||||
}, [player]);
|
||||
|
||||
const providerValue = useMemo(() => {
|
||||
return {
|
||||
id: routeId,
|
||||
pageKey,
|
||||
};
|
||||
}, [routeId, pageKey]);
|
||||
|
||||
const currentSongId = currentSong?.id;
|
||||
|
||||
if (!tableConfig || columns.length === 0) {
|
||||
return (
|
||||
<AnimatedPage>
|
||||
<ListContext.Provider value={providerValue}>
|
||||
<AlbumArtistDetailFavoriteSongsListHeader
|
||||
data={songs}
|
||||
itemCount={itemCount}
|
||||
title={detailQuery?.data?.name || 'Unknown'}
|
||||
/>
|
||||
</ListContext.Provider>
|
||||
</AnimatedPage>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<AnimatedPage>
|
||||
<ListContext.Provider value={providerValue}>
|
||||
<AlbumArtistDetailFavoriteSongsListHeader
|
||||
data={songs}
|
||||
itemCount={itemCount}
|
||||
title={detailQuery?.data?.name || 'Unknown'}
|
||||
/>
|
||||
<ItemTableList
|
||||
activeRowId={currentSongId}
|
||||
autoFitColumns={tableConfig.autoFitColumns}
|
||||
CellComponent={ItemTableListColumn}
|
||||
columns={columns}
|
||||
data={songs}
|
||||
enableAlternateRowColors={tableConfig.enableAlternateRowColors}
|
||||
enableDrag
|
||||
enableExpansion={false}
|
||||
enableHeader={tableConfig.enableHeader}
|
||||
enableHorizontalBorders={tableConfig.enableHorizontalBorders}
|
||||
enableRowHoverHighlight={tableConfig.enableRowHoverHighlight}
|
||||
enableSelection
|
||||
enableSelectionDialog={false}
|
||||
enableVerticalBorders={tableConfig.enableVerticalBorders}
|
||||
itemType={LibraryItem.SONG}
|
||||
onColumnReordered={handleColumnReordered}
|
||||
onColumnResized={handleColumnResized}
|
||||
overrideControls={overrideControls}
|
||||
size={tableConfig.size}
|
||||
/>
|
||||
</ListContext.Provider>
|
||||
</AnimatedPage>
|
||||
);
|
||||
};
|
||||
|
||||
const AlbumArtistDetailTopSongsListRouteWithBoundary = () => {
|
||||
return (
|
||||
<PageErrorBoundary>
|
||||
<AlbumArtistDetailFavoriteSongsListRoute />
|
||||
</PageErrorBoundary>
|
||||
);
|
||||
};
|
||||
|
||||
export default AlbumArtistDetailTopSongsListRouteWithBoundary;
|
||||
@@ -12,11 +12,11 @@ import { artistsQueries } from '/@/renderer/features/artists/api/artists-api';
|
||||
import { AlbumArtistDetailTopSongsListHeader } from '/@/renderer/features/artists/components/album-artist-detail-top-songs-list-header';
|
||||
import { usePlayer } from '/@/renderer/features/player/context/player-context';
|
||||
import { AnimatedPage } from '/@/renderer/features/shared/components/animated-page';
|
||||
import { LibraryContainer } from '/@/renderer/features/shared/components/library-container';
|
||||
import { PageErrorBoundary } from '/@/renderer/features/shared/components/page-error-boundary';
|
||||
import { usePlayerSong } from '/@/renderer/store';
|
||||
import { useCurrentServer } from '/@/renderer/store/auth.store';
|
||||
import { useSettingsStore } from '/@/renderer/store/settings.store';
|
||||
import { useLocalStorage } from '/@/shared/hooks/use-local-storage';
|
||||
import { LibraryItem, Song } from '/@/shared/types/domain-types';
|
||||
import { ItemListKey, Play } from '/@/shared/types/types';
|
||||
|
||||
@@ -29,6 +29,11 @@ const AlbumArtistDetailTopSongsListRoute = () => {
|
||||
const server = useCurrentServer();
|
||||
const pageKey = LibraryItem.SONG;
|
||||
|
||||
const [topSongsQueryType] = useLocalStorage<'community' | 'personal'>({
|
||||
defaultValue: 'community',
|
||||
key: 'album-artist-top-songs-query-type',
|
||||
});
|
||||
|
||||
const detailQuery = useQuery(
|
||||
artistsQueries.albumArtistDetail({
|
||||
query: { id: routeId },
|
||||
@@ -39,7 +44,11 @@ const AlbumArtistDetailTopSongsListRoute = () => {
|
||||
const topSongsQuery = useQuery(
|
||||
artistsQueries.topSongs({
|
||||
options: { enabled: !!detailQuery?.data?.name },
|
||||
query: { artist: detailQuery?.data?.name || '', artistId: routeId },
|
||||
query: {
|
||||
artist: detailQuery?.data?.name || '',
|
||||
artistId: routeId,
|
||||
type: topSongsQueryType,
|
||||
},
|
||||
serverId: server?.id,
|
||||
}),
|
||||
);
|
||||
@@ -93,13 +102,11 @@ const AlbumArtistDetailTopSongsListRoute = () => {
|
||||
return (
|
||||
<AnimatedPage>
|
||||
<ListContext.Provider value={providerValue}>
|
||||
<LibraryContainer>
|
||||
<AlbumArtistDetailTopSongsListHeader
|
||||
data={songs}
|
||||
itemCount={itemCount}
|
||||
title={detailQuery?.data?.name || 'Unknown'}
|
||||
/>
|
||||
</LibraryContainer>
|
||||
<AlbumArtistDetailTopSongsListHeader
|
||||
data={songs}
|
||||
itemCount={itemCount}
|
||||
title={detailQuery?.data?.name || 'Unknown'}
|
||||
/>
|
||||
</ListContext.Provider>
|
||||
</AnimatedPage>
|
||||
);
|
||||
@@ -108,34 +115,32 @@ const AlbumArtistDetailTopSongsListRoute = () => {
|
||||
return (
|
||||
<AnimatedPage>
|
||||
<ListContext.Provider value={providerValue}>
|
||||
<LibraryContainer>
|
||||
<AlbumArtistDetailTopSongsListHeader
|
||||
data={songs}
|
||||
itemCount={itemCount}
|
||||
title={detailQuery?.data?.name || 'Unknown'}
|
||||
/>
|
||||
<ItemTableList
|
||||
activeRowId={currentSongId}
|
||||
autoFitColumns={tableConfig.autoFitColumns}
|
||||
CellComponent={ItemTableListColumn}
|
||||
columns={columns}
|
||||
data={songs}
|
||||
enableAlternateRowColors={tableConfig.enableAlternateRowColors}
|
||||
enableDrag
|
||||
enableExpansion={false}
|
||||
enableHeader={tableConfig.enableHeader}
|
||||
enableHorizontalBorders={tableConfig.enableHorizontalBorders}
|
||||
enableRowHoverHighlight={tableConfig.enableRowHoverHighlight}
|
||||
enableSelection
|
||||
enableSelectionDialog={false}
|
||||
enableVerticalBorders={tableConfig.enableVerticalBorders}
|
||||
itemType={LibraryItem.SONG}
|
||||
onColumnReordered={handleColumnReordered}
|
||||
onColumnResized={handleColumnResized}
|
||||
overrideControls={overrideControls}
|
||||
size={tableConfig.size}
|
||||
/>
|
||||
</LibraryContainer>
|
||||
<AlbumArtistDetailTopSongsListHeader
|
||||
data={songs}
|
||||
itemCount={itemCount}
|
||||
title={detailQuery?.data?.name || 'Unknown'}
|
||||
/>
|
||||
<ItemTableList
|
||||
activeRowId={currentSongId}
|
||||
autoFitColumns={tableConfig.autoFitColumns}
|
||||
CellComponent={ItemTableListColumn}
|
||||
columns={columns}
|
||||
data={songs}
|
||||
enableAlternateRowColors={tableConfig.enableAlternateRowColors}
|
||||
enableDrag
|
||||
enableExpansion={false}
|
||||
enableHeader={tableConfig.enableHeader}
|
||||
enableHorizontalBorders={tableConfig.enableHorizontalBorders}
|
||||
enableRowHoverHighlight={tableConfig.enableRowHoverHighlight}
|
||||
enableSelection
|
||||
enableSelectionDialog={false}
|
||||
enableVerticalBorders={tableConfig.enableVerticalBorders}
|
||||
itemType={LibraryItem.SONG}
|
||||
onColumnReordered={handleColumnReordered}
|
||||
onColumnResized={handleColumnResized}
|
||||
overrideControls={overrideControls}
|
||||
size={tableConfig.size}
|
||||
/>
|
||||
</ListContext.Provider>
|
||||
</AnimatedPage>
|
||||
);
|
||||
|
||||
@@ -38,7 +38,7 @@ export const PlayTrackRadioAction = ({ disabled, song }: PlayTrackRadioActionPro
|
||||
});
|
||||
|
||||
if (similarSongs && similarSongs.length > 0) {
|
||||
player.addToQueueByData(similarSongs, playType);
|
||||
player.addToQueueByData([song, ...similarSongs], playType);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load track radio:', error);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user