Compare commits

...

74 Commits

Author SHA1 Message Date
jeffvli 3ad5447871 enable album play buttons if yt enabled 2026-02-07 14:44:24 -08:00
jeffvli 3a50dee7a2 revert settings migrations, lint 2026-02-07 13:40:05 -08:00
jeffvli c4ecfeedec add automatic country prioritization based on existing releases 2026-02-07 13:36:17 -08:00
jeffvli f43655ed5a refactor mbz country priority to be multiselect 2026-02-07 13:24:32 -08:00
jeffvli fddda70190 handle release group image for song normalization 2026-02-07 13:14:02 -08:00
jeffvli a5992943d0 fetch image by release group instead of release 2026-02-07 13:12:19 -08:00
jeffvli 56e2611992 serve electron renderer via express to allow yt iframe playback 2026-02-07 12:59:59 -08:00
jeffvli c8ae128ac4 handle imageUrl in drag preview and context menu 2026-02-07 02:33:31 -08:00
jeffvli ba56ab8844 redesign external song indicator on itemcard 2026-02-07 02:30:41 -08:00
jeffvli 7cb7dfb62b add external song indicator for queue 2026-02-07 02:14:24 -08:00
jeffvli 86537a8d1e increase cache time for youtube queries 2026-02-07 01:53:19 -08:00
jeffvli 3b3e77b672 handle external imageUrl 2026-02-07 01:52:58 -08:00
jeffvli bec6464a44 move external playback fetch to context 2026-02-07 01:26:04 -08:00
jeffvli 812ca5302a fix audio context breaking on source change 2026-02-07 01:01:59 -08:00
jeffvli 1824083b99 adjust yt search query format 2026-02-07 00:41:55 -08:00
jeffvli f46ca8cd35 handle playback from ItemCard 2026-02-07 00:41:36 -08:00
jeffvli f04ea3bca0 fix artist name joining from mbz 2026-02-06 22:29:16 -08:00
jeffvli a547be1577 add settings configuration for integrations 2026-02-06 22:19:42 -08:00
jeffvli 8ae29407ec support ytmusic controls on web/mpv players 2026-02-06 21:38:05 -08:00
jeffvli 8e603871b7 add experimental ytmusic playback for external songs 2026-02-06 20:47:27 -08:00
jeffvli 40ec16e191 support mbz album detail view 2026-02-06 20:13:58 -08:00
jeffvli 0bb30ab0da decouple internal and external album count in releasetype sections 2026-02-06 14:47:02 -08:00
jeffvli 9919ff9626 improve card styling on external items 2026-02-06 14:40:15 -08:00
jeffvli f6cec17710 progress 2026-02-06 13:02:44 -08:00
jeffvli 03b01472f8 remove duplicate ServerType enum 2026-02-06 13:02:44 -08:00
jeffvli 3550177f67 ignore external albums in album section playback handler 2026-02-06 05:23:33 -08:00
jeffvli 82914c27f0 add missing releases from musicbrainz to artist page 2026-02-06 05:23:33 -08:00
jeffvli 10d02087d0 add selector to convert musicbrainz releases to Album type 2026-02-06 05:23:33 -08:00
jeffvli 4b509951a5 add musicbrainz artist query 2026-02-06 05:23:33 -08:00
jeffvli 2869aab728 add musicbrainz-api package 2026-02-06 05:23:33 -08:00
jeffvli 7cecd859ae add mbzReleaseGroupId to Album type 2026-02-06 05:22:56 -08:00
jeffvli fea2966f62 refactor jellyfin field properties and include ProviderIds 2026-02-06 05:17:40 -08:00
jeffvli 6efa308e85 fix release channel input value 2026-02-06 04:13:53 -08:00
Pyx 82b50a60bc Implement Glassy Dark theme (#1388)
* implement theme

* refactor theme stylesheets to load inline to simplify vite bundling

* add missing css module scope name for web build

---------

Co-authored-by: jeffvli <jeffvictorli@gmail.com>
2026-02-06 02:01:32 -08:00
jeffvli f52c4f7900 fix alpha release notes to compare to development instead of alpha tag 2026-02-06 00:16:11 -08:00
jeffvli 2fb621993d rename electron-builder config to alpha instead of nightly 2026-02-05 23:52:38 -08:00
jeffvli cf663de2fc add handlers and setting for nightly release 2026-02-05 23:45:32 -08:00
jeffvli 65c215fa9c fix R2_ENDPOINT_URL reference 2026-02-05 20:58:15 -08:00
jeffvli 8af972c20b add nightly publish build 2026-02-05 20:48:05 -08:00
jeffvli 027e4046a2 handle radio metadata in discord rpc / fullscreen player (#1649) 2026-02-05 19:14:30 -08:00
jeffvli 4c256348fc add configuration to blur explicit album/song art 2026-02-04 01:20:31 -08:00
jeffvli 6e3275c05c add explicit / clean indicators for album and song titles (#1634) 2026-02-04 00:35:35 -08:00
Jake Klingler 3518a3f3b6 populate bit depth from jellyfin (#1648) 2026-02-04 00:10:28 -08:00
jeffvli 2b6b0cb38b fix artist favorite songs (subsonic) 2026-02-03 23:58:44 -08:00
jeffvli f56a836ffd add personal/community toggle for artist top songs (#1372) 2026-02-03 23:58:44 -08:00
jeffvli 2d963a9d23 use correct filters for album song sort options 2026-02-03 23:58:44 -08:00
rushii 4423b06807 fix(Settings): mpv path selector (#1641)
An unnecessary default value appears to be stringifying a Promise when a separate useEffect hook is supposed to properly load the setting value.
2026-02-03 22:56:09 -08:00
T 1f9223b476 Fix: ratings display on player bar and mobile player (#1646)
* fix(playerbar): use settings to display ratings

* fix(mobile player): use settings to display ratings
2026-02-03 22:50:43 -08:00
Alexander Welsing b4ecf5d257 Update instant mix to use the new items endpoint instead of songs (#1642)
songs/:itemId:/InstantMix -> items/:itemId/InstantMix
2026-02-03 22:34:34 -08:00
jeffvli 0dd13cbab1 add release notes modal to appmenu 2026-02-03 01:06:52 -08:00
jeffvli 48e50430fe add play button group to artist top / favorite song sections 2026-02-02 22:54:10 -08:00
jeffvli ac5611fdca add favorite songs section to artist page (#1604) 2026-02-02 22:23:38 -08:00
jeffvli 50c3dbc0a0 set first item of track radio to the triggering item 2026-02-02 21:18:29 -08:00
jeffvli ddd840d2df fix inconsistent size of musicbrainz icon on album page 2026-02-02 21:14:03 -08:00
jeffvli c0c9878fad add cors / ssl ignore switches to all login components (#1606) 2026-02-02 21:05:01 -08:00
jeffvli c4fc8a8aef fix overlayscrollbars init on loading state 2026-02-02 20:53:11 -08:00
Kendall Garner 0620b096db fix(mpv): only check player time when there is an item in the track (#1639) 2026-02-02 20:49:34 -08:00
Kendall Garner f998491beb fix(playlist): optimistically update rating for playlist song list 2026-02-02 20:48:44 -08:00
Damien Erambert 55a6ea4fca Prevent double fetching when force refreshing paginated views (#1637)
* Prevent double fetching when force refreshing paginated views

* remove await from infinite list loader query invalidation

* add mutation and loading state to list refresh

* add non-suspense query to list genre filters to add loading state

* remove list count data set on random queries

---------

Co-authored-by: jeffvli <jeffvictorli@gmail.com>
2026-02-02 20:25:19 -08:00
Damien Erambert 72fc5beb98 Use a re-usable Intl.Collator instance for locale compare when possible (#1638)
* Use a re-usable Intl.Collator instance for locale compare
2026-02-02 18:28:01 -08:00
jeffvli a45b607fe7 add Noto Sans Hebrew to default font configuration 2026-02-02 18:14:19 -08:00
jeffvli adfdf04240 update to v1.4.2 2026-02-02 01:45:02 -08:00
jeffvli faa7281993 add datetime to release notes 2026-02-02 01:45:02 -08:00
Hosted Weblate 2d0f4e7881 Translated using Weblate (Polish)
Currently translated at 99.9% (1128 of 1129 strings)

Translated using Weblate (Chinese (Traditional Han script))

Currently translated at 92.7% (1047 of 1129 strings)

Update translation files

Updated by "Remove blank strings" hook in Weblate.

Translated using Weblate (Chinese (Simplified Han script))

Currently translated at 97.6% (1102 of 1129 strings)

Translated using Weblate (Chinese (Simplified Han script))

Currently translated at 95.9% (1083 of 1129 strings)

Translated using Weblate (Chinese (Simplified Han script))

Currently translated at 90.5% (1022 of 1129 strings)

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: haha4ni <haha4ni@hotmail.com>
Co-authored-by: skajmer <skajmer@protonmail.com>
Co-authored-by: 無情天 <kofzhanganguo@126.com>
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/pl/
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/zh_Hans/
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/zh_Hant/
Translation: feishin/Translation
2026-02-02 10:44:51 +01:00
jeffvli ce9183ffd6 revert black background on visualizer container 2026-02-01 22:36:21 -08:00
jeffvli 3a5508653b add missing CrossfadeStyle types 2026-02-01 22:13:59 -08:00
jeffvli 7e4e28037c add typecheck to lint 2026-02-01 22:13:48 -08:00
Kendall Garner d2d8ea8249 misc type fixes, album artist header page favorite/rating work now 2026-02-01 22:04:46 -08:00
jeffvli ba835bec3e update to v1.4.1 2026-02-01 20:38:17 -08:00
jeffvli 9850874dfd support viewing up to 5 previous releases in the release notes modal 2026-02-01 20:37:51 -08:00
jeffvli 51a8285ba2 adjust fullscreen player z-indexes back
- the modal needs to appear above
- instead, move the titlebar controls z-index under the fullscreen players
2026-02-01 20:28:47 -08:00
jeffvli e12150d026 match Save as Collection popover width to its target 2026-02-01 20:26:02 -08:00
jeffvli 54bc241984 add Save as Collection button to the filters modal 2026-02-01 20:25:43 -08:00
jeffvli a698f83c45 fix butterchurn preset display not updating 2026-02-01 20:14:25 -08:00
147 changed files with 5884 additions and 867 deletions
+189
View File
@@ -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
+59
View File
@@ -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
View File
@@ -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"
},
+652 -3
View File
File diff suppressed because it is too large Load Diff
+26 -1
View File
@@ -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",
+16 -3
View File
@@ -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
View File
@@ -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": "自定义标签"
}
}
+12 -5
View File
@@ -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": {
+2 -1
View File
@@ -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;
+1
View File
@@ -4,3 +4,4 @@ import './player';
import './remote';
import './settings';
import './discord-rpc';
import './youtube';
+1
View File
@@ -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,
+18
View File
@@ -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
View File
@@ -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();
+2
View File
@@ -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;
+5
View File
@@ -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,
+11
View File
@@ -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;
+1 -1
View File
@@ -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;
+33
View File
@@ -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) => {
+5 -1
View File
@@ -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;
};
+2
View File
@@ -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 {
+88 -23
View File
@@ -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}
@@ -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
@@ -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);
}
@@ -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
@@ -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]);
@@ -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(
@@ -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,
@@ -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,
});
},
};
+224
View File
@@ -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