Compare commits

...

56 Commits

Author SHA1 Message Date
jeffvli 9fe088174d remove discord-rpc import in renderer 2026-04-10 01:30:33 -07:00
jeffvli 515638c063 fix invalid dynamic imports in renderer 2026-04-10 01:20:52 -07:00
jeffvli 9b48671dcd increase node memory limit 2026-04-10 01:14:52 -07:00
jeffvli debec2e236 remove dynamic import for platform features 2026-04-10 01:07:30 -07:00
Kendall Garner 2a6e9b6ad3 add extendInfo to alpha/beta builders 2026-04-08 07:28:34 -07:00
Kendall Garner 167b42df2b Merge pull request #1926 from noctuum/fix/wayland-screen-share-dialog
fix(linux): remove unnecessary screen capture from audio loopback handler
2026-04-08 02:11:36 +00:00
jeffvli e6a2bc3acf disable useTransition in router again 2026-04-07 18:21:32 -07:00
jeffvli ca3c7015c6 add fallback to direct streamURL if getTranscodeDecision fails 2026-04-07 18:14:47 -07:00
jeffvli c7c15d917a isolate item card control renders 2026-04-07 18:14:47 -07:00
noctuum 6adb29bc38 fix(linux): remove unnecessary desktopCapturer call from display media handler
The setDisplayMediaRequestHandler was calling desktopCapturer.getSources()
to provide a video source that the renderer never uses (it requests
video: false and only consumes audio tracks). On Wayland, this created a
new xdg-desktop-portal ScreenCast session on every launch, showing an
unavoidable screen share dialog because Electron does not persist
PipeWire restore tokens across desktopCapturer sessions.

Simplified the handler to return only { audio: 'loopback' }, which
captures system audio via PipeWire/PulseAudio monitor source without
any portal interaction.
2026-04-08 04:56:05 +07:00
Hosted Weblate 2c3cd7af24 Translated using Weblate
Currently translated at 94.6% (1136 of 1200 strings) (German)
Translation: feishin/Translation
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/de/

Translated using Weblate

Currently translated at 100.0% (1200 of 1200 strings) (Chinese (Traditional Han script))
Translation: feishin/Translation
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/zh_Hant/

Translated using Weblate

Currently translated at 100.0% (1200 of 1200 strings) (Japanese)
Translation: feishin/Translation
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/ja/

Translated using Weblate

Currently translated at 100.0% (1200 of 1200 strings) (Czech)
Translation: feishin/Translation
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/cs/

Translated using Weblate

Currently translated at 100.0% (1200 of 1200 strings) (Spanish)
Translation: feishin/Translation
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/es/

Translated using Weblate

Currently translated at 100.0% (1200 of 1200 strings) (French)
Translation: feishin/Translation
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/fr/

Translated using Weblate

Currently translated at 100.0% (1200 of 1200 strings) (Polish)
Translation: feishin/Translation
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/pl/

Co-authored-by: Fordas <fordas15@gmail.com>
Co-authored-by: KosmoMoustache <kosmomoustache@users.noreply.hosted.weblate.org>
Co-authored-by: York <goog10216922@gmail.com>
Co-authored-by: Zarakkas <kaz@users.noreply.hosted.weblate.org>
Co-authored-by: karigane <169052233+karigane-cha@users.noreply.github.com>
Co-authored-by: lorduskordus <lorduskordus@gmail.com>
Co-authored-by: skajmer <skajmer@protonmail.com>
2026-04-07 08:10:05 +02:00
jeffvli 3c442a2d40 update to v1.11.0 2026-04-06 17:10:18 -07:00
Andrzej Voss d67c185c93 feat: Make "Clear" button "Refresh" when there are no lyrics found. (#1920)
Ref: effvli/feishin#1919 - tl;dr: Button actually reloads/refreshes
lyrics info from the server too, it makes it, well, clearer what it does
in that case - allows to reread lyrics from server without clearing whole cache.
2026-04-06 16:59:01 -07:00
jeffvli ff96a5f121 lint 2026-04-06 12:06:55 -07:00
jeffvli 6fc7b6b271 support image drop for upload 2026-04-06 11:41:33 -07:00
jeffvli 918f453066 support navidrome artist image upload/delete 2026-04-06 11:41:26 -07:00
jeffvli 4a986069f8 set flac as default transcoding profile 2026-04-06 10:58:37 -07:00
jeffvli 11d26af893 remove arm/v7 from container build 2026-04-06 09:47:28 -07:00
jeffvli ad13fea033 update to v1.10.0 2026-04-05 22:41:06 -07:00
Hosted Weblate 8a75ec2558 Translated using Weblate
Currently translated at 100.0% (1196 of 1196 strings) (Chinese (Traditional Han script))
Translation: feishin/Translation
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/zh_Hant/

Co-authored-by: York <goog10216922@gmail.com>
2026-04-06 05:27:10 +00:00
Auzlex 895cbb4d16 fix(media-session): prevent handlers from being destroyed during playback (#1898)
Handlers were being registered and destroyed on state change/re-render,
causing media controls to vanish during rapid use or quick track skipping.
Persist handlers and add debounce for rapid track skipping.

Tested on Windows, Linux, and Android.
2026-04-05 22:27:04 -07:00
jeffvli 3f300c40cc add in-app prompt for system audio connection 2026-04-05 22:19:09 -07:00
jeffvli c8e8f58cce re-add useTransition to router 2026-04-05 21:54:07 -07:00
jeffvli 56cd50e0ed add react compiler 2026-04-05 18:38:06 -07:00
jeffvli 1b2a6dfc1f optimize item list controls 2026-04-05 18:21:28 -07:00
jeffvli 356f5487b0 reorder playlist context menu items 2026-04-05 14:10:07 -07:00
jeffvli 37501f2983 remove automatic autosize, use dummy fill column instead 2026-04-05 09:06:03 -07:00
jeffvli d61587b16f add automatic autosize columns when auto-fit is disabled 2026-04-05 08:12:10 -07:00
Kendall Garner 06b7b53dc9 feat(macos): add NSLocalNetworkUsageDescription 2026-04-05 08:03:28 -07:00
Kendall Garner 6c2cd1c274 fix(mpris): serve minimal metadata when playing radio
1. MPRIS (or `mpris-service`) is very fragile. If an invalid `mpris:trackid` (something with `-` or spaces) is passed in, it breaks. Use a minimal track id instead
2. Only populate album/artist/title
2026-04-05 08:01:28 -07:00
jeffvli ef129e4638 remove video from displayMedia request 2026-04-05 07:58:01 -07:00
jeffvli a01b4e664d add plex fork notice 2026-04-05 07:57:41 -07:00
jeffvli 0b45ab7f36 support real-time table column resizing 2026-04-05 07:48:54 -07:00
jeffvli 031d365262 decrease padding on list header 2026-04-05 03:49:31 -07:00
jeffvli 4fd56281d5 increase font size of smart playlist JSON editor 2026-04-05 02:42:05 -07:00
jeffvli 08ce8a4028 add nd v0.61.1 smart playlist fields 2026-04-05 02:38:40 -07:00
jeffvli e06877af76 make visualizer idle kill consistent for both 2026-04-05 02:35:32 -07:00
jeffvli 84395ce5b4 pass muted text props to JoinedArtists in left controls 2026-04-05 00:59:53 -07:00
jeffvli 94886a2d5a add system audio loopback for webaudio 2026-04-05 00:48:38 -07:00
jeffvli 25bb7f7069 fix scroll shadow z-indexing issue with table scrollbars 2026-04-04 23:48:48 -07:00
jeffvli 573fe5ee35 use external store for scroll shadow 2026-04-04 23:32:32 -07:00
jeffvli a868d4d539 combine wav codec check 2026-04-04 23:11:55 -07:00
jeffvli 564ee721c4 revert default transcoding profile to opus, add safari check for mp3 2026-04-04 23:08:53 -07:00
jeffvli a8d990db23 fix subsonic transcoding stream url to return raw string instead of fetch 2026-04-04 23:03:46 -07:00
jeffvli e21515f7fb add additional codec probes for transcoding profile, use mp3 instead of opus for default transcode 2026-04-04 23:03:29 -07:00
jeffvli 3e5a8ac78d re-add default suspense to album/artist routes 2026-04-04 22:25:21 -07:00
jeffvli 6c73d06dcf remove useTransition from router 2026-04-04 22:14:07 -07:00
jeffvli a8954bfa2a remove imageUrl in favor or imageId for artistInfo 2026-04-04 21:52:44 -07:00
jeffvli 19a1617a8d remove suspense spinner from router 2026-04-04 18:26:25 -07:00
jeffvli 1abae986f8 move server selector into app menu 2026-04-04 18:25:04 -07:00
jeffvli 43fa574dab add responsive breakpoint for queue control items 2026-04-04 17:37:47 -07:00
jeffvli 99530c670e redesign queue controls bar 2026-04-04 17:37:05 -07:00
jeffvli 3a0dfe59ce improve visibily of keyboard-focused search items 2026-04-04 17:37:05 -07:00
York d60ed0a793 feat: macOS menu enhancement (#1903) 2026-04-04 17:35:30 -07:00
Kendall Garner a32fed3bcf chore: upgrade dependencies (#1906)
* upgrade dependencies

* downgrade fast-average-color
2026-04-04 17:10:57 -07:00
Kendall Garner 132ac92984 chore: use consistent order of track / artist / ablum on full screen page 2026-04-04 14:46:13 -07:00
151 changed files with 6033 additions and 3808 deletions
@@ -51,5 +51,4 @@ jobs:
labels: ${{ steps.meta.outputs.labels }}
platforms: |
linux/amd64
linux/arm/v7
linux/arm64/v8
+1
View File
@@ -24,6 +24,7 @@ jobs:
- name: Build and Publish releases
env:
NODE_OPTIONS: --max-old-space-size=4096
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
uses: nick-invision/retry@v3.0.2
with:
+4
View File
@@ -169,6 +169,10 @@ Feishin supports any music server that implements a [Navidrome](https://www.navi
- [Qm-Music](https://github.com/chenqimiao/qm-music)
- More (?)
- [Plex](https://www.plex.tv/media-server-downloads)
- [Feishin fork by lux032](https://github.com/lux032/feishin) - Plex is not natively supported. Use the fork by lux032 to use Plex with Feishin.
### I have the issue "The SUID sandbox helper binary was found, but is not configured correctly" on Linux
This happens when you have user (unprivileged) namespaces disabled (`sysctl kernel.unprivileged_userns_clone` returns 0). You can fix this by either enabling unprivileged namespaces, or by making the `chrome-sandbox` Setuid.
+4 -3
View File
@@ -43,10 +43,11 @@ mac:
icon: assets/icons/icon.icns
type: distribution
hardenedRuntime: false
identity: "-"
identity: '-'
gatekeeperAssess: false
notarize: false
extendInfo:
NSLocalNetworkUsageDescription: 'Local network is necessary for accessing servers hosted on the same system as Feishin'
dmg:
contents: [{ x: 130, y: 220 }, { x: 410, y: 220, type: link, path: /Applications }]
@@ -61,7 +62,7 @@ linux:
artifactName: ${productName}-${os}-${arch}.${ext}
toolsets:
appimage: "1.0.2"
appimage: '1.0.2'
npmRebuild: false
+4 -2
View File
@@ -43,9 +43,11 @@ mac:
icon: assets/icons/icon.icns
type: distribution
hardenedRuntime: false
identity: "-"
identity: '-'
gatekeeperAssess: false
notarize: false
extendInfo:
NSLocalNetworkUsageDescription: 'Local network is necessary for accessing servers hosted on the same system as Feishin'
dmg:
contents: [{ x: 130, y: 220 }, { x: 410, y: 220, type: link, path: /Applications }]
@@ -60,7 +62,7 @@ linux:
artifactName: ${productName}-${os}-${arch}.${ext}
toolsets:
appimage: "1.0.2"
appimage: '1.0.2'
npmRebuild: false
publish:
+4 -2
View File
@@ -43,9 +43,11 @@ mac:
icon: assets/icons/icon.icns
type: distribution
hardenedRuntime: false
identity: "-"
identity: '-'
gatekeeperAssess: false
notarize: false
extendInfo:
NSLocalNetworkUsageDescription: 'Local network is necessary for accessing servers hosted on the same system as Feishin'
dmg:
contents: [{ x: 130, y: 220 }, { x: 410, y: 220, type: link, path: /Applications }]
@@ -60,7 +62,7 @@ linux:
artifactName: ${productName}-${os}-${arch}.${ext}
toolsets:
appimage: "1.0.2"
appimage: '1.0.2'
npmRebuild: false
afterAllArtifactBuild: scripts/after-all-artifact-build.mjs
+3 -2
View File
@@ -1,10 +1,11 @@
import react from '@vitejs/plugin-react';
import { externalizeDepsPlugin, UserConfig } from 'electron-vite';
import { resolve } from 'path';
import conditionalImportPlugin from 'vite-plugin-conditional-import';
import dynamicImportPlugin from 'vite-plugin-dynamic-import';
import { ViteEjsPlugin } from 'vite-plugin-ejs';
import { createReactPlugin } from './vite.react-plugin';
const currentOSEnv = process.platform;
const electronRendererTarget = 'chrome87';
@@ -64,7 +65,7 @@ const config: UserConfig = {
localsConvention: 'camelCase',
},
},
plugins: [react(), ViteEjsPlugin({ web: false })],
plugins: [createReactPlugin(), ViteEjsPlugin({ web: false })],
resolve: {
alias: {
'/@/i18n': resolve('src/i18n'),
+1 -1
View File
@@ -25,7 +25,7 @@ export default tseslint.config(
'react-refresh': eslintPluginReactRefresh,
},
rules: {
...eslintPluginReactHooks.configs.recommended.rules,
...eslintPluginReactHooks.configs['recommended-latest'].rules,
...eslintPluginReactRefresh.configs.vite.rules,
'@typescript-eslint/explicit-function-return-type': 'off',
'@typescript-eslint/no-duplicate-enum-values': 'off',
+78 -74
View File
@@ -1,6 +1,6 @@
{
"name": "feishin",
"version": "1.9.0",
"version": "1.11.0",
"description": "A modern self-hosted music player.",
"keywords": [
"subsonic",
@@ -31,36 +31,36 @@
"i18next": "i18next -c src/i18n/i18next-parser.config.js",
"postinstall": "electron-builder install-app-deps",
"lint": "pnpm run typecheck && pnpm run lint-code && pnpm run lint-styles",
"lint:fix": "pnpm run lint-code:fix && pnpm run lint-styles:fix",
"lint-code": "eslint --max-warnings=0 --cache .",
"lint-code:fix": "eslint --cache --fix .",
"lint-styles": "stylelint --max-warnings=0 'src/**/*.{css,scss}'",
"lint-styles:fix": "stylelint 'src/**/*.{css,scss}' --fix",
"lint:fix": "pnpm run lint-code:fix && pnpm run lint-styles:fix",
"package": "pnpm run build && electron-builder",
"package:dev": "pnpm run build && electron-builder --dir",
"package:linux": "pnpm run build && electron-builder --linux",
"package:linux-arm64:pr": "pnpm run build && electron-builder --linux --arm64 --publish never",
"package:linux:pr": "pnpm run build && electron-builder --linux --publish never",
"package:linux-arm64:pr": "pnpm run build && electron-builder --linux --arm64 --publish never",
"package:mac": "pnpm run build && electron-builder --mac",
"package:mac:pr": "pnpm run build && electron-builder --mac --publish never",
"package:win": "pnpm run build && electron-builder --win",
"package:win-arm64:pr": "pnpm run build && electron-builder --win --arm64 --publish never",
"package:win:pr": "pnpm run build && electron-builder --win --publish never",
"package:win-arm64:pr": "pnpm run build && electron-builder --win --arm64 --publish never",
"publish:linux": "pnpm run build && electron-builder --publish always --linux",
"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: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",
"publish:win-arm64": "pnpm run build && electron-builder --publish always --win --arm64",
"publish:win-arm64:alpha": "pnpm run build && electron-builder --config electron-builder-alpha.yml --publish always --win --arm64",
"publish:win-arm64:beta": "pnpm run build && electron-builder --config electron-builder-beta.yml --publish always --win --arm64",
"publish:win:alpha": "pnpm run build && electron-builder --config electron-builder-alpha.yml --publish always --win",
"publish:win:beta": "pnpm run build && electron-builder --config electron-builder-beta.yml --publish always --win",
"start": "electron-vite preview",
"typecheck": "pnpm run typecheck:node && pnpm run typecheck:web",
"typecheck:node": "tsc --noEmit -p tsconfig.node.json --composite false",
@@ -68,119 +68,123 @@
"version": "pnpm version --no-git-tag-version",
"postversion": "node ./scripts/update-app-stream.mjs"
},
"resolutions": {
"react-router": "7.14.0",
"xml2js": "0.5.0"
},
"dependencies": {
"@atlaskit/pragmatic-drag-and-drop": "1.7.7",
"@atlaskit/pragmatic-drag-and-drop-auto-scroll": "^2.1.2",
"@atlaskit/pragmatic-drag-and-drop-auto-scroll": "^2.1.5",
"@atlaskit/pragmatic-drag-and-drop-hitbox": "^1.1.0",
"@electron-toolkit/preload": "^3.0.1",
"@electron-toolkit/preload": "^3.0.2",
"@electron-toolkit/utils": "^4.0.0",
"@mantine/colors-generator": "^8.3.8",
"@mantine/core": "^8.3.8",
"@mantine/dates": "^8.3.8",
"@mantine/form": "^8.3.8",
"@mantine/hooks": "^8.3.8",
"@mantine/modals": "^8.3.8",
"@mantine/notifications": "^8.3.8",
"@mantine/colors-generator": "^8.3.18",
"@mantine/core": "^8.3.18",
"@mantine/dates": "^8.3.18",
"@mantine/form": "^8.3.18",
"@mantine/hooks": "^8.3.18",
"@mantine/modals": "^8.3.18",
"@mantine/notifications": "^8.3.18",
"@radix-ui/react-context-menu": "^2.2.16",
"@tanstack/react-query": "^5.90.9",
"@tanstack/react-query-devtools": "^5.90.2",
"@tanstack/react-query-persist-client": "^5.90.11",
"@tanstack/react-query": "^5.96.2",
"@tanstack/react-query-devtools": "^5.96.2",
"@tanstack/react-query-persist-client": "^5.96.2",
"@ts-rest/core": "^3.52.1",
"@wavesurfer/react": "^1.0.11",
"@xhayper/discord-rpc": "^1.3.0",
"audiomotion-analyzer": "^4.5.1",
"axios": "^1.13.5",
"butterchurn": "^3.0.0-beta.5",
"butterchurn-presets": "^3.0.0-beta.4",
"cheerio": "^1.1.2",
"@wavesurfer/react": "^1.0.12",
"@xhayper/discord-rpc": "^1.3.3",
"audiomotion-analyzer": "^4.5.4",
"axios": "^1.14.0",
"butterchurn": "3.0.0-beta.5",
"butterchurn-presets": "3.0.0-beta.4",
"cheerio": "^1.2.0",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"dayjs": "^1.11.19",
"dompurify": "^3.3.0",
"dayjs": "^1.11.20",
"dompurify": "^3.3.3",
"electron-debug": "^3.2.0",
"electron-localshortcut": "^3.2.1",
"electron-log": "^5.4.3",
"electron-store": "^8.2.0",
"electron-updater": "^6.6.2",
"fast-average-color": "^9.5.0",
"fast-xml-parser": "^5.3.8",
"electron-updater": "^6.8.3",
"fast-average-color": "9.5.0",
"fast-xml-parser": "^5.5.10",
"format-duration": "^3.0.2",
"fuse.js": "^7.1.0",
"i18next": "^25.6.2",
"fuse.js": "^7.2.0",
"i18next": "^25.10.10",
"icecast-metadata-stats": "^0.1.12",
"idb-keyval": "^6.2.2",
"immer": "^10.2.0",
"is-electron": "^2.2.2",
"lodash": "^4.17.23",
"lodash": "^4.18.1",
"md5": "^2.3.0",
"motion": "^12.23.24",
"motion": "^12.38.0",
"mpris-service": "^2.1.2",
"nanoid": "^3.3.11",
"node-mpv": "github:jeffvli/Node-MPV#32b4d64395289ad710c41d481d2707a7acfc228f",
"nuqs": "^2.7.1",
"overlayscrollbars": "^2.11.1",
"overlayscrollbars": "^2.14.0",
"overlayscrollbars-react": "^0.5.6",
"qs": "^6.14.2",
"react": "^19.1.0",
"react-call": "^1.8.1",
"react-dom": "^19.1.0",
"qs": "^6.15.0",
"react": "^19.2.4",
"react-call": "^1.8.2",
"react-dom": "^19.2.4",
"react-error-boundary": "^5.0.0",
"react-i18next": "^16.3.3",
"react-icons": "^5.5.0",
"react-player": "^2.16.0",
"react-router": "^7.13.1",
"react-split-pane": "^3.0.4",
"react-i18next": "^16.6.6",
"react-icons": "^5.6.0",
"react-player": "^2.16.1",
"react-router": "^7.14.0",
"react-split-pane": "^3.2.0",
"react-virtualized-auto-sizer": "^1.0.26",
"react-window": "1.8.11",
"react-window-v2": "npm:react-window@^2.2.3",
"semver": "^7.5.4",
"react-window-v2": "npm:react-window@^2.2.7",
"semver": "^7.7.4",
"string-to-color": "^2.2.2",
"wavesurfer.js": "^7.11.1",
"ws": "^8.18.2",
"zod": "^3.22.3",
"zustand": "^5.0.5"
"wavesurfer.js": "^7.12.5",
"ws": "^8.20.0",
"zod": "^3.25.76",
"zustand": "^5.0.12"
},
"devDependencies": {
"@electron-toolkit/eslint-config-prettier": "^3.0.0",
"@electron-toolkit/eslint-config-ts": "^3.0.0",
"@electron-toolkit/eslint-config-ts": "^3.1.0",
"@electron-toolkit/tsconfig": "^2.0.0",
"@types/electron-localshortcut": "^3.1.0",
"@types/lodash": "^4.17.18",
"@types/md5": "^2.3.5",
"@types/node": "^24.10.1",
"@types/react": "^19.2.5",
"@types/electron-localshortcut": "^3.1.3",
"@types/lodash": "^4.17.24",
"@types/md5": "^2.3.6",
"@types/node": "^24.12.2",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@types/react-window": "^1.8.8",
"@types/source-map-support": "^0.5.10",
"@types/ws": "^8.18.1",
"@vitejs/plugin-react": "^5.1.1",
"@vitejs/plugin-react": "^5.2.0",
"babel-plugin-react-compiler": "^1.0.0",
"concurrently": "^9.2.1",
"cross-env": "^10.1.0",
"electron": "^39.4.0",
"electron": "^39.8.6",
"electron-builder": "^26.8.2",
"electron-devtools-installer": "^4.0.0",
"electron-vite": "^4.0.1",
"eslint": "^9.24.0",
"eslint-plugin-perfectionist": "^4.13.0",
"eslint-plugin-prettier": "^5.4.0",
"eslint": "^9.39.4",
"eslint-plugin-perfectionist": "^4.15.1",
"eslint-plugin-prettier": "^5.5.5",
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.24",
"i18next-parser": "^9.3.0",
"eslint-plugin-react-refresh": "^0.4.26",
"i18next-parser": "^9.4.0",
"postcss-preset-mantine": "^1.18.0",
"postcss-simple-vars": "^7.0.1",
"prettier": "^3.6.2",
"prettier-plugin-packagejson": "^2.5.19",
"stylelint": "^16.25.0",
"stylelint-config-css-modules": "^4.5.1",
"stylelint-config-recess-order": "^7.4.0",
"prettier": "^3.8.1",
"prettier-plugin-packagejson": "^2.5.22",
"stylelint": "^16.26.1",
"stylelint-config-css-modules": "^4.6.0",
"stylelint-config-recess-order": "^7.7.0",
"stylelint-config-standard": "^39.0.1",
"typescript": "^5.8.3",
"vite": "^7.2.2",
"typescript": "^5.9.3",
"vite": "^7.3.1",
"vite-plugin-conditional-import": "^0.1.7",
"vite-plugin-dynamic-import": "^1.6.0",
"vite-plugin-ejs": "^1.7.0",
"vite-plugin-pwa": "^1.1.0"
"vite-plugin-pwa": "^1.2.0"
},
"pnpm": {
"onlyBuiltDependencies": [
+2436 -2404
View File
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -1,9 +1,9 @@
import react from '@vitejs/plugin-react';
import path from 'path';
import { defineConfig, normalizePath } from 'vite';
import { ViteEjsPlugin } from 'vite-plugin-ejs';
import { version } from './package.json';
import { createReactPlugin } from './vite.react-plugin';
export default defineConfig({
build: {
@@ -35,7 +35,7 @@ export default defineConfig({
},
},
plugins: [
react(),
createReactPlugin(),
ViteEjsPlugin({
prod: process.env.NODE_ENV === 'production',
root: normalizePath(path.resolve(__dirname, './src/remote')),
+7 -1
View File
@@ -1381,6 +1381,12 @@
}
},
"pasteGradient": "Vložit přechod",
"pasteGradientPlaceholder": "Sem vložte JSON přechodu…"
"pasteGradientPlaceholder": "Sem vložte JSON přechodu…",
"systemAudioConsentAllow": "Povolit",
"systemAudioConsentBody": "Vizualizér potřebuje pro svou činnost přístup k systémovému zvuku",
"systemAudioConsentTitle": "Povolit přístup k systémovému zvuku?",
"systemAudioCaptureFailed": "Nepodařilo se spustit zachytávání: {{message}}",
"systemAudioNoAudioTrack": "Nebyla zachycena žádná zvuková stopa. Ujistěte se, že jste při výzvě povolili zachytávání zvuku.",
"systemAudioConsentDecline": "Zamítnout"
}
}
+40 -27
View File
@@ -242,7 +242,7 @@
"criticRating": "Kritikerbewertung",
"album": "$t(entity.album, {\"count\": 1})",
"trackNumber": "Track",
"channels": "$t(common.channel_other)",
"channels": "$t(common.channel,{\"count\":2})",
"owner": "$t(common.owner)",
"genre": "$t(entity.genre, {\"count\": 1})",
"artist": "$t(entity.artist, {\"count\": 1})",
@@ -273,13 +273,13 @@
"input_name": "Servername",
"success": "Server erfolgreich hinzugefügt",
"input_savePassword": "Passwort speichern",
"ignoreSsl": "SSL ignorieren $t(common.restartRequired)",
"ignoreCors": "CORS ignorieren $t(common.restartRequired)",
"ignoreSsl": "SSL ignorieren ($t(common.restartRequired))",
"ignoreCors": "CORS ignorieren ($t(common.restartRequired))",
"error_savePassword": "Beim Speichern des Passworts ist ein Fehler aufgetreten",
"input_preferInstantMix": "Instant-Mix bevorzugen",
"input_preferInstantMixDescription": "nur Instant-Mix verwenden, um ähnliche Songs zu erhalten. Nützlich bei Verwendung von Plugins, die in dieses Verhalten eingreifen",
"input_preferRemoteUrl": "öffentliche URL bevorzugen",
"input_remoteUrl": "Öffentliche URL",
"input_remoteUrl": "öffentliche URL",
"input_remoteUrlPlaceholder": "Optional: öffentliche URL für externe Funktionen"
},
"addToPlaylist": {
@@ -357,6 +357,9 @@
"input_offset": "$t(setting.lyricOffset)",
"export": "Songtexte exportieren",
"input_synced": "Synchronisierte Songtexte exportieren"
},
"editRadioStation": {
"success": "Radiosender erfolgreich aktualisiert"
}
},
"entity": {
@@ -426,8 +429,8 @@
"pagination": "Seitenzahlen",
"pagination_itemsPerPage": "Elemente pro Seite",
"pagination_infinite": "unendlich",
"moveUp": "Nach oben bewegen",
"moveDown": "Nach unten bewegen",
"moveUp": "nach oben",
"moveDown": "nach unten",
"pinToLeft": "links anheften",
"pinToRight": "rechts anheften",
"itemGap": "Item Abstand (px)",
@@ -450,13 +453,13 @@
"albumArtist": "$t(entity.albumArtist, {\"count\": 1})",
"artist": "$t(entity.artist, {\"count\": 1})",
"favorite": "$t(common.favorite)",
"actions": "$t(common.action_other)",
"actions": "$t(common.action,{\"count\":2})",
"genre": "$t(entity.genre, {\"count\": 1})",
"album": "$t(entity.album, {\"count\": 1})",
"size": "$t(common.size)",
"bpm": "$t(common.bpm)",
"titleCombined": "$t(common.title) (kombiniert)",
"channels": "$t(common.channel_other)",
"channels": "$t(common.channel,{\"count\":2})",
"duration": "$t(common.duration)",
"note": "$t(common.note)",
"owner": "$t(common.owner)",
@@ -493,7 +496,7 @@
"rating": "Bewertung",
"albumCount": "$t(entity.album, {\"count\": 2})",
"artist": "$t(entity.artist, {\"count\": 1})",
"channels": "$t(common.channel_other)",
"channels": "$t(common.channel,{\"count\":2})",
"comment": "Kommentar",
"dateAdded": "hinzugefügt am",
"playCount": "Abgespielt",
@@ -716,7 +719,8 @@
},
"releasenotes": {
"commitsSinceStable": "Commits seit {{stable}}",
"noStableReleaseToCompare": "Kein stable Relase zum vergleichen verfügbar"
"noStableReleaseToCompare": "Kein stable Relase zum vergleichen verfügbar",
"noNewCommits": "keine neuen Beiträge in diesem Bereich"
}
},
"player": {
@@ -766,12 +770,12 @@
"sleepTimer": "Sleep Timer",
"sleepTimer_custom": "Benutzerdefiniert",
"sleepTimer_hours": "{{count}} std",
"sleepTimer_minutes": "{{count}} min",
"sleepTimer_minutes": "{{count}} Min",
"trackRadio": "Song Radio",
"albumRadio": "Album Radio"
},
"setting": {
"audioDevice_description": "wählen Sie das Audiogerät aus, das für die Wiedergabe verwendet werden soll (nur Webplayer)",
"audioDevice_description": "das für die Wiedergabe zu verwendende Audiogerät auswählen",
"audioExclusiveMode": "Audio-Exklusivmodus",
"audioDevice": "Audiogerät",
"accentColor": "Akzentfarbe",
@@ -789,7 +793,7 @@
"crossfadeDuration": "Dauer der Überblendung",
"discordIdleStatus": "rich presence status im Leerlauf",
"audioPlayer": "Audio-Player",
"discordApplicationId": "{{discord}} Anwendungs ID",
"discordApplicationId": "{{discord}} Anwendungs-ID",
"customFontPath_description": "Legt den Pfad zur benutzerdefinierten Schriftart fest, welche für die Anwendung verwendet werden soll",
"remotePort_description": "Legt den Port des Fernsteuerungsserver fest",
"hotkey_skipBackward": "rückwärts springen",
@@ -1009,7 +1013,7 @@
"transcodeFormat": "Format für Umwandlung",
"startMinimized_description": "Startet die Anwendung im Info-Bereich",
"startMinimized": "Im Info-Bereich starten",
"mediaSession_description": "Aktiviert die Windows Media Session-Integration, zeigt Mediensteuerelemente und Metadaten im Systemlautstärke-Overlay und auf dem Sperrbildschirm an (nur Windows)",
"mediaSession_description": "aktiviert die Media Session Integration. Dies ermöglicht die Steuerung und Anzeige der Medien in der Systemlautstärkeoption und auf dem Sperrbildschirm",
"mediaSession": "Media Session aktivieren",
"artistBackgroundBlur": "Unschärfegrad für Künstlerhintergründe",
"artistBackgroundBlur_description": "Legt den Grad der Unschärfe fest, der auf das Hintergrundbild des Künstlers angewendet wird",
@@ -1017,11 +1021,11 @@
"contextMenu_description": "Legt die Einträge fest, die im Rechtsklick-Menü angezeigt werden sollen. Abgewählte Einträge werden ausgeblendet",
"crossfadeStyle": "Art der Überblende",
"customCss_description": "Benutzerdefinierter CSS-Inhalt. Hinweis: Inhalte und Remote-URLs sind nicht zulässige Eigenschaften. Unten siehst du eine Vorschau deines Inhalts. Aufgrund von Bereinigung werden womöglich zusätzliche, nicht von dir definierte Felder angezeigt",
"customCssNotice": "Warnung: Obwohl eine gewisse Bereinigung erfolgt (url() und content: sind nicht zulässig), kann die Verwendung von benutzerdefiniertem CSS dennoch Risiken mit sich bringen, da dadurch die Benutzeroberfläche verändert wird",
"customCssNotice": "Warnung: Obwohl eine gewisse Bereinigung erfolgt (nicht zulässig sind z. B. \"url()\" und \"content:\"), kann ein benutzerdefiniertes CSS Risiken mit sich bringen, da die Benutzeroberfläche dadurch verändert wird",
"releaseChannel_optionBeta": "Beta",
"releaseChannel_optionLatest": "Stabil",
"releaseChannel": "Veröffentlichungskanal",
"releaseChannel_description": "Zwischen stabilen und beta Veröffentlichungen für automatische Aktualisierungen wählen",
"releaseChannel_description": "zwischen stabilen, Beta- oder Alpha-Versionen (Nightly) für automatische Updates wählen",
"discordDisplayType_artistname": "Künstlername(n)",
"discordDisplayType_description": "Ändert den aktuellen Titel im Zuhör-Status",
"discordDisplayType_songname": "Songtitel",
@@ -1121,7 +1125,13 @@
"nativeSpotify": "Spotify App benutzen",
"qobuz_description": "Zeige Links zu Qobuz auf den Interpreten/Alben Seiten",
"spotify_description": "Zeige Links zu Spotify auf den Interpreten/Alben Seiten",
"artistReleaseTypeConfiguration": "Interpreten Release Typ Einstellung"
"artistReleaseTypeConfiguration": "Interpreten Release Typ Einstellung",
"discordStateIcon": "Play Icon anzeigen",
"homeFeatureStyle_optionSingle": "Einzeln",
"nativeSpotify_description": "in der Spotify App statt im Browser öffnen",
"imageResolution_optionFullScreenPlayer": "Wiedergabe im Vollbildmodus",
"sidePlayQueueLayout_optionHorizontal": "horizontal",
"sidePlayQueueLayout_optionVertical": "vertikal"
},
"dragDropZone": {
"error_oneFileOnly": "Bitte wähle nur 1 Datei",
@@ -1211,7 +1221,7 @@
"dualVertical": "Dual-Vertikal"
}
},
"minimumFrequency": "Mindestfrequenz",
"minimumFrequency": "Minimale Frequenz",
"minimumDecibels": "Minimale Dezibel",
"visualizerType": "Visualizer Art",
"cyclePresets": "Vorlagen durchrotieren",
@@ -1246,10 +1256,10 @@
"channelLayout": "Kanallayout",
"maxFPS": "Max FPS",
"opacity": "Deckkraft",
"customGradients": "Benutzerdefinierte Gradienten",
"customGradients": "Benutzerdefinierter Farbverlauf",
"addCustomGradient": "Benutzerdefinierten Gradienten hinzufügen",
"gradientName": "Gradientenname",
"gradientNamePlaceholder": "Gradientenname",
"gradientName": "Name Farbverlauf",
"gradientNamePlaceholder": "Name Farbverlauf",
"vertical": "Vertikal",
"horizontal": "Horizontal",
"addColor": "Farbe hinzufügen",
@@ -1262,9 +1272,9 @@
"builtIn": "Eingebaut",
"colors": "Farben",
"colorMode": "Farbmodus",
"gradient": "Gradienten",
"gradientLeft": "Gradienten links",
"gradientRight": "Gradienten rechts",
"gradient": "Farbverlauf",
"gradientLeft": "Farberverlauf links",
"gradientRight": "Farbverlauf rechts",
"fft": "FFT",
"fftSize": "FFT Größe",
"smoothing": "Glätten",
@@ -1273,17 +1283,20 @@
"sensitivity": "Empfindlichkeit",
"weightingFilter": "Gewichtungsfilter",
"maximumDecibels": "Maximale Dezibel",
"linearAmplitude": "Lineare Amplitude",
"linearAmplitude": "Linearer Ausschlag",
"linearBoost": "Linearer Boost",
"radialSpectrum": "Radiales Spektrum",
"radial": "Radial",
"radialInvert": "Radial invertiert",
"radius": "Radius",
"miscellaneousSettings": "Verschiedenes Einstellungen",
"miscellaneousSettings": "Verschiedene Einstellungen",
"ansiBands": "ANSI Bänder",
"lowResolution": "Niedrige Auflösung",
"showFPS": "FPS anzeigen",
"fadePeaks": "Spitzen abblenden",
"showPeaks": "Spitzen anzeigen"
"showPeaks": "Spitzen anzeigen",
"systemAudioConsentAllow": "Erlauben",
"systemAudioConsentDecline": "Ablehnen",
"frequencyScale": "Frequenzskala"
}
}
+6
View File
@@ -1214,6 +1214,12 @@
"mainText": "drop a file here"
},
"visualizer": {
"systemAudioConsentAllow": "Allow",
"systemAudioConsentBody": "The visualizer requires access to the system audio to work",
"systemAudioConsentDecline": "Deny",
"systemAudioConsentTitle": "Allow access to system audio?",
"systemAudioCaptureFailed": "Could not start capture: {{message}}",
"systemAudioNoAudioTrack": "No audio track was returned. Ensure audio capture is enabled when prompted.",
"visualizerType": "Visualizer Type",
"cyclePresets": "Cycle Presets",
"cycleTime": "Cycle Time (seconds)",
+10 -1
View File
@@ -1001,6 +1001,9 @@
"export": "Exportar letras",
"input_synced": "Exportar letras sincronizadas",
"input_offset": "$t(setting.lyricOffset)"
},
"editRadioStation": {
"success": "Estación de radio actualizada con éxito"
}
},
"table": {
@@ -1378,6 +1381,12 @@
"lowResolution": "Baja resolución",
"splitGradient": "Dividir degradado",
"noteLabels": "Etiquetas de notas",
"lumiBars": "Barras luminiscentes"
"lumiBars": "Barras luminiscentes",
"systemAudioConsentAllow": "Permitir",
"systemAudioConsentDecline": "Denegar",
"systemAudioConsentTitle": "¿Permitir acceso al sistema de audio?",
"systemAudioConsentBody": "El visualizador requiere acceso al sistema de audio para funcionar",
"systemAudioCaptureFailed": "No se pudo iniciar la captura: {{message}}",
"systemAudioNoAudioTrack": "Ninguna pista de audio devuelta. Asegúrate de que la captura de audio está activada cuando se solicite."
}
}
+10 -1
View File
@@ -1002,6 +1002,9 @@
"export": "exporter les paroles",
"input_synced": "exporter les paroles synchronisées",
"input_offset": "$t(setting.lyricOffset)"
},
"editRadioStation": {
"success": "station de radio a été mise à jour avec succès"
}
},
"entity": {
@@ -1379,6 +1382,12 @@
},
"lumiBars": "Lumi Bars",
"outlineBars": "Outline Bars",
"splitGradient": "Split Gradient"
"splitGradient": "Split Gradient",
"systemAudioNoAudioTrack": "Aucune piste audio n'a été renvoyée. Assurez-vous que la capture audio est activée lorsque vous y êtes invité.",
"systemAudioConsentAllow": "Permettre",
"systemAudioConsentBody": "Le visualiseur nécessite un accès au système audio pour fonctionner",
"systemAudioConsentDecline": "Refuser",
"systemAudioConsentTitle": "Permettre l'accès au système audio?",
"systemAudioCaptureFailed": "Impossible de démarrer la capture : {{message}}"
}
}
+11 -2
View File
@@ -1037,7 +1037,7 @@
},
"updateServer": {
"title": "サーバーをアップデート",
"success": "サーバーがアップデートされました"
"success": "サーバーの更新に成功しました"
},
"queryEditor": {
"input_optionMatchAll": "すべて一致",
@@ -1102,6 +1102,9 @@
},
"saveQueue": {
"success": "プレイキューをサーバーに保存しました"
},
"editRadioStation": {
"success": "ラジオ局の更新に成功しました"
}
},
"entity": {
@@ -1332,6 +1335,12 @@
"d": "D",
"z": "Z"
}
}
},
"systemAudioConsentAllow": "許可",
"systemAudioConsentBody": "ビジュアライザーを機能させるためには、システムオーディオへのアクセスが必要です",
"systemAudioConsentDecline": "拒否",
"systemAudioConsentTitle": "システムオーディオへのアクセスを許可しますか?",
"systemAudioCaptureFailed": "キャプチャを開始できませんでした: {{message}}",
"systemAudioNoAudioTrack": "音声トラックが返されませんでした。プロンプトが表示されたら、音声キャプチャが有効になっていることを確認してください。"
}
}
+7 -1
View File
@@ -1381,6 +1381,12 @@
},
"pasteGradient": "Wklej Gradient",
"pasteGradientPlaceholder": "Wklej tutaj JSON gradientu...",
"ansiBands": "Paski ANSI"
"ansiBands": "Paski ANSI",
"systemAudioConsentAllow": "Zezwól",
"systemAudioConsentBody": "Wizualizer wymaga dostępu do audio systemu do działania",
"systemAudioConsentDecline": "Odmów",
"systemAudioConsentTitle": "Przyznać dostęp do audio systemu?",
"systemAudioCaptureFailed": "Nie udało się rozpocząć przechwytywania: {{message}}",
"systemAudioNoAudioTrack": "Nie została zwrócona żadna ścieżka audio. Sprawdź czy przechwytywanie audio będzie włączone, gdy będzie o to prośba."
}
}
+7 -1
View File
@@ -1335,6 +1335,12 @@
"d": "D",
"z": "Z"
}
}
},
"systemAudioCaptureFailed": "無法開始擷取:{{message}}",
"systemAudioNoAudioTrack": "沒有回傳任何音軌。確保在提示時啟用音訊擷取。",
"systemAudioConsentAllow": "允許",
"systemAudioConsentBody": "此視覺化器需要存取系統音訊才能運作",
"systemAudioConsentDecline": "拒絕",
"systemAudioConsentTitle": "允許存取系統音訊?"
}
}
+1
View File
@@ -40,6 +40,7 @@ export const store = new Store<any>({
playbackType: 'web',
should_prompt_accessibility: true,
shown_accessibility_warning: false,
visualizer_system_audio_consent_granted: false,
window_enable_tray: true,
window_exit_to_tray: false,
window_minimize_to_tray: false,
+8 -1
View File
@@ -1,2 +1,9 @@
import './core';
import(`./${process.platform}`);
if (process.platform === 'linux') {
import('./linux');
} else if (process.platform === 'darwin') {
import('./darwin');
} else if (process.platform === 'win32') {
import('./win32');
}
+17
View File
@@ -150,6 +150,23 @@ ipcMain.on(
return;
}
// If the served id is an empty string, this is a radio
// Use a limited subset of the fields
if (song._serverId === '') {
// The id as passed in from use-mpris is radio- plus the radio ID
// If there are spaces or some other characters, this causes MPRIS to error and
// disconnect the bus. To prevent this, just use a fake track/radio
mprisPlayer.metadata = {
'mpris:trackid': mprisPlayer.objectPath(`track/radio`),
'xesam:album': song.album || null,
'xesam:artist': song.artists?.length
? song.artists.map((artist) => artist.name)
: null,
'xesam:title': song.name || null,
};
return;
}
mprisPlayer.metadata = {
'mpris:artUrl': imageUrl || null,
'mpris:length': song.duration ? Math.round((song.duration || 0) * 1e3) : null,
+1
View File
@@ -0,0 +1 @@
export {};
+103 -8
View File
@@ -29,7 +29,7 @@ import packageJson from '../../package.json';
import { disableMediaKeys, enableMediaKeys } from './features/core/player/media-keys';
import { shutdownServer } from './features/core/remote';
import { store } from './features/core/settings';
import MenuBuilder from './menu';
import MenuBuilder, { MenuPlaybackState } from './menu';
import {
autoUpdaterLogInterface,
createLog,
@@ -41,7 +41,7 @@ import {
} from './utils';
import './features';
import { PlayerType, TitleTheme } from '/@/shared/types/types';
import { PlayerRepeat, PlayerStatus, PlayerType, TitleTheme } from '/@/shared/types/types';
const ALPHA_UPDATER_CONFIG: {
bucket: string;
@@ -277,6 +277,13 @@ let tray: null | Tray = null;
let exitFromTray = false;
let forceQuit = false;
let powerSaveBlockerId: null | number = null;
let menuBuilder: MenuBuilder | null = null;
let currentPlaybackStatus: PlayerStatus = PlayerStatus.PAUSED;
let currentPrivateMode = false;
let currentRepeatMode: PlayerRepeat = PlayerRepeat.NONE;
let currentSidebarCollapsed = false;
let currentShuffleEnabled = false;
let playbackMenuAccelerators: MenuPlaybackState['accelerators'] = {};
if (process.env.NODE_ENV === 'production') {
import('source-map-support').then((sourceMapSupport) => {
@@ -333,6 +340,23 @@ export const getMainWindow = () => {
return mainWindow;
};
const rebuildMainMenu = () => {
if (!menuBuilder || !mainWindow) return;
menuBuilder.buildMenu({
accelerators: playbackMenuAccelerators,
playbackStatus: currentPlaybackStatus,
privateMode: currentPrivateMode,
repeatMode: currentRepeatMode,
shuffleEnabled: currentShuffleEnabled,
sidebarCollapsed: currentSidebarCollapsed,
});
if (process.platform !== 'darwin') {
Menu.setApplicationMenu(null);
}
};
export const sendToastToRenderer = ({
message,
type,
@@ -699,12 +723,8 @@ async function createWindow(first = true): Promise<void> {
});
}
const menuBuilder = new MenuBuilder(mainWindow);
menuBuilder.buildMenu();
if (process.platform !== 'darwin') {
Menu.setApplicationMenu(null);
}
menuBuilder = new MenuBuilder(mainWindow);
rebuildMainMenu();
// Open URLs in the user's browser
mainWindow.webContents.setWindowOpenHandler((edata) => {
@@ -712,6 +732,10 @@ async function createWindow(first = true): Promise<void> {
return { action: 'deny' };
});
mainWindow.webContents.session.setDisplayMediaRequestHandler((_request, callback) => {
callback({ audio: 'loopback' });
});
if (!disableAutoUpdates() && store.get('disable_auto_updates') !== true) {
new AppUpdater();
}
@@ -782,6 +806,17 @@ enum BindingActions {
VOLUME_UP = 'volumeUp',
}
const getMenuAccelerator = (
data: Record<BindingActions, { allowGlobal: boolean; hotkey: string; isGlobal: boolean }>,
action: BindingActions,
) => {
const hotkey = data[action]?.hotkey;
if (!hotkey) return undefined;
return hotkeyToElectronAccelerator(hotkey);
};
const HOTKEY_ACTIONS: Record<BindingActions, () => void> = {
[BindingActions.GLOBAL_SEARCH]: () => {},
[BindingActions.LOCAL_SEARCH]: () => {},
@@ -835,6 +870,26 @@ ipcMain.on(
}
}
playbackMenuAccelerators = {
next: getMenuAccelerator(data, BindingActions.NEXT),
playPause:
getMenuAccelerator(data, BindingActions.PLAY_PAUSE) ||
getMenuAccelerator(data, BindingActions.PLAY) ||
getMenuAccelerator(data, BindingActions.PAUSE),
previous: getMenuAccelerator(data, BindingActions.PREVIOUS),
repeat: getMenuAccelerator(data, BindingActions.TOGGLE_REPEAT),
seekBackward: getMenuAccelerator(data, BindingActions.SKIP_BACKWARD),
seekForward: getMenuAccelerator(data, BindingActions.SKIP_FORWARD),
shuffle: getMenuAccelerator(data, BindingActions.SHUFFLE),
stop: getMenuAccelerator(data, BindingActions.STOP),
volumeDown: getMenuAccelerator(data, BindingActions.VOLUME_DOWN),
volumeUp: getMenuAccelerator(data, BindingActions.VOLUME_UP),
};
if (isMacOS()) {
rebuildMainMenu();
}
const globalMediaKeysEnabled = store.get('global_media_hotkeys', true) as boolean;
if (globalMediaKeysEnabled) {
@@ -975,3 +1030,43 @@ if (!ipcMain.eventNames().includes('open-application-directory')) {
shell.openPath(userDataPath);
});
}
ipcMain.on('update-playback', (_event, status: PlayerStatus) => {
currentPlaybackStatus = status;
if (!isMacOS()) return;
rebuildMainMenu();
});
ipcMain.on('update-repeat', (_event, repeat: PlayerRepeat) => {
currentRepeatMode = repeat;
if (!isMacOS()) return;
rebuildMainMenu();
});
ipcMain.on('update-shuffle', (_event, shuffle: boolean) => {
currentShuffleEnabled = shuffle;
if (!isMacOS()) return;
rebuildMainMenu();
});
ipcMain.on('update-private-mode', (_event, privateMode: boolean) => {
currentPrivateMode = privateMode;
if (!isMacOS()) return;
rebuildMainMenu();
});
ipcMain.on('update-sidebar-collapsed', (_event, collapsedSidebar: boolean) => {
currentSidebarCollapsed = collapsedSidebar;
if (!isMacOS()) return;
rebuildMainMenu();
});
+190 -4
View File
@@ -1,18 +1,53 @@
import { app, BrowserWindow, Menu, MenuItemConstructorOptions, shell } from 'electron';
import packageJson from '../../package.json';
import { PlayerRepeat, PlayerStatus } from '/@/shared/types/types';
export type MenuPlaybackState = {
accelerators?: {
next?: string;
playPause?: string;
previous?: string;
repeat?: string;
seekBackward?: string;
seekForward?: string;
shuffle?: string;
stop?: string;
volumeDown?: string;
volumeUp?: string;
};
playbackStatus?: PlayerStatus;
privateMode?: boolean;
repeatMode?: PlayerRepeat;
shuffleEnabled?: boolean;
sidebarCollapsed?: boolean;
};
interface DarwinMenuItemConstructorOptions extends MenuItemConstructorOptions {
selector?: string;
submenu?: DarwinMenuItemConstructorOptions[] | Menu;
}
export default class MenuBuilder {
developmentEnvironmentSetup = false;
mainWindow: BrowserWindow;
constructor(mainWindow: BrowserWindow) {
this.mainWindow = mainWindow;
}
buildDarwinTemplate(): MenuItemConstructorOptions[] {
buildDarwinTemplate({
accelerators,
playbackStatus = PlayerStatus.PAUSED,
privateMode = false,
repeatMode = PlayerRepeat.NONE,
shuffleEnabled = false,
sidebarCollapsed = false,
}: MenuPlaybackState = {}): MenuItemConstructorOptions[] {
const isPlaying = playbackStatus === PlayerStatus.PLAYING;
const isRepeatEnabled = repeatMode !== PlayerRepeat.NONE;
const subMenuAbout: DarwinMenuItemConstructorOptions = {
label: 'Electron',
submenu: [
@@ -29,6 +64,21 @@ export default class MenuBuilder {
label: 'Settings',
},
{ type: 'separator' },
{
click: () => {
this.mainWindow.webContents.send('renderer-open-manage-servers');
},
label: 'Manage servers',
},
{
checked: privateMode,
click: () => {
this.mainWindow.webContents.send('renderer-toggle-private-mode');
},
label: 'Private session',
type: 'checkbox',
},
{ type: 'separator' },
{ label: 'Services', submenu: [] },
{ type: 'separator' },
{
@@ -71,6 +121,22 @@ export default class MenuBuilder {
const subMenuViewDev: MenuItemConstructorOptions = {
label: 'View',
submenu: [
{
accelerator: 'Command+K',
click: () => {
this.mainWindow.webContents.send('renderer-open-command-palette');
},
label: 'Command Palette...',
},
{
checked: sidebarCollapsed,
click: () => {
this.mainWindow.webContents.send('renderer-toggle-sidebar');
},
label: 'Collapse sidebar',
type: 'checkbox',
},
{ type: 'separator' },
{
accelerator: 'Command+R',
click: () => {
@@ -97,6 +163,22 @@ export default class MenuBuilder {
const subMenuViewProd: MenuItemConstructorOptions = {
label: 'View',
submenu: [
{
accelerator: 'Command+K',
click: () => {
this.mainWindow.webContents.send('renderer-open-command-palette');
},
label: 'Command Palette...',
},
{
checked: sidebarCollapsed,
click: () => {
this.mainWindow.webContents.send('renderer-toggle-sidebar');
},
label: 'Collapse sidebar',
type: 'checkbox',
},
{ type: 'separator' },
{
accelerator: 'Ctrl+Command+F',
click: () => {
@@ -119,6 +201,89 @@ export default class MenuBuilder {
{ label: 'Bring All to Front', selector: 'arrangeInFront:' },
],
};
const subMenuPlayback: MenuItemConstructorOptions = {
label: 'Playback',
submenu: [
{
accelerator: accelerators?.playPause,
click: () => {
this.mainWindow.webContents.send('renderer-player-play-pause');
},
label: isPlaying ? 'Pause' : 'Play',
},
{ type: 'separator' },
{
accelerator: accelerators?.next,
click: () => {
this.mainWindow.webContents.send('renderer-player-next');
},
label: 'Next',
},
{
accelerator: accelerators?.previous,
click: () => {
this.mainWindow.webContents.send('renderer-player-previous');
},
label: 'Previous',
},
{
accelerator: accelerators?.seekForward,
click: () => {
this.mainWindow.webContents.send('renderer-player-skip-forward');
},
label: 'Seek Forward',
},
{
accelerator: accelerators?.seekBackward,
click: () => {
this.mainWindow.webContents.send('renderer-player-skip-backward');
},
label: 'Seek Backforward',
},
{ type: 'separator' },
{
accelerator: accelerators?.shuffle,
checked: shuffleEnabled,
click: () => {
this.mainWindow.webContents.send('renderer-player-toggle-shuffle');
},
label: 'Shuffle',
type: 'checkbox',
},
{
accelerator: accelerators?.repeat,
checked: isRepeatEnabled,
click: () => {
this.mainWindow.webContents.send('renderer-player-toggle-repeat');
},
label: 'Repeat',
type: 'checkbox',
},
{ type: 'separator' },
{
accelerator: accelerators?.stop,
click: () => {
this.mainWindow.webContents.send('renderer-player-stop');
},
label: 'Stop',
},
{ type: 'separator' },
{
accelerator: accelerators?.volumeUp,
click: () => {
this.mainWindow.webContents.send('renderer-player-volume-up');
},
label: 'Volume Up',
},
{
accelerator: accelerators?.volumeDown,
click: () => {
this.mainWindow.webContents.send('renderer-player-volume-down');
},
label: 'Volume Down',
},
],
};
const subMenuHelp: MenuItemConstructorOptions = {
label: 'Help',
submenu: [
@@ -148,6 +313,13 @@ export default class MenuBuilder {
},
label: 'Search Issues',
},
{ type: 'separator' },
{
click: () => {
this.mainWindow.webContents.send('renderer-open-release-notes');
},
label: 'Version ' + packageJson.version,
},
],
};
@@ -156,7 +328,14 @@ export default class MenuBuilder {
? subMenuViewDev
: subMenuViewProd;
return [subMenuAbout, subMenuEdit, subMenuView, subMenuWindow, subMenuHelp];
return [
subMenuAbout,
subMenuEdit,
subMenuView,
subMenuPlayback,
subMenuWindow,
subMenuHelp,
];
}
buildDefaultTemplate(): MenuItemConstructorOptions[] {
@@ -262,14 +441,14 @@ export default class MenuBuilder {
return templateDefault;
}
buildMenu(): Menu {
buildMenu(playbackState: MenuPlaybackState = {}): Menu {
if (process.env.NODE_ENV === 'development' || process.env.DEBUG_PROD === 'true') {
this.setupDevelopmentEnvironment();
}
const template =
process.platform === 'darwin'
? this.buildDarwinTemplate()
? this.buildDarwinTemplate(playbackState)
: this.buildDefaultTemplate();
const menu = Menu.buildFromTemplate(template);
@@ -279,6 +458,13 @@ export default class MenuBuilder {
}
setupDevelopmentEnvironment(): void {
// buildMenu can run multiple times as menu state updates; attach this once.
if (this.developmentEnvironmentSetup) {
return;
}
this.developmentEnvironmentSetup = true;
this.mainWindow.webContents.on('context-menu', (_, props) => {
const { x, y } = props;
+2 -1
View File
@@ -1,4 +1,5 @@
import { SetActivity } from '@xhayper/discord-rpc';
import type { SetActivity } from '@xhayper/discord-rpc';
import { ipcRenderer } from 'electron';
const initialize = (clientId: string) => {
+25
View File
@@ -65,6 +65,26 @@ const rendererOpenSettings = (cb: (event: IpcRendererEvent) => void) => {
ipcRenderer.on('renderer-open-settings', cb);
};
const rendererOpenCommandPalette = (cb: (event: IpcRendererEvent) => void) => {
ipcRenderer.on('renderer-open-command-palette', cb);
};
const rendererOpenManageServers = (cb: (event: IpcRendererEvent) => void) => {
ipcRenderer.on('renderer-open-manage-servers', cb);
};
const rendererTogglePrivateMode = (cb: (event: IpcRendererEvent) => void) => {
ipcRenderer.on('renderer-toggle-private-mode', cb);
};
const rendererToggleSidebar = (cb: (event: IpcRendererEvent) => void) => {
ipcRenderer.on('renderer-toggle-sidebar', cb);
};
const rendererOpenReleaseNotes = (cb: (event: IpcRendererEvent) => void) => {
ipcRenderer.on('renderer-open-release-notes', cb);
};
export const utils = {
checkForUpdates,
disableAutoUpdates,
@@ -78,7 +98,12 @@ export const utils = {
openApplicationDirectory,
openItem,
playerErrorListener,
rendererOpenCommandPalette,
rendererOpenManageServers,
rendererOpenReleaseNotes,
rendererOpenSettings,
rendererTogglePrivateMode,
rendererToggleSidebar,
};
export type Utils = typeof utils;
+28
View File
@@ -147,6 +147,20 @@ export const controller: GeneralController = {
server.type,
)?.(addContext({ ...args, apiClientProps: { ...args.apiClientProps, server } }));
},
deleteArtistImage(args) {
const server = getServerById(args.apiClientProps.serverId);
if (!server) {
throw new Error(
`${i18n.t('error.apiRouteError', { postProcess: 'sentenceCase' })}: deleteArtistImage`,
);
}
return apiController(
'deleteArtistImage',
server.type,
)?.(addContext({ ...args, apiClientProps: { ...args.apiClientProps, server } }));
},
deleteFavorite(args) {
const server = getServerById(args.apiClientProps.serverId);
@@ -988,6 +1002,20 @@ export const controller: GeneralController = {
server.type,
)?.(addContext({ ...args, apiClientProps: { ...args.apiClientProps, server } }));
},
uploadArtistImage(args) {
const server = getServerById(args.apiClientProps.serverId);
if (!server) {
throw new Error(
`${i18n.t('error.apiRouteError', { postProcess: 'sentenceCase' })}: uploadArtistImage`,
);
}
return apiController(
'uploadArtistImage',
server.type,
)?.(addContext({ ...args, apiClientProps: { ...args.apiClientProps, server } }));
},
uploadInternetRadioStationImage(args) {
const server = getServerById(args.apiClientProps.serverId);
@@ -46,6 +46,15 @@ export const contract = c.router({
500: resultWithHeaders(ndType._response.error),
},
},
deleteArtistImage: {
body: null,
method: 'DELETE',
path: 'artist/:id/image',
responses: {
200: resultWithHeaders(ndType._response.deleteArtistImage),
500: resultWithHeaders(ndType._response.error),
},
},
deleteInternetRadioStation: {
body: null,
method: 'DELETE',
@@ -259,6 +268,15 @@ export const contract = c.router({
500: resultWithHeaders(ndType._response.error),
},
},
uploadArtistImage: {
body: ndType._parameters.uploadArtistImage,
method: 'POST',
path: 'artist/:id/image',
responses: {
200: resultWithHeaders(ndType._response.uploadArtistImage),
500: resultWithHeaders(ndType._response.error),
},
},
uploadInternetRadioStationImage: {
body: ndType._parameters.uploadInternetRadioStationImage,
method: 'POST',
@@ -13,6 +13,8 @@ import {
albumArtistListSortMap,
albumListSortMap,
AuthenticationResponse,
DeleteArtistImageArgs,
DeleteArtistImageResponse,
DeleteInternetRadioStationImageArgs,
DeleteInternetRadioStationImageResponse,
DeletePlaylistImageArgs,
@@ -28,6 +30,8 @@ import {
SortOrder,
sortOrderMap,
tagListSortMap,
UploadArtistImageArgs,
UploadArtistImageResponse,
UploadInternetRadioStationImageArgs,
UploadInternetRadioStationImageResponse,
UploadPlaylistImageArgs,
@@ -42,6 +46,7 @@ const VERSION_INFO: VersionInfo = [
[
'0.61.0',
{
[ServerFeature.ARTIST_IMAGE_UPLOAD]: [1],
[ServerFeature.INTERNET_RADIO_IMAGE_UPLOAD]: [1],
[ServerFeature.PLAYLIST_IMAGE_UPLOAD]: [1],
},
@@ -186,6 +191,21 @@ export const NavidromeController: InternalControllerEndpoint = {
id: res.body.data.id,
};
},
deleteArtistImage: async (args: DeleteArtistImageArgs): Promise<DeleteArtistImageResponse> => {
const { apiClientProps, query } = args;
const res = await ndApiClient(apiClientProps as any).deleteArtistImage({
params: {
id: query.id,
},
});
if (res.status !== 200) {
throw new Error('Failed to delete artist image');
}
return res.body.data.status === 'ok';
},
deleteFavorite: SubsonicController.deleteFavorite,
deleteInternetRadioStation: async (args) => {
const { apiClientProps, query } = args;
@@ -297,8 +317,8 @@ export const NavidromeController: InternalControllerEndpoint = {
similarArtists:
artistInfo?.similarArtist?.map((artist) => ({
id: artist.id,
imageId: null,
imageUrl: artist?.artistImageUrl?.replace(/\?size=\d+/, '') ?? null,
imageId: artist.id,
imageUrl: null,
name: artist.name,
userFavorite: Boolean(artist.starred) || false,
userRating: artist.userRating ?? null,
@@ -1270,6 +1290,40 @@ export const NavidromeController: InternalControllerEndpoint = {
return null;
},
uploadArtistImage: async (args: UploadArtistImageArgs): Promise<UploadArtistImageResponse> => {
const { apiClientProps, body, query } = args;
const server = apiClientProps.server;
const serverUrl = server?.url?.replace(/\/$/, '');
if (!serverUrl) {
throw new Error('Server is required');
}
const form = new FormData();
const bytes = body.image as Uint8Array<ArrayBuffer>;
const fileLike =
typeof File !== 'undefined'
? new File([bytes], 'image', { type: 'application/octet-stream' })
: new Blob([bytes], { type: 'application/octet-stream' });
form.append('image', fileLike as any);
const res = await axios.post(`${serverUrl}/api/artist/${query.id}/image`, form, {
headers: {
'Content-Type': 'multipart/form-data',
...(server?.ndCredential && {
'x-nd-authorization': `Bearer ${server.ndCredential}`,
}),
},
signal: apiClientProps.signal,
});
if (res.status !== 200) {
throw new Error('Failed to upload artist image');
}
return res.data?.status === 'ok';
},
uploadInternetRadioStationImage: async (
args: UploadInternetRadioStationImageArgs,
): Promise<UploadInternetRadioStationImageResponse> => {
@@ -237,6 +237,27 @@ function appendTranscodeParams(url: string, format?: string, bitrate?: number) {
return streamUrl;
}
function buildGetTranscodeStreamUrl(
server: null | undefined | { credential?: string; url?: string },
args: {
mediaId: string;
mediaType: 'podcast' | 'song';
offset: number;
transcodeParams: string;
},
): string {
const params = new URLSearchParams({
c: 'Feishin',
mediaId: args.mediaId,
mediaType: args.mediaType,
offset: String(args.offset),
transcodeParams: args.transcodeParams,
v: '1.13.0',
});
return `${server?.url}/rest/getTranscodeStream.view?${params.toString()}&${server?.credential}`;
}
function sortAndPaginate<T>(
items: T[],
options: {
@@ -487,7 +508,7 @@ export const SubsonicController: InternalControllerEndpoint = {
similarArtists:
artistInfo?.similarArtist?.map((artist) => ({
id: artist.id,
imageId: null,
imageId: artist.coverArt ?? artist.id,
imageUrl: null,
name: artist.name,
userFavorite: Boolean(artist.starred) || false,
@@ -1994,8 +2015,12 @@ export const SubsonicController: InternalControllerEndpoint = {
},
});
// If the server returns an error for transcodeDecision, fall back to direct stream so that we don't break the player
if (transcodeDecision.status !== 200) {
throw new Error('Failed to get transcode decision');
logFn.error(
`Failed to get transcode decision for song ${id}, falling back to direct stream`,
);
return streamUrl;
}
const td = transcodeDecision.body.transcodeDecision;
@@ -2013,20 +2038,14 @@ export const SubsonicController: InternalControllerEndpoint = {
return appendTranscodeParams(streamUrl, format, bitrate);
}
const transcodeStreamUrl = await ssApiClient(apiClientProps).getTranscodeStream({
query: {
mediaId: id,
mediaType,
offset: 0,
transcodeParams: td.transcodeParams,
},
const transcodeStreamUrl = buildGetTranscodeStreamUrl(server, {
mediaId: String(id),
mediaType: (mediaType ?? 'song') as 'podcast' | 'song',
offset: 0,
transcodeParams: td.transcodeParams,
});
if (transcodeStreamUrl.status !== 200) {
throw new Error('Failed to get transcode stream');
}
return transcodeStreamUrl.body;
return transcodeStreamUrl;
}
return streamUrl;
+6 -23
View File
@@ -10,9 +10,9 @@ import isElectron from 'is-electron';
import { lazy, memo, Suspense, useEffect, useMemo, useRef, useState } from 'react';
import i18n from '/@/i18n/i18n';
import { openSettingsModal } from '/@/renderer/features/settings/utils/open-settings-modal';
import { WebAudioContext } from '/@/renderer/features/player/context/webaudio-context';
import { useCheckForUpdates } from '/@/renderer/hooks/use-check-for-updates';
import { useNativeMenuSync } from '/@/renderer/hooks/use-native-menu-sync';
import { useSyncSettingsToMain } from '/@/renderer/hooks/use-sync-settings-to-main';
import { AppRouter } from '/@/renderer/router/app-router';
import { useCssSettings, useHotkeySettings, useLanguage } from '/@/renderer/store';
@@ -22,12 +22,7 @@ import { WebAudio } from '/@/shared/types/types';
import '/@/shared/styles/global.css';
import { PlayerProvider } from '/@/renderer/features/player/context/player-context';
import { AudioPlayers } from '/@/renderer/features/player/components/audio-players';
const ReleaseNotesModal = lazy(() =>
import('./release-notes-modal').then((module) => ({
default: module.ReleaseNotesModal,
})),
);
import { ReleaseNotesModal } from '/@/renderer/release-notes-modal';
const UpdateAvailableDialog = lazy(() =>
import('./update-available-dialog').then((module) => ({
@@ -82,8 +77,8 @@ const AppShell = memo(function AppShell() {
<AppRouter />
</PlayerProvider>
</WebAudioContext.Provider>
<ReleaseNotesModal />
<Suspense fallback={null}>
<ReleaseNotesModal />
<UpdateAvailableDialog />
</Suspense>
</>
@@ -97,7 +92,7 @@ const AppEffects = () => (
<CssSettingsEffect />
<GlobalShortcutsEffect />
<LanguageEffect />
<OpenSettingsEffect />
<NativeMenuSyncEffect />
</>
);
@@ -170,20 +165,8 @@ const LanguageEffect = () => {
return null;
};
const OpenSettingsEffect = () => {
useEffect(() => {
if (isElectron()) {
window.api.utils.rendererOpenSettings(() => {
openSettingsModal();
});
return () => {
ipc?.removeAllListeners('renderer-open-settings');
};
}
return undefined;
}, []);
const NativeMenuSyncEffect = () => {
useNativeMenuSync();
return null;
};
+340 -302
View File
@@ -169,6 +169,292 @@ export interface ItemCardDerivativeProps extends Omit<ItemCardProps, 'type'> {
showRating: boolean;
}
type ItemCardData = NonNullable<ItemCardProps['data']>;
const ItemCardStandardImageArea = memo(function ItemCardStandardImageArea({
controls,
data,
enableExpansion,
enableImageViewport = true,
enableNavigation,
handleContextMenu,
handleImageClick,
handleLinkDragStart,
imageAsLink,
imageFetchPriority,
internalState,
isRound,
itemType,
navigationPath,
showRating,
variant,
withControls,
}: {
controls?: ItemControls;
data: ItemCardData;
enableExpansion?: boolean;
enableImageViewport?: boolean;
enableNavigation?: boolean;
handleContextMenu: (e: React.MouseEvent<HTMLElement>) => void;
handleImageClick: (e: React.MouseEvent<HTMLElement>) => void;
handleLinkDragStart: (e: React.DragEvent<HTMLAnchorElement>) => void;
imageAsLink?: boolean;
imageFetchPriority?: 'auto' | 'high' | 'low';
internalState?: ItemListStateActions;
isRound?: boolean;
itemType: LibraryItem;
navigationPath: null | string;
showRating: boolean;
variant: 'default' | 'poster';
withControls?: boolean;
}) {
const [showControls, setShowControls] = useState(false);
const handleMouseEnter = () => {
if (withControls) {
setShowControls(true);
}
};
const handleMouseLeave = () => {
if (withControls) {
setShowControls(false);
}
};
const imageContainerClassName = clsx(styles.imageContainer, {
[styles.isRound]: isRound,
});
const isFavorite = 'userFavorite' in data && (data as { userFavorite: boolean }).userFavorite;
const userRating =
'userRating' in data &&
typeof (data as { userRating: null | number }).userRating === 'number'
? (data as { userRating: null | number }).userRating
: null;
const hasRating = showRating && userRating !== null && userRating > 0;
const imageContainerContent = (
<>
{itemType === LibraryItem.GENRE &&
data &&
'name' in data &&
typeof (data as Genre).name === 'string' ? (
<GenreImagePlaceholder
className={clsx(styles.image, styles.genrePlaceholder, {
[styles.isRound]: isRound,
})}
name={(data as Genre).name}
/>
) : (
<ItemImage
className={clsx(styles.image, { [styles.isRound]: isRound })}
enableDebounce={false}
{...(variant === 'poster' ? { enableViewport: enableImageViewport } : {})}
explicitStatus={'explicitStatus' in data && data ? data.explicitStatus : null}
fetchPriority={imageFetchPriority}
id={(data as { imageId?: string })?.imageId}
itemType={itemType}
src={(data as { imageUrl?: string })?.imageUrl}
type="itemCard"
/>
)}
{isFavorite && <div className={styles.favoriteBadge} />}
{hasRating && <div className={styles.ratingBadge}>{userRating}</div>}
<AnimatePresence>
{withControls && showControls && (
<ItemCardControls
controls={controls}
enableExpansion={enableExpansion}
{...(variant === 'poster' ? { internalState } : {})}
item={data}
itemType={itemType}
showRating={showRating}
type={variant}
/>
)}
</AnimatePresence>
</>
);
return enableNavigation && navigationPath && (imageAsLink ?? !internalState) ? (
<Link
className={imageContainerClassName}
draggable={false}
onClick={handleImageClick}
onContextMenu={handleContextMenu}
onDragStart={handleLinkDragStart}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
state={{ item: data }}
to={navigationPath}
>
{imageContainerContent}
</Link>
) : (
<div
className={imageContainerClassName}
onClick={handleImageClick}
onContextMenu={handleContextMenu}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
>
{imageContainerContent}
</div>
);
});
ItemCardStandardImageArea.displayName = 'ItemCardStandardImageArea';
const CompactItemCardImageArea = memo(function CompactItemCardImageArea({
controls,
data,
enableExpansion,
enableNavigation,
handleContextMenu,
handleImageClick,
handleLinkDragStart,
imageAsLink,
imageFetchPriority,
internalState,
isRound,
itemType,
navigationPath,
rows,
showRating,
withControls,
}: {
controls?: ItemControls;
data: ItemCardData;
enableExpansion?: boolean;
enableNavigation?: boolean;
handleContextMenu: (e: React.MouseEvent<HTMLElement>) => void;
handleImageClick: (e: React.MouseEvent<HTMLElement>) => void;
handleLinkDragStart: (e: React.DragEvent<HTMLAnchorElement>) => void;
imageAsLink?: boolean;
imageFetchPriority?: 'auto' | 'high' | 'low';
internalState?: ItemListStateActions;
isRound?: boolean;
itemType: LibraryItem;
navigationPath: null | string;
rows: DataRow[];
showRating: boolean;
withControls?: boolean;
}) {
const [showControls, setShowControls] = useState(false);
const handleMouseEnter = () => {
if (withControls) {
setShowControls(true);
}
};
const handleMouseLeave = () => {
if (withControls) {
setShowControls(false);
}
};
const imageContainerClassName = clsx(styles.imageContainer, {
[styles.isRound]: isRound,
});
const isFavorite = 'userFavorite' in data && (data as { userFavorite: boolean }).userFavorite;
const userRating =
'userRating' in data &&
typeof (data as { userRating: null | number }).userRating === 'number'
? (data as { userRating: null | number }).userRating
: null;
const hasRating = showRating && userRating !== null && userRating > 0;
const imageContainerContent = (
<>
{itemType === LibraryItem.GENRE &&
data &&
'name' in data &&
typeof (data as Genre).name === 'string' ? (
<GenreImagePlaceholder
className={clsx(styles.image, styles.genrePlaceholder, {
[styles.isRound]: isRound,
})}
name={(data as Genre).name}
/>
) : (
<ItemImage
className={clsx(styles.image, {
[styles.isRound]: isRound,
})}
enableDebounce={false}
explicitStatus={'explicitStatus' in data && data ? data.explicitStatus : null}
fetchPriority={imageFetchPriority}
id={data?.imageId}
itemType={itemType}
src={(data as Album | AlbumArtist | Playlist | Song)?.imageUrl}
type="itemCard"
/>
)}
{isFavorite && <div className={styles.favoriteBadge} />}
{hasRating && <div className={styles.ratingBadge}>{userRating}</div>}
<AnimatePresence>
{withControls && showControls && data && (
<ItemCardControls
controls={controls}
enableExpansion={enableExpansion}
internalState={internalState}
item={data}
itemType={itemType}
showRating={showRating}
type="compact"
/>
)}
</AnimatePresence>
<div className={clsx(styles.detailContainer, styles.compact)}>
{rows
.filter(
(row): row is NonNullable<typeof row> => row !== null && row !== undefined,
)
.map((row, index) => (
<ItemCardRow
data={data!}
index={index}
key={row.id}
row={row}
type="compact"
/>
))}
</div>
</>
);
return enableNavigation && navigationPath && (imageAsLink ?? !internalState) ? (
<Link
className={imageContainerClassName}
draggable={false}
onClick={handleImageClick}
onContextMenu={handleContextMenu}
onDragStart={handleLinkDragStart}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
state={{ item: data }}
to={navigationPath}
>
{imageContainerContent}
</Link>
) : (
<div
className={imageContainerClassName}
onClick={handleImageClick}
onContextMenu={handleContextMenu}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
>
{imageContainerContent}
</div>
);
});
CompactItemCardImageArea.displayName = 'CompactItemCardImageArea';
const CompactItemCard = ({
controls,
data,
@@ -185,7 +471,6 @@ const CompactItemCard = ({
showRating,
withControls,
}: ItemCardDerivativeProps) => {
const [showControls, setShowControls] = useState(false);
const itemRowId =
data && internalState && typeof data === 'object' && 'id' in data
? internalState.extractRowId(data)
@@ -297,18 +582,6 @@ const CompactItemCard = ({
if (data) {
const navigationPath = getItemNavigationPath(data, itemType);
const handleMouseEnter = () => {
if (withControls) {
setShowControls(true);
}
};
const handleMouseLeave = () => {
if (withControls) {
setShowControls(false);
}
};
const handleContextMenu = (e: React.MouseEvent<HTMLElement>) => {
if (!data || !controls) {
return;
@@ -338,81 +611,6 @@ const CompactItemCard = ({
e.stopPropagation();
};
const isFavorite =
'userFavorite' in data && (data as { userFavorite: boolean }).userFavorite;
const userRating =
'userRating' in data &&
typeof (data as { userRating: null | number }).userRating === 'number'
? (data as { userRating: null | number }).userRating
: null;
const hasRating = showRating && userRating !== null && userRating > 0;
const imageContainerClassName = clsx(styles.imageContainer, {
[styles.isRound]: isRound,
});
const imageContainerContent = (
<>
{itemType === LibraryItem.GENRE &&
data &&
'name' in data &&
typeof (data as Genre).name === 'string' ? (
<GenreImagePlaceholder
className={clsx(styles.image, styles.genrePlaceholder, {
[styles.isRound]: isRound,
})}
name={(data as Genre).name}
/>
) : (
<ItemImage
className={clsx(styles.image, {
[styles.isRound]: isRound,
})}
enableDebounce={false}
explicitStatus={
'explicitStatus' in data && data ? data.explicitStatus : null
}
fetchPriority={imageFetchPriority}
id={data?.imageId}
itemType={itemType}
src={(data as Album | AlbumArtist | Playlist | Song)?.imageUrl}
type="itemCard"
/>
)}
{isFavorite && <div className={styles.favoriteBadge} />}
{hasRating && <div className={styles.ratingBadge}>{userRating}</div>}
<AnimatePresence>
{withControls && showControls && data && (
<ItemCardControls
controls={controls}
enableExpansion={enableExpansion}
internalState={internalState}
item={data}
itemType={itemType}
showRating={showRating}
type="compact"
/>
)}
</AnimatePresence>
<div className={clsx(styles.detailContainer, styles.compact)}>
{rows
.filter(
(row): row is NonNullable<typeof row> =>
row !== null && row !== undefined,
)
.map((row, index) => (
<ItemCardRow
data={data!}
index={index}
key={row.id}
row={row}
type="compact"
/>
))}
</div>
</>
);
return (
<div
className={clsx(styles.container, styles.compact, {
@@ -421,31 +619,24 @@ const CompactItemCard = ({
})}
ref={ref}
>
{enableNavigation && navigationPath && (imageAsLink ?? !internalState) ? (
<Link
className={imageContainerClassName}
draggable={false}
onClick={handleImageClick}
onContextMenu={handleContextMenu}
onDragStart={handleLinkDragStart}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
state={{ item: data }}
to={navigationPath}
>
{imageContainerContent}
</Link>
) : (
<div
className={imageContainerClassName}
onClick={handleImageClick}
onContextMenu={handleContextMenu}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
>
{imageContainerContent}
</div>
)}
<CompactItemCardImageArea
controls={controls}
data={data}
enableExpansion={enableExpansion}
enableNavigation={enableNavigation}
handleContextMenu={handleContextMenu}
handleImageClick={handleImageClick}
handleLinkDragStart={handleLinkDragStart}
imageAsLink={imageAsLink}
imageFetchPriority={imageFetchPriority}
internalState={internalState}
isRound={isRound}
itemType={itemType}
navigationPath={navigationPath}
rows={rows}
showRating={showRating}
withControls={withControls}
/>
</div>
);
}
@@ -491,7 +682,6 @@ const DefaultItemCard = ({
showRating,
withControls,
}: ItemCardDerivativeProps) => {
const [showControls, setShowControls] = useState(false);
const itemRowId =
data && internalState && typeof data === 'object' && 'id' in data
? internalState.extractRowId(data)
@@ -538,18 +728,6 @@ const DefaultItemCard = ({
if (data) {
const navigationPath = getItemNavigationPath(data, itemType);
const handleMouseEnter = () => {
if (withControls) {
setShowControls(true);
}
};
const handleMouseLeave = () => {
if (withControls) {
setShowControls(false);
}
};
const handleContextMenu = (e: React.MouseEvent<HTMLElement>) => {
if (!data || !controls) {
return;
@@ -579,93 +757,30 @@ const DefaultItemCard = ({
e.stopPropagation();
};
const imageContainerClassName = clsx(styles.imageContainer, {
[styles.isRound]: isRound,
});
const isFavorite =
'userFavorite' in data && (data as { userFavorite: boolean }).userFavorite;
const userRating =
'userRating' in data &&
typeof (data as { userRating: null | number }).userRating === 'number'
? (data as { userRating: null | number }).userRating
: null;
const hasRating = showRating && userRating !== null && userRating > 0;
const imageContainerContent = (
<>
{itemType === LibraryItem.GENRE &&
data &&
'name' in data &&
typeof (data as Genre).name === 'string' ? (
<GenreImagePlaceholder
className={clsx(styles.image, styles.genrePlaceholder, {
[styles.isRound]: isRound,
})}
name={(data as Genre).name}
/>
) : (
<ItemImage
className={clsx(styles.image, { [styles.isRound]: isRound })}
enableDebounce={false}
explicitStatus={
'explicitStatus' in data && data ? data.explicitStatus : null
}
fetchPriority={imageFetchPriority}
id={data?.imageId}
itemType={itemType}
src={(data as Album | AlbumArtist | Playlist | Song)?.imageUrl}
type="itemCard"
/>
)}
{isFavorite && <div className={styles.favoriteBadge} />}
{hasRating && <div className={styles.ratingBadge}>{userRating}</div>}
<AnimatePresence>
{withControls && showControls && (
<ItemCardControls
controls={controls}
enableExpansion={enableExpansion}
item={data}
itemType={itemType}
showRating={showRating}
type="default"
/>
)}
</AnimatePresence>
</>
);
return (
<div
className={clsx(styles.container, {
[styles.selected]: isSelected,
})}
>
{enableNavigation && navigationPath && (imageAsLink ?? !internalState) ? (
<Link
className={imageContainerClassName}
draggable={false}
onClick={handleImageClick}
onContextMenu={handleContextMenu}
onDragStart={handleLinkDragStart}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
state={{ item: data }}
to={navigationPath}
>
{imageContainerContent}
</Link>
) : (
<div
className={imageContainerClassName}
onClick={handleImageClick}
onContextMenu={handleContextMenu}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
>
{imageContainerContent}
</div>
)}
<ItemCardStandardImageArea
controls={controls}
data={data}
enableExpansion={enableExpansion}
enableNavigation={enableNavigation}
handleContextMenu={handleContextMenu}
handleImageClick={handleImageClick}
handleLinkDragStart={handleLinkDragStart}
imageAsLink={imageAsLink}
imageFetchPriority={imageFetchPriority}
internalState={internalState}
isRound={isRound}
itemType={itemType}
navigationPath={navigationPath}
showRating={showRating}
variant="default"
withControls={withControls}
/>
<div className={styles.detailContainer}>
{rows
.filter(
@@ -728,7 +843,6 @@ const PosterItemCard = ({
showRating,
withControls,
}: ItemCardDerivativeProps) => {
const [showControls, setShowControls] = useState(false);
const itemRowId =
data && internalState && typeof data === 'object' && 'id' in data
? internalState.extractRowId(data)
@@ -840,18 +954,6 @@ const PosterItemCard = ({
if (data) {
const navigationPath = getItemNavigationPath(data, itemType);
const handleMouseEnter = () => {
if (withControls) {
setShowControls(true);
}
};
const handleMouseLeave = () => {
if (withControls) {
setShowControls(false);
}
};
const handleContextMenu = (e: React.MouseEvent<HTMLElement>) => {
if (!data || !controls) {
return;
@@ -881,63 +983,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 =
'userRating' in data &&
typeof (data as { userRating: null | number }).userRating === 'number'
? (data as { userRating: null | number }).userRating
: null;
const hasRating = showRating && userRating !== null && userRating > 0;
const imageContainerContent = (
<>
{itemType === LibraryItem.GENRE &&
data &&
'name' in data &&
typeof (data as Genre).name === 'string' ? (
<GenreImagePlaceholder
className={clsx(styles.image, styles.genrePlaceholder, {
[styles.isRound]: isRound,
})}
name={(data as Genre).name}
/>
) : (
<ItemImage
className={clsx(styles.image, { [styles.isRound]: isRound })}
enableDebounce={false}
explicitStatus={
'explicitStatus' in data && data ? data.explicitStatus : null
}
fetchPriority={imageFetchPriority}
id={(data as { imageId: string })?.imageId}
itemType={itemType}
src={(data as { imageUrl: string })?.imageUrl}
type="itemCard"
/>
)}
{isFavorite && <div className={styles.favoriteBadge} />}
{hasRating && <div className={styles.ratingBadge}>{userRating}</div>}
<AnimatePresence>
{withControls && showControls && data && (
<ItemCardControls
controls={controls}
enableExpansion={enableExpansion}
internalState={internalState}
item={data}
itemType={itemType}
showRating={showRating}
type="poster"
/>
)}
</AnimatePresence>
</>
);
return (
<div
className={clsx(styles.container, styles.poster, {
@@ -946,31 +991,24 @@ const PosterItemCard = ({
})}
ref={ref}
>
{enableNavigation && navigationPath && (imageAsLink ?? !internalState) ? (
<Link
className={imageContainerClassName}
draggable={false}
onClick={handleImageClick}
onContextMenu={handleContextMenu}
onDragStart={handleLinkDragStart}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
state={{ item: data }}
to={navigationPath}
>
{imageContainerContent}
</Link>
) : (
<div
className={imageContainerClassName}
onClick={handleImageClick}
onContextMenu={handleContextMenu}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
>
{imageContainerContent}
</div>
)}
<ItemCardStandardImageArea
controls={controls}
data={data}
enableExpansion={enableExpansion}
enableNavigation={enableNavigation}
handleContextMenu={handleContextMenu}
handleImageClick={handleImageClick}
handleLinkDragStart={handleLinkDragStart}
imageAsLink={imageAsLink}
imageFetchPriority={imageFetchPriority}
internalState={internalState}
isRound={isRound}
itemType={itemType}
navigationPath={navigationPath}
showRating={showRating}
variant="poster"
withControls={withControls}
/>
{data && (
<div className={styles.detailContainer}>
{rows
@@ -0,0 +1,30 @@
import { ItemTableListColumnConfig } from '/@/renderer/components/item-list/types';
import { TableColumn } from '/@/shared/types/types';
const LAYOUT_FILL_COLUMN: ItemTableListColumnConfig = {
align: 'start',
autoSize: true,
id: TableColumn.LAYOUT_FILL,
isEnabled: true,
pinned: null,
width: 0,
};
export const appendLayoutFillColumn = (
columns: ItemTableListColumnConfig[],
autoFitColumns: boolean,
): ItemTableListColumnConfig[] => {
if (autoFitColumns || columns.length === 0) {
return columns;
}
const unpinnedEnabled = columns.filter((c) => c.pinned === null && c.isEnabled !== false);
if (unpinnedEnabled.length === 0) {
return columns;
}
if (unpinnedEnabled.some((c) => c.autoSize === true)) {
return columns;
}
return [...columns, LAYOUT_FILL_COLUMN];
};
@@ -40,6 +40,13 @@ export const useDefaultItemListControls = (args?: UseDefaultItemListControlsArgs
const setFavorite = useSetFavorite();
const setRating = useSetRating();
const playerRef = useRef(player);
const setFavoriteRef = useRef(setFavorite);
const setRatingRef = useRef(setRating);
playerRef.current = player;
setFavoriteRef.current = setFavorite;
setRatingRef.current = setRating;
useEffect(() => {
navigateRef.current = navigate;
}, [navigate]);
@@ -266,14 +273,14 @@ export const useDefaultItemListControls = (args?: UseDefaultItemListControlsArgs
return;
}
player.addToQueueByData(songsToAdd, playType, item.id);
playerRef.current.addToQueueByData(songsToAdd, playType, item.id);
return;
}
if (itemType === LibraryItem.QUEUE_SONG) {
const queueSong = item as QueueSong;
if (queueSong._uniqueId) {
player.mediaPlay(queueSong._uniqueId);
playerRef.current.mediaPlay(queueSong._uniqueId);
}
}
},
@@ -316,7 +323,7 @@ export const useDefaultItemListControls = (args?: UseDefaultItemListControlsArgs
return;
}
setFavorite(item._serverId, [item.id], apiItemType, favorite);
setFavoriteRef.current(item._serverId, [item.id], apiItemType, favorite);
},
onMore: ({ event, internalState, item, itemType }: DefaultItemControlProps) => {
@@ -394,7 +401,7 @@ export const useDefaultItemListControls = (args?: UseDefaultItemListControlsArgs
return;
}
player.addToQueueByFetch(item._serverId, [item.id], itemType, playType);
playerRef.current.addToQueueByFetch(item._serverId, [item.id], itemType, playType);
},
onRating: ({
@@ -420,20 +427,12 @@ export const useDefaultItemListControls = (args?: UseDefaultItemListControlsArgs
newRating = 0;
}
setRating(item._serverId, [item.id], apiItemType, newRating);
setRatingRef.current(item._serverId, [item.id], apiItemType, newRating);
},
...overrides,
};
}, [
enableMultiSelect,
overrides,
onColumnReordered,
onColumnResized,
player,
setFavorite,
setRating,
]);
}, [enableMultiSelect, overrides, onColumnReordered, onColumnResized]);
return controls;
};
@@ -349,9 +349,12 @@ export const useItemListInfiniteLoader = ({
mutationKey: getListRefreshMutationKey(eventKey),
});
const refreshMutationRef = useRef(refreshMutation);
refreshMutationRef.current = refreshMutation;
const refresh = useCallback(
async (force?: boolean) => refreshMutation.mutateAsync(force),
[refreshMutation],
async (force?: boolean) => refreshMutationRef.current.mutateAsync(force),
[],
);
const updateItems = useCallback(
@@ -383,7 +386,7 @@ export const useItemListInfiniteLoader = ({
return;
}
refreshMutation.mutate(true);
refreshMutationRef.current.mutate(true);
};
eventEmitter.on('ITEM_LIST_REFRESH', handleRefresh);
@@ -391,7 +394,7 @@ export const useItemListInfiniteLoader = ({
return () => {
eventEmitter.off('ITEM_LIST_REFRESH', handleRefresh);
};
}, [eventKey, refreshMutation]);
}, [eventKey]);
useEffect(() => {
const handleFavorite = (payload: UserFavoriteEventPayload) => {
@@ -5,7 +5,7 @@ import {
useSuspenseQuery,
UseSuspenseQueryOptions,
} from '@tanstack/react-query';
import { useCallback, useEffect, useMemo } from 'react';
import { useCallback, useEffect, useMemo, useRef } from 'react';
import { queryKeys } from '/@/renderer/api/query-keys';
import { useListContext } from '/@/renderer/context/list-context';
@@ -115,6 +115,9 @@ export const useItemListPaginatedLoader = ({
mutationKey: getListRefreshMutationKey(eventKey ?? 'paginated'),
});
const refreshMutationRef = useRef(refreshMutation);
refreshMutationRef.current = refreshMutation;
const updateItems = useCallback(
(indexes: number[], value: object) => {
return queryClient.setQueryData(
@@ -153,7 +156,7 @@ export const useItemListPaginatedLoader = ({
return;
}
refreshMutation.mutate(true);
refreshMutationRef.current.mutate(true);
};
const handleFavorite = (payload: UserFavoriteEventPayload) => {
@@ -220,7 +223,7 @@ export const useItemListPaginatedLoader = ({
eventEmitter.off('USER_FAVORITE', handleFavorite);
eventEmitter.off('USER_RATING', handleRating);
};
}, [data, eventKey, itemType, refreshMutation, serverId, updateItems]);
}, [data, eventKey, itemType, serverId, updateItems]);
return { data: data?.items || [], pageCount, totalItemCount };
};
@@ -67,6 +67,7 @@ const getRowIdFromTableColumn = (tableColumn: TableColumn): null | string => {
[TableColumn.ID]: null,
[TableColumn.IMAGE]: null,
[TableColumn.LAST_PLAYED]: 'lastPlayedAt',
[TableColumn.LAYOUT_FILL]: null,
[TableColumn.OWNER]: null,
[TableColumn.PATH]: null,
[TableColumn.PLAY_COUNT]: 'playCount',
@@ -179,6 +179,14 @@
opacity: 1;
}
.resize-handle.resize-handle-disabled {
cursor: not-allowed;
}
.track-header-cell:hover .resize-handle.resize-handle-disabled {
cursor: not-allowed;
}
.resize-handle:hover {
opacity: 1;
}
@@ -911,8 +911,7 @@ const DetailListHeaderCell = memo(
const percent = col ? (columnWidthPercents[colIndex] ?? 0) : 0;
const { fixedWidth, isFixedColumn } = getTrackColumnFixed(columnId);
const currentWidth = col?.width ?? (fixedWidth || 100);
const showResizeHandle =
enableColumnResize && !isFixedColumn && !col?.autoSize && onColumnResized;
const showResizeHandle = enableColumnResize && !isFixedColumn;
useEffect(() => {
if (!containerRef.current || !onColumnReordered) {
@@ -1026,6 +1025,7 @@ const DetailListHeaderCell = memo(
{showResizeHandle && (
<DetailListColumnResizeHandle
columnId={columnId}
disabled={!!col?.autoSize}
initialWidth={currentWidth}
onResize={handleResize}
side="right"
@@ -1040,6 +1040,7 @@ DetailListHeaderCell.displayName = 'DetailListHeaderCell';
interface DetailListColumnResizeHandleProps {
columnId: TableColumn;
disabled?: boolean;
initialWidth: number;
onResize: (columnId: TableColumn, width: number) => void;
side: 'left' | 'right';
@@ -1047,6 +1048,7 @@ interface DetailListColumnResizeHandleProps {
const DetailListColumnResizeHandle = ({
columnId,
disabled = false,
initialWidth,
onResize,
side,
@@ -1091,6 +1093,11 @@ const DetailListColumnResizeHandle = ({
}, [isDragging, columnId, onResize]);
const handleMouseDown = (event: React.MouseEvent<HTMLDivElement>) => {
if (disabled) {
event.preventDefault();
event.stopPropagation();
return;
}
event.preventDefault();
event.stopPropagation();
setIsDragging(true);
@@ -1103,6 +1110,7 @@ const DetailListColumnResizeHandle = ({
return (
<div
className={clsx(styles.resizeHandle, {
[styles.resizeHandleDisabled]: disabled,
[styles.resizeHandleDragging]: isDragging,
[styles.resizeHandleLeft]: side === 'left',
[styles.resizeHandleRight]: side === 'right',
@@ -4,6 +4,7 @@
flex-direction: column !important;
width: 100%;
height: 100%;
padding-block: var(--theme-spacing-xs);
padding-right: var(--theme-spacing-md);
outline: none;
border: none;
@@ -1,3 +1,5 @@
import type { TableScrollShadowStore } from '/@/renderer/components/item-list/item-table-list/table-scroll-shadow-store';
import { autoScrollForElements } from '@atlaskit/pragmatic-drag-and-drop-auto-scroll/element';
import throttle from 'lodash/throttle';
import { useOverlayScrollbars } from 'overlayscrollbars-react';
@@ -18,9 +20,7 @@ export const useTablePaneSync = ({
pinnedRowRef,
rowRef,
scrollContainerRef,
setShowLeftShadow,
setShowRightShadow,
setShowTopShadow,
scrollShadowStore,
}: {
enableDrag: boolean | undefined;
enableDragScroll: boolean | undefined;
@@ -36,9 +36,7 @@ export const useTablePaneSync = ({
pinnedRowRef: React.RefObject<HTMLDivElement | null>;
rowRef: React.RefObject<HTMLDivElement | null>;
scrollContainerRef: React.RefObject<HTMLDivElement | null>;
setShowLeftShadow: (v: boolean) => void;
setShowRightShadow: (v: boolean) => void;
setShowTopShadow: (v: boolean) => void;
scrollShadowStore: TableScrollShadowStore;
}) => {
// Main grid overlayscrollbars - only handle X-axis if right-pinned columns exist
const [initialize, osInstance] = useOverlayScrollbars({
@@ -471,8 +469,10 @@ export const useTablePaneSync = ({
if (!row) {
const timeout = setTimeout(() => {
setShowLeftShadow(false);
setShowRightShadow(false);
scrollShadowStore.setSnapshot({
showLeftShadow: false,
showRightShadow: false,
});
}, 0);
return () => clearTimeout(timeout);
@@ -482,8 +482,10 @@ export const useTablePaneSync = ({
const scrollLeft = row.scrollLeft;
const maxScrollLeft = row.scrollWidth - row.clientWidth;
setShowLeftShadow(pinnedLeftColumnCount > 0 && scrollLeft > 0);
setShowRightShadow(pinnedRightColumnCount > 0 && scrollLeft < maxScrollLeft);
scrollShadowStore.setSnapshot({
showLeftShadow: pinnedLeftColumnCount > 0 && scrollLeft > 0,
showRightShadow: pinnedRightColumnCount > 0 && scrollLeft < maxScrollLeft,
});
}, 50);
checkScrollPosition();
@@ -494,13 +496,7 @@ export const useTablePaneSync = ({
checkScrollPosition.cancel();
row.removeEventListener('scroll', checkScrollPosition);
};
}, [
pinnedLeftColumnCount,
pinnedRightColumnCount,
rowRef,
setShowLeftShadow,
setShowRightShadow,
]);
}, [pinnedLeftColumnCount, pinnedRightColumnCount, rowRef, scrollShadowStore]);
// Handle top shadow visibility based on vertical scroll
useEffect(() => {
@@ -509,7 +505,7 @@ export const useTablePaneSync = ({
if (!row || !enableHeader) {
const timeout = setTimeout(() => {
setShowTopShadow(false);
scrollShadowStore.setSnapshot({ showTopShadow: false });
}, 0);
return () => clearTimeout(timeout);
@@ -519,7 +515,7 @@ export const useTablePaneSync = ({
const checkScrollPosition = throttle(() => {
const currentScrollTop = scrollElement.scrollTop;
setShowTopShadow(currentScrollTop > 0);
scrollShadowStore.setSnapshot({ showTopShadow: currentScrollTop > 0 });
}, 50);
checkScrollPosition();
@@ -530,5 +526,5 @@ export const useTablePaneSync = ({
checkScrollPosition.cancel();
scrollElement.removeEventListener('scroll', checkScrollPosition);
};
}, [enableHeader, pinnedRightColumnCount, pinnedRightColumnRef, rowRef, setShowTopShadow]);
}, [enableHeader, pinnedRightColumnCount, pinnedRightColumnRef, rowRef, scrollShadowStore]);
};
@@ -366,6 +366,14 @@
opacity: 1;
}
.resize-handle.resize-handle-disabled {
cursor: not-allowed;
}
.header-container:hover .resize-handle.resize-handle-disabled {
cursor: not-allowed;
}
.resize-handle:hover {
opacity: 1;
}
@@ -57,6 +57,7 @@ import { TitleCombinedColumn } from '/@/renderer/components/item-list/item-table
import { YearColumn } from '/@/renderer/components/item-list/item-table-list/columns/year-column';
import { useItemDragDropState } from '/@/renderer/components/item-list/item-table-list/hooks/use-item-drag-drop-state';
import { TableItemProps } from '/@/renderer/components/item-list/item-table-list/item-table-list';
import { useItemTableListColumnResizeLive } from '/@/renderer/components/item-list/item-table-list/item-table-list-context';
import { ItemControls, ItemListItem } from '/@/renderer/components/item-list/types';
import { Flex } from '/@/shared/components/flex/flex';
import { Icon } from '/@/shared/components/icon/icon';
@@ -193,6 +194,14 @@ const ItemTableListColumnBase = (props: ItemTableListColumn) => {
);
}
if (type === TableColumn.LAYOUT_FILL) {
return (
<TableColumnContainer {...props} {...dragProps} controls={controls} type={type}>
{null}
</TableColumnContainer>
);
}
if (itemType !== LibraryItem.FOLDER) {
switch (type) {
case TableColumn.ACTIONS:
@@ -707,6 +716,8 @@ export const TableColumnContainer = (
interface ColumnResizeHandleProps {
columnId: TableColumn;
columnIndex: number;
disabled?: boolean;
initialWidth: number;
onResize: (columnId: TableColumn, width: number) => void;
side: 'left' | 'right';
@@ -714,6 +725,8 @@ interface ColumnResizeHandleProps {
const ColumnResizeHandle = ({
columnId,
columnIndex,
disabled = false,
initialWidth,
onResize,
side,
@@ -723,6 +736,17 @@ const ColumnResizeHandle = ({
const startWidthRef = useRef<number>(initialWidth);
const startXRef = useRef<number>(0);
const finalWidthRef = useRef<number>(initialWidth);
const columnResizeLive = useItemTableListColumnResizeLive();
const onResizeRef = useRef(onResize);
const columnResizeLiveRef = useRef(columnResizeLive);
useEffect(() => {
onResizeRef.current = onResize;
}, [onResize]);
useEffect(() => {
columnResizeLiveRef.current = columnResizeLive;
}, [columnResizeLive]);
// Update the ref when initialWidth changes (but not during drag)
useEffect(() => {
@@ -738,6 +762,7 @@ const ColumnResizeHandle = ({
const deltaX = event.clientX - startXRef.current;
const newWidth = Math.min(Math.max(10, startWidthRef.current + deltaX), 1000);
finalWidthRef.current = newWidth;
columnResizeLiveRef.current?.scheduleColumnResizePreview(columnIndex, newWidth);
};
const handleMouseUp = () => {
@@ -746,7 +771,8 @@ const ColumnResizeHandle = ({
document.body.style.userSelect = '';
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
onResize(columnId, finalWidthRef.current);
onResizeRef.current(columnId, finalWidthRef.current);
columnResizeLiveRef.current?.clearColumnResizePreview();
};
document.addEventListener('mousemove', handleMouseMove);
@@ -755,10 +781,18 @@ const ColumnResizeHandle = ({
return () => {
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
document.body.style.cursor = '';
document.body.style.userSelect = '';
columnResizeLiveRef.current?.clearColumnResizePreview();
};
}, [isDragging, columnId, onResize]);
}, [isDragging, columnId, columnIndex]);
const handleMouseDown = (event: React.MouseEvent<HTMLDivElement>) => {
if (disabled) {
event.preventDefault();
event.stopPropagation();
return;
}
event.preventDefault();
event.stopPropagation();
setIsDragging(true);
@@ -771,6 +805,7 @@ const ColumnResizeHandle = ({
return (
<div
className={clsx(styles.resizeHandle, {
[styles.resizeHandleDisabled]: disabled,
[styles.resizeHandleDragging]: isDragging,
[styles.resizeHandleLeft]: side === 'left',
[styles.resizeHandleRight]: side === 'right',
@@ -802,7 +837,11 @@ export const TableColumnHeaderContainer = (
const [isDraggedOver, setIsDraggedOver] = useState<Edge | null>(null);
useEffect(() => {
if (!containerRef.current || !props.enableColumnReorder) {
if (
!containerRef.current ||
!props.enableColumnReorder ||
props.type === TableColumn.LAYOUT_FILL
) {
return;
}
@@ -917,9 +956,11 @@ export const TableColumnHeaderContainer = (
>
{columnLabelMap[props.type]}
</Text>
{!columnConfig.autoSize && props.enableColumnResize && (
{props.enableColumnResize && (
<ColumnResizeHandle
columnId={props.type}
columnIndex={props.columnIndex}
disabled={!!columnConfig.autoSize}
initialWidth={currentWidth}
onResize={handleResize}
side="right"
@@ -982,6 +1023,7 @@ export const columnLabelMap: Record<TableColumn, ReactNode | string> = {
[TableColumn.LAST_PLAYED]: i18n.t('table.column.lastPlayed', {
postProcess: 'upperCase',
}) as string,
[TableColumn.LAYOUT_FILL]: '',
[TableColumn.OWNER]: i18n.t('table.column.owner', { postProcess: 'upperCase' }) as string,
[TableColumn.PATH]: i18n.t('table.column.path', { postProcess: 'upperCase' }) as string,
[TableColumn.PLAY_COUNT]: i18n.t('table.column.playCount', {
@@ -1,6 +1,14 @@
import type { ReactElement } from 'react';
import React, { createContext, useContext, useEffect, useMemo, useRef } from 'react';
import React, {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import { useSyncExternalStore } from 'react';
import type { TableItemProps } from './item-table-list';
@@ -68,6 +76,69 @@ export const useItemTableListConfig = (): ItemTableListConfig | null => {
return useContext(ItemTableListConfigContext);
};
export type ItemTableListColumnResizeLiveContextValue = {
clearColumnResizePreview: () => void;
scheduleColumnResizePreview: (columnIndex: number, width: number) => void;
};
const ItemTableListColumnResizeLiveContext =
createContext<ItemTableListColumnResizeLiveContextValue | null>(null);
export const ItemTableListColumnResizeLiveProvider = ({
children,
value,
}: {
children: React.ReactNode;
value: ItemTableListColumnResizeLiveContextValue;
}) => {
return (
<ItemTableListColumnResizeLiveContext.Provider value={value}>
{children}
</ItemTableListColumnResizeLiveContext.Provider>
);
};
export const useItemTableListColumnResizeLive =
(): ItemTableListColumnResizeLiveContextValue | null => {
return useContext(ItemTableListColumnResizeLiveContext);
};
export const useItemTableListColumnResizeLiveState = () => {
const [columnResizePreview, setColumnResizePreview] = useState<null | {
columnIndex: number;
width: number;
}>(null);
const previewRafRef = useRef<null | number>(null);
const pendingPreviewRef = useRef<null | { columnIndex: number; width: number }>(null);
const scheduleColumnResizePreview = useCallback((columnIndex: number, width: number) => {
pendingPreviewRef.current = { columnIndex, width };
if (previewRafRef.current !== null) return;
previewRafRef.current = requestAnimationFrame(() => {
previewRafRef.current = null;
const pending = pendingPreviewRef.current;
if (pending) {
setColumnResizePreview(pending);
}
});
}, []);
const clearColumnResizePreview = useCallback(() => {
if (previewRafRef.current !== null) {
cancelAnimationFrame(previewRafRef.current);
previewRafRef.current = null;
}
pendingPreviewRef.current = null;
setColumnResizePreview(null);
}, []);
return {
clearColumnResizePreview,
columnResizePreview,
scheduleColumnResizePreview,
};
};
type ItemTableListStoreContextValue = {
activeRowStore: ActiveRowStore;
};
@@ -72,7 +72,7 @@
z-index: 15;
display: flex;
flex-direction: row;
overflow: hidden;
overflow: visible;
pointer-events: none;
background-color: var(--theme-colors-background);
border-bottom: 1px solid var(--theme-colors-border);
@@ -168,6 +168,7 @@
min-width: 0;
height: 100%;
min-height: 0;
overflow-x: visible;
}
.no-scrollbar {
@@ -178,6 +179,10 @@
height: 100%;
}
.item-table-container :global(.os-scrollbar) {
z-index: 2;
}
.item-table-pinned-header-shadow {
position: absolute;
top: 100%;
@@ -14,12 +14,14 @@ import React, {
useMemo,
useRef,
useState,
useSyncExternalStore,
} from 'react';
import { useParams } from 'react-router';
import { type CellComponentProps, Grid } from 'react-window-v2';
import styles from './item-table-list.module.css';
import { appendLayoutFillColumn } from '/@/renderer/components/item-list/helpers/append-layout-fill-column';
import { createExtractRowId } from '/@/renderer/components/item-list/helpers/extract-row-id';
import { useDefaultItemListControls } from '/@/renderer/components/item-list/helpers/item-list-controls';
import {
@@ -44,14 +46,20 @@ import { useTableRowModel } from '/@/renderer/components/item-list/item-table-li
import { useTableScrollToIndex } from '/@/renderer/components/item-list/item-table-list/hooks/use-table-scroll-to-index';
import { ItemTableListColumn } from '/@/renderer/components/item-list/item-table-list/item-table-list-column';
import {
ItemTableListColumnResizeLiveProvider,
type ItemTableListConfig,
ItemTableListConfigProvider,
ItemTableListStoreProvider,
useItemTableListColumnResizeLiveState,
} from '/@/renderer/components/item-list/item-table-list/item-table-list-context';
import {
MemoizedCellRouter,
useColumnCellComponents,
} from '/@/renderer/components/item-list/item-table-list/memoized-cell-router';
import {
createTableScrollShadowStore,
type TableScrollShadowStore,
} from '/@/renderer/components/item-list/item-table-list/table-scroll-shadow-store';
import {
ItemControls,
ItemListHandle,
@@ -103,6 +111,63 @@ export enum TableItemSize {
LARGE = 88,
}
const ItemTableScrollShadowTop = memo(function ItemTableScrollShadowTop({
enableHeader,
enableScrollShadow,
scrollShadowStore,
}: {
enableHeader: boolean;
enableScrollShadow: boolean;
scrollShadowStore: TableScrollShadowStore;
}) {
const { showTopShadow } = useSyncExternalStore(
scrollShadowStore.subscribe,
scrollShadowStore.getSnapshot,
);
if (!enableHeader || !enableScrollShadow || !showTopShadow) return null;
return <div className={styles.itemTableTopScrollShadow} />;
});
ItemTableScrollShadowTop.displayName = 'ItemTableScrollShadowTop';
const ItemTableScrollShadowLeft = memo(function ItemTableScrollShadowLeft({
enableScrollShadow,
pinnedLeftColumnCount,
scrollShadowStore,
}: {
enableScrollShadow: boolean;
pinnedLeftColumnCount: number;
scrollShadowStore: TableScrollShadowStore;
}) {
const { showLeftShadow } = useSyncExternalStore(
scrollShadowStore.subscribe,
scrollShadowStore.getSnapshot,
);
if (pinnedLeftColumnCount <= 0 || !enableScrollShadow || !showLeftShadow) return null;
return <div className={styles.itemTableLeftScrollShadow} />;
});
ItemTableScrollShadowLeft.displayName = 'ItemTableScrollShadowLeft';
const ItemTableScrollShadowRight = memo(function ItemTableScrollShadowRight({
enableScrollShadow,
pinnedRightColumnCount,
scrollShadowStore,
}: {
enableScrollShadow: boolean;
pinnedRightColumnCount: number;
scrollShadowStore: TableScrollShadowStore;
}) {
const { showRightShadow } = useSyncExternalStore(
scrollShadowStore.subscribe,
scrollShadowStore.getSnapshot,
);
if (pinnedRightColumnCount <= 0 || !enableScrollShadow || !showRightShadow) return null;
return <div className={styles.itemTableRightScrollShadow} />;
});
ItemTableScrollShadowRight.displayName = 'ItemTableScrollShadowRight';
interface VirtualizedTableGridProps {
calculatedColumnWidths: number[];
CellComponent: JSXElementConstructor<CellComponentProps<TableItemProps>>;
@@ -120,9 +185,7 @@ interface VirtualizedTableGridProps {
pinnedRightColumnRef: React.RefObject<HTMLDivElement | null>;
pinnedRowCount: number;
pinnedRowRef: React.RefObject<HTMLDivElement | null>;
showLeftShadow: boolean;
showRightShadow: boolean;
showTopShadow: boolean;
scrollShadowStore: TableScrollShadowStore;
tableConfig: ItemTableListConfig;
totalColumnCount: number;
totalRowCount: number;
@@ -145,9 +208,7 @@ const VirtualizedTableGrid = ({
pinnedRightColumnRef,
pinnedRowCount,
pinnedRowRef,
showLeftShadow,
showRightShadow,
showTopShadow,
scrollShadowStore,
tableConfig,
totalColumnCount,
totalRowCount,
@@ -483,7 +544,7 @@ const VirtualizedTableGrid = ({
})}
style={{
minHeight: `${pinnedRowsMinHeightPx}px`,
overflow: 'hidden',
overflow: 'visible',
}}
>
<Grid
@@ -497,9 +558,11 @@ const VirtualizedTableGrid = ({
/>
</div>
)}
{enableHeader && enableScrollShadow && showTopShadow && (
<div className={styles.itemTableTopScrollShadow} />
)}
<ItemTableScrollShadowTop
enableHeader={!!enableHeader}
enableScrollShadow={enableScrollShadow}
scrollShadowStore={scrollShadowStore}
/>
{!!pinnedLeftColumnCount && (
<div
className={styles.itemTablePinnedColumnsContainer}
@@ -554,9 +617,11 @@ const VirtualizedTableGrid = ({
/>
</div>
)}
{enableHeader && enableScrollShadow && showTopShadow && (
<div className={styles.itemTableTopScrollShadow} />
)}
<ItemTableScrollShadowTop
enableHeader={!!enableHeader}
enableScrollShadow={enableScrollShadow}
scrollShadowStore={scrollShadowStore}
/>
<div className={styles.itemTableGridContainer} ref={mergedRowRef}>
<Grid
cellComponent={RowCell}
@@ -568,12 +633,16 @@ const VirtualizedTableGrid = ({
rowCount={totalRowCount}
rowHeight={rowHeightMemoized}
/>
{pinnedLeftColumnCount > 0 && enableScrollShadow && showLeftShadow && (
<div className={styles.itemTableLeftScrollShadow} />
)}
{pinnedRightColumnCount > 0 && enableScrollShadow && showRightShadow && (
<div className={styles.itemTableRightScrollShadow} />
)}
<ItemTableScrollShadowLeft
enableScrollShadow={enableScrollShadow}
pinnedLeftColumnCount={pinnedLeftColumnCount}
scrollShadowStore={scrollShadowStore}
/>
<ItemTableScrollShadowRight
enableScrollShadow={enableScrollShadow}
pinnedRightColumnCount={pinnedRightColumnCount}
scrollShadowStore={scrollShadowStore}
/>
</div>
</div>
{!!pinnedRightColumnCount && (
@@ -593,7 +662,7 @@ const VirtualizedTableGrid = ({
})}
style={{
minHeight: `${pinnedRowsMinHeightPx}px`,
overflow: 'hidden',
overflow: 'visible',
}}
>
<Grid
@@ -611,9 +680,11 @@ const VirtualizedTableGrid = ({
/>
</div>
)}
{enableHeader && enableScrollShadow && showTopShadow && (
<div className={styles.itemTableTopScrollShadow} />
)}
<ItemTableScrollShadowTop
enableHeader={!!enableHeader}
enableScrollShadow={enableScrollShadow}
scrollShadowStore={scrollShadowStore}
/>
<div
className={styles.itemTablePinnedRightColumnsContainer}
ref={pinnedRightColumnRef}
@@ -666,9 +737,7 @@ const MemoizedVirtualizedTableGrid = memo(VirtualizedTableGrid, (prevProps, next
prevProps.pinnedRightColumnRef === nextProps.pinnedRightColumnRef &&
prevProps.pinnedRowCount === nextProps.pinnedRowCount &&
prevProps.pinnedRowRef === nextProps.pinnedRowRef &&
prevProps.showLeftShadow === nextProps.showLeftShadow &&
prevProps.showRightShadow === nextProps.showRightShadow &&
prevProps.showTopShadow === nextProps.showTopShadow &&
prevProps.scrollShadowStore === nextProps.scrollShadowStore &&
prevProps.totalColumnCount === nextProps.totalColumnCount &&
prevProps.totalRowCount === nextProps.totalRowCount &&
prevProps.CellComponent === nextProps.CellComponent
@@ -902,7 +971,7 @@ const ItemTableListStickyUI = memo(
style={{
flex: '0 1 auto',
minWidth: `${pinnedLeftWidth}px`,
overflow: 'hidden',
overflow: 'visible',
}}
>
{parsedColumns
@@ -986,7 +1055,7 @@ const ItemTableListStickyUI = memo(
style={{
flex: '0 1 auto',
minWidth: `${pinnedRightWidth}px`,
overflow: 'hidden',
overflow: 'visible',
}}
>
{parsedColumns
@@ -1210,6 +1279,11 @@ const BaseItemTableList = ({
const [centerContainerWidth, setCenterContainerWidth] = useState(0);
const [totalContainerWidth, setTotalContainerWidth] = useState(0);
const columnsForLayout = useMemo(
() => appendLayoutFillColumn(columns, autoFitColumns),
[autoFitColumns, columns],
);
const {
calculatedColumnWidths,
parsedColumns,
@@ -1219,9 +1293,33 @@ const BaseItemTableList = ({
} = useTableColumnModel({
autoFitColumns,
centerContainerWidth,
columns,
columns: columnsForLayout,
totalContainerWidth,
});
const { clearColumnResizePreview, columnResizePreview, scheduleColumnResizePreview } =
useItemTableListColumnResizeLiveState();
const columnResizeLiveValue = useMemo(
() => ({
clearColumnResizePreview,
scheduleColumnResizePreview,
}),
[clearColumnResizePreview, scheduleColumnResizePreview],
);
const displayColumnWidths = useMemo(() => {
if (!columnResizePreview) {
return calculatedColumnWidths;
}
const next = calculatedColumnWidths.slice();
const { columnIndex, width } = columnResizePreview;
if (columnIndex >= 0 && columnIndex < next.length) {
next[columnIndex] = width;
}
return next;
}, [calculatedColumnWidths, columnResizePreview]);
const playerContext = usePlayer();
const {
@@ -1257,9 +1355,7 @@ const BaseItemTableList = ({
const pinnedRightColumnRef = useRef<HTMLDivElement>(null);
const scrollContainerRef = useRef<HTMLDivElement | null>(null);
const mergedRowRef = useMergedRef(rowRef, scrollContainerRef);
const [showLeftShadow, setShowLeftShadow] = useState(false);
const [showRightShadow, setShowRightShadow] = useState(false);
const [showTopShadow, setShowTopShadow] = useState(false);
const scrollShadowStore = useMemo(() => createTableScrollShadowStore(), []);
const handleRef = useRef<ItemListHandle | null>(null);
const { focused, ref: focusRef } = useFocusWithin();
const containerRef = useRef<HTMLDivElement | null>(null);
@@ -1317,9 +1413,7 @@ const BaseItemTableList = ({
pinnedRowRef,
rowRef,
scrollContainerRef,
setShowLeftShadow,
setShowRightShadow,
setShowTopShadow,
scrollShadowStore,
});
const getRowHeight = useCallback(
@@ -1443,7 +1537,7 @@ const BaseItemTableList = ({
// Create itemProps for sticky header
const stickyHeaderItemProps: TableItemProps = useMemo(
() => ({
calculatedColumnWidths,
calculatedColumnWidths: displayColumnWidths,
cellPadding,
columns: parsedColumns,
controls,
@@ -1463,9 +1557,9 @@ const BaseItemTableList = ({
internalState,
itemType,
pinnedLeftColumnCount,
pinnedLeftColumnWidths: calculatedColumnWidths.slice(0, pinnedLeftColumnCount),
pinnedLeftColumnWidths: displayColumnWidths.slice(0, pinnedLeftColumnCount),
pinnedRightColumnCount,
pinnedRightColumnWidths: calculatedColumnWidths.slice(
pinnedRightColumnWidths: displayColumnWidths.slice(
pinnedLeftColumnCount + totalColumnCount,
),
playerContext,
@@ -1474,7 +1568,7 @@ const BaseItemTableList = ({
tableId,
}),
[
calculatedColumnWidths,
displayColumnWidths,
cellPadding,
controls,
parsedColumns,
@@ -1579,73 +1673,81 @@ const BaseItemTableList = ({
};
}, [CellComponent, columnCellComponents]);
const tableMotion = (
<motion.div
className={styles.itemTableListContainer}
onKeyDown={handleKeyDown}
onMouseDown={(e) => {
const element = e.currentTarget as HTMLDivElement;
// Focus without scrolling into view
if (element.focus) {
element.focus({ preventScroll: true });
}
}}
ref={mergedContainerRef}
tabIndex={0}
{...animationProps.fadeIn}
transition={{ duration: enableEntranceAnimation ? 0.3 : 0, ease: 'anticipate' }}
>
<ItemTableListStickyUI
calculatedColumnWidths={displayColumnWidths}
CellComponent={optimizedCellComponent}
containerRef={containerRef}
data={data}
enableHeader={!!enableHeader}
enableStickyGroupRows={!!enableStickyGroupRows}
enableStickyHeader={!!enableStickyHeader}
getRowHeightWrapper={getRowHeightWrapper}
groups={groups}
headerHeight={headerHeight}
internalState={internalState}
parsedColumns={parsedColumns}
pinnedLeftColumnCount={pinnedLeftColumnCount}
pinnedLeftColumnRef={pinnedLeftColumnRef}
pinnedRightColumnCount={pinnedRightColumnCount}
pinnedRightColumnRef={pinnedRightColumnRef}
pinnedRowRef={pinnedRowRef}
rowHeight={rowHeight}
rowRef={rowRef}
size={size}
stickyHeaderItemProps={stickyHeaderItemProps}
totalColumnCount={totalColumnCount}
/>
<MemoizedVirtualizedTableGrid
calculatedColumnWidths={displayColumnWidths}
CellComponent={optimizedCellComponent}
data={data}
dataWithGroups={dataWithGroups}
enableScrollShadow={enableScrollShadow}
getItem={getItem}
headerHeight={headerHeight}
mergedRowRef={mergedRowRef}
onRangeChanged={onRangeChanged}
parsedColumns={parsedColumns}
pinnedLeftColumnCount={pinnedLeftColumnCount}
pinnedLeftColumnRef={pinnedLeftColumnRef}
pinnedRightColumnCount={pinnedRightColumnCount}
pinnedRightColumnRef={pinnedRightColumnRef}
pinnedRowCount={pinnedRowCount}
pinnedRowRef={pinnedRowRef}
scrollShadowStore={scrollShadowStore}
tableConfig={tableConfigValue}
totalColumnCount={totalColumnCount}
totalRowCount={totalRowCount}
/>
</motion.div>
);
return (
<ItemTableListStoreProvider activeRowId={activeRowId}>
<ItemTableListConfigProvider value={tableConfigValue}>
<motion.div
className={styles.itemTableListContainer}
onKeyDown={handleKeyDown}
onMouseDown={(e) => {
const element = e.currentTarget as HTMLDivElement;
// Focus without scrolling into view
if (element.focus) {
element.focus({ preventScroll: true });
}
}}
ref={mergedContainerRef}
tabIndex={0}
{...animationProps.fadeIn}
transition={{ duration: enableEntranceAnimation ? 0.3 : 0, ease: 'anticipate' }}
>
<ItemTableListStickyUI
calculatedColumnWidths={calculatedColumnWidths}
CellComponent={optimizedCellComponent}
containerRef={containerRef}
data={data}
enableHeader={!!enableHeader}
enableStickyGroupRows={!!enableStickyGroupRows}
enableStickyHeader={!!enableStickyHeader}
getRowHeightWrapper={getRowHeightWrapper}
groups={groups}
headerHeight={headerHeight}
internalState={internalState}
parsedColumns={parsedColumns}
pinnedLeftColumnCount={pinnedLeftColumnCount}
pinnedLeftColumnRef={pinnedLeftColumnRef}
pinnedRightColumnCount={pinnedRightColumnCount}
pinnedRightColumnRef={pinnedRightColumnRef}
pinnedRowRef={pinnedRowRef}
rowHeight={rowHeight}
rowRef={rowRef}
size={size}
stickyHeaderItemProps={stickyHeaderItemProps}
totalColumnCount={totalColumnCount}
/>
<MemoizedVirtualizedTableGrid
calculatedColumnWidths={calculatedColumnWidths}
CellComponent={optimizedCellComponent}
data={data}
dataWithGroups={dataWithGroups}
enableScrollShadow={enableScrollShadow}
getItem={getItem}
headerHeight={headerHeight}
mergedRowRef={mergedRowRef}
onRangeChanged={onRangeChanged}
parsedColumns={parsedColumns}
pinnedLeftColumnCount={pinnedLeftColumnCount}
pinnedLeftColumnRef={pinnedLeftColumnRef}
pinnedRightColumnCount={pinnedRightColumnCount}
pinnedRightColumnRef={pinnedRightColumnRef}
pinnedRowCount={pinnedRowCount}
pinnedRowRef={pinnedRowRef}
showLeftShadow={showLeftShadow}
showRightShadow={showRightShadow}
showTopShadow={showTopShadow}
tableConfig={tableConfigValue}
totalColumnCount={totalColumnCount}
totalRowCount={totalRowCount}
/>
</motion.div>
{onColumnResized ? (
<ItemTableListColumnResizeLiveProvider value={columnResizeLiveValue}>
{tableMotion}
</ItemTableListColumnResizeLiveProvider>
) : (
tableMotion
)}
</ItemTableListConfigProvider>
</ItemTableListStoreProvider>
);
@@ -0,0 +1,36 @@
export interface TableScrollShadowSnapshot {
showLeftShadow: boolean;
showRightShadow: boolean;
showTopShadow: boolean;
}
export type TableScrollShadowStore = ReturnType<typeof createTableScrollShadowStore>;
export function createTableScrollShadowStore() {
let snapshot: TableScrollShadowSnapshot = {
showLeftShadow: false,
showRightShadow: false,
showTopShadow: false,
};
const listeners = new Set<() => void>();
return {
getSnapshot: (): TableScrollShadowSnapshot => snapshot,
setSnapshot: (patch: Partial<TableScrollShadowSnapshot>) => {
const next: TableScrollShadowSnapshot = { ...snapshot, ...patch };
if (
next.showLeftShadow === snapshot.showLeftShadow &&
next.showRightShadow === snapshot.showRightShadow &&
next.showTopShadow === snapshot.showTopShadow
) {
return;
}
snapshot = next;
listeners.forEach((l) => l());
},
subscribe: (listener: () => void) => {
listeners.add(listener);
return () => listeners.delete(listener);
},
};
}
@@ -97,7 +97,7 @@
justify-content: center;
width: 100%;
height: 100%;
padding: var(--theme-spacing-sm);
padding: 0 var(--theme-spacing-xs);
}
.title-wrapper.hidden {
@@ -11,8 +11,10 @@ import { useWindowSettings } from '/@/renderer/store/settings.store';
import { Flex, FlexProps } from '/@/shared/components/flex/flex';
import { Platform } from '/@/shared/types/types';
export interface PageHeaderProps
extends Omit<FlexProps, 'onAnimationStart' | 'onDrag' | 'onDragEnd' | 'onDragStart'> {
export interface PageHeaderProps extends Omit<
FlexProps,
'onAnimationStart' | 'onDrag' | 'onDragEnd' | 'onDragStart'
> {
animated?: boolean;
backgroundColor?: string;
children?: ReactNode;
@@ -54,15 +54,15 @@ export const AlbumListHeaderFilters = ({ toggleGenreTarget }: { toggleGenreTarge
return Boolean(
isFilterValueSet(query[FILTER_KEYS.ALBUM._CUSTOM]) ||
isFilterValueSet(query[FILTER_KEYS.ALBUM.ARTIST_IDS]) ||
query[FILTER_KEYS.ALBUM.COMPILATION] !== undefined ||
query[FILTER_KEYS.ALBUM.FAVORITE] !== undefined ||
isFilterValueSet(query[FILTER_KEYS.ALBUM.GENRE_ID]) ||
query[FILTER_KEYS.ALBUM.HAS_RATING] !== undefined ||
isFilterValueSet(query[FILTER_KEYS.ALBUM.MAX_YEAR]) ||
isFilterValueSet(query[FILTER_KEYS.ALBUM.MIN_YEAR]) ||
query[FILTER_KEYS.ALBUM.RECENTLY_PLAYED] !== undefined ||
isFilterValueSet(query[FILTER_KEYS.SHARED.SEARCH_TERM]),
isFilterValueSet(query[FILTER_KEYS.ALBUM.ARTIST_IDS]) ||
query[FILTER_KEYS.ALBUM.COMPILATION] !== undefined ||
query[FILTER_KEYS.ALBUM.FAVORITE] !== undefined ||
isFilterValueSet(query[FILTER_KEYS.ALBUM.GENRE_ID]) ||
query[FILTER_KEYS.ALBUM.HAS_RATING] !== undefined ||
isFilterValueSet(query[FILTER_KEYS.ALBUM.MAX_YEAR]) ||
isFilterValueSet(query[FILTER_KEYS.ALBUM.MIN_YEAR]) ||
query[FILTER_KEYS.ALBUM.RECENTLY_PLAYED] !== undefined ||
isFilterValueSet(query[FILTER_KEYS.SHARED.SEARCH_TERM]),
);
}, [albumFilters.query]);
@@ -13,6 +13,7 @@ import {
setMultipleSearchParams,
setSearchParam,
} from '/@/renderer/utils/query-params';
import { runInUrlTransition } from '/@/renderer/utils/url-transition';
import { AlbumListSort, SortOrder } from '/@/shared/types/domain-types';
import { ItemListKey } from '/@/shared/types/types';
@@ -74,8 +75,10 @@ export const useAlbumListFilters = (listKey?: ItemListKey) => {
const setGenreId = useCallback(
(value: null | string[]) => {
setSearchParams((prev) => setSearchParam(prev, FILTER_KEYS.ALBUM.GENRE_ID, value), {
replace: true,
runInUrlTransition(() => {
setSearchParams((prev) => setSearchParam(prev, FILTER_KEYS.ALBUM.GENRE_ID, value), {
replace: true,
});
});
},
[setSearchParams],
@@ -83,8 +86,13 @@ export const useAlbumListFilters = (listKey?: ItemListKey) => {
const setAlbumArtist = useCallback(
(value: null | string[]) => {
setSearchParams((prev) => setSearchParam(prev, FILTER_KEYS.ALBUM.ARTIST_IDS, value), {
replace: true,
runInUrlTransition(() => {
setSearchParams(
(prev) => setSearchParam(prev, FILTER_KEYS.ALBUM.ARTIST_IDS, value),
{
replace: true,
},
);
});
},
[setSearchParams],
@@ -92,8 +100,10 @@ export const useAlbumListFilters = (listKey?: ItemListKey) => {
const setMinYear = useCallback(
(value: null | number) => {
setSearchParams((prev) => setSearchParam(prev, FILTER_KEYS.ALBUM.MIN_YEAR, value), {
replace: true,
runInUrlTransition(() => {
setSearchParams((prev) => setSearchParam(prev, FILTER_KEYS.ALBUM.MIN_YEAR, value), {
replace: true,
});
});
},
[setSearchParams],
@@ -101,8 +111,10 @@ export const useAlbumListFilters = (listKey?: ItemListKey) => {
const setMaxYear = useCallback(
(value: null | number) => {
setSearchParams((prev) => setSearchParam(prev, FILTER_KEYS.ALBUM.MAX_YEAR, value), {
replace: true,
runInUrlTransition(() => {
setSearchParams((prev) => setSearchParam(prev, FILTER_KEYS.ALBUM.MAX_YEAR, value), {
replace: true,
});
});
},
[setSearchParams],
@@ -110,8 +122,10 @@ export const useAlbumListFilters = (listKey?: ItemListKey) => {
const setFavorite = useCallback(
(value: boolean | null) => {
setSearchParams((prev) => setSearchParam(prev, FILTER_KEYS.ALBUM.FAVORITE, value), {
replace: true,
runInUrlTransition(() => {
setSearchParams((prev) => setSearchParam(prev, FILTER_KEYS.ALBUM.FAVORITE, value), {
replace: true,
});
});
},
[setSearchParams],
@@ -119,8 +133,13 @@ export const useAlbumListFilters = (listKey?: ItemListKey) => {
const setCompilation = useCallback(
(value: boolean | null) => {
setSearchParams((prev) => setSearchParam(prev, FILTER_KEYS.ALBUM.COMPILATION, value), {
replace: true,
runInUrlTransition(() => {
setSearchParams(
(prev) => setSearchParam(prev, FILTER_KEYS.ALBUM.COMPILATION, value),
{
replace: true,
},
);
});
},
[setSearchParams],
@@ -128,8 +147,13 @@ export const useAlbumListFilters = (listKey?: ItemListKey) => {
const setHasRating = useCallback(
(value: boolean | null) => {
setSearchParams((prev) => setSearchParam(prev, FILTER_KEYS.ALBUM.HAS_RATING, value), {
replace: true,
runInUrlTransition(() => {
setSearchParams(
(prev) => setSearchParam(prev, FILTER_KEYS.ALBUM.HAS_RATING, value),
{
replace: true,
},
);
});
},
[setSearchParams],
@@ -137,65 +161,71 @@ export const useAlbumListFilters = (listKey?: ItemListKey) => {
const setRecentlyPlayed = useCallback(
(value: boolean | null) => {
setSearchParams(
(prev) => setSearchParam(prev, FILTER_KEYS.ALBUM.RECENTLY_PLAYED, value),
{
replace: true,
},
);
runInUrlTransition(() => {
setSearchParams(
(prev) => setSearchParam(prev, FILTER_KEYS.ALBUM.RECENTLY_PLAYED, value),
{
replace: true,
},
);
});
},
[setSearchParams],
);
const setCustom = useCallback(
(value: null | Record<string, any>) => {
setSearchParams(
(prev) => {
const previousValue = prev.get(FILTER_KEYS.ALBUM._CUSTOM);
runInUrlTransition(() => {
setSearchParams(
(prev) => {
const previousValue = prev.get(FILTER_KEYS.ALBUM._CUSTOM);
const newCustom = {
...(previousValue ? JSON.parse(previousValue) : {}),
...value,
};
const newCustom = {
...(previousValue ? JSON.parse(previousValue) : {}),
...value,
};
const filteredNewCustom = Object.fromEntries(
Object.entries(newCustom).filter(
([, value]) => value !== null && value !== undefined,
),
);
const filteredNewCustom = Object.fromEntries(
Object.entries(newCustom).filter(
([, value]) => value !== null && value !== undefined,
),
);
prev.set(FILTER_KEYS.ALBUM._CUSTOM, JSON.stringify(filteredNewCustom));
return prev;
},
{
replace: true,
},
);
prev.set(FILTER_KEYS.ALBUM._CUSTOM, JSON.stringify(filteredNewCustom));
return prev;
},
{
replace: true,
},
);
});
},
[setSearchParams],
);
const clear = useCallback(() => {
setSearchParams(
(prev) =>
setMultipleSearchParams(
prev,
{
[FILTER_KEYS.ALBUM._CUSTOM]: null,
[FILTER_KEYS.ALBUM.ARTIST_IDS]: null,
[FILTER_KEYS.ALBUM.COMPILATION]: null,
[FILTER_KEYS.ALBUM.FAVORITE]: null,
[FILTER_KEYS.ALBUM.GENRE_ID]: null,
[FILTER_KEYS.ALBUM.HAS_RATING]: null,
[FILTER_KEYS.ALBUM.MAX_YEAR]: null,
[FILTER_KEYS.ALBUM.MIN_YEAR]: null,
[FILTER_KEYS.ALBUM.RECENTLY_PLAYED]: null,
[FILTER_KEYS.SHARED.SEARCH_TERM]: null,
},
new Set([FILTER_KEYS.ALBUM._CUSTOM]),
),
{ replace: true },
);
runInUrlTransition(() => {
setSearchParams(
(prev) =>
setMultipleSearchParams(
prev,
{
[FILTER_KEYS.ALBUM._CUSTOM]: null,
[FILTER_KEYS.ALBUM.ARTIST_IDS]: null,
[FILTER_KEYS.ALBUM.COMPILATION]: null,
[FILTER_KEYS.ALBUM.FAVORITE]: null,
[FILTER_KEYS.ALBUM.GENRE_ID]: null,
[FILTER_KEYS.ALBUM.HAS_RATING]: null,
[FILTER_KEYS.ALBUM.MAX_YEAR]: null,
[FILTER_KEYS.ALBUM.MIN_YEAR]: null,
[FILTER_KEYS.ALBUM.RECENTLY_PLAYED]: null,
[FILTER_KEYS.SHARED.SEARCH_TERM]: null,
},
new Set([FILTER_KEYS.ALBUM._CUSTOM]),
),
{ replace: true },
);
});
}, [setSearchParams]);
const query = useMemo(
@@ -1,6 +1,6 @@
import { useQuery } from '@tanstack/react-query';
import { useSuspenseQuery } from '@tanstack/react-query';
import { useRef } from 'react';
import { useLocation, useParams } from 'react-router';
import { useParams } from 'react-router';
import { useItemImageUrl } from '/@/renderer/components/item-image/item-image';
import { NativeScrollArea } from '/@/renderer/components/native-scroll-area/native-scroll-area';
@@ -16,7 +16,7 @@ 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 } from '/@/renderer/store';
import { LibraryItem } from '/@/shared/types/domain-types';
const ALBUM_DETAIL_BG_FALLBACK = 'var(--theme-colors-foreground-muted)';
@@ -27,13 +27,10 @@ const AlbumDetailRoute = () => {
const { albumBackground, albumBackgroundBlur } = useAlbumBackground();
const { albumId } = useParams() as { albumId: string };
const server = useCurrentServer();
const serverId = useCurrentServerId();
const location = useLocation();
const detailQuery = useQuery({
...albumQueries.detail({ query: { id: albumId }, serverId: server?.id }),
placeholderData: location.state?.item,
const detailQuery = useSuspenseQuery({
...albumQueries.detail({ query: { id: albumId }, serverId }),
});
const imageUrl =
@@ -65,9 +62,7 @@ const AlbumDetailRoute = () => {
itemType={LibraryItem.ALBUM}
variant="default"
/>
<LibraryHeaderBar.Title>
{detailQuery?.data?.name}
</LibraryHeaderBar.Title>
<LibraryHeaderBar.Title>{detailQuery.data.name}</LibraryHeaderBar.Title>
</LibraryHeaderBar>
),
offset: 200,
@@ -1,4 +1,4 @@
import { useQuery } from '@tanstack/react-query';
import { useSuspenseQuery } from '@tanstack/react-query';
import { Fragment } from 'react';
import { useTranslation } from 'react-i18next';
import { generatePath, Link, useParams } from 'react-router';
@@ -39,7 +39,7 @@ const DummyAlbumDetailRoute = () => {
const { albumId } = useParams() as { albumId: string };
const server = useCurrentServer();
const queryKey = queryKeys.songs.detail(server?.id || '', albumId);
const detailQuery = useQuery({
const detailQuery = useSuspenseQuery({
queryFn: ({ signal }) => {
return api.controller.getSongDetail({
apiClientProps: { serverId: server?.id || '', signal },
@@ -52,7 +52,7 @@ const DummyAlbumDetailRoute = () => {
const { background, colorId } = useFastAverageColor({
id: albumId,
src: detailQuery.data?.imageUrl,
srcLoaded: !detailQuery.isLoading,
srcLoaded: Boolean(detailQuery.data?.imageUrl),
});
const { addToQueueByFetch } = usePlayer();
const playButtonBehavior = usePlayButtonBehavior();
@@ -1,5 +1,5 @@
import { useQuery, useSuspenseQuery, UseSuspenseQueryResult } from '@tanstack/react-query';
import { forwardRef, Fragment, useCallback, useMemo } from 'react';
import { useSuspenseQuery, UseSuspenseQueryResult } from '@tanstack/react-query';
import { forwardRef, Fragment, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { useParams } from 'react-router';
@@ -8,6 +8,8 @@ import styles from './album-artist-detail-header.module.css';
import { useItemImageUrl } from '/@/renderer/components/item-image/item-image';
import { artistsQueries } from '/@/renderer/features/artists/api/artists-api';
import { getArtistAlbumsGrouped } from '/@/renderer/features/artists/hooks/use-artist-albums-grouped';
import { useDeleteArtistImage } from '/@/renderer/features/artists/mutations/delete-artist-image-mutation';
import { useUploadArtistImage } from '/@/renderer/features/artists/mutations/upload-artist-image-mutation';
import { ContextMenuController } from '/@/renderer/features/context-menu/context-menu-controller';
import { usePlayer } from '/@/renderer/features/player/context/player-context';
import {
@@ -20,17 +22,80 @@ import { AppRoute } from '/@/renderer/router/routes';
import { useAppStore, useCurrentServer, useShowRatings } from '/@/renderer/store';
import { useArtistReleaseTypeItems, usePlayButtonBehavior } from '/@/renderer/store/settings.store';
import { formatDurationString } from '/@/renderer/utils';
import { SEPARATOR_STRING, sortAlbumList } from '/@/shared/api/utils';
import { hasFeature, SEPARATOR_STRING, sortAlbumList } from '/@/shared/api/utils';
import { ActionIcon } from '/@/shared/components/action-icon/action-icon';
import { FileButton } from '/@/shared/components/file-button/file-button';
import { Group } from '/@/shared/components/group/group';
import { Stack } from '/@/shared/components/stack/stack';
import { Text } from '/@/shared/components/text/text';
import { AlbumListResponse, LibraryItem, ServerType } from '/@/shared/types/domain-types';
import {
AlbumArtistDetailResponse,
AlbumListResponse,
LibraryItem,
ServerType,
} from '/@/shared/types/domain-types';
import { ServerFeature } from '/@/shared/types/features-types';
import { Play } from '/@/shared/types/types';
interface AlbumArtistDetailHeaderProps {
albumsQuery: UseSuspenseQueryResult<AlbumListResponse, Error>;
}
function ArtistImageUploadOverlay({
data,
onUploadFile,
}: {
data?: AlbumArtistDetailResponse;
onUploadFile: (file: File) => Promise<void>;
}) {
const deleteArtistImageMutation = useDeleteArtistImage({});
const server = useCurrentServer();
if (!data) return null;
if (!hasFeature(server, ServerFeature.ARTIST_IMAGE_UPLOAD)) return null;
return (
<Group gap="xs">
<FileButton
accept="image/*"
onChange={async (file) => {
if (!file) return;
await onUploadFile(file);
}}
>
{(props) => (
<ActionIcon
icon="uploadImage"
iconProps={{ size: 'lg' }}
radius="xl"
size="xs"
variant="default"
{...props}
/>
)}
</FileButton>
<ActionIcon
disabled={!data?.uploadedImage}
icon="delete"
iconProps={{ size: 'lg' }}
onClick={(e) => {
e.stopPropagation();
if (!data?._serverId) return;
deleteArtistImageMutation.mutate({
apiClientProps: {
serverId: data._serverId,
},
query: { id: data.id },
});
}}
radius="xl"
size="xs"
variant="default"
/>
</Group>
);
}
export const AlbumArtistDetailHeader = forwardRef<HTMLDivElement, AlbumArtistDetailHeaderProps>(
({ albumsQuery }, ref) => {
const { albumArtistId, artistId } = useParams() as {
@@ -78,6 +143,7 @@ export const AlbumArtistDetailHeader = forwardRef<HTMLDivElement, AlbumArtistDet
const playButtonBehavior = usePlayButtonBehavior();
const setFavorite = useSetFavorite();
const setRating = useSetRating();
const uploadArtistImageMutation = useUploadArtistImage({});
const albumArtistDetailSort = useAppStore((state) => state.albumArtistDetailSort);
const sortBy = albumArtistDetailSort.sortBy;
@@ -167,40 +233,52 @@ export const AlbumArtistDetailHeader = forwardRef<HTMLDivElement, AlbumArtistDet
[detailQuery.data],
);
const imageUrl = useItemImageUrl({
const headerImageUrl = useItemImageUrl({
id: detailQuery.data?.imageId || undefined,
imageUrl: detailQuery.data?.imageUrl,
itemType: LibraryItem.ALBUM_ARTIST,
type: 'itemCard',
});
const artistInfoQuery = useQuery({
...artistsQueries.albumArtistInfo({
query: { id: routeId, limit: 10 },
serverId: server?.id,
}),
enabled: Boolean(server?.id && routeId),
type: 'header',
});
const showRating = showRatings && detailQuery?.data?._serverType === ServerType.NAVIDROME;
const selectedImageUrl = useMemo(() => {
return detailQuery.data?.imageUrl || imageUrl;
}, [detailQuery.data?.imageUrl, imageUrl]);
const canUploadArtistImage =
hasFeature(server, ServerFeature.ARTIST_IMAGE_UPLOAD) &&
Boolean(detailQuery.data?._serverId);
const alternateImageUrl = artistInfoQuery.data?.imageUrl;
const hasImageId = Boolean(detailQuery.data?.imageId);
const fallbackHeaderImageUrl = alternateImageUrl || selectedImageUrl;
const handleArtistImageUpload = useCallback(
async (file: File) => {
const artist = detailQuery.data;
if (!artist?._serverId) return;
const buffer = await file.arrayBuffer();
uploadArtistImageMutation.mutate({
apiClientProps: {
serverId: artist._serverId,
},
body: { image: new Uint8Array(buffer) },
query: { id: artist.id },
});
},
[detailQuery.data, uploadArtistImageMutation],
);
return (
<LibraryHeader
imageUrl={hasImageId ? undefined : fallbackHeaderImageUrl}
imageOverlay={
<ArtistImageUploadOverlay
data={detailQuery.data}
onUploadFile={handleArtistImageUpload}
/>
}
imageUrl={headerImageUrl}
item={{
imageId: detailQuery.data?.imageId,
imageUrl: hasImageId ? undefined : fallbackHeaderImageUrl,
imageUrl: detailQuery.data?.imageUrl,
route: AppRoute.LIBRARY_ALBUM_ARTISTS,
type: LibraryItem.ALBUM_ARTIST,
}}
onImageFileDrop={canUploadArtistImage ? handleArtistImageUpload : undefined}
ref={ref}
title={detailQuery.data?.name || ''}
>
@@ -16,8 +16,7 @@ import {
} from '/@/shared/types/domain-types';
import { ItemListKey } from '/@/shared/types/types';
interface AlbumArtistListInfiniteGridProps
extends ItemListGridComponentProps<AlbumArtistListQuery> {}
interface AlbumArtistListInfiniteGridProps extends ItemListGridComponentProps<AlbumArtistListQuery> {}
export const AlbumArtistListInfiniteGrid = ({
gap = 'md',
@@ -17,8 +17,7 @@ import {
} from '/@/shared/types/domain-types';
import { ItemListKey } from '/@/shared/types/types';
interface AlbumArtistListInfiniteTableProps
extends ItemListTableComponentProps<AlbumArtistListQuery> {}
interface AlbumArtistListInfiniteTableProps extends ItemListTableComponentProps<AlbumArtistListQuery> {}
export const AlbumArtistListInfiniteTable = ({
autoFitColumns = false,
@@ -18,8 +18,7 @@ import {
} from '/@/shared/types/domain-types';
import { ItemListKey } from '/@/shared/types/types';
interface AlbumArtistListPaginatedGridProps
extends ItemListGridComponentProps<AlbumArtistListQuery> {}
interface AlbumArtistListPaginatedGridProps extends ItemListGridComponentProps<AlbumArtistListQuery> {}
export const AlbumArtistListPaginatedGrid = ({
gap = 'md',
@@ -19,8 +19,7 @@ import {
} from '/@/shared/types/domain-types';
import { ItemListKey } from '/@/shared/types/types';
interface AlbumArtistListPaginatedTableProps
extends ItemListTableComponentProps<AlbumArtistListQuery> {}
interface AlbumArtistListPaginatedTableProps extends ItemListTableComponentProps<AlbumArtistListQuery> {}
export const AlbumArtistListPaginatedTable = ({
autoFitColumns = false,
@@ -6,6 +6,7 @@ import { useSortByFilter } from '/@/renderer/features/shared/hooks/use-sort-by-f
import { useSortOrderFilter } from '/@/renderer/features/shared/hooks/use-sort-order-filter';
import { FILTER_KEYS } from '/@/renderer/features/shared/utils';
import { setMultipleSearchParams } from '/@/renderer/utils/query-params';
import { runInUrlTransition } from '/@/renderer/utils/url-transition';
import { AlbumArtistListSort } from '/@/shared/types/domain-types';
import { ItemListKey } from '/@/shared/types/types';
@@ -19,13 +20,15 @@ export const useAlbumArtistListFilters = () => {
const [, setSearchParams] = useSearchParams();
const clear = useCallback(() => {
setSearchParams(
(prev) =>
setMultipleSearchParams(prev, {
[FILTER_KEYS.SHARED.SEARCH_TERM]: null,
}),
{ replace: true },
);
runInUrlTransition(() => {
setSearchParams(
(prev) =>
setMultipleSearchParams(prev, {
[FILTER_KEYS.SHARED.SEARCH_TERM]: null,
}),
{ replace: true },
);
});
}, [setSearchParams]);
const query = {
@@ -0,0 +1,41 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { AxiosError } from 'axios';
import { api } from '/@/renderer/api';
import { queryKeys } from '/@/renderer/api/query-keys';
import { MutationHookArgs } from '/@/renderer/lib/react-query';
import { DeleteArtistImageArgs, DeleteArtistImageResponse } from '/@/shared/types/domain-types';
export const useDeleteArtistImage = (args: MutationHookArgs) => {
const { options } = args || {};
const queryClient = useQueryClient();
return useMutation<DeleteArtistImageResponse, AxiosError, DeleteArtistImageArgs, null>({
mutationFn: (args) => {
return api.controller.deleteArtistImage({
...args,
apiClientProps: { serverId: args.apiClientProps.serverId },
});
},
onSuccess: (_data, variables) => {
const { apiClientProps, query } = variables;
const serverId = apiClientProps.serverId;
if (!serverId) return;
queryClient.invalidateQueries({
queryKey: queryKeys.albumArtists.list(serverId),
});
if (query?.id) {
queryClient.invalidateQueries({
queryKey: queryKeys.albumArtists.detail(serverId, { id: query.id }),
});
queryClient.invalidateQueries({
queryKey: queryKeys.albumArtists.info(serverId, { id: query.id }),
});
}
},
...options,
});
};
@@ -0,0 +1,41 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { AxiosError } from 'axios';
import { api } from '/@/renderer/api';
import { queryKeys } from '/@/renderer/api/query-keys';
import { MutationHookArgs } from '/@/renderer/lib/react-query';
import { UploadArtistImageArgs, UploadArtistImageResponse } from '/@/shared/types/domain-types';
export const useUploadArtistImage = (args: MutationHookArgs) => {
const { options } = args || {};
const queryClient = useQueryClient();
return useMutation<UploadArtistImageResponse, AxiosError, UploadArtistImageArgs, null>({
mutationFn: (args) => {
return api.controller.uploadArtistImage({
...args,
apiClientProps: { serverId: args.apiClientProps.serverId },
});
},
onSuccess: (_data, variables) => {
const { apiClientProps, query } = variables;
const serverId = apiClientProps.serverId;
if (!serverId) return;
queryClient.invalidateQueries({
queryKey: queryKeys.albumArtists.list(serverId),
});
if (query?.id) {
queryClient.invalidateQueries({
queryKey: queryKeys.albumArtists.detail(serverId, { id: query.id }),
});
queryClient.invalidateQueries({
queryKey: queryKeys.albumArtists.info(serverId, { id: query.id }),
});
}
},
...options,
});
};
@@ -1,4 +1,4 @@
import { useQuery } from '@tanstack/react-query';
import { useSuspenseQueries } from '@tanstack/react-query';
import { useMemo } from 'react';
import { useParams } from 'react-router';
@@ -35,20 +35,18 @@ const AlbumArtistDetailFavoriteSongsListRoute = () => {
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 [detailQuery, favoriteSongsQuery] = useSuspenseQueries({
queries: [
artistsQueries.albumArtistDetail({
query: { id: routeId },
serverId: server?.id,
}),
artistsQueries.favoriteSongs({
query: { artistId: routeId },
serverId: server?.id,
}),
],
});
const songs = useMemo(
() => favoriteSongsQuery?.data?.items || [],
@@ -168,7 +166,7 @@ const AlbumArtistDetailFavoriteSongsListRoute = () => {
);
};
const AlbumArtistDetailTopSongsListRouteWithBoundary = () => {
const AlbumArtistDetailFavoriteSongsListRouteWithBoundary = () => {
return (
<PageErrorBoundary>
<AlbumArtistDetailFavoriteSongsListRoute />
@@ -176,4 +174,4 @@ const AlbumArtistDetailTopSongsListRouteWithBoundary = () => {
);
};
export default AlbumArtistDetailTopSongsListRouteWithBoundary;
export default AlbumArtistDetailFavoriteSongsListRouteWithBoundary;
@@ -1,4 +1,4 @@
import { useQuery } from '@tanstack/react-query';
import { useSuspenseQuery } from '@tanstack/react-query';
import { useMemo } from 'react';
import { useParams } from 'react-router';
@@ -34,16 +34,15 @@ const AlbumArtistDetailTopSongsListRoute = () => {
key: 'album-artist-top-songs-query-type',
});
const detailQuery = useQuery(
const detailQuery = useSuspenseQuery(
artistsQueries.albumArtistDetail({
query: { id: routeId },
serverId: server?.id,
}),
);
const topSongsQuery = useQuery(
const topSongsQuery = useSuspenseQuery(
artistsQueries.topSongs({
options: { enabled: !!detailQuery?.data?.name },
query: {
artist: detailQuery?.data?.name || '',
artistId: routeId,
@@ -347,7 +347,7 @@ export const AddToPlaylistAction = ({ items, itemType }: AddToPlaylistActionProp
innerProps: {
...modalProps,
},
modalKey: 'addToPlaylist',
modal: 'addToPlaylist',
size: 'lg',
title: t('page.contextMenu.addToPlaylist', { postProcess: 'sentenceCase' }),
});
@@ -36,7 +36,7 @@ export const ShareAction = ({ ids, itemType }: ShareActionProps) => {
itemIds: ids,
resourceType,
},
modalKey: 'shareItem',
modal: 'shareItem',
title: t('page.contextMenu.shareItem', { postProcess: 'titleCase' }),
});
}, [ids, resourceType, t]);
@@ -38,10 +38,10 @@ export const PlaylistContextMenu = ({ items, type }: PlaylistContextMenuProps) =
<ContextMenu.Divider />
<AddToPlaylistAction items={ids} itemType={LibraryItem.PLAYLIST} />
<ContextMenu.Divider />
<GetInfoAction disabled={items.length === 0} items={items} />
<ContextMenu.Divider />
<EditPlaylistAction disabled={!canEditPlaylist} items={items} />
<DeletePlaylistAction disabled={!canDeletePlaylist} items={items} />
<ContextMenu.Divider />
<GetInfoAction disabled={items.length === 0} items={items} />
</ContextMenu.Content>
);
};
@@ -1,4 +1,5 @@
import { SetActivity, StatusDisplayType } from '@xhayper/discord-rpc';
import type { SetActivity } from '@xhayper/discord-rpc';
import isElectron from 'is-electron';
import React, { useCallback, useEffect, useRef, useState } from 'react';
@@ -27,6 +28,13 @@ import { LibraryItem, QueueSong, ServerType } from '/@/shared/types/domain-types
import { PlayerStatus } from '/@/shared/types/types';
const discordRpc = isElectron() ? window.api.discordRpc : null;
const DiscordStatusDisplayType = {
DETAILS: 2,
NAME: 0,
STATE: 1,
} as const;
type ActivityState = [QueueSong | undefined, number, PlayerStatus];
const MAX_FIELD_LENGTH = 127;
@@ -122,7 +130,7 @@ export const useDiscordRpc = () => {
: undefined
: sentenceCase(current[2]),
state: truncate(artist),
statusDisplayType: StatusDisplayType.STATE,
statusDisplayType: DiscordStatusDisplayType.STATE,
type: discordSettings.showAsListening ? 2 : 0,
};
@@ -196,9 +204,9 @@ export const useDiscordRpc = () => {
const artists = song?.artists.map((artist) => artist.name).join(', ');
const statusDisplayMap = {
[DiscordDisplayType.ARTIST_NAME]: StatusDisplayType.STATE,
[DiscordDisplayType.FEISHIN]: StatusDisplayType.NAME,
[DiscordDisplayType.SONG_NAME]: StatusDisplayType.DETAILS,
[DiscordDisplayType.ARTIST_NAME]: DiscordStatusDisplayType.STATE,
[DiscordDisplayType.FEISHIN]: DiscordStatusDisplayType.NAME,
[DiscordDisplayType.SONG_NAME]: DiscordStatusDisplayType.DETAILS,
};
const activity: SetActivity = {
@@ -6,6 +6,7 @@ import { useSortByFilter } from '/@/renderer/features/shared/hooks/use-sort-by-f
import { useSortOrderFilter } from '/@/renderer/features/shared/hooks/use-sort-order-filter';
import { FILTER_KEYS } from '/@/renderer/features/shared/utils';
import { parseJsonParam, setJsonSearchParam } from '/@/renderer/utils/query-params';
import { runInUrlTransition } from '/@/renderer/utils/url-transition';
import { SongListSort, SortOrder } from '/@/shared/types/domain-types';
import { ItemListKey } from '/@/shared/types/types';
@@ -29,13 +30,19 @@ export const useFolderListFilters = () => {
}, [searchParams]);
const setFolderPath = (path: FolderPathItem[]) => {
setSearchParams(
(prev) => {
const newParams = setJsonSearchParam(prev, FILTER_KEYS.FOLDER.FOLDER_PATH, path);
return newParams;
},
{ replace: false },
);
runInUrlTransition(() => {
setSearchParams(
(prev) => {
const newParams = setJsonSearchParam(
prev,
FILTER_KEYS.FOLDER.FOLDER_PATH,
path,
);
return newParams;
},
{ replace: false },
);
});
};
// Navigate to a folder (adds to path)
@@ -131,7 +131,9 @@ export const LyricsActions = ({
uppercase
variant="subtle"
>
{t('common.clear', { postProcess: 'sentenceCase' })}
{hasLyrics
? t('common.clear', { postProcess: 'sentenceCase' })
: t('common.refresh', { postProcess: 'sentenceCase' })}
</Button>
) : null}
</Group>
@@ -5,7 +5,7 @@ import i18n from '/@/i18n/i18n';
export const openLyricsSettingsModal = (settingsKey: string = 'default') => {
openContextModal({
innerProps: { settingsKey },
modalKey: 'lyricsSettings',
modal: 'lyricsSettings',
overlayProps: {
blur: 0,
opacity: 0,
@@ -0,0 +1,17 @@
.toolbar {
width: 100%;
min-width: 0;
container-type: inline-size;
}
.restore-section {
display: contents;
}
@container (max-width: 296px) {
.restore-section {
display: none;
}
}
@@ -3,6 +3,8 @@ import { t } from 'i18next';
import { RefObject } from 'react';
import { useTranslation } from 'react-i18next';
import styles from './play-queue-list-controls.module.css';
import { queryKeys } from '/@/renderer/api/query-keys';
import { SONG_TABLE_COLUMNS } from '/@/renderer/components/item-list/item-table-list/default-columns';
import { ItemListHandle } from '/@/renderer/components/item-list/types';
@@ -16,6 +18,8 @@ import { SearchInput } from '/@/renderer/features/shared/components/search-input
import { useCurrentServer, usePlayerStoreBase } from '/@/renderer/store';
import { hasFeature } from '/@/shared/api/utils';
import { ActionIcon } from '/@/shared/components/action-icon/action-icon';
import { Box } from '/@/shared/components/box/box';
import { Divider } from '/@/shared/components/divider/divider';
import { Group } from '/@/shared/components/group/group';
import { ServerFeature } from '/@/shared/types/features-types';
import { ItemListKey, ListDisplayType } from '/@/shared/types/types';
@@ -33,6 +37,53 @@ export const PlayQueueListControls = ({
tableRef,
type,
}: PlayQueueListOptionsProps) => {
return (
<Group
align="center"
className={styles.toolbar}
gap="sm"
justify="flex-start"
px="md"
py="xs"
style={{ borderBottom: '1px solid var(--theme-colors-border)' }}
w="100%"
wrap="nowrap"
>
<Group gap="xs" style={{ flexShrink: 0 }} wrap="nowrap">
<QueueRestoreActions />
<QueuePlaybackIcons tableRef={tableRef} />
</Group>
<Divider h="60%" orientation="vertical" style={{ alignSelf: 'center' }} />
<Box style={{ display: 'flex', flex: 1, minWidth: 0 }}>
<SearchInput
enableHotkey={false}
fillContainer
onChange={(e) => handleSearch(e.target.value)}
value={searchTerm}
/>
</Box>
<Divider h="60%" orientation="vertical" style={{ alignSelf: 'center' }} />
<Box style={{ flexShrink: 0 }}>
<ListConfigMenu
displayTypes={[
{ hidden: true, value: ListDisplayType.GRID },
...SONG_DISPLAY_TYPES,
]}
listKey={type}
optionsConfig={{
table: {
itemsPerPage: { hidden: true },
pagination: { hidden: true },
},
}}
tableColumnsData={SONG_TABLE_COLUMNS}
/>
</Box>
</Group>
);
};
const QueuePlaybackIcons = ({ tableRef }: { tableRef: RefObject<ItemListHandle | null> }) => {
const { t } = useTranslation();
const player = usePlayer();
@@ -52,53 +103,29 @@ export const PlayQueueListControls = ({
};
return (
<Group h="65px" justify="space-between" px="1rem" py="1rem" w="100%">
<Group gap="xs">
<QueueRestoreActions />
<ActionIcon
icon="mediaShuffle"
iconProps={{ size: 'lg' }}
onClick={handleShuffleQueue}
tooltip={{ label: t('player.shuffle', { postProcess: 'sentenceCase' }) }}
variant="subtle"
/>
<ActionIcon
icon="x"
iconProps={{ size: 'lg' }}
onClick={handleClearQueue}
tooltip={{ label: t('action.clearQueue', { postProcess: 'sentenceCase' }) }}
variant="subtle"
/>
<ActionIcon
icon="goToItem"
iconProps={{ size: 'lg' }}
onClick={handleJumpToCurrent}
tooltip={{ label: t('action.goToCurrent', { postProcess: 'sentenceCase' }) }}
variant="subtle"
/>
</Group>
<Group gap="xs">
<SearchInput
enableHotkey={false}
onChange={(e) => handleSearch(e.target.value)}
value={searchTerm}
/>
<ListConfigMenu
displayTypes={[
{ hidden: true, value: ListDisplayType.GRID },
...SONG_DISPLAY_TYPES,
]}
listKey={type}
optionsConfig={{
table: {
itemsPerPage: { hidden: true },
pagination: { hidden: true },
},
}}
tableColumnsData={SONG_TABLE_COLUMNS}
/>
</Group>
</Group>
<>
<ActionIcon
icon="mediaShuffle"
iconProps={{ size: 'lg' }}
onClick={handleShuffleQueue}
tooltip={{ label: t('player.shuffle', { postProcess: 'sentenceCase' }) }}
variant="subtle"
/>
<ActionIcon
icon="x"
iconProps={{ size: 'lg' }}
onClick={handleClearQueue}
tooltip={{ label: t('action.clearQueue', { postProcess: 'sentenceCase' }) }}
variant="subtle"
/>
<ActionIcon
icon="goToItem"
iconProps={{ size: 'lg' }}
onClick={handleJumpToCurrent}
tooltip={{ label: t('action.goToCurrent', { postProcess: 'sentenceCase' }) }}
variant="subtle"
/>
</>
);
};
@@ -117,7 +144,7 @@ const QueueRestoreActions = () => {
}
return (
<>
<span className={styles.restoreSection}>
<ActionIcon
disabled={Boolean(isFetching)}
icon="upload"
@@ -144,6 +171,6 @@ const QueueRestoreActions = () => {
}}
variant="subtle"
/>
</>
</span>
);
};
@@ -27,7 +27,7 @@ import {
import { ActionIcon, ActionIconGroup } from '/@/shared/components/action-icon/action-icon';
import { Flex } from '/@/shared/components/flex/flex';
import { Stack } from '/@/shared/components/stack/stack';
import { ItemListKey, Platform, PlayerType } from '/@/shared/types/types';
import { ItemListKey, Platform } from '/@/shared/types/types';
type SidebarPanelType = 'lyrics' | 'queue' | 'visualizer';
@@ -55,9 +55,9 @@ export const SidebarPlayQueue = () => {
const showLyricsInSidebar = useShowLyricsInSidebar();
const showVisualizerInSidebar = useShowVisualizerInSidebar();
const sidebarPanelOrder = useSidebarPanelOrder();
const { type, webAudio } = usePlaybackSettings();
const { webAudio } = usePlaybackSettings();
const { windowBarStyle } = useWindowSettings();
const showVisualizer = showVisualizerInSidebar && type === PlayerType.WEB && webAudio;
const showVisualizer = showVisualizerInSidebar && webAudio;
const showPanel = showLyricsInSidebar || showVisualizer;
const shouldAddTopMargin = isElectron() && windowBarStyle === Platform.WEB;
@@ -374,8 +374,8 @@ const CombinedLyricsAndVisualizerPanel = () => {
const visualizerType = useSettingsStore((store) => store.visualizer.type);
const showLyricsInSidebar = useShowLyricsInSidebar();
const showVisualizerInSidebar = useShowVisualizerInSidebar();
const { type, webAudio } = usePlaybackSettings();
const showVisualizer = showVisualizerInSidebar && type === PlayerType.WEB && webAudio;
const { webAudio } = usePlaybackSettings();
const showVisualizer = showVisualizerInSidebar && webAudio;
const { data: lyricsData } = useQuery(
lyricsQueries.songLyrics(
@@ -25,6 +25,7 @@ import {
useIsRadioActive,
} from '/@/renderer/features/radio/hooks/use-radio-player';
import { RemoteHook } from '/@/renderer/features/remote/hooks/use-remote';
import { VisualizerSystemAudioBridgeHook } from '/@/renderer/features/visualizer/components/visualizer-system-audio-bridge';
import {
updateQueueFavorites,
updateQueueRatings,
@@ -40,19 +41,32 @@ import { PlayerType } from '/@/shared/types/types';
const CODEC_PROBES = [
{ codec: 'mp3', container: 'mp3', mime: 'audio/mpeg' },
{ codec: 'aac', container: 'mp4', mime: 'audio/mp4; codecs="mp4a.40.2"' },
{ codec: 'aac', container: 'aac', mime: 'audio/aac' },
{ codec: 'aac', container: 'mp4', mime: 'audio/x-m4a' },
{ codec: 'opus', container: 'ogg', mime: 'audio/ogg; codecs="opus"' },
{ codec: 'opus', container: 'webm', mime: 'audio/webm; codecs="opus"' },
{ codec: 'vorbis', container: 'ogg', mime: 'audio/ogg; codecs="vorbis"' },
{ codec: 'vorbis', container: 'webm', mime: 'audio/webm; codecs="vorbis"' },
{ codec: 'flac', container: 'flac', mime: 'audio/flac' },
{ codec: 'wav', container: 'wav', mime: 'audio/wav' },
{ codec: ['pcm', 'wav'], container: 'wav', mime: 'audio/wav' },
{ codec: 'alac', container: 'mp4', mime: 'audio/mp4; codecs="alac"' },
];
const DEFAULT_TRANSCODING_PROFILES = [
{ audioCodec: 'flac', container: 'flac', protocol: 'http' },
{ audioCodec: 'opus', container: 'ogg', protocol: 'http' },
{ audioCodec: 'mp3', container: 'mp3', protocol: 'http' },
];
const SAFARI_TRANSCODING_PROFILES = [{ audioCodec: 'mp3', container: 'mp3', protocol: 'http' }];
const DIRECT_PLAY_PROFILES: {
audioCodecs: string[];
containers: string[];
@@ -60,7 +74,7 @@ const DIRECT_PLAY_PROFILES: {
}[] = [];
export function getDefaultTranscodingProfiles() {
return DEFAULT_TRANSCODING_PROFILES;
return isSafari() ? SAFARI_TRANSCODING_PROFILES : DEFAULT_TRANSCODING_PROFILES;
}
export function getDirectPlayProfiles() {
@@ -72,9 +86,9 @@ function detectBrowserProfile() {
const audio = new Audio();
for (const { codec, container, mime } of CODEC_PROBES) {
if (audio.canPlayType(mime) === 'probably') {
if (audio.canPlayType(mime) === 'maybe' || audio.canPlayType(mime) === 'probably') {
DIRECT_PLAY_PROFILES.push({
audioCodecs: [codec],
audioCodecs: Array.isArray(codec) ? codec : [codec],
containers: [container],
protocols: ['http'],
});
@@ -86,6 +100,11 @@ function detectBrowserProfile() {
return DIRECT_PLAY_PROFILES;
}
function isSafari() {
const ua = navigator.userAgent;
return ua.includes('Safari') && !ua.includes('Chrome') && !ua.includes('Chromium');
}
export const AudioPlayers = () => {
const playbackType = usePlaybackType();
const serverId = useCurrentServerId();
@@ -118,6 +137,7 @@ export const AudioPlayers = () => {
<UpdateCurrentSongHook />
<RadioAudioInstanceHook />
<RadioMetadataHook />
<VisualizerSystemAudioBridgeHook />
<AutosaveHook />
<AudioPlayersContent
audioContext={audioContext}
@@ -269,25 +269,7 @@ export const FullScreenPlayerImage = () => {
? 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">
<Text key="fs-artists" size="xl">
{isPlayingRadio
? radioMetadata?.artist || stationName || 'Radio'
: currentSong?.artists?.map((artist, index) => (
@@ -314,6 +296,24 @@ export const FullScreenPlayerImage = () => {
</Fragment>
))}
</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>
)}
{!isPlayingRadio && (
<Group justify="center" mt="sm">
{playerItems.map((i) => !i.disabled && builtDataItems[i.id])}
@@ -15,7 +15,7 @@ import {
} from '/@/renderer/store/full-screen-player.store';
import { Button } from '/@/shared/components/button/button';
import { Group } from '/@/shared/components/group/group';
import { ItemListKey, PlayerType } from '/@/shared/types/types';
import { ItemListKey } from '/@/shared/types/types';
const AudioMotionAnalyzerVisualizer = lazy(() =>
import('../../visualizer/components/audiomotionanalyzer/visualizer').then((module) => ({
@@ -33,7 +33,7 @@ export const FullScreenPlayerQueue = () => {
const { t } = useTranslation();
const { activeTab, opacity } = useFullScreenPlayerStore();
const { setStore } = useFullScreenPlayerStoreActions();
const { type, webAudio } = usePlaybackSettings();
const { webAudio } = usePlaybackSettings();
const visualizerType = useSettingsStore((store) => store.visualizer.type);
const headerItems = useMemo(() => {
@@ -55,7 +55,7 @@ export const FullScreenPlayerQueue = () => {
},
];
if (type === PlayerType.WEB && webAudio) {
if (webAudio) {
items.push({
active: activeTab === 'visualizer',
label: t('page.fullscreenPlayer.visualizer', { postProcess: 'titleCase' }),
@@ -64,7 +64,7 @@ export const FullScreenPlayerQueue = () => {
}
return items;
}, [activeTab, setStore, t, type, webAudio]);
}, [activeTab, setStore, t, webAudio]);
return (
<div
@@ -119,7 +119,7 @@ export const FullScreenPlayerQueue = () => {
</div>
) : activeTab === 'lyrics' ? (
<Lyrics fadeOutNoLyricsMessage={false} />
) : activeTab === 'visualizer' && type === PlayerType.WEB && webAudio ? (
) : activeTab === 'visualizer' && webAudio ? (
<Suspense fallback={<></>}>
{visualizerType === 'butterchurn' ? (
<ButterchurnVisualizer />
@@ -13,7 +13,7 @@ import {
useWindowSettings,
} from '/@/renderer/store/settings.store';
import { useHotkeys } from '/@/shared/hooks/use-hotkeys';
import { Platform, PlayerType } from '/@/shared/types/types';
import { Platform } from '/@/shared/types/types';
const AudioMotionAnalyzerVisualizer = lazy(() =>
import('../../visualizer/components/audiomotionanalyzer/visualizer').then((module) => ({
@@ -131,7 +131,7 @@ VisualizerContainer.displayName = 'VisualizerContainer';
export const FullScreenVisualizer = () => {
const { setStore } = useFullScreenPlayerStoreActions();
const { windowBarStyle } = useWindowSettings();
const { type, webAudio } = usePlaybackSettings();
const { webAudio } = usePlaybackSettings();
const visualizerType = useSettingsStore((store) => store.visualizer.type);
const isMobile = useIsMobile();
@@ -155,7 +155,7 @@ export const FullScreenVisualizer = () => {
return (
<VisualizerContainer isMobile={isMobile} windowBarStyle={windowBarStyle}>
<div className={styles.visualizerContainer}>
{type === PlayerType.WEB && webAudio ? (
{webAudio ? (
<Suspense fallback={<></>}>
{visualizerType === 'butterchurn' ? (
<ButterchurnVisualizer />
@@ -8,7 +8,10 @@ import { shallow } from 'zustand/shallow';
import styles from './left-controls.module.css';
import { ItemImage } from '/@/renderer/components/item-image/item-image';
import { JoinedArtists } from '/@/renderer/features/albums/components/joined-artists';
import {
JOINED_ARTISTS_MUTED_PROPS,
JoinedArtists,
} from '/@/renderer/features/albums/components/joined-artists';
import { ContextMenuController } from '/@/renderer/features/context-menu/context-menu-controller';
import { RadioMetadataDisplay } from '/@/renderer/features/player/components/radio-metadata-display';
import {
@@ -266,6 +269,14 @@ export const LeftControls = () => {
<JoinedArtists
artistName={currentSong?.artistName || ''}
artists={artists || []}
linkProps={{
...JOINED_ARTISTS_MUTED_PROPS.linkProps,
size: 'md',
}}
rootTextProps={{
...JOINED_ARTISTS_MUTED_PROPS.rootTextProps,
size: 'md',
}}
/>
</div>
<div
@@ -189,7 +189,7 @@ const randomFetchQuery = (args: {
export const openShuffleAllModal = async () => {
openContextModal({
innerProps: {},
modalKey: 'shuffleAll',
modal: 'shuffleAll',
size: 'sm',
title: i18n.t('player.playRandom', { postProcess: 'sentenceCase' }) as string,
});
@@ -0,0 +1,17 @@
import {
useFullScreenPlayerStore,
usePlaybackSettings,
useShowVisualizerInSidebar,
} from '/@/renderer/store';
export function useIsLocalVisualizerSurfaceVisible(): boolean {
const { webAudio: webAudioEnabled } = usePlaybackSettings();
const showVisualizerInSidebar = useShowVisualizerInSidebar();
const { activeTab, expanded, visualizerExpanded } = useFullScreenPlayerStore();
const sidebarVisualizer = showVisualizerInSidebar && webAudioEnabled;
const fullScreenPlayerVisualizerTab = expanded && activeTab === 'visualizer' && webAudioEnabled;
const fullScreenVisualizerOverlay = visualizerExpanded && webAudioEnabled;
return sidebarVisualizer || fullScreenPlayerVisualizerTab || fullScreenVisualizerOverlay;
}
@@ -1,5 +1,6 @@
import isElectron from 'is-electron';
import React, { useCallback, useEffect, useMemo } from 'react';
import { debounce } from 'lodash';
import React, { useCallback, useEffect, useMemo, useRef } from 'react';
import { getItemImageUrl } from '/@/renderer/components/item-image/item-image';
import { usePlayerEvents } from '/@/renderer/features/player/audio-player/hooks/use-player-events';
@@ -9,8 +10,9 @@ import {
useRadioPlayer,
} from '/@/renderer/features/radio/hooks/use-radio-player';
import {
subscribeCurrentTrack,
subscribePlayerStatus,
usePlaybackSettings,
usePlaybackType,
usePlayerStore,
useSettingsStore,
useSkipButtons,
@@ -29,6 +31,40 @@ export const useMediaSession = () => {
const isRadioActive = useIsRadioActive();
const { isPlaying: isRadioPlaying, metadata: radioMetadata, stationName } = useRadioPlayer();
// Keep refs to current values to avoid dependency changes triggering handler re-registration
const playerRef = useRef(player);
const skipRef = useRef(skip);
const isRadioActiveRef = useRef(isRadioActive);
const isRadioPlayingRef = useRef(isRadioPlaying);
const radioMetadataRef = useRef(radioMetadata);
const stationNameRef = useRef(stationName);
const isMediaSessionEnabledRef = useRef(false);
// Update refs whenever values change, but don't trigger effects
useEffect(() => {
playerRef.current = player;
}, [player]);
useEffect(() => {
skipRef.current = skip;
}, [skip]);
useEffect(() => {
isRadioActiveRef.current = isRadioActive;
}, [isRadioActive]);
useEffect(() => {
isRadioPlayingRef.current = isRadioPlaying;
}, [isRadioPlaying]);
useEffect(() => {
radioMetadataRef.current = radioMetadata;
}, [radioMetadata]);
useEffect(() => {
stationNameRef.current = stationName;
}, [stationName]);
const isMediaSessionEnabled = useMemo(() => {
// Always enable media session on web
if (!isElectron()) {
@@ -38,71 +74,87 @@ export const useMediaSession = () => {
return Boolean(mediaSessionEnabled && playbackType === PlayerType.WEB);
}, [mediaSessionEnabled, playbackType]);
useEffect(() => {
isMediaSessionEnabledRef.current = isMediaSessionEnabled;
}, [isMediaSessionEnabled]);
// Register/unregister handlers whenever isMediaSessionEnabled changes so that
// enabling the setting after mount correctly registers handlers instead of
// silently no-oping because the [] effect already ran.
useEffect(() => {
if (!isMediaSessionEnabled) {
mediaSession.setActionHandler('nexttrack', null);
mediaSession.setActionHandler('pause', null);
mediaSession.setActionHandler('play', null);
mediaSession.setActionHandler('previoustrack', null);
mediaSession.setActionHandler('seekto', null);
mediaSession.setActionHandler('stop', null);
mediaSession.setActionHandler('seekbackward', null);
mediaSession.setActionHandler('seekforward', null);
return;
}
mediaSession.setActionHandler('nexttrack', () => {
if (isRadioActive && isRadioPlaying) {
if (isRadioActiveRef.current && isRadioPlayingRef.current) {
return;
}
player.mediaNext();
playerRef.current.mediaNext();
});
mediaSession.setActionHandler('pause', () => {
player.mediaPause();
playerRef.current.mediaPause();
});
mediaSession.setActionHandler('play', () => {
player.mediaPlay();
playerRef.current.mediaPlay();
});
mediaSession.setActionHandler('previoustrack', () => {
if (isRadioActive && isRadioPlaying) {
if (isRadioActiveRef.current && isRadioPlayingRef.current) {
return;
}
player.mediaPrevious();
playerRef.current.mediaPrevious();
});
mediaSession.setActionHandler('seekto', (e) => {
if (isRadioActive && isRadioPlaying) {
if (isRadioActiveRef.current && isRadioPlayingRef.current) {
return;
}
if (e.seekTime) {
player.mediaSeekToTimestamp(e.seekTime);
playerRef.current.mediaSeekToTimestamp(e.seekTime);
} else if (e.seekOffset) {
const currentTimestamp = useTimestampStoreBase.getState().timestamp;
player.mediaSeekToTimestamp(currentTimestamp + e.seekOffset);
playerRef.current.mediaSeekToTimestamp(currentTimestamp + e.seekOffset);
}
});
mediaSession.setActionHandler('stop', () => {
player.mediaStop();
playerRef.current.mediaStop();
});
mediaSession.setActionHandler('seekbackward', (e) => {
if (isRadioActive && isRadioPlaying) {
if (isRadioActiveRef.current && isRadioPlayingRef.current) {
return;
}
const currentTimestamp = useTimestampStoreBase.getState().timestamp;
player.mediaSeekToTimestamp(
currentTimestamp - (e.seekOffset || skip?.skipBackwardSeconds || 5),
playerRef.current.mediaSeekToTimestamp(
currentTimestamp - (e.seekOffset || skipRef.current?.skipBackwardSeconds || 5),
);
});
mediaSession.setActionHandler('seekforward', (e) => {
if (isRadioActive && isRadioPlaying) {
if (isRadioActiveRef.current && isRadioPlayingRef.current) {
return;
}
const currentTimestamp = useTimestampStoreBase.getState().timestamp;
player.mediaSeekToTimestamp(
currentTimestamp + (e.seekOffset || skip?.skipForwardSeconds || 5),
playerRef.current.mediaSeekToTimestamp(
currentTimestamp + (e.seekOffset || skipRef.current?.skipForwardSeconds || 5),
);
});
@@ -116,28 +168,22 @@ export const useMediaSession = () => {
mediaSession.setActionHandler('seekbackward', null);
mediaSession.setActionHandler('seekforward', null);
};
}, [
player,
skip?.skipBackwardSeconds,
skip?.skipForwardSeconds,
isMediaSessionEnabled,
isRadioActive,
isRadioPlaying,
]);
}, [isMediaSessionEnabled]);
const updateMediaSessionMetadata = useCallback(
(song: QueueSong | undefined) => {
if (!isMediaSessionEnabled) {
// Read from ref so this callback is never stale regardless of when it was created
if (!isMediaSessionEnabledRef.current) {
return;
}
// Handle radio metadata when radio is active and playing
if (isRadioActive && isRadioPlaying) {
const title = radioMetadata?.title || stationName || 'Radio';
const artist = radioMetadata?.artist || stationName || '';
if (isRadioActiveRef.current && isRadioPlayingRef.current) {
const title = radioMetadataRef.current?.title || stationNameRef.current || 'Radio';
const artist = radioMetadataRef.current?.artist || stationNameRef.current || '';
mediaSession.metadata = new MediaMetadata({
album: stationName || '',
album: stationNameRef.current || '',
artist: artist,
artwork: [],
title: title,
@@ -164,62 +210,88 @@ export const useMediaSession = () => {
title: song?.name ?? '',
});
},
[isMediaSessionEnabled, isRadioActive, isRadioPlaying, radioMetadata, stationName],
// All values are read from refs — stable callback, no stale closure risk
[],
);
// Debounced version to handle rapid skipping — only the last skip in a burst commits
// to the media session. Without this, rapid MediaMetadata assignments can tear the
// browser's media session state and permanently drop the handlers.
const debouncedUpdateMetadata = useRef(
debounce((song: QueueSong | undefined) => {
updateMediaSessionMetadata(song);
}, 100),
).current;
// Cancel any pending debounced update on unmount
useEffect(() => {
return () => {
debouncedUpdateMetadata.cancel();
};
}, [debouncedUpdateMetadata]);
// Update metadata when radio metadata changes
useEffect(() => {
if (!isMediaSessionEnabled) {
return;
}
if (isRadioActive && isRadioPlaying) {
updateMediaSessionMetadata(undefined);
if (isRadioActiveRef.current && isRadioPlayingRef.current) {
debouncedUpdateMetadata(undefined);
}
}, [
isMediaSessionEnabled,
isRadioActive,
isRadioPlaying,
radioMetadata,
stationName,
updateMediaSessionMetadata,
]);
}, [radioMetadata, isRadioPlaying, isMediaSessionEnabled, debouncedUpdateMetadata]);
// Subscribe directly to the player store instead of using usePlayerEvents.
// usePlayerEvents receives inline handler objects that cause it to re-subscribe on every
// render, which destroys and recreates the media session on play/pause and track changes.
// subscribeCurrentTrack and subscribePlayerStatus are stable Zustand subscriptions with
// proper equality checks — registered once on mount and never torn down mid-session.
useEffect(() => {
const unsubscribeCurrentSong = subscribeCurrentTrack(({ song }) => {
if (!isMediaSessionEnabledRef.current) {
return;
}
if (isRadioActiveRef.current && isRadioPlayingRef.current) {
return;
}
debouncedUpdateMetadata(song);
});
const unsubscribeStatus = subscribePlayerStatus(({ status }) => {
if (!isMediaSessionEnabledRef.current) {
return;
}
mediaSession.playbackState = status === PlayerStatus.PLAYING ? 'playing' : 'paused';
});
return () => {
unsubscribeCurrentSong();
unsubscribeStatus();
};
}, [debouncedUpdateMetadata]);
// onPlayerRepeated fires via eventEmitter (not Zustand), so usePlayerEvents is safe here —
// the event emitter uses stable function references for on/off and does not re-subscribe
// on render. The inline object is fine because deps is [] and the effect only runs once.
usePlayerEvents(
{
onCurrentSongChange: (properties) => {
if (!isMediaSessionEnabled) {
return;
}
if (isRadioActive && isRadioPlaying) {
return;
}
updateMediaSessionMetadata(properties.song);
},
onPlayerRepeated: () => {
if (!isMediaSessionEnabled) {
if (!isMediaSessionEnabledRef.current) {
return;
}
if (isRadioActive && isRadioPlaying) {
if (isRadioActiveRef.current && isRadioPlayingRef.current) {
return;
}
const currentSong = usePlayerStore.getState().getCurrentSong();
updateMediaSessionMetadata(currentSong);
},
onPlayerStatus: (properties) => {
if (!isMediaSessionEnabled) {
return;
}
const status = properties.status;
mediaSession.playbackState = status === PlayerStatus.PLAYING ? 'playing' : 'paused';
debouncedUpdateMetadata(currentSong);
},
},
[isMediaSessionEnabled, isRadioActive, isRadioPlaying, updateMediaSessionMetadata],
[],
);
};
@@ -229,18 +301,7 @@ const MediaSessionHookInner = () => {
};
export const MediaSessionHook = () => {
const isElectronEnv = isElectron();
const playbackType = usePlaybackType();
const isMediaSessionEnabled = useSettingsStore((state) => state.playback.mediaSession);
// We always want to enable media session when on web
// Otherwise, only enable if it is explicitly enabled in the settings AND using the web player
const shouldUseMediaSession =
!isElectronEnv || (isMediaSessionEnabled && playbackType === PlayerType.WEB);
if (!shouldUseMediaSession) {
return null;
}
// Always render the hook — let the internal guard logic decide whether to act.
// Conditional rendering here causes unmount/remount cycles that destroy handlers mid-session.
return React.createElement(MediaSessionHookInner);
};
@@ -0,0 +1,155 @@
import isElectron from 'is-electron';
import { useCallback, useEffect, useRef } from 'react';
import i18n from '/@/i18n/i18n';
import { useWebAudio } from '/@/renderer/features/player/hooks/use-webaudio';
import { usePlaybackType } from '/@/renderer/store/settings.store';
import { toast } from '/@/shared/components/toast/toast';
import { PlayerType } from '/@/shared/types/types';
export function useVisualizerSystemAudio(options: {
onSystemAudioCaptureDenied?: () => void;
onSystemAudioCaptureSuccess?: () => void;
shouldAttemptConnection: boolean;
}) {
const { onSystemAudioCaptureDenied, onSystemAudioCaptureSuccess, shouldAttemptConnection } =
options;
const onDeniedRef = useRef(onSystemAudioCaptureDenied);
const onSuccessRef = useRef(onSystemAudioCaptureSuccess);
onDeniedRef.current = onSystemAudioCaptureDenied;
onSuccessRef.current = onSystemAudioCaptureSuccess;
const playbackType = usePlaybackType();
const { setWebAudio, webAudio } = useWebAudio();
const webAudioRef = useRef(webAudio);
const streamRef = useRef<MediaStream | null>(null);
const sourceRef = useRef<MediaStreamAudioSourceNode | null>(null);
const connectInFlightRef = useRef(false);
useEffect(() => {
webAudioRef.current = webAudio;
}, [webAudio]);
const disconnect = useCallback(() => {
if (streamRef.current) {
streamRef.current.getTracks().forEach((t) => t.stop());
streamRef.current = null;
}
if (sourceRef.current) {
try {
sourceRef.current.disconnect();
} catch {
// ignore
}
sourceRef.current = null;
}
const w = webAudioRef.current;
if (w?.visualizerInputs?.length && setWebAudio) {
const next = { ...w, visualizerInputs: undefined };
setWebAudio(next);
webAudioRef.current = next;
}
}, [setWebAudio]);
useEffect(() => {
if (playbackType === PlayerType.WEB || !shouldAttemptConnection) {
disconnect();
}
}, [playbackType, shouldAttemptConnection, disconnect]);
const connect = useCallback(async () => {
if (!isElectron()) {
return;
}
const w = webAudioRef.current;
if (!w?.context || w.context.state === 'closed') {
return;
}
if (!setWebAudio) return;
disconnect();
const wAfterDisconnect = webAudioRef.current;
if (!wAfterDisconnect?.context || wAfterDisconnect.context.state === 'closed') {
return;
}
connectInFlightRef.current = true;
try {
const stream = await navigator.mediaDevices.getDisplayMedia({
audio: true,
video: false,
});
const audioTracks = stream.getAudioTracks();
if (audioTracks.length === 0) {
stream.getTracks().forEach((t) => t.stop());
onDeniedRef.current?.();
return;
}
const latest = webAudioRef.current;
if (!latest?.context || latest.context.state === 'closed') {
stream.getTracks().forEach((t) => t.stop());
return;
}
try {
await latest.context.resume();
} catch {
// ignore
}
const source = latest.context.createMediaStreamSource(stream);
streamRef.current = stream;
sourceRef.current = source;
const next = { ...latest, visualizerInputs: [source] };
setWebAudio(next);
webAudioRef.current = next;
onSuccessRef.current?.();
} catch (e) {
const name = (e as DOMException)?.name;
if (name === 'NotAllowedError' || name === 'AbortError') {
onDeniedRef.current?.();
return;
}
toast.error({
message: i18n.t('visualizer.systemAudioCaptureFailed', {
message: (e as Error).message,
}),
});
} finally {
connectInFlightRef.current = false;
}
}, [disconnect, setWebAudio]);
const connectRef = useRef(connect);
connectRef.current = connect;
useEffect(() => {
if (playbackType !== PlayerType.LOCAL || !isElectron() || !shouldAttemptConnection) {
return;
}
const w = webAudioRef.current;
if (!w?.context || w.context.state === 'closed') {
return;
}
if (w.visualizerInputs?.length) {
return;
}
if (connectInFlightRef.current) {
return;
}
void connectRef.current();
}, [
playbackType,
shouldAttemptConnection,
webAudio?.context,
webAudio?.visualizerInputs?.length,
]);
}
@@ -0,0 +1,15 @@
import { useFullScreenPlayerStore, useSettingsStore } from '/@/renderer/store';
export function closeLocalVisualizerSurfaces(): void {
const fullScreen = useFullScreenPlayerStore.getState();
fullScreen.actions.setStore({
...(fullScreen.expanded && fullScreen.activeTab === 'visualizer'
? { activeTab: 'queue' as const }
: {}),
visualizerExpanded: false,
});
useSettingsStore.getState().actions.setSettings({
general: { showVisualizerInSidebar: false },
});
}
@@ -0,0 +1,14 @@
import type { WebAudio } from '/@/shared/types/types';
import { PlayerType } from '/@/shared/types/types';
export function getVisualizerAudioNodes(
webAudio: undefined | WebAudio,
playbackType: PlayerType,
): AudioNode[] {
if (!webAudio) return [];
if (playbackType === PlayerType.LOCAL) {
return webAudio.visualizerInputs ?? [];
}
return webAudio.gains;
}
@@ -5,7 +5,7 @@ import i18n from '/@/i18n/i18n';
export const openVisualizerSettingsModal = () => {
openContextModal({
innerProps: {},
modalKey: 'visualizerSettings',
modal: 'visualizerSettings',
overlayProps: {
blur: 0,
opacity: 0,
@@ -20,8 +20,10 @@ import {
} from '/@/shared/types/domain-types';
import { ItemListKey } from '/@/shared/types/types';
interface PlaylistDetailSongListGridProps
extends Omit<ItemListGridComponentProps<PlaylistSongListQuery>, 'query'> {
interface PlaylistDetailSongListGridProps extends Omit<
ItemListGridComponentProps<PlaylistSongListQuery>,
'query'
> {
currentPage?: number;
data: PlaylistSongListResponse;
items?: Song[];
@@ -60,12 +60,12 @@ const PlaylistSongListFiltersModal = () => {
const hasActiveFilters = useMemo(() => {
return Boolean(
isFilterValueSet(query[FILTER_KEYS.SONG.ALBUM_ARTIST_IDS]) ||
isFilterValueSet(query[FILTER_KEYS.SONG.ARTIST_IDS]) ||
query[FILTER_KEYS.SONG.FAVORITE] !== undefined ||
isFilterValueSet(query[FILTER_KEYS.SONG.GENRE_ID]) ||
query[FILTER_KEYS.SONG.HAS_RATING] !== undefined ||
query[FILTER_KEYS.SONG.MAX_YEAR] !== undefined ||
query[FILTER_KEYS.SONG.MIN_YEAR] !== undefined,
isFilterValueSet(query[FILTER_KEYS.SONG.ARTIST_IDS]) ||
query[FILTER_KEYS.SONG.FAVORITE] !== undefined ||
isFilterValueSet(query[FILTER_KEYS.SONG.GENRE_ID]) ||
query[FILTER_KEYS.SONG.HAS_RATING] !== undefined ||
query[FILTER_KEYS.SONG.MAX_YEAR] !== undefined ||
query[FILTER_KEYS.SONG.MIN_YEAR] !== undefined,
);
}, [query]);
@@ -258,7 +258,7 @@ export const openSaveAndReplaceModal = (
) => {
openContextModal({
innerProps: { onSuccess, playlistId, songIds },
modalKey: 'saveAndReplace',
modal: 'saveAndReplace',
size: 'sm',
title: i18n.t('common.saveAndReplace', { postProcess: 'titleCase' }) as string,
});
@@ -1,4 +1,5 @@
import { useQuery } from '@tanstack/react-query';
import { useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { useLocation, useParams } from 'react-router';
@@ -40,8 +41,13 @@ interface PlaylistDetailSongListHeaderProps {
onToggleQueryBuilder?: () => void;
}
function ImageUploadOverlay({ data }: { data?: Playlist }) {
const uploadPlaylistImageMutation = useUploadPlaylistImage({});
function ImageUploadOverlay({
data,
onUploadFile,
}: {
data?: Playlist;
onUploadFile: (file: File) => Promise<void>;
}) {
const deletePlaylistImageMutation = useDeletePlaylistImage({});
const server = useCurrentServer();
@@ -53,16 +59,8 @@ function ImageUploadOverlay({ data }: { data?: Playlist }) {
<FileButton
accept="image/*"
onChange={async (file) => {
if (!file || !data?._serverId) return;
const buffer = await file.arrayBuffer();
uploadPlaylistImageMutation.mutate({
apiClientProps: {
serverId: data._serverId,
},
body: { image: new Uint8Array(buffer) },
query: { id: data.id },
});
if (!file) return;
await onUploadFile(file);
}}
>
{(props) => (
@@ -121,11 +119,33 @@ export const PlaylistDetailSongListHeader = ({
});
const player = usePlayer();
const uploadPlaylistImageMutation = useUploadPlaylistImage({});
const handlePlay = (type?: Play) => {
player.addToQueueByData(listData as Song[], type || Play.NOW);
};
const canUploadPlaylistImage =
hasFeature(server, ServerFeature.PLAYLIST_IMAGE_UPLOAD) &&
Boolean(detailQuery?.data?._serverId);
const handlePlaylistImageUpload = useCallback(
async (file: File) => {
const playlist = detailQuery?.data;
if (!playlist?._serverId) return;
const buffer = await file.arrayBuffer();
uploadPlaylistImageMutation.mutate({
apiClientProps: {
serverId: playlist._serverId,
},
body: { image: new Uint8Array(buffer) },
query: { id: playlist.id },
});
},
[detailQuery?.data, uploadPlaylistImageMutation],
);
const imageUrl = useItemImageUrl({
id: detailQuery?.data?.imageId || undefined,
itemType: LibraryItem.PLAYLIST,
@@ -163,7 +183,12 @@ export const PlaylistDetailSongListHeader = ({
) : (
<LibraryHeader
compact
imageOverlay={<ImageUploadOverlay data={detailQuery?.data} />}
imageOverlay={
<ImageUploadOverlay
data={detailQuery?.data}
onUploadFile={handlePlaylistImageUpload}
/>
}
imageUrl={imageUrl}
item={{
imageId: detailQuery?.data?.imageId,
@@ -171,6 +196,7 @@ export const PlaylistDetailSongListHeader = ({
route: AppRoute.PLAYLISTS,
type: LibraryItem.PLAYLIST,
}}
onImageFileDrop={canUploadPlaylistImage ? handlePlaylistImageUpload : undefined}
title={detailQuery?.data?.name || ''}
topRight={<ListSearchInput />}
>
@@ -23,8 +23,10 @@ import {
} from '/@/shared/types/domain-types';
import { ItemListKey, Play, TableColumn } from '/@/shared/types/types';
interface PlaylistDetailSongListTableProps
extends Omit<ItemListTableComponentProps<PlaylistSongListQuery>, 'query'> {
interface PlaylistDetailSongListTableProps extends Omit<
ItemListTableComponentProps<PlaylistSongListQuery>,
'query'
> {
currentPage?: number;
data: PlaylistSongListResponse;
items?: Song[];
@@ -1,5 +1,6 @@
import type { UseSuspenseQueryResult } from '@tanstack/react-query';
import { closeAllModals, openModal } from '@mantine/modals';
import { useQuery } from '@tanstack/react-query';
import { useCallback, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
@@ -25,7 +26,7 @@ import { toast } from '/@/shared/components/toast/toast';
import { SongListSort } from '/@/shared/types/domain-types';
export interface PlaylistQueryEditorProps {
detailQuery: ReturnType<typeof useQuery<any>>;
detailQuery: UseSuspenseQueryResult<any, Error>;
handleSave: (
filter: Record<string, any>,
extraFilters: {
@@ -421,8 +422,12 @@ export const PlaylistQueryEditor = ({
minRows={8}
onChange={(value) => setJsonText(value)}
placeholder='{ "all": [], "limit": 100, "sort": "+dateAdded" }'
size="lg"
spellCheck={false}
style={{ flex: 1, minHeight: 0 }}
style={{
flex: 1,
minHeight: 0,
}}
value={jsonText}
/>
</ScrollArea>
@@ -13,6 +13,7 @@ import { useCurrentServer, useCurrentServerId, usePermissions } from '/@/rendere
import { hasFeature } from '/@/shared/api/utils';
import { ActionIcon } from '/@/shared/components/action-icon/action-icon';
import { Box } from '/@/shared/components/box/box';
import { DragDropZone } from '/@/shared/components/drag-drop-zone/drag-drop-zone';
import { FileButton } from '/@/shared/components/file-button/file-button';
import { Flex } from '/@/shared/components/flex/flex';
import { Group } from '/@/shared/components/group/group';
@@ -270,16 +271,20 @@ function PlaylistCoverField({
const iconControls = (
<>
<FileButton accept="image/*" onChange={onFileSelect}>
{(props) => (
<ActionIcon
icon="uploadImage"
iconProps={{ size: 'lg' }}
radius="xl"
size="sm"
variant="default"
{...props}
/>
)}
{(props) => {
const { ...triggerRest } = props;
return (
<ActionIcon
icon="uploadImage"
iconProps={{ size: 'lg' }}
radius="xl"
size="sm"
variant="default"
{...triggerRest}
style={{ pointerEvents: 'auto' }}
/>
);
}}
</FileButton>
<ActionIcon
disabled={secondaryDisabled}
@@ -288,22 +293,12 @@ function PlaylistCoverField({
onClick={secondaryAction}
radius="xl"
size="sm"
style={{ pointerEvents: 'auto' }}
variant="default"
/>
</>
);
const coverArt = (
<ItemImage
enableViewport={false}
id={previewId}
itemType={LibraryItem.PLAYLIST}
serverId={server?.id}
src={previewSrc}
type="header"
/>
);
return (
<Box
style={{
@@ -315,21 +310,41 @@ function PlaylistCoverField({
width: COVER_SIZE,
}}
>
{coverArt}
<Group
gap={4}
<DragDropZone
accept="image/*"
mode="file"
onFileSelected={(file) => onFileSelect(file)}
style={{
background: 'rgba(0, 0, 0, 0.55)',
borderRadius: 'var(--mantine-radius-md)',
bottom: 6,
padding: 4,
position: 'absolute',
right: 6,
height: '100%',
overflow: 'hidden',
position: 'relative',
width: '100%',
}}
wrap="nowrap"
>
{iconControls}
</Group>
<ItemImage
enableViewport={false}
id={previewId}
itemType={LibraryItem.PLAYLIST}
serverId={server?.id}
src={previewSrc}
type="header"
/>
<Group
gap={4}
style={{
background: 'rgba(0, 0, 0, 0.55)',
bottom: 6,
padding: 4,
pointerEvents: 'none',
position: 'absolute',
right: 6,
zIndex: 2,
}}
wrap="nowrap"
>
{iconControls}
</Group>
</DragDropZone>
</Box>
);
}
@@ -30,7 +30,7 @@ export const openUpdatePlaylistModal = async (args: { playlist: Playlist }) => {
},
query: { id: playlist?.id },
},
modalKey: 'updatePlaylist',
modal: 'updatePlaylist',
size: hasImageUpload ? 'lg' : 'md',
title: i18n.t('form.editPlaylist.title', { postProcess: 'titleCase' }) as string,
});
@@ -6,6 +6,7 @@ import { useSortByFilter } from '/@/renderer/features/shared/hooks/use-sort-by-f
import { useSortOrderFilter } from '/@/renderer/features/shared/hooks/use-sort-order-filter';
import { FILTER_KEYS } from '/@/renderer/features/shared/utils';
import { parseCustomFiltersParam } from '/@/renderer/utils/query-params';
import { runInUrlTransition } from '/@/renderer/utils/url-transition';
import { PlaylistListSort } from '/@/shared/types/domain-types';
import { ItemListKey } from '/@/shared/types/types';
@@ -24,28 +25,30 @@ export const usePlaylistListFilters = () => {
const setCustom = useCallback(
(value: null | Record<string, any>) => {
setSearchParams(
(prev) => {
const previousValue = prev.get(FILTER_KEYS.ALBUM._CUSTOM);
runInUrlTransition(() => {
setSearchParams(
(prev) => {
const previousValue = prev.get(FILTER_KEYS.ALBUM._CUSTOM);
const newCustom = {
...(previousValue ? JSON.parse(previousValue) : {}),
...value,
};
const newCustom = {
...(previousValue ? JSON.parse(previousValue) : {}),
...value,
};
const filteredNewCustom = Object.fromEntries(
Object.entries(newCustom).filter(
([, value]) => value !== null && value !== undefined,
),
);
const filteredNewCustom = Object.fromEntries(
Object.entries(newCustom).filter(
([, value]) => value !== null && value !== undefined,
),
);
prev.set(FILTER_KEYS.ALBUM._CUSTOM, JSON.stringify(filteredNewCustom));
return prev;
},
{
replace: true,
},
);
prev.set(FILTER_KEYS.ALBUM._CUSTOM, JSON.stringify(filteredNewCustom));
return prev;
},
{
replace: true,
},
);
});
},
[setSearchParams],
);
@@ -14,6 +14,7 @@ import {
setMultipleSearchParams,
setSearchParam,
} from '/@/renderer/utils/query-params';
import { runInUrlTransition } from '/@/renderer/utils/url-transition';
import { SongListSort, SortOrder } from '/@/shared/types/domain-types';
import { ItemListKey } from '/@/shared/types/types';
@@ -74,18 +75,22 @@ export const usePlaylistSongListFilters = () => {
const setAlbumArtistIds = useCallback(
(value: null | string[]) => {
setSearchParams(
(prev) => setSearchParam(prev, FILTER_KEYS.SONG.ALBUM_ARTIST_IDS, value),
{ replace: true },
);
runInUrlTransition(() => {
setSearchParams(
(prev) => setSearchParam(prev, FILTER_KEYS.SONG.ALBUM_ARTIST_IDS, value),
{ replace: true },
);
});
},
[setSearchParams],
);
const setGenreId = useCallback(
(value: null | string[]) => {
setSearchParams((prev) => setSearchParam(prev, FILTER_KEYS.SONG.GENRE_ID, value), {
replace: true,
runInUrlTransition(() => {
setSearchParams((prev) => setSearchParam(prev, FILTER_KEYS.SONG.GENRE_ID, value), {
replace: true,
});
});
},
[setSearchParams],
@@ -93,8 +98,13 @@ export const usePlaylistSongListFilters = () => {
const setArtistIds = useCallback(
(value: null | string[]) => {
setSearchParams((prev) => setSearchParam(prev, FILTER_KEYS.SONG.ARTIST_IDS, value), {
replace: true,
runInUrlTransition(() => {
setSearchParams(
(prev) => setSearchParam(prev, FILTER_KEYS.SONG.ARTIST_IDS, value),
{
replace: true,
},
);
});
},
[setSearchParams],
@@ -102,8 +112,10 @@ export const usePlaylistSongListFilters = () => {
const setMinYear = useCallback(
(value: null | number) => {
setSearchParams((prev) => setSearchParam(prev, FILTER_KEYS.SONG.MIN_YEAR, value), {
replace: true,
runInUrlTransition(() => {
setSearchParams((prev) => setSearchParam(prev, FILTER_KEYS.SONG.MIN_YEAR, value), {
replace: true,
});
});
},
[setSearchParams],
@@ -111,8 +123,10 @@ export const usePlaylistSongListFilters = () => {
const setMaxYear = useCallback(
(value: null | number) => {
setSearchParams((prev) => setSearchParam(prev, FILTER_KEYS.SONG.MAX_YEAR, value), {
replace: true,
runInUrlTransition(() => {
setSearchParams((prev) => setSearchParam(prev, FILTER_KEYS.SONG.MAX_YEAR, value), {
replace: true,
});
});
},
[setSearchParams],
@@ -120,8 +134,10 @@ export const usePlaylistSongListFilters = () => {
const setFavorite = useCallback(
(value: boolean | null) => {
setSearchParams((prev) => setSearchParam(prev, FILTER_KEYS.SONG.FAVORITE, value), {
replace: true,
runInUrlTransition(() => {
setSearchParams((prev) => setSearchParam(prev, FILTER_KEYS.SONG.FAVORITE, value), {
replace: true,
});
});
},
[setSearchParams],
@@ -129,8 +145,13 @@ export const usePlaylistSongListFilters = () => {
const setHasRating = useCallback(
(value: boolean | null) => {
setSearchParams((prev) => setSearchParam(prev, FILTER_KEYS.SONG.HAS_RATING, value), {
replace: true,
runInUrlTransition(() => {
setSearchParams(
(prev) => setSearchParam(prev, FILTER_KEYS.SONG.HAS_RATING, value),
{
replace: true,
},
);
});
},
[setSearchParams],
@@ -153,51 +174,55 @@ export const usePlaylistSongListFilters = () => {
const setCustom = useCallback(
(value: null | Record<string, any>) => {
setSearchParams(
(prev) => {
const previousValue = prev.get(FILTER_KEYS.ALBUM._CUSTOM);
runInUrlTransition(() => {
setSearchParams(
(prev) => {
const previousValue = prev.get(FILTER_KEYS.ALBUM._CUSTOM);
const newCustom = {
...(previousValue ? JSON.parse(previousValue) : {}),
...value,
};
const newCustom = {
...(previousValue ? JSON.parse(previousValue) : {}),
...value,
};
const filteredNewCustom = Object.fromEntries(
Object.entries(newCustom).filter(
([, value]) => value !== null && value !== undefined,
),
);
const filteredNewCustom = Object.fromEntries(
Object.entries(newCustom).filter(
([, value]) => value !== null && value !== undefined,
),
);
prev.set(FILTER_KEYS.ALBUM._CUSTOM, JSON.stringify(filteredNewCustom));
return prev;
},
{
replace: true,
},
);
prev.set(FILTER_KEYS.ALBUM._CUSTOM, JSON.stringify(filteredNewCustom));
return prev;
},
{
replace: true,
},
);
});
},
[setSearchParams],
);
const clear = useCallback(() => {
setSearchParams(
(prev) =>
setMultipleSearchParams(
prev,
{
[FILTER_KEYS.SONG._CUSTOM]: null,
[FILTER_KEYS.SONG.ALBUM_ARTIST_IDS]: null,
[FILTER_KEYS.SONG.ARTIST_IDS]: null,
[FILTER_KEYS.SONG.FAVORITE]: null,
[FILTER_KEYS.SONG.GENRE_ID]: null,
[FILTER_KEYS.SONG.HAS_RATING]: null,
[FILTER_KEYS.SONG.MAX_YEAR]: null,
[FILTER_KEYS.SONG.MIN_YEAR]: null,
},
new Set([FILTER_KEYS.SONG._CUSTOM]),
),
{ replace: true },
);
runInUrlTransition(() => {
setSearchParams(
(prev) =>
setMultipleSearchParams(
prev,
{
[FILTER_KEYS.SONG._CUSTOM]: null,
[FILTER_KEYS.SONG.ALBUM_ARTIST_IDS]: null,
[FILTER_KEYS.SONG.ARTIST_IDS]: null,
[FILTER_KEYS.SONG.FAVORITE]: null,
[FILTER_KEYS.SONG.GENRE_ID]: null,
[FILTER_KEYS.SONG.HAS_RATING]: null,
[FILTER_KEYS.SONG.MAX_YEAR]: null,
[FILTER_KEYS.SONG.MIN_YEAR]: null,
},
new Set([FILTER_KEYS.SONG._CUSTOM]),
),
{ replace: true },
);
});
}, [setSearchParams]);
const query = useMemo(
@@ -1,8 +1,8 @@
import { closeAllModals, openModal } from '@mantine/modals';
import { useQuery } from '@tanstack/react-query';
import { useSuspenseQuery } from '@tanstack/react-query';
import { Suspense, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { generatePath, useLocation, useNavigate, useParams } from 'react-router';
import { generatePath, useNavigate, useParams } from 'react-router';
import { ListContext, useListContext } from '/@/renderer/context/list-context';
import { playlistsQueries } from '/@/renderer/features/playlists/api/playlists-api';
@@ -72,13 +72,11 @@ const PlaylistSongListFiltersSidebar = () => {
const PlaylistDetailSongListRoute = () => {
const { t } = useTranslation();
const navigate = useNavigate();
const location = useLocation();
const { playlistId } = useParams() as { playlistId: string };
const server = useCurrentServer();
const detailQuery = useQuery({
const detailQuery = useSuspenseQuery({
...playlistsQueries.detail({ query: { id: playlistId }, serverId: server?.id }),
placeholderData: location.state?.item,
});
const deletePlaylistMutation = useDeletePlaylist({});
const updatePlaylistMutation = useUpdatePlaylist({});
@@ -212,9 +210,7 @@ const PlaylistDetailSongListRoute = () => {
};
const isSmartPlaylist = Boolean(
!detailQuery?.isLoading &&
detailQuery?.data?.rules &&
server?.type === ServerType.NAVIDROME,
detailQuery?.data?.rules && server?.type === ServerType.NAVIDROME,
);
const [showQueryBuilder, setShowQueryBuilder] = useState(false);
@@ -12,6 +12,7 @@ import { logMsg } from '/@/renderer/utils/logger-message';
import { hasFeature } from '/@/shared/api/utils';
import { ActionIcon } from '/@/shared/components/action-icon/action-icon';
import { Box } from '/@/shared/components/box/box';
import { DragDropZone } from '/@/shared/components/drag-drop-zone/drag-drop-zone';
import { FileButton } from '/@/shared/components/file-button/file-button';
import { Flex } from '/@/shared/components/flex/flex';
import { Group } from '/@/shared/components/group/group';
@@ -241,16 +242,20 @@ function RadioStationCoverField({
const iconControls = (
<>
<FileButton accept="image/*" onChange={onFileSelect}>
{(props) => (
<ActionIcon
icon="uploadImage"
iconProps={{ size: 'lg' }}
radius="xl"
size="sm"
variant="default"
{...props}
/>
)}
{(props) => {
const { ...triggerRest } = props;
return (
<ActionIcon
icon="uploadImage"
iconProps={{ size: 'lg' }}
radius="xl"
size="sm"
variant="default"
{...triggerRest}
style={{ pointerEvents: 'auto' }}
/>
);
}}
</FileButton>
<ActionIcon
disabled={secondaryDisabled}
@@ -259,22 +264,12 @@ function RadioStationCoverField({
onClick={secondaryAction}
radius="xl"
size="sm"
style={{ pointerEvents: 'auto' }}
variant="default"
/>
</>
);
const coverArt = (
<ItemImage
enableViewport={false}
id={previewId}
itemType={LibraryItem.RADIO_STATION}
serverId={server?.id}
src={previewSrc}
type="header"
/>
);
return (
<Box
style={{
@@ -286,21 +281,41 @@ function RadioStationCoverField({
width: COVER_SIZE,
}}
>
{coverArt}
<Group
gap={4}
<DragDropZone
accept="image/*"
mode="file"
onFileSelected={(file) => onFileSelect(file)}
style={{
background: 'rgba(0, 0, 0, 0.55)',
borderRadius: 'var(--mantine-radius-md)',
bottom: 6,
padding: 4,
position: 'absolute',
right: 6,
height: '100%',
overflow: 'hidden',
position: 'relative',
width: '100%',
}}
wrap="nowrap"
>
{iconControls}
</Group>
<ItemImage
enableViewport={false}
id={previewId}
itemType={LibraryItem.RADIO_STATION}
serverId={server?.id}
src={previewSrc}
type="header"
/>
<Group
gap={4}
style={{
background: 'rgba(0, 0, 0, 0.55)',
bottom: 6,
padding: 4,
pointerEvents: 'none',
position: 'absolute',
right: 6,
zIndex: 2,
}}
wrap="nowrap"
>
{iconControls}
</Group>
</DragDropZone>
</Box>
);
}
@@ -11,7 +11,6 @@
font-size: var(--theme-font-size-sm);
cursor: pointer;
user-select: none;
opacity: 0.8;
}
.heading:hover {
@@ -39,3 +38,7 @@
display: flex;
flex-direction: column;
}
.items button:focus-visible {
outline: 2px solid var(--theme-colors-primary);
}
@@ -1,8 +1,10 @@
import { Command } from 'cmdk';
import { ComponentPropsWithoutRef, ReactNode, useEffect, useRef, useState } from 'react';
interface CommandItemSelectableProps
extends Omit<ComponentPropsWithoutRef<typeof Command.Item>, 'children'> {
interface CommandItemSelectableProps extends Omit<
ComponentPropsWithoutRef<typeof Command.Item>,
'children'
> {
children: (args: { isHighlighted: boolean }) => ReactNode;
}
@@ -1,4 +1,4 @@
import { useCallback, useRef, useState } from 'react';
import { useCallback, useDeferredValue, useRef, useState } from 'react';
import { Command, CommandPalettePages } from '/@/renderer/features/search/components/command';
import { GoToCommands } from '/@/renderer/features/search/components/go-to-commands';
@@ -49,6 +49,7 @@ function CommandPaletteSearch({
setQuery,
}: CommandPaletteSearchProps) {
const [debouncedQuery] = useDebouncedValue(query, 400);
const deferredSearchQuery = useDeferredValue(debouncedQuery ?? '');
const searchSectionsExpanded = useAppStore(
(state) => state.commandPaletteSearchSectionsExpanded,
);
@@ -83,7 +84,7 @@ function CommandPaletteSearch({
<Command.List>
<Stack gap="xs">
<SearchAlbumsSection
debouncedQuery={debouncedQuery ?? ''}
debouncedQuery={deferredSearchQuery}
expanded={searchSectionsExpanded[SEARCH_SECTION_IDS.albums] ?? true}
isHome={isHome}
onSelectResult={onSelectResult}
@@ -96,7 +97,7 @@ function CommandPaletteSearch({
query={query}
/>
<SearchAlbumArtistsSection
debouncedQuery={debouncedQuery ?? ''}
debouncedQuery={deferredSearchQuery}
expanded={searchSectionsExpanded[SEARCH_SECTION_IDS.artists] ?? true}
isHome={isHome}
onSelectResult={onSelectResult}
@@ -109,7 +110,7 @@ function CommandPaletteSearch({
query={query}
/>
<SearchSongsSection
debouncedQuery={debouncedQuery ?? ''}
debouncedQuery={deferredSearchQuery}
expanded={searchSectionsExpanded[SEARCH_SECTION_IDS.tracks] ?? true}
isHome={isHome}
onSelectResult={onSelectResult}

Some files were not shown because too many files have changed in this diff Show More