mirror of
https://github.com/jeffvli/feishin.git
synced 2026-05-07 04:20:12 +02:00
Compare commits
74 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3ad5447871 | |||
| 3a50dee7a2 | |||
| c4ecfeedec | |||
| f43655ed5a | |||
| fddda70190 | |||
| a5992943d0 | |||
| 56e2611992 | |||
| c8ae128ac4 | |||
| ba56ab8844 | |||
| 7cb7dfb62b | |||
| 86537a8d1e | |||
| 3b3e77b672 | |||
| bec6464a44 | |||
| 812ca5302a | |||
| 1824083b99 | |||
| f46ca8cd35 | |||
| f04ea3bca0 | |||
| a547be1577 | |||
| 8ae29407ec | |||
| 8e603871b7 | |||
| 40ec16e191 | |||
| 0bb30ab0da | |||
| 9919ff9626 | |||
| f6cec17710 | |||
| 03b01472f8 | |||
| 3550177f67 | |||
| 82914c27f0 | |||
| 10d02087d0 | |||
| 4b509951a5 | |||
| 2869aab728 | |||
| 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 | |||
| adfdf04240 | |||
| faa7281993 | |||
| 2d0f4e7881 | |||
| ce9183ffd6 | |||
| 3a5508653b | |||
| 7e4e28037c | |||
| d2d8ea8249 | |||
| ba835bec3e | |||
| 9850874dfd | |||
| 51a8285ba2 | |||
| e12150d026 | |||
| 54bc241984 | |||
| a698f83c45 |
@@ -0,0 +1,189 @@
|
||||
# Alpha builds published to Cloudflare R2 with date versioning (e.g. 1.0.0-alpha-20260205).
|
||||
# Required repo secrets: R2_ACCESS_KEY_ID, R2_SECRET_ACCESS_KEY (from R2 API token in Cloudflare dashboard).
|
||||
name: Publish Alpha
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: 'Semantic version number (e.g., 1.0.0) - alpha suffix will be added automatically'
|
||||
required: false
|
||||
type: string
|
||||
schedule:
|
||||
# Run at 3:00 AM PST daily (11:00 UTC; PST = UTC-8)
|
||||
- cron: '0 11 * * *'
|
||||
|
||||
jobs:
|
||||
check-new-commits:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
has_new_commits: ${{ steps.manual.outputs.has_new_commits || steps.check.outputs['has-new-commits'] }}
|
||||
steps:
|
||||
- name: Set has new commits (manual trigger)
|
||||
id: manual
|
||||
if: github.event_name == 'workflow_dispatch'
|
||||
run: echo "has_new_commits=true" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Check for new commits (24 hr interval)
|
||||
id: check
|
||||
if: github.event_name != 'workflow_dispatch'
|
||||
uses: adriangl/check-new-commits-action@v1
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
seconds: 86400
|
||||
|
||||
prepare:
|
||||
needs: check-new-commits
|
||||
if: needs.check-new-commits.outputs.has_new_commits == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
version: ${{ steps.version.outputs.version }}
|
||||
steps:
|
||||
- name: Checkout git repo
|
||||
uses: actions/checkout@v1
|
||||
|
||||
- name: Install Node and PNPM
|
||||
uses: pnpm/action-setup@v4.1.0
|
||||
with:
|
||||
version: 9
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install
|
||||
|
||||
- name: Set date-based alpha version
|
||||
id: version
|
||||
shell: pwsh
|
||||
run: |
|
||||
$inputVersion = "${{ github.event.inputs.version }}"
|
||||
Write-Host "Input version: $inputVersion"
|
||||
|
||||
if ($inputVersion -eq "" -or $inputVersion -eq "null") {
|
||||
# No input version provided (scheduled run or manual without input), auto-increment patch version
|
||||
Write-Host "No version provided, auto-incrementing patch version..."
|
||||
|
||||
$currentVersion = (Get-Content package.json | ConvertFrom-Json).version
|
||||
Write-Host "Current version: $currentVersion"
|
||||
|
||||
$cleanVersion = $currentVersion -replace '-.*$', ''
|
||||
$versionParts = $cleanVersion.Split('.')
|
||||
if ($versionParts.Length -ne 3) {
|
||||
Write-Error "Current version format is invalid: $cleanVersion"
|
||||
exit 1
|
||||
}
|
||||
|
||||
$major = [int]$versionParts[0]
|
||||
$minor = [int]$versionParts[1]
|
||||
$patch = [int]$versionParts[2]
|
||||
$newPatch = $patch + 1
|
||||
$inputVersion = "$major.$minor.$newPatch"
|
||||
Write-Host "Auto-generated version: $inputVersion"
|
||||
} else {
|
||||
# Validate semantic version format (major.minor.patch)
|
||||
$versionPattern = '^\d+\.\d+\.\d+$'
|
||||
if ($inputVersion -notmatch $versionPattern) {
|
||||
Write-Error "Invalid version format. Expected semantic version (e.g., 1.0.0), got: $inputVersion"
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
|
||||
# Date in YYYYMMDD (PST / America/Los_Angeles)
|
||||
$pst = [TimeZoneInfo]::FindSystemTimeZoneById('America/Los_Angeles')
|
||||
$dateInPst = [TimeZoneInfo]::ConvertTimeFromUtc([DateTime]::UtcNow, $pst)
|
||||
$dateStr = $dateInPst.ToString("yyyyMMdd")
|
||||
$alphaVersion = "$inputVersion-alpha-$dateStr"
|
||||
Write-Host "Alpha version: $alphaVersion"
|
||||
|
||||
# Update package.json
|
||||
$packageJson = Get-Content package.json | ConvertFrom-Json
|
||||
$packageJson.version = $alphaVersion
|
||||
$packageJson | ConvertTo-Json -Depth 10 | Set-Content package.json
|
||||
|
||||
echo "version=$alphaVersion" >> $env:GITHUB_OUTPUT
|
||||
|
||||
cleanup:
|
||||
needs: prepare
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
|
||||
R2_ENDPOINT_URL: ${{ secrets.R2_ENDPOINT_URL }}
|
||||
steps:
|
||||
- name: Delete all objects in R2 bucket
|
||||
run: |
|
||||
aws s3 rm s3://feishin-nightly --recursive --endpoint-url $R2_ENDPOINT_URL
|
||||
|
||||
publish:
|
||||
needs: [prepare, cleanup]
|
||||
runs-on: ${{ matrix.os }}
|
||||
env:
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
|
||||
|
||||
strategy:
|
||||
matrix:
|
||||
os: [windows-latest, macos-latest, ubuntu-latest]
|
||||
|
||||
steps:
|
||||
- name: Checkout git repo
|
||||
uses: actions/checkout@v1
|
||||
|
||||
- name: Install Node and PNPM
|
||||
uses: pnpm/action-setup@v4.1.0
|
||||
with:
|
||||
version: 9
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install
|
||||
|
||||
- name: Set version from prepare job
|
||||
shell: pwsh
|
||||
run: |
|
||||
$version = "${{ needs.prepare.outputs.version }}"
|
||||
Write-Host "Setting version: $version"
|
||||
$packageJson = Get-Content package.json | ConvertFrom-Json
|
||||
$packageJson.version = $version
|
||||
$packageJson | ConvertTo-Json -Depth 10 | Set-Content package.json
|
||||
|
||||
- name: Build and Publish to R2 (Windows)
|
||||
if: matrix.os == 'windows-latest'
|
||||
uses: nick-invision/retry@v2.8.2
|
||||
with:
|
||||
timeout_minutes: 30
|
||||
max_attempts: 3
|
||||
retry_on: error
|
||||
command: |
|
||||
pnpm run publish:win:alpha
|
||||
on_retry_command: pnpm cache delete
|
||||
|
||||
- name: Build and Publish to R2 (macOS)
|
||||
if: matrix.os == 'macos-latest'
|
||||
uses: nick-invision/retry@v2.8.2
|
||||
with:
|
||||
timeout_minutes: 30
|
||||
max_attempts: 3
|
||||
retry_on: error
|
||||
command: |
|
||||
pnpm run publish:mac:alpha
|
||||
on_retry_command: pnpm cache delete
|
||||
|
||||
- name: Build and Publish to R2 (Linux)
|
||||
if: matrix.os == 'ubuntu-latest'
|
||||
uses: nick-invision/retry@v2.8.2
|
||||
with:
|
||||
timeout_minutes: 30
|
||||
max_attempts: 3
|
||||
retry_on: error
|
||||
command: |
|
||||
pnpm run publish:linux:alpha
|
||||
on_retry_command: pnpm cache delete
|
||||
|
||||
- name: Build and Publish to R2 (Linux ARM64)
|
||||
if: matrix.os == 'ubuntu-latest'
|
||||
uses: nick-invision/retry@v2.8.2
|
||||
with:
|
||||
timeout_minutes: 30
|
||||
max_attempts: 3
|
||||
retry_on: error
|
||||
command: |
|
||||
pnpm run publish:linux-arm64:alpha
|
||||
on_retry_command: pnpm cache delete
|
||||
@@ -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
|
||||
+9
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "feishin",
|
||||
"version": "1.4.0",
|
||||
"version": "1.4.2",
|
||||
"description": "A modern self-hosted music player.",
|
||||
"keywords": [
|
||||
"subsonic",
|
||||
@@ -30,7 +30,7 @@
|
||||
"dev:watch": "electron-vite dev --watch",
|
||||
"i18next": "i18next -c src/i18n/i18next-parser.config.js",
|
||||
"postinstall": "electron-builder install-app-deps",
|
||||
"lint": "pnpm run lint-code && pnpm run lint-styles",
|
||||
"lint": "pnpm run typecheck && pnpm run lint-code && pnpm run lint-styles",
|
||||
"lint-code": "eslint --max-warnings=0 --cache .",
|
||||
"lint-code:fix": "eslint --cache --fix .",
|
||||
"lint-styles": "stylelint --max-warnings=0 'src/**/*.{css,scss}'",
|
||||
@@ -47,11 +47,15 @@
|
||||
"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: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",
|
||||
@@ -94,6 +98,7 @@
|
||||
"electron-log": "^5.4.3",
|
||||
"electron-store": "^8.2.0",
|
||||
"electron-updater": "^6.6.2",
|
||||
"express": "^5.2.1",
|
||||
"fast-average-color": "^9.5.0",
|
||||
"fast-xml-parser": "^5.3.2",
|
||||
"format-duration": "^3.0.2",
|
||||
@@ -107,6 +112,7 @@
|
||||
"md5": "^2.3.0",
|
||||
"motion": "^12.23.24",
|
||||
"mpris-service": "^2.1.2",
|
||||
"musicbrainz-api": "^0.27.1",
|
||||
"nanoid": "^3.3.11",
|
||||
"node-mpv": "github:jeffvli/Node-MPV#32b4d64395289ad710c41d481d2707a7acfc228f",
|
||||
"nuqs": "^2.7.1",
|
||||
@@ -130,6 +136,7 @@
|
||||
"string-to-color": "^2.2.2",
|
||||
"wavesurfer.js": "^7.11.1",
|
||||
"ws": "^8.18.2",
|
||||
"ytmusic-api": "^5.3.0",
|
||||
"zod": "^3.22.3",
|
||||
"zustand": "^5.0.5"
|
||||
},
|
||||
|
||||
Generated
+652
-3
File diff suppressed because it is too large
Load Diff
@@ -79,6 +79,7 @@
|
||||
"dismiss": "dismiss",
|
||||
"doNotShowAgain": "do not show this again",
|
||||
"duration": "duration",
|
||||
"external": "external",
|
||||
"view": "view",
|
||||
"edit": "edit",
|
||||
"enable": "enable",
|
||||
@@ -152,6 +153,7 @@
|
||||
"trackPeak": "track peak",
|
||||
"translation": "translation",
|
||||
"unknown": "unknown",
|
||||
"unavailable": "unavailable",
|
||||
"version": "version",
|
||||
"year": "year",
|
||||
"yes": "yes",
|
||||
@@ -418,13 +420,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 +450,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})"
|
||||
},
|
||||
@@ -573,6 +584,7 @@
|
||||
"analytics": "analytics",
|
||||
"generalTab": "general",
|
||||
"hotkeysTab": "hotkeys",
|
||||
"integrationsTab": "integrations",
|
||||
"playbackTab": "playback",
|
||||
"windowTab": "window",
|
||||
"updates": "update",
|
||||
@@ -741,10 +753,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",
|
||||
@@ -882,6 +895,16 @@
|
||||
"mpvExtraParameters_help": "one per line",
|
||||
"musicbrainz_description": "show links to MusicBrainz on artist/album pages, where MusicBrainz ID exists",
|
||||
"musicbrainz": "show MusicBrainz links",
|
||||
"musicBrainzQueries": "enable MusicBrainz integration",
|
||||
"musicBrainzQueries_description": "the integration will query MusicBrainz for missing artist releases and other miscellaneous data",
|
||||
"musicbrainzExcludeReleaseTypes": "MusicBrainz release type exclusion",
|
||||
"musicbrainzExcludeReleaseTypes_description": "release types to exclude when loading MusicBrainz artist releases",
|
||||
"musicbrainzPrioritizeCountries": "MusicBrainz country priority",
|
||||
"musicbrainzPrioritizeCountries_description": "countries to prioritize when ordering MusicBrainz releases (first in list has highest priority)",
|
||||
"musicbrainzAutoCountryPriority": "automatic country priority",
|
||||
"musicbrainzAutoCountryPriority_description": "derive country priority from the artist's MusicBrainz releases (countries with more releases are ranked higher)",
|
||||
"youtube": "enable YouTube playback",
|
||||
"youtube_description": "external songs will attempt to use YouTube to resolve stream URLs (desktop only)",
|
||||
"neteaseTranslation_description": "When enabled, fetches and displays translated lyrics from NetEase if available",
|
||||
"neteaseTranslation": "Enable NetEase translations",
|
||||
"notify": "enable song notifications",
|
||||
@@ -932,6 +955,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",
|
||||
|
||||
@@ -162,7 +162,8 @@
|
||||
"mood": "nastrój",
|
||||
"example": "przykład",
|
||||
"filter_multiple": "multi",
|
||||
"filter_single": "single"
|
||||
"filter_single": "single",
|
||||
"rename": "zmień nazwę"
|
||||
},
|
||||
"entity": {
|
||||
"genre_one": "gatunek",
|
||||
@@ -512,7 +513,8 @@
|
||||
"shared": "udostępniono $t(entity.playlist, {\"count\": 2})",
|
||||
"myLibrary": "Moja biblioteka",
|
||||
"favorites": "$t(entity.favorite, {\"count\": 2})",
|
||||
"radio": "$t(entity.radioStation, {\"count\": 2})"
|
||||
"radio": "$t(entity.radioStation, {\"count\": 2})",
|
||||
"collections": "kolekcje"
|
||||
},
|
||||
"home": {
|
||||
"mostPlayed": "najczęściej odtwarzane",
|
||||
@@ -601,6 +603,14 @@
|
||||
},
|
||||
"radioList": {
|
||||
"title": "stacje radiowe"
|
||||
},
|
||||
"windowBar": {
|
||||
"paused": "(Wstrzymane) ",
|
||||
"privateMode": "(Tryb prywatny)"
|
||||
},
|
||||
"collections": {
|
||||
"overrideExisting": "nadpisz istniejące",
|
||||
"saveAsCollection": "zapisz jako kolekcję"
|
||||
}
|
||||
},
|
||||
"player": {
|
||||
@@ -984,7 +994,10 @@
|
||||
"enableGridMultiSelect_description": "gdy włączone, pozwala na wybieranie wielu elementów w widokach siatki, gdy wyłączone, klikanie obrazów elementów siatki będzie przenosić na stronę elementu",
|
||||
"enableGridMultiSelect": "wybieranie wielu w siatce",
|
||||
"sidebarPlaylistSorting_description": "pozwala na ręczne sortowanie playlist w bocznym pasku używając przeciągania i upuszczania zamiast używania domyślnej kolejności serwera",
|
||||
"sidebarPlaylistSorting": "sortowanie playlist w bocznym pasku"
|
||||
"sidebarPlaylistSorting": "sortowanie playlist w bocznym pasku",
|
||||
"sidebarPlaylistListFilterRegex_description": "ukryj playlisty w pasku bocznym pasujące do wyrażenia regularnego",
|
||||
"sidebarPlaylistListFilterRegex_placeholder": "np. ^Miks codzienny.^",
|
||||
"sidebarPlaylistListFilterRegex": "filtr playlist regex"
|
||||
},
|
||||
"table": {
|
||||
"config": {
|
||||
|
||||
+182
-28
@@ -150,7 +150,12 @@
|
||||
"tableColumns": "表格列",
|
||||
"itemsMore": "{{count}} 更多",
|
||||
"countSelected": "已选择{{count}}项",
|
||||
"retry": "重试"
|
||||
"retry": "重试",
|
||||
"example": "示例",
|
||||
"filter_single": "单项",
|
||||
"mood": "氛围",
|
||||
"rename": "重命名",
|
||||
"filter_multiple": "多项"
|
||||
},
|
||||
"entity": {
|
||||
"albumArtist_other": "专辑艺术家",
|
||||
@@ -170,7 +175,9 @@
|
||||
"genreWithCount_other": "{{count}} 种流派",
|
||||
"trackWithCount_other": "{{count}} 首曲目",
|
||||
"play_other": "{{count}} 次播放",
|
||||
"song_other": "歌曲"
|
||||
"song_other": "歌曲",
|
||||
"radioStation_other": "广播电台",
|
||||
"radioStationWithCount_other": "{{count}} 个广播电台"
|
||||
},
|
||||
"player": {
|
||||
"repeat_all": "循环全部",
|
||||
@@ -187,7 +194,7 @@
|
||||
"shuffle": "播放(随机)",
|
||||
"playbackFetchNoResults": "未找到歌曲",
|
||||
"playbackFetchInProgress": "正在加载歌曲…",
|
||||
"addNext": "下一首播放",
|
||||
"addNext": "下一个",
|
||||
"playbackFetchCancel": "请稍等…关闭通知以取消操作",
|
||||
"play": "播放",
|
||||
"repeat_off": "循环关闭",
|
||||
@@ -197,7 +204,7 @@
|
||||
"queue_moveToTop": "将所选项移至底部",
|
||||
"queue_moveToBottom": "将所选项移至顶部",
|
||||
"shuffle_off": "禁用随机播放",
|
||||
"addLast": "上一曲",
|
||||
"addLast": "最后",
|
||||
"mute": "静音",
|
||||
"skip_forward": "向前跳过",
|
||||
"playbackSpeed": "播放速度",
|
||||
@@ -206,7 +213,12 @@
|
||||
"viewQueue": "查看播放队列",
|
||||
"saveQueueToServer": "将播放队列保存到服务器",
|
||||
"restoreQueueFromServer": "从服务器恢复播放队列",
|
||||
"lyrics": "歌词"
|
||||
"lyrics": "歌词",
|
||||
"addLastShuffled": "最后(随机)",
|
||||
"addNextShuffled": "下一个(随机)",
|
||||
"artistRadio": "艺术家电台",
|
||||
"holdToShuffle": "按住即可随机",
|
||||
"trackRadio": "追踪广播"
|
||||
},
|
||||
"setting": {
|
||||
"crossfadeStyle_description": "选择用于音频播放器的淡入淡出风格",
|
||||
@@ -333,7 +345,7 @@
|
||||
"useSystemTheme_description": "使用系统定义的浅色或深色主题",
|
||||
"playButtonBehavior_optionAddNext": "$t(player.addNext)",
|
||||
"lyricFetch_description": "从多个互联网源获取歌词",
|
||||
"lyricFetchProvider_description": "选择歌词源。 歌词源顺序与查询顺序一致",
|
||||
"lyricFetchProvider_description": "选择要从中获取歌词的提供商",
|
||||
"sidePlayQueueStyle_optionDetached": "不吸附",
|
||||
"hotkey_zoomOut": "缩小",
|
||||
"hotkey_unfavoriteCurrentSong": "取消收藏$t(common.currentSong)",
|
||||
@@ -454,7 +466,7 @@
|
||||
"releaseChannel": "发布通道",
|
||||
"releaseChannel_description": "选择稳定版本或测试版以进行自动更新",
|
||||
"mediaSession": "启用媒体会话",
|
||||
"mediaSession_description": "启用 Windows 媒体会话集成,在系统音量覆盖和锁定屏幕中显示媒体控件和元数据(仅限 Windows)",
|
||||
"mediaSession_description": "启用媒体会话集成,在系统音量叠加层和锁屏界面显示媒体控件和元数据",
|
||||
"exportImportSettings_control_description": "通过 JSON 导出和导入设置",
|
||||
"exportImportSettings_control_exportText": "导出设置",
|
||||
"exportImportSettings_control_importText": "导入设置",
|
||||
@@ -515,7 +527,43 @@
|
||||
"playerbarWaveformAlign": "波形对齐方式",
|
||||
"playerbarWaveformBarWidth": "波形宽度",
|
||||
"playerbarWaveformGap": "波形间距",
|
||||
"transcode": "启用转码功能"
|
||||
"transcode": "启用转码功能",
|
||||
"useThemeAccentColor_description": "使用所选主题中定义的主色,而不是自定义强调色",
|
||||
"homeFeatureStyle_optionSingle": "单项",
|
||||
"autoDJ": "自动DJ",
|
||||
"autoDJ_itemCount": "项目数量",
|
||||
"autoDJ_itemCount_description": "启用自动 DJ 功能后,尝试添加到队列中的项目数",
|
||||
"autoDJ_timing": "定时",
|
||||
"autoDJ_timing_description": "自动 DJ 触发前队列中剩余的歌曲数量",
|
||||
"crossfadeStyle": "交叉渐变风格",
|
||||
"discordRichPresence": "{{discord}} rich presence",
|
||||
"homeFeatureStyle_description": "控制首页特色轮播图的样式",
|
||||
"homeFeatureStyle": "首页特色旋转样式",
|
||||
"homeFeatureStyle_optionMultiple": "多样",
|
||||
"hotkey_listNavigateToPage": "列表导航至项目页面",
|
||||
"hotkey_listPlayDefault": "播放列表",
|
||||
"hotkey_listPlayLast": "播放列表最后",
|
||||
"hotkey_listPlayNext": "播放列表下一个",
|
||||
"hotkey_listPlayNow": "播放列表现在",
|
||||
"pathReplace": "文件路径替换",
|
||||
"pathReplace_description": "替换服务器的默认文件路径",
|
||||
"pathReplace_optionRemovePrefix": "移除前缀",
|
||||
"pathReplace_optionAddPrefix": "添加前缀",
|
||||
"playerFilters": "从队列中筛选歌曲",
|
||||
"playerFilters_description": "根据以下条件,忽略添加到队列中的歌曲",
|
||||
"artistRadioCount_description": "设置艺术家电台和曲目电台要获取的歌曲数量",
|
||||
"artistRadioCount": "艺术家/曲目电台数量",
|
||||
"imageResolution_optionItemCard": "项目卡",
|
||||
"playerbarWaveformRadius": "波形半径",
|
||||
"enableGridMultiSelect": "启用网格多选",
|
||||
"enableGridMultiSelect_description": "启用后,允许在网格视图中选择多个项目。禁用后,点击网格项目图像将跳转到项目页面",
|
||||
"sidebarPlaylistSorting_description": "允许在侧边栏中使用拖放操作手动对播放列表进行排序,而不是使用默认的服务器顺序",
|
||||
"sidebarPlaylistSorting": "侧边栏播放列表排序",
|
||||
"sidebarPlaylistListFilterRegex_description": "隐藏侧边栏中与此正则表达式匹配的播放列表",
|
||||
"sidebarPlaylistListFilterRegex_placeholder": "例如:^每日精选*",
|
||||
"sidebarPlaylistListFilterRegex": "播放列表筛选正则表达式",
|
||||
"queryBuilder": "查询构建器",
|
||||
"queryBuilderCustomFields": "自定义字段"
|
||||
},
|
||||
"error": {
|
||||
"remotePortWarning": "重启服务器使新端口生效",
|
||||
@@ -568,7 +616,7 @@
|
||||
"biography": "个人简介",
|
||||
"songCount": "歌曲数量",
|
||||
"random": "随机",
|
||||
"lastPlayed": "上次播放过",
|
||||
"lastPlayed": "最后播放",
|
||||
"toYear": "截止年份",
|
||||
"fromYear": "起始年份",
|
||||
"criticRating": "评论家评分",
|
||||
@@ -582,7 +630,7 @@
|
||||
"recentlyUpdated": "最近更新",
|
||||
"isRated": "已评分",
|
||||
"isRecentlyPlayed": "最近播放过",
|
||||
"channels": "$t(common.channel_other)",
|
||||
"channels": "$t(common.channel, {\"count\": 2})",
|
||||
"owner": "$t(common.owner)",
|
||||
"genre": "$t(entity.genre, {\"count\": 1})",
|
||||
"note": "注释",
|
||||
@@ -591,7 +639,8 @@
|
||||
"disc": "碟片",
|
||||
"duration": "时长",
|
||||
"album": "$t(entity.album, {\"count\": 1})",
|
||||
"explicitStatus": "$t(common.explicitStatus)"
|
||||
"explicitStatus": "$t(common.explicitStatus)",
|
||||
"sortName": "排序名称"
|
||||
},
|
||||
"page": {
|
||||
"sidebar": {
|
||||
@@ -609,7 +658,8 @@
|
||||
"shared": "共享$t(entity.playlist, {\"count\": 2})",
|
||||
"myLibrary": "我的媒体库",
|
||||
"favorites": "$t(entity.favorite, {\"count\": 2})",
|
||||
"radio": "$t(entity.radioStation, {\"count\": 2})"
|
||||
"radio": "$t(entity.radioStation, {\"count\": 2})",
|
||||
"collections": "合集"
|
||||
},
|
||||
"fullscreenPlayer": {
|
||||
"config": {
|
||||
@@ -649,7 +699,8 @@
|
||||
"privateModeOn": "开启私人模式",
|
||||
"multipleMusicFolders": "已选择{{count}}个媒体库",
|
||||
"noMusicFolder": "未选择任何音乐库",
|
||||
"selectMusicFolder": "选择媒体库"
|
||||
"selectMusicFolder": "选择媒体库",
|
||||
"commandPalette": "打开命令面板"
|
||||
},
|
||||
"home": {
|
||||
"mostPlayed": "最多播放",
|
||||
@@ -687,7 +738,8 @@
|
||||
"discord": "Discord",
|
||||
"logger": "日志记录器",
|
||||
"queryBuilder": "查询构建器",
|
||||
"lyricsDisplay": "歌词显示"
|
||||
"lyricsDisplay": "歌词显示",
|
||||
"playerFilters": "播放筛选器"
|
||||
},
|
||||
"globalSearch": {
|
||||
"commands": {
|
||||
@@ -756,7 +808,8 @@
|
||||
"about": "关于{{artist}}",
|
||||
"appearsOn": "出现在",
|
||||
"viewAll": "查看全部",
|
||||
"groupingTypeAll": "所有发行类型"
|
||||
"groupingTypeAll": "所有发行类型",
|
||||
"groupingTypePrimary": "首选发布类型"
|
||||
},
|
||||
"itemDetail": {
|
||||
"copyPath": "将路径复制到剪贴板",
|
||||
@@ -779,6 +832,17 @@
|
||||
},
|
||||
"folderList": {
|
||||
"title": "$t(entity.folder, {\"count\": 2})"
|
||||
},
|
||||
"radioList": {
|
||||
"title": "广播电台"
|
||||
},
|
||||
"windowBar": {
|
||||
"paused": "(暂停) ",
|
||||
"privateMode": "(私人模式)"
|
||||
},
|
||||
"collections": {
|
||||
"overrideExisting": "覆盖现有",
|
||||
"saveAsCollection": "保存为集合"
|
||||
}
|
||||
},
|
||||
"form": {
|
||||
@@ -801,9 +865,9 @@
|
||||
"input_url": "url",
|
||||
"input_preferInstantMixDescription": "仅使用即时混音来获取类似的歌曲。如果您有修改此行为的插件,则很有用",
|
||||
"input_preferInstantMix": "首选即时混音",
|
||||
"input_preferRemoteUrl": "首选公共 URL",
|
||||
"input_remoteUrl": "公共 URL",
|
||||
"input_remoteUrlPlaceholder": "可选:对外功能的公共 URL"
|
||||
"input_preferRemoteUrl": "首选公共 url",
|
||||
"input_remoteUrl": "公共 url",
|
||||
"input_remoteUrlPlaceholder": "可选:对外功能的公共 url"
|
||||
},
|
||||
"addToPlaylist": {
|
||||
"success": "添加$t(entity.trackWithCount, {\"count\": {{message}} })到$t(entity.playlistWithCount, {\"count\": {{numOfPlaylists}} })",
|
||||
@@ -865,7 +929,9 @@
|
||||
"createRadioStation": {
|
||||
"input_homepageUrl": "首页地址",
|
||||
"input_name": "名称",
|
||||
"input_streamUrl": "串流地址"
|
||||
"input_streamUrl": "串流地址",
|
||||
"success": "电台创建成功",
|
||||
"title": "创建广播电台"
|
||||
},
|
||||
"lyricsExport": {
|
||||
"export": "导出歌词",
|
||||
@@ -882,7 +948,9 @@
|
||||
"input_maxYear": "截止年份",
|
||||
"input_minYear": "起始年份",
|
||||
"input_played_optionUnplayed": "仅未播放的曲目",
|
||||
"input_played_optionPlayed": "仅已播放的曲目"
|
||||
"input_played_optionPlayed": "仅已播放的曲目",
|
||||
"input_limit": "有多少首歌?",
|
||||
"input_played": "播放筛选器"
|
||||
}
|
||||
},
|
||||
"table": {
|
||||
@@ -916,7 +984,8 @@
|
||||
"advancedSettings": "高级设置",
|
||||
"autosize": "自动调整大小",
|
||||
"horizontalBorders": "行边框",
|
||||
"verticalBorders": "列边框"
|
||||
"verticalBorders": "列边框",
|
||||
"showHeader": "显示标题"
|
||||
},
|
||||
"view": {
|
||||
"table": "表格",
|
||||
@@ -940,10 +1009,10 @@
|
||||
"biography": "$t(common.biography)",
|
||||
"owner": "$t(common.owner)",
|
||||
"path": "$t(common.path)",
|
||||
"channels": "$t(common.channel_other)",
|
||||
"channels": "$t(common.channel, {\"count\": 2})",
|
||||
"playCount": "播放次数",
|
||||
"bitrate": "$t(common.bitrate)",
|
||||
"actions": "$t(common.action_other)",
|
||||
"actions": "$t(common.action, {\"count\": 2})",
|
||||
"genre": "$t(entity.genre, {\"count\": 1})",
|
||||
"discNumber": "碟片编号",
|
||||
"favorite": "$t(common.favorite)",
|
||||
@@ -956,7 +1025,9 @@
|
||||
"image": "图片",
|
||||
"bitDepth": "$t(common.bitDepth)",
|
||||
"sampleRate": "$t(common.sampleRate)",
|
||||
"genreBadge": "$t(entity.genre, {\"count\": 1})(徽章)"
|
||||
"genreBadge": "$t(entity.genre, {\"count\": 1})(徽章)",
|
||||
"composer": "作曲家",
|
||||
"titleArtist": "$t(common.title) (艺术家)"
|
||||
}
|
||||
},
|
||||
"column": {
|
||||
@@ -980,7 +1051,7 @@
|
||||
"genre": "$t(entity.genre, {\"count\": 1})",
|
||||
"albumArtist": "专辑艺术家",
|
||||
"path": "路径",
|
||||
"channels": "$t(common.channel_other)",
|
||||
"channels": "$t(common.channel, {\"count\": 2})",
|
||||
"discNumber": "碟片",
|
||||
"size": "$t(common.size)",
|
||||
"codec": "$t(common.codec)",
|
||||
@@ -1011,7 +1082,10 @@
|
||||
"mixtape": "混音专辑",
|
||||
"remix": "再混音(Remix)",
|
||||
"soundtrack": "原声带",
|
||||
"audioDrama": "广播剧"
|
||||
"audioDrama": "广播剧",
|
||||
"djMix": "DJ混音",
|
||||
"fieldRecording": "现场录制",
|
||||
"spokenWord": "访谈"
|
||||
}
|
||||
},
|
||||
"filterOperator": {
|
||||
@@ -1032,7 +1106,8 @@
|
||||
"notContains": "不包含",
|
||||
"startsWith": "以…开头",
|
||||
"inTheRangeDate": "在(日期)范围内",
|
||||
"notInPlaylist": "不在…中"
|
||||
"notInPlaylist": "不在…中",
|
||||
"notInTheLast": "不在最后"
|
||||
},
|
||||
"datetime": {
|
||||
"minuteShort": "分",
|
||||
@@ -1085,6 +1160,85 @@
|
||||
"builtIn": "内置",
|
||||
"colors": "颜色",
|
||||
"gradient": "渐变",
|
||||
"miscellaneousSettings": "杂项设置"
|
||||
"miscellaneousSettings": "杂项设置",
|
||||
"options": {
|
||||
"channelLayout": {
|
||||
"single": "单项"
|
||||
},
|
||||
"mode": {
|
||||
"0": "[0] 离散频率"
|
||||
},
|
||||
"colorMode": {
|
||||
"gradient": "渐变"
|
||||
},
|
||||
"gradient": {
|
||||
"classic": "经典",
|
||||
"prism": "棱镜",
|
||||
"rainbow": "彩虹"
|
||||
},
|
||||
"frequencyScale": {
|
||||
"none": "无"
|
||||
},
|
||||
"weightingFilter": {
|
||||
"none": "无",
|
||||
"a": "A",
|
||||
"b": "B",
|
||||
"c": "C",
|
||||
"d": "D",
|
||||
"z": "Z"
|
||||
}
|
||||
},
|
||||
"cyclePresets": "循环预设",
|
||||
"includeAllPresets": "包含所有预设",
|
||||
"ignoredPresets": "忽略预设",
|
||||
"selectedPresets": "已选预设",
|
||||
"randomizeNextPreset": "随机化下一个预设",
|
||||
"blendTime": "混合时间",
|
||||
"barSpace": "住间距",
|
||||
"colorStops": "颜色停止",
|
||||
"level": "等级",
|
||||
"colorMode": "颜色模式",
|
||||
"gradientLeft": "左侧渐变",
|
||||
"gradientRight": "右侧渐变",
|
||||
"fft": "FFT",
|
||||
"fftSize": "FFT 大小",
|
||||
"smoothing": "平滑",
|
||||
"frequencyRangeAndScaling": "频率范围和缩放",
|
||||
"minimumFrequency": "最低频率",
|
||||
"maximumFrequency": "最大频率",
|
||||
"frequencyScale": "频率尺度",
|
||||
"sensitivity": "灵敏度",
|
||||
"weightingFilter": "加权滤波器",
|
||||
"minimumDecibels": "最低分贝",
|
||||
"maximumDecibels": "最大分贝",
|
||||
"linearAmplitude": "线性振幅",
|
||||
"linearBoost": "线性增强",
|
||||
"peakBehavior": "峰值行为",
|
||||
"showPeaks": "显示峰值",
|
||||
"fadePeaks": "峰值淡出",
|
||||
"peakLine": "峰值线条",
|
||||
"gravity": "重力",
|
||||
"peakFadeTime": "峰值淡出时间(毫秒)",
|
||||
"peakHoldTime": "峰值保持时间(毫秒)",
|
||||
"radialSpectrum": "圆形频谱",
|
||||
"radial": "径向",
|
||||
"radialInvert": "径向反转",
|
||||
"spinSpeed": "旋转速度",
|
||||
"radius": "半径",
|
||||
"reflexMirror": "反射镜",
|
||||
"reflexFit": "反射贴合",
|
||||
"reflexRatio": "反射比率",
|
||||
"reflexAlpha": "反射Alpha",
|
||||
"reflexBrightness": "反射亮度",
|
||||
"mirror": "镜像",
|
||||
"lowResolution": "低分辨率",
|
||||
"splitGradient": "渐变分割",
|
||||
"showScaleX": "显示比例尺 X",
|
||||
"noteLabels": "笔记标签",
|
||||
"showScaleY": "显示比例尺 Y"
|
||||
},
|
||||
"queryBuilder": {
|
||||
"standardTags": "标准标签",
|
||||
"customTags": "自定义标签"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
"ascending": "升冪",
|
||||
"disable": "禁用",
|
||||
"disc": "光碟",
|
||||
"dismiss": "忽略",
|
||||
"dismiss": "不再顯示",
|
||||
"duration": "時長",
|
||||
"edit": "編輯",
|
||||
"enable": "啟用",
|
||||
@@ -113,7 +113,9 @@
|
||||
"mood": "情緒",
|
||||
"view": "查看",
|
||||
"rename": "重新命名",
|
||||
"itemsMore": "{{count}} 更多"
|
||||
"itemsMore": "{{count}} 更多",
|
||||
"filter_single": "單選",
|
||||
"filter_multiple": "複選"
|
||||
},
|
||||
"error": {
|
||||
"endpointNotImplementedError": "{{serverType}} 尚未實現端點 {{endpoint}}",
|
||||
@@ -338,6 +340,9 @@
|
||||
},
|
||||
"radioList": {
|
||||
"title": "電台"
|
||||
},
|
||||
"windowBar": {
|
||||
"paused": "(暫停) "
|
||||
}
|
||||
},
|
||||
"player": {
|
||||
@@ -490,7 +495,7 @@
|
||||
"scrobble_description": "在你的媒體伺服器中記錄播放資訊",
|
||||
"showSkipButton": "顯示跳過按鈕",
|
||||
"showSkipButton_description": "在播放條上顯示/隱藏跳過按鈕",
|
||||
"sidebarPlaylistList": "側邊欄歌單清單",
|
||||
"sidebarPlaylistList": "側邊欄播放清單列表",
|
||||
"sidebarCollapsedNavigation": "側邊欄(已折疊)導航",
|
||||
"sidebarCollapsedNavigation_description": "在折疊的側邊欄中顯示或隱藏導航",
|
||||
"sidebarConfiguration": "側邊欄設定",
|
||||
@@ -709,7 +714,8 @@
|
||||
"pathReplace": "檔案路徑替換",
|
||||
"pathReplace_description": "替換您伺服器的預設檔案路徑",
|
||||
"pathReplace_optionRemovePrefix": "移除前綴",
|
||||
"pathReplace_optionAddPrefix": "增加前綴"
|
||||
"pathReplace_optionAddPrefix": "增加前綴",
|
||||
"sidebarPlaylistSorting": "側邊欄播放清單排序"
|
||||
},
|
||||
"table": {
|
||||
"config": {
|
||||
@@ -1049,7 +1055,8 @@
|
||||
"live": "Live",
|
||||
"mixtape": "混音帶",
|
||||
"remix": "Remix",
|
||||
"soundtrack": "原聲帶"
|
||||
"soundtrack": "原聲帶",
|
||||
"spokenWord": "訪談"
|
||||
}
|
||||
},
|
||||
"dragDropZone": {
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { createSocket } from 'dgram';
|
||||
import { ipcMain } from 'electron';
|
||||
|
||||
import { DiscoveredServerItem, ServerType } from '/@/shared/types/types';
|
||||
import { ServerType } from '/@/shared/types/domain-types';
|
||||
import { DiscoveredServerItem } from '/@/shared/types/types';
|
||||
|
||||
type JellyfinResponse = {
|
||||
Address: string;
|
||||
|
||||
@@ -4,3 +4,4 @@ import './player';
|
||||
import './remote';
|
||||
import './settings';
|
||||
import './discord-rpc';
|
||||
import './youtube';
|
||||
|
||||
@@ -38,6 +38,7 @@ export const store = new Store<any>({
|
||||
lyrics: ['NetEase', 'lrclib.net'],
|
||||
mediaSession: false,
|
||||
playbackType: 'web',
|
||||
renderer_server_port: 38472,
|
||||
should_prompt_accessibility: true,
|
||||
shown_accessibility_warning: false,
|
||||
window_enable_tray: true,
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import { ipcMain } from 'electron';
|
||||
import YTMusic from 'ytmusic-api';
|
||||
|
||||
let youtubeApi: InstanceType<typeof YTMusic> | null = null;
|
||||
|
||||
const getYoutubeApi = async (): Promise<InstanceType<typeof YTMusic>> => {
|
||||
if (!youtubeApi) {
|
||||
youtubeApi = new YTMusic();
|
||||
await youtubeApi.initialize();
|
||||
}
|
||||
return youtubeApi;
|
||||
};
|
||||
|
||||
ipcMain.handle('youtube-search', async (_event, query: string) => {
|
||||
const api = await getYoutubeApi();
|
||||
const results = await api.search(query);
|
||||
return results;
|
||||
});
|
||||
+174
-30
@@ -18,7 +18,8 @@ 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 express from 'express';
|
||||
import { access, constants } from 'fs';
|
||||
import path, { join } from 'path';
|
||||
|
||||
@@ -40,40 +41,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) => {
|
||||
@@ -147,6 +219,37 @@ const getAssetPath = (...paths: string[]): string => {
|
||||
return path.join(RESOURCES_PATH, ...paths);
|
||||
};
|
||||
|
||||
const DEFAULT_RENDERER_SERVER_PORT = 38472;
|
||||
|
||||
const getRendererServerPort = (): number => {
|
||||
const port = Number(store.get('renderer_server_port', DEFAULT_RENDERER_SERVER_PORT));
|
||||
if (!Number.isInteger(port) || port < 1024 || port > 65535) {
|
||||
return DEFAULT_RENDERER_SERVER_PORT;
|
||||
}
|
||||
return port;
|
||||
};
|
||||
|
||||
let rendererServerUrl: null | string = null;
|
||||
let rendererHttpServer: null | ReturnType<express.Application['listen']> = null;
|
||||
|
||||
const startRendererServer = (): Promise<string> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (rendererServerUrl) {
|
||||
resolve(rendererServerUrl);
|
||||
return;
|
||||
}
|
||||
const port = getRendererServerPort();
|
||||
const rendererPath = join(__dirname, '../renderer');
|
||||
const app = express();
|
||||
app.use(express.static(rendererPath));
|
||||
rendererHttpServer = app.listen(port, () => {
|
||||
rendererServerUrl = `http://localhost:${port}`;
|
||||
resolve(rendererServerUrl);
|
||||
});
|
||||
rendererHttpServer.on('error', reject);
|
||||
});
|
||||
};
|
||||
|
||||
export const getMainWindow = () => {
|
||||
return mainWindow;
|
||||
};
|
||||
@@ -359,6 +462,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) {
|
||||
@@ -479,12 +612,11 @@ async function createWindow(first = true): Promise<void> {
|
||||
return { action: 'deny' };
|
||||
});
|
||||
|
||||
// HMR for renderer base on electron-vite cli.
|
||||
// Load the remote URL for development or the local html file for production.
|
||||
// HMR for renderer: use Vite dev server URL in development, otherwise the local HTTP server.
|
||||
if (is.dev && process.env['ELECTRON_RENDERER_URL']) {
|
||||
mainWindow.loadURL(process.env['ELECTRON_RENDERER_URL']);
|
||||
} else {
|
||||
mainWindow.loadFile(join(__dirname, '../renderer/index.html'));
|
||||
mainWindow.loadURL(rendererServerUrl!);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -638,6 +770,14 @@ app.on('window-all-closed', () => {
|
||||
}
|
||||
});
|
||||
|
||||
app.on('before-quit', () => {
|
||||
if (rendererHttpServer) {
|
||||
rendererHttpServer.close();
|
||||
rendererHttpServer = null;
|
||||
rendererServerUrl = null;
|
||||
}
|
||||
});
|
||||
|
||||
const FONT_HEADERS = [
|
||||
'font/collection',
|
||||
'font/otf',
|
||||
@@ -665,7 +805,7 @@ if (!singleInstance) {
|
||||
});
|
||||
|
||||
app.whenReady()
|
||||
.then(() => {
|
||||
.then(async () => {
|
||||
protocol.handle('feishin', async (request) => {
|
||||
const filePath = `file://${request.url.slice('feishin://'.length)}`;
|
||||
const response = await net.fetch(filePath);
|
||||
@@ -683,6 +823,10 @@ if (!singleInstance) {
|
||||
return response;
|
||||
});
|
||||
|
||||
if (!(is.dev && process.env['ELECTRON_RENDERER_URL'])) {
|
||||
await startRendererServer();
|
||||
}
|
||||
|
||||
createWindow();
|
||||
if (store.get('window_enable_tray', true)) {
|
||||
createTray();
|
||||
|
||||
@@ -11,6 +11,7 @@ import { mpris } from './mpris';
|
||||
import { mpvPlayer, mpvPlayerListener } from './mpv-player';
|
||||
import { remote } from './remote';
|
||||
import { utils } from './utils';
|
||||
import { youtube } from './youtube';
|
||||
|
||||
// Custom APIs for renderer
|
||||
const api = {
|
||||
@@ -25,6 +26,7 @@ const api = {
|
||||
mpvPlayerListener,
|
||||
remote,
|
||||
utils,
|
||||
youtube,
|
||||
};
|
||||
|
||||
export type PreloadApi = typeof api;
|
||||
|
||||
@@ -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') {
|
||||
@@ -58,6 +62,7 @@ const forceGarbageCollection = (): boolean => {
|
||||
};
|
||||
|
||||
export const utils = {
|
||||
checkForUpdates,
|
||||
disableAutoUpdates,
|
||||
download,
|
||||
forceGarbageCollection,
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import { ipcRenderer } from 'electron';
|
||||
|
||||
const search = (query: string) => {
|
||||
return ipcRenderer.invoke('youtube-search', query);
|
||||
};
|
||||
|
||||
export const youtube = {
|
||||
search,
|
||||
};
|
||||
|
||||
export type Youtube = typeof youtube;
|
||||
@@ -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',
|
||||
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: 'Genres, Overview, SortName, ProviderIds',
|
||||
},
|
||||
}),
|
||||
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: 'Genres, DateCreated, ExternalUrls, Overview, SortName, ProviderIds',
|
||||
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: 'Genres, DateCreated, MediaSources, ParentId, People, Tags, SortName, ProviderIds',
|
||||
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,7 +1108,7 @@ 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,
|
||||
@@ -1127,7 +1143,7 @@ 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,
|
||||
@@ -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) {
|
||||
@@ -263,6 +270,32 @@ export const queryKeys: Record<
|
||||
},
|
||||
root: (serverId: string) => [serverId, 'genres'] as const,
|
||||
},
|
||||
musicbrainz: {
|
||||
artist: (
|
||||
limit: number | undefined,
|
||||
mbzArtistId: string,
|
||||
config?: {
|
||||
autoCountryPriority: boolean;
|
||||
excludeReleaseTypes: string[];
|
||||
prioritizeCountries: string[];
|
||||
},
|
||||
) =>
|
||||
[
|
||||
'musicbrainz',
|
||||
'artist',
|
||||
mbzArtistId,
|
||||
limit,
|
||||
config
|
||||
? [
|
||||
String(config.autoCountryPriority),
|
||||
[...config.excludeReleaseTypes].sort().join(','),
|
||||
[...config.prioritizeCountries].sort().join(','),
|
||||
]
|
||||
: null,
|
||||
] as const,
|
||||
release: (releaseId: string) => ['musicbrainz', 'release', releaseId] as const,
|
||||
root: () => ['musicbrainz'] as const,
|
||||
},
|
||||
musicFolders: {
|
||||
list: (serverId: string) => [serverId, 'musicFolders', 'list'] as const,
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -11,6 +11,7 @@ import { lazy, Suspense, useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import i18n from '/@/i18n/i18n';
|
||||
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 +39,7 @@ export const App = () => {
|
||||
const cssRef = useRef<HTMLStyleElement | null>(null);
|
||||
|
||||
useSyncSettingsToMain();
|
||||
useCheckForUpdates();
|
||||
|
||||
const [webAudio, setWebAudio] = useState<WebAudio>();
|
||||
|
||||
|
||||
@@ -32,8 +32,10 @@ export const DragPreview = memo(({ data }: DragPreviewProps) => {
|
||||
const itemName = firstItem ? getItemName(firstItem) : 'Item';
|
||||
|
||||
const itemImage = useItemImageUrl({
|
||||
id: (firstItem as { imageId: string })?.imageId,
|
||||
id: (firstItem as { imageId?: string })?.imageId,
|
||||
imageUrl: (firstItem as { imageUrl?: string })?.imageUrl,
|
||||
itemType: data.itemType || LibraryItem.SONG,
|
||||
serverId: (firstItem as { _serverId?: string })?._serverId,
|
||||
type: 'table',
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -58,12 +59,33 @@
|
||||
}
|
||||
}
|
||||
|
||||
.image-container.external {
|
||||
img {
|
||||
opacity: 0.5;
|
||||
transition: all 0.2s ease-in-out;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
img {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.image-container.is-round {
|
||||
&::before {
|
||||
border-radius: 50%;
|
||||
}
|
||||
}
|
||||
|
||||
.image-container.no-hover-overlay {
|
||||
&:hover {
|
||||
&::before {
|
||||
opacity: 0 !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.favorite-badge {
|
||||
position: absolute;
|
||||
top: -50px;
|
||||
@@ -99,9 +121,19 @@
|
||||
transition: opacity 0.2s ease-in-out;
|
||||
}
|
||||
|
||||
.image-container:hover .favorite-badge,
|
||||
.image-container:hover .rating-badge {
|
||||
opacity: 0;
|
||||
.external-badge {
|
||||
position: absolute;
|
||||
bottom: var(--theme-spacing-sm);
|
||||
left: var(--theme-spacing-sm);
|
||||
z-index: 5;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: var(--theme-spacing-xs) var(--theme-spacing-sm);
|
||||
pointer-events: none;
|
||||
background-color: alpha(var(--theme-colors-state-error), 0.85);
|
||||
border-radius: var(--theme-radius-md);
|
||||
box-shadow: 0 2px 8px rgb(0 0 0 / 30%);
|
||||
}
|
||||
|
||||
.image {
|
||||
|
||||
@@ -19,7 +19,7 @@ import { ItemControls } from '/@/renderer/components/item-list/types';
|
||||
import { JoinedArtists } from '/@/renderer/features/albums/components/joined-artists';
|
||||
import { useDragDrop } from '/@/renderer/hooks/use-drag-drop';
|
||||
import { AppRoute } from '/@/renderer/router/routes';
|
||||
import { useShowRatings } from '/@/renderer/store';
|
||||
import { useIntegrationsSettings, useShowRatings } from '/@/renderer/store';
|
||||
import {
|
||||
formatDateAbsolute,
|
||||
formatDateAbsoluteUTC,
|
||||
@@ -28,6 +28,8 @@ import {
|
||||
formatRating,
|
||||
} from '/@/renderer/utils/format';
|
||||
import { SEPARATOR_STRING } from '/@/shared/api/utils';
|
||||
import { ExplicitIndicator } from '/@/shared/components/explicit-indicator/explicit-indicator';
|
||||
import { ExternalSongIndicator } from '/@/shared/components/external-song-indicator/external-song-indicator';
|
||||
import { Group } from '/@/shared/components/group/group';
|
||||
import { Icon } from '/@/shared/components/icon/icon';
|
||||
import { Separator } from '/@/shared/components/separator/separator';
|
||||
@@ -41,6 +43,7 @@ import {
|
||||
Genre,
|
||||
LibraryItem,
|
||||
Playlist,
|
||||
ServerType,
|
||||
Song,
|
||||
} from '/@/shared/types/domain-types';
|
||||
import { DragOperation, DragTarget } from '/@/shared/types/drag-and-drop';
|
||||
@@ -177,6 +180,7 @@ const CompactItemCard = ({
|
||||
showRating,
|
||||
withControls,
|
||||
}: ItemCardDerivativeProps) => {
|
||||
const { youtube: youtubeIntegrationEnabled } = useIntegrationsSettings();
|
||||
const [showControls, setShowControls] = useState(false);
|
||||
const itemRowId =
|
||||
data && internalState && typeof data === 'object' && 'id' in data
|
||||
@@ -338,9 +342,15 @@ const CompactItemCard = ({
|
||||
? (data as { userRating: null | number }).userRating
|
||||
: null;
|
||||
const hasRating = showRating && userRating !== null && userRating > 0;
|
||||
const isExternal = data._serverType === ServerType.EXTERNAL;
|
||||
|
||||
const showItemCardControls =
|
||||
withControls && showControls && data && (!isExternal || youtubeIntegrationEnabled);
|
||||
|
||||
const imageContainerClassName = clsx(styles.imageContainer, {
|
||||
[styles.external]: isExternal,
|
||||
[styles.isRound]: isRound,
|
||||
[styles.noHoverOverlay]: isExternal && !showItemCardControls,
|
||||
});
|
||||
|
||||
const imageContainerContent = (
|
||||
@@ -361,6 +371,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}
|
||||
@@ -369,8 +382,13 @@ const CompactItemCard = ({
|
||||
)}
|
||||
{isFavorite && <div className={styles.favoriteBadge} />}
|
||||
{hasRating && <div className={styles.ratingBadge}>{userRating}</div>}
|
||||
{isExternal && (
|
||||
<div className={styles.externalBadge} title={i18n.t('common.external')}>
|
||||
<ExternalSongIndicator isExternal size="sm" withSpace={false} />
|
||||
</div>
|
||||
)}
|
||||
<AnimatePresence>
|
||||
{withControls && showControls && data && (
|
||||
{showItemCardControls && (
|
||||
<ItemCardControls
|
||||
controls={controls}
|
||||
enableExpansion={enableExpansion}
|
||||
@@ -405,6 +423,7 @@ const CompactItemCard = ({
|
||||
<div
|
||||
className={clsx(styles.container, styles.compact, {
|
||||
[styles.dragging]: isDragging,
|
||||
[styles.external]: isExternal,
|
||||
[styles.selected]: isSelected,
|
||||
})}
|
||||
ref={ref}
|
||||
@@ -478,6 +497,7 @@ const DefaultItemCard = ({
|
||||
showRating,
|
||||
withControls,
|
||||
}: ItemCardDerivativeProps) => {
|
||||
const { youtube: youtubeIntegrationEnabled } = useIntegrationsSettings();
|
||||
const [showControls, setShowControls] = useState(false);
|
||||
const itemRowId =
|
||||
data && internalState && typeof data === 'object' && 'id' in data
|
||||
@@ -566,10 +586,6 @@ const DefaultItemCard = ({
|
||||
e.stopPropagation();
|
||||
};
|
||||
|
||||
const imageContainerClassName = clsx(styles.imageContainer, {
|
||||
[styles.isRound]: isRound,
|
||||
});
|
||||
|
||||
const isFavorite =
|
||||
'userFavorite' in data && (data as { userFavorite: boolean }).userFavorite;
|
||||
const userRating =
|
||||
@@ -578,6 +594,16 @@ const DefaultItemCard = ({
|
||||
? (data as { userRating: null | number }).userRating
|
||||
: null;
|
||||
const hasRating = showRating && userRating !== null && userRating > 0;
|
||||
const isExternal = data._serverType === ServerType.EXTERNAL;
|
||||
|
||||
const showItemCardControls =
|
||||
withControls && showControls && data && (!isExternal || youtubeIntegrationEnabled);
|
||||
|
||||
const imageContainerClassName = clsx(styles.imageContainer, {
|
||||
[styles.external]: isExternal,
|
||||
[styles.isRound]: isRound,
|
||||
[styles.noHoverOverlay]: isExternal && !showItemCardControls,
|
||||
});
|
||||
|
||||
const imageContainerContent = (
|
||||
<>
|
||||
@@ -595,6 +621,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}
|
||||
@@ -603,8 +632,13 @@ const DefaultItemCard = ({
|
||||
)}
|
||||
{isFavorite && <div className={styles.favoriteBadge} />}
|
||||
{hasRating && <div className={styles.ratingBadge}>{userRating}</div>}
|
||||
{isExternal && (
|
||||
<div className={styles.externalBadge} title={i18n.t('common.external')}>
|
||||
<ExternalSongIndicator isExternal size="sm" withSpace={false} />
|
||||
</div>
|
||||
)}
|
||||
<AnimatePresence>
|
||||
{withControls && showControls && (
|
||||
{showItemCardControls && (
|
||||
<ItemCardControls
|
||||
controls={controls}
|
||||
enableExpansion={enableExpansion}
|
||||
@@ -621,6 +655,7 @@ const DefaultItemCard = ({
|
||||
return (
|
||||
<div
|
||||
className={clsx(styles.container, {
|
||||
[styles.external]: isExternal,
|
||||
[styles.selected]: isSelected,
|
||||
})}
|
||||
>
|
||||
@@ -710,6 +745,7 @@ const PosterItemCard = ({
|
||||
showRating,
|
||||
withControls,
|
||||
}: ItemCardDerivativeProps) => {
|
||||
const { youtube: youtubeIntegrationEnabled } = useIntegrationsSettings();
|
||||
const [showControls, setShowControls] = useState(false);
|
||||
const itemRowId =
|
||||
data && internalState && typeof data === 'object' && 'id' in data
|
||||
@@ -863,10 +899,6 @@ const PosterItemCard = ({
|
||||
e.stopPropagation();
|
||||
};
|
||||
|
||||
const imageContainerClassName = clsx(styles.imageContainer, {
|
||||
[styles.isRound]: isRound,
|
||||
});
|
||||
|
||||
const isFavorite =
|
||||
'userFavorite' in data && (data as { userFavorite: boolean }).userFavorite;
|
||||
const userRating =
|
||||
@@ -875,6 +907,16 @@ const PosterItemCard = ({
|
||||
? (data as { userRating: null | number }).userRating
|
||||
: null;
|
||||
const hasRating = showRating && userRating !== null && userRating > 0;
|
||||
const isExternal = data._serverType === ServerType.EXTERNAL;
|
||||
|
||||
const showItemCardControls =
|
||||
withControls && showControls && data && (!isExternal || youtubeIntegrationEnabled);
|
||||
|
||||
const imageContainerClassName = clsx(styles.imageContainer, {
|
||||
[styles.external]: isExternal,
|
||||
[styles.isRound]: isRound,
|
||||
[styles.noHoverOverlay]: isExternal && !showItemCardControls,
|
||||
});
|
||||
|
||||
const imageContainerContent = (
|
||||
<>
|
||||
@@ -892,6 +934,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}
|
||||
@@ -900,8 +945,13 @@ const PosterItemCard = ({
|
||||
)}
|
||||
{isFavorite && <div className={styles.favoriteBadge} />}
|
||||
{hasRating && <div className={styles.ratingBadge}>{userRating}</div>}
|
||||
{isExternal && (
|
||||
<div className={styles.externalBadge}>
|
||||
<ExternalSongIndicator isExternal size="xl" withSpace={false} />
|
||||
</div>
|
||||
)}
|
||||
<AnimatePresence>
|
||||
{withControls && showControls && data && (
|
||||
{showItemCardControls && (
|
||||
<ItemCardControls
|
||||
controls={controls}
|
||||
enableExpansion={enableExpansion}
|
||||
@@ -920,6 +970,7 @@ const PosterItemCard = ({
|
||||
<div
|
||||
className={clsx(styles.container, styles.poster, {
|
||||
[styles.dragging]: isDragging,
|
||||
[styles.external]: isExternal,
|
||||
[styles.selected]: isSelected,
|
||||
})}
|
||||
ref={ref}
|
||||
@@ -1010,21 +1061,25 @@ 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) {
|
||||
switch (data._itemType) {
|
||||
case LibraryItem.ALBUM:
|
||||
return (
|
||||
<Link
|
||||
state={{ item: data }}
|
||||
to={generatePath(AppRoute.LIBRARY_ALBUMS_DETAIL, {
|
||||
albumId: data.id,
|
||||
})}
|
||||
>
|
||||
case LibraryItem.ALBUM: {
|
||||
const albumPath = getTitlePath(LibraryItem.ALBUM, data.id);
|
||||
return albumPath ? (
|
||||
<Link state={{ item: data }} to={albumPath}>
|
||||
<ExplicitIndicator explicitStatus={explicitStatus} />
|
||||
{data.name}
|
||||
</Link>
|
||||
) : (
|
||||
<>
|
||||
<ExplicitIndicator explicitStatus={explicitStatus} />
|
||||
{data.name}
|
||||
</>
|
||||
);
|
||||
}
|
||||
case LibraryItem.ALBUM_ARTIST:
|
||||
return (
|
||||
<Link
|
||||
@@ -1036,6 +1091,7 @@ export const getDataRows = (type?: 'compact' | 'default' | 'poster'): DataRow[]
|
||||
},
|
||||
)}
|
||||
>
|
||||
<ExplicitIndicator explicitStatus={explicitStatus} />
|
||||
{data.name}
|
||||
</Link>
|
||||
);
|
||||
@@ -1062,11 +1118,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 '';
|
||||
},
|
||||
@@ -1310,7 +1376,6 @@ const getItemNavigationPath = (
|
||||
}
|
||||
|
||||
const effectiveItemType = '_itemType' in data && data._itemType ? data._itemType : itemType;
|
||||
|
||||
return getTitlePath(effectiveItemType, data.id);
|
||||
};
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -8,7 +8,7 @@ import { ContextMenuController } from '/@/renderer/features/context-menu/context
|
||||
import { usePlayer } from '/@/renderer/features/player/context/player-context';
|
||||
import { useSetFavorite } from '/@/renderer/features/shared/hooks/use-set-favorite';
|
||||
import { useSetRating } from '/@/renderer/features/shared/hooks/use-set-rating';
|
||||
import { LibraryItem, QueueSong, Song } from '/@/shared/types/domain-types';
|
||||
import { LibraryItem, QueueSong, ServerType, Song } from '/@/shared/types/domain-types';
|
||||
import { Play, TableColumn } from '/@/shared/types/types';
|
||||
|
||||
interface UseDefaultItemListControlsArgs {
|
||||
@@ -384,6 +384,15 @@ export const useDefaultItemListControls = (args?: UseDefaultItemListControlsArgs
|
||||
return;
|
||||
}
|
||||
|
||||
const isExternal =
|
||||
(item as Song & { _serverType?: ServerType })._serverType ===
|
||||
ServerType.EXTERNAL;
|
||||
|
||||
if (isExternal && itemType === LibraryItem.SONG) {
|
||||
player.addToQueueByData([item as Song], playType, item.id);
|
||||
return;
|
||||
}
|
||||
|
||||
player.addToQueueByFetch(item._serverId, [item.id], itemType, playType);
|
||||
},
|
||||
|
||||
@@ -417,9 +426,9 @@ export const useDefaultItemListControls = (args?: UseDefaultItemListControlsArgs
|
||||
};
|
||||
}, [
|
||||
enableMultiSelect,
|
||||
overrides,
|
||||
onColumnReordered,
|
||||
onColumnResized,
|
||||
overrides,
|
||||
player,
|
||||
setFavorite,
|
||||
setRating,
|
||||
|
||||
@@ -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 };
|
||||
};
|
||||
|
||||
@@ -109,6 +109,7 @@ export interface ItemListStateItem {
|
||||
_itemType: LibraryItem;
|
||||
_serverId: string;
|
||||
id: string;
|
||||
imageId: null | string;
|
||||
}
|
||||
|
||||
export type ItemListStateItemWithRequiredProperties = Record<string, unknown> & {
|
||||
|
||||
@@ -90,6 +90,7 @@ const ImageColumnBase = (props: ItemTableListInnerColumn) => {
|
||||
})}
|
||||
enableDebounce={true}
|
||||
enableViewport={false}
|
||||
explicitStatus={item?.explicitStatus}
|
||||
id={item?.imageId}
|
||||
itemType={item?._itemType}
|
||||
src={item?.imageUrl}
|
||||
|
||||
+1
-3
@@ -5,8 +5,6 @@
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
|
||||
|
||||
.text-container {
|
||||
display: grid;
|
||||
grid-template-rows: 1fr 1fr;
|
||||
@@ -43,7 +41,6 @@ a.title {
|
||||
width: auto;
|
||||
}
|
||||
|
||||
|
||||
.artists {
|
||||
display: block;
|
||||
width: 100%;
|
||||
@@ -65,3 +62,4 @@ a.title {
|
||||
.active {
|
||||
color: var(--theme-colors-primary);
|
||||
}
|
||||
|
||||
|
||||
@@ -13,9 +13,11 @@ 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 { ExternalSongIndicator } from '/@/shared/components/external-song-indicator/external-song-indicator';
|
||||
import { Icon } from '/@/shared/components/icon/icon';
|
||||
import { Text } from '/@/shared/components/text/text';
|
||||
import { Folder, LibraryItem, QueueSong } from '/@/shared/types/domain-types';
|
||||
import { Folder, LibraryItem, QueueSong, ServerType } from '/@/shared/types/domain-types';
|
||||
|
||||
export const DefaultTitleArtistColumn = (props: ItemTableListInnerColumn) => {
|
||||
const rowItem = props.getRowItem?.(props.rowIndex) ?? (props.data as any[])[props.rowIndex];
|
||||
@@ -52,6 +54,11 @@ export const DefaultTitleArtistColumn = (props: ItemTableListInnerColumn) => {
|
||||
})}
|
||||
>
|
||||
<Text className={styles.title} isNoSelect size="md" {...titleLinkProps}>
|
||||
<ExplicitIndicator explicitStatus={item?.explicitStatus} />
|
||||
<ExternalSongIndicator
|
||||
isExternal={item?._serverType === ServerType.EXTERNAL}
|
||||
size="sm"
|
||||
/>
|
||||
{item.name as string}
|
||||
</Text>
|
||||
<div className={styles.artists}>
|
||||
@@ -120,6 +127,11 @@ export const QueueSongTitleArtistColumn = (props: ItemTableListInnerColumn) => {
|
||||
size="md"
|
||||
{...titleLinkProps}
|
||||
>
|
||||
<ExplicitIndicator explicitStatus={song?.explicitStatus} />
|
||||
<ExternalSongIndicator
|
||||
isExternal={song?._serverType === ServerType.EXTERNAL}
|
||||
size="sm"
|
||||
/>
|
||||
{row.name as string}
|
||||
{song?.trackSubtitle && props.itemType !== LibraryItem.QUEUE_SONG && (
|
||||
<Text
|
||||
|
||||
@@ -23,3 +23,4 @@ a.name-container {
|
||||
.active {
|
||||
color: var(--theme-colors-primary);
|
||||
}
|
||||
|
||||
|
||||
@@ -11,8 +11,10 @@ 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 { ExternalSongIndicator } from '/@/shared/components/external-song-indicator/external-song-indicator';
|
||||
import { Text } from '/@/shared/components/text/text';
|
||||
import { LibraryItem, QueueSong } from '/@/shared/types/domain-types';
|
||||
import { LibraryItem, QueueSong, ServerType } from '/@/shared/types/domain-types';
|
||||
|
||||
const TitleColumnBase = (props: ItemTableListInnerColumn) => {
|
||||
const { itemType } = props;
|
||||
@@ -58,6 +60,11 @@ function DefaultTitleColumn(props: ItemTableListInnerColumn) {
|
||||
isNoSelect
|
||||
{...titleLinkProps}
|
||||
>
|
||||
<ExplicitIndicator explicitStatus={item?.explicitStatus} />
|
||||
<ExternalSongIndicator
|
||||
isExternal={item?._serverType === ServerType.EXTERNAL}
|
||||
size="sm"
|
||||
/>
|
||||
{row}
|
||||
</Text>
|
||||
</TableColumnContainer>
|
||||
@@ -103,6 +110,8 @@ function QueueSongTitleColumn(props: ItemTableListInnerColumn) {
|
||||
isNoSelect
|
||||
{...titleLinkProps}
|
||||
>
|
||||
<ExplicitIndicator explicitStatus={song?.explicitStatus} />
|
||||
<ExternalSongIndicator isExternal={song?._serverType === ServerType.EXTERNAL} />
|
||||
{row}
|
||||
{song?.trackSubtitle && props.itemType !== LibraryItem.QUEUE_SONG && (
|
||||
<Text
|
||||
|
||||
+1
-1
@@ -42,7 +42,6 @@ a.title {
|
||||
width: auto;
|
||||
}
|
||||
|
||||
|
||||
.artists {
|
||||
display: block;
|
||||
width: 100%;
|
||||
@@ -94,3 +93,4 @@ a.title {
|
||||
.active {
|
||||
color: var(--theme-colors-primary);
|
||||
}
|
||||
|
||||
|
||||
+15
-1
@@ -20,9 +20,11 @@ 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 { ExternalSongIndicator } from '/@/shared/components/external-song-indicator/external-song-indicator';
|
||||
import { Icon } from '/@/shared/components/icon/icon';
|
||||
import { Text } from '/@/shared/components/text/text';
|
||||
import { Folder, LibraryItem, QueueSong } from '/@/shared/types/domain-types';
|
||||
import { Folder, LibraryItem, QueueSong, ServerType } from '/@/shared/types/domain-types';
|
||||
import { Play } from '/@/shared/types/types';
|
||||
|
||||
export const DefaultTitleCombinedColumn = (props: ItemTableListInnerColumn) => {
|
||||
@@ -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,11 @@ export const DefaultTitleCombinedColumn = (props: ItemTableListInnerColumn) => {
|
||||
})}
|
||||
>
|
||||
<Text className={styles.title} isNoSelect size="md" {...titleLinkProps}>
|
||||
<ExplicitIndicator explicitStatus={item?.explicitStatus} />
|
||||
<ExternalSongIndicator
|
||||
isExternal={item?._serverType === ServerType.EXTERNAL}
|
||||
size="sm"
|
||||
/>
|
||||
{item.name as string}
|
||||
</Text>
|
||||
<div className={styles.artists}>
|
||||
@@ -244,6 +252,7 @@ export const QueueSongTitleCombinedColumn = (props: ItemTableListInnerColumn) =>
|
||||
>
|
||||
<ItemImage
|
||||
containerClassName={styles.image}
|
||||
explicitStatus={item?.explicitStatus}
|
||||
id={item?.imageId}
|
||||
itemType={item?._itemType}
|
||||
serverId={item?._serverId}
|
||||
@@ -289,6 +298,11 @@ export const QueueSongTitleCombinedColumn = (props: ItemTableListInnerColumn) =>
|
||||
size="md"
|
||||
{...titleLinkProps}
|
||||
>
|
||||
<ExplicitIndicator explicitStatus={song?.explicitStatus} />
|
||||
<ExternalSongIndicator
|
||||
isExternal={song?._serverType === ServerType.EXTERNAL}
|
||||
size="sm"
|
||||
/>
|
||||
{row.name as string}
|
||||
{song?.trackSubtitle && props.itemType !== LibraryItem.QUEUE_SONG && (
|
||||
<Text
|
||||
|
||||
+2
-1
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useImperativeHandle, useMemo } from 'react';
|
||||
|
||||
import { ItemListHandle, ItemListStateActions } from '/@/renderer/components/item-list/types';
|
||||
import { ItemListStateActions } from '/@/renderer/components/item-list/helpers/item-list-state';
|
||||
import { ItemListHandle } from '/@/renderer/components/item-list/types';
|
||||
|
||||
interface UseTableImperativeHandleProps {
|
||||
enableHeader: boolean;
|
||||
|
||||
@@ -4,13 +4,21 @@ import { api } from '/@/renderer/api';
|
||||
import { controller } from '/@/renderer/api/controller';
|
||||
import { queryKeys } from '/@/renderer/api/query-keys';
|
||||
import { getOptimizedListCount } from '/@/renderer/api/utils-list-count';
|
||||
import { fetchMbzReleaseAsAlbum } from '/@/renderer/features/musicbrainz/api/musicbrainz-api';
|
||||
import { getMbzReleaseIdFromAlbumId } from '/@/renderer/features/musicbrainz/utils';
|
||||
import { QueryHookArgs } from '/@/renderer/lib/react-query';
|
||||
import { AlbumDetailQuery, AlbumListQuery, ListCountQuery } from '/@/shared/types/domain-types';
|
||||
|
||||
export const albumQueries = {
|
||||
detail: (args: QueryHookArgs<AlbumDetailQuery>) => {
|
||||
return queryOptions({
|
||||
queryFn: ({ signal }) => {
|
||||
queryFn: async ({ signal }) => {
|
||||
const mbzReleaseId = getMbzReleaseIdFromAlbumId(args.query.id);
|
||||
|
||||
if (mbzReleaseId !== null) {
|
||||
return fetchMbzReleaseAsAlbum(mbzReleaseId);
|
||||
}
|
||||
|
||||
return api.controller.getAlbumDetail({
|
||||
apiClientProps: { serverId: args.serverId, signal },
|
||||
query: args.query,
|
||||
|
||||
@@ -20,13 +20,17 @@ import { ItemTableListColumn } from '/@/renderer/components/item-list/item-table
|
||||
import { ItemControls } from '/@/renderer/components/item-list/types';
|
||||
import { albumQueries } from '/@/renderer/features/albums/api/album-api';
|
||||
import { AlbumInfiniteCarousel } from '/@/renderer/features/albums/components/album-infinite-carousel';
|
||||
import { isMbzAlbumId } from '/@/renderer/features/musicbrainz/utils';
|
||||
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';
|
||||
import { useCurrentServer, usePlayerSong } from '/@/renderer/store';
|
||||
import { useCurrentServerId, usePlayerSong } from '/@/renderer/store';
|
||||
import { useExternalLinks, useSettingsStore } from '/@/renderer/store/settings.store';
|
||||
import { sentenceCase, titleCase } from '/@/renderer/utils';
|
||||
import { replaceURLWithHTMLLinks } from '/@/renderer/utils/linkify';
|
||||
@@ -116,6 +120,13 @@ const AlbumMetadataTags = ({ album }: AlbumMetadataTagsProps) => {
|
||||
|
||||
const items: Array<{ id: string; value: ReactNode | string | undefined }> = [];
|
||||
|
||||
if (album._serverType === ServerType.EXTERNAL) {
|
||||
items.push({
|
||||
id: 'unavailable',
|
||||
value: t('common.unavailable', { postProcess: 'sentenceCase' }),
|
||||
});
|
||||
}
|
||||
|
||||
items.push(
|
||||
...releaseTypes,
|
||||
{
|
||||
@@ -345,7 +356,6 @@ const AlbumMetadataExternalLinks = ({
|
||||
}}
|
||||
radius="md"
|
||||
rel="noopener noreferrer"
|
||||
size="md"
|
||||
target="_blank"
|
||||
tooltip={{
|
||||
label: t('action.openIn.musicbrainz'),
|
||||
@@ -360,9 +370,14 @@ const AlbumMetadataExternalLinks = ({
|
||||
|
||||
export const AlbumDetailContent = () => {
|
||||
const { albumId } = useParams() as { albumId: string };
|
||||
const server = useCurrentServer();
|
||||
const serverId = useCurrentServerId();
|
||||
const isMbz = isMbzAlbumId(albumId);
|
||||
|
||||
const detailQuery = useSuspenseQuery(
|
||||
albumQueries.detail({ query: { id: albumId }, serverId: server.id }),
|
||||
albumQueries.detail({
|
||||
query: { id: albumId },
|
||||
serverId: isMbz ? 'musicbrainz' : serverId,
|
||||
}),
|
||||
);
|
||||
|
||||
const { externalLinks, lastFM, musicBrainz } = useExternalLinks();
|
||||
@@ -743,7 +758,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}
|
||||
/>
|
||||
|
||||
@@ -8,6 +8,7 @@ import styles from './album-detail-header.module.css';
|
||||
import { albumQueries } from '/@/renderer/features/albums/api/album-api';
|
||||
import { JoinedArtists } from '/@/renderer/features/albums/components/joined-artists';
|
||||
import { ContextMenuController } from '/@/renderer/features/context-menu/context-menu-controller';
|
||||
import { isMbzAlbumId } from '/@/renderer/features/musicbrainz/utils';
|
||||
import { usePlayer } from '/@/renderer/features/player/context/player-context';
|
||||
import {
|
||||
LibraryHeader,
|
||||
@@ -16,7 +17,7 @@ import {
|
||||
import { useSetFavorite } from '/@/renderer/features/shared/hooks/use-set-favorite';
|
||||
import { useSetRating } from '/@/renderer/features/shared/hooks/use-set-rating';
|
||||
import { AppRoute } from '/@/renderer/router/routes';
|
||||
import { useCurrentServer, useShowRatings } from '/@/renderer/store';
|
||||
import { useCurrentServerId, useIntegrationsSettings, useShowRatings } from '/@/renderer/store';
|
||||
import { usePlayButtonBehavior } from '/@/renderer/store/settings.store';
|
||||
import { formatDateAbsoluteUTC, formatDurationString } from '/@/renderer/utils';
|
||||
import { normalizeReleaseTypes } from '/@/renderer/utils/normalize-release-types';
|
||||
@@ -30,13 +31,22 @@ import { Play } from '/@/shared/types/types';
|
||||
export const AlbumDetailHeader = forwardRef<HTMLDivElement>((_props, ref) => {
|
||||
const { albumId } = useParams() as { albumId: string };
|
||||
const { t } = useTranslation();
|
||||
const server = useCurrentServer();
|
||||
const serverId = useCurrentServerId();
|
||||
const showRatings = useShowRatings();
|
||||
|
||||
const isMbz = isMbzAlbumId(albumId);
|
||||
const detailQuery = useQuery(
|
||||
albumQueries.detail({ query: { id: albumId }, serverId: server?.id }),
|
||||
albumQueries.detail({
|
||||
query: { id: albumId },
|
||||
serverId: isMbz ? 'musicbrainz' : serverId,
|
||||
}),
|
||||
);
|
||||
|
||||
const isExternal = detailQuery?.data?._serverType === ServerType.EXTERNAL;
|
||||
const { youtube: youtubeEnabled } = useIntegrationsSettings();
|
||||
|
||||
const showRating =
|
||||
!isExternal &&
|
||||
showRatings &&
|
||||
(detailQuery?.data?._serverType === ServerType.NAVIDROME ||
|
||||
detailQuery?.data?._serverType === ServerType.SUBSONIC);
|
||||
@@ -80,8 +90,8 @@ export const AlbumDetailHeader = forwardRef<HTMLDivElement>((_props, ref) => {
|
||||
: undefined;
|
||||
|
||||
const handlePlay = (type?: Play) => {
|
||||
if (!server?.id || !albumId) return;
|
||||
addToQueueByFetch(server.id, [albumId], LibraryItem.ALBUM, type || playButtonBehavior);
|
||||
if (isExternal || !serverId || !albumId) return;
|
||||
addToQueueByFetch(serverId, [albumId], LibraryItem.ALBUM, type || playButtonBehavior);
|
||||
};
|
||||
|
||||
const handleMoreOptions = (e: React.MouseEvent<HTMLButtonElement>) => {
|
||||
@@ -220,6 +230,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,
|
||||
@@ -247,6 +258,7 @@ export const AlbumDetailHeader = forwardRef<HTMLDivElement>((_props, ref) => {
|
||||
/>
|
||||
</Group>
|
||||
<LibraryHeaderMenu
|
||||
disabled={isExternal && !youtubeEnabled}
|
||||
favorite={detailQuery?.data?.userFavorite}
|
||||
onFavorite={handleFavorite}
|
||||
onMore={handleMoreOptions}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
import { createContext, useContext } from 'react';
|
||||
|
||||
import { ListKey } from '/@/renderer/store';
|
||||
|
||||
export const AlbumListContext = createContext<{ id?: string; pageKey: ListKey }>({
|
||||
pageKey: 'album',
|
||||
});
|
||||
|
||||
export const useAlbumListContext = () => {
|
||||
const ctxValue = useContext(AlbumListContext);
|
||||
return ctxValue;
|
||||
};
|
||||
@@ -7,6 +7,7 @@ import { NativeScrollArea } from '/@/renderer/components/native-scroll-area/nati
|
||||
import { albumQueries } from '/@/renderer/features/albums/api/album-api';
|
||||
import { AlbumDetailContent } from '/@/renderer/features/albums/components/album-detail-content';
|
||||
import { AlbumDetailHeader } from '/@/renderer/features/albums/components/album-detail-header';
|
||||
import { isMbzAlbumId } from '/@/renderer/features/musicbrainz/utils';
|
||||
import { AnimatedPage } from '/@/renderer/features/shared/components/animated-page';
|
||||
import {
|
||||
LibraryBackgroundImage,
|
||||
@@ -16,9 +17,9 @@ import { LibraryContainer } from '/@/renderer/features/shared/components/library
|
||||
import { LibraryHeaderBar } from '/@/renderer/features/shared/components/library-header-bar';
|
||||
import { PageErrorBoundary } from '/@/renderer/features/shared/components/page-error-boundary';
|
||||
import { useFastAverageColor } from '/@/renderer/hooks';
|
||||
import { useAlbumBackground, useCurrentServer } from '/@/renderer/store';
|
||||
import { useAlbumBackground, useCurrentServerId, useIntegrationsSettings } from '/@/renderer/store';
|
||||
import { Spinner } from '/@/shared/components/spinner/spinner';
|
||||
import { LibraryItem } from '/@/shared/types/domain-types';
|
||||
import { LibraryItem, ServerType } from '/@/shared/types/domain-types';
|
||||
|
||||
const AlbumDetailRoute = () => {
|
||||
const scrollAreaRef = useRef<HTMLDivElement>(null);
|
||||
@@ -26,18 +27,24 @@ const AlbumDetailRoute = () => {
|
||||
const { albumBackground, albumBackgroundBlur } = useAlbumBackground();
|
||||
|
||||
const { albumId } = useParams() as { albumId: string };
|
||||
const server = useCurrentServer();
|
||||
const serverId = useCurrentServerId();
|
||||
const { youtube: youtubeEnabled } = useIntegrationsSettings();
|
||||
const isMbz = isMbzAlbumId(albumId);
|
||||
|
||||
const location = useLocation();
|
||||
|
||||
const detailQuery = useQuery({
|
||||
...albumQueries.detail({ query: { id: albumId }, serverId: server?.id }),
|
||||
...albumQueries.detail({
|
||||
query: { id: albumId },
|
||||
serverId: isMbz ? 'musicbrainz' : serverId,
|
||||
}),
|
||||
placeholderData: location.state?.item,
|
||||
});
|
||||
|
||||
const imageUrl =
|
||||
useItemImageUrl({
|
||||
id: detailQuery?.data?.imageId || undefined,
|
||||
imageUrl: detailQuery?.data?.imageUrl || undefined,
|
||||
itemType: LibraryItem.ALBUM,
|
||||
type: 'itemCard',
|
||||
}) || '';
|
||||
@@ -52,10 +59,12 @@ const AlbumDetailRoute = () => {
|
||||
|
||||
const showBlurredImage = albumBackground;
|
||||
|
||||
if (isColorLoading) {
|
||||
if (isColorLoading || (detailQuery.isLoading && !detailQuery.data)) {
|
||||
return <Spinner container />;
|
||||
}
|
||||
|
||||
const isExternal = detailQuery?.data?._serverType === ServerType.EXTERNAL;
|
||||
|
||||
return (
|
||||
<AnimatedPage key={`album-detail-${albumId}`}>
|
||||
<NativeScrollArea
|
||||
@@ -64,6 +73,7 @@ const AlbumDetailRoute = () => {
|
||||
children: (
|
||||
<LibraryHeaderBar>
|
||||
<LibraryHeaderBar.PlayButton
|
||||
disabled={isExternal && !youtubeEnabled}
|
||||
ids={[albumId]}
|
||||
itemType={LibraryItem.ALBUM}
|
||||
variant="default"
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
@@ -24,6 +24,7 @@ import { ItemTableListColumn } from '/@/renderer/components/item-list/item-table
|
||||
import { ItemControls } from '/@/renderer/components/item-list/types';
|
||||
import { artistsQueries } from '/@/renderer/features/artists/api/artists-api';
|
||||
import { AlbumArtistGridCarousel } from '/@/renderer/features/artists/components/album-artist-grid-carousel';
|
||||
import { musicbrainzQueries } from '/@/renderer/features/musicbrainz/api/musicbrainz-api';
|
||||
import { useIsPlayerFetching, usePlayer } from '/@/renderer/features/player/context/player-context';
|
||||
import { ListConfigMenu } from '/@/renderer/features/shared/components/list-config-menu';
|
||||
import {
|
||||
@@ -66,6 +67,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 +76,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 +91,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 +239,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 +256,7 @@ const AlbumArtistMetadataTopSongsContent = ({
|
||||
query: {
|
||||
artist: detailQuery.data?.name || '',
|
||||
artistId: routeId,
|
||||
type: topSongsQueryType,
|
||||
},
|
||||
serverId: serverId,
|
||||
}),
|
||||
@@ -293,41 +303,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 +334,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 +357,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 +516,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 +1032,11 @@ export const AlbumArtistDetailContent = ({
|
||||
/>
|
||||
</Grid.Col>
|
||||
)}
|
||||
{enabledItem.favoriteSongs && (
|
||||
<Grid.Col order={itemOrder.favoriteSongs} span={12}>
|
||||
<AlbumArtistMetadataFavoriteSongs routeId={routeId} />
|
||||
</Grid.Col>
|
||||
)}
|
||||
</Grid>
|
||||
</div>
|
||||
</div>
|
||||
@@ -759,21 +1070,46 @@ const AlbumSection = ({ albums, controls, cq, releaseType, rows, title }: AlbumS
|
||||
const { t } = useTranslation();
|
||||
|
||||
const itemsPerRow = getItemsPerRow(cq);
|
||||
const albumCount = albums.length;
|
||||
const [showAll, setShowAll] = useState(false);
|
||||
const player = usePlayer();
|
||||
const serverId = useCurrentServerId();
|
||||
|
||||
const { albumCountBadge, nonExternalAlbums } = useMemo(() => {
|
||||
const { external, nonExternal } = albums.reduce<{
|
||||
external: typeof albums;
|
||||
nonExternal: typeof albums;
|
||||
}>(
|
||||
(acc, album) => {
|
||||
if (album._serverType === ServerType.EXTERNAL) {
|
||||
acc.external.push(album);
|
||||
} else {
|
||||
acc.nonExternal.push(album);
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
{ external: [], nonExternal: [] },
|
||||
);
|
||||
|
||||
const originalCount = nonExternal.length;
|
||||
const externalCount = external.length;
|
||||
|
||||
return {
|
||||
albumCountBadge:
|
||||
externalCount > 0 ? `${originalCount} + ${externalCount}` : String(originalCount),
|
||||
nonExternalAlbums: nonExternal,
|
||||
};
|
||||
}, [albums]);
|
||||
|
||||
const displayedAlbums = showAll ? albums : albums.slice(0, MAX_SECTION_CARDS);
|
||||
const hasMoreAlbums = albums.length > MAX_SECTION_CARDS;
|
||||
|
||||
const handlePlay = useCallback(
|
||||
(playType: Play) => {
|
||||
if (albums.length === 0) return;
|
||||
const albumIds = albums.map((album) => album.id);
|
||||
if (nonExternalAlbums.length === 0) return;
|
||||
const albumIds = nonExternalAlbums.map((album) => album.id);
|
||||
player.addToQueueByFetch(serverId, albumIds, LibraryItem.ALBUM, playType);
|
||||
},
|
||||
[albums, player, serverId],
|
||||
[nonExternalAlbums, player, serverId],
|
||||
);
|
||||
|
||||
const handlePlayNext = usePlayButtonClick({
|
||||
@@ -810,11 +1146,11 @@ const AlbumSection = ({ albums, controls, cq, releaseType, rows, title }: AlbumS
|
||||
<TextTitle fw={700} order={3}>
|
||||
{title}
|
||||
</TextTitle>
|
||||
<Badge variant="default">{albumCount}</Badge>
|
||||
<Badge variant="default">{albumCountBadge}</Badge>
|
||||
</Group>
|
||||
<div className={styles.albumSectionDividerContainer}>
|
||||
<div className={styles.albumSectionDivider} />
|
||||
{albumCount > 0 && (
|
||||
{nonExternalAlbums.length > 0 && (
|
||||
<ActionIconGroup>
|
||||
<PlayTooltip type={Play.NOW}>
|
||||
<ActionIcon
|
||||
@@ -1042,6 +1378,15 @@ interface ArtistAlbumsProps {
|
||||
const ArtistAlbums = ({ albumsQuery }: ArtistAlbumsProps) => {
|
||||
const { t } = useTranslation();
|
||||
const artistReleaseTypeItems = useArtistReleaseTypeItems();
|
||||
const musicBrainzExcludeReleaseTypes = useSettingsStore(
|
||||
(state) => state.integrations.musicBrainzExcludeReleaseTypes,
|
||||
);
|
||||
const musicbrainzAutoCountryPriority = useSettingsStore(
|
||||
(state) => state.integrations.musicbrainzAutoCountryPriority,
|
||||
);
|
||||
const musicBrainzPrioritizeCountries = useSettingsStore(
|
||||
(state) => state.integrations.musicBrainzPrioritizeCountries,
|
||||
);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [debouncedSearchTerm] = useDebouncedValue(searchTerm, 300);
|
||||
const albumArtistDetailSort = useAppStore((state) => state.albumArtistDetailSort);
|
||||
@@ -1054,16 +1399,55 @@ const ArtistAlbums = ({ albumsQuery }: ArtistAlbumsProps) => {
|
||||
albumArtistId?: string;
|
||||
artistId?: string;
|
||||
};
|
||||
|
||||
const serverId = useCurrentServerId();
|
||||
const routeId = (artistId || albumArtistId) as string;
|
||||
|
||||
const detailQuery = useSuspenseQuery(
|
||||
artistsQueries.albumArtistDetail({
|
||||
query: { id: routeId },
|
||||
serverId: serverId,
|
||||
}),
|
||||
);
|
||||
|
||||
const musicBrainzEnabled = useSettingsStore((state) => state.integrations.musicBrainz);
|
||||
const musicbrainzArtistQuery = useQuery({
|
||||
...musicbrainzQueries.artist({
|
||||
autoCountryPriority: musicbrainzAutoCountryPriority,
|
||||
excludeReleaseTypes: musicBrainzExcludeReleaseTypes,
|
||||
mbzArtistId: detailQuery.data?.mbz as string,
|
||||
prioritizeCountries: musicBrainzPrioritizeCountries,
|
||||
}),
|
||||
enabled: Boolean(musicBrainzEnabled && detailQuery.data?.mbz),
|
||||
meta: {
|
||||
albumArtist: detailQuery.data,
|
||||
albums: albumsQuery.data?.items || [],
|
||||
autoCountryPriority: musicbrainzAutoCountryPriority,
|
||||
excludeReleaseTypes: musicBrainzExcludeReleaseTypes,
|
||||
prioritizeCountries: musicBrainzPrioritizeCountries,
|
||||
},
|
||||
});
|
||||
|
||||
const musicbrainzAlbums = useMemo(() => {
|
||||
return musicbrainzArtistQuery.data || [];
|
||||
}, [musicbrainzArtistQuery.data]);
|
||||
|
||||
const rows = useGridRows(LibraryItem.ALBUM, ItemListKey.ALBUM);
|
||||
const controls = useDefaultItemListControls();
|
||||
|
||||
const filteredAndSortedAlbums = useMemo(() => {
|
||||
const albums = albumsQuery.data?.items || [];
|
||||
const existingReleaseIds = new Set(
|
||||
albumsQuery.data?.items?.map((item) => item.mbzId) || [],
|
||||
);
|
||||
|
||||
const newMusicbrainzAlbums = musicbrainzAlbums.filter(
|
||||
(album) => !existingReleaseIds.has(album.mbzId),
|
||||
);
|
||||
|
||||
const albums = [...(albumsQuery.data?.items || []), ...newMusicbrainzAlbums];
|
||||
const searched = searchLibraryItems(albums, debouncedSearchTerm, LibraryItem.ALBUM);
|
||||
return sortAlbumList(searched, sortBy, sortOrder);
|
||||
}, [albumsQuery.data?.items, debouncedSearchTerm, sortBy, sortOrder]);
|
||||
}, [albumsQuery.data?.items, debouncedSearchTerm, musicbrainzAlbums, sortBy, sortOrder]);
|
||||
|
||||
const albumsByReleaseType = useMemo(() => {
|
||||
return groupAlbumsByReleaseType(filteredAndSortedAlbums, routeId, groupingType);
|
||||
@@ -1251,12 +1635,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>
|
||||
);
|
||||
};
|
||||
@@ -13,6 +13,8 @@ import {
|
||||
LibraryHeader,
|
||||
LibraryHeaderMenu,
|
||||
} from '/@/renderer/features/shared/components/library-header';
|
||||
import { useSetFavorite } from '/@/renderer/features/shared/hooks/use-set-favorite';
|
||||
import { useSetRating } from '/@/renderer/features/shared/hooks/use-set-rating';
|
||||
import { AppRoute } from '/@/renderer/router/routes';
|
||||
import { useCurrentServer, useShowRatings } from '/@/renderer/store';
|
||||
import { usePlayButtonBehavior } from '/@/renderer/store/settings.store';
|
||||
@@ -65,7 +67,9 @@ export const AlbumArtistDetailHeader = forwardRef((_props, ref: Ref<HTMLDivEleme
|
||||
},
|
||||
];
|
||||
|
||||
const { addToQueueByFetch, setFavorite, setRating } = usePlayer();
|
||||
const { addToQueueByFetch } = usePlayer();
|
||||
const setFavorite = useSetFavorite();
|
||||
const setRating = useSetRating();
|
||||
const playButtonBehavior = usePlayButtonBehavior();
|
||||
|
||||
const handlePlay = useCallback(
|
||||
|
||||
+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);
|
||||
|
||||
@@ -5,8 +5,7 @@ import { useSetRating } from '/@/renderer/features/shared/hooks/use-set-rating';
|
||||
import { useCurrentServer, useCurrentServerId, useShowRatings } from '/@/renderer/store';
|
||||
import { ContextMenu } from '/@/shared/components/context-menu/context-menu';
|
||||
import { Rating } from '/@/shared/components/rating/rating';
|
||||
import { LibraryItem } from '/@/shared/types/domain-types';
|
||||
import { ServerType } from '/@/shared/types/types';
|
||||
import { LibraryItem, ServerType } from '/@/shared/types/domain-types';
|
||||
|
||||
interface SetRatingActionProps {
|
||||
ids: string[];
|
||||
|
||||
@@ -24,29 +24,16 @@ const getItemName = (item: unknown): string => {
|
||||
return 'Item';
|
||||
};
|
||||
|
||||
const getItemImage = (item: unknown): null | string => {
|
||||
if (item && typeof item === 'object') {
|
||||
if ('imageId' in item && typeof item.imageId === 'string') {
|
||||
return item.imageId;
|
||||
}
|
||||
|
||||
if ('imageUrl' in item && typeof item.imageUrl === 'string') {
|
||||
return item.imageUrl;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
export const ContextMenuPreview = ({ items, itemType }: ContextMenuPreviewProps) => {
|
||||
const { t } = useTranslation();
|
||||
const itemCount = items.length;
|
||||
const firstItem = items[0];
|
||||
const itemName = firstItem ? getItemName(firstItem) : 'Item';
|
||||
const itemImage = firstItem ? getItemImage(firstItem) : null;
|
||||
const isMultiple = itemCount > 1;
|
||||
|
||||
const imageUrl = useItemImageUrl({
|
||||
id: (firstItem as { imageId?: string })?.imageId,
|
||||
imageUrl: (firstItem as { imageUrl?: string })?.imageUrl,
|
||||
itemType: itemType || LibraryItem.SONG,
|
||||
serverId: (firstItem as { _serverId?: string })?._serverId,
|
||||
type: 'table',
|
||||
@@ -61,7 +48,7 @@ export const ContextMenuPreview = ({ items, itemType }: ContextMenuPreviewProps)
|
||||
<div className={styles.divider} />
|
||||
<div className={styles.preview}>
|
||||
<div className={styles.content}>
|
||||
{itemImage ? (
|
||||
{imageUrl ? (
|
||||
<div className={styles.imageContainer}>
|
||||
<img alt={itemName} className={styles.image} src={imageUrl} />
|
||||
<div className={styles.imageOverlay} />
|
||||
|
||||
@@ -4,6 +4,10 @@ import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { api } from '/@/renderer/api';
|
||||
import { useItemImageUrl } from '/@/renderer/components/item-image/item-image';
|
||||
import {
|
||||
useIsRadioActive,
|
||||
useRadioPlayer,
|
||||
} from '/@/renderer/features/radio/hooks/use-radio-player';
|
||||
import {
|
||||
DiscordDisplayType,
|
||||
DiscordLinkType,
|
||||
@@ -37,6 +41,9 @@ export const useDiscordRpc = () => {
|
||||
const privateMode = useAppStore((state) => state.privateMode);
|
||||
const [lastUniqueId, setlastUniqueId] = useState('');
|
||||
|
||||
const isRadioActive = useIsRadioActive();
|
||||
const { isPlaying: isRadioPlaying, metadata: radioMetadata, stationName } = useRadioPlayer();
|
||||
|
||||
const currentSong = usePlayerSong();
|
||||
const imageUrl = useItemImageUrl({
|
||||
id: currentSong?.imageId || undefined,
|
||||
@@ -67,14 +74,17 @@ export const useDiscordRpc = () => {
|
||||
: song !== previousSong;
|
||||
const trackChanged = song ? lastUniqueId !== song._uniqueId : false;
|
||||
|
||||
const isPlayingRadio = isRadioActive && isRadioPlaying;
|
||||
const hasTrackOrRadio = Boolean(current[0]) || isPlayingRadio;
|
||||
|
||||
if (
|
||||
!current[0] || // No track
|
||||
(current[2] === 'paused' && !discordSettings.showPaused) // Track paused with show paused setting disabled
|
||||
!hasTrackOrRadio || // No track and not playing radio
|
||||
(current[2] === 'paused' && !discordSettings.showPaused) // Paused with show paused setting disabled
|
||||
) {
|
||||
let reason: string;
|
||||
if (!current[0]) {
|
||||
reason = 'no_track';
|
||||
} else if (current[1] === 0) {
|
||||
if (!hasTrackOrRadio) {
|
||||
reason = current[0] ? 'no_track' : 'no_track_or_radio';
|
||||
} else if (current[1] === 0 && !isPlayingRadio) {
|
||||
reason = 'start_of_track';
|
||||
} else {
|
||||
reason = 'paused_with_show_paused_disabled';
|
||||
@@ -90,6 +100,46 @@ export const useDiscordRpc = () => {
|
||||
return discordRpc?.clearActivity();
|
||||
}
|
||||
|
||||
if (isPlayingRadio) {
|
||||
const title = radioMetadata?.title || stationName || 'Radio';
|
||||
const artist = radioMetadata?.artist || stationName || '';
|
||||
|
||||
const activity: SetActivity = {
|
||||
details: truncate(title),
|
||||
instance: false,
|
||||
largeImageKey: 'icon',
|
||||
largeImageText: truncate(stationName || 'Radio'),
|
||||
smallImageKey: current[2] === PlayerStatus.PLAYING ? 'playing' : 'paused',
|
||||
smallImageText: sentenceCase(current[2]),
|
||||
state: truncate(artist),
|
||||
statusDisplayType: StatusDisplayType.STATE,
|
||||
type: discordSettings.showAsListening ? 2 : 0,
|
||||
};
|
||||
|
||||
const isConnected = await discordRpc?.isConnected();
|
||||
if (!isConnected) {
|
||||
logFn.debug(logMsg[LogCategory.EXTERNAL].discordRpcInitialized, {
|
||||
category: LogCategory.EXTERNAL,
|
||||
meta: { clientId: discordSettings.clientId },
|
||||
});
|
||||
previousEnabledRef.current = true;
|
||||
await discordRpc?.initialize(discordSettings.clientId);
|
||||
}
|
||||
|
||||
logFn.debug(logMsg[LogCategory.EXTERNAL].discordRpcSetActivity, {
|
||||
category: LogCategory.EXTERNAL,
|
||||
meta: {
|
||||
currentStatus: current[2],
|
||||
reason: 'radio',
|
||||
showAsListening: discordSettings.showAsListening,
|
||||
stationName: stationName || 'Radio',
|
||||
title,
|
||||
},
|
||||
});
|
||||
discordRpc?.setActivity(activity);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!song) {
|
||||
return;
|
||||
}
|
||||
@@ -306,6 +356,11 @@ export const useDiscordRpc = () => {
|
||||
discordSettings.linkType,
|
||||
lastUniqueId,
|
||||
currentSong?._uniqueId,
|
||||
isRadioActive,
|
||||
isRadioPlaying,
|
||||
radioMetadata?.artist,
|
||||
radioMetadata?.title,
|
||||
stationName,
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
import JellyfinIcon from '/@/renderer/features/servers/assets/jellyfin.png';
|
||||
import NavidromeIcon from '/@/renderer/features/servers/assets/navidrome.png';
|
||||
import SubsonicIcon from '/@/renderer/features/servers/assets/opensubsonic.png';
|
||||
import { IgnoreCorsSslSwitches } from '/@/renderer/features/servers/components/ignore-cors-ssl-switches';
|
||||
import { AnimatedPage } from '/@/renderer/features/shared/components/animated-page';
|
||||
import { PageErrorBoundary } from '/@/renderer/features/shared/components/page-error-boundary';
|
||||
import { AppRoute } from '/@/renderer/router/routes';
|
||||
@@ -28,18 +29,22 @@ import { TextTitle } from '/@/shared/components/text-title/text-title';
|
||||
import { Text } from '/@/shared/components/text/text';
|
||||
import { toast } from '/@/shared/components/toast/toast';
|
||||
import { useForm } from '/@/shared/hooks/use-form';
|
||||
import { AuthenticationResponse, ServerListItemWithCredential } from '/@/shared/types/domain-types';
|
||||
import { ServerType, toServerType } from '/@/shared/types/types';
|
||||
import {
|
||||
AuthenticationResponse,
|
||||
ServerListItemWithCredential,
|
||||
ServerType,
|
||||
} from '/@/shared/types/domain-types';
|
||||
import { toServerType } from '/@/shared/types/types';
|
||||
|
||||
const localSettings = isElectron() ? window.api.localSettings : null;
|
||||
|
||||
const SERVER_ICONS: Record<ServerType, string> = {
|
||||
const SERVER_ICONS: Record<Exclude<ServerType, ServerType.EXTERNAL>, string> = {
|
||||
[ServerType.JELLYFIN]: JellyfinIcon,
|
||||
[ServerType.NAVIDROME]: NavidromeIcon,
|
||||
[ServerType.SUBSONIC]: SubsonicIcon,
|
||||
};
|
||||
|
||||
const SERVER_NAMES: Record<ServerType, string> = {
|
||||
const SERVER_NAMES: Record<Exclude<ServerType, ServerType.EXTERNAL>, string> = {
|
||||
[ServerType.JELLYFIN]: 'Jellyfin',
|
||||
[ServerType.NAVIDROME]: 'Navidrome',
|
||||
[ServerType.SUBSONIC]: 'OpenSubsonic',
|
||||
@@ -229,6 +234,7 @@ const LoginRoute = () => {
|
||||
variant="filled"
|
||||
{...form.getInputProps('password')}
|
||||
/>
|
||||
<IgnoreCorsSslSwitches />
|
||||
</Stack>
|
||||
|
||||
<Button
|
||||
|
||||
@@ -0,0 +1,434 @@
|
||||
import { queryOptions } from '@tanstack/react-query';
|
||||
import memoize from 'lodash/memoize';
|
||||
import {
|
||||
IArtist,
|
||||
IBrowseReleasesResult,
|
||||
IRelation,
|
||||
IRelease,
|
||||
IReleaseGroup,
|
||||
IWork,
|
||||
MusicBrainzApi,
|
||||
} from 'musicbrainz-api';
|
||||
|
||||
import packageJson from '../../../../../package.json';
|
||||
|
||||
import { queryKeys } from '/@/renderer/api/query-keys';
|
||||
import {
|
||||
collectWorksFromRelease,
|
||||
getImageUrlByReleaseGroupId,
|
||||
normalizeReleaseToAlbum,
|
||||
} from '/@/renderer/features/musicbrainz/utils';
|
||||
import { logFn } from '/@/renderer/utils/logger';
|
||||
import {
|
||||
Album,
|
||||
AlbumArtist,
|
||||
LibraryItem,
|
||||
RelatedArtist,
|
||||
ServerType,
|
||||
} from '/@/shared/types/domain-types';
|
||||
|
||||
export const musicbrainzApi = new MusicBrainzApi({
|
||||
appContactInfo: packageJson.homepage,
|
||||
appName: packageJson.name,
|
||||
appVersion: packageJson.version,
|
||||
});
|
||||
|
||||
const CACHE_TIME = 1000 * 60 * 5;
|
||||
|
||||
export type IRelationWithWork = IRelation & { work?: IWork };
|
||||
|
||||
export type MusicBrainzArtistSelectMeta = {
|
||||
albumArtist: AlbumArtist;
|
||||
albums?: Album[];
|
||||
autoCountryPriority?: boolean;
|
||||
excludeReleaseTypes?: string[];
|
||||
prioritizeCountries?: string[];
|
||||
};
|
||||
|
||||
const artistSelect = memoize(
|
||||
({
|
||||
data,
|
||||
meta,
|
||||
}: {
|
||||
data: {
|
||||
artist: IArtist;
|
||||
releases: IBrowseReleasesResult;
|
||||
};
|
||||
meta: MusicBrainzArtistSelectMeta;
|
||||
}) => {
|
||||
const albumArtist: RelatedArtist = {
|
||||
id: meta.albumArtist.id,
|
||||
imageId: meta.albumArtist.imageId,
|
||||
imageUrl: meta.albumArtist.imageUrl,
|
||||
name: meta.albumArtist.name,
|
||||
userFavorite: meta.albumArtist.userFavorite,
|
||||
userRating: meta.albumArtist.userRating,
|
||||
};
|
||||
|
||||
const ownedMbzReleaseGroupIds = new Set<string>();
|
||||
const ownedMbzReleaseIds = new Set<string>();
|
||||
|
||||
const counts = {
|
||||
existingMbzReleaseGroupIds: 0,
|
||||
existingMbzReleaseIds: 0,
|
||||
};
|
||||
|
||||
for (const album of meta.albums || []) {
|
||||
if (album.mbzReleaseGroupId) {
|
||||
ownedMbzReleaseGroupIds.add(album.mbzReleaseGroupId);
|
||||
counts.existingMbzReleaseGroupIds++;
|
||||
}
|
||||
|
||||
if (album.mbzId) {
|
||||
ownedMbzReleaseIds.add(album.mbzId);
|
||||
counts.existingMbzReleaseIds++;
|
||||
}
|
||||
}
|
||||
|
||||
const albumArtistName = meta.albumArtist.name;
|
||||
|
||||
const existingReleaseGroups = new Map<string, IRelease>();
|
||||
const existingReleases = new Map<string, IRelease>();
|
||||
const unownedReleases = new Map<string, IRelease>();
|
||||
const unownedReleaseGroups = new Map<string, IReleaseGroup>();
|
||||
|
||||
for (const release of data.releases.releases) {
|
||||
const releaseGroup = release['release-group'];
|
||||
const hasReleaseGroup = releaseGroup?.id !== undefined;
|
||||
|
||||
if (hasReleaseGroup && ownedMbzReleaseGroupIds.has(releaseGroup.id)) {
|
||||
existingReleaseGroups.set(releaseGroup.id, release);
|
||||
}
|
||||
|
||||
if (ownedMbzReleaseIds.has(release.id)) {
|
||||
existingReleases.set(release.id, release);
|
||||
}
|
||||
}
|
||||
|
||||
for (const release of data.releases.releases) {
|
||||
const releaseGroupId = release['release-group']?.id;
|
||||
if (
|
||||
releaseGroupId &&
|
||||
!ownedMbzReleaseIds.has(release.id) &&
|
||||
!ownedMbzReleaseGroupIds.has(releaseGroupId)
|
||||
) {
|
||||
unownedReleases.set(release.id, release);
|
||||
if (releaseGroupId && release['release-group']) {
|
||||
unownedReleaseGroups.set(releaseGroupId, release['release-group']);
|
||||
}
|
||||
} else if (!releaseGroupId && !ownedMbzReleaseIds.has(release.id)) {
|
||||
console.log('adding unowned release by release id', release.id);
|
||||
unownedReleases.set(release.id, release);
|
||||
}
|
||||
}
|
||||
|
||||
const excludeReleaseTypes = (meta.excludeReleaseTypes ?? []).map((t) => t.toLowerCase());
|
||||
const excludeSet = new Set(excludeReleaseTypes);
|
||||
|
||||
let prioritizeCountries: string[];
|
||||
if (meta.autoCountryPriority) {
|
||||
const countryCounts = new Map<string, number>();
|
||||
for (const release of data.releases.releases) {
|
||||
const country = release.country?.toUpperCase();
|
||||
if (country) {
|
||||
countryCounts.set(country, (countryCounts.get(country) ?? 0) + 1);
|
||||
}
|
||||
}
|
||||
prioritizeCountries = [...countryCounts.entries()]
|
||||
.sort(([, a], [, b]) => b - a)
|
||||
.map(([code]) => code);
|
||||
} else {
|
||||
prioritizeCountries = (meta.prioritizeCountries ?? []).map((c) => c.toUpperCase());
|
||||
}
|
||||
|
||||
const releaseEntries = Array.from(unownedReleases.entries())
|
||||
.filter(([, release]) => {
|
||||
if (excludeSet.size === 0) return true;
|
||||
const releaseGroup = release['release-group'];
|
||||
const primary = releaseGroup?.['primary-type']?.toLowerCase();
|
||||
const secondary =
|
||||
releaseGroup?.['secondary-types']?.map((t) => t.toLowerCase()) ?? [];
|
||||
const types = [primary, ...secondary].filter(Boolean) as string[];
|
||||
return !types.some((t) => excludeSet.has(t));
|
||||
})
|
||||
.sort(([, a], [, b]) => {
|
||||
if (prioritizeCountries.length === 0) return 0;
|
||||
const indexA = a.country
|
||||
? prioritizeCountries.indexOf(a.country.toUpperCase())
|
||||
: -1;
|
||||
const indexB = b.country
|
||||
? prioritizeCountries.indexOf(b.country.toUpperCase())
|
||||
: -1;
|
||||
const posA = indexA === -1 ? Number.MAX_SAFE_INTEGER : indexA;
|
||||
const posB = indexB === -1 ? Number.MAX_SAFE_INTEGER : indexB;
|
||||
return posA - posB;
|
||||
});
|
||||
|
||||
const seenReleaseGroupIds = new Set<string>();
|
||||
const releaseEntriesUniqueByGroup = releaseEntries.filter(([, release]) => {
|
||||
const releaseGroupId = release['release-group']?.id;
|
||||
if (releaseGroupId == null) return true;
|
||||
if (seenReleaseGroupIds.has(releaseGroupId)) return false;
|
||||
seenReleaseGroupIds.add(releaseGroupId);
|
||||
return true;
|
||||
});
|
||||
|
||||
const albums: Album[] = releaseEntriesUniqueByGroup
|
||||
.map(([, release]) => {
|
||||
const releaseGroup = release['release-group'];
|
||||
const hasArtwork = releaseGroup;
|
||||
|
||||
const primaryReleaseType = releaseGroup?.['primary-type']?.toLowerCase() || null;
|
||||
const secondaryReleaseTypes =
|
||||
releaseGroup?.['secondary-types']?.map((type) => type.toLowerCase()) || [];
|
||||
const releaseTypes = [primaryReleaseType, ...secondaryReleaseTypes].filter(
|
||||
(type) => type !== null,
|
||||
) as string[];
|
||||
const isCompilation = releaseTypes.includes('compilation');
|
||||
const originalDate = releaseGroup?.['first-release-date'] || null;
|
||||
const originalYear = originalDate ? Number(originalDate.split('-')[0]) : null;
|
||||
const releaseDate = release.date ? release.date : null;
|
||||
const releaseYear = release.date ? Number(release.date.split('-')[0]) : null;
|
||||
const imageUrl = hasArtwork ? getImageUrlByReleaseGroupId(releaseGroup.id) : null;
|
||||
|
||||
const album: Album = {
|
||||
_itemType: LibraryItem.ALBUM,
|
||||
_serverId: 'musicbrainz',
|
||||
_serverType: ServerType.EXTERNAL,
|
||||
albumArtistName: albumArtistName,
|
||||
albumArtists: [albumArtist],
|
||||
artists: [],
|
||||
comment: null,
|
||||
createdAt: '',
|
||||
duration: null,
|
||||
explicitStatus: null,
|
||||
genres: [],
|
||||
id: `musicbrainz-${release.id}`,
|
||||
imageId: null,
|
||||
imageUrl: imageUrl,
|
||||
isCompilation: isCompilation,
|
||||
lastPlayedAt: null,
|
||||
mbzId: release.id,
|
||||
mbzReleaseGroupId: releaseGroup?.id || null,
|
||||
name: release.title,
|
||||
originalDate: originalDate,
|
||||
originalYear: originalYear,
|
||||
participants: {},
|
||||
playCount: null,
|
||||
recordLabels: [],
|
||||
releaseDate: releaseDate,
|
||||
releaseType: primaryReleaseType,
|
||||
releaseTypes: releaseTypes,
|
||||
releaseYear: releaseYear,
|
||||
size: null,
|
||||
songCount: null,
|
||||
sortName: release.title,
|
||||
tags: {},
|
||||
updatedAt: '',
|
||||
userFavorite: false,
|
||||
userRating: null,
|
||||
version: null,
|
||||
};
|
||||
|
||||
return album;
|
||||
})
|
||||
.filter((album): album is Album => album !== null);
|
||||
|
||||
return albums;
|
||||
},
|
||||
);
|
||||
|
||||
async function fetchMbzReleasesByArtistId(mbzArtistId: string): Promise<IBrowseReleasesResult> {
|
||||
const PAGE_SIZE = 100;
|
||||
const includes: Array<'media' | 'release-groups'> = ['media', 'release-groups'];
|
||||
|
||||
// Fetch first page to get total count
|
||||
const firstPage = (await musicbrainzApi.browse(
|
||||
'release',
|
||||
{
|
||||
artist: mbzArtistId,
|
||||
limit: PAGE_SIZE,
|
||||
offset: 0,
|
||||
},
|
||||
includes,
|
||||
)) as unknown as IBrowseReleasesResult;
|
||||
|
||||
const totalCount = firstPage['release-count'];
|
||||
const allReleases = [...firstPage.releases];
|
||||
|
||||
if (allReleases.length >= totalCount) {
|
||||
return firstPage;
|
||||
}
|
||||
|
||||
const remainingCount = totalCount - allReleases.length;
|
||||
const numberOfPages = Math.ceil(remainingCount / PAGE_SIZE);
|
||||
|
||||
const pagePromises = Array.from({ length: numberOfPages }, (_, i) => {
|
||||
const offset = (i + 1) * PAGE_SIZE;
|
||||
return musicbrainzApi.browse(
|
||||
'release',
|
||||
{
|
||||
artist: mbzArtistId,
|
||||
limit: PAGE_SIZE,
|
||||
offset: offset,
|
||||
},
|
||||
includes,
|
||||
) as unknown as Promise<IBrowseReleasesResult>;
|
||||
});
|
||||
|
||||
const remainingPages = await Promise.all(pagePromises);
|
||||
|
||||
for (const page of remainingPages) {
|
||||
allReleases.push(...page.releases);
|
||||
}
|
||||
|
||||
return {
|
||||
'release-count': totalCount,
|
||||
'release-offset': 0,
|
||||
releases: allReleases,
|
||||
};
|
||||
}
|
||||
|
||||
const RELEASE_INCLUDES: Array<
|
||||
| 'artist-credits'
|
||||
| 'artists'
|
||||
| 'media'
|
||||
| 'recording-level-rels'
|
||||
| 'recordings'
|
||||
| 'release-groups'
|
||||
> = ['artist-credits', 'artists', 'media', 'recording-level-rels', 'recordings', 'release-groups'];
|
||||
|
||||
const EMPTY_BROWSE_RELEASES: IBrowseReleasesResult = {
|
||||
'release-count': 0,
|
||||
'release-offset': 0,
|
||||
releases: [],
|
||||
};
|
||||
|
||||
export const musicbrainzQueries = {
|
||||
artist: (args: {
|
||||
autoCountryPriority?: boolean;
|
||||
excludeReleaseTypes?: string[];
|
||||
mbzArtistId: string;
|
||||
prioritizeCountries?: string[];
|
||||
}) => {
|
||||
const config = {
|
||||
autoCountryPriority: args.autoCountryPriority ?? false,
|
||||
excludeReleaseTypes: args.excludeReleaseTypes ?? [],
|
||||
prioritizeCountries: args.prioritizeCountries ?? [],
|
||||
};
|
||||
|
||||
return queryOptions({
|
||||
gcTime: CACHE_TIME,
|
||||
queryFn: async ({ meta }) => {
|
||||
try {
|
||||
const artist = await musicbrainzApi.lookup('artist', args.mbzArtistId);
|
||||
const releases = await fetchMbzReleasesByArtistId(args.mbzArtistId);
|
||||
|
||||
logFn.debug('MusicBrainz artist lookup API queried', {
|
||||
meta: { artistId: args.mbzArtistId, releases },
|
||||
});
|
||||
|
||||
return {
|
||||
data: { artist, releases },
|
||||
meta: meta as MusicBrainzArtistSelectMeta,
|
||||
};
|
||||
} catch (error) {
|
||||
logFn.warn('MusicBrainz artist lookup failed', {
|
||||
meta: { artistId: args.mbzArtistId, error },
|
||||
});
|
||||
return {
|
||||
data: {
|
||||
artist: {} as IArtist,
|
||||
releases: EMPTY_BROWSE_RELEASES,
|
||||
},
|
||||
meta: meta as MusicBrainzArtistSelectMeta,
|
||||
};
|
||||
}
|
||||
},
|
||||
queryKey: queryKeys.musicbrainz.artist(undefined, args.mbzArtistId, config),
|
||||
select: artistSelect,
|
||||
staleTime: CACHE_TIME,
|
||||
});
|
||||
},
|
||||
release: (args: { releaseId: string }) =>
|
||||
queryOptions({
|
||||
gcTime: CACHE_TIME,
|
||||
queryFn: async () => {
|
||||
try {
|
||||
const mbzRelease = await musicbrainzApi.lookup(
|
||||
'release',
|
||||
args.releaseId,
|
||||
RELEASE_INCLUDES,
|
||||
);
|
||||
const release = normalizeReleaseToAlbum(mbzRelease);
|
||||
const works = collectWorksFromRelease(mbzRelease);
|
||||
|
||||
logFn.debug('MusicBrainz release lookup API queried', {
|
||||
meta: { release, releaseId: args.releaseId },
|
||||
});
|
||||
|
||||
return { release, works };
|
||||
} catch (error) {
|
||||
logFn.warn('MusicBrainz release lookup failed', {
|
||||
meta: { error, releaseId: args.releaseId },
|
||||
});
|
||||
return { release: null, works: [] };
|
||||
}
|
||||
},
|
||||
queryKey: queryKeys.musicbrainz.release(args.releaseId),
|
||||
staleTime: CACHE_TIME,
|
||||
}),
|
||||
};
|
||||
|
||||
export const MUSICBRAINZ_ID_PREFIX = 'musicbrainz-';
|
||||
|
||||
export async function fetchMbzReleaseAsAlbum(releaseId: string): Promise<Album> {
|
||||
try {
|
||||
const mbzRelease = await musicbrainzApi.lookup('release', releaseId, RELEASE_INCLUDES);
|
||||
return normalizeReleaseToAlbum(mbzRelease);
|
||||
} catch (error) {
|
||||
logFn.warn('MusicBrainz release fetch failed', { meta: { error, releaseId } });
|
||||
return createEmptyMbzAlbum(releaseId);
|
||||
}
|
||||
}
|
||||
|
||||
function createEmptyMbzAlbum(releaseId: string): Album {
|
||||
return {
|
||||
_itemType: LibraryItem.ALBUM,
|
||||
_serverId: 'musicbrainz',
|
||||
_serverType: ServerType.EXTERNAL,
|
||||
albumArtistName: '',
|
||||
albumArtists: [],
|
||||
artists: [],
|
||||
comment: null,
|
||||
createdAt: '',
|
||||
duration: null,
|
||||
explicitStatus: null,
|
||||
genres: [],
|
||||
id: `musicbrainz-${releaseId}`,
|
||||
imageId: null,
|
||||
imageUrl: null,
|
||||
isCompilation: null,
|
||||
lastPlayedAt: null,
|
||||
mbzId: releaseId,
|
||||
mbzReleaseGroupId: null,
|
||||
name: '',
|
||||
originalDate: null,
|
||||
originalYear: null,
|
||||
participants: {},
|
||||
playCount: null,
|
||||
recordLabels: [],
|
||||
releaseDate: null,
|
||||
releaseType: null,
|
||||
releaseTypes: [],
|
||||
releaseYear: null,
|
||||
size: null,
|
||||
songCount: null,
|
||||
sortName: '',
|
||||
tags: {},
|
||||
updatedAt: '',
|
||||
userFavorite: false,
|
||||
userRating: null,
|
||||
version: null,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { queryOptions } from '@tanstack/react-query';
|
||||
|
||||
import { logFn } from '/@/renderer/utils/logger';
|
||||
|
||||
async function searchYoutube(query: string): Promise<Array<{ type: string; videoId?: string }>> {
|
||||
if (typeof window !== 'undefined' && window.api?.youtube) {
|
||||
return window.api.youtube.search(query);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
export const youtubeQueries = {
|
||||
search: (args: { query: string }) => {
|
||||
return queryOptions({
|
||||
gcTime: 1000 * 60 * 5,
|
||||
queryFn: async () => {
|
||||
const results = await searchYoutube(args.query);
|
||||
logFn.debug('Youtube API queried', { meta: { query: args.query, results } });
|
||||
return results;
|
||||
},
|
||||
queryKey: ['youtube', 'search', args.query],
|
||||
staleTime: 1000 * 60 * 5,
|
||||
});
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,224 @@
|
||||
import { IArtist, IMedium, IRelease, ITrack, IWork } from 'musicbrainz-api';
|
||||
|
||||
import {
|
||||
IRelationWithWork,
|
||||
MUSICBRAINZ_ID_PREFIX,
|
||||
} from '/@/renderer/features/musicbrainz/api/musicbrainz-api';
|
||||
import { Album, LibraryItem, RelatedArtist, ServerType, Song } from '/@/shared/types/domain-types';
|
||||
|
||||
export function collectWorksFromRelease(release: IRelease): IWork[] {
|
||||
const works: IWork[] = [];
|
||||
const seenIds = new Set<string>();
|
||||
|
||||
for (const medium of release.media ?? []) {
|
||||
for (const track of medium.tracks ?? []) {
|
||||
const recording = track.recording;
|
||||
const relations = (recording as { relations?: IRelationWithWork[] })?.relations ?? [];
|
||||
for (const rel of relations) {
|
||||
const work = (rel as IRelationWithWork).work;
|
||||
if (work?.id && !seenIds.has(work.id)) {
|
||||
seenIds.add(work.id);
|
||||
works.push(work);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return works;
|
||||
}
|
||||
|
||||
export function getImageUrl(releaseId: string): string {
|
||||
return `https://coverartarchive.org/release/${releaseId}/front-250.jpg`;
|
||||
}
|
||||
|
||||
export function getImageUrlByReleaseGroupId(releaseGroupId: string): string {
|
||||
return `https://coverartarchive.org/release-group/${releaseGroupId}/front-250.jpg`;
|
||||
}
|
||||
|
||||
export function getMbzReleaseIdFromAlbumId(albumId: string): null | string {
|
||||
if (!albumId.startsWith(MUSICBRAINZ_ID_PREFIX)) return null;
|
||||
return albumId.slice(MUSICBRAINZ_ID_PREFIX.length);
|
||||
}
|
||||
|
||||
export function isMbzAlbumId(albumId: string): boolean {
|
||||
return albumId.startsWith(MUSICBRAINZ_ID_PREFIX);
|
||||
}
|
||||
export function normalizeReleaseToAlbum(release: IRelease): Album {
|
||||
const releaseGroup = release['release-group'];
|
||||
const artistCredit = release['artist-credit'] ?? releaseGroup?.['artist-credit'] ?? [];
|
||||
const albumArtistName = artistCredit
|
||||
.map((entry) => `${entry.name}${entry.joinphrase ?? ''}`)
|
||||
.join(' ');
|
||||
const albumArtists: RelatedArtist[] = (artistCredit as { artist: IArtist; name: string }[]).map(
|
||||
(ac) => ({
|
||||
id: `musicbrainz-${ac.artist.id}`,
|
||||
imageId: null,
|
||||
imageUrl: null,
|
||||
name: ac.name || ac.artist.name,
|
||||
userFavorite: false,
|
||||
userRating: null,
|
||||
}),
|
||||
);
|
||||
|
||||
const hasArtwork = releaseGroup;
|
||||
const primaryReleaseType = releaseGroup?.['primary-type']?.toLowerCase() || null;
|
||||
const secondaryReleaseTypes =
|
||||
releaseGroup?.['secondary-types']?.map((type) => type.toLowerCase()) || [];
|
||||
const releaseTypes = [primaryReleaseType, ...secondaryReleaseTypes].filter(
|
||||
(type) => type !== null,
|
||||
) as string[];
|
||||
const isCompilation = releaseTypes.includes('compilation');
|
||||
const originalDate = releaseGroup?.['first-release-date'] || null;
|
||||
const originalYear = originalDate ? Number(originalDate.split('-')[0]) : null;
|
||||
const releaseDate = release.date ? release.date : null;
|
||||
const releaseYear = release.date ? Number(release.date.split('-')[0]) : null;
|
||||
const imageUrl = hasArtwork ? getImageUrlByReleaseGroupId(releaseGroup.id) : null;
|
||||
const albumId = `musicbrainz-${release.id}`;
|
||||
|
||||
const songs: Song[] = [];
|
||||
for (const medium of release.media ?? []) {
|
||||
for (const track of medium.tracks ?? []) {
|
||||
songs.push(
|
||||
normalizeRecordingToSong(
|
||||
release,
|
||||
medium,
|
||||
track,
|
||||
albumArtistName,
|
||||
albumArtists,
|
||||
albumId,
|
||||
imageUrl,
|
||||
releaseDate,
|
||||
releaseYear,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const totalDuration = songs.reduce((sum, s) => sum + s.duration, 0);
|
||||
|
||||
return {
|
||||
_itemType: LibraryItem.ALBUM,
|
||||
_serverId: 'musicbrainz',
|
||||
_serverType: ServerType.EXTERNAL,
|
||||
albumArtistName,
|
||||
albumArtists,
|
||||
artists: [],
|
||||
comment: null,
|
||||
createdAt: '',
|
||||
duration: totalDuration || null,
|
||||
explicitStatus: null,
|
||||
genres: [],
|
||||
id: albumId,
|
||||
imageId: null,
|
||||
imageUrl,
|
||||
isCompilation,
|
||||
lastPlayedAt: null,
|
||||
mbzId: release.id,
|
||||
mbzReleaseGroupId: releaseGroup?.id || null,
|
||||
name: release.title,
|
||||
originalDate,
|
||||
originalYear,
|
||||
participants: {},
|
||||
playCount: null,
|
||||
recordLabels: [],
|
||||
releaseDate,
|
||||
releaseType: primaryReleaseType,
|
||||
releaseTypes,
|
||||
releaseYear,
|
||||
size: null,
|
||||
songCount: songs.length,
|
||||
songs,
|
||||
sortName: release.title,
|
||||
tags: {},
|
||||
updatedAt: '',
|
||||
userFavorite: false,
|
||||
userRating: null,
|
||||
version: null,
|
||||
};
|
||||
}
|
||||
function normalizeArtistCreditToRelatedArtists(
|
||||
artistCredit: Array<{ artist: IArtist; name: string }>,
|
||||
): RelatedArtist[] {
|
||||
return artistCredit.map((ac) => ({
|
||||
id: `musicbrainz-${ac.artist.id}`,
|
||||
imageId: null,
|
||||
imageUrl: null,
|
||||
name: ac.name || ac.artist.name,
|
||||
userFavorite: false,
|
||||
userRating: null,
|
||||
}));
|
||||
}
|
||||
function normalizeRecordingToSong(
|
||||
release: IRelease,
|
||||
medium: IMedium,
|
||||
track: ITrack,
|
||||
albumArtistName: string,
|
||||
albumArtists: RelatedArtist[],
|
||||
albumId: string,
|
||||
imageUrl: null | string,
|
||||
releaseDate: null | string,
|
||||
releaseYear: null | number,
|
||||
): Song {
|
||||
const recording = track.recording;
|
||||
const trackArtistCredit = track['artist-credit'] ?? recording['artist-credit'] ?? [];
|
||||
|
||||
const artistName = trackArtistCredit
|
||||
.map((entry) => `${entry.name}${entry.joinphrase ?? ''}`)
|
||||
.join(' ');
|
||||
|
||||
const artists = normalizeArtistCreditToRelatedArtists(
|
||||
trackArtistCredit as Array<{ artist: IArtist; name: string }>,
|
||||
);
|
||||
|
||||
const durationMilliseconds = track.length || recording.length || 0;
|
||||
const trackNumber = track.position || parseInt(track.number, 10) || 0;
|
||||
|
||||
return {
|
||||
_itemType: LibraryItem.SONG,
|
||||
_serverId: 'musicbrainz',
|
||||
_serverType: ServerType.EXTERNAL,
|
||||
album: release.title,
|
||||
albumArtistName,
|
||||
albumArtists,
|
||||
albumId,
|
||||
artistName,
|
||||
artists,
|
||||
bitDepth: null,
|
||||
bitRate: 0,
|
||||
bpm: null,
|
||||
channels: null,
|
||||
comment: null,
|
||||
compilation: null,
|
||||
container: null,
|
||||
createdAt: '',
|
||||
discNumber: medium.position || 1,
|
||||
discSubtitle: medium.title || null,
|
||||
duration: durationMilliseconds,
|
||||
explicitStatus: null,
|
||||
gain: null,
|
||||
genres: [],
|
||||
id: `musicbrainz-${release.id}-${recording.id}-${track.position}-${track.number}`,
|
||||
imageId: null,
|
||||
imageUrl,
|
||||
lastPlayedAt: null,
|
||||
lyrics: null,
|
||||
mbzRecordingId: recording.id,
|
||||
mbzTrackId: track.id,
|
||||
name: track.title || recording.title,
|
||||
participants: {},
|
||||
path: null,
|
||||
peak: null,
|
||||
playCount: 0,
|
||||
releaseDate,
|
||||
releaseYear,
|
||||
sampleRate: null,
|
||||
size: 0,
|
||||
sortName: track.title || recording.title,
|
||||
tags: null,
|
||||
trackNumber,
|
||||
trackSubtitle: null,
|
||||
updatedAt: '',
|
||||
userFavorite: false,
|
||||
userRating: null,
|
||||
};
|
||||
}
|
||||
@@ -237,7 +237,6 @@ const EmptyQueueDropZone = () => {
|
||||
const sourceServerId = (
|
||||
args.source.item?.[0] as unknown as { _serverId: string }
|
||||
)?._serverId;
|
||||
|
||||
const sourceItemType = args.source.itemType as LibraryItem;
|
||||
|
||||
switch (args.source.type) {
|
||||
@@ -297,7 +296,7 @@ const EmptyQueueDropZone = () => {
|
||||
const folderIds = folders.map((folder) => folder.id);
|
||||
|
||||
// Handle folders: fetch and add to queue
|
||||
if (folderIds.length > 0) {
|
||||
if (folderIds.length > 0 && sourceServerId) {
|
||||
playerContext.addToQueueByFetch(
|
||||
sourceServerId,
|
||||
folderIds,
|
||||
|
||||
@@ -12,6 +12,7 @@ import { getMpvProperties } from '/@/renderer/features/settings/components/playb
|
||||
import {
|
||||
usePlaybackSettings,
|
||||
usePlayerActions,
|
||||
usePlayerSong,
|
||||
usePlayerStore,
|
||||
useSettingsStore,
|
||||
} from '/@/renderer/store';
|
||||
@@ -20,8 +21,10 @@ import { PlayerStatus } from '/@/shared/types/types';
|
||||
export interface MpvPlayerEngineHandle extends AudioPlayer {}
|
||||
|
||||
interface MpvPlayerEngineProps {
|
||||
currentSongUrl: string | undefined;
|
||||
isMuted: boolean;
|
||||
isTransitioning: boolean;
|
||||
nextSongUrl: string | undefined;
|
||||
onEnded: () => void;
|
||||
onProgress: (e: PlayerOnProgressProps) => void;
|
||||
playerRef: RefObject<MpvPlayerEngineHandle | null>;
|
||||
@@ -38,8 +41,10 @@ const PROGRESS_UPDATE_INTERVAL = 250;
|
||||
|
||||
export const MpvPlayerEngine = (props: MpvPlayerEngineProps) => {
|
||||
const {
|
||||
currentSongUrl: currentSongUrlProp,
|
||||
isMuted,
|
||||
isTransitioning,
|
||||
nextSongUrl: nextSongUrlProp,
|
||||
onEnded,
|
||||
onProgress,
|
||||
playerRef,
|
||||
@@ -49,12 +54,17 @@ export const MpvPlayerEngine = (props: MpvPlayerEngineProps) => {
|
||||
} = props;
|
||||
|
||||
const [internalVolume, setInternalVolume] = useState(volume / 100 || 0);
|
||||
const [duration] = useState(0);
|
||||
const currentSong = usePlayerSong();
|
||||
|
||||
const progressIntervalRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const isInitializedRef = useRef<boolean>(false);
|
||||
const hasPopulatedQueueRef = useRef<boolean>(false);
|
||||
const isMountedRef = useRef<boolean>(true);
|
||||
const currentSongUrlRef = useRef<string | undefined>(currentSongUrlProp);
|
||||
const nextSongUrlRef = useRef<string | undefined>(nextSongUrlProp);
|
||||
|
||||
currentSongUrlRef.current = currentSongUrlProp;
|
||||
nextSongUrlRef.current = nextSongUrlProp;
|
||||
|
||||
const { mpvAudioDeviceId, transcode } = usePlaybackSettings();
|
||||
const mpvExtraParameters = useSettingsStore((store) => store.playback.mpvExtraParameters);
|
||||
@@ -123,15 +133,17 @@ export const MpvPlayerEngine = (props: MpvPlayerEngineProps) => {
|
||||
|
||||
if (!radioState.currentStreamUrl) {
|
||||
const playerData = usePlayerStore.getState().getPlayerData();
|
||||
const currentSongUrl = playerData.currentSong
|
||||
? getSongUrl(playerData.currentSong, transcode)
|
||||
: undefined;
|
||||
const nextSongUrl = playerData.nextSong
|
||||
? getSongUrl(playerData.nextSong, transcode)
|
||||
: undefined;
|
||||
const currentResolved =
|
||||
currentSongUrlProp ??
|
||||
(playerData.currentSong
|
||||
? getSongUrl(playerData.currentSong, transcode)
|
||||
: undefined);
|
||||
const nextResolved =
|
||||
nextSongUrlProp ??
|
||||
(playerData.nextSong ? getSongUrl(playerData.nextSong, transcode) : undefined);
|
||||
|
||||
if (currentSongUrl && nextSongUrl && !hasPopulatedQueueRef.current && mpvPlayer) {
|
||||
mpvPlayer.setQueue(currentSongUrl, nextSongUrl, true);
|
||||
if (currentResolved && !hasPopulatedQueueRef.current && mpvPlayer) {
|
||||
mpvPlayer.setQueue(currentResolved, nextResolved ?? currentResolved, true);
|
||||
hasPopulatedQueueRef.current = true;
|
||||
}
|
||||
}
|
||||
@@ -156,6 +168,30 @@ export const MpvPlayerEngine = (props: MpvPlayerEngineProps) => {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [mpvExtraParameters, mpvProperties, mpvAudioDeviceId, reloadTrigger]);
|
||||
|
||||
// Sync queue when current/next song URLs change (e.g. user selects song, or external URL resolves from useSongUrl)
|
||||
useEffect(() => {
|
||||
if (!mpvPlayer || !isInitializedRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const radioState = useRadioStore.getState();
|
||||
if (radioState.currentStreamUrl) {
|
||||
return;
|
||||
}
|
||||
|
||||
const playerData = usePlayerStore.getState().getPlayerData();
|
||||
const currentResolved =
|
||||
currentSongUrlProp ??
|
||||
(playerData.currentSong ? getSongUrl(playerData.currentSong, transcode) : undefined);
|
||||
const nextResolved =
|
||||
nextSongUrlProp ??
|
||||
(playerData.nextSong ? getSongUrl(playerData.nextSong, transcode) : undefined);
|
||||
|
||||
if (currentResolved) {
|
||||
mpvPlayer.setQueue(currentResolved, nextResolved ?? currentResolved, false);
|
||||
}
|
||||
}, [currentSongUrlProp, nextSongUrlProp, currentSong?.id, currentSong?._uniqueId, transcode]);
|
||||
|
||||
// Update volume
|
||||
useEffect(() => {
|
||||
if (!mpvPlayer) {
|
||||
@@ -204,12 +240,18 @@ export const MpvPlayerEngine = (props: MpvPlayerEngineProps) => {
|
||||
}
|
||||
}, [playerStatus]);
|
||||
|
||||
const hasCurrentSong = !!currentSong?.id;
|
||||
|
||||
// Set up progress tracking
|
||||
useEffect(() => {
|
||||
if (progressIntervalRef.current) {
|
||||
clearInterval(progressIntervalRef.current);
|
||||
}
|
||||
|
||||
if (!hasCurrentSong) {
|
||||
return;
|
||||
}
|
||||
|
||||
const updateProgress = async () => {
|
||||
if (!mpvPlayer || !isMountedRef.current) {
|
||||
return;
|
||||
@@ -219,7 +261,7 @@ export const MpvPlayerEngine = (props: MpvPlayerEngineProps) => {
|
||||
const time = await mpvPlayer.getCurrentTime();
|
||||
if (time !== undefined && isMountedRef.current) {
|
||||
onProgress({
|
||||
played: time / (duration || time + 10),
|
||||
played: time / (time + 10),
|
||||
playedSeconds: time,
|
||||
});
|
||||
}
|
||||
@@ -239,7 +281,7 @@ export const MpvPlayerEngine = (props: MpvPlayerEngineProps) => {
|
||||
progressIntervalRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [isTransitioning, duration, onProgress]);
|
||||
}, [hasCurrentSong, isTransitioning, onProgress]);
|
||||
|
||||
const { mediaAutoNext } = usePlayerActions();
|
||||
|
||||
@@ -250,7 +292,7 @@ export const MpvPlayerEngine = (props: MpvPlayerEngineProps) => {
|
||||
|
||||
const handleOnAutoNext = () => {
|
||||
mediaAutoNext();
|
||||
handleMpvAutoNext(transcode);
|
||||
handleMpvAutoNext(transcode, nextSongUrlRef.current);
|
||||
};
|
||||
|
||||
mpvPlayerListener.rendererAutoNext(handleOnAutoNext);
|
||||
@@ -263,10 +305,10 @@ export const MpvPlayerEngine = (props: MpvPlayerEngineProps) => {
|
||||
usePlayerEvents(
|
||||
{
|
||||
onMediaNext: () => {
|
||||
replaceMpvQueue(transcode);
|
||||
replaceMpvQueue(transcode, currentSongUrlRef.current, nextSongUrlRef.current);
|
||||
},
|
||||
onMediaPrev: () => {
|
||||
replaceMpvQueue(transcode);
|
||||
replaceMpvQueue(transcode, currentSongUrlRef.current, nextSongUrlRef.current);
|
||||
},
|
||||
onNextSongInsertion: (song) => {
|
||||
const radioState = useRadioStore.getState();
|
||||
@@ -275,11 +317,12 @@ export const MpvPlayerEngine = (props: MpvPlayerEngineProps) => {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextSongUrl = song ? getSongUrl(song, transcode) : undefined;
|
||||
const nextSongUrl =
|
||||
nextSongUrlRef.current ?? (song ? getSongUrl(song, transcode) : undefined);
|
||||
mpvPlayer?.setQueueNext(nextSongUrl);
|
||||
},
|
||||
onPlayerPlay: () => {
|
||||
replaceMpvQueue(transcode);
|
||||
replaceMpvQueue(transcode, currentSongUrlRef.current, nextSongUrlRef.current);
|
||||
},
|
||||
onQueueCleared: () => {},
|
||||
},
|
||||
@@ -330,24 +373,30 @@ export const MpvPlayerEngine = (props: MpvPlayerEngineProps) => {
|
||||
|
||||
MpvPlayerEngine.displayName = 'MpvPlayerEngine';
|
||||
|
||||
function handleMpvAutoNext(transcode: {
|
||||
bitrate?: number | undefined;
|
||||
enabled: boolean;
|
||||
format?: string | undefined;
|
||||
}) {
|
||||
function handleMpvAutoNext(
|
||||
transcode: {
|
||||
bitrate?: number | undefined;
|
||||
enabled: boolean;
|
||||
format?: string | undefined;
|
||||
},
|
||||
nextUrlOverride?: string,
|
||||
) {
|
||||
const playerData = usePlayerStore.getState().getPlayerData();
|
||||
const nextSongUrl = playerData.nextSong
|
||||
? getSongUrl(playerData.nextSong, transcode)
|
||||
: undefined;
|
||||
const nextSongUrl =
|
||||
nextUrlOverride ??
|
||||
(playerData.nextSong ? getSongUrl(playerData.nextSong, transcode) : undefined);
|
||||
mpvPlayer?.autoNext(nextSongUrl);
|
||||
}
|
||||
|
||||
function replaceMpvQueue(transcode: {
|
||||
bitrate?: number | undefined;
|
||||
enabled: boolean;
|
||||
format?: string | undefined;
|
||||
}) {
|
||||
// Don't override queue if radio is active
|
||||
function replaceMpvQueue(
|
||||
transcode: {
|
||||
bitrate?: number | undefined;
|
||||
enabled: boolean;
|
||||
format?: string | undefined;
|
||||
},
|
||||
currentUrlOverride?: string,
|
||||
nextUrlOverride?: string,
|
||||
) {
|
||||
const radioState = useRadioStore.getState();
|
||||
|
||||
if (radioState.currentStreamUrl) {
|
||||
@@ -355,11 +404,14 @@ function replaceMpvQueue(transcode: {
|
||||
}
|
||||
|
||||
const playerData = usePlayerStore.getState().getPlayerData();
|
||||
const currentSongUrl = playerData.currentSong
|
||||
? getSongUrl(playerData.currentSong, transcode)
|
||||
: undefined;
|
||||
const nextSongUrl = playerData.nextSong
|
||||
? getSongUrl(playerData.nextSong, transcode)
|
||||
: undefined;
|
||||
mpvPlayer?.setQueue(currentSongUrl, nextSongUrl, false);
|
||||
const currentSongUrl =
|
||||
currentUrlOverride ??
|
||||
(playerData.currentSong ? getSongUrl(playerData.currentSong, transcode) : undefined);
|
||||
const nextSongUrl =
|
||||
nextUrlOverride ??
|
||||
(playerData.nextSong ? getSongUrl(playerData.nextSong, transcode) : undefined);
|
||||
|
||||
if (currentSongUrl) {
|
||||
mpvPlayer?.setQueue(currentSongUrl, nextSongUrl ?? currentSongUrl, false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,14 +10,17 @@ import { logMsg } from '/@/renderer/utils/logger-message';
|
||||
import { PlayerStatus } from '/@/shared/types/types';
|
||||
|
||||
export interface WebPlayerEngineHandle extends AudioPlayer {
|
||||
player1(): {
|
||||
ref: null | ReactPlayer;
|
||||
setVolume: (volume: number) => void;
|
||||
};
|
||||
player2(): {
|
||||
ref: null | ReactPlayer;
|
||||
setVolume: (volume: number) => void;
|
||||
};
|
||||
player1(): WebPlayerEnginePlayerHandle;
|
||||
player2(): WebPlayerEnginePlayerHandle;
|
||||
}
|
||||
|
||||
export interface WebPlayerEnginePlayerHandle {
|
||||
getCurrentTime: () => number;
|
||||
getDuration: () => number;
|
||||
pause: () => void;
|
||||
play: () => void;
|
||||
ref: null | ReactPlayer;
|
||||
setVolume: (volume: number) => void;
|
||||
}
|
||||
|
||||
interface WebPlayerEngineProps {
|
||||
@@ -39,6 +42,70 @@ interface WebPlayerEngineProps {
|
||||
volume: number;
|
||||
}
|
||||
|
||||
interface YouTubePlayer {
|
||||
getCurrentTime?: () => number;
|
||||
getDuration?: () => number;
|
||||
pauseVideo?: () => void;
|
||||
playVideo?: () => void;
|
||||
}
|
||||
|
||||
function getInternalCurrentTime(ref: null | ReactPlayer): number {
|
||||
const internal = ref?.getInternalPlayer();
|
||||
if (!internal) return 0;
|
||||
if (internal instanceof HTMLMediaElement) {
|
||||
return (internal as HTMLMediaElement).currentTime ?? 0;
|
||||
}
|
||||
if (isYouTubePlayer(internal) && typeof internal.getCurrentTime === 'function') {
|
||||
return internal.getCurrentTime() ?? 0;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
function getInternalDuration(ref: null | ReactPlayer): number {
|
||||
const internal = ref?.getInternalPlayer();
|
||||
if (!internal) return 0;
|
||||
if (internal instanceof HTMLMediaElement) {
|
||||
return (internal as HTMLMediaElement).duration ?? 0;
|
||||
}
|
||||
if (isYouTubePlayer(internal) && typeof internal.getDuration === 'function') {
|
||||
return internal.getDuration() ?? 0;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
function isYouTubePlayer(internal: unknown): internal is YouTubePlayer {
|
||||
return (
|
||||
typeof internal === 'object' &&
|
||||
internal !== null &&
|
||||
'playVideo' in internal &&
|
||||
typeof (internal as YouTubePlayer).playVideo === 'function'
|
||||
);
|
||||
}
|
||||
|
||||
function pauseInternalPlayer(ref: null | ReactPlayer): void {
|
||||
const internal = ref?.getInternalPlayer();
|
||||
if (!internal) return;
|
||||
if (internal instanceof HTMLMediaElement) {
|
||||
(internal as HTMLMediaElement).pause();
|
||||
return;
|
||||
}
|
||||
if (isYouTubePlayer(internal)) {
|
||||
internal.pauseVideo?.();
|
||||
}
|
||||
}
|
||||
|
||||
function playInternalPlayer(ref: null | ReactPlayer): void {
|
||||
const internal = ref?.getInternalPlayer();
|
||||
if (!internal) return;
|
||||
if (internal instanceof HTMLMediaElement) {
|
||||
void (internal as HTMLMediaElement).play().catch(() => {});
|
||||
return;
|
||||
}
|
||||
if (isYouTubePlayer(internal)) {
|
||||
internal.playVideo?.();
|
||||
}
|
||||
}
|
||||
|
||||
// Credits: https://gist.github.com/novwhisky/8a1a0168b94f3b6abfaa?permalink_comment_id=1551393#gistcomment-1551393
|
||||
// This is used so that the player will always have an <audio> element. This means that
|
||||
// player1Source and player2Source are connected BEFORE the user presses play for
|
||||
@@ -108,25 +175,33 @@ export const WebPlayerEngine = (props: WebPlayerEngineProps) => {
|
||||
setInternalVolume2(Math.min(1, internalVolume2 + by / 100));
|
||||
},
|
||||
pause() {
|
||||
player1Ref.current?.getInternalPlayer()?.pause();
|
||||
player2Ref.current?.getInternalPlayer()?.pause();
|
||||
pauseInternalPlayer(player1Ref.current);
|
||||
pauseInternalPlayer(player2Ref.current);
|
||||
},
|
||||
play() {
|
||||
if (playerNum === 1) {
|
||||
player1Ref.current?.getInternalPlayer()?.play();
|
||||
playInternalPlayer(player1Ref.current);
|
||||
} else {
|
||||
player2Ref.current?.getInternalPlayer()?.play();
|
||||
playInternalPlayer(player2Ref.current);
|
||||
}
|
||||
},
|
||||
player1() {
|
||||
player1(): WebPlayerEnginePlayerHandle {
|
||||
return {
|
||||
ref: player1Ref?.current,
|
||||
getCurrentTime: () => getInternalCurrentTime(player1Ref.current),
|
||||
getDuration: () => getInternalDuration(player1Ref.current),
|
||||
pause: () => pauseInternalPlayer(player1Ref.current),
|
||||
play: () => playInternalPlayer(player1Ref.current),
|
||||
ref: player1Ref?.current ?? null,
|
||||
setVolume: (volume: number) => setInternalVolume1(volume / 100 || 0),
|
||||
};
|
||||
},
|
||||
player2() {
|
||||
player2(): WebPlayerEnginePlayerHandle {
|
||||
return {
|
||||
ref: player2Ref?.current,
|
||||
getCurrentTime: () => getInternalCurrentTime(player2Ref.current),
|
||||
getDuration: () => getInternalDuration(player2Ref.current),
|
||||
pause: () => pauseInternalPlayer(player2Ref.current),
|
||||
play: () => playInternalPlayer(player2Ref.current),
|
||||
ref: player2Ref?.current ?? null,
|
||||
setVolume: (volume: number) => setInternalVolume2(volume / 100 || 0),
|
||||
};
|
||||
},
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useMemo, useRef } from 'react';
|
||||
|
||||
import { api } from '/@/renderer/api';
|
||||
import { TranscodingConfig } from '/@/renderer/store';
|
||||
import { QueueSong } from '/@/shared/types/domain-types';
|
||||
import { youtubeQueries } from '/@/renderer/features/musicbrainz/api/youtube-api';
|
||||
import { TranscodingConfig, useSettingsStore } from '/@/renderer/store';
|
||||
import { QueueSong, ServerType } from '/@/shared/types/domain-types';
|
||||
|
||||
const YOUTUBE_WATCH_BASE = 'https://www.youtube.com/watch?v=';
|
||||
|
||||
export function useSongUrl(
|
||||
song: QueueSong | undefined,
|
||||
@@ -11,10 +15,38 @@ export function useSongUrl(
|
||||
): string | undefined {
|
||||
const prior = useRef(['', '']);
|
||||
|
||||
const isExternal = song?._serverType === ServerType.EXTERNAL;
|
||||
const youtubeEnabled = useSettingsStore((state) => state.integrations.youtube);
|
||||
const searchQuery =
|
||||
song && isExternal ? buildYoutubeSearchQuery(song.name, song.artistName) : '';
|
||||
|
||||
const youtubeSearch = useQuery({
|
||||
...youtubeQueries.search({ query: searchQuery }),
|
||||
enabled: Boolean(song && isExternal && searchQuery && youtubeEnabled),
|
||||
});
|
||||
|
||||
const externalUrl = useMemo(() => {
|
||||
if (!song || !isExternal) return undefined;
|
||||
if (current && prior.current[0] === song._uniqueId && prior.current[1]) {
|
||||
return prior.current[1];
|
||||
}
|
||||
const url = getYoutubeUrlFromSearchResults(youtubeSearch.data);
|
||||
|
||||
if (url) prior.current = [song._uniqueId, url];
|
||||
return url;
|
||||
}, [song, isExternal, current, youtubeSearch.data]);
|
||||
|
||||
return useMemo(() => {
|
||||
if (song?._serverId) {
|
||||
// If we are the current track, we do not want a transcoding
|
||||
// reconfiguration to force a restart.
|
||||
if (!song) {
|
||||
prior.current = ['', ''];
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (isExternal) {
|
||||
return externalUrl;
|
||||
}
|
||||
|
||||
if (song._serverId) {
|
||||
if (current && prior.current[0] === song._uniqueId) {
|
||||
return prior.current[1];
|
||||
}
|
||||
@@ -29,18 +61,16 @@ export function useSongUrl(
|
||||
},
|
||||
});
|
||||
|
||||
// transcoding enabled; save the updated result
|
||||
prior.current = [song._uniqueId, url];
|
||||
return url;
|
||||
}
|
||||
|
||||
// no track; clear result
|
||||
prior.current = ['', ''];
|
||||
return undefined;
|
||||
}, [
|
||||
song?._serverId,
|
||||
song?._uniqueId,
|
||||
song?.id,
|
||||
song,
|
||||
isExternal,
|
||||
externalUrl,
|
||||
current,
|
||||
transcode.bitrate,
|
||||
transcode.format,
|
||||
@@ -48,7 +78,31 @@ export function useSongUrl(
|
||||
]);
|
||||
}
|
||||
|
||||
export const getSongUrl = (song: QueueSong, transcode: TranscodingConfig) => {
|
||||
function buildYoutubeSearchQuery(
|
||||
title: string | undefined,
|
||||
artistName: string | undefined,
|
||||
): string {
|
||||
const t = (title ?? '').trim();
|
||||
const a = (artistName ?? '').trim();
|
||||
if (t && a) return `${a} - ${t}`;
|
||||
return t || a || '';
|
||||
}
|
||||
|
||||
function getYoutubeUrlFromSearchResults(
|
||||
results: Array<{ type: string; videoId?: string }> | undefined,
|
||||
): string | undefined {
|
||||
if (!results?.length) return undefined;
|
||||
const first = results.find((r) => r.type === 'SONG' || r.type === 'VIDEO');
|
||||
|
||||
return first && 'videoId' in first && first.videoId
|
||||
? `${YOUTUBE_WATCH_BASE}${first.videoId}`
|
||||
: undefined;
|
||||
}
|
||||
|
||||
export const getSongUrl = (song: QueueSong, transcode: TranscodingConfig): string => {
|
||||
if (song._serverType === ServerType.EXTERNAL) {
|
||||
return '';
|
||||
}
|
||||
return api.controller.getStreamUrl({
|
||||
apiClientProps: { serverId: song._serverId },
|
||||
query: {
|
||||
@@ -59,3 +113,33 @@ export const getSongUrl = (song: QueueSong, transcode: TranscodingConfig) => {
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export async function getSongUrlAsync(
|
||||
song: QueueSong | undefined,
|
||||
transcode: TranscodingConfig,
|
||||
): Promise<string | undefined> {
|
||||
if (!song) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (song._serverType === ServerType.EXTERNAL) {
|
||||
const youtubeEnabled = useSettingsStore.getState().integrations?.youtube ?? true;
|
||||
if (!youtubeEnabled || typeof window === 'undefined' || !window.api?.youtube) {
|
||||
return undefined;
|
||||
}
|
||||
const searchQuery = buildYoutubeSearchQuery(song.name, song.artistName);
|
||||
if (!searchQuery) {
|
||||
return undefined;
|
||||
}
|
||||
try {
|
||||
const results = await window.api.youtube.search(searchQuery);
|
||||
console.log('results', results);
|
||||
return getYoutubeUrlFromSearchResults(results);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
const url = getSongUrl(song, transcode);
|
||||
return url || undefined;
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { MpvPlayerEngine, MpvPlayerEngineHandle } from './engine/mpv-player-engine';
|
||||
|
||||
import { usePlayerEvents } from '/@/renderer/features/player/audio-player/hooks/use-player-events';
|
||||
import { useSongUrl } from '/@/renderer/features/player/audio-player/hooks/use-stream-url';
|
||||
import { usePlayer } from '/@/renderer/features/player/context/player-context';
|
||||
import {
|
||||
usePlaybackSettings,
|
||||
@@ -23,12 +24,15 @@ const mpvPlayer = isElectron() ? window.api.mpvPlayer : null;
|
||||
|
||||
export function MpvPlayer() {
|
||||
const playerRef = useRef<MpvPlayerEngineHandle>(null);
|
||||
const { status } = usePlayerData();
|
||||
const { currentSong, nextSong, status } = usePlayerData();
|
||||
const { mediaAutoNext, setTimestamp } = usePlayerActions();
|
||||
const { speed } = usePlayerProperties();
|
||||
const isMuted = usePlayerMuted();
|
||||
const volume = usePlayerVolume();
|
||||
const { audioFadeOnStatusChange } = usePlaybackSettings();
|
||||
const { audioFadeOnStatusChange, transcode } = usePlaybackSettings();
|
||||
|
||||
const currentSongUrl = useSongUrl(currentSong, true, transcode);
|
||||
const nextSongUrl = useSongUrl(nextSong, false, transcode);
|
||||
|
||||
const [localPlayerStatus, setLocalPlayerStatus] = useState<PlayerStatus>(status);
|
||||
const [isTransitioning, setIsTransitioning] = useState(false);
|
||||
@@ -147,8 +151,10 @@ export function MpvPlayer() {
|
||||
};
|
||||
}, []);
|
||||
|
||||
const hasCurrentSong = !!currentSong?.id;
|
||||
|
||||
useEffect(() => {
|
||||
if (localPlayerStatus !== PlayerStatus.PLAYING) {
|
||||
if (localPlayerStatus !== PlayerStatus.PLAYING || !hasCurrentSong) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -168,12 +174,14 @@ export function MpvPlayer() {
|
||||
}, 500);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [localPlayerStatus, setTimestamp]);
|
||||
}, [hasCurrentSong, localPlayerStatus, setTimestamp]);
|
||||
|
||||
return (
|
||||
<MpvPlayerEngine
|
||||
currentSongUrl={currentSongUrl}
|
||||
isMuted={isMuted}
|
||||
isTransitioning={isTransitioning}
|
||||
nextSongUrl={nextSongUrl}
|
||||
onEnded={handleOnEnded}
|
||||
onProgress={onProgress}
|
||||
playerRef={playerRef}
|
||||
|
||||
@@ -46,6 +46,26 @@ export function WebPlayer() {
|
||||
const [player1Source, setPlayer1Source] = useState<MediaElementAudioSourceNode | null>(null);
|
||||
const [player2Source, setPlayer2Source] = useState<MediaElementAudioSourceNode | null>(null);
|
||||
|
||||
// `react-player` may swap its underlying internal player when switching URLs
|
||||
// (e.g. file/http streams => HTMLMediaElement, YouTube => iframe player). A
|
||||
// MediaElementAudioSourceNode is permanently bound to a specific element, so we
|
||||
// must recreate the node when the element changes (or disconnect when it stops
|
||||
// being a media element).
|
||||
const player1InternalRef = useRef<HTMLMediaElement | null>(null);
|
||||
const player2InternalRef = useRef<HTMLMediaElement | null>(null);
|
||||
const player1SourceRef = useRef<MediaElementAudioSourceNode | null>(null);
|
||||
const player2SourceRef = useRef<MediaElementAudioSourceNode | null>(null);
|
||||
const player1ConnectInFlightRef = useRef<null | Promise<void>>(null);
|
||||
const player2ConnectInFlightRef = useRef<null | Promise<void>>(null);
|
||||
|
||||
useEffect(() => {
|
||||
player1SourceRef.current = player1Source;
|
||||
}, [player1Source]);
|
||||
|
||||
useEffect(() => {
|
||||
player2SourceRef.current = player2Source;
|
||||
}, [player2Source]);
|
||||
|
||||
const fadeAndSetStatus = useCallback(
|
||||
async (startVolume: number, endVolume: number, duration: number, status: PlayerStatus) => {
|
||||
// Cancel any in-progress fade
|
||||
@@ -106,7 +126,7 @@ export function WebPlayer() {
|
||||
currentPlayer: playerRef.current.player1(),
|
||||
currentPlayerNum: num,
|
||||
currentTime: e.playedSeconds,
|
||||
duration: getDuration(playerRef.current.player1().ref),
|
||||
duration: getDuration(playerRef.current.player1()),
|
||||
hasNextSong: Boolean(player2),
|
||||
isTransitioning,
|
||||
nextPlayer: playerRef.current.player2(),
|
||||
@@ -118,7 +138,7 @@ export function WebPlayer() {
|
||||
case PlayerStyle.GAPLESS:
|
||||
gaplessHandler({
|
||||
currentTime: e.playedSeconds,
|
||||
duration: getDuration(playerRef.current.player1().ref),
|
||||
duration: getDuration(playerRef.current.player1()),
|
||||
isFlac: false,
|
||||
isTransitioning,
|
||||
nextPlayer: playerRef.current.player2(),
|
||||
@@ -144,7 +164,7 @@ export function WebPlayer() {
|
||||
currentPlayer: playerRef.current.player2(),
|
||||
currentPlayerNum: num,
|
||||
currentTime: e.playedSeconds,
|
||||
duration: getDuration(playerRef.current.player2().ref),
|
||||
duration: getDuration(playerRef.current.player2()),
|
||||
hasNextSong: Boolean(player1),
|
||||
isTransitioning,
|
||||
nextPlayer: playerRef.current.player1(),
|
||||
@@ -156,7 +176,7 @@ export function WebPlayer() {
|
||||
case PlayerStyle.GAPLESS:
|
||||
gaplessHandler({
|
||||
currentTime: e.playedSeconds,
|
||||
duration: getDuration(playerRef.current.player2().ref),
|
||||
duration: getDuration(playerRef.current.player2()),
|
||||
isFlac: false,
|
||||
isTransitioning,
|
||||
nextPlayer: playerRef.current.player1(),
|
||||
@@ -175,7 +195,7 @@ export function WebPlayer() {
|
||||
});
|
||||
|
||||
promise.then(() => {
|
||||
playerRef.current?.player1()?.ref?.getInternalPlayer().pause();
|
||||
playerRef.current?.player1()?.pause();
|
||||
playerRef.current?.setVolume(volume);
|
||||
setIsTransitioning(false);
|
||||
});
|
||||
@@ -188,7 +208,7 @@ export function WebPlayer() {
|
||||
});
|
||||
|
||||
promise.then(() => {
|
||||
playerRef.current?.player2()?.ref?.getInternalPlayer().pause();
|
||||
playerRef.current?.player2()?.pause();
|
||||
playerRef.current?.setVolume(volume);
|
||||
setIsTransitioning(false);
|
||||
});
|
||||
@@ -213,11 +233,11 @@ export function WebPlayer() {
|
||||
if (num === 1) {
|
||||
playerRef.current?.player1()?.setVolume(volume);
|
||||
playerRef.current?.player2()?.setVolume(0);
|
||||
playerRef.current?.player2()?.ref?.getInternalPlayer()?.pause();
|
||||
playerRef.current?.player2()?.pause();
|
||||
} else {
|
||||
playerRef.current?.player2()?.setVolume(volume);
|
||||
playerRef.current?.player1()?.setVolume(0);
|
||||
playerRef.current?.player1()?.ref?.getInternalPlayer()?.pause();
|
||||
playerRef.current?.player1()?.pause();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -241,11 +261,11 @@ export function WebPlayer() {
|
||||
if (num === 1) {
|
||||
playerRef.current?.player1()?.setVolume(volume);
|
||||
playerRef.current?.player2()?.setVolume(0);
|
||||
playerRef.current?.player2()?.ref?.getInternalPlayer()?.pause();
|
||||
playerRef.current?.player2()?.pause();
|
||||
} else {
|
||||
playerRef.current?.player2()?.setVolume(volume);
|
||||
playerRef.current?.player1()?.setVolume(0);
|
||||
playerRef.current?.player1()?.ref?.getInternalPlayer()?.pause();
|
||||
playerRef.current?.player1()?.pause();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -294,14 +314,12 @@ export function WebPlayer() {
|
||||
const interval = setInterval(() => {
|
||||
const activePlayer =
|
||||
num === 1 ? playerRef.current?.player1() : playerRef.current?.player2();
|
||||
const internalPlayer =
|
||||
activePlayer?.ref?.getInternalPlayer() as HTMLAudioElement | null;
|
||||
|
||||
if (!internalPlayer) {
|
||||
if (!activePlayer) {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentTime = internalPlayer.currentTime;
|
||||
const currentTime = activePlayer.getCurrentTime();
|
||||
|
||||
if (
|
||||
transitionType === PlayerStyle.CROSSFADE ||
|
||||
@@ -400,46 +418,110 @@ export function WebPlayer() {
|
||||
const player1Url = useSongUrl(player1, num === 1, transcode);
|
||||
const player2Url = useSongUrl(player2, num === 2, transcode);
|
||||
|
||||
const handlePlayer1Start = useCallback(
|
||||
async (player: ReactPlayer) => {
|
||||
if (!webAudio || player1Source) return;
|
||||
if (player1Url) {
|
||||
// This should fire once, only if the source is real (meaning we
|
||||
// saw the dummy source) and the context is not ready
|
||||
if (webAudio.context.state !== 'running') {
|
||||
await webAudio.context.resume();
|
||||
const disconnectPlayerSource = useCallback(
|
||||
(playerNum: 1 | 2) => {
|
||||
const sourceRef = playerNum === 1 ? player1SourceRef : player2SourceRef;
|
||||
const setSource = playerNum === 1 ? setPlayer1Source : setPlayer2Source;
|
||||
const internalRef = playerNum === 1 ? player1InternalRef : player2InternalRef;
|
||||
|
||||
if (sourceRef.current) {
|
||||
try {
|
||||
sourceRef.current.disconnect();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
const internal = player.getInternalPlayer() as HTMLMediaElement | undefined;
|
||||
if (internal) {
|
||||
const { context, gains } = webAudio;
|
||||
const source = context.createMediaElementSource(internal);
|
||||
source.connect(gains[0]);
|
||||
setPlayer1Source(source);
|
||||
}
|
||||
sourceRef.current = null;
|
||||
internalRef.current = null;
|
||||
setSource(null);
|
||||
},
|
||||
[player1Source, player1Url, webAudio],
|
||||
[setPlayer1Source, setPlayer2Source],
|
||||
);
|
||||
|
||||
const connectPlayerToWebAudio = useCallback(
|
||||
async (playerNum: 1 | 2, player: ReactPlayer) => {
|
||||
if (!webAudio) return;
|
||||
|
||||
const inFlightRef =
|
||||
playerNum === 1 ? player1ConnectInFlightRef : player2ConnectInFlightRef;
|
||||
if (inFlightRef.current) {
|
||||
await inFlightRef.current;
|
||||
return;
|
||||
}
|
||||
|
||||
const internalRef = playerNum === 1 ? player1InternalRef : player2InternalRef;
|
||||
const sourceRef = playerNum === 1 ? player1SourceRef : player2SourceRef;
|
||||
const setSource = playerNum === 1 ? setPlayer1Source : setPlayer2Source;
|
||||
const gain = webAudio.gains[playerNum === 1 ? 0 : 1];
|
||||
|
||||
const task = (async () => {
|
||||
const internal = player.getInternalPlayer() as unknown;
|
||||
|
||||
// YouTube (and some other sources) are not HTMLMediaElements, so WebAudio
|
||||
// can't attach; ensure we drop any stale node from a prior media element.
|
||||
if (!(internal instanceof HTMLMediaElement)) {
|
||||
disconnectPlayerSource(playerNum);
|
||||
return;
|
||||
}
|
||||
|
||||
if (webAudio.context.state !== 'running') {
|
||||
try {
|
||||
await webAudio.context.resume();
|
||||
} catch {
|
||||
// ignore resume failures; we'll try again on next ready
|
||||
}
|
||||
}
|
||||
|
||||
// If the internal media element changed, we must recreate the source node.
|
||||
if (internalRef.current === internal && sourceRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (sourceRef.current) {
|
||||
try {
|
||||
sourceRef.current.disconnect();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
internalRef.current = internal;
|
||||
|
||||
try {
|
||||
const source = webAudio.context.createMediaElementSource(internal);
|
||||
source.connect(gain);
|
||||
sourceRef.current = source;
|
||||
setSource(source);
|
||||
} catch (error) {
|
||||
// Most commonly: trying to create another MediaElementSourceNode for the
|
||||
// same element, or attempting to attach a tainted/cross-origin element.
|
||||
console.error('Error connecting WebAudio source', { error, playerNum });
|
||||
disconnectPlayerSource(playerNum);
|
||||
}
|
||||
})();
|
||||
|
||||
inFlightRef.current = task.finally(() => {
|
||||
inFlightRef.current = null;
|
||||
});
|
||||
|
||||
await inFlightRef.current;
|
||||
},
|
||||
[disconnectPlayerSource, webAudio],
|
||||
);
|
||||
|
||||
const handlePlayer1Start = useCallback(
|
||||
async (player: ReactPlayer) => {
|
||||
await connectPlayerToWebAudio(1, player);
|
||||
},
|
||||
[connectPlayerToWebAudio],
|
||||
);
|
||||
|
||||
const handlePlayer2Start = useCallback(
|
||||
async (player: ReactPlayer) => {
|
||||
if (!webAudio || player2Source) return;
|
||||
if (player2Url) {
|
||||
if (webAudio.context.state !== 'running') {
|
||||
await webAudio.context.resume();
|
||||
}
|
||||
}
|
||||
|
||||
const internal = player.getInternalPlayer() as HTMLMediaElement | undefined;
|
||||
if (internal) {
|
||||
const { context, gains } = webAudio;
|
||||
const source = context.createMediaElementSource(internal);
|
||||
source.connect(gains[1]);
|
||||
setPlayer2Source(source);
|
||||
}
|
||||
await connectPlayerToWebAudio(2, player);
|
||||
},
|
||||
[player2Source, player2Url, webAudio],
|
||||
[connectPlayerToWebAudio],
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -468,6 +550,7 @@ function crossfadeHandler(args: {
|
||||
crossfadeDuration: number;
|
||||
crossfadeStyle: CrossfadeStyle;
|
||||
currentPlayer: {
|
||||
pause: () => void;
|
||||
ref: null | ReactPlayer;
|
||||
setVolume: (volume: number) => void;
|
||||
};
|
||||
@@ -477,6 +560,8 @@ function crossfadeHandler(args: {
|
||||
hasNextSong: boolean;
|
||||
isTransitioning: boolean | string;
|
||||
nextPlayer: {
|
||||
pause: () => void;
|
||||
play: () => void;
|
||||
ref: null | ReactPlayer;
|
||||
setVolume: (volume: number) => void;
|
||||
};
|
||||
@@ -504,7 +589,7 @@ function crossfadeHandler(args: {
|
||||
if (!hasNextSong) {
|
||||
currentPlayer.setVolume(volume);
|
||||
nextPlayer.setVolume(0);
|
||||
nextPlayer.ref?.getInternalPlayer()?.pause();
|
||||
nextPlayer.pause();
|
||||
|
||||
if (isTransitioning) {
|
||||
setIsTransitioning(false);
|
||||
@@ -516,7 +601,7 @@ function crossfadeHandler(args: {
|
||||
if (!isTransitioning) {
|
||||
if (duration > 0 && currentTime > duration - crossfadeDuration) {
|
||||
nextPlayer.setVolume(0);
|
||||
nextPlayer.ref?.getInternalPlayer().play();
|
||||
nextPlayer.play();
|
||||
return setIsTransitioning(player);
|
||||
}
|
||||
|
||||
@@ -586,6 +671,7 @@ function gaplessHandler(args: {
|
||||
isFlac: boolean;
|
||||
isTransitioning: boolean | string;
|
||||
nextPlayer: {
|
||||
play: () => void;
|
||||
ref: null | ReactPlayer;
|
||||
setVolume: (volume: number) => void;
|
||||
};
|
||||
@@ -604,10 +690,8 @@ function gaplessHandler(args: {
|
||||
const durationPadding = getDurationPadding(isFlac);
|
||||
|
||||
if (currentTime + durationPadding >= duration) {
|
||||
return nextPlayer.ref
|
||||
?.getInternalPlayer()
|
||||
?.play()
|
||||
.catch(() => {});
|
||||
nextPlayer.play();
|
||||
return;
|
||||
}
|
||||
|
||||
return null;
|
||||
@@ -647,8 +731,14 @@ function getCrossfadeEasing(style: CrossfadeStyle): {
|
||||
}
|
||||
}
|
||||
|
||||
function getDuration(ref: null | ReactPlayer | undefined) {
|
||||
return ref?.getInternalPlayer()?.duration || 0;
|
||||
function getDuration(
|
||||
player:
|
||||
| undefined
|
||||
| {
|
||||
getDuration: () => number;
|
||||
},
|
||||
) {
|
||||
return player?.getDuration?.() ?? 0;
|
||||
}
|
||||
|
||||
function getDurationPadding(isFlac: boolean) {
|
||||
|
||||
@@ -6,6 +6,10 @@ import { generatePath, Link } from 'react-router';
|
||||
import styles from './full-screen-player-image.module.css';
|
||||
|
||||
import { useItemImageUrl } from '/@/renderer/components/item-image/item-image';
|
||||
import {
|
||||
useIsRadioActive,
|
||||
useRadioPlayer,
|
||||
} from '/@/renderer/features/radio/hooks/use-radio-player';
|
||||
import { AppRoute } from '/@/renderer/router/routes';
|
||||
import { useNativeAspectRatio, usePlayerData, usePlayerSong } from '/@/renderer/store';
|
||||
import { Badge } from '/@/shared/components/badge/badge';
|
||||
@@ -45,8 +49,9 @@ const MotionImage = motion.img;
|
||||
|
||||
const ImageWithPlaceholder = ({
|
||||
className,
|
||||
placeholderIcon = 'itemAlbum',
|
||||
...props
|
||||
}: HTMLMotionProps<'img'> & { placeholder?: string }) => {
|
||||
}: HTMLMotionProps<'img'> & { placeholder?: string; placeholderIcon?: 'itemAlbum' | 'radio' }) => {
|
||||
const nativeAspectRatio = useNativeAspectRatio();
|
||||
|
||||
if (!props.src) {
|
||||
@@ -59,7 +64,7 @@ const ImageWithPlaceholder = ({
|
||||
width: '100%',
|
||||
}}
|
||||
>
|
||||
<Icon color="muted" icon="itemAlbum" size="25%" />
|
||||
<Icon color="muted" icon={placeholderIcon} size="25%" />
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
@@ -79,11 +84,17 @@ const ImageWithPlaceholder = ({
|
||||
export const FullScreenPlayerImage = () => {
|
||||
const mainImageRef = useRef<HTMLImageElement | null>(null);
|
||||
|
||||
const isRadioActive = useIsRadioActive();
|
||||
const { isPlaying: isRadioPlaying, metadata: radioMetadata, stationName } = useRadioPlayer();
|
||||
|
||||
const currentSong = usePlayerSong();
|
||||
const { nextSong } = usePlayerData();
|
||||
|
||||
const isPlayingRadio = isRadioActive && isRadioPlaying;
|
||||
|
||||
const currentImageUrl = useItemImageUrl({
|
||||
id: currentSong?.imageId || undefined,
|
||||
imageUrl: currentSong?.imageUrl,
|
||||
itemType: LibraryItem.SONG,
|
||||
serverId: currentSong?._serverId,
|
||||
type: 'fullScreenPlayer',
|
||||
@@ -91,6 +102,7 @@ export const FullScreenPlayerImage = () => {
|
||||
|
||||
const nextImageUrl = useItemImageUrl({
|
||||
id: nextSong?.imageId || undefined,
|
||||
imageUrl: nextSong?.imageUrl,
|
||||
itemType: LibraryItem.SONG,
|
||||
serverId: nextSong?._serverId,
|
||||
type: 'fullScreenPlayer',
|
||||
@@ -111,8 +123,11 @@ export const FullScreenPlayerImage = () => {
|
||||
imageStateRef.current = imageState;
|
||||
}, [imageState]);
|
||||
|
||||
// Update images when song or size changes
|
||||
// Update images when song or size changes (skip when playing radio - no album art)
|
||||
useEffect(() => {
|
||||
if (isPlayingRadio) {
|
||||
return;
|
||||
}
|
||||
if (currentSong?._uniqueId === previousSongRef.current) {
|
||||
return;
|
||||
}
|
||||
@@ -126,7 +141,14 @@ export const FullScreenPlayerImage = () => {
|
||||
});
|
||||
|
||||
previousSongRef.current = currentSong?._uniqueId;
|
||||
}, [currentSong?._uniqueId, currentImageUrl, nextSong?._uniqueId, nextImageUrl, setImageState]);
|
||||
}, [
|
||||
isPlayingRadio,
|
||||
currentSong?._uniqueId,
|
||||
currentImageUrl,
|
||||
nextSong?._uniqueId,
|
||||
nextImageUrl,
|
||||
setImageState,
|
||||
]);
|
||||
|
||||
return (
|
||||
<Flex
|
||||
@@ -138,7 +160,7 @@ export const FullScreenPlayerImage = () => {
|
||||
>
|
||||
<div className={styles.imageContainer} ref={mainImageRef}>
|
||||
<AnimatePresence initial={false} mode="sync">
|
||||
{imageState.current === 0 && (
|
||||
{!isPlayingRadio && imageState.current === 0 && (
|
||||
<ImageWithPlaceholder
|
||||
animate="open"
|
||||
className="full-screen-player-image"
|
||||
@@ -153,7 +175,7 @@ export const FullScreenPlayerImage = () => {
|
||||
/>
|
||||
)}
|
||||
|
||||
{imageState.current === 1 && (
|
||||
{!isPlayingRadio && imageState.current === 1 && (
|
||||
<ImageWithPlaceholder
|
||||
animate="open"
|
||||
className="full-screen-player-image"
|
||||
@@ -167,57 +189,85 @@ export const FullScreenPlayerImage = () => {
|
||||
variants={imageVariants}
|
||||
/>
|
||||
)}
|
||||
|
||||
{isPlayingRadio && (
|
||||
<ImageWithPlaceholder
|
||||
animate="open"
|
||||
className="full-screen-player-image"
|
||||
custom={{ isOpen: true }}
|
||||
draggable={false}
|
||||
exit="closed"
|
||||
initial="closed"
|
||||
key="radio"
|
||||
placeholder="var(--theme-colors-foreground-muted)"
|
||||
placeholderIcon="radio"
|
||||
src=""
|
||||
variants={imageVariants}
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
<Stack className={styles.metadataContainer} gap="md" maw="100%">
|
||||
<Text fw={900} lh="1.2" overflow="hidden" size="4xl" w="100%">
|
||||
{currentSong?.name}
|
||||
</Text>
|
||||
<Text
|
||||
component={Link}
|
||||
isLink
|
||||
overflow="hidden"
|
||||
size="xl"
|
||||
to={generatePath(AppRoute.LIBRARY_ALBUMS_DETAIL, {
|
||||
albumId: currentSong?.albumId || '',
|
||||
})}
|
||||
w="100%"
|
||||
>
|
||||
{currentSong?.album}
|
||||
{isPlayingRadio
|
||||
? radioMetadata?.title || stationName || 'Radio'
|
||||
: currentSong?.name}
|
||||
</Text>
|
||||
{isPlayingRadio ? (
|
||||
<Text overflow="hidden" size="xl" w="100%">
|
||||
{stationName || 'Radio'}
|
||||
</Text>
|
||||
) : (
|
||||
<Text
|
||||
component={Link}
|
||||
isLink
|
||||
overflow="hidden"
|
||||
size="xl"
|
||||
to={generatePath(AppRoute.LIBRARY_ALBUMS_DETAIL, {
|
||||
albumId: currentSong?.albumId || '',
|
||||
})}
|
||||
w="100%"
|
||||
>
|
||||
{currentSong?.album}
|
||||
</Text>
|
||||
)}
|
||||
<Text key="fs-artists">
|
||||
{currentSong?.artists?.map((artist, index) => (
|
||||
<Fragment key={`fs-artist-${artist.id}`}>
|
||||
{index > 0 && (
|
||||
<Text
|
||||
style={{
|
||||
display: 'inline-block',
|
||||
padding: '0 0.5rem',
|
||||
}}
|
||||
>
|
||||
•
|
||||
</Text>
|
||||
)}
|
||||
<Text
|
||||
component={Link}
|
||||
isLink
|
||||
to={generatePath(AppRoute.LIBRARY_ALBUM_ARTISTS_DETAIL, {
|
||||
albumArtistId: artist.id,
|
||||
})}
|
||||
>
|
||||
{artist.name}
|
||||
</Text>
|
||||
</Fragment>
|
||||
))}
|
||||
{isPlayingRadio
|
||||
? radioMetadata?.artist || stationName || 'Radio'
|
||||
: currentSong?.artists?.map((artist, index) => (
|
||||
<Fragment key={`fs-artist-${artist.id}`}>
|
||||
{index > 0 && (
|
||||
<Text
|
||||
style={{
|
||||
display: 'inline-block',
|
||||
padding: '0 0.5rem',
|
||||
}}
|
||||
>
|
||||
•
|
||||
</Text>
|
||||
)}
|
||||
<Text
|
||||
component={Link}
|
||||
isLink
|
||||
to={generatePath(AppRoute.LIBRARY_ALBUM_ARTISTS_DETAIL, {
|
||||
albumArtistId: artist.id,
|
||||
})}
|
||||
>
|
||||
{artist.name}
|
||||
</Text>
|
||||
</Fragment>
|
||||
))}
|
||||
</Text>
|
||||
<Group justify="center" mt="sm">
|
||||
{currentSong?.container && (
|
||||
<Badge variant="transparent">{currentSong?.container}</Badge>
|
||||
)}
|
||||
{currentSong?.releaseYear && (
|
||||
<Badge variant="transparent">{currentSong?.releaseYear}</Badge>
|
||||
)}
|
||||
</Group>
|
||||
{!isPlayingRadio && (
|
||||
<Group justify="center" mt="sm">
|
||||
{currentSong?.container && (
|
||||
<Badge variant="transparent">{currentSong?.container}</Badge>
|
||||
)}
|
||||
{currentSong?.releaseYear && (
|
||||
<Badge variant="transparent">{currentSong?.releaseYear}</Badge>
|
||||
)}
|
||||
</Group>
|
||||
)}
|
||||
</Stack>
|
||||
</Flex>
|
||||
);
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
z-index: 5100;
|
||||
z-index: 200;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 2rem;
|
||||
|
||||
@@ -17,6 +17,10 @@ import { useItemImageUrl } from '/@/renderer/components/item-image/item-image';
|
||||
import { SONG_TABLE_COLUMNS } from '/@/renderer/components/item-list/item-table-list/default-columns';
|
||||
import { FullScreenPlayerImage } from '/@/renderer/features/player/components/full-screen-player-image';
|
||||
import { FullScreenPlayerQueue } from '/@/renderer/features/player/components/full-screen-player-queue';
|
||||
import {
|
||||
useIsRadioActive,
|
||||
useRadioPlayer,
|
||||
} from '/@/renderer/features/radio/hooks/use-radio-player';
|
||||
import { ListConfigMenu } from '/@/renderer/features/shared/components/list-config-menu';
|
||||
import { useFastAverageColor } from '/@/renderer/hooks';
|
||||
import {
|
||||
@@ -79,13 +83,17 @@ const BackgroundImage = memo(({ dynamicBackground, dynamicIsImage }: BackgroundI
|
||||
|
||||
const currentImageUrl = useItemImageUrl({
|
||||
id: currentSong?.imageId || undefined,
|
||||
imageUrl: currentSong?.imageUrl,
|
||||
itemType: LibraryItem.SONG,
|
||||
serverId: currentSong?._serverId,
|
||||
type: 'itemCard',
|
||||
});
|
||||
|
||||
const nextImageUrl = useItemImageUrl({
|
||||
id: nextSong?.imageId || undefined,
|
||||
imageUrl: nextSong?.imageUrl,
|
||||
itemType: LibraryItem.SONG,
|
||||
serverId: nextSong?._serverId,
|
||||
type: 'itemCard',
|
||||
});
|
||||
|
||||
@@ -657,6 +665,11 @@ export const FullScreenPlayer = () => {
|
||||
const { dynamicBackground, dynamicImageBlur, dynamicIsImage } = useFullScreenPlayerStore();
|
||||
const { setStore } = useFullScreenPlayerStoreActions();
|
||||
const { windowBarStyle } = useWindowSettings();
|
||||
const isRadioActive = useIsRadioActive();
|
||||
const { isPlaying: isRadioPlaying } = useRadioPlayer();
|
||||
|
||||
const isPlayingRadio = isRadioActive && isRadioPlaying;
|
||||
const effectiveDynamicBackground = dynamicBackground && !isPlayingRadio;
|
||||
|
||||
const location = useLocation();
|
||||
const isOpenedRef = useRef<boolean | null>(null);
|
||||
@@ -671,13 +684,13 @@ export const FullScreenPlayer = () => {
|
||||
|
||||
return (
|
||||
<PlayerContainer
|
||||
dynamicBackground={dynamicBackground}
|
||||
dynamicBackground={effectiveDynamicBackground}
|
||||
dynamicIsImage={dynamicIsImage}
|
||||
windowBarStyle={windowBarStyle}
|
||||
>
|
||||
<Controls />
|
||||
<BackgroundImageOverlay
|
||||
dynamicBackground={dynamicBackground}
|
||||
dynamicBackground={effectiveDynamicBackground}
|
||||
dynamicImageBlur={dynamicImageBlur}
|
||||
/>
|
||||
<div className={styles.responsiveContainer}>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
z-index: 5100;
|
||||
z-index: 200;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
|
||||
@@ -50,6 +50,11 @@
|
||||
object-fit: var(--theme-image-fit);
|
||||
}
|
||||
|
||||
.radio-image {
|
||||
background: var(--theme-colors-surface);
|
||||
border-radius: var(--theme-radius-md);
|
||||
}
|
||||
|
||||
.line-item {
|
||||
display: inline-block;
|
||||
width: 100%;
|
||||
|
||||
@@ -21,7 +21,9 @@ import {
|
||||
useSetFullScreenPlayerStore,
|
||||
} from '/@/renderer/store';
|
||||
import { ActionIcon } from '/@/shared/components/action-icon/action-icon';
|
||||
import { Center } from '/@/shared/components/center/center';
|
||||
import { Group } from '/@/shared/components/group/group';
|
||||
import { Icon } from '/@/shared/components/icon/icon';
|
||||
import { Separator } from '/@/shared/components/separator/separator';
|
||||
import { Text } from '/@/shared/components/text/text';
|
||||
import { Tooltip } from '/@/shared/components/tooltip/tooltip';
|
||||
@@ -48,7 +50,7 @@ export const LeftControls = () => {
|
||||
const { bindings } = useHotkeySettings();
|
||||
|
||||
const isRadioMode = isRadioActive;
|
||||
const hideImage = (image && !collapsed) || isRadioMode;
|
||||
const hideImage = image && !collapsed;
|
||||
const isSongDefined = Boolean(currentSong?.id) && !isRadioMode;
|
||||
const title = currentSong?.name;
|
||||
const artists = currentSong?.artists;
|
||||
@@ -116,19 +118,32 @@ export const LeftControls = () => {
|
||||
})}
|
||||
openDelay={0}
|
||||
>
|
||||
<ItemImage
|
||||
className={clsx(
|
||||
styles.playerbarImage,
|
||||
PlaybackSelectors.playerCoverArt,
|
||||
)}
|
||||
enableDebounce={false}
|
||||
enableViewport={false}
|
||||
fetchPriority="high"
|
||||
id={currentSong?.imageId}
|
||||
itemType={LibraryItem.SONG}
|
||||
serverId={currentSong?._serverId}
|
||||
type="table"
|
||||
/>
|
||||
{isRadioMode ? (
|
||||
<Center
|
||||
className={clsx(
|
||||
styles.playerbarImage,
|
||||
styles.radioImage,
|
||||
)}
|
||||
>
|
||||
<Icon color="muted" icon="radio" size="40%" />
|
||||
</Center>
|
||||
) : (
|
||||
<ItemImage
|
||||
className={clsx(
|
||||
styles.playerbarImage,
|
||||
PlaybackSelectors.playerCoverArt,
|
||||
)}
|
||||
enableDebounce={false}
|
||||
enableViewport={false}
|
||||
explicitStatus={currentSong?.explicitStatus}
|
||||
fetchPriority="high"
|
||||
id={currentSong?.imageId}
|
||||
itemType={LibraryItem.SONG}
|
||||
serverId={currentSong?._serverId}
|
||||
src={currentSong?.imageUrl}
|
||||
type="table"
|
||||
/>
|
||||
)}
|
||||
</Tooltip>
|
||||
{!collapsed && (
|
||||
<ActionIcon
|
||||
|
||||
@@ -5,6 +5,10 @@ import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react
|
||||
import styles from './mobile-fullscreen-player.module.css';
|
||||
|
||||
import { useItemImageUrl } from '/@/renderer/components/item-image/item-image';
|
||||
import {
|
||||
useIsRadioActive,
|
||||
useRadioPlayer,
|
||||
} from '/@/renderer/features/radio/hooks/use-radio-player';
|
||||
import {
|
||||
useFullScreenPlayerStore,
|
||||
useImageRes,
|
||||
@@ -44,9 +48,14 @@ const MotionImage = motion.img;
|
||||
|
||||
const ImageWithPlaceholder = ({
|
||||
className,
|
||||
placeholderIcon,
|
||||
useImageAspectRatio,
|
||||
...props
|
||||
}: HTMLMotionProps<'img'> & { placeholder?: string; useImageAspectRatio?: boolean }) => {
|
||||
}: HTMLMotionProps<'img'> & {
|
||||
placeholder?: string;
|
||||
placeholderIcon?: 'itemAlbum' | 'radio';
|
||||
useImageAspectRatio?: boolean;
|
||||
}) => {
|
||||
if (!props.src) {
|
||||
return (
|
||||
<Center
|
||||
@@ -57,7 +66,11 @@ const ImageWithPlaceholder = ({
|
||||
width: '100%',
|
||||
}}
|
||||
>
|
||||
<Icon color="muted" icon="itemAlbum" size="25%" />
|
||||
<Icon
|
||||
color="muted"
|
||||
icon={placeholderIcon === 'radio' ? 'radio' : 'itemAlbum'}
|
||||
size="25%"
|
||||
/>
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
@@ -80,19 +93,27 @@ export const MobileFullscreenPlayerAlbumArt = () => {
|
||||
|
||||
const { fullScreenPlayer: albumArtRes } = useImageRes();
|
||||
const { useImageAspectRatio } = useFullScreenPlayerStore();
|
||||
const isRadioActive = useIsRadioActive();
|
||||
const { isPlaying: isRadioPlaying } = useRadioPlayer();
|
||||
const currentSong = usePlayerSong();
|
||||
const { nextSong } = usePlayerData();
|
||||
|
||||
const isPlayingRadio = isRadioActive && isRadioPlaying;
|
||||
|
||||
const currentImageUrl = useItemImageUrl({
|
||||
id: currentSong?.imageId || undefined,
|
||||
imageUrl: currentSong?.imageUrl,
|
||||
itemType: LibraryItem.SONG,
|
||||
serverId: currentSong?._serverId,
|
||||
size: mainImageDimensions.idealSize,
|
||||
type: 'fullScreenPlayer',
|
||||
});
|
||||
|
||||
const nextImageUrl = useItemImageUrl({
|
||||
id: nextSong?.imageId || undefined,
|
||||
imageUrl: nextSong?.imageUrl,
|
||||
itemType: LibraryItem.SONG,
|
||||
serverId: nextSong?._serverId,
|
||||
size: mainImageDimensions.idealSize,
|
||||
type: 'fullScreenPlayer',
|
||||
});
|
||||
@@ -151,38 +172,58 @@ export const MobileFullscreenPlayerAlbumArt = () => {
|
||||
})}
|
||||
>
|
||||
<AnimatePresence initial={false} mode="sync">
|
||||
{imageState.current === 0 && (
|
||||
{isPlayingRadio ? (
|
||||
<ImageWithPlaceholder
|
||||
animate="open"
|
||||
className={PlaybackSelectors.playerCoverArt}
|
||||
custom={{ isOpen: imageState.current === 0 }}
|
||||
custom={{ isOpen: true }}
|
||||
draggable={false}
|
||||
exit="closed"
|
||||
initial="closed"
|
||||
key={`top-${currentSong?._uniqueId || 'none'}`}
|
||||
key="radio"
|
||||
loading="eager"
|
||||
placeholder="var(--theme-colors-foreground-muted)"
|
||||
src={imageState.topImage || ''}
|
||||
placeholderIcon="radio"
|
||||
src=""
|
||||
useImageAspectRatio={useImageAspectRatio}
|
||||
variants={imageVariants}
|
||||
/>
|
||||
)}
|
||||
) : (
|
||||
<>
|
||||
{imageState.current === 0 && (
|
||||
<ImageWithPlaceholder
|
||||
animate="open"
|
||||
className={PlaybackSelectors.playerCoverArt}
|
||||
custom={{ isOpen: imageState.current === 0 }}
|
||||
draggable={false}
|
||||
exit="closed"
|
||||
initial="closed"
|
||||
key={`top-${currentSong?._uniqueId || 'none'}`}
|
||||
loading="eager"
|
||||
placeholder="var(--theme-colors-foreground-muted)"
|
||||
src={imageState.topImage || ''}
|
||||
useImageAspectRatio={useImageAspectRatio}
|
||||
variants={imageVariants}
|
||||
/>
|
||||
)}
|
||||
|
||||
{imageState.current === 1 && (
|
||||
<ImageWithPlaceholder
|
||||
animate="open"
|
||||
className={PlaybackSelectors.playerCoverArt}
|
||||
custom={{ isOpen: imageState.current === 1 }}
|
||||
draggable={false}
|
||||
exit="closed"
|
||||
initial="closed"
|
||||
key={`bottom-${currentSong?._uniqueId || 'none'}`}
|
||||
loading="eager"
|
||||
placeholder="var(--theme-colors-foreground-muted)"
|
||||
src={imageState.bottomImage || ''}
|
||||
useImageAspectRatio={useImageAspectRatio}
|
||||
variants={imageVariants}
|
||||
/>
|
||||
{imageState.current === 1 && (
|
||||
<ImageWithPlaceholder
|
||||
animate="open"
|
||||
className={PlaybackSelectors.playerCoverArt}
|
||||
custom={{ isOpen: imageState.current === 1 }}
|
||||
draggable={false}
|
||||
exit="closed"
|
||||
initial="closed"
|
||||
key={`bottom-${currentSong?._uniqueId || 'none'}`}
|
||||
loading="eager"
|
||||
placeholder="var(--theme-colors-foreground-muted)"
|
||||
src={imageState.bottomImage || ''}
|
||||
useImageAspectRatio={useImageAspectRatio}
|
||||
variants={imageVariants}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
@@ -16,6 +16,9 @@ interface MobileFullscreenPlayerMetadataProps {
|
||||
currentSong?: QueueSong;
|
||||
onToggleFavorite: (e: MouseEvent<HTMLButtonElement>) => void;
|
||||
onUpdateRating: (rating: number) => void;
|
||||
radioArtist?: string;
|
||||
radioStationName?: string;
|
||||
radioTitle?: string;
|
||||
showRating?: boolean;
|
||||
}
|
||||
|
||||
@@ -24,17 +27,24 @@ export const MobileFullscreenPlayerMetadata = memo(
|
||||
currentSong,
|
||||
onToggleFavorite,
|
||||
onUpdateRating,
|
||||
radioArtist,
|
||||
radioStationName,
|
||||
radioTitle,
|
||||
showRating,
|
||||
}: MobileFullscreenPlayerMetadataProps) => {
|
||||
const title = currentSong?.name;
|
||||
const artists = currentSong?.artists;
|
||||
const album = currentSong?.album;
|
||||
const isRadio = radioTitle !== undefined || radioStationName !== undefined;
|
||||
|
||||
const title = isRadio ? radioTitle || radioStationName || 'Radio' : currentSong?.name;
|
||||
const artistsDisplay = isRadio
|
||||
? radioArtist || radioStationName || '—'
|
||||
: currentSong?.artists?.map((a) => a.name).join(', ');
|
||||
const album = isRadio ? radioStationName || '—' : currentSong?.album;
|
||||
const container = currentSong?.container;
|
||||
const year = currentSong?.releaseYear;
|
||||
const isFavorite = currentSong?.userFavorite;
|
||||
const rating = currentSong?.userRating;
|
||||
|
||||
const hasMetadata = container || year;
|
||||
const hasMetadata = !isRadio && (container || year);
|
||||
|
||||
return (
|
||||
<div className={styles.metadataContainer}>
|
||||
@@ -49,7 +59,7 @@ export const MobileFullscreenPlayerMetadata = memo(
|
||||
</TextTitle>
|
||||
</div>
|
||||
<Text className={clsx(PlaybackSelectors.songArtist)} size="md" truncate>
|
||||
{artists?.map((a) => a.name).join(', ') || '—'}
|
||||
{artistsDisplay || '—'}
|
||||
</Text>
|
||||
<Text className={clsx(PlaybackSelectors.songAlbum)} size="md" truncate>
|
||||
{album || '—'}
|
||||
@@ -65,21 +75,23 @@ export const MobileFullscreenPlayerMetadata = memo(
|
||||
)}
|
||||
</Group>
|
||||
)}
|
||||
<Group align="center" className={styles.actionsRow} gap="xs">
|
||||
<ActionIcon
|
||||
icon="favorite"
|
||||
iconProps={{
|
||||
fill: isFavorite ? 'primary' : undefined,
|
||||
size: 'md',
|
||||
}}
|
||||
onClick={onToggleFavorite}
|
||||
size="sm"
|
||||
variant="subtle"
|
||||
/>
|
||||
{showRating && (
|
||||
<Rating onChange={onUpdateRating} size="sm" value={rating || 0} />
|
||||
)}
|
||||
</Group>
|
||||
{!isRadio && (
|
||||
<Group align="center" className={styles.actionsRow} gap="xs">
|
||||
<ActionIcon
|
||||
icon="favorite"
|
||||
iconProps={{
|
||||
fill: isFavorite ? 'primary' : undefined,
|
||||
size: 'md',
|
||||
}}
|
||||
onClick={onToggleFavorite}
|
||||
size="sm"
|
||||
variant="subtle"
|
||||
/>
|
||||
{showRating && (
|
||||
<Rating onChange={onUpdateRating} size="sm" value={rating || 0} />
|
||||
)}
|
||||
</Group>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
|
||||
@@ -24,6 +24,10 @@ import { MobileFullscreenPlayerControls } from '/@/renderer/features/player/comp
|
||||
import { MobileFullscreenPlayerHeader } from '/@/renderer/features/player/components/mobile-fullscreen-player-header';
|
||||
import { MobileFullscreenPlayerMetadata } from '/@/renderer/features/player/components/mobile-fullscreen-player-metadata';
|
||||
import { MobileFullscreenPlayerProgress } from '/@/renderer/features/player/components/mobile-fullscreen-player-progress';
|
||||
import {
|
||||
useIsRadioActive,
|
||||
useRadioPlayer,
|
||||
} from '/@/renderer/features/radio/hooks/use-radio-player';
|
||||
import { useSetFavorite } from '/@/renderer/features/shared/hooks/use-set-favorite';
|
||||
import { useSetRating } from '/@/renderer/features/shared/hooks/use-set-rating';
|
||||
import { useFastAverageColor } from '/@/renderer/hooks';
|
||||
@@ -31,6 +35,7 @@ import {
|
||||
useCurrentServer,
|
||||
useFullScreenPlayerStore,
|
||||
useFullScreenPlayerStoreActions,
|
||||
useGeneralSettings,
|
||||
usePlayerData,
|
||||
usePlayerSong,
|
||||
useSetFullScreenPlayerStore,
|
||||
@@ -76,13 +81,17 @@ const BackgroundImage = memo(({ dynamicBackground, dynamicIsImage }: BackgroundI
|
||||
|
||||
const currentImageUrl = useItemImageUrl({
|
||||
id: currentSong?.imageId || undefined,
|
||||
imageUrl: currentSong?.imageUrl,
|
||||
itemType: LibraryItem.SONG,
|
||||
serverId: currentSong?._serverId,
|
||||
type: 'itemCard',
|
||||
});
|
||||
|
||||
const nextImageUrl = useItemImageUrl({
|
||||
id: nextSong?.imageId || undefined,
|
||||
imageUrl: nextSong?.imageUrl,
|
||||
itemType: LibraryItem.SONG,
|
||||
serverId: nextSong?._serverId,
|
||||
type: 'itemCard',
|
||||
});
|
||||
|
||||
@@ -305,6 +314,7 @@ const MobilePlayerContainer = memo(
|
||||
id: currentSong?.imageId || undefined,
|
||||
imageUrl: currentSong?.imageUrl,
|
||||
itemType: LibraryItem.SONG,
|
||||
serverId: currentSong?._serverId,
|
||||
type: 'itemCard',
|
||||
});
|
||||
const { background } = useFastAverageColor({
|
||||
@@ -375,8 +385,14 @@ export const MobileFullscreenPlayer = () => {
|
||||
useFullScreenPlayerStore();
|
||||
const currentSong = usePlayerSong();
|
||||
const { currentSong: currentSongData } = usePlayerData();
|
||||
const isRadioActive = useIsRadioActive();
|
||||
const { isPlaying: isRadioPlaying, metadata: radioMetadata, stationName } = useRadioPlayer();
|
||||
const server = useCurrentServer();
|
||||
|
||||
const isPlayingRadio = isRadioActive && isRadioPlaying;
|
||||
const effectiveDynamicBackground = dynamicBackground && !isPlayingRadio;
|
||||
const setFavorite = useSetFavorite();
|
||||
const { showRatings: showRatingsSetting } = useGeneralSettings();
|
||||
const setRating = useSetRating();
|
||||
|
||||
const [isPageHovered, setIsPageHovered] = useState(false);
|
||||
@@ -435,16 +451,17 @@ export const MobileFullscreenPlayer = () => {
|
||||
const isLyricsState = activeTab === 'lyrics';
|
||||
const isSongDefined = Boolean(currentSong?.id);
|
||||
const showRating =
|
||||
showRatingsSetting &&
|
||||
isSongDefined &&
|
||||
(server?.type === ServerType.NAVIDROME || server?.type === ServerType.SUBSONIC);
|
||||
|
||||
return (
|
||||
<MobilePlayerContainer
|
||||
dynamicBackground={dynamicBackground}
|
||||
dynamicBackground={effectiveDynamicBackground}
|
||||
dynamicIsImage={dynamicIsImage}
|
||||
>
|
||||
<BackgroundImageOverlay
|
||||
dynamicBackground={dynamicBackground}
|
||||
dynamicBackground={effectiveDynamicBackground}
|
||||
dynamicImageBlur={dynamicImageBlur}
|
||||
/>
|
||||
<motion.div
|
||||
@@ -467,6 +484,9 @@ export const MobileFullscreenPlayer = () => {
|
||||
currentSong={currentSong}
|
||||
onToggleFavorite={handleToggleFavorite}
|
||||
onUpdateRating={handleUpdateRating}
|
||||
radioArtist={isPlayingRadio ? (radioMetadata?.artist ?? undefined) : undefined}
|
||||
radioStationName={isPlayingRadio ? (stationName ?? undefined) : undefined}
|
||||
radioTitle={isPlayingRadio ? (radioMetadata?.title ?? undefined) : undefined}
|
||||
showRating={showRating}
|
||||
/>
|
||||
<MobileFullscreenPlayerProgress currentSong={currentSong} />
|
||||
|
||||
@@ -94,9 +94,12 @@ export const MobilePlayerbar = () => {
|
||||
)}
|
||||
enableDebounce={false}
|
||||
enableViewport={false}
|
||||
explicitStatus={currentSong.explicitStatus}
|
||||
fetchPriority="high"
|
||||
id={currentSong.imageId}
|
||||
itemType={LibraryItem.SONG}
|
||||
serverId={currentSong?._serverId}
|
||||
src={currentSong?.imageUrl}
|
||||
type="table"
|
||||
/>
|
||||
</Tooltip>
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
useAutoDJSettings,
|
||||
useCurrentServer,
|
||||
useFullScreenPlayerStore,
|
||||
useGeneralSettings,
|
||||
useHotkeySettings,
|
||||
usePlayerData,
|
||||
usePlayerMuted,
|
||||
@@ -63,10 +64,11 @@ const calculateVolumeDown = (volume: number, volumeWheelStep: number) => {
|
||||
};
|
||||
|
||||
export const RightControls = () => {
|
||||
const { showRatings } = useGeneralSettings();
|
||||
return (
|
||||
<Flex align="flex-end" direction="column" h="100%" px="1rem" py="0.5rem">
|
||||
<Group h="calc(100% / 3)">
|
||||
<RatingButton />
|
||||
{showRatings && <RatingButton />}
|
||||
<AutoDJButton />
|
||||
</Group>
|
||||
<Group align="center" gap="xs" wrap="nowrap">
|
||||
|
||||
@@ -890,6 +890,9 @@ export const usePlayer = () => {
|
||||
* @param args - The arguments to use to fetch the data
|
||||
* @returns The songs to add to the queue
|
||||
*/
|
||||
|
||||
const EXTERNAL_SERVER_ID = 'musicbrainz';
|
||||
|
||||
export async function fetchSongsByItemType(
|
||||
queryClient: QueryClient,
|
||||
serverId: string,
|
||||
@@ -901,6 +904,23 @@ export async function fetchSongsByItemType(
|
||||
) {
|
||||
const songs: Song[] = [];
|
||||
|
||||
if (serverId === EXTERNAL_SERVER_ID) {
|
||||
if (args.itemType === LibraryItem.ALBUM) {
|
||||
for (const albumId of args.id) {
|
||||
const album = await queryClient.fetchQuery(
|
||||
albumQueries.detail({
|
||||
query: { id: albumId },
|
||||
serverId: EXTERNAL_SERVER_ID,
|
||||
}),
|
||||
);
|
||||
songs.push(...(album?.songs ?? []));
|
||||
}
|
||||
return songs;
|
||||
}
|
||||
|
||||
return songs;
|
||||
}
|
||||
|
||||
switch (args.itemType) {
|
||||
case LibraryItem.ALBUM: {
|
||||
const albumSongsResponse = await getAlbumSongsById({
|
||||
|
||||
@@ -8,8 +8,8 @@ import {
|
||||
useRadioPlayer,
|
||||
} from '/@/renderer/features/radio/hooks/use-radio-player';
|
||||
import { usePlayerSong, usePlayerStore } from '/@/renderer/store';
|
||||
import { LibraryItem, QueueSong } from '/@/shared/types/domain-types';
|
||||
import { PlayerShuffle, ServerType } from '/@/shared/types/types';
|
||||
import { LibraryItem, QueueSong, ServerType } from '/@/shared/types/domain-types';
|
||||
import { PlayerShuffle } from '/@/shared/types/types';
|
||||
|
||||
const ipc = isElectron() ? window.api.ipc : null;
|
||||
const utils = isElectron() ? window.api.utils : null;
|
||||
|
||||
@@ -7,7 +7,7 @@ import { queryKeys } from '/@/renderer/api/query-keys';
|
||||
import { usePlayerEvents } from '/@/renderer/features/player/audio-player/hooks/use-player-events';
|
||||
import { updateQueueSong } from '/@/renderer/store/player.store';
|
||||
import { LogCategory, logFn } from '/@/renderer/utils/logger';
|
||||
import { QueueSong, SongDetailQuery } from '/@/shared/types/domain-types';
|
||||
import { QueueSong, ServerType, SongDetailQuery } from '/@/shared/types/domain-types';
|
||||
|
||||
export const useUpdateCurrentSong = () => {
|
||||
const queryClient = useQueryClient();
|
||||
@@ -16,7 +16,11 @@ export const useUpdateCurrentSong = () => {
|
||||
async (properties: { index: number; song: QueueSong | undefined }) => {
|
||||
const currentSong = properties.song;
|
||||
|
||||
if (!currentSong?.id || !currentSong?._serverId) {
|
||||
if (
|
||||
!currentSong?.id ||
|
||||
!currentSong?._serverId ||
|
||||
currentSong?._serverType === ServerType.EXTERNAL
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -41,6 +41,16 @@ export const useSendScrobble = (options?: MutationOptions) => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ['home', 'mostPlayed'],
|
||||
});
|
||||
|
||||
// Invalidate album artist top songs
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.albumArtists.topSongs(serverId),
|
||||
});
|
||||
|
||||
// Invalidate album artist favorite songs
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.albumArtists.favoriteSongs(serverId),
|
||||
});
|
||||
}
|
||||
},
|
||||
...options,
|
||||
|
||||
@@ -166,6 +166,7 @@ export const CommandPalette = ({ modalProps }: CommandPaletteProps) => {
|
||||
>
|
||||
{({ isHighlighted }) => (
|
||||
<LibraryCommandItem
|
||||
explicitStatus={album.explicitStatus}
|
||||
id={album.id}
|
||||
imageId={album.imageId}
|
||||
imageUrl={album.imageUrl}
|
||||
@@ -238,6 +239,7 @@ export const CommandPalette = ({ modalProps }: CommandPaletteProps) => {
|
||||
>
|
||||
{({ isHighlighted }) => (
|
||||
<LibraryCommandItem
|
||||
explicitStatus={song.explicitStatus}
|
||||
id={song.id}
|
||||
imageId={song.imageId}
|
||||
imageUrl={song.imageUrl}
|
||||
|
||||
@@ -13,11 +13,12 @@ import { useCurrentServer } from '/@/renderer/store';
|
||||
import { ActionIcon, ActionIconGroup } from '/@/shared/components/action-icon/action-icon';
|
||||
import { Flex } from '/@/shared/components/flex/flex';
|
||||
import { Text } from '/@/shared/components/text/text';
|
||||
import { LibraryItem, Song } from '/@/shared/types/domain-types';
|
||||
import { ExplicitStatus, LibraryItem, Song } from '/@/shared/types/domain-types';
|
||||
import { Play } from '/@/shared/types/types';
|
||||
|
||||
interface LibraryCommandItemProps {
|
||||
disabled?: boolean;
|
||||
explicitStatus?: ExplicitStatus | null;
|
||||
id: string;
|
||||
imageId: null | string;
|
||||
imageUrl: null | string;
|
||||
@@ -30,6 +31,7 @@ interface LibraryCommandItemProps {
|
||||
|
||||
export const LibraryCommandItem = ({
|
||||
disabled,
|
||||
explicitStatus,
|
||||
id,
|
||||
imageId,
|
||||
imageUrl,
|
||||
@@ -100,6 +102,7 @@ export const LibraryCommandItem = ({
|
||||
<ItemImage
|
||||
alt="cover"
|
||||
className={styles.image}
|
||||
explicitStatus={explicitStatus ?? song?.explicitStatus ?? null}
|
||||
height={40}
|
||||
id={imageId}
|
||||
itemType={itemType}
|
||||
|
||||
@@ -12,8 +12,10 @@ import {
|
||||
import JellyfinIcon from '/@/renderer/features/servers/assets/jellyfin.png';
|
||||
import NavidromeIcon from '/@/renderer/features/servers/assets/navidrome.png';
|
||||
import SubsonicIcon from '/@/renderer/features/servers/assets/opensubsonic.png';
|
||||
import { IgnoreCorsSslSwitches } from '/@/renderer/features/servers/components/ignore-cors-ssl-switches';
|
||||
import { useAuthStoreActions } from '/@/renderer/store';
|
||||
import { Checkbox } from '/@/shared/components/checkbox/checkbox';
|
||||
import { Divider } from '/@/shared/components/divider/divider';
|
||||
import { Group } from '/@/shared/components/group/group';
|
||||
import { ModalButton } from '/@/shared/components/modal/model-shared';
|
||||
import { Paper } from '/@/shared/components/paper/paper';
|
||||
@@ -25,8 +27,12 @@ import { Text } from '/@/shared/components/text/text';
|
||||
import { toast } from '/@/shared/components/toast/toast';
|
||||
import { useFocusTrap } from '/@/shared/hooks/use-focus-trap';
|
||||
import { useForm } from '/@/shared/hooks/use-form';
|
||||
import { AuthenticationResponse, ServerListItemWithCredential } from '/@/shared/types/domain-types';
|
||||
import { DiscoveredServerItem, ServerType, toServerType } from '/@/shared/types/types';
|
||||
import {
|
||||
AuthenticationResponse,
|
||||
ServerListItemWithCredential,
|
||||
ServerType,
|
||||
} from '/@/shared/types/domain-types';
|
||||
import { DiscoveredServerItem, toServerType } from '/@/shared/types/types';
|
||||
|
||||
const autodiscover = isElectron() ? window.api.autodiscover : null;
|
||||
const localSettings = isElectron() ? window.api.localSettings : null;
|
||||
@@ -68,7 +74,7 @@ function useAutodiscovery() {
|
||||
return { isDone, servers };
|
||||
}
|
||||
|
||||
const SERVER_TYPES: Record<ServerType, ServerDetails> = {
|
||||
const SERVER_TYPES: Record<Exclude<ServerType, ServerType.EXTERNAL>, ServerDetails> = {
|
||||
[ServerType.JELLYFIN]: {
|
||||
icon: JellyfinIcon,
|
||||
name: 'Jellyfin',
|
||||
@@ -334,6 +340,13 @@ export const AddServerForm = ({ onCancel }: AddServerFormProps) => {
|
||||
})}
|
||||
/>
|
||||
)}
|
||||
{isElectron() && (
|
||||
<>
|
||||
<Divider />
|
||||
<IgnoreCorsSslSwitches />
|
||||
<Divider />
|
||||
</>
|
||||
)}
|
||||
<Group grow justify="flex-end">
|
||||
{onCancel && (
|
||||
<ModalButton onClick={onCancel}>{t('common.cancel')}</ModalButton>
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import isElectron from 'is-electron';
|
||||
import { ChangeEvent } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { Group } from '/@/shared/components/group/group';
|
||||
import { Switch } from '/@/shared/components/switch/switch';
|
||||
import { useLocalStorage } from '/@/shared/hooks/use-local-storage';
|
||||
|
||||
const localSettings = isElectron() ? window.api.localSettings : null;
|
||||
|
||||
export function IgnoreCorsSslSwitches() {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [ignoreCORS, setIgnoreCORS] = useLocalStorage({
|
||||
defaultValue: 'false',
|
||||
key: 'ignore_cors',
|
||||
});
|
||||
const [ignoreSSL, setIgnoreSSL] = useLocalStorage({
|
||||
defaultValue: 'false',
|
||||
key: 'ignore_ssl',
|
||||
});
|
||||
|
||||
const handleUpdateIgnoreCORS = (e: ChangeEvent<HTMLInputElement>) => {
|
||||
setIgnoreCORS(String(e.currentTarget.checked));
|
||||
localSettings?.set('ignore_cors', e.currentTarget.checked);
|
||||
};
|
||||
|
||||
const handleUpdateIgnoreSSL = (e: ChangeEvent<HTMLInputElement>) => {
|
||||
setIgnoreSSL(String(e.currentTarget.checked));
|
||||
localSettings?.set('ignore_ssl', e.currentTarget.checked);
|
||||
};
|
||||
|
||||
if (!isElectron()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Group>
|
||||
<Switch
|
||||
checked={ignoreCORS === 'true'}
|
||||
label={t('form.addServer.ignoreCors', {
|
||||
postProcess: 'sentenceCase',
|
||||
})}
|
||||
onChange={handleUpdateIgnoreCORS}
|
||||
/>
|
||||
</Group>
|
||||
<Group>
|
||||
<Switch
|
||||
checked={ignoreSSL === 'true'}
|
||||
label={t('form.addServer.ignoreSsl', {
|
||||
postProcess: 'sentenceCase',
|
||||
})}
|
||||
onChange={handleUpdateIgnoreSSL}
|
||||
/>
|
||||
</Group>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,12 +1,12 @@
|
||||
import { openContextModal } from '@mantine/modals';
|
||||
import isElectron from 'is-electron';
|
||||
import { ChangeEvent } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import JellyfinLogo from '/@/renderer/features/servers/assets/jellyfin.png';
|
||||
import NavidromeLogo from '/@/renderer/features/servers/assets/navidrome.png';
|
||||
import OpenSubsonicLogo from '/@/renderer/features/servers/assets/opensubsonic.png';
|
||||
import { AddServerForm } from '/@/renderer/features/servers/components/add-server-form';
|
||||
import { IgnoreCorsSslSwitches } from '/@/renderer/features/servers/components/ignore-cors-ssl-switches';
|
||||
import { ServerListItem } from '/@/renderer/features/servers/components/server-list-item';
|
||||
import { useCurrentServer, useServerList } from '/@/renderer/store';
|
||||
import { Accordion } from '/@/shared/components/accordion/accordion';
|
||||
@@ -16,13 +16,9 @@ import { Group } from '/@/shared/components/group/group';
|
||||
import { Icon } from '/@/shared/components/icon/icon';
|
||||
import { ContextModalVars } from '/@/shared/components/modal/modal';
|
||||
import { Stack } from '/@/shared/components/stack/stack';
|
||||
import { Switch } from '/@/shared/components/switch/switch';
|
||||
import { Text } from '/@/shared/components/text/text';
|
||||
import { useLocalStorage } from '/@/shared/hooks/use-local-storage';
|
||||
import { ServerType } from '/@/shared/types/domain-types';
|
||||
|
||||
const localSettings = isElectron() ? window.api.localSettings : null;
|
||||
|
||||
export const ServerList = () => {
|
||||
const { t } = useTranslation();
|
||||
const currentServer = useCurrentServer();
|
||||
@@ -40,32 +36,6 @@ export const ServerList = () => {
|
||||
});
|
||||
};
|
||||
|
||||
const [ignoreCORS, setIgnoreCORS] = useLocalStorage({
|
||||
defaultValue: 'false',
|
||||
key: 'ignore_cors',
|
||||
});
|
||||
|
||||
const [ignoreSSL, setIgnoreSSL] = useLocalStorage({
|
||||
defaultValue: 'false',
|
||||
key: 'ignore_ssl',
|
||||
});
|
||||
|
||||
const handleUpdateIgnoreCORS = (e: ChangeEvent<HTMLInputElement>) => {
|
||||
setIgnoreCORS(String(e.currentTarget.checked));
|
||||
|
||||
if (isElectron()) {
|
||||
localSettings?.set('ignore_cors', e.currentTarget.checked);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpdateIgnoreSSL = (e: ChangeEvent<HTMLInputElement>) => {
|
||||
setIgnoreSSL(String(e.currentTarget.checked));
|
||||
|
||||
if (isElectron()) {
|
||||
localSettings?.set('ignore_ssl', e.currentTarget.checked);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Stack>
|
||||
@@ -113,24 +83,7 @@ export const ServerList = () => {
|
||||
{isElectron() && (
|
||||
<>
|
||||
<Divider />
|
||||
<Group>
|
||||
<Switch
|
||||
checked={ignoreCORS === 'true'}
|
||||
label={t('form.addServer.ignoreCors', {
|
||||
postProcess: 'sentenceCase',
|
||||
})}
|
||||
onChange={handleUpdateIgnoreCORS}
|
||||
/>
|
||||
</Group>
|
||||
<Group>
|
||||
<Switch
|
||||
checked={ignoreSSL === 'true'}
|
||||
label={t('form.addServer.ignoreSsl', {
|
||||
postProcess: 'sentenceCase',
|
||||
})}
|
||||
onChange={handleUpdateIgnoreSSL}
|
||||
/>
|
||||
</Group>
|
||||
<IgnoreCorsSslSwitches />
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
@@ -621,6 +621,28 @@ export const ApplicationSettings = memo(() => {
|
||||
isHidden: false,
|
||||
title: t('setting.showRatings', { postProcess: 'sentenceCase' }),
|
||||
},
|
||||
{
|
||||
control: (
|
||||
<Switch
|
||||
aria-label={t('setting.blurExplicitImages', { postProcess: 'sentenceCase' })}
|
||||
defaultChecked={settings.blurExplicitImages}
|
||||
onChange={(e) =>
|
||||
setSettings({
|
||||
general: {
|
||||
...settings,
|
||||
blurExplicitImages: e.currentTarget.checked,
|
||||
},
|
||||
})
|
||||
}
|
||||
/>
|
||||
),
|
||||
description: t('setting.blurExplicitImages', {
|
||||
context: 'description',
|
||||
postProcess: 'sentenceCase',
|
||||
}),
|
||||
isHidden: false,
|
||||
title: t('setting.blurExplicitImages', { postProcess: 'sentenceCase' }),
|
||||
},
|
||||
{
|
||||
control: (
|
||||
<Switch
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
|
||||
const ARTIST_ITEMS: Array<[ArtistItem, string]> = [
|
||||
[ArtistItem.BIOGRAPHY, 'table.column.biography'],
|
||||
[ArtistItem.FAVORITE_SONGS, 'page.albumArtistDetail.favoriteSongs'],
|
||||
[ArtistItem.TOP_SONGS, 'page.albumArtistDetail.topSongs'],
|
||||
[ArtistItem.RECENT_ALBUMS, 'page.albumArtistDetail.recentReleases'],
|
||||
[ArtistItem.SIMILAR_ARTISTS, 'page.albumArtistDetail.relatedArtists'],
|
||||
|
||||
@@ -0,0 +1,424 @@
|
||||
import isElectron from 'is-electron';
|
||||
import { memo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import {
|
||||
SettingOption,
|
||||
SettingsSection,
|
||||
} from '/@/renderer/features/settings/components/settings-section';
|
||||
import {
|
||||
useGeneralSettings,
|
||||
useIntegrationsSettings,
|
||||
useSettingsStoreActions,
|
||||
} from '/@/renderer/store';
|
||||
import { MultiSelect } from '/@/shared/components/multi-select/multi-select';
|
||||
import { Stack } from '/@/shared/components/stack/stack';
|
||||
import { Switch } from '/@/shared/components/switch/switch';
|
||||
|
||||
const MUSICBRAINZ_RELEASE_TYPES = [
|
||||
'album',
|
||||
'single',
|
||||
'ep',
|
||||
'broadcast',
|
||||
'compilation',
|
||||
'live',
|
||||
'remix',
|
||||
'appears-on',
|
||||
'audiobook',
|
||||
'audio drama',
|
||||
'demo',
|
||||
'dj-mix',
|
||||
'field recording',
|
||||
'interview',
|
||||
'mixtape/street',
|
||||
'other',
|
||||
'soundtrack',
|
||||
'spokenword',
|
||||
];
|
||||
|
||||
const MUSICBRAINZ_COUNTRY_CODES: Record<string, string> = {
|
||||
AD: 'Andorra',
|
||||
AE: 'United Arab Emirates',
|
||||
AF: 'Afghanistan',
|
||||
AG: 'Antigua and Barbuda',
|
||||
AI: 'Anguilla',
|
||||
AL: 'Albania',
|
||||
AM: 'Armenia',
|
||||
AN: 'Netherlands Antilles',
|
||||
AO: 'Angola',
|
||||
AQ: 'Antarctica',
|
||||
AR: 'Argentina',
|
||||
AS: 'American Samoa',
|
||||
AT: 'Austria',
|
||||
AU: 'Australia',
|
||||
AW: 'Aruba',
|
||||
AX: 'Åland Islands',
|
||||
AZ: 'Azerbaijan',
|
||||
BA: 'Bosnia and Herzegovina',
|
||||
BB: 'Barbados',
|
||||
BD: 'Bangladesh',
|
||||
BE: 'Belgium',
|
||||
BF: 'Burkina Faso',
|
||||
BG: 'Bulgaria',
|
||||
BH: 'Bahrain',
|
||||
BI: 'Burundi',
|
||||
BJ: 'Benin',
|
||||
BL: 'Saint Barthélemy',
|
||||
BM: 'Bermuda',
|
||||
BN: 'Brunei',
|
||||
BO: 'Bolivia',
|
||||
BQ: 'Bonaire, Sint Eustatius and Saba',
|
||||
BR: 'Brazil',
|
||||
BS: 'Bahamas',
|
||||
BT: 'Bhutan',
|
||||
BV: 'Bouvet Island',
|
||||
BW: 'Botswana',
|
||||
BY: 'Belarus',
|
||||
BZ: 'Belize',
|
||||
CA: 'Canada',
|
||||
CC: 'Cocos (Keeling) Islands',
|
||||
CD: 'Democratic Republic of the Congo',
|
||||
CF: 'Central African Republic',
|
||||
CG: 'Congo',
|
||||
CH: 'Switzerland',
|
||||
CI: "Côte d'Ivoire",
|
||||
CK: 'Cook Islands',
|
||||
CL: 'Chile',
|
||||
CM: 'Cameroon',
|
||||
CN: 'China',
|
||||
CO: 'Colombia',
|
||||
CR: 'Costa Rica',
|
||||
CS: 'Serbia and Montenegro',
|
||||
CU: 'Cuba',
|
||||
CV: 'Cape Verde',
|
||||
CW: 'Curaçao',
|
||||
CX: 'Christmas Island',
|
||||
CY: 'Cyprus',
|
||||
CZ: 'Czechia',
|
||||
DE: 'Germany',
|
||||
DJ: 'Djibouti',
|
||||
DK: 'Denmark',
|
||||
DM: 'Dominica',
|
||||
DO: 'Dominican Republic',
|
||||
DZ: 'Algeria',
|
||||
EC: 'Ecuador',
|
||||
EE: 'Estonia',
|
||||
EG: 'Egypt',
|
||||
EH: 'Western Sahara',
|
||||
ER: 'Eritrea',
|
||||
ES: 'Spain',
|
||||
ET: 'Ethiopia',
|
||||
FI: 'Finland',
|
||||
FJ: 'Fiji',
|
||||
FK: 'Falkland Islands',
|
||||
FM: 'Federated States of Micronesia',
|
||||
FO: 'Faroe Islands',
|
||||
FR: 'France',
|
||||
GA: 'Gabon',
|
||||
GB: 'United Kingdom',
|
||||
GD: 'Grenada',
|
||||
GE: 'Georgia',
|
||||
GF: 'French Guiana',
|
||||
GG: 'Guernsey',
|
||||
GH: 'Ghana',
|
||||
GI: 'Gibraltar',
|
||||
GL: 'Greenland',
|
||||
GM: 'Gambia',
|
||||
GN: 'Guinea',
|
||||
GP: 'Guadeloupe',
|
||||
GQ: 'Equatorial Guinea',
|
||||
GR: 'Greece',
|
||||
GS: 'South Georgia and the South Sandwich Islands',
|
||||
GT: 'Guatemala',
|
||||
GU: 'Guam',
|
||||
GW: 'Guinea-Bissau',
|
||||
GY: 'Guyana',
|
||||
HK: 'Hong Kong',
|
||||
HM: 'Heard Island and McDonald Islands',
|
||||
HN: 'Honduras',
|
||||
HR: 'Croatia',
|
||||
HT: 'Haiti',
|
||||
HU: 'Hungary',
|
||||
ID: 'Indonesia',
|
||||
IE: 'Ireland',
|
||||
IL: 'Israel',
|
||||
IM: 'Isle of Man',
|
||||
IN: 'India',
|
||||
IO: 'British Indian Ocean Territory',
|
||||
IQ: 'Iraq',
|
||||
IR: 'Iran',
|
||||
IS: 'Iceland',
|
||||
IT: 'Italy',
|
||||
JE: 'Jersey',
|
||||
JM: 'Jamaica',
|
||||
JO: 'Jordan',
|
||||
JP: 'Japan',
|
||||
KE: 'Kenya',
|
||||
KG: 'Kyrgyzstan',
|
||||
KH: 'Cambodia',
|
||||
KI: 'Kiribati',
|
||||
KM: 'Comoros',
|
||||
KN: 'Saint Kitts and Nevis',
|
||||
KP: 'North Korea',
|
||||
KR: 'South Korea',
|
||||
KW: 'Kuwait',
|
||||
KY: 'Cayman Islands',
|
||||
KZ: 'Kazakhstan',
|
||||
LA: 'Laos',
|
||||
LB: 'Lebanon',
|
||||
LC: 'Saint Lucia',
|
||||
LI: 'Liechtenstein',
|
||||
LK: 'Sri Lanka',
|
||||
LR: 'Liberia',
|
||||
LS: 'Lesotho',
|
||||
LT: 'Lithuania',
|
||||
LU: 'Luxembourg',
|
||||
LV: 'Latvia',
|
||||
LY: 'Libya',
|
||||
MA: 'Morocco',
|
||||
MC: 'Monaco',
|
||||
MD: 'Moldova',
|
||||
ME: 'Montenegro',
|
||||
MF: 'Saint Martin (French part)',
|
||||
MG: 'Madagascar',
|
||||
MH: 'Marshall Islands',
|
||||
MK: 'North Macedonia',
|
||||
ML: 'Mali',
|
||||
MM: 'Myanmar',
|
||||
MN: 'Mongolia',
|
||||
MO: 'Macao',
|
||||
MP: 'Northern Mariana Islands',
|
||||
MQ: 'Martinique',
|
||||
MR: 'Mauritania',
|
||||
MS: 'Montserrat',
|
||||
MT: 'Malta',
|
||||
MU: 'Mauritius',
|
||||
MV: 'Maldives',
|
||||
MW: 'Malawi',
|
||||
MX: 'Mexico',
|
||||
MY: 'Malaysia',
|
||||
MZ: 'Mozambique',
|
||||
NA: 'Namibia',
|
||||
NC: 'New Caledonia',
|
||||
NE: 'Niger',
|
||||
NF: 'Norfolk Island',
|
||||
NG: 'Nigeria',
|
||||
NI: 'Nicaragua',
|
||||
NL: 'Netherlands',
|
||||
NO: 'Norway',
|
||||
NP: 'Nepal',
|
||||
NR: 'Nauru',
|
||||
NU: 'Niue',
|
||||
NZ: 'New Zealand',
|
||||
OM: 'Oman',
|
||||
PA: 'Panama',
|
||||
PE: 'Peru',
|
||||
PF: 'French Polynesia',
|
||||
PG: 'Papua New Guinea',
|
||||
PH: 'Philippines',
|
||||
PK: 'Pakistan',
|
||||
PL: 'Poland',
|
||||
PM: 'Saint Pierre and Miquelon',
|
||||
PN: 'Pitcairn',
|
||||
PR: 'Puerto Rico',
|
||||
PS: 'Palestine',
|
||||
PT: 'Portugal',
|
||||
PW: 'Palau',
|
||||
PY: 'Paraguay',
|
||||
QA: 'Qatar',
|
||||
RE: 'Réunion',
|
||||
RO: 'Romania',
|
||||
RS: 'Serbia',
|
||||
RU: 'Russia',
|
||||
RW: 'Rwanda',
|
||||
SA: 'Saudi Arabia',
|
||||
SB: 'Solomon Islands',
|
||||
SC: 'Seychelles',
|
||||
SD: 'Sudan',
|
||||
SE: 'Sweden',
|
||||
SG: 'Singapore',
|
||||
SH: 'Saint Helena, Ascension and Tristan da Cunha',
|
||||
SI: 'Slovenia',
|
||||
SJ: 'Svalbard and Jan Mayen',
|
||||
SK: 'Slovakia',
|
||||
SL: 'Sierra Leone',
|
||||
SM: 'San Marino',
|
||||
SN: 'Senegal',
|
||||
SO: 'Somalia',
|
||||
SR: 'Suriname',
|
||||
SS: 'South Sudan',
|
||||
ST: 'Sao Tome and Principe',
|
||||
SU: 'Soviet Union',
|
||||
SV: 'El Salvador',
|
||||
SX: 'Sint Maarten (Dutch part)',
|
||||
SY: 'Syria',
|
||||
SZ: 'Eswatini',
|
||||
TC: 'Turks and Caicos Islands',
|
||||
TD: 'Chad',
|
||||
TF: 'French Southern Territories',
|
||||
TG: 'Togo',
|
||||
TH: 'Thailand',
|
||||
TJ: 'Tajikistan',
|
||||
TK: 'Tokelau',
|
||||
TL: 'Timor-Leste',
|
||||
TM: 'Turkmenistan',
|
||||
TN: 'Tunisia',
|
||||
TO: 'Tonga',
|
||||
TR: 'Turkey',
|
||||
TT: 'Trinidad and Tobago',
|
||||
TV: 'Tuvalu',
|
||||
TW: 'Taiwan',
|
||||
TZ: 'Tanzania',
|
||||
UA: 'Ukraine',
|
||||
UG: 'Uganda',
|
||||
UM: 'United States Minor Outlying Islands',
|
||||
US: 'United States',
|
||||
UY: 'Uruguay',
|
||||
UZ: 'Uzbekistan',
|
||||
VA: 'Vatican City',
|
||||
VC: 'Saint Vincent and The Grenadines',
|
||||
VE: 'Venezuela',
|
||||
VG: 'British Virgin Islands',
|
||||
VI: 'U.S. Virgin Islands',
|
||||
VN: 'Vietnam',
|
||||
VU: 'Vanuatu',
|
||||
WF: 'Wallis and Futuna',
|
||||
WS: 'Samoa',
|
||||
XC: 'Czechoslovakia',
|
||||
XE: 'Europe',
|
||||
XG: 'East Germany',
|
||||
XK: 'Kosovo',
|
||||
XW: '[Worldwide]',
|
||||
YE: 'Yemen',
|
||||
YT: 'Mayotte',
|
||||
YU: 'Yugoslavia',
|
||||
ZA: 'South Africa',
|
||||
ZM: 'Zambia',
|
||||
ZW: 'Zimbabwe',
|
||||
};
|
||||
|
||||
const MUSICBRAINZ_COUNTRY_OPTIONS = Object.entries(MUSICBRAINZ_COUNTRY_CODES)
|
||||
.map(([code, name]) => ({ label: `${code} - ${name}`, value: code }))
|
||||
.sort((a, b) => a.label.localeCompare(b.label));
|
||||
|
||||
export const IntegrationsTab = memo(() => {
|
||||
const { t } = useTranslation();
|
||||
const { musicBrainz } = useGeneralSettings();
|
||||
const settings = useIntegrationsSettings();
|
||||
const { setSettings } = useSettingsStoreActions();
|
||||
|
||||
const updateIntegrations = (updates: Partial<typeof settings>) => {
|
||||
setSettings({
|
||||
integrations: {
|
||||
...settings,
|
||||
...updates,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const options: SettingOption[] = [
|
||||
{
|
||||
control: (
|
||||
<Switch
|
||||
aria-label={t('setting.musicBrainzQueries', { postProcess: 'sentenceCase' })}
|
||||
defaultChecked={settings.musicBrainz}
|
||||
onChange={(e) => updateIntegrations({ musicBrainz: e.currentTarget.checked })}
|
||||
/>
|
||||
),
|
||||
description: t('setting.musicBrainzQueries', {
|
||||
context: 'description',
|
||||
postProcess: 'sentenceCase',
|
||||
}),
|
||||
title: t('setting.musicBrainzQueries', { postProcess: 'sentenceCase' }),
|
||||
},
|
||||
{
|
||||
control: (
|
||||
<MultiSelect
|
||||
aria-label={t('setting.musicbrainzExcludeReleaseTypes', {
|
||||
postProcess: 'sentenceCase',
|
||||
})}
|
||||
clearable
|
||||
data={MUSICBRAINZ_RELEASE_TYPES}
|
||||
defaultValue={settings.musicBrainzExcludeReleaseTypes}
|
||||
onChange={(value) =>
|
||||
updateIntegrations({ musicBrainzExcludeReleaseTypes: value })
|
||||
}
|
||||
width={300}
|
||||
/>
|
||||
),
|
||||
description: t('setting.musicbrainzExcludeReleaseTypes', {
|
||||
context: 'description',
|
||||
postProcess: 'sentenceCase',
|
||||
}),
|
||||
isHidden: !musicBrainz || !settings.musicBrainz,
|
||||
title: t('setting.musicbrainzExcludeReleaseTypes', { postProcess: 'sentenceCase' }),
|
||||
},
|
||||
{
|
||||
control: (
|
||||
<MultiSelect
|
||||
aria-label={t('setting.musicbrainzPrioritizeCountries', {
|
||||
postProcess: 'sentenceCase',
|
||||
})}
|
||||
clearable
|
||||
data={MUSICBRAINZ_COUNTRY_OPTIONS}
|
||||
defaultValue={settings.musicBrainzPrioritizeCountries
|
||||
.map((c) => c.toUpperCase())
|
||||
.filter((code) => code in MUSICBRAINZ_COUNTRY_CODES)}
|
||||
onChange={(value) =>
|
||||
updateIntegrations({ musicBrainzPrioritizeCountries: value })
|
||||
}
|
||||
searchable
|
||||
width={300}
|
||||
/>
|
||||
),
|
||||
description: t('setting.musicbrainzPrioritizeCountries', {
|
||||
context: 'description',
|
||||
postProcess: 'sentenceCase',
|
||||
}),
|
||||
isHidden: !musicBrainz || !settings.musicBrainz,
|
||||
title: t('setting.musicbrainzPrioritizeCountries', { postProcess: 'sentenceCase' }),
|
||||
},
|
||||
{
|
||||
control: (
|
||||
<Switch
|
||||
aria-label={t('setting.musicbrainzAutoCountryPriority', {
|
||||
postProcess: 'sentenceCase',
|
||||
})}
|
||||
defaultChecked={settings.musicbrainzAutoCountryPriority}
|
||||
onChange={(e) =>
|
||||
updateIntegrations({
|
||||
musicbrainzAutoCountryPriority: e.currentTarget.checked,
|
||||
})
|
||||
}
|
||||
/>
|
||||
),
|
||||
description: t('setting.musicbrainzAutoCountryPriority', {
|
||||
context: 'description',
|
||||
postProcess: 'sentenceCase',
|
||||
}),
|
||||
isHidden: !musicBrainz || !settings.musicBrainz,
|
||||
title: t('setting.musicbrainzAutoCountryPriority', { postProcess: 'sentenceCase' }),
|
||||
},
|
||||
{
|
||||
control: (
|
||||
<Switch
|
||||
aria-label={t('setting.youtube', { postProcess: 'sentenceCase' })}
|
||||
defaultChecked={settings.youtube}
|
||||
disabled={!isElectron()}
|
||||
onChange={(e) => updateIntegrations({ youtube: e.currentTarget.checked })}
|
||||
/>
|
||||
),
|
||||
description: t('setting.youtube', {
|
||||
context: 'description',
|
||||
postProcess: 'sentenceCase',
|
||||
}),
|
||||
title: t('setting.youtube', { postProcess: 'sentenceCase' }),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
<SettingsSection options={options} title={'MusicBrainz'} />
|
||||
</Stack>
|
||||
);
|
||||
});
|
||||
@@ -36,9 +36,7 @@ export const MpvSettings = memo(() => {
|
||||
// const { pause } = usePlayerControls();
|
||||
// const { clearQueue } = useQueueControls();
|
||||
|
||||
const [mpvPath, setMpvPath] = useState(
|
||||
(localSettings?.get('mpv_path') as string | undefined) || '',
|
||||
);
|
||||
const [mpvPath, setMpvPath] = useState('');
|
||||
|
||||
const handleSetMpvPath = async (clear?: boolean) => {
|
||||
if (clear) {
|
||||
@@ -62,8 +60,8 @@ export const MpvSettings = memo(() => {
|
||||
useEffect(() => {
|
||||
const getMpvPath = async () => {
|
||||
if (!localSettings) return setMpvPath('');
|
||||
const mpvPath = (await localSettings.get('mpv_path')) as string;
|
||||
return setMpvPath(mpvPath);
|
||||
const mpvPath = (await localSettings.get('mpv_path')) as string | undefined;
|
||||
return setMpvPath(mpvPath || '');
|
||||
};
|
||||
|
||||
getMpvPath();
|
||||
|
||||
@@ -24,6 +24,14 @@ const HotkeysTab = lazy(() =>
|
||||
})),
|
||||
);
|
||||
|
||||
const IntegrationsTab = lazy(() =>
|
||||
import('/@/renderer/features/settings/components/integrations/integrations-tab').then(
|
||||
(module) => ({
|
||||
default: module.IntegrationsTab,
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
const WindowTab = lazy(() =>
|
||||
import('/@/renderer/features/settings/components/window/window-tab').then((module) => ({
|
||||
default: module.WindowTab,
|
||||
@@ -61,6 +69,9 @@ export const SettingsContent = () => {
|
||||
<Tabs.Tab value="hotkeys">
|
||||
{t('page.setting.hotkeysTab', { postProcess: 'sentenceCase' })}
|
||||
</Tabs.Tab>
|
||||
<Tabs.Tab value="integrations">
|
||||
{t('page.setting.integrationsTab', { postProcess: 'sentenceCase' })}
|
||||
</Tabs.Tab>
|
||||
{isElectron() && (
|
||||
<Tabs.Tab value="window">
|
||||
{t('page.setting.windowTab', { postProcess: 'sentenceCase' })}
|
||||
@@ -85,6 +96,11 @@ export const SettingsContent = () => {
|
||||
<HotkeysTab />
|
||||
</Suspense>
|
||||
</Tabs.Panel>
|
||||
<Tabs.Panel value="integrations">
|
||||
<Suspense fallback={null}>
|
||||
<IntegrationsTab />
|
||||
</Suspense>
|
||||
</Tabs.Panel>
|
||||
{isElectron() && (
|
||||
<Tabs.Panel value="window">
|
||||
<Suspense fallback={null}>
|
||||
|
||||
@@ -53,5 +53,5 @@ export const PasswordSettings = memo(() => {
|
||||
},
|
||||
];
|
||||
|
||||
return <SettingsSection divider={false} options={updateOptions} />;
|
||||
return <SettingsSection options={updateOptions} />;
|
||||
});
|
||||
|
||||
@@ -41,16 +41,21 @@ export const UpdateSettings = memo(() => {
|
||||
}),
|
||||
value: 'beta',
|
||||
},
|
||||
{
|
||||
label: t('setting.releaseChannel', {
|
||||
context: 'optionAlpha',
|
||||
postProcess: 'titleCase',
|
||||
}),
|
||||
value: 'alpha',
|
||||
},
|
||||
]}
|
||||
defaultValue={
|
||||
(localSettings?.get('release_channel') as string | undefined) || 'latest'
|
||||
}
|
||||
defaultValue={settings.releaseChannel || 'latest'}
|
||||
onChange={(value) => {
|
||||
if (!value) return;
|
||||
localSettings?.set('release_channel', value);
|
||||
setSettings({
|
||||
window: {
|
||||
releaseChannel: value as 'beta' | 'latest',
|
||||
releaseChannel: value as 'alpha' | 'beta' | 'latest',
|
||||
},
|
||||
});
|
||||
}}
|
||||
|
||||
@@ -32,6 +32,7 @@ const LibraryHeaderBarComponent = ({ children, ignoreMaxWidth }: LibraryHeaderBa
|
||||
|
||||
interface HeaderPlayButtonProps {
|
||||
className?: string;
|
||||
disabled?: boolean;
|
||||
ids?: string[];
|
||||
itemType: LibraryItem;
|
||||
listQuery?: Record<string, any>;
|
||||
@@ -41,10 +42,12 @@ interface HeaderPlayButtonProps {
|
||||
|
||||
interface TitleProps {
|
||||
children: ReactNode;
|
||||
order?: number;
|
||||
}
|
||||
|
||||
const HeaderPlayButton = ({
|
||||
className,
|
||||
disabled,
|
||||
ids,
|
||||
itemType,
|
||||
listQuery,
|
||||
@@ -57,6 +60,8 @@ const HeaderPlayButton = ({
|
||||
|
||||
const handlePlay = useCallback(
|
||||
(playType: Play) => {
|
||||
if (disabled) return;
|
||||
|
||||
if (listQuery) {
|
||||
player.addToQueueByListQuery(serverId, listQuery, itemType, playType);
|
||||
} else if (ids) {
|
||||
@@ -67,7 +72,7 @@ const HeaderPlayButton = ({
|
||||
|
||||
closeAllModals();
|
||||
},
|
||||
[listQuery, ids, songs, player, serverId, itemType],
|
||||
[disabled, listQuery, ids, songs, player, serverId, itemType],
|
||||
);
|
||||
|
||||
const isPlayerFetching = useIsPlayerFetching();
|
||||
@@ -79,6 +84,7 @@ const HeaderPlayButton = ({
|
||||
<div className={styles.playButtonContainer}>
|
||||
<DefaultPlayButton
|
||||
className={className}
|
||||
disabled={disabled}
|
||||
loading={isPlayerFetching}
|
||||
onClick={() => setIsOpen((prev) => !prev)}
|
||||
ref={buttonRef}
|
||||
@@ -100,9 +106,9 @@ const HeaderPlayButton = ({
|
||||
);
|
||||
};
|
||||
|
||||
const Title = ({ children }: TitleProps) => {
|
||||
const Title = ({ children, order = 1 }: TitleProps) => {
|
||||
return (
|
||||
<TextTitle fw={700} order={1} overflow="hidden">
|
||||
<TextTitle fw={700} order={order as any} overflow="hidden">
|
||||
{children}
|
||||
</TextTitle>
|
||||
);
|
||||
|
||||
@@ -27,7 +27,7 @@ import { BaseImage } from '/@/shared/components/image/image';
|
||||
import { Rating } from '/@/shared/components/rating/rating';
|
||||
import { Spinner } from '/@/shared/components/spinner/spinner';
|
||||
import { Text } from '/@/shared/components/text/text';
|
||||
import { LibraryItem } from '/@/shared/types/domain-types';
|
||||
import { ExplicitStatus, LibraryItem } from '/@/shared/types/domain-types';
|
||||
import { Play } from '/@/shared/types/types';
|
||||
|
||||
interface LibraryHeaderProps {
|
||||
@@ -37,6 +37,7 @@ interface LibraryHeaderProps {
|
||||
imageUrl?: null | string;
|
||||
item: {
|
||||
children?: ReactNode;
|
||||
explicitStatus?: ExplicitStatus | null;
|
||||
imageId?: null | string;
|
||||
imageUrl?: null | string;
|
||||
route: string;
|
||||
@@ -48,12 +49,14 @@ interface LibraryHeaderProps {
|
||||
|
||||
export const LibraryHeader = forwardRef(
|
||||
(
|
||||
{ children, containerClassName, imageUrl, item, title }: LibraryHeaderProps,
|
||||
{ children, containerClassName, imageUrl: imageUrlProp, item, title }: LibraryHeaderProps,
|
||||
ref: Ref<HTMLDivElement>,
|
||||
) => {
|
||||
const { t } = useTranslation();
|
||||
const [isImageError, setIsImageError] = useState<boolean | null>(false);
|
||||
|
||||
const effectiveImageUrl = imageUrlProp ?? item.imageUrl ?? undefined;
|
||||
|
||||
const onImageError = () => {
|
||||
setIsImageError(true);
|
||||
};
|
||||
@@ -76,20 +79,18 @@ export const LibraryHeader = forwardRef(
|
||||
};
|
||||
|
||||
const openImage = useCallback(() => {
|
||||
const imageId = item.imageId;
|
||||
const itemType = item.type as LibraryItem;
|
||||
|
||||
if (!imageId || !itemType) {
|
||||
return;
|
||||
let modalImageUrl = effectiveImageUrl;
|
||||
|
||||
if (!modalImageUrl && item.imageId && itemType) {
|
||||
modalImageUrl = getItemImageUrl({
|
||||
id: item.imageId,
|
||||
itemType,
|
||||
});
|
||||
}
|
||||
|
||||
const imageUrl = getItemImageUrl({
|
||||
id: imageId,
|
||||
itemType,
|
||||
});
|
||||
|
||||
if (!imageUrl) {
|
||||
console.error('No image URL found');
|
||||
if (!modalImageUrl) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -108,7 +109,8 @@ export const LibraryHeader = forwardRef(
|
||||
enableDebounce={false}
|
||||
enableViewport={false}
|
||||
fetchPriority="high"
|
||||
src={imageUrl}
|
||||
isExplicit={item.explicitStatus === ExplicitStatus.EXPLICIT}
|
||||
src={modalImageUrl}
|
||||
style={{
|
||||
maxHeight: '100%',
|
||||
maxWidth: '100%',
|
||||
@@ -120,7 +122,7 @@ export const LibraryHeader = forwardRef(
|
||||
),
|
||||
fullScreen: true,
|
||||
});
|
||||
}, [item.imageId, item.type]);
|
||||
}, [effectiveImageUrl, item.explicitStatus, item.imageId, item.type]);
|
||||
|
||||
return (
|
||||
<div className={clsx(styles.libraryHeader, containerClassName)} ref={ref}>
|
||||
@@ -142,11 +144,12 @@ export const LibraryHeader = forwardRef(
|
||||
containerClassName={styles.image}
|
||||
enableDebounce={false}
|
||||
enableViewport={false}
|
||||
explicitStatus={item.explicitStatus ?? null}
|
||||
fetchPriority="high"
|
||||
id={item.imageId}
|
||||
itemType={item.type as LibraryItem}
|
||||
onError={onImageError}
|
||||
src={imageUrl || ''}
|
||||
src={effectiveImageUrl ?? ''}
|
||||
type="header"
|
||||
/>
|
||||
)}
|
||||
@@ -260,6 +263,7 @@ export const calculateTitleSize = (title: string) => {
|
||||
};
|
||||
|
||||
interface LibraryHeaderMenuProps {
|
||||
disabled?: boolean;
|
||||
favorite?: boolean;
|
||||
onArtistRadio?: () => void;
|
||||
onFavorite?: (e: React.MouseEvent<HTMLButtonElement>) => void;
|
||||
@@ -271,6 +275,7 @@ interface LibraryHeaderMenuProps {
|
||||
}
|
||||
|
||||
export const LibraryHeaderMenu = ({
|
||||
disabled,
|
||||
favorite,
|
||||
onArtistRadio,
|
||||
onFavorite,
|
||||
@@ -316,15 +321,30 @@ export const LibraryHeaderMenu = ({
|
||||
return (
|
||||
<div className={styles.libraryHeaderMenu}>
|
||||
<Group wrap="nowrap">
|
||||
{onPlay && <PlayTextButton {...handlePlayNow.handlers} {...handlePlayNow.props} />}
|
||||
{onPlay && (
|
||||
<PlayNextTextButton {...handlePlayNext.handlers} {...handlePlayNext.props} />
|
||||
<PlayTextButton
|
||||
{...handlePlayNow.handlers}
|
||||
{...handlePlayNow.props}
|
||||
disabled={disabled}
|
||||
/>
|
||||
)}
|
||||
{onPlay && (
|
||||
<PlayLastTextButton {...handlePlayLast.handlers} {...handlePlayLast.props} />
|
||||
<PlayNextTextButton
|
||||
{...handlePlayNext.handlers}
|
||||
{...handlePlayNext.props}
|
||||
disabled={disabled}
|
||||
/>
|
||||
)}
|
||||
{onPlay && (
|
||||
<PlayLastTextButton
|
||||
{...handlePlayLast.handlers}
|
||||
{...handlePlayLast.props}
|
||||
disabled={disabled}
|
||||
/>
|
||||
)}
|
||||
{onArtistRadio && (
|
||||
<Button
|
||||
disabled={disabled}
|
||||
leftSection={
|
||||
isPlayerFetching ? (
|
||||
<Spinner color="white" />
|
||||
@@ -341,17 +361,17 @@ export const LibraryHeaderMenu = ({
|
||||
)}
|
||||
</Group>
|
||||
<Group gap="sm" wrap="nowrap">
|
||||
{onRating && (
|
||||
{onRating && !disabled && (
|
||||
<Rating
|
||||
onChange={onRating}
|
||||
readOnly={isMutatingRating}
|
||||
readOnly={isMutatingRating || disabled}
|
||||
size="lg"
|
||||
value={rating || 0}
|
||||
/>
|
||||
)}
|
||||
{onFavorite && (
|
||||
{onFavorite && !disabled && (
|
||||
<ActionIcon
|
||||
disabled={isMutatingFavorite}
|
||||
disabled={isMutatingFavorite || disabled}
|
||||
icon="favorite"
|
||||
iconProps={{
|
||||
fill: favorite ? 'primary' : undefined,
|
||||
@@ -361,8 +381,9 @@ export const LibraryHeaderMenu = ({
|
||||
variant="transparent"
|
||||
/>
|
||||
)}
|
||||
{onMore && (
|
||||
{onMore && !disabled && (
|
||||
<ActionIcon
|
||||
disabled={disabled}
|
||||
icon="ellipsisHorizontal"
|
||||
onClick={onMore}
|
||||
size="lg"
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user