mirror of
https://github.com/jeffvli/feishin.git
synced 2026-05-08 13:00:13 +02:00
Compare commits
68 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3c442a2d40 | |||
| d67c185c93 | |||
| ff96a5f121 | |||
| 6fc7b6b271 | |||
| 918f453066 | |||
| 4a986069f8 | |||
| 11d26af893 | |||
| ad13fea033 | |||
| 8a75ec2558 | |||
| 895cbb4d16 | |||
| 3f300c40cc | |||
| c8e8f58cce | |||
| 56cd50e0ed | |||
| 1b2a6dfc1f | |||
| 356f5487b0 | |||
| 37501f2983 | |||
| d61587b16f | |||
| 06b7b53dc9 | |||
| 6c2cd1c274 | |||
| ef129e4638 | |||
| a01b4e664d | |||
| 0b45ab7f36 | |||
| 031d365262 | |||
| 4fd56281d5 | |||
| 08ce8a4028 | |||
| e06877af76 | |||
| 84395ce5b4 | |||
| 94886a2d5a | |||
| 25bb7f7069 | |||
| 573fe5ee35 | |||
| a868d4d539 | |||
| 564ee721c4 | |||
| a8d990db23 | |||
| e21515f7fb | |||
| 3e5a8ac78d | |||
| 6c73d06dcf | |||
| a8954bfa2a | |||
| 19a1617a8d | |||
| 1abae986f8 | |||
| 43fa574dab | |||
| 99530c670e | |||
| 3a0dfe59ce | |||
| d60ed0a793 | |||
| a32fed3bcf | |||
| 132ac92984 | |||
| 141a20f042 | |||
| 1592204515 | |||
| b9f5459725 | |||
| d4e9b9b7a6 | |||
| ec9e4b1339 | |||
| f09109b887 | |||
| 1494c8e044 | |||
| f3a6027e6d | |||
| 3c42355c1e | |||
| feda1bb06f | |||
| 72f1d2f9f9 | |||
| ad11a9303c | |||
| db06e7f601 | |||
| fbf82c1ef0 | |||
| 92cea5dfda | |||
| 7442f9d3ca | |||
| 68dacea228 | |||
| 51425b5e86 | |||
| c60610cb42 | |||
| d3881ee3be | |||
| de403ea6ac | |||
| a30b1ec90b | |||
| 7982c0e1bd |
@@ -51,5 +51,4 @@ jobs:
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
platforms: |
|
||||
linux/amd64
|
||||
linux/arm/v7
|
||||
linux/arm64/v8
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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": [
|
||||
|
||||
Generated
+2436
-2404
File diff suppressed because it is too large
Load Diff
@@ -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')),
|
||||
|
||||
@@ -1110,6 +1110,9 @@
|
||||
"export": "exportovat texty",
|
||||
"input_synced": "exportovat synchronizované texty",
|
||||
"input_offset": "$t(setting.lyricOffset)"
|
||||
},
|
||||
"editRadioStation": {
|
||||
"success": "stanice rádia úspěšně upravena"
|
||||
}
|
||||
},
|
||||
"entity": {
|
||||
|
||||
@@ -364,6 +364,9 @@
|
||||
"input_name": "name",
|
||||
"input_streamUrl": "stream url"
|
||||
},
|
||||
"editRadioStation": {
|
||||
"success": "radio station updated successfully"
|
||||
},
|
||||
"deletePlaylist": {
|
||||
"input_confirm": "type the name of the $t(entity.playlist, {\"count\": 1}) to confirm",
|
||||
"success": "$t(entity.playlist, {\"count\": 1}) deleted successfully",
|
||||
@@ -1211,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)",
|
||||
|
||||
@@ -574,7 +574,7 @@
|
||||
"hotkey_browserForward": "nabigatzailean aurreraka",
|
||||
"imageAspectRatio": "erabili jatorrizko azaleko artearen aspektu-erlazioa",
|
||||
"lyricFetchProvider": "letrak eskuratzeko hornitzaileak",
|
||||
"lyricFetchProvider_description": "aukeratu letrak eskuratzeko hornitzaileak. hornitzaileen ordena kontsultatuko diren ordena da",
|
||||
"lyricFetchProvider_description": "aukeratu letrak eskuratzeko hornitzaileak",
|
||||
"minimizeToTray": "minimizatu erretilura",
|
||||
"minimizeToTray_description": "minimizatu aplikazioa sistemaren erretilura",
|
||||
"minimumScrobblePercentage": "scrobble iraupen minimoa (ehunekoa)",
|
||||
@@ -688,7 +688,33 @@
|
||||
"remotePort_description": "urruneko kontrol zerbitzariaren portua ezartzen du",
|
||||
"remotePort": "urruneko kontrol zerbitzariaren ataka",
|
||||
"remoteUsername_description": "urruneko kontrol zerbitzariaren erabiltzaile-izena ezartzen du. Erabiltzaile-izena eta pasahitza hutsik badaude, autentifikazioa desgaituta egongo da",
|
||||
"remoteUsername": "urruneko kontrol zerbitzariaren erabiltzaile-izena"
|
||||
"remoteUsername": "urruneko kontrol zerbitzariaren erabiltzaile-izena",
|
||||
"logLevel_optionWarn": "abisua",
|
||||
"qobuz_description": "erakutsi Qobuz-erako estekak artista/album orrialdeetan",
|
||||
"qobuz": "erakutsi Qobuz-erako estekak",
|
||||
"spotify_description": "erakutsi Spotify-rako estekak artista/album orrialdeetan",
|
||||
"spotify": "erakutsi Spotify-rako estekak",
|
||||
"nativeSpotify_description": "ireki Spotify aplikazioan, arakatzailearen ordez",
|
||||
"nativeSpotify": "erabili Spotify aplikazioa",
|
||||
"playerbarSlider_description": "uhin-forma ez da gomendagarria interneteko konexio motela edo neurtua baduzu",
|
||||
"playerbarSliderType_optionWaveform": "uhin-forma",
|
||||
"playerbarWaveformAlign": "uhin-formaren lerrokatzea",
|
||||
"playerbarWaveformAlign_optionTop": "nagusia",
|
||||
"playerbarWaveformBarWidth": "uhin-formako barraren zabalera",
|
||||
"playerbarWaveformGap": "uhin-formaren tartea",
|
||||
"playerbarWaveformRadius": "uhin-formaren erradioa",
|
||||
"showLyricsInSidebar_description": "letrak erakusten dituen panel bat gehituko da erantsitako erreprodukzio-ilaran",
|
||||
"showLyricsInSidebar": "erakutsi letra erreproduzitzailearen alboko barran",
|
||||
"blurExplicitImages": "irudi esplizituak lausotu",
|
||||
"blurExplicitImages_description": "esplizitu gisa etiketatutako albumaren eta abestiaren azalak lausotuta agertuko dira",
|
||||
"enableGridMultiSelect": "gaitu sareta anitzeko hautaketa",
|
||||
"enableGridMultiSelect_description": "gaituta dagoenean, sareta-ikuspegietan hainbat elementu hautatzea ahalbidetzen du. desgaituta dagoenean, sareta-elementuen irudietan klik egitean elementuaren orrialdera nabigatuko da",
|
||||
"showVisualizerInSidebar_description": "bistaratzailea erakusten duen panel bat gehituko da erreproduzitzailearen alboko barran",
|
||||
"preservePitch_description": "erreprodukzio-abiadura aldatzean tonua mantentzen du",
|
||||
"preservePitch": "mantendu tonua",
|
||||
"preventSleepOnPlayback": "erreprodukzioan loa saihestu",
|
||||
"replayGainClipping_description": "Saihestu {{ReplayGain}}-k eragindako mozketa irabazpena automatikoki jaitsiz",
|
||||
"replayGainMode_description": "doitu bolumenaren irabazia fitxategiaren metadatuetan gordetako {{ReplayGain}} balioen arabera"
|
||||
},
|
||||
"form": {
|
||||
"addServer": {
|
||||
@@ -943,7 +969,8 @@
|
||||
"nowPlaying": "orain erreproduzitzen",
|
||||
"shared": "partekatutako $t(entity.playlist, {\"count\": 2})",
|
||||
"favorites": "$t(entity.favorite, {\"count\": 2})",
|
||||
"radio": "$t(entity.radioStation, {\"count\": 2})"
|
||||
"radio": "$t(entity.radioStation, {\"count\": 2})",
|
||||
"collections": "bildumak"
|
||||
},
|
||||
"trackList": {
|
||||
"title": "$t(entity.track, {\"count\": 2})",
|
||||
@@ -1112,6 +1139,26 @@
|
||||
"saveAsPreset": "Aurrezarpen gisa gorde",
|
||||
"applyPreset": "Aurrezarpena Aplikatu",
|
||||
"selectPreset": "Aukeratu Aurrezarpena",
|
||||
"presets": "Aurrezarpenak"
|
||||
"presets": "Aurrezarpenak",
|
||||
"visualizerType": "Bistaratzaile Mota",
|
||||
"cycleTime": "Zikloaren denbora (segundoak)",
|
||||
"includeAllPresets": "Aurrezarpen guztiak sartu",
|
||||
"ignoredPresets": "Aurrezarpen baztertuak",
|
||||
"selectedPresets": "Hautatutako aurrezarpenak",
|
||||
"mode1To8": "1 - 8 modua",
|
||||
"mode10": "10 modua",
|
||||
"gradientLeft": "Gradientearen ezkerra",
|
||||
"gradientRight": "Gradientearen eskuina",
|
||||
"peakBehavior": "Gailurraren Portaera",
|
||||
"peakLine": "Gailurraren lerroa",
|
||||
"miscellaneousSettings": "Hainbat ezarpen",
|
||||
"alphaBars": "Alfa barrak",
|
||||
"ansiBands": "ANSI bandak",
|
||||
"ledBars": "LED barrak",
|
||||
"trueLeds": "True LED-ak",
|
||||
"roundBars": "Barra biribilduak",
|
||||
"lowResolution": "Erresoluzio baxua",
|
||||
"showFPS": "Erakutsi FPS",
|
||||
"showScaleX": "Erakutsi X eskala"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -891,7 +891,9 @@
|
||||
"sidePlayQueueLayout": "disposition de la file d'attente",
|
||||
"sidePlayQueueLayout_description": "définit la disposition de la file d'attente attaché",
|
||||
"sidePlayQueueLayout_optionHorizontal": "horizontal",
|
||||
"sidePlayQueueLayout_optionVertical": "vertical"
|
||||
"sidePlayQueueLayout_optionVertical": "vertical",
|
||||
"waveformLoadingDelay": "délai de chargement de la forme d'onde",
|
||||
"waveformLoadingDelay_description": "délai en secondes avant le chargement de l'onde. augmentez cette valeur si vous rencontrez des saccades lors de l'utilisation du lecteur web."
|
||||
},
|
||||
"form": {
|
||||
"deletePlaylist": {
|
||||
@@ -1090,7 +1092,7 @@
|
||||
"pagination_itemsPerPage": "entrées par page",
|
||||
"pagination_infinite": "infini",
|
||||
"pagination_paginate": "paginé",
|
||||
"alternateRowColors": "alterner les couleurs des lignes",
|
||||
"alternateRowColors": "alterner la couleur des lignes",
|
||||
"horizontalBorders": "bordures de ligne",
|
||||
"rowHoverHighlight": "surligner les lignes au survol",
|
||||
"verticalBorders": "bordure de colonne",
|
||||
@@ -1232,12 +1234,12 @@
|
||||
},
|
||||
"visualizer": {
|
||||
"visualizerType": "type de visualisateur",
|
||||
"cyclePresets": "cycle les préréglages",
|
||||
"cycleTime": "temps de cycle (secondes)",
|
||||
"cyclePresets": "cycler les préréglages",
|
||||
"cycleTime": "durée d'un cycle (secondes)",
|
||||
"includeAllPresets": "inclure tous les préréglages",
|
||||
"ignoredPresets": "préréglages ignorés",
|
||||
"selectedPresets": "préréglages sélectionné",
|
||||
"randomizeNextPreset": "randomiser le préréglage suivant",
|
||||
"selectedPresets": "préréglages sélectionnés",
|
||||
"randomizeNextPreset": "préréglage suivant aléatoire",
|
||||
"blendTime": "temps de mélange",
|
||||
"presets": "préréglages",
|
||||
"selectPreset": "sélectionner un préréglage",
|
||||
@@ -1247,7 +1249,7 @@
|
||||
"copyConfiguration": "copier la configuration",
|
||||
"pasteConfiguration": "coller la configuration",
|
||||
"pasteConfigurationPlaceholder": "coller ici la configuration JSON...",
|
||||
"pasteFromClipboard": "coller depuis le presse-papier",
|
||||
"pasteFromClipboard": "coller depuis le presse-papiers",
|
||||
"applyConfiguration": "appliquer la configuration",
|
||||
"configCopied": "configuration copiée dans le presse-papiers",
|
||||
"configCopyFailed": "échec de la copie de la configuration",
|
||||
@@ -1272,7 +1274,7 @@
|
||||
"gradientNamePlaceholder": "nom du dégradé",
|
||||
"vertical": "verticale",
|
||||
"horizontal": "horizontale",
|
||||
"colorStops": "couleur d'arrêts",
|
||||
"colorStops": "Points de Couleur",
|
||||
"addColor": "ajouter un couleur",
|
||||
"position": "position",
|
||||
"level": "niveau",
|
||||
|
||||
@@ -169,7 +169,8 @@
|
||||
"filter_single": "single",
|
||||
"rename": "zmień nazwę",
|
||||
"newVersionAvailable": "nowa wersja jest dostępna",
|
||||
"numberOfResults": "{{numberOfResults}} wyników"
|
||||
"numberOfResults": "{{numberOfResults}} wyników",
|
||||
"grouping": "grupowanie"
|
||||
},
|
||||
"entity": {
|
||||
"genre_one": "gatunek",
|
||||
@@ -420,6 +421,9 @@
|
||||
"export": "eksportuj tekst",
|
||||
"input_synced": "eksportuj zsynchronizowany tekst",
|
||||
"input_offset": "$t(setting.lyricOffset)"
|
||||
},
|
||||
"editRadioStation": {
|
||||
"success": "stacja radiowa zaktualizowana pomyślnie"
|
||||
}
|
||||
},
|
||||
"page": {
|
||||
@@ -1058,7 +1062,9 @@
|
||||
"sidePlayQueueLayout": "układ boczny kolejki odtwarzania",
|
||||
"sidePlayQueueLayout_description": "ustawia układ przyczepionej z boku kolejki odtwarzania",
|
||||
"sidePlayQueueLayout_optionHorizontal": "poziomy",
|
||||
"sidePlayQueueLayout_optionVertical": "pionowy"
|
||||
"sidePlayQueueLayout_optionVertical": "pionowy",
|
||||
"waveformLoadingDelay": "opóźnienie załadowania fali",
|
||||
"waveformLoadingDelay_description": "opóźnienie w sekundach przed załadowaniem fali. zwiększ tą wartość jeżeli doświadczasz zawieszania się odtwarzacza przeglądarkowego."
|
||||
},
|
||||
"table": {
|
||||
"config": {
|
||||
|
||||
@@ -161,7 +161,8 @@
|
||||
"rename": "重命名",
|
||||
"filter_multiple": "多项",
|
||||
"newVersionAvailable": "新版本现已可用",
|
||||
"numberOfResults": "{{numberOfResults}} 结果"
|
||||
"numberOfResults": "{{numberOfResults}} 结果",
|
||||
"grouping": "分组"
|
||||
},
|
||||
"entity": {
|
||||
"albumArtist_other": "专辑艺术家",
|
||||
@@ -609,7 +610,9 @@
|
||||
"sidePlayQueueLayout": "侧边播放队列布局",
|
||||
"sidePlayQueueLayout_description": "设置附加侧边播放队列的布局",
|
||||
"sidePlayQueueLayout_optionHorizontal": "水平",
|
||||
"sidePlayQueueLayout_optionVertical": "垂直"
|
||||
"sidePlayQueueLayout_optionVertical": "垂直",
|
||||
"waveformLoadingDelay": "波形加载延迟",
|
||||
"waveformLoadingDelay_description": "加载波形前的延迟时间(秒)。如果在使用网页播放器时遇到卡顿现象,请增加此值。"
|
||||
},
|
||||
"error": {
|
||||
"remotePortWarning": "重启服务器使新端口生效",
|
||||
|
||||
@@ -1124,6 +1124,9 @@
|
||||
"export": "匯出歌詞",
|
||||
"input_synced": "匯出同步歌詞",
|
||||
"input_offset": "$t(setting.lyricOffset)"
|
||||
},
|
||||
"editRadioStation": {
|
||||
"success": "電臺更新成功"
|
||||
}
|
||||
},
|
||||
"releaseType": {
|
||||
@@ -1332,6 +1335,8 @@
|
||||
"d": "D",
|
||||
"z": "Z"
|
||||
}
|
||||
}
|
||||
},
|
||||
"systemAudioCaptureFailed": "無法開始擷取:{{message}}",
|
||||
"systemAudioNoAudioTrack": "沒有回傳任何音軌。確保在提示時啟用音訊擷取。"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -437,10 +437,18 @@ ipcMain.on('player-mute', async (_event, mute: boolean) => {
|
||||
|
||||
ipcMain.handle('player-get-time', async (): Promise<number | undefined> => {
|
||||
try {
|
||||
return getMpvInstance()?.getTimePosition();
|
||||
const mpv = getMpvInstance();
|
||||
if (!mpv) {
|
||||
return undefined;
|
||||
}
|
||||
return await mpv.getTimePosition();
|
||||
} catch (err: any | NodeMpvError) {
|
||||
// Err 3: IPC command invalid — e.g. time-pos unavailable when idle / between tracks
|
||||
if (err?.errcode === 3) {
|
||||
return undefined;
|
||||
}
|
||||
mpvLog({ action: `Failed to get current time` }, err);
|
||||
return 0;
|
||||
return undefined;
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
+116
-8
@@ -5,6 +5,7 @@ import {
|
||||
app,
|
||||
BrowserWindow,
|
||||
BrowserWindowConstructorOptions,
|
||||
desktopCapturer,
|
||||
globalShortcut,
|
||||
ipcMain,
|
||||
Menu,
|
||||
@@ -29,7 +30,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 +42,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 +278,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 +341,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 +724,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 +733,22 @@ async function createWindow(first = true): Promise<void> {
|
||||
return { action: 'deny' };
|
||||
});
|
||||
|
||||
mainWindow.webContents.session.setDisplayMediaRequestHandler((_request, callback) => {
|
||||
desktopCapturer
|
||||
.getSources({ types: ['screen'] })
|
||||
.then((sources) => {
|
||||
if (sources.length > 0) {
|
||||
callback({ audio: 'loopback', video: sources[0] });
|
||||
} else {
|
||||
callback({});
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
log.warn('desktopCapturer.getSources failed', err);
|
||||
callback({});
|
||||
});
|
||||
});
|
||||
|
||||
if (!disableAutoUpdates() && store.get('disable_auto_updates') !== true) {
|
||||
new AppUpdater();
|
||||
}
|
||||
@@ -782,6 +819,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 +883,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 +1043,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
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -175,6 +189,20 @@ export const controller: GeneralController = {
|
||||
server.type,
|
||||
)?.(addContext({ ...args, apiClientProps: { ...args.apiClientProps, server } }));
|
||||
},
|
||||
deleteInternetRadioStationImage(args) {
|
||||
const server = getServerById(args.apiClientProps.serverId);
|
||||
|
||||
if (!server) {
|
||||
throw new Error(
|
||||
`${i18n.t('error.apiRouteError', { postProcess: 'sentenceCase' })}: deleteInternetRadioStationImage`,
|
||||
);
|
||||
}
|
||||
|
||||
return apiController(
|
||||
'deleteInternetRadioStationImage',
|
||||
server.type,
|
||||
)?.(addContext({ ...args, apiClientProps: { ...args.apiClientProps, server } }));
|
||||
},
|
||||
deletePlaylist(args) {
|
||||
const server = getServerById(args.apiClientProps.serverId);
|
||||
|
||||
@@ -189,6 +217,20 @@ export const controller: GeneralController = {
|
||||
server.type,
|
||||
)?.(addContext({ ...args, apiClientProps: { ...args.apiClientProps, server } }));
|
||||
},
|
||||
deletePlaylistImage(args) {
|
||||
const server = getServerById(args.apiClientProps.serverId);
|
||||
|
||||
if (!server) {
|
||||
throw new Error(
|
||||
`${i18n.t('error.apiRouteError', { postProcess: 'sentenceCase' })}: deletePlaylistImage`,
|
||||
);
|
||||
}
|
||||
|
||||
return apiController(
|
||||
'deletePlaylistImage',
|
||||
server.type,
|
||||
)?.(addContext({ ...args, apiClientProps: { ...args.apiClientProps, server } }));
|
||||
},
|
||||
getAlbumArtistDetail(args) {
|
||||
const server = getServerById(args.apiClientProps.serverId);
|
||||
|
||||
@@ -960,4 +1002,46 @@ 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);
|
||||
|
||||
if (!server) {
|
||||
throw new Error(
|
||||
`${i18n.t('error.apiRouteError', { postProcess: 'sentenceCase' })}: uploadInternetRadioStationImage`,
|
||||
);
|
||||
}
|
||||
|
||||
return apiController(
|
||||
'uploadInternetRadioStationImage',
|
||||
server.type,
|
||||
)?.(addContext({ ...args, apiClientProps: { ...args.apiClientProps, server } }));
|
||||
},
|
||||
uploadPlaylistImage(args) {
|
||||
const server = getServerById(args.apiClientProps.serverId);
|
||||
|
||||
if (!server) {
|
||||
throw new Error(
|
||||
`${i18n.t('error.apiRouteError', { postProcess: 'sentenceCase' })}: uploadPlaylistImage`,
|
||||
);
|
||||
}
|
||||
|
||||
return apiController(
|
||||
'uploadPlaylistImage',
|
||||
server.type,
|
||||
)?.(addContext({ ...args, apiClientProps: { ...args.apiClientProps, server } }));
|
||||
},
|
||||
};
|
||||
|
||||
@@ -46,6 +46,33 @@ 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',
|
||||
path: 'radio/:id',
|
||||
responses: {
|
||||
200: resultWithHeaders(ndType._response.deleteInternetRadioStation),
|
||||
500: resultWithHeaders(ndType._response.error),
|
||||
},
|
||||
},
|
||||
deleteInternetRadioStationImage: {
|
||||
body: null,
|
||||
method: 'DELETE',
|
||||
path: 'radio/:id/image',
|
||||
responses: {
|
||||
200: resultWithHeaders(ndType._response.deleteInternetRadioStationImage),
|
||||
500: resultWithHeaders(ndType._response.error),
|
||||
},
|
||||
},
|
||||
deletePlaylist: {
|
||||
body: null,
|
||||
method: 'DELETE',
|
||||
@@ -55,6 +82,15 @@ export const contract = c.router({
|
||||
500: resultWithHeaders(ndType._response.error),
|
||||
},
|
||||
},
|
||||
deletePlaylistImage: {
|
||||
body: null,
|
||||
method: 'DELETE',
|
||||
path: 'playlist/:id/image',
|
||||
responses: {
|
||||
200: resultWithHeaders(ndType._response.deletePlaylistImage),
|
||||
500: resultWithHeaders(ndType._response.error),
|
||||
},
|
||||
},
|
||||
getAlbumArtistDetail: {
|
||||
method: 'GET',
|
||||
path: 'artist/:id',
|
||||
@@ -132,6 +168,15 @@ export const contract = c.router({
|
||||
500: resultWithHeaders(ndType._response.error),
|
||||
},
|
||||
},
|
||||
getRadioList: {
|
||||
method: 'GET',
|
||||
path: 'radio',
|
||||
query: ndType._parameters.radioList,
|
||||
responses: {
|
||||
200: resultWithHeaders(ndType._response.radioList),
|
||||
500: resultWithHeaders(ndType._response.error),
|
||||
},
|
||||
},
|
||||
getSongDetail: {
|
||||
method: 'GET',
|
||||
path: 'song/:id',
|
||||
@@ -205,6 +250,15 @@ export const contract = c.router({
|
||||
500: resultWithHeaders(ndType._response.error),
|
||||
},
|
||||
},
|
||||
updateInternetRadioStation: {
|
||||
body: ndType._parameters.updateInternetRadioStation,
|
||||
method: 'PUT',
|
||||
path: 'radio/:id',
|
||||
responses: {
|
||||
200: resultWithHeaders(ndType._response.updateInternetRadioStation),
|
||||
500: resultWithHeaders(ndType._response.error),
|
||||
},
|
||||
},
|
||||
updatePlaylist: {
|
||||
body: ndType._parameters.updatePlaylist,
|
||||
method: 'PUT',
|
||||
@@ -214,6 +268,33 @@ 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',
|
||||
path: 'radio/:id/image',
|
||||
responses: {
|
||||
200: resultWithHeaders(ndType._response.uploadInternetRadioStationImage),
|
||||
500: resultWithHeaders(ndType._response.error),
|
||||
},
|
||||
},
|
||||
uploadPlaylistImage: {
|
||||
body: ndType._parameters.uploadPlaylistImage,
|
||||
method: 'POST',
|
||||
path: 'playlist/:id/image',
|
||||
responses: {
|
||||
200: resultWithHeaders(ndType._response.uploadPlaylistImage),
|
||||
500: resultWithHeaders(ndType._response.error),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const axiosClient = axios.create({});
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import axios from 'axios';
|
||||
import { set } from 'idb-keyval';
|
||||
import orderBy from 'lodash/orderBy';
|
||||
|
||||
@@ -5,13 +6,19 @@ import { ndApiClient } from '/@/renderer/api/navidrome/navidrome-api';
|
||||
import { ssApiClient } from '/@/renderer/api/subsonic/subsonic-api';
|
||||
import { SubsonicController } from '/@/renderer/api/subsonic/subsonic-controller';
|
||||
import { ndNormalize } from '/@/shared/api/navidrome/navidrome-normalize';
|
||||
import { NDSongListSort } from '/@/shared/api/navidrome/navidrome-types';
|
||||
import { NDRadioListSort, NDSongListSort } from '/@/shared/api/navidrome/navidrome-types';
|
||||
import { ssNormalize } from '/@/shared/api/subsonic/subsonic-normalize';
|
||||
import { getFeatures, hasFeature, hasFeatureWithVersion, VersionInfo } from '/@/shared/api/utils';
|
||||
import {
|
||||
albumArtistListSortMap,
|
||||
albumListSortMap,
|
||||
AuthenticationResponse,
|
||||
DeleteArtistImageArgs,
|
||||
DeleteArtistImageResponse,
|
||||
DeleteInternetRadioStationImageArgs,
|
||||
DeleteInternetRadioStationImageResponse,
|
||||
DeletePlaylistImageArgs,
|
||||
DeletePlaylistImageResponse,
|
||||
genreListSortMap,
|
||||
InternalControllerEndpoint,
|
||||
playlistListSortMap,
|
||||
@@ -23,6 +30,12 @@ import {
|
||||
SortOrder,
|
||||
sortOrderMap,
|
||||
tagListSortMap,
|
||||
UploadArtistImageArgs,
|
||||
UploadArtistImageResponse,
|
||||
UploadInternetRadioStationImageArgs,
|
||||
UploadInternetRadioStationImageResponse,
|
||||
UploadPlaylistImageArgs,
|
||||
UploadPlaylistImageResponse,
|
||||
userListSortMap,
|
||||
} from '/@/shared/types/domain-types';
|
||||
import { ServerFeature } from '/@/shared/types/features-types';
|
||||
@@ -30,6 +43,14 @@ import { ServerFeature } from '/@/shared/types/features-types';
|
||||
const VERSION_INFO: VersionInfo = [
|
||||
// Why 2? Subsonic controller will return 1 for its own implementation
|
||||
// Use 2 to denote that Navidrome's own API has a different endpoint
|
||||
[
|
||||
'0.61.0',
|
||||
{
|
||||
[ServerFeature.ARTIST_IMAGE_UPLOAD]: [1],
|
||||
[ServerFeature.INTERNET_RADIO_IMAGE_UPLOAD]: [1],
|
||||
[ServerFeature.PLAYLIST_IMAGE_UPLOAD]: [1],
|
||||
},
|
||||
],
|
||||
['0.60.4', { [ServerFeature.TRACK_YES_NO_RATING_FILTER]: [1] }],
|
||||
['0.57.0', { [ServerFeature.SERVER_PLAY_QUEUE]: [2] }],
|
||||
['0.56.0', { [ServerFeature.TRACK_ALBUM_ARTIST_SEARCH]: [1] }],
|
||||
@@ -170,8 +191,54 @@ 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: SubsonicController.deleteInternetRadioStation,
|
||||
deleteInternetRadioStation: async (args) => {
|
||||
const { apiClientProps, query } = args;
|
||||
|
||||
const res = await ndApiClient(apiClientProps).deleteInternetRadioStation({
|
||||
params: {
|
||||
id: query.id,
|
||||
},
|
||||
});
|
||||
|
||||
if (res.status !== 200) {
|
||||
throw new Error('Failed to delete internet radio station');
|
||||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
deleteInternetRadioStationImage: async (
|
||||
args: DeleteInternetRadioStationImageArgs,
|
||||
): Promise<DeleteInternetRadioStationImageResponse> => {
|
||||
const { apiClientProps, query } = args;
|
||||
|
||||
const res = await ndApiClient(apiClientProps as any).deleteInternetRadioStationImage({
|
||||
params: {
|
||||
id: query.id,
|
||||
},
|
||||
});
|
||||
|
||||
if (res.status !== 200) {
|
||||
throw new Error('Failed to delete internet radio station image');
|
||||
}
|
||||
|
||||
return res.body.data.status === 'ok';
|
||||
},
|
||||
deletePlaylist: async (args) => {
|
||||
const { apiClientProps, query } = args;
|
||||
|
||||
@@ -187,6 +254,23 @@ export const NavidromeController: InternalControllerEndpoint = {
|
||||
|
||||
return null;
|
||||
},
|
||||
deletePlaylistImage: async (
|
||||
args: DeletePlaylistImageArgs,
|
||||
): Promise<DeletePlaylistImageResponse> => {
|
||||
const { apiClientProps, query } = args;
|
||||
|
||||
const res = await ndApiClient(apiClientProps as any).deletePlaylistImage({
|
||||
params: {
|
||||
id: query.id,
|
||||
},
|
||||
});
|
||||
|
||||
if (res.status !== 200) {
|
||||
throw new Error('Failed to delete playlist image');
|
||||
}
|
||||
|
||||
return res.body.data.status === 'ok';
|
||||
},
|
||||
getAlbumArtistDetail: async (args) => {
|
||||
const { apiClientProps, query } = args;
|
||||
|
||||
@@ -233,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,
|
||||
@@ -547,7 +631,24 @@ export const NavidromeController: InternalControllerEndpoint = {
|
||||
},
|
||||
getImageRequest: SubsonicController.getImageRequest,
|
||||
getImageUrl: SubsonicController.getImageUrl,
|
||||
getInternetRadioStations: SubsonicController.getInternetRadioStations,
|
||||
getInternetRadioStations: async (args) => {
|
||||
const { apiClientProps } = args;
|
||||
|
||||
const res = await ndApiClient(apiClientProps).getRadioList({
|
||||
query: {
|
||||
_end: -1,
|
||||
_order: 'ASC',
|
||||
_sort: NDRadioListSort.NAME,
|
||||
_start: 0,
|
||||
},
|
||||
});
|
||||
|
||||
if (res.status !== 200) {
|
||||
throw new Error('Failed to get internet radio stations');
|
||||
}
|
||||
|
||||
return res.body.data.map((station) => ndNormalize.internetRadioStation(station));
|
||||
},
|
||||
getLyrics: SubsonicController.getLyrics,
|
||||
getMusicFolderList: SubsonicController.getMusicFolderList,
|
||||
getPlaylistDetail: async (args) => {
|
||||
@@ -1145,7 +1246,26 @@ export const NavidromeController: InternalControllerEndpoint = {
|
||||
id: res.body.data.id,
|
||||
};
|
||||
},
|
||||
updateInternetRadioStation: SubsonicController.updateInternetRadioStation,
|
||||
updateInternetRadioStation: async (args) => {
|
||||
const { apiClientProps, body, query } = args;
|
||||
|
||||
const res = await ndApiClient(apiClientProps).updateInternetRadioStation({
|
||||
body: {
|
||||
homePageUrl: body.homepageUrl ?? '',
|
||||
name: body.name,
|
||||
streamUrl: body.streamUrl,
|
||||
},
|
||||
params: {
|
||||
id: query.id,
|
||||
},
|
||||
});
|
||||
|
||||
if (res.status !== 200) {
|
||||
throw new Error('Failed to update internet radio station');
|
||||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
updatePlaylist: async (args) => {
|
||||
const { apiClientProps, body, query } = args;
|
||||
|
||||
@@ -1170,4 +1290,110 @@ 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> => {
|
||||
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/radio/${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 internet radio station image');
|
||||
}
|
||||
|
||||
return res.data?.status === 'ok';
|
||||
},
|
||||
uploadPlaylistImage: async (
|
||||
args: UploadPlaylistImageArgs,
|
||||
): Promise<UploadPlaylistImageResponse> => {
|
||||
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/playlist/${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 playlist image');
|
||||
}
|
||||
|
||||
return res.data?.status === 'ok';
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
@@ -1185,7 +1206,7 @@ export const SubsonicController: InternalControllerEndpoint = {
|
||||
return ssNormalize.playlist(res.body.playlist, apiClientProps.server);
|
||||
},
|
||||
getPlaylistList: async ({ apiClientProps, query }) => {
|
||||
const sortOrder = query.sortOrder.toLowerCase() as 'asc' | 'desc';
|
||||
const sortOrder = (query.sortOrder || SortOrder.ASC).toLowerCase() as 'asc' | 'desc';
|
||||
|
||||
const res = await ssApiClient(apiClientProps).getPlaylists({});
|
||||
|
||||
@@ -2013,20 +2034,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;
|
||||
|
||||
+102
-56
@@ -7,12 +7,12 @@ import '@mantine/core/styles.css';
|
||||
import '@mantine/dates/styles.css';
|
||||
import '@mantine/notifications/styles.css';
|
||||
import isElectron from 'is-electron';
|
||||
import { lazy, Suspense, useEffect, useMemo, useRef, useState } from 'react';
|
||||
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';
|
||||
@@ -38,67 +38,26 @@ const UpdateAvailableDialog = lazy(() =>
|
||||
const ipc = isElectron() ? window.api.ipc : null;
|
||||
|
||||
export const App = () => {
|
||||
return <ThemedApp />;
|
||||
};
|
||||
|
||||
const ThemedApp = () => {
|
||||
const { mode, theme } = useAppTheme();
|
||||
const language = useLanguage();
|
||||
|
||||
const { content, enabled } = useCssSettings();
|
||||
const { bindings } = useHotkeySettings();
|
||||
const cssRef = useRef<HTMLStyleElement | null>(null);
|
||||
|
||||
useSyncSettingsToMain();
|
||||
useCheckForUpdates();
|
||||
return (
|
||||
<MantineProvider forceColorScheme={mode} theme={theme}>
|
||||
<AppShell />
|
||||
</MantineProvider>
|
||||
);
|
||||
};
|
||||
|
||||
const AppShell = memo(function AppShell() {
|
||||
const [webAudio, setWebAudio] = useState<WebAudio>();
|
||||
|
||||
useEffect(() => {
|
||||
if (enabled && content) {
|
||||
// Yes, CSS is sanitized here as well. Prevent a suer from changing the
|
||||
// localStorage to bypass sanitizing.
|
||||
const sanitized = sanitizeCss(content);
|
||||
if (!cssRef.current) {
|
||||
cssRef.current = document.createElement('style');
|
||||
document.body.appendChild(cssRef.current);
|
||||
}
|
||||
|
||||
cssRef.current.textContent = sanitized;
|
||||
|
||||
return () => {
|
||||
cssRef.current!.textContent = '';
|
||||
};
|
||||
}
|
||||
|
||||
return () => {};
|
||||
}, [content, enabled]);
|
||||
|
||||
const webAudioProvider = useMemo(() => {
|
||||
return { setWebAudio, webAudio };
|
||||
}, [webAudio]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isElectron()) {
|
||||
ipc?.send('set-global-shortcuts', bindings);
|
||||
}
|
||||
}, [bindings]);
|
||||
|
||||
useEffect(() => {
|
||||
if (language) {
|
||||
i18n.changeLanguage(language);
|
||||
}
|
||||
}, [language]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isElectron()) {
|
||||
window.api.utils.rendererOpenSettings(() => {
|
||||
openSettingsModal();
|
||||
});
|
||||
|
||||
return () => {
|
||||
ipc?.removeAllListeners('renderer-open-settings');
|
||||
};
|
||||
}
|
||||
return undefined;
|
||||
}, []);
|
||||
|
||||
const notificationStyles = useMemo(
|
||||
() => ({
|
||||
root: {
|
||||
@@ -109,7 +68,8 @@ export const App = () => {
|
||||
);
|
||||
|
||||
return (
|
||||
<MantineProvider forceColorScheme={mode} theme={theme}>
|
||||
<>
|
||||
<AppEffects />
|
||||
<Notifications
|
||||
containerWidth="300px"
|
||||
position="bottom-center"
|
||||
@@ -126,6 +86,92 @@ export const App = () => {
|
||||
<ReleaseNotesModal />
|
||||
<UpdateAvailableDialog />
|
||||
</Suspense>
|
||||
</MantineProvider>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
const AppEffects = () => (
|
||||
<>
|
||||
<SyncSettingsEffect />
|
||||
<UpdateCheckEffect />
|
||||
<CssSettingsEffect />
|
||||
<GlobalShortcutsEffect />
|
||||
<LanguageEffect />
|
||||
<NativeMenuSyncEffect />
|
||||
</>
|
||||
);
|
||||
|
||||
const SyncSettingsEffect = () => {
|
||||
useSyncSettingsToMain();
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const UpdateCheckEffect = () => {
|
||||
useCheckForUpdates();
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const CssSettingsEffect = () => {
|
||||
const { content, enabled } = useCssSettings();
|
||||
const cssRef = useRef<HTMLStyleElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled || !content) {
|
||||
if (cssRef.current) {
|
||||
cssRef.current.textContent = '';
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Yes, CSS is sanitized here as well. Prevent a user from changing the
|
||||
// localStorage to bypass sanitizing.
|
||||
const sanitized = sanitizeCss(content);
|
||||
if (!cssRef.current) {
|
||||
cssRef.current = document.createElement('style');
|
||||
document.body.appendChild(cssRef.current);
|
||||
}
|
||||
|
||||
cssRef.current.textContent = sanitized;
|
||||
|
||||
return () => {
|
||||
if (cssRef.current) {
|
||||
cssRef.current.textContent = '';
|
||||
}
|
||||
};
|
||||
}, [content, enabled]);
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const GlobalShortcutsEffect = () => {
|
||||
const { bindings } = useHotkeySettings();
|
||||
|
||||
useEffect(() => {
|
||||
if (isElectron()) {
|
||||
ipc?.send('set-global-shortcuts', bindings);
|
||||
}
|
||||
}, [bindings]);
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const LanguageEffect = () => {
|
||||
const language = useLanguage();
|
||||
|
||||
useEffect(() => {
|
||||
if (language) {
|
||||
i18n.changeLanguage(language);
|
||||
}
|
||||
}, [language]);
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const NativeMenuSyncEffect = () => {
|
||||
useNativeMenuSync();
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
@@ -36,12 +36,16 @@
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.grid-carousel-viewport {
|
||||
width: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(var(--cards-to-show, 2), minmax(0, 1fr));
|
||||
gap: var(--theme-spacing-md);
|
||||
contain: layout paint;
|
||||
content-visibility: auto;
|
||||
overflow: hidden;
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
@@ -22,9 +22,9 @@ import { AppRoute } from '/@/renderer/router/routes';
|
||||
import { useShowRatings } from '/@/renderer/store';
|
||||
import {
|
||||
formatDateAbsolute,
|
||||
formatDateAbsoluteUTC,
|
||||
formatDateRelative,
|
||||
formatDurationString,
|
||||
formatPartialIsoDateUTC,
|
||||
formatRating,
|
||||
} from '/@/renderer/utils/format';
|
||||
import { SEPARATOR_STRING } from '/@/shared/api/utils';
|
||||
@@ -1161,12 +1161,10 @@ export const getDataRows = (type?: 'compact' | 'default' | 'poster'): DataRow[]
|
||||
},
|
||||
{
|
||||
format: (data) => {
|
||||
if ('releaseYear' in data && data.releaseYear !== null) {
|
||||
if ('releaseYear' in data && data.releaseYear != null) {
|
||||
const releaseYear = data.releaseYear;
|
||||
const originalYear =
|
||||
'originalYear' in data && data.originalYear !== null
|
||||
? data.originalYear
|
||||
: null;
|
||||
'originalYear' in data && data.originalYear > 0 ? data.originalYear : null;
|
||||
|
||||
if (originalYear !== null && originalYear !== releaseYear) {
|
||||
return `${originalYear}${SEPARATOR_STRING}${releaseYear}`;
|
||||
@@ -1186,10 +1184,10 @@ export const getDataRows = (type?: 'compact' | 'default' | 'poster'): DataRow[]
|
||||
data.originalDate &&
|
||||
data.originalDate !== data.releaseDate
|
||||
) {
|
||||
return `${formatDateAbsoluteUTC(data.originalDate)}${SEPARATOR_STRING}${formatDateAbsoluteUTC(data.releaseDate)}`;
|
||||
return `${formatPartialIsoDateUTC(data.originalDate)}${SEPARATOR_STRING}${formatPartialIsoDateUTC(data.releaseDate)}`;
|
||||
}
|
||||
|
||||
return `${formatDateAbsoluteUTC(data.releaseDate)}`;
|
||||
return `${formatPartialIsoDateUTC(data.releaseDate)}`;
|
||||
}
|
||||
return '';
|
||||
},
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -1,6 +1,21 @@
|
||||
import { ItemDetailListCellProps } from './types';
|
||||
|
||||
import { formatDateAbsoluteUTC } from '/@/renderer/utils/format';
|
||||
import { formatPartialIsoDateUTC } from '/@/renderer/utils/format';
|
||||
import { SEPARATOR_STRING } from '/@/shared/api/utils';
|
||||
|
||||
export const ReleaseDateColumn = ({ song }: ItemDetailListCellProps) =>
|
||||
song.releaseDate ? formatDateAbsoluteUTC(song.releaseDate) : <> </>;
|
||||
export const ReleaseDateColumn = ({ song }: ItemDetailListCellProps) => {
|
||||
const row = song as typeof song & { originalDate?: null | string };
|
||||
const releaseDate = row.releaseDate;
|
||||
if (!releaseDate) {
|
||||
return <> </>;
|
||||
}
|
||||
|
||||
const originalDate =
|
||||
row.originalDate && row.originalDate !== releaseDate ? row.originalDate : null;
|
||||
|
||||
if (originalDate) {
|
||||
return `${formatPartialIsoDateUTC(originalDate)}${SEPARATOR_STRING}${formatPartialIsoDateUTC(releaseDate)}`;
|
||||
}
|
||||
|
||||
return formatPartialIsoDateUTC(releaseDate);
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -67,7 +67,7 @@ import { songsQueries } from '/@/renderer/features/songs/api/songs-api';
|
||||
import { useDragDrop } from '/@/renderer/hooks/use-drag-drop';
|
||||
import { AppRoute } from '/@/renderer/router/routes';
|
||||
import { useSettingsStore, useShowRatings } from '/@/renderer/store';
|
||||
import { formatDateAbsoluteUTC, formatDurationString } from '/@/renderer/utils';
|
||||
import { formatDurationString, formatPartialIsoDateUTC } from '/@/renderer/utils';
|
||||
import { SEPARATOR_STRING } from '/@/shared/api/utils';
|
||||
import { ExplicitIndicator } from '/@/shared/components/explicit-indicator/explicit-indicator';
|
||||
import { Skeleton } from '/@/shared/components/skeleton/skeleton';
|
||||
@@ -489,9 +489,9 @@ const MetadataSection = memo(
|
||||
let releaseStr = '';
|
||||
if (item.releaseDate) {
|
||||
if (item.originalDate && item.originalDate !== item.releaseDate) {
|
||||
releaseStr = `${formatDateAbsoluteUTC(item.originalDate)}${SEPARATOR_STRING}${formatDateAbsoluteUTC(item.releaseDate)}`;
|
||||
releaseStr = `${formatPartialIsoDateUTC(item.originalDate)}${SEPARATOR_STRING}${formatPartialIsoDateUTC(item.releaseDate)}`;
|
||||
} else {
|
||||
releaseStr = formatDateAbsoluteUTC(item.releaseDate);
|
||||
releaseStr = formatPartialIsoDateUTC(item.releaseDate);
|
||||
}
|
||||
} else if (item.releaseYear != null) {
|
||||
releaseStr = String(item.releaseYear);
|
||||
@@ -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;
|
||||
|
||||
@@ -20,7 +20,8 @@ export const createColumnCellComponent = (
|
||||
prevProps.columnIndex === nextProps.columnIndex &&
|
||||
prevProps.data === nextProps.data &&
|
||||
prevProps.style === nextProps.style &&
|
||||
prevProps.columns === nextProps.columns
|
||||
prevProps.columns === nextProps.columns &&
|
||||
prevProps.playlistId === nextProps.playlistId
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -8,49 +8,25 @@ import {
|
||||
} from '/@/renderer/components/item-list/item-table-list/item-table-list-column';
|
||||
import {
|
||||
formatDateAbsolute,
|
||||
formatDateAbsoluteUTC,
|
||||
formatDateRelative,
|
||||
formatHrDateTime,
|
||||
formatPartialIsoDateUTC,
|
||||
} from '/@/renderer/utils/format';
|
||||
import { SEPARATOR_STRING } from '/@/shared/api/utils';
|
||||
import { Stack } from '/@/shared/components/stack/stack';
|
||||
import { Text } from '/@/shared/components/text/text';
|
||||
import { Tooltip } from '/@/shared/components/tooltip/tooltip';
|
||||
import { TableColumn } from '/@/shared/types/types';
|
||||
|
||||
const getDateTooltipLabel = (utcString: string) => {
|
||||
return (
|
||||
<Stack gap="xs" justify="center">
|
||||
<Text size="md" ta="center">
|
||||
{formatHrDateTime(utcString)}
|
||||
</Text>
|
||||
<Text isMuted size="sm" ta="center">
|
||||
{utcString}
|
||||
</Text>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
const DateColumnBase = (props: ItemTableListInnerColumn) => {
|
||||
const rowItem = props.getRowItem?.(props.rowIndex) ?? (props.data as any[])[props.rowIndex];
|
||||
const row: string | undefined = (rowItem as any)?.[props.columns[props.columnIndex].id];
|
||||
|
||||
const { formattedDate, tooltipLabel } = useMemo(() => {
|
||||
if (typeof row === 'string' && row) {
|
||||
return {
|
||||
formattedDate: formatDateAbsolute(row),
|
||||
tooltipLabel: getDateTooltipLabel(row),
|
||||
};
|
||||
}
|
||||
return { formattedDate: null, tooltipLabel: null };
|
||||
}, [row]);
|
||||
const formattedAbsolute = useMemo(
|
||||
() => (typeof row === 'string' && row ? formatDateAbsolute(row) : null),
|
||||
[row],
|
||||
);
|
||||
|
||||
if (typeof row === 'string' && row) {
|
||||
if (formattedAbsolute) {
|
||||
return (
|
||||
<TableColumnTextContainer {...props}>
|
||||
<Tooltip label={tooltipLabel} multiline={false}>
|
||||
<span>{formattedDate}</span>
|
||||
</Tooltip>
|
||||
<span>{formattedAbsolute}</span>
|
||||
</TableColumnTextContainer>
|
||||
);
|
||||
}
|
||||
@@ -79,44 +55,37 @@ const AbsoluteDateColumnBase = (props: ItemTableListInnerColumn) => {
|
||||
: null;
|
||||
|
||||
if (originalDate) {
|
||||
const formattedOriginalDate = formatDateAbsoluteUTC(originalDate);
|
||||
const formattedReleaseDate = formatDateAbsoluteUTC(releaseDate);
|
||||
const displayText = `${formattedOriginalDate}${SEPARATOR_STRING}${formattedReleaseDate}`;
|
||||
|
||||
return {
|
||||
displayText,
|
||||
tooltipLabel: getDateTooltipLabel(releaseDate),
|
||||
};
|
||||
const formattedOriginalDate = formatPartialIsoDateUTC(originalDate);
|
||||
const formattedReleaseDate = formatPartialIsoDateUTC(releaseDate);
|
||||
return `${formattedOriginalDate}${SEPARATOR_STRING}${formattedReleaseDate}`;
|
||||
}
|
||||
|
||||
if (typeof releaseDate === 'string' && releaseDate) {
|
||||
return {
|
||||
displayText: formatDateAbsoluteUTC(releaseDate),
|
||||
tooltipLabel: getDateTooltipLabel(releaseDate),
|
||||
};
|
||||
return formatPartialIsoDateUTC(releaseDate);
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}, [props.type, rowItem]);
|
||||
|
||||
const { formattedDate, tooltipLabel } = useMemo(() => {
|
||||
if (typeof row === 'string' && row) {
|
||||
return {
|
||||
formattedDate: formatDateAbsoluteUTC(row),
|
||||
tooltipLabel: getDateTooltipLabel(row),
|
||||
};
|
||||
}
|
||||
return { formattedDate: null, tooltipLabel: null };
|
||||
}, [row]);
|
||||
const formattedIsoFallback = useMemo(
|
||||
() => (typeof row === 'string' && row ? formatPartialIsoDateUTC(row) : null),
|
||||
[row],
|
||||
);
|
||||
|
||||
if (props.type === TableColumn.RELEASE_DATE) {
|
||||
if (releaseDateContent) {
|
||||
return (
|
||||
<TableColumnTextContainer {...props}>
|
||||
<Tooltip label={releaseDateContent.tooltipLabel} multiline={false}>
|
||||
<span>{releaseDateContent.displayText}</span>
|
||||
</Tooltip>
|
||||
<span>{releaseDateContent}</span>
|
||||
</TableColumnTextContainer>
|
||||
);
|
||||
}
|
||||
|
||||
if (formattedIsoFallback) {
|
||||
return (
|
||||
<TableColumnTextContainer {...props}>
|
||||
<span>{formattedIsoFallback}</span>
|
||||
</TableColumnTextContainer>
|
||||
);
|
||||
}
|
||||
@@ -128,20 +97,6 @@ const AbsoluteDateColumnBase = (props: ItemTableListInnerColumn) => {
|
||||
return <ColumnSkeletonFixed {...props} />;
|
||||
}
|
||||
|
||||
if (typeof row === 'string' && row) {
|
||||
return (
|
||||
<TableColumnTextContainer {...props}>
|
||||
<Tooltip label={tooltipLabel} multiline={false}>
|
||||
<span>{formattedDate}</span>
|
||||
</Tooltip>
|
||||
</TableColumnTextContainer>
|
||||
);
|
||||
}
|
||||
|
||||
if (row === null) {
|
||||
return <ColumnNullFallback {...props} />;
|
||||
}
|
||||
|
||||
return <ColumnSkeletonFixed {...props} />;
|
||||
};
|
||||
|
||||
@@ -151,22 +106,15 @@ const RelativeDateColumnBase = (props: ItemTableListInnerColumn) => {
|
||||
const rowItem = props.getRowItem?.(props.rowIndex) ?? (props.data as any[])[props.rowIndex];
|
||||
const row: string | undefined = (rowItem as any)?.[props.columns[props.columnIndex].id];
|
||||
|
||||
const { formattedDate, tooltipLabel } = useMemo(() => {
|
||||
if (typeof row === 'string') {
|
||||
return {
|
||||
formattedDate: formatDateRelative(row),
|
||||
tooltipLabel: getDateTooltipLabel(row),
|
||||
};
|
||||
}
|
||||
return { formattedDate: null, tooltipLabel: null };
|
||||
const formattedRelative = useMemo(() => {
|
||||
if (typeof row !== 'string') return null;
|
||||
return formatDateRelative(row);
|
||||
}, [row]);
|
||||
|
||||
if (typeof row === 'string') {
|
||||
if (formattedRelative !== null) {
|
||||
return (
|
||||
<TableColumnTextContainer {...props}>
|
||||
<Tooltip label={tooltipLabel} multiline={false}>
|
||||
<span>{formattedDate}</span>
|
||||
</Tooltip>
|
||||
<span>{formattedRelative}</span>
|
||||
</TableColumnTextContainer>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import clsx from 'clsx';
|
||||
import { useMemo } from 'react';
|
||||
import { Link } from 'react-router';
|
||||
|
||||
import styles from './title-column.module.css';
|
||||
@@ -35,8 +36,12 @@ function DefaultTitleColumn(props: ItemTableListInnerColumn) {
|
||||
const rowItem = props.getRowItem?.(props.rowIndex) ?? (props.data as any[])[props.rowIndex];
|
||||
const row: string | undefined = rowItem?.[props.columns[props.columnIndex].id];
|
||||
|
||||
const path = useMemo(() => {
|
||||
if (typeof row !== 'string' || !rowItem || !(rowItem as any).id) return undefined;
|
||||
return getTitlePath(props.itemType, (rowItem as any).id as string);
|
||||
}, [props.itemType, row, rowItem]);
|
||||
|
||||
if (typeof row === 'string') {
|
||||
const path = getTitlePath(props.itemType, (rowItem as any).id as string);
|
||||
const item = rowItem as any;
|
||||
|
||||
const titleLinkProps = path
|
||||
@@ -80,8 +85,12 @@ function QueueSongTitleColumn(props: ItemTableListInnerColumn) {
|
||||
const song = rowItem as QueueSong;
|
||||
const isActive = useIsActiveRow(song?.id, song?._uniqueId);
|
||||
|
||||
const path = useMemo(() => {
|
||||
if (typeof row !== 'string' || !rowItem || !(rowItem as any).id) return undefined;
|
||||
return getTitlePath(props.itemType, (rowItem as any).id as string);
|
||||
}, [props.itemType, row, rowItem]);
|
||||
|
||||
if (typeof row === 'string') {
|
||||
const path = getTitlePath(props.itemType, (rowItem as any).id as string);
|
||||
const item = rowItem as any;
|
||||
|
||||
const titleLinkProps = path
|
||||
|
||||
@@ -13,10 +13,10 @@ const YearColumnBase = (props: ItemTableListInnerColumn) => {
|
||||
const item = rowItem as any;
|
||||
|
||||
const yearDisplay = useMemo(() => {
|
||||
if (item && 'releaseYear' in item && item.releaseYear !== null) {
|
||||
if (item && 'releaseYear' in item && item.releaseYear != null) {
|
||||
const releaseYear = item.releaseYear;
|
||||
const originalYear =
|
||||
'originalYear' in item && item.originalYear !== null ? item.originalYear : null;
|
||||
'originalYear' in item && item.originalYear > 0 ? item.originalYear : null;
|
||||
|
||||
if (originalYear !== null && originalYear !== releaseYear) {
|
||||
return `${originalYear}${SEPARATOR_STRING}${releaseYear}`;
|
||||
|
||||
+237
-225
@@ -34,256 +34,268 @@ export const useItemDragDropState = <TElement extends HTMLElement = HTMLDivEleme
|
||||
}: UseItemDragDropStateProps): DragDropState<TElement> => {
|
||||
const shouldEnableDrag = enableDrag && isDataRow && !!item;
|
||||
|
||||
const needsDropRegistration =
|
||||
shouldEnableDrag &&
|
||||
(itemType === LibraryItem.QUEUE_SONG || itemType === LibraryItem.PLAYLIST_SONG);
|
||||
|
||||
const {
|
||||
isDraggedOver,
|
||||
isDragging: isDraggingLocal,
|
||||
ref: dragRef,
|
||||
} = useDragDrop<TElement>({
|
||||
drag: {
|
||||
getId: () => {
|
||||
if (!item || !isDataRow) {
|
||||
return [];
|
||||
}
|
||||
drag: shouldEnableDrag
|
||||
? {
|
||||
getId: () => {
|
||||
if (!item || !isDataRow) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const draggedItems = getDraggedItems(item as any, internalState);
|
||||
const draggedItems = getDraggedItems(item as any, internalState);
|
||||
|
||||
return draggedItems.map((draggedItem) => draggedItem.id);
|
||||
},
|
||||
getItem: () => {
|
||||
if (!item || !isDataRow) {
|
||||
return [];
|
||||
}
|
||||
return draggedItems.map((draggedItem) => draggedItem.id);
|
||||
},
|
||||
getItem: () => {
|
||||
if (!item || !isDataRow) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const draggedItems = getDraggedItems(item as any, internalState);
|
||||
const draggedItems = getDraggedItems(item as any, internalState);
|
||||
|
||||
return draggedItems;
|
||||
},
|
||||
itemType,
|
||||
onDragStart: () => {
|
||||
if (!item || !isDataRow) {
|
||||
return;
|
||||
}
|
||||
return draggedItems;
|
||||
},
|
||||
itemType,
|
||||
onDragStart: () => {
|
||||
if (!item || !isDataRow) {
|
||||
return;
|
||||
}
|
||||
|
||||
const draggedItems = getDraggedItems(item as any, internalState);
|
||||
if (internalState) {
|
||||
internalState.setDragging(draggedItems);
|
||||
}
|
||||
},
|
||||
onDrop: () => {
|
||||
if (internalState) {
|
||||
internalState.setDragging([]);
|
||||
}
|
||||
},
|
||||
operation:
|
||||
itemType === LibraryItem.QUEUE_SONG
|
||||
? [DragOperation.REORDER, DragOperation.ADD]
|
||||
: itemType === LibraryItem.PLAYLIST_SONG
|
||||
? [DragOperation.REORDER, DragOperation.ADD]
|
||||
: [DragOperation.ADD],
|
||||
target: DragTargetMap[itemType] || DragTarget.GENERIC,
|
||||
},
|
||||
drop: {
|
||||
canDrop: (args) => {
|
||||
if (args.source.type === DragTarget.TABLE_COLUMN) {
|
||||
return false;
|
||||
}
|
||||
const draggedItems = getDraggedItems(item as any, internalState);
|
||||
if (internalState) {
|
||||
internalState.setDragging(draggedItems);
|
||||
}
|
||||
},
|
||||
onDrop: () => {
|
||||
if (internalState) {
|
||||
internalState.setDragging([]);
|
||||
}
|
||||
},
|
||||
operation:
|
||||
itemType === LibraryItem.QUEUE_SONG
|
||||
? [DragOperation.REORDER, DragOperation.ADD]
|
||||
: itemType === LibraryItem.PLAYLIST_SONG
|
||||
? [DragOperation.REORDER, DragOperation.ADD]
|
||||
: [DragOperation.ADD],
|
||||
target: DragTargetMap[itemType] || DragTarget.GENERIC,
|
||||
}
|
||||
: undefined,
|
||||
drop: needsDropRegistration
|
||||
? {
|
||||
canDrop: (args) => {
|
||||
if (args.source.type === DragTarget.TABLE_COLUMN) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Allow drops for QUEUE_SONG (queue reordering)
|
||||
if (itemType === LibraryItem.QUEUE_SONG) {
|
||||
return true;
|
||||
}
|
||||
// Allow drops for QUEUE_SONG (queue reordering)
|
||||
if (itemType === LibraryItem.QUEUE_SONG) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Allow drops for PLAYLIST_SONG (playlist reordering)
|
||||
// Only allow drops when drag is started from the reorder handle
|
||||
if (
|
||||
itemType === LibraryItem.PLAYLIST_SONG &&
|
||||
args.source.itemType === LibraryItem.PLAYLIST_SONG &&
|
||||
args.source.metadata?.fromReorderHandle === true
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
// Allow drops for PLAYLIST_SONG (playlist reordering)
|
||||
// Only allow drops when drag is started from the reorder handle
|
||||
if (
|
||||
itemType === LibraryItem.PLAYLIST_SONG &&
|
||||
args.source.itemType === LibraryItem.PLAYLIST_SONG &&
|
||||
args.source.metadata?.fromReorderHandle === true
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
},
|
||||
getData: () => {
|
||||
return {
|
||||
id: [(item as unknown as { id: string }).id],
|
||||
item: [item as unknown as unknown[]],
|
||||
itemType,
|
||||
type: DragTargetMap[itemType] || DragTarget.GENERIC,
|
||||
};
|
||||
},
|
||||
onDrag: () => {
|
||||
return;
|
||||
},
|
||||
onDragLeave: () => {
|
||||
return;
|
||||
},
|
||||
onDrop: (args) => {
|
||||
if (args.self.type === DragTarget.QUEUE_SONG) {
|
||||
const sourceServerId = (
|
||||
args.source.item?.[0] as unknown as { _serverId: string }
|
||||
)._serverId;
|
||||
return false;
|
||||
},
|
||||
getData: () => {
|
||||
return {
|
||||
id: [(item as unknown as { id: string }).id],
|
||||
item: [item as unknown as unknown[]],
|
||||
itemType,
|
||||
type: DragTargetMap[itemType] || DragTarget.GENERIC,
|
||||
};
|
||||
},
|
||||
onDrag: () => {
|
||||
return;
|
||||
},
|
||||
onDragLeave: () => {
|
||||
return;
|
||||
},
|
||||
onDrop: (args) => {
|
||||
if (args.self.type === DragTarget.QUEUE_SONG) {
|
||||
const sourceServerId = (
|
||||
args.source.item?.[0] as unknown as { _serverId: string }
|
||||
)._serverId;
|
||||
|
||||
const sourceItemType = args.source.itemType as LibraryItem;
|
||||
const sourceItemType = args.source.itemType as LibraryItem;
|
||||
|
||||
const droppedOnUniqueId = (
|
||||
args.self.item?.[0] as unknown as { _uniqueId: string }
|
||||
)._uniqueId;
|
||||
const droppedOnUniqueId = (
|
||||
args.self.item?.[0] as unknown as { _uniqueId: string }
|
||||
)._uniqueId;
|
||||
|
||||
switch (args.source.type) {
|
||||
case DragTarget.ALBUM: {
|
||||
playerContext.addToQueueByFetch(
|
||||
sourceServerId,
|
||||
args.source.id,
|
||||
sourceItemType,
|
||||
{ edge: args.edge, uniqueId: droppedOnUniqueId },
|
||||
);
|
||||
break;
|
||||
}
|
||||
case DragTarget.ALBUM_ARTIST: {
|
||||
playerContext.addToQueueByFetch(
|
||||
sourceServerId,
|
||||
args.source.id,
|
||||
sourceItemType,
|
||||
{ edge: args.edge, uniqueId: droppedOnUniqueId },
|
||||
);
|
||||
break;
|
||||
}
|
||||
case DragTarget.ARTIST: {
|
||||
playerContext.addToQueueByFetch(
|
||||
sourceServerId,
|
||||
args.source.id,
|
||||
sourceItemType,
|
||||
{ edge: args.edge, uniqueId: droppedOnUniqueId },
|
||||
);
|
||||
break;
|
||||
}
|
||||
case DragTarget.FOLDER: {
|
||||
const items = args.source.item;
|
||||
switch (args.source.type) {
|
||||
case DragTarget.ALBUM: {
|
||||
playerContext.addToQueueByFetch(
|
||||
sourceServerId,
|
||||
args.source.id,
|
||||
sourceItemType,
|
||||
{ edge: args.edge, uniqueId: droppedOnUniqueId },
|
||||
);
|
||||
break;
|
||||
}
|
||||
case DragTarget.ALBUM_ARTIST: {
|
||||
playerContext.addToQueueByFetch(
|
||||
sourceServerId,
|
||||
args.source.id,
|
||||
sourceItemType,
|
||||
{ edge: args.edge, uniqueId: droppedOnUniqueId },
|
||||
);
|
||||
break;
|
||||
}
|
||||
case DragTarget.ARTIST: {
|
||||
playerContext.addToQueueByFetch(
|
||||
sourceServerId,
|
||||
args.source.id,
|
||||
sourceItemType,
|
||||
{ edge: args.edge, uniqueId: droppedOnUniqueId },
|
||||
);
|
||||
break;
|
||||
}
|
||||
case DragTarget.FOLDER: {
|
||||
const items = args.source.item;
|
||||
|
||||
const { folders, songs } = (items || []).reduce<{
|
||||
folders: Folder[];
|
||||
songs: Song[];
|
||||
}>(
|
||||
(acc, item) => {
|
||||
if ((item as unknown as Song)._itemType === LibraryItem.SONG) {
|
||||
acc.songs.push(item as unknown as Song);
|
||||
} else if (
|
||||
(item as unknown as Folder)._itemType === LibraryItem.FOLDER
|
||||
) {
|
||||
acc.folders.push(item as unknown as Folder);
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
{ folders: [], songs: [] },
|
||||
);
|
||||
const { folders, songs } = (items || []).reduce<{
|
||||
folders: Folder[];
|
||||
songs: Song[];
|
||||
}>(
|
||||
(acc, item) => {
|
||||
if (
|
||||
(item as unknown as Song)._itemType ===
|
||||
LibraryItem.SONG
|
||||
) {
|
||||
acc.songs.push(item as unknown as Song);
|
||||
} else if (
|
||||
(item as unknown as Folder)._itemType ===
|
||||
LibraryItem.FOLDER
|
||||
) {
|
||||
acc.folders.push(item as unknown as Folder);
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
{ folders: [], songs: [] },
|
||||
);
|
||||
|
||||
const folderIds = folders.map((folder) => folder.id);
|
||||
const folderIds = folders.map((folder) => folder.id);
|
||||
|
||||
// Handle folders: fetch and add to queue
|
||||
if (folderIds.length > 0) {
|
||||
playerContext.addToQueueByFetch(
|
||||
sourceServerId,
|
||||
folderIds,
|
||||
LibraryItem.FOLDER,
|
||||
{ edge: args.edge, uniqueId: droppedOnUniqueId },
|
||||
);
|
||||
}
|
||||
// Handle folders: fetch and add to queue
|
||||
if (folderIds.length > 0) {
|
||||
playerContext.addToQueueByFetch(
|
||||
sourceServerId,
|
||||
folderIds,
|
||||
LibraryItem.FOLDER,
|
||||
{ edge: args.edge, uniqueId: droppedOnUniqueId },
|
||||
);
|
||||
}
|
||||
|
||||
// Handle songs: add directly to queue
|
||||
if (songs.length > 0) {
|
||||
playerContext.addToQueueByData(songs, {
|
||||
edge: args.edge,
|
||||
uniqueId: droppedOnUniqueId,
|
||||
});
|
||||
}
|
||||
// Handle songs: add directly to queue
|
||||
if (songs.length > 0) {
|
||||
playerContext.addToQueueByData(songs, {
|
||||
edge: args.edge,
|
||||
uniqueId: droppedOnUniqueId,
|
||||
});
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
case DragTarget.GENRE: {
|
||||
playerContext.addToQueueByFetch(
|
||||
sourceServerId,
|
||||
args.source.id,
|
||||
sourceItemType,
|
||||
{ edge: args.edge, uniqueId: droppedOnUniqueId },
|
||||
);
|
||||
break;
|
||||
}
|
||||
case DragTarget.PLAYLIST: {
|
||||
playerContext.addToQueueByFetch(
|
||||
sourceServerId,
|
||||
args.source.id,
|
||||
sourceItemType,
|
||||
{ edge: args.edge, uniqueId: droppedOnUniqueId },
|
||||
);
|
||||
break;
|
||||
}
|
||||
case DragTarget.QUEUE_SONG: {
|
||||
const sourceItems = (args.source.item || []) as QueueSong[];
|
||||
if (
|
||||
sourceItems.length > 0 &&
|
||||
args.edge &&
|
||||
(args.edge === 'top' || args.edge === 'bottom')
|
||||
) {
|
||||
playerContext.moveSelectedTo(
|
||||
sourceItems,
|
||||
args.edge,
|
||||
droppedOnUniqueId,
|
||||
);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case DragTarget.SONG: {
|
||||
const sourceItems = (args.source.item || []) as Song[];
|
||||
if (sourceItems.length > 0) {
|
||||
playerContext.addToQueueByData(sourceItems, {
|
||||
edge: args.edge,
|
||||
uniqueId: droppedOnUniqueId,
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
case DragTarget.GENRE: {
|
||||
playerContext.addToQueueByFetch(
|
||||
sourceServerId,
|
||||
args.source.id,
|
||||
sourceItemType,
|
||||
{ edge: args.edge, uniqueId: droppedOnUniqueId },
|
||||
);
|
||||
break;
|
||||
}
|
||||
case DragTarget.PLAYLIST: {
|
||||
playerContext.addToQueueByFetch(
|
||||
sourceServerId,
|
||||
args.source.id,
|
||||
sourceItemType,
|
||||
{ edge: args.edge, uniqueId: droppedOnUniqueId },
|
||||
);
|
||||
break;
|
||||
}
|
||||
case DragTarget.QUEUE_SONG: {
|
||||
const sourceItems = (args.source.item || []) as QueueSong[];
|
||||
if (
|
||||
sourceItems.length > 0 &&
|
||||
args.edge &&
|
||||
(args.edge === 'top' || args.edge === 'bottom')
|
||||
) {
|
||||
playerContext.moveSelectedTo(
|
||||
sourceItems,
|
||||
args.edge,
|
||||
droppedOnUniqueId,
|
||||
);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case DragTarget.SONG: {
|
||||
const sourceItems = (args.source.item || []) as Song[];
|
||||
if (sourceItems.length > 0) {
|
||||
playerContext.addToQueueByData(sourceItems, {
|
||||
edge: args.edge,
|
||||
uniqueId: droppedOnUniqueId,
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handle PLAYLIST_SONG reordering
|
||||
// Only allow drops when drag is started from the reorder handle
|
||||
if (
|
||||
args.self.itemType === LibraryItem.PLAYLIST_SONG &&
|
||||
args.source.itemType === LibraryItem.PLAYLIST_SONG &&
|
||||
args.source.metadata?.fromReorderHandle === true &&
|
||||
playlistId
|
||||
) {
|
||||
const sourceItems = (args.source.item || []) as any[];
|
||||
const targetItem = item as any;
|
||||
// Handle PLAYLIST_SONG reordering
|
||||
// Only allow drops when drag is started from the reorder handle
|
||||
if (
|
||||
args.self.itemType === LibraryItem.PLAYLIST_SONG &&
|
||||
args.source.itemType === LibraryItem.PLAYLIST_SONG &&
|
||||
args.source.metadata?.fromReorderHandle === true &&
|
||||
playlistId
|
||||
) {
|
||||
const sourceItems = (args.source.item || []) as any[];
|
||||
const targetItem = item as any;
|
||||
|
||||
if (
|
||||
sourceItems.length > 0 &&
|
||||
args.edge &&
|
||||
(args.edge === 'top' || args.edge === 'bottom') &&
|
||||
targetItem
|
||||
) {
|
||||
// Emit event to reorder playlist songs
|
||||
eventEmitter.emit('PLAYLIST_REORDER', {
|
||||
edge: args.edge,
|
||||
playlistId,
|
||||
sourceIds: args.source.id,
|
||||
targetId: targetItem.id,
|
||||
});
|
||||
}
|
||||
}
|
||||
if (
|
||||
sourceItems.length > 0 &&
|
||||
args.edge &&
|
||||
(args.edge === 'top' || args.edge === 'bottom') &&
|
||||
targetItem
|
||||
) {
|
||||
// Emit event to reorder playlist songs
|
||||
eventEmitter.emit('PLAYLIST_REORDER', {
|
||||
edge: args.edge,
|
||||
playlistId,
|
||||
sourceIds: args.source.id,
|
||||
targetId: targetItem.id,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (internalState) {
|
||||
internalState.setDragging([]);
|
||||
}
|
||||
if (internalState) {
|
||||
internalState.setDragging([]);
|
||||
}
|
||||
|
||||
return;
|
||||
},
|
||||
},
|
||||
return;
|
||||
},
|
||||
}
|
||||
: undefined,
|
||||
isEnabled: shouldEnableDrag,
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -19,7 +19,6 @@ import React, {
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { useParams } from 'react-router';
|
||||
import { CellComponentProps } from 'react-window-v2';
|
||||
|
||||
import styles from './item-table-list-column.module.css';
|
||||
@@ -58,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';
|
||||
@@ -82,7 +82,6 @@ export interface ItemTableListInnerColumn extends ItemTableListColumn {
|
||||
}
|
||||
|
||||
const ItemTableListColumnBase = (props: ItemTableListColumn) => {
|
||||
const { playlistId } = useParams() as { playlistId?: string };
|
||||
const type = props.columnType ?? (props.columns[props.columnIndex].id as TableColumn);
|
||||
|
||||
const isHeaderEnabled = !!props.enableHeader;
|
||||
@@ -135,7 +134,7 @@ const ItemTableListColumnBase = (props: ItemTableListColumn) => {
|
||||
item,
|
||||
itemType: props.itemType,
|
||||
playerContext: props.playerContext,
|
||||
playlistId,
|
||||
playlistId: props.playlistId,
|
||||
});
|
||||
|
||||
const controls = props.controls;
|
||||
@@ -195,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:
|
||||
@@ -362,6 +369,7 @@ export const ItemTableListColumn = memo(ItemTableListColumnBase, (prevProps, nex
|
||||
prevProps.enableColumnResize === nextProps.enableColumnResize &&
|
||||
prevProps.enableColumnReorder === nextProps.enableColumnReorder &&
|
||||
prevProps.cellPadding === nextProps.cellPadding &&
|
||||
prevProps.playlistId === nextProps.playlistId &&
|
||||
prevItem === nextItem
|
||||
);
|
||||
});
|
||||
@@ -708,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';
|
||||
@@ -715,6 +725,8 @@ interface ColumnResizeHandleProps {
|
||||
|
||||
const ColumnResizeHandle = ({
|
||||
columnId,
|
||||
columnIndex,
|
||||
disabled = false,
|
||||
initialWidth,
|
||||
onResize,
|
||||
side,
|
||||
@@ -724,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(() => {
|
||||
@@ -739,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 = () => {
|
||||
@@ -747,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);
|
||||
@@ -756,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);
|
||||
@@ -772,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',
|
||||
@@ -803,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;
|
||||
}
|
||||
|
||||
@@ -918,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"
|
||||
@@ -983,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,31 +1,59 @@
|
||||
import React, { createContext, useContext, useEffect, useMemo, useRef } from 'react';
|
||||
import type { ReactElement } from 'react';
|
||||
|
||||
import React, {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { useSyncExternalStore } from 'react';
|
||||
|
||||
import type { TableItemProps } from './item-table-list';
|
||||
|
||||
import { ItemListStateActions } from '/@/renderer/components/item-list/helpers/item-list-state';
|
||||
import { ItemControls, ItemTableListColumnConfig } from '/@/renderer/components/item-list/types';
|
||||
import { PlayerContext } from '/@/renderer/features/player/context/player-context';
|
||||
import { LibraryItem } from '/@/shared/types/domain-types';
|
||||
|
||||
/**
|
||||
* Stage A/B: Provide table-scoped config + external stores so churny values can update
|
||||
* without forcing `cellProps` identity changes (and therefore without rerendering every visible cell).
|
||||
*/
|
||||
|
||||
export type ItemTableListConfig = {
|
||||
cellPadding: 'lg' | 'md' | 'sm' | 'xl' | 'xs';
|
||||
columns: ItemTableListColumnConfig[];
|
||||
controls: ItemControls;
|
||||
enableAlternateRowColors: boolean;
|
||||
enableColumnReorder: boolean;
|
||||
enableColumnResize: boolean;
|
||||
enableDrag: boolean;
|
||||
enableExpansion: boolean;
|
||||
enableHeader: boolean;
|
||||
enableHorizontalBorders: boolean;
|
||||
enableRowHoverHighlight: boolean;
|
||||
enableSelection: boolean;
|
||||
enableVerticalBorders: boolean;
|
||||
getRowHeight: (index: number, cellProps: TableItemProps) => number;
|
||||
groups?: ItemTableListGroupHeader[];
|
||||
internalState: ItemListStateActions;
|
||||
itemType: LibraryItem;
|
||||
playerContext: PlayerContext;
|
||||
playlistId?: string;
|
||||
size: 'compact' | 'default' | 'large';
|
||||
startRowIndex?: number;
|
||||
tableId: string;
|
||||
};
|
||||
|
||||
export type ItemTableListGroupHeader = {
|
||||
itemCount: number;
|
||||
render: (props: {
|
||||
data: unknown[];
|
||||
groupIndex: number;
|
||||
index: number;
|
||||
internalState: ItemListStateActions;
|
||||
startDataIndex: number;
|
||||
}) => ReactElement;
|
||||
};
|
||||
|
||||
const ItemTableListConfigContext = createContext<ItemTableListConfig | null>(null);
|
||||
|
||||
export const ItemTableListConfigProvider = ({
|
||||
@@ -48,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,11 +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 {
|
||||
@@ -43,13 +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,
|
||||
@@ -101,30 +111,71 @@ 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>>;
|
||||
cellPadding: 'lg' | 'md' | 'sm' | 'xl' | 'xs';
|
||||
controls: ItemControls;
|
||||
data: unknown[];
|
||||
dataWithGroups: (null | unknown)[];
|
||||
enableAlternateRowColors: boolean;
|
||||
enableColumnReorder: boolean;
|
||||
enableColumnResize: boolean;
|
||||
enableDrag?: boolean;
|
||||
enableExpansion: boolean;
|
||||
enableHeader: boolean;
|
||||
enableHorizontalBorders: boolean;
|
||||
enableRowHoverHighlight: boolean;
|
||||
enableScrollShadow: boolean;
|
||||
enableSelection: boolean;
|
||||
enableVerticalBorders: boolean;
|
||||
getItem?: (index: number) => undefined | unknown;
|
||||
getRowHeight: (index: number, cellProps: TableItemProps) => number;
|
||||
groups?: TableGroupHeader[];
|
||||
headerHeight: number;
|
||||
internalState: ItemListStateActions;
|
||||
itemType: LibraryItem;
|
||||
mergedRowRef: React.Ref<HTMLDivElement>;
|
||||
onRangeChanged?: ItemTableListProps['onRangeChanged'];
|
||||
parsedColumns: ReturnType<typeof parseTableColumns>;
|
||||
@@ -134,13 +185,8 @@ interface VirtualizedTableGridProps {
|
||||
pinnedRightColumnRef: React.RefObject<HTMLDivElement | null>;
|
||||
pinnedRowCount: number;
|
||||
pinnedRowRef: React.RefObject<HTMLDivElement | null>;
|
||||
playerContext: PlayerContext;
|
||||
showLeftShadow: boolean;
|
||||
showRightShadow: boolean;
|
||||
showTopShadow: boolean;
|
||||
size: 'compact' | 'default' | 'large';
|
||||
startRowIndex?: number;
|
||||
tableId: string;
|
||||
scrollShadowStore: TableScrollShadowStore;
|
||||
tableConfig: ItemTableListConfig;
|
||||
totalColumnCount: number;
|
||||
totalRowCount: number;
|
||||
}
|
||||
@@ -148,27 +194,11 @@ interface VirtualizedTableGridProps {
|
||||
const VirtualizedTableGrid = ({
|
||||
calculatedColumnWidths,
|
||||
CellComponent,
|
||||
cellPadding,
|
||||
controls,
|
||||
data,
|
||||
dataWithGroups,
|
||||
enableAlternateRowColors,
|
||||
enableColumnReorder,
|
||||
enableColumnResize,
|
||||
enableDrag,
|
||||
enableExpansion,
|
||||
enableHeader,
|
||||
enableHorizontalBorders,
|
||||
enableRowHoverHighlight,
|
||||
enableScrollShadow,
|
||||
enableSelection,
|
||||
enableVerticalBorders,
|
||||
getItem,
|
||||
getRowHeight,
|
||||
groups,
|
||||
headerHeight,
|
||||
internalState,
|
||||
itemType,
|
||||
mergedRowRef,
|
||||
onRangeChanged,
|
||||
parsedColumns,
|
||||
@@ -178,16 +208,12 @@ const VirtualizedTableGrid = ({
|
||||
pinnedRightColumnRef,
|
||||
pinnedRowCount,
|
||||
pinnedRowRef,
|
||||
playerContext,
|
||||
showLeftShadow,
|
||||
showRightShadow,
|
||||
showTopShadow,
|
||||
size,
|
||||
startRowIndex,
|
||||
tableId,
|
||||
scrollShadowStore,
|
||||
tableConfig,
|
||||
totalColumnCount,
|
||||
totalRowCount,
|
||||
}: VirtualizedTableGridProps) => {
|
||||
const { enableHeader, enableRowHoverHighlight, getRowHeight, groups } = tableConfig;
|
||||
const hoverDelegateRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
useRowInteractionDelegate({
|
||||
@@ -345,35 +371,7 @@ const VirtualizedTableGrid = ({
|
||||
],
|
||||
);
|
||||
|
||||
const stableConfigProps = useMemo(
|
||||
() => ({
|
||||
cellPadding,
|
||||
columns: parsedColumns,
|
||||
controls,
|
||||
enableHeader,
|
||||
getRowHeight,
|
||||
hasAlbumGroupColumn: parsedColumns.some((col) => col.id === TableColumn.ALBUM_GROUP),
|
||||
internalState,
|
||||
itemType,
|
||||
playerContext,
|
||||
size,
|
||||
tableId,
|
||||
}),
|
||||
[
|
||||
cellPadding,
|
||||
parsedColumns,
|
||||
controls,
|
||||
enableHeader,
|
||||
getRowHeight,
|
||||
internalState,
|
||||
itemType,
|
||||
playerContext,
|
||||
size,
|
||||
tableId,
|
||||
],
|
||||
);
|
||||
|
||||
const dynamicDataProps = useMemo(
|
||||
const gridOnlyProps = useMemo(
|
||||
() => ({
|
||||
calculatedColumnWidths,
|
||||
data: dataWithGroups,
|
||||
@@ -381,11 +379,11 @@ const VirtualizedTableGrid = ({
|
||||
getGroupRenderData,
|
||||
getRowItem,
|
||||
groupHeaderInfoByRowIndex,
|
||||
hasAlbumGroupColumn: parsedColumns.some((col) => col.id === TableColumn.ALBUM_GROUP),
|
||||
pinnedLeftColumnCount,
|
||||
pinnedLeftColumnWidths,
|
||||
pinnedRightColumnCount,
|
||||
pinnedRightColumnWidths,
|
||||
startRowIndex,
|
||||
}),
|
||||
[
|
||||
calculatedColumnWidths,
|
||||
@@ -394,50 +392,68 @@ const VirtualizedTableGrid = ({
|
||||
getAdjustedRowIndex,
|
||||
getGroupRenderData,
|
||||
groupHeaderInfoByRowIndex,
|
||||
parsedColumns,
|
||||
pinnedLeftColumnCount,
|
||||
pinnedLeftColumnWidths,
|
||||
pinnedRightColumnCount,
|
||||
pinnedRightColumnWidths,
|
||||
startRowIndex,
|
||||
],
|
||||
);
|
||||
|
||||
const featureFlags = useMemo(
|
||||
() => ({
|
||||
enableAlternateRowColors,
|
||||
enableColumnReorder,
|
||||
enableColumnResize,
|
||||
enableDrag,
|
||||
enableExpansion,
|
||||
enableHorizontalBorders,
|
||||
enableRowHoverHighlight,
|
||||
enableSelection,
|
||||
enableVerticalBorders,
|
||||
groups,
|
||||
}),
|
||||
[
|
||||
enableAlternateRowColors,
|
||||
enableColumnReorder,
|
||||
enableColumnResize,
|
||||
enableDrag,
|
||||
enableExpansion,
|
||||
enableHorizontalBorders,
|
||||
enableRowHoverHighlight,
|
||||
enableSelection,
|
||||
enableVerticalBorders,
|
||||
groups,
|
||||
],
|
||||
);
|
||||
|
||||
const itemProps: TableItemProps = useMemo(
|
||||
() => ({
|
||||
...stableConfigProps,
|
||||
...dynamicDataProps,
|
||||
...featureFlags,
|
||||
cellPadding: tableConfig.cellPadding,
|
||||
columns: tableConfig.columns,
|
||||
controls: tableConfig.controls,
|
||||
enableAlternateRowColors: tableConfig.enableAlternateRowColors,
|
||||
enableColumnReorder: tableConfig.enableColumnReorder,
|
||||
enableColumnResize: tableConfig.enableColumnResize,
|
||||
enableDrag: tableConfig.enableDrag,
|
||||
enableExpansion: tableConfig.enableExpansion,
|
||||
enableHeader: tableConfig.enableHeader,
|
||||
enableHorizontalBorders: tableConfig.enableHorizontalBorders,
|
||||
enableRowHoverHighlight: tableConfig.enableRowHoverHighlight,
|
||||
enableSelection: tableConfig.enableSelection,
|
||||
enableVerticalBorders: tableConfig.enableVerticalBorders,
|
||||
getRowHeight: tableConfig.getRowHeight,
|
||||
groups: tableConfig.groups,
|
||||
internalState: tableConfig.internalState,
|
||||
itemType: tableConfig.itemType,
|
||||
playerContext: tableConfig.playerContext,
|
||||
playlistId: tableConfig.playlistId,
|
||||
size: tableConfig.size,
|
||||
startRowIndex: tableConfig.startRowIndex,
|
||||
tableId: tableConfig.tableId,
|
||||
...gridOnlyProps,
|
||||
}),
|
||||
[stableConfigProps, dynamicDataProps, featureFlags],
|
||||
[gridOnlyProps, tableConfig],
|
||||
);
|
||||
|
||||
const pinnedLeftGridMinWidthPx = useMemo(() => {
|
||||
let sum = 0;
|
||||
for (let i = 0; i < pinnedLeftColumnCount; i++) {
|
||||
sum += calculatedColumnWidths[i] ?? 0;
|
||||
}
|
||||
return sum;
|
||||
}, [calculatedColumnWidths, pinnedLeftColumnCount]);
|
||||
|
||||
const pinnedRightGridMinWidthPx = useMemo(() => {
|
||||
let sum = 0;
|
||||
const start = pinnedLeftColumnCount + totalColumnCount;
|
||||
for (let i = 0; i < pinnedRightColumnCount; i++) {
|
||||
sum += calculatedColumnWidths[start + i] ?? 0;
|
||||
}
|
||||
return sum;
|
||||
}, [calculatedColumnWidths, pinnedLeftColumnCount, pinnedRightColumnCount, totalColumnCount]);
|
||||
|
||||
const pinnedRowsMinHeightPx = useMemo(() => {
|
||||
let sum = 0;
|
||||
for (let i = 0; i < pinnedRowCount; i++) {
|
||||
sum += getRowHeight(i, itemProps);
|
||||
}
|
||||
return sum;
|
||||
}, [getRowHeight, itemProps, pinnedRowCount]);
|
||||
|
||||
const PinnedRowCell = useCallback(
|
||||
(cellProps: CellComponentProps & TableItemProps) => {
|
||||
return (
|
||||
@@ -447,16 +463,14 @@ const VirtualizedTableGrid = ({
|
||||
/>
|
||||
);
|
||||
},
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[pinnedLeftColumnCount, CellComponent, featureFlags, calculatedColumnWidths],
|
||||
[pinnedLeftColumnCount, CellComponent],
|
||||
);
|
||||
|
||||
const PinnedColumnCell = useCallback(
|
||||
(cellProps: CellComponentProps & TableItemProps) => {
|
||||
return <CellComponent {...cellProps} rowIndex={cellProps.rowIndex + pinnedRowCount} />;
|
||||
},
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[pinnedRowCount, CellComponent, featureFlags, calculatedColumnWidths],
|
||||
[pinnedRowCount, CellComponent],
|
||||
);
|
||||
|
||||
const PinnedRightColumnCell = useCallback(
|
||||
@@ -469,15 +483,7 @@ const VirtualizedTableGrid = ({
|
||||
/>
|
||||
);
|
||||
},
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[
|
||||
pinnedLeftColumnCount,
|
||||
pinnedRowCount,
|
||||
totalColumnCount,
|
||||
CellComponent,
|
||||
featureFlags,
|
||||
calculatedColumnWidths,
|
||||
],
|
||||
[pinnedLeftColumnCount, pinnedRowCount, totalColumnCount, CellComponent],
|
||||
);
|
||||
|
||||
const PinnedRightIntersectionCell = useCallback(
|
||||
@@ -489,14 +495,7 @@ const VirtualizedTableGrid = ({
|
||||
/>
|
||||
);
|
||||
},
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[
|
||||
pinnedLeftColumnCount,
|
||||
totalColumnCount,
|
||||
CellComponent,
|
||||
featureFlags,
|
||||
calculatedColumnWidths,
|
||||
],
|
||||
[pinnedLeftColumnCount, totalColumnCount, CellComponent],
|
||||
);
|
||||
|
||||
const RowCell = useCallback(
|
||||
@@ -509,14 +508,7 @@ const VirtualizedTableGrid = ({
|
||||
/>
|
||||
);
|
||||
},
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[
|
||||
pinnedLeftColumnCount,
|
||||
pinnedRowCount,
|
||||
CellComponent,
|
||||
featureFlags,
|
||||
calculatedColumnWidths,
|
||||
],
|
||||
[pinnedLeftColumnCount, pinnedRowCount, CellComponent],
|
||||
);
|
||||
|
||||
const handleOnCellsRendered = useCallback(
|
||||
@@ -541,10 +533,7 @@ const VirtualizedTableGrid = ({
|
||||
style={
|
||||
{
|
||||
'--header-height': `${headerHeight}px`,
|
||||
minWidth: `${Array.from({ length: pinnedLeftColumnCount }, () => 0).reduce(
|
||||
(a, _, i) => a + columnWidth(i),
|
||||
0,
|
||||
)}px`,
|
||||
minWidth: `${pinnedLeftGridMinWidthPx}px`,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
>
|
||||
@@ -554,11 +543,8 @@ const VirtualizedTableGrid = ({
|
||||
[styles.withHeader]: enableHeader,
|
||||
})}
|
||||
style={{
|
||||
minHeight: `${Array.from({ length: pinnedRowCount }, () => 0).reduce(
|
||||
(a, _, i) => a + getRowHeight(i, itemProps),
|
||||
0,
|
||||
)}px`,
|
||||
overflow: 'hidden',
|
||||
minHeight: `${pinnedRowsMinHeightPx}px`,
|
||||
overflow: 'visible',
|
||||
}}
|
||||
>
|
||||
<Grid
|
||||
@@ -572,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}
|
||||
@@ -611,10 +599,7 @@ const VirtualizedTableGrid = ({
|
||||
style={
|
||||
{
|
||||
'--header-height': `${headerHeight}px`,
|
||||
minHeight: `${Array.from(
|
||||
{ length: pinnedRowCount },
|
||||
() => 0,
|
||||
).reduce((a, _, i) => a + getRowHeight(i, itemProps), 0)}px`,
|
||||
minHeight: `${pinnedRowsMinHeightPx}px`,
|
||||
overflow: 'hidden',
|
||||
} as React.CSSProperties
|
||||
}
|
||||
@@ -627,14 +612,16 @@ const VirtualizedTableGrid = ({
|
||||
columnWidth={(index) => {
|
||||
return columnWidth(index + pinnedLeftColumnCount);
|
||||
}}
|
||||
rowCount={Array.from({ length: pinnedRowCount }, () => 0).length}
|
||||
rowCount={pinnedRowCount}
|
||||
rowHeight={getRowHeight}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{enableHeader && enableScrollShadow && showTopShadow && (
|
||||
<div className={styles.itemTableTopScrollShadow} />
|
||||
)}
|
||||
<ItemTableScrollShadowTop
|
||||
enableHeader={!!enableHeader}
|
||||
enableScrollShadow={enableScrollShadow}
|
||||
scrollShadowStore={scrollShadowStore}
|
||||
/>
|
||||
<div className={styles.itemTableGridContainer} ref={mergedRowRef}>
|
||||
<Grid
|
||||
cellComponent={RowCell}
|
||||
@@ -646,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 && (
|
||||
@@ -660,14 +651,7 @@ const VirtualizedTableGrid = ({
|
||||
style={
|
||||
{
|
||||
'--header-height': `${headerHeight}px`,
|
||||
minWidth: `${Array.from(
|
||||
{ length: pinnedRightColumnCount },
|
||||
() => 0,
|
||||
).reduce(
|
||||
(a, _, i) =>
|
||||
a + columnWidth(i + pinnedLeftColumnCount + totalColumnCount),
|
||||
0,
|
||||
)}px`,
|
||||
minWidth: `${pinnedRightGridMinWidthPx}px`,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
>
|
||||
@@ -677,11 +661,8 @@ const VirtualizedTableGrid = ({
|
||||
[styles.withHeader]: enableHeader,
|
||||
})}
|
||||
style={{
|
||||
minHeight: `${Array.from(
|
||||
{ length: pinnedRowCount },
|
||||
() => 0,
|
||||
).reduce((a, _, i) => a + getRowHeight(i, itemProps), 0)}px`,
|
||||
overflow: 'hidden',
|
||||
minHeight: `${pinnedRowsMinHeightPx}px`,
|
||||
overflow: 'visible',
|
||||
}}
|
||||
>
|
||||
<Grid
|
||||
@@ -699,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}
|
||||
@@ -739,27 +722,12 @@ const MemoizedVirtualizedTableGrid = memo(VirtualizedTableGrid, (prevProps, next
|
||||
prevProps.calculatedColumnWidths,
|
||||
nextProps.calculatedColumnWidths,
|
||||
) &&
|
||||
prevProps.cellPadding === nextProps.cellPadding &&
|
||||
prevProps.controls === nextProps.controls &&
|
||||
prevProps.tableConfig === nextProps.tableConfig &&
|
||||
prevProps.data === nextProps.data &&
|
||||
prevProps.dataWithGroups === nextProps.dataWithGroups &&
|
||||
prevProps.enableAlternateRowColors === nextProps.enableAlternateRowColors &&
|
||||
prevProps.enableColumnReorder === nextProps.enableColumnReorder &&
|
||||
prevProps.enableColumnResize === nextProps.enableColumnResize &&
|
||||
prevProps.enableDrag === nextProps.enableDrag &&
|
||||
prevProps.enableExpansion === nextProps.enableExpansion &&
|
||||
prevProps.enableHeader === nextProps.enableHeader &&
|
||||
prevProps.enableHorizontalBorders === nextProps.enableHorizontalBorders &&
|
||||
prevProps.enableRowHoverHighlight === nextProps.enableRowHoverHighlight &&
|
||||
prevProps.enableScrollShadow === nextProps.enableScrollShadow &&
|
||||
prevProps.enableSelection === nextProps.enableSelection &&
|
||||
prevProps.enableVerticalBorders === nextProps.enableVerticalBorders &&
|
||||
prevProps.getItem === nextProps.getItem &&
|
||||
prevProps.getRowHeight === nextProps.getRowHeight &&
|
||||
prevProps.groups === nextProps.groups &&
|
||||
prevProps.headerHeight === nextProps.headerHeight &&
|
||||
prevProps.internalState === nextProps.internalState &&
|
||||
prevProps.itemType === nextProps.itemType &&
|
||||
prevProps.mergedRowRef === nextProps.mergedRowRef &&
|
||||
prevProps.onRangeChanged === nextProps.onRangeChanged &&
|
||||
prevProps.parsedColumns === nextProps.parsedColumns &&
|
||||
@@ -769,13 +737,7 @@ const MemoizedVirtualizedTableGrid = memo(VirtualizedTableGrid, (prevProps, next
|
||||
prevProps.pinnedRightColumnRef === nextProps.pinnedRightColumnRef &&
|
||||
prevProps.pinnedRowCount === nextProps.pinnedRowCount &&
|
||||
prevProps.pinnedRowRef === nextProps.pinnedRowRef &&
|
||||
prevProps.playerContext === nextProps.playerContext &&
|
||||
prevProps.showLeftShadow === nextProps.showLeftShadow &&
|
||||
prevProps.showRightShadow === nextProps.showRightShadow &&
|
||||
prevProps.showTopShadow === nextProps.showTopShadow &&
|
||||
prevProps.size === nextProps.size &&
|
||||
prevProps.startRowIndex === nextProps.startRowIndex &&
|
||||
prevProps.tableId === nextProps.tableId &&
|
||||
prevProps.scrollShadowStore === nextProps.scrollShadowStore &&
|
||||
prevProps.totalColumnCount === nextProps.totalColumnCount &&
|
||||
prevProps.totalRowCount === nextProps.totalRowCount &&
|
||||
prevProps.CellComponent === nextProps.CellComponent
|
||||
@@ -828,6 +790,7 @@ export interface TableItemProps {
|
||||
pinnedRightColumnCount?: number;
|
||||
pinnedRightColumnWidths?: number[];
|
||||
playerContext: PlayerContext;
|
||||
playlistId?: string;
|
||||
size?: ItemTableListProps['size'];
|
||||
startRowIndex?: number;
|
||||
tableId: string;
|
||||
@@ -1008,7 +971,7 @@ const ItemTableListStickyUI = memo(
|
||||
style={{
|
||||
flex: '0 1 auto',
|
||||
minWidth: `${pinnedLeftWidth}px`,
|
||||
overflow: 'hidden',
|
||||
overflow: 'visible',
|
||||
}}
|
||||
>
|
||||
{parsedColumns
|
||||
@@ -1092,7 +1055,7 @@ const ItemTableListStickyUI = memo(
|
||||
style={{
|
||||
flex: '0 1 auto',
|
||||
minWidth: `${pinnedRightWidth}px`,
|
||||
overflow: 'hidden',
|
||||
overflow: 'visible',
|
||||
}}
|
||||
>
|
||||
{parsedColumns
|
||||
@@ -1309,12 +1272,18 @@ const BaseItemTableList = ({
|
||||
size = 'default',
|
||||
startRowIndex,
|
||||
}: ItemTableListProps) => {
|
||||
const { playlistId: routePlaylistId } = useParams() as { playlistId?: string };
|
||||
const tableId = useId();
|
||||
const baseItemCount = itemCount ?? data.length;
|
||||
const totalItemCount = enableHeader ? baseItemCount + 1 : baseItemCount;
|
||||
const [centerContainerWidth, setCenterContainerWidth] = useState(0);
|
||||
const [totalContainerWidth, setTotalContainerWidth] = useState(0);
|
||||
|
||||
const columnsForLayout = useMemo(
|
||||
() => appendLayoutFillColumn(columns, autoFitColumns),
|
||||
[autoFitColumns, columns],
|
||||
);
|
||||
|
||||
const {
|
||||
calculatedColumnWidths,
|
||||
parsedColumns,
|
||||
@@ -1324,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 {
|
||||
@@ -1362,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);
|
||||
@@ -1422,9 +1413,7 @@ const BaseItemTableList = ({
|
||||
pinnedRowRef,
|
||||
rowRef,
|
||||
scrollContainerRef,
|
||||
setShowLeftShadow,
|
||||
setShowRightShadow,
|
||||
setShowTopShadow,
|
||||
scrollShadowStore,
|
||||
});
|
||||
|
||||
const getRowHeight = useCallback(
|
||||
@@ -1548,7 +1537,7 @@ const BaseItemTableList = ({
|
||||
// Create itemProps for sticky header
|
||||
const stickyHeaderItemProps: TableItemProps = useMemo(
|
||||
() => ({
|
||||
calculatedColumnWidths,
|
||||
calculatedColumnWidths: displayColumnWidths,
|
||||
cellPadding,
|
||||
columns: parsedColumns,
|
||||
controls,
|
||||
@@ -1568,17 +1557,18 @@ 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,
|
||||
playlistId: routePlaylistId,
|
||||
size,
|
||||
tableId,
|
||||
}),
|
||||
[
|
||||
calculatedColumnWidths,
|
||||
displayColumnWidths,
|
||||
cellPadding,
|
||||
controls,
|
||||
parsedColumns,
|
||||
@@ -1599,6 +1589,7 @@ const BaseItemTableList = ({
|
||||
pinnedLeftColumnCount,
|
||||
pinnedRightColumnCount,
|
||||
playerContext,
|
||||
routePlaylistId,
|
||||
size,
|
||||
tableId,
|
||||
totalColumnCount,
|
||||
@@ -1612,17 +1603,27 @@ const BaseItemTableList = ({
|
||||
itemType,
|
||||
});
|
||||
|
||||
const tableConfigValue = useMemo(
|
||||
const tableConfigValue = useMemo<ItemTableListConfig>(
|
||||
() => ({
|
||||
cellPadding,
|
||||
columns: parsedColumns,
|
||||
controls,
|
||||
enableAlternateRowColors,
|
||||
enableColumnReorder: !!onColumnReordered,
|
||||
enableColumnResize: !!onColumnResized,
|
||||
enableDrag,
|
||||
enableExpansion,
|
||||
enableHeader,
|
||||
enableHorizontalBorders,
|
||||
enableRowHoverHighlight,
|
||||
enableSelection,
|
||||
enableVerticalBorders,
|
||||
getRowHeight,
|
||||
groups,
|
||||
internalState,
|
||||
itemType,
|
||||
playerContext,
|
||||
playlistId: routePlaylistId,
|
||||
size,
|
||||
startRowIndex,
|
||||
tableId,
|
||||
@@ -1631,12 +1632,22 @@ const BaseItemTableList = ({
|
||||
cellPadding,
|
||||
parsedColumns,
|
||||
controls,
|
||||
enableAlternateRowColors,
|
||||
onColumnReordered,
|
||||
onColumnResized,
|
||||
enableDrag,
|
||||
enableExpansion,
|
||||
enableHeader,
|
||||
enableHorizontalBorders,
|
||||
enableRowHoverHighlight,
|
||||
enableSelection,
|
||||
enableVerticalBorders,
|
||||
getRowHeight,
|
||||
groups,
|
||||
internalState,
|
||||
itemType,
|
||||
playerContext,
|
||||
routePlaylistId,
|
||||
size,
|
||||
startRowIndex,
|
||||
tableId,
|
||||
@@ -1662,92 +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}
|
||||
cellPadding={cellPadding}
|
||||
controls={controls}
|
||||
data={data}
|
||||
dataWithGroups={dataWithGroups}
|
||||
enableAlternateRowColors={enableAlternateRowColors}
|
||||
enableColumnReorder={!!onColumnReordered}
|
||||
enableColumnResize={!!onColumnResized}
|
||||
enableDrag={enableDrag}
|
||||
enableExpansion={enableExpansion}
|
||||
enableHeader={enableHeader}
|
||||
enableHorizontalBorders={enableHorizontalBorders}
|
||||
enableRowHoverHighlight={enableRowHoverHighlight}
|
||||
enableScrollShadow={enableScrollShadow}
|
||||
enableSelection={enableSelection}
|
||||
enableVerticalBorders={enableVerticalBorders}
|
||||
getItem={getItem}
|
||||
getRowHeight={getRowHeight}
|
||||
groups={groups}
|
||||
headerHeight={headerHeight}
|
||||
internalState={internalState}
|
||||
itemType={itemType}
|
||||
mergedRowRef={mergedRowRef}
|
||||
onRangeChanged={onRangeChanged}
|
||||
parsedColumns={parsedColumns}
|
||||
pinnedLeftColumnCount={pinnedLeftColumnCount}
|
||||
pinnedLeftColumnRef={pinnedLeftColumnRef}
|
||||
pinnedRightColumnCount={pinnedRightColumnCount}
|
||||
pinnedRightColumnRef={pinnedRightColumnRef}
|
||||
pinnedRowCount={pinnedRowCount}
|
||||
pinnedRowRef={pinnedRowRef}
|
||||
playerContext={playerContext}
|
||||
showLeftShadow={showLeftShadow}
|
||||
showRightShadow={showRightShadow}
|
||||
showTopShadow={showTopShadow}
|
||||
size={size}
|
||||
startRowIndex={startRowIndex}
|
||||
tableId={tableId}
|
||||
totalColumnCount={totalColumnCount}
|
||||
totalRowCount={totalRowCount}
|
||||
/>
|
||||
</motion.div>
|
||||
{onColumnResized ? (
|
||||
<ItemTableListColumnResizeLiveProvider value={columnResizeLiveValue}>
|
||||
{tableMotion}
|
||||
</ItemTableListColumnResizeLiveProvider>
|
||||
) : (
|
||||
tableMotion
|
||||
)}
|
||||
</ItemTableListConfigProvider>
|
||||
</ItemTableListStoreProvider>
|
||||
);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { memo, useMemo } from 'react';
|
||||
import React, { useMemo } from 'react';
|
||||
import { CellComponentProps } from 'react-window-v2';
|
||||
|
||||
import { createColumnCellComponents } from './cell-component-factory';
|
||||
@@ -24,24 +24,7 @@ const MemoizedCellRouterBase = (props: MemoizedCellRouterProps) => {
|
||||
return <ItemTableListColumn {...props} />;
|
||||
};
|
||||
|
||||
export const MemoizedCellRouter = memo(MemoizedCellRouterBase, (prevProps, nextProps) => {
|
||||
return (
|
||||
prevProps.rowIndex === nextProps.rowIndex &&
|
||||
prevProps.columnIndex === nextProps.columnIndex &&
|
||||
prevProps.data === nextProps.data &&
|
||||
prevProps.columns === nextProps.columns &&
|
||||
prevProps.columnCellComponents === nextProps.columnCellComponents &&
|
||||
prevProps.size === nextProps.size &&
|
||||
prevProps.enableAlternateRowColors === nextProps.enableAlternateRowColors &&
|
||||
prevProps.enableHorizontalBorders === nextProps.enableHorizontalBorders &&
|
||||
prevProps.enableVerticalBorders === nextProps.enableVerticalBorders &&
|
||||
prevProps.enableRowHoverHighlight === nextProps.enableRowHoverHighlight &&
|
||||
prevProps.enableSelection === nextProps.enableSelection &&
|
||||
prevProps.enableColumnResize === nextProps.enableColumnResize &&
|
||||
prevProps.enableColumnReorder === nextProps.enableColumnReorder &&
|
||||
prevProps.cellPadding === nextProps.cellPadding
|
||||
);
|
||||
});
|
||||
export const MemoizedCellRouter = MemoizedCellRouterBase;
|
||||
|
||||
export const useColumnCellComponents = (
|
||||
columns: TableColumn[],
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -20,7 +20,7 @@ import { songsQueries } from '/@/renderer/features/songs/api/songs-api';
|
||||
import { AppRoute } from '/@/renderer/router/routes';
|
||||
import { useCurrentServer, useShowRatings } from '/@/renderer/store';
|
||||
import { useArtistRadioCount, usePlayButtonBehavior } from '/@/renderer/store/settings.store';
|
||||
import { formatDateAbsoluteUTC, formatDurationString, formatSizeString } from '/@/renderer/utils';
|
||||
import { formatDurationString, formatPartialIsoDateUTC, formatSizeString } from '/@/renderer/utils';
|
||||
import { normalizeReleaseTypes } from '/@/renderer/utils/normalize-release-types';
|
||||
import { Group } from '/@/shared/components/group/group';
|
||||
import { Separator } from '/@/shared/components/separator/separator';
|
||||
@@ -131,7 +131,10 @@ export const AlbumDetailHeader = forwardRef<HTMLDivElement>((_props, ref) => {
|
||||
const originalDifferentFromRelease =
|
||||
album?.originalDate && album?.originalDate !== album?.releaseDate;
|
||||
|
||||
const originalYearDifferentFromRelease = album?.originalYear !== album?.releaseYear;
|
||||
const originalYearDifferentFromRelease =
|
||||
album.originalYear > 0 &&
|
||||
album.releaseYear != null &&
|
||||
album.originalYear !== album.releaseYear;
|
||||
|
||||
const playCount = album?.playCount;
|
||||
|
||||
@@ -147,17 +150,17 @@ export const AlbumDetailHeader = forwardRef<HTMLDivElement>((_props, ref) => {
|
||||
if (originalDifferentFromRelease) {
|
||||
items.push({
|
||||
id: 'originalDate',
|
||||
value: `♫ ${formatDateAbsoluteUTC(album.originalDate)}`,
|
||||
value: `♫ ${formatPartialIsoDateUTC(album.originalDate)}`,
|
||||
});
|
||||
}
|
||||
|
||||
if (releaseDate) {
|
||||
items.push({
|
||||
id: 'releaseDate',
|
||||
value: `${releasePrefix} ${formatDateAbsoluteUTC(releaseDate)}`,
|
||||
value: `${releasePrefix} ${formatPartialIsoDateUTC(releaseDate)}`,
|
||||
});
|
||||
}
|
||||
} else if (album.originalYear) {
|
||||
} else if (album.originalYear > 0) {
|
||||
if (originalYearDifferentFromRelease) {
|
||||
items.push({
|
||||
id: 'originalYear',
|
||||
@@ -168,14 +171,24 @@ export const AlbumDetailHeader = forwardRef<HTMLDivElement>((_props, ref) => {
|
||||
if (releaseDate) {
|
||||
items.push({
|
||||
id: 'releaseDate',
|
||||
value: `${releaseYearPrefix} ${formatDateAbsoluteUTC(releaseDate)}`,
|
||||
value: `${releaseYearPrefix} ${formatPartialIsoDateUTC(releaseDate)}`,
|
||||
});
|
||||
} else if (releaseYear) {
|
||||
} else if (releaseYear != null && releaseYear > 0) {
|
||||
items.push({
|
||||
id: 'releaseYear',
|
||||
value: `${releaseYearPrefix} ${releaseYear}`,
|
||||
});
|
||||
}
|
||||
} else if (releaseDate) {
|
||||
items.push({
|
||||
id: 'releaseDate',
|
||||
value: `♫ ${formatPartialIsoDateUTC(releaseDate)}`,
|
||||
});
|
||||
} else if (releaseYear != null && releaseYear > 0) {
|
||||
items.push({
|
||||
id: 'releaseYear',
|
||||
value: `♫ ${releaseYear}`,
|
||||
});
|
||||
}
|
||||
|
||||
items.push(
|
||||
|
||||
@@ -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,23 +16,21 @@ 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 { Spinner } from '/@/shared/components/spinner/spinner';
|
||||
import { useAlbumBackground, useCurrentServerId } from '/@/renderer/store';
|
||||
import { LibraryItem } from '/@/shared/types/domain-types';
|
||||
|
||||
const ALBUM_DETAIL_BG_FALLBACK = 'var(--theme-colors-foreground-muted)';
|
||||
|
||||
const AlbumDetailRoute = () => {
|
||||
const scrollAreaRef = useRef<HTMLDivElement>(null);
|
||||
const headerRef = useRef<HTMLDivElement>(null);
|
||||
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 =
|
||||
@@ -42,25 +40,21 @@ const AlbumDetailRoute = () => {
|
||||
type: 'itemCard',
|
||||
}) || '';
|
||||
|
||||
const { background: backgroundColor, isLoading: isColorLoading } = useFastAverageColor({
|
||||
const { background: backgroundColor } = useFastAverageColor({
|
||||
id: albumId,
|
||||
src: imageUrl,
|
||||
srcLoaded: true,
|
||||
});
|
||||
|
||||
const background = backgroundColor;
|
||||
const background = backgroundColor ?? ALBUM_DETAIL_BG_FALLBACK;
|
||||
|
||||
const showBlurredImage = albumBackground;
|
||||
|
||||
if (isColorLoading) {
|
||||
return <Spinner container />;
|
||||
}
|
||||
|
||||
return (
|
||||
<AnimatedPage key={`album-detail-${albumId}`}>
|
||||
<NativeScrollArea
|
||||
pageHeaderProps={{
|
||||
backgroundColor: backgroundColor || undefined,
|
||||
backgroundColor: backgroundColor ?? ALBUM_DETAIL_BG_FALLBACK,
|
||||
children: (
|
||||
<LibraryHeaderBar>
|
||||
<LibraryHeaderBar.PlayButton
|
||||
@@ -68,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,38 +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 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={alternateImageUrl || selectedImageUrl}
|
||||
imageOverlay={
|
||||
<ArtistImageUploadOverlay
|
||||
data={detailQuery.data}
|
||||
onUploadFile={handleArtistImageUpload}
|
||||
/>
|
||||
}
|
||||
imageUrl={headerImageUrl}
|
||||
item={{
|
||||
imageId: detailQuery.data?.imageId,
|
||||
imageUrl: alternateImageUrl || detailQuery.data?.imageUrl,
|
||||
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,
|
||||
});
|
||||
};
|
||||
+15
-17
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -216,6 +216,10 @@ export const MpvPlayerEngine = (props: MpvPlayerEngineProps) => {
|
||||
return;
|
||||
}
|
||||
|
||||
if (playerStatus !== PlayerStatus.PLAYING) {
|
||||
return;
|
||||
}
|
||||
|
||||
const updateProgress = async () => {
|
||||
if (!mpvPlayer || !isMountedRef.current) {
|
||||
return;
|
||||
@@ -245,7 +249,7 @@ export const MpvPlayerEngine = (props: MpvPlayerEngineProps) => {
|
||||
progressIntervalRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [hasCurrentSong, isTransitioning, onProgress]);
|
||||
}, [hasCurrentSong, isTransitioning, onProgress, playerStatus]);
|
||||
|
||||
const { mediaAutoNext } = usePlayerActions();
|
||||
|
||||
|
||||
@@ -80,7 +80,7 @@ export const useMainPlayerListener = () => {
|
||||
|
||||
mpvPlayerListener.rendererStop(() => {
|
||||
if (!isRadioActive) {
|
||||
mediaStop();
|
||||
mediaStop({ reset: false });
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
@@ -33,25 +34,39 @@ import {
|
||||
usePlaybackType,
|
||||
useSettingsStoreActions,
|
||||
} from '/@/renderer/store';
|
||||
import { logFn } from '/@/renderer/utils/logger';
|
||||
import { toast } from '/@/shared/components/toast/toast';
|
||||
import { LibraryItem } from '/@/shared/types/domain-types';
|
||||
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[];
|
||||
@@ -59,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() {
|
||||
@@ -71,18 +86,25 @@ 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'],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
logFn.info('DIRECT_PLAY_PROFILES', { meta: DIRECT_PLAY_PROFILES });
|
||||
|
||||
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();
|
||||
@@ -96,7 +118,6 @@ export const AudioPlayers = () => {
|
||||
const { setWebAudio, webAudio: audioContext } = useWebAudio();
|
||||
|
||||
useEffect(() => {
|
||||
console.log('getDirectPlayProfiles');
|
||||
detectBrowserProfile();
|
||||
}, []);
|
||||
|
||||
@@ -116,6 +137,7 @@ export const AudioPlayers = () => {
|
||||
<UpdateCurrentSongHook />
|
||||
<RadioAudioInstanceHook />
|
||||
<RadioMetadataHook />
|
||||
<VisualizerSystemAudioBridgeHook />
|
||||
<AutosaveHook />
|
||||
<AudioPlayersContent
|
||||
audioContext={audioContext}
|
||||
|
||||
@@ -112,7 +112,7 @@ const StopButton = ({ disabled }: { disabled?: boolean }) => {
|
||||
<PlayerButton
|
||||
disabled={disabled}
|
||||
icon={<Icon fill="default" icon="mediaStop" size={buttonSize - 2} />}
|
||||
onClick={mediaStop}
|
||||
onClick={() => mediaStop()}
|
||||
tooltip={{
|
||||
label: t('player.stop', { postProcess: 'sentenceCase' }),
|
||||
openDelay: 0,
|
||||
|
||||
@@ -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,10 +8,16 @@ 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 { useIsRadioActive } from '/@/renderer/features/radio/hooks/use-radio-player';
|
||||
import {
|
||||
useIsRadioActive,
|
||||
useRadioPlayer,
|
||||
} from '/@/renderer/features/radio/hooks/use-radio-player';
|
||||
import { AppRoute } from '/@/renderer/router/routes';
|
||||
import {
|
||||
useAppStore,
|
||||
@@ -50,9 +56,11 @@ export const LeftControls = () => {
|
||||
|
||||
const currentSong = usePlayerSong();
|
||||
const isRadioActive = useIsRadioActive();
|
||||
const { currentStationArt } = useRadioPlayer();
|
||||
const { bindings } = useHotkeySettings();
|
||||
|
||||
const isRadioMode = isRadioActive;
|
||||
const hasRadioStationImage = Boolean(currentStationArt?.imageId || currentStationArt?.imageUrl);
|
||||
const hideImage = image && !collapsed;
|
||||
const isSongDefined = Boolean(currentSong?.id) && !isRadioMode;
|
||||
const title = currentSong?.name;
|
||||
@@ -128,7 +136,22 @@ export const LeftControls = () => {
|
||||
})}
|
||||
openDelay={0}
|
||||
>
|
||||
{isRadioMode ? (
|
||||
{isRadioMode && hasRadioStationImage ? (
|
||||
<ItemImage
|
||||
className={clsx(
|
||||
styles.playerbarImage,
|
||||
PlaybackSelectors.playerCoverArt,
|
||||
)}
|
||||
enableDebounce={false}
|
||||
enableViewport={false}
|
||||
fetchPriority="high"
|
||||
id={currentStationArt?.imageId ?? undefined}
|
||||
itemType={LibraryItem.RADIO_STATION}
|
||||
serverId={currentStationArt?.serverId}
|
||||
src={currentStationArt?.imageUrl ?? ''}
|
||||
type="table"
|
||||
/>
|
||||
) : isRadioMode ? (
|
||||
<Center
|
||||
className={clsx(
|
||||
styles.playerbarImage,
|
||||
@@ -246,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,
|
||||
});
|
||||
|
||||
@@ -64,7 +64,7 @@ export interface PlayerContext {
|
||||
mediaSeekToTimestamp: (timestamp: number) => void;
|
||||
mediaSkipBackward: () => void;
|
||||
mediaSkipForward: () => void;
|
||||
mediaStop: () => void;
|
||||
mediaStop: (options?: { reset?: boolean }) => void;
|
||||
mediaToggleMute: () => void;
|
||||
mediaTogglePlayPause: () => void;
|
||||
moveSelectedTo: (items: QueueSong[], edge: 'bottom' | 'top', uniqueId: string) => void;
|
||||
@@ -596,13 +596,17 @@ export const PlayerProvider = ({ children }: { children: React.ReactNode }) => {
|
||||
storeActions.mediaPrevious();
|
||||
}, [storeActions]);
|
||||
|
||||
const mediaStop = useCallback(() => {
|
||||
logFn.debug(logMsg[LogCategory.PLAYER].mediaStop, {
|
||||
category: LogCategory.PLAYER,
|
||||
});
|
||||
const mediaStop = useCallback(
|
||||
(options?: { reset?: boolean }) => {
|
||||
logFn.debug(logMsg[LogCategory.PLAYER].mediaStop, {
|
||||
category: LogCategory.PLAYER,
|
||||
meta: { reset: options?.reset },
|
||||
});
|
||||
|
||||
storeActions.mediaStop();
|
||||
}, [storeActions]);
|
||||
storeActions.mediaStop(options);
|
||||
},
|
||||
[storeActions],
|
||||
);
|
||||
|
||||
const mediaSeekToTimestamp = useCallback(
|
||||
(timestamp: number) => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -72,6 +72,7 @@ export const CreatePlaylistForm = ({ onCancel }: CreatePlaylistFormProps) => {
|
||||
? {
|
||||
...convertQueryGroupToNDQuery(smartPlaylist.filters),
|
||||
limit: smartPlaylist.extraFilters.limit,
|
||||
limitPercent: smartPlaylist.extraFilters.limitPercent,
|
||||
// order field is now optional - sort direction is embedded in sort field
|
||||
sort: sortValue || '+dateAdded',
|
||||
}
|
||||
|
||||
@@ -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[];
|
||||
|
||||
+7
-7
@@ -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';
|
||||
|
||||
@@ -8,6 +9,8 @@ import { useListContext } from '/@/renderer/context/list-context';
|
||||
import { usePlayer } from '/@/renderer/features/player/context/player-context';
|
||||
import { playlistsQueries } from '/@/renderer/features/playlists/api/playlists-api';
|
||||
import { PlaylistDetailSongListHeaderFilters } from '/@/renderer/features/playlists/components/playlist-detail-song-list-header-filters';
|
||||
import { useDeletePlaylistImage } from '/@/renderer/features/playlists/mutations/delete-playlist-image-mutation';
|
||||
import { useUploadPlaylistImage } from '/@/renderer/features/playlists/mutations/upload-playlist-image-mutation';
|
||||
import { FilterBar } from '/@/renderer/features/shared/components/filter-bar';
|
||||
import {
|
||||
LibraryHeader,
|
||||
@@ -18,9 +21,17 @@ import { ListSearchInput } from '/@/renderer/features/shared/components/list-sea
|
||||
import { AppRoute } from '/@/renderer/router/routes';
|
||||
import { useCurrentServer } from '/@/renderer/store';
|
||||
import { formatDurationString } from '/@/renderer/utils';
|
||||
import { replaceURLWithHTMLLinks } from '/@/renderer/utils/linkify';
|
||||
import { hasFeature } 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 { Spoiler } from '/@/shared/components/spoiler/spoiler';
|
||||
import { Stack } from '/@/shared/components/stack/stack';
|
||||
import { Text } from '/@/shared/components/text/text';
|
||||
import { useLocalStorage } from '/@/shared/hooks/use-local-storage';
|
||||
import { LibraryItem, Song } from '/@/shared/types/domain-types';
|
||||
import { LibraryItem, Playlist, Song } from '/@/shared/types/domain-types';
|
||||
import { ServerFeature } from '/@/shared/types/features-types';
|
||||
import { Play } from '/@/shared/types/types';
|
||||
|
||||
interface PlaylistDetailSongListHeaderProps {
|
||||
@@ -30,6 +41,61 @@ interface PlaylistDetailSongListHeaderProps {
|
||||
onToggleQueryBuilder?: () => void;
|
||||
}
|
||||
|
||||
function ImageUploadOverlay({
|
||||
data,
|
||||
onUploadFile,
|
||||
}: {
|
||||
data?: Playlist;
|
||||
onUploadFile: (file: File) => Promise<void>;
|
||||
}) {
|
||||
const deletePlaylistImageMutation = useDeletePlaylistImage({});
|
||||
const server = useCurrentServer();
|
||||
|
||||
if (!data) return null;
|
||||
if (!hasFeature(server, ServerFeature.PLAYLIST_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;
|
||||
deletePlaylistImageMutation.mutate({
|
||||
apiClientProps: {
|
||||
serverId: data._serverId,
|
||||
},
|
||||
query: { id: data.id },
|
||||
});
|
||||
}}
|
||||
radius="xl"
|
||||
size="xs"
|
||||
variant="default"
|
||||
/>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
export const PlaylistDetailSongListHeader = ({
|
||||
isSmartPlaylist,
|
||||
}: PlaylistDetailSongListHeaderProps) => {
|
||||
@@ -45,6 +111,7 @@ export const PlaylistDetailSongListHeader = ({
|
||||
});
|
||||
|
||||
const playlistDuration = detailQuery?.data?.duration;
|
||||
const playlistDescription = detailQuery?.data?.description?.trim();
|
||||
|
||||
const [collapsed] = useLocalStorage<boolean>({
|
||||
defaultValue: false,
|
||||
@@ -52,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,
|
||||
@@ -94,6 +183,12 @@ export const PlaylistDetailSongListHeader = ({
|
||||
) : (
|
||||
<LibraryHeader
|
||||
compact
|
||||
imageOverlay={
|
||||
<ImageUploadOverlay
|
||||
data={detailQuery?.data}
|
||||
onUploadFile={handlePlaylistImageUpload}
|
||||
/>
|
||||
}
|
||||
imageUrl={imageUrl}
|
||||
item={{
|
||||
imageId: detailQuery?.data?.imageId,
|
||||
@@ -101,13 +196,36 @@ export const PlaylistDetailSongListHeader = ({
|
||||
route: AppRoute.PLAYLISTS,
|
||||
type: LibraryItem.PLAYLIST,
|
||||
}}
|
||||
onImageFileDrop={canUploadPlaylistImage ? handlePlaylistImageUpload : undefined}
|
||||
title={detailQuery?.data?.name || ''}
|
||||
topRight={<ListSearchInput />}
|
||||
>
|
||||
<LibraryHeaderMenu
|
||||
onPlay={(type) => handlePlay(type)}
|
||||
onShuffle={() => handlePlay(Play.SHUFFLE)}
|
||||
/>
|
||||
<Stack gap="md" w="100%">
|
||||
{playlistDescription ? (
|
||||
<Spoiler
|
||||
hideLabel={<></>}
|
||||
maxHeight={16}
|
||||
showLabel={<></>}
|
||||
style={{ marginBottom: 0 }}
|
||||
>
|
||||
<Text
|
||||
isMuted
|
||||
size="sm"
|
||||
style={{
|
||||
maxWidth: '100%',
|
||||
whiteSpace: 'pre-wrap',
|
||||
wordBreak: 'break-word',
|
||||
}}
|
||||
>
|
||||
{replaceURLWithHTMLLinks(playlistDescription)}
|
||||
</Text>
|
||||
</Spoiler>
|
||||
) : null}
|
||||
<LibraryHeaderMenu
|
||||
onPlay={(type) => handlePlay(type)}
|
||||
onShuffle={() => handlePlay(Play.SHUFFLE)}
|
||||
/>
|
||||
</Stack>
|
||||
</LibraryHeader>
|
||||
)}
|
||||
<FilterBar>
|
||||
|
||||
@@ -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[];
|
||||
|
||||
@@ -32,6 +32,7 @@ import { Flex } from '/@/shared/components/flex/flex';
|
||||
import { Group } from '/@/shared/components/group/group';
|
||||
import { NumberInput } from '/@/shared/components/number-input/number-input';
|
||||
import { ScrollArea } from '/@/shared/components/scroll-area/scroll-area';
|
||||
import { SegmentedControl } from '/@/shared/components/segmented-control/segmented-control';
|
||||
import { Select } from '/@/shared/components/select/select';
|
||||
import { Stack } from '/@/shared/components/stack/stack';
|
||||
import { useForm } from '/@/shared/hooks/use-form';
|
||||
@@ -51,6 +52,7 @@ type DeleteArgs = {
|
||||
|
||||
interface PlaylistQueryBuilderProps {
|
||||
limit?: number;
|
||||
limitPercent?: number;
|
||||
playlistId?: string;
|
||||
query: any;
|
||||
sortBy: SongListSort | SongListSort[];
|
||||
@@ -155,6 +157,7 @@ export type PlaylistQueryBuilderRef = {
|
||||
getFilters: () => {
|
||||
extraFilters: {
|
||||
limit?: number;
|
||||
limitPercent?: number;
|
||||
sortBy?: string[];
|
||||
sortOrder?: string;
|
||||
};
|
||||
@@ -164,7 +167,7 @@ export type PlaylistQueryBuilderRef = {
|
||||
|
||||
export const PlaylistQueryBuilder = forwardRef(
|
||||
(
|
||||
{ limit, playlistId, query, sortBy, sortOrder }: PlaylistQueryBuilderProps,
|
||||
{ limit, limitPercent, playlistId, query, sortBy, sortOrder }: PlaylistQueryBuilderProps,
|
||||
ref: Ref<PlaylistQueryBuilderRef>,
|
||||
) => {
|
||||
const { t } = useTranslation();
|
||||
@@ -213,6 +216,8 @@ export const PlaylistQueryBuilder = forwardRef(
|
||||
const extraFiltersForm = useForm({
|
||||
initialValues: {
|
||||
limit,
|
||||
limitMode: limitPercent != null ? 'limitPercent' : 'limit',
|
||||
limitPercent,
|
||||
sortEntries: initialSortEntries,
|
||||
},
|
||||
});
|
||||
@@ -224,16 +229,26 @@ export const PlaylistQueryBuilder = forwardRef(
|
||||
const sortString = convertSortEntriesToSortString(
|
||||
extraFiltersForm.values.sortEntries,
|
||||
);
|
||||
const isLimitPercent = extraFiltersForm.values.limitMode === 'limitPercent';
|
||||
return {
|
||||
extraFilters: {
|
||||
limit: extraFiltersForm.values.limit,
|
||||
limit: isLimitPercent ? undefined : extraFiltersForm.values.limit,
|
||||
limitPercent: isLimitPercent
|
||||
? extraFiltersForm.values.limitPercent
|
||||
: undefined,
|
||||
sortBy: sortString ? [sortString] : undefined,
|
||||
},
|
||||
filters,
|
||||
};
|
||||
},
|
||||
}),
|
||||
[extraFiltersForm.values.sortEntries, extraFiltersForm.values.limit, filters],
|
||||
[
|
||||
extraFiltersForm.values.sortEntries,
|
||||
extraFiltersForm.values.limit,
|
||||
extraFiltersForm.values.limitMode,
|
||||
extraFiltersForm.values.limitPercent,
|
||||
filters,
|
||||
],
|
||||
);
|
||||
|
||||
const handleResetFilters = useCallback(() => {
|
||||
@@ -608,10 +623,50 @@ export const PlaylistQueryBuilder = forwardRef(
|
||||
))}
|
||||
</Stack>
|
||||
<NumberInput
|
||||
label={t('common.limit', { postProcess: 'titleCase' })}
|
||||
maxWidth="20%"
|
||||
label={
|
||||
<Group align="center" gap="xs" wrap="nowrap">
|
||||
{t('common.limit', { postProcess: 'titleCase' })}
|
||||
<SegmentedControl
|
||||
data={[
|
||||
{ label: '#', value: 'limit' },
|
||||
{ label: '%', value: 'limitPercent' },
|
||||
]}
|
||||
onChange={(value) =>
|
||||
extraFiltersForm.setFieldValue(
|
||||
'limitMode',
|
||||
value as 'limit' | 'limitPercent',
|
||||
)
|
||||
}
|
||||
size="xs"
|
||||
value={extraFiltersForm.values.limitMode}
|
||||
/>
|
||||
</Group>
|
||||
}
|
||||
max={
|
||||
extraFiltersForm.values.limitMode === 'limitPercent'
|
||||
? 100
|
||||
: undefined
|
||||
}
|
||||
min={
|
||||
extraFiltersForm.values.limitMode === 'limitPercent'
|
||||
? 0
|
||||
: undefined
|
||||
}
|
||||
onChange={(value) => {
|
||||
const nextValue =
|
||||
value === '' || value == null ? undefined : Number(value);
|
||||
if (extraFiltersForm.values.limitMode === 'limitPercent') {
|
||||
extraFiltersForm.setFieldValue('limitPercent', nextValue);
|
||||
} else {
|
||||
extraFiltersForm.setFieldValue('limit', nextValue);
|
||||
}
|
||||
}}
|
||||
value={
|
||||
extraFiltersForm.values.limitMode === 'limitPercent'
|
||||
? extraFiltersForm.values.limitPercent
|
||||
: extraFiltersForm.values.limit
|
||||
}
|
||||
width={75}
|
||||
{...extraFiltersForm.getInputProps('limit')}
|
||||
/>
|
||||
</Group>
|
||||
</Stack>
|
||||
|
||||
@@ -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,14 +26,24 @@ 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: { limit?: number; sortBy?: string[]; sortOrder?: string },
|
||||
extraFilters: {
|
||||
limit?: number;
|
||||
limitPercent?: number;
|
||||
sortBy?: string[];
|
||||
sortOrder?: string;
|
||||
},
|
||||
) => void;
|
||||
handleSaveAs: (
|
||||
filter: Record<string, any>,
|
||||
extraFilters: { limit?: number; sortBy?: string[]; sortOrder?: string },
|
||||
extraFilters: {
|
||||
limit?: number;
|
||||
limitPercent?: number;
|
||||
sortBy?: string[];
|
||||
sortOrder?: string;
|
||||
},
|
||||
) => void;
|
||||
isQueryBuilderExpanded: boolean;
|
||||
onToggleExpand: () => void;
|
||||
@@ -43,6 +54,7 @@ export interface PlaylistQueryEditorProps {
|
||||
|
||||
type AppliedJsonState = {
|
||||
limit?: number;
|
||||
limitPercent?: number;
|
||||
query: Record<string, any>;
|
||||
sort?: string;
|
||||
};
|
||||
@@ -50,7 +62,7 @@ type AppliedJsonState = {
|
||||
type EditorMode = 'builder' | 'json';
|
||||
|
||||
const serializeFiltersToRulesJson = (filters: {
|
||||
extraFilters: { limit?: number; sortBy?: string[] };
|
||||
extraFilters: { limit?: number; limitPercent?: number; sortBy?: string[] };
|
||||
filters: any;
|
||||
}): Record<string, any> => {
|
||||
const queryValue = convertQueryGroupToNDQuery(filters.filters);
|
||||
@@ -58,18 +70,25 @@ const serializeFiltersToRulesJson = (filters: {
|
||||
return {
|
||||
...queryValue,
|
||||
...(filters.extraFilters.limit != null && { limit: filters.extraFilters.limit }),
|
||||
...(filters.extraFilters.limitPercent != null && {
|
||||
limitPercent: filters.extraFilters.limitPercent,
|
||||
}),
|
||||
...(sortString && { sort: sortString }),
|
||||
};
|
||||
};
|
||||
|
||||
const parseRulesJsonToSaveArgs = (
|
||||
parsed: Record<string, any>,
|
||||
): { extraFilters: { limit?: number; sortBy?: string[] }; filter: Record<string, any> } => {
|
||||
): {
|
||||
extraFilters: { limit?: number; limitPercent?: number; sortBy?: string[] };
|
||||
filter: Record<string, any>;
|
||||
} => {
|
||||
const rootKey = parsed.all ? 'all' : 'any';
|
||||
const filter = rootKey in parsed ? { [rootKey]: parsed[rootKey] } : { all: [] };
|
||||
return {
|
||||
extraFilters: {
|
||||
...(parsed.limit != null && { limit: parsed.limit }),
|
||||
...(parsed.limitPercent != null && { limitPercent: parsed.limitPercent }),
|
||||
...(parsed.sort != null && { sortBy: [parsed.sort] }),
|
||||
},
|
||||
filter,
|
||||
@@ -93,7 +112,12 @@ export const PlaylistQueryEditor = ({
|
||||
const [appliedJsonState, setAppliedJsonState] = useState<AppliedJsonState | null>(null);
|
||||
|
||||
const getFiltersForSave = useCallback((): null | {
|
||||
extraFilters: { limit?: number; sortBy?: string[]; sortOrder?: string };
|
||||
extraFilters: {
|
||||
limit?: number;
|
||||
limitPercent?: number;
|
||||
sortBy?: string[];
|
||||
sortOrder?: string;
|
||||
};
|
||||
filter: Record<string, any>;
|
||||
} => {
|
||||
if (editorMode === 'json') {
|
||||
@@ -124,6 +148,9 @@ export const PlaylistQueryEditor = ({
|
||||
const previewValue = {
|
||||
...payload.filter,
|
||||
...(payload.extraFilters.limit != null && { limit: payload.extraFilters.limit }),
|
||||
...(payload.extraFilters.limitPercent != null && {
|
||||
limitPercent: payload.extraFilters.limitPercent,
|
||||
}),
|
||||
...(payload.extraFilters.sortBy?.[0] && { sort: payload.extraFilters.sortBy[0] }),
|
||||
};
|
||||
openModal({
|
||||
@@ -208,6 +235,8 @@ export const PlaylistQueryEditor = ({
|
||||
[appliedJsonState?.query, detailQuery?.data?.rules],
|
||||
);
|
||||
const effectiveLimit = appliedJsonState?.limit ?? detailQuery?.data?.rules?.limit;
|
||||
const effectiveLimitPercent =
|
||||
appliedJsonState?.limitPercent ?? detailQuery?.data?.rules?.limitPercent;
|
||||
const effectiveSortBy = useMemo(
|
||||
() =>
|
||||
(appliedJsonState?.sort ? [appliedJsonState.sort] : parseSortBy()) as
|
||||
@@ -233,6 +262,8 @@ export const PlaylistQueryEditor = ({
|
||||
? { ...effectiveQuery }
|
||||
: { all: [] };
|
||||
if (effectiveLimit != null) fallback.limit = effectiveLimit;
|
||||
if (effectiveLimitPercent != null)
|
||||
fallback.limitPercent = effectiveLimitPercent;
|
||||
if (effectiveSortBy?.[0]) fallback.sort = effectiveSortBy[0];
|
||||
if (!fallback.sort) fallback.sort = '+dateAdded';
|
||||
setJsonText(JSON.stringify(fallback, null, 2));
|
||||
@@ -248,6 +279,7 @@ export const PlaylistQueryEditor = ({
|
||||
}
|
||||
setAppliedJsonState({
|
||||
limit: parsed.limit,
|
||||
limitPercent: parsed.limitPercent,
|
||||
query: { [rootKey]: parsed[rootKey] },
|
||||
sort: parsed.sort,
|
||||
});
|
||||
@@ -263,7 +295,16 @@ export const PlaylistQueryEditor = ({
|
||||
setEditorMode('builder');
|
||||
}
|
||||
},
|
||||
[editorMode, effectiveLimit, effectiveQuery, effectiveSortBy, jsonText, queryBuilderRef, t],
|
||||
[
|
||||
editorMode,
|
||||
effectiveLimit,
|
||||
effectiveLimitPercent,
|
||||
effectiveQuery,
|
||||
effectiveSortBy,
|
||||
jsonText,
|
||||
queryBuilderRef,
|
||||
t,
|
||||
],
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -367,6 +408,7 @@ export const PlaylistQueryEditor = ({
|
||||
<PlaylistQueryBuilder
|
||||
key={JSON.stringify(appliedJsonState ?? detailQuery?.data?.rules)}
|
||||
limit={effectiveLimit}
|
||||
limitPercent={effectiveLimitPercent}
|
||||
playlistId={playlistId}
|
||||
query={effectiveQuery}
|
||||
ref={queryBuilderRef}
|
||||
@@ -380,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>
|
||||
|
||||
@@ -1,21 +1,32 @@
|
||||
import { closeModal, ContextModalProps } from '@mantine/modals';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { t } from 'i18next';
|
||||
import { type ReactNode, useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { ItemImage } from '/@/renderer/components/item-image/item-image';
|
||||
import { useDeletePlaylistImage } from '/@/renderer/features/playlists/mutations/delete-playlist-image-mutation';
|
||||
import { useUpdatePlaylist } from '/@/renderer/features/playlists/mutations/update-playlist-mutation';
|
||||
import { useUploadPlaylistImage } from '/@/renderer/features/playlists/mutations/upload-playlist-image-mutation';
|
||||
import { sharedQueries } from '/@/renderer/features/shared/api/shared-api';
|
||||
import { useCurrentServer, useCurrentServerId, usePermissions } 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 { 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';
|
||||
import { ModalButton } from '/@/shared/components/modal/model-shared';
|
||||
import { Select } from '/@/shared/components/select/select';
|
||||
import { Stack } from '/@/shared/components/stack/stack';
|
||||
import { Switch } from '/@/shared/components/switch/switch';
|
||||
import { TextInput } from '/@/shared/components/text-input/text-input';
|
||||
import { Textarea } from '/@/shared/components/textarea/textarea';
|
||||
import { toast } from '/@/shared/components/toast/toast';
|
||||
import { useForm } from '/@/shared/hooks/use-form';
|
||||
import {
|
||||
LibraryItem,
|
||||
ServerType,
|
||||
SortOrder,
|
||||
UpdatePlaylistBody,
|
||||
@@ -24,17 +35,41 @@ import {
|
||||
} from '/@/shared/types/domain-types';
|
||||
import { ServerFeature } from '/@/shared/types/features-types';
|
||||
|
||||
type PlaylistImageProps = {
|
||||
imageId: null | string;
|
||||
imageUrl: null | string;
|
||||
uploadedImage?: string;
|
||||
};
|
||||
|
||||
export const UpdatePlaylistContextModal = ({
|
||||
id,
|
||||
innerProps,
|
||||
}: ContextModalProps<{
|
||||
body: Partial<UpdatePlaylistBody>;
|
||||
playlistImage?: PlaylistImageProps;
|
||||
query: UpdatePlaylistQuery;
|
||||
}>) => {
|
||||
const { t } = useTranslation();
|
||||
const mutation = useUpdatePlaylist({});
|
||||
const updateMutation = useUpdatePlaylist({});
|
||||
const uploadImageMutation = useUploadPlaylistImage({});
|
||||
const deleteImageMutation = useDeletePlaylistImage({});
|
||||
const server = useCurrentServer();
|
||||
const { body, query } = innerProps;
|
||||
const { body, playlistImage, query } = innerProps;
|
||||
|
||||
const [pendingFile, setPendingFile] = useState<File | null>(null);
|
||||
const [pendingPreviewUrl, setPendingPreviewUrl] = useState<null | string>(null);
|
||||
const [removeCustomCover, setRemoveCustomCover] = useState(false);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!pendingFile) {
|
||||
setPendingPreviewUrl(null);
|
||||
return;
|
||||
}
|
||||
const url = URL.createObjectURL(pendingFile);
|
||||
setPendingPreviewUrl(url);
|
||||
return () => URL.revokeObjectURL(url);
|
||||
}, [pendingFile]);
|
||||
|
||||
const form = useForm<UpdatePlaylistBody>({
|
||||
initialValues: {
|
||||
@@ -47,91 +82,273 @@ export const UpdatePlaylistContextModal = ({
|
||||
},
|
||||
});
|
||||
|
||||
const handleSubmit = form.onSubmit((values) => {
|
||||
mutation.mutate(
|
||||
{
|
||||
apiClientProps: { serverId: server?.id || '' },
|
||||
const handleSubmit = form.onSubmit(async (values) => {
|
||||
if (!server?.id) return;
|
||||
|
||||
setIsSaving(true);
|
||||
try {
|
||||
await updateMutation.mutateAsync({
|
||||
apiClientProps: { serverId: server.id },
|
||||
body: values,
|
||||
query,
|
||||
},
|
||||
{
|
||||
onError: (err) => {
|
||||
toast.error({
|
||||
message: err.message,
|
||||
title: t('error.genericError', { postProcess: 'sentenceCase' }),
|
||||
});
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success({
|
||||
message: t('form.editPlaylist.success', { postProcess: 'sentenceCase' }),
|
||||
});
|
||||
closeModal(id);
|
||||
},
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
if (pendingFile) {
|
||||
const buffer = await pendingFile.arrayBuffer();
|
||||
await uploadImageMutation.mutateAsync({
|
||||
apiClientProps: { serverId: server.id },
|
||||
body: { image: new Uint8Array(buffer) },
|
||||
query: { id: query.id },
|
||||
});
|
||||
} else if (removeCustomCover && playlistImage?.uploadedImage) {
|
||||
await deleteImageMutation.mutateAsync({
|
||||
apiClientProps: { serverId: server.id },
|
||||
query: { id: query.id },
|
||||
});
|
||||
}
|
||||
|
||||
toast.success({
|
||||
message: t('form.editPlaylist.success', { postProcess: 'sentenceCase' }),
|
||||
});
|
||||
closeModal(id);
|
||||
} catch (err: any) {
|
||||
toast.error({
|
||||
message: err?.message,
|
||||
title: t('error.genericError', { postProcess: 'sentenceCase' }),
|
||||
});
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
});
|
||||
|
||||
const isPublicDisplayed = hasFeature(server, ServerFeature.PUBLIC_PLAYLIST);
|
||||
const isOwnerDisplayed = server?.type === ServerType.NAVIDROME;
|
||||
const isCommentDisplayed = server?.type === ServerType.NAVIDROME;
|
||||
const isSubmitDisabled = !form.values.name || mutation.isPending;
|
||||
const isCoverImageDisplayed = hasFeature(server, ServerFeature.PLAYLIST_IMAGE_UPLOAD);
|
||||
const isSubmitDisabled = !form.values.name || isSaving;
|
||||
const hadUploadedCover = !!playlistImage?.uploadedImage;
|
||||
|
||||
const fieldNodes: ReactNode[] = [
|
||||
<TextInput
|
||||
data-autofocus
|
||||
key="name"
|
||||
label={t('form.createPlaylist.input', {
|
||||
context: 'name',
|
||||
postProcess: 'titleCase',
|
||||
})}
|
||||
required
|
||||
{...form.getInputProps('name')}
|
||||
/>,
|
||||
];
|
||||
|
||||
if (isCommentDisplayed) {
|
||||
fieldNodes.push(
|
||||
<Textarea
|
||||
autosize
|
||||
key="comment"
|
||||
label={t('form.createPlaylist.input', {
|
||||
context: 'description',
|
||||
postProcess: 'titleCase',
|
||||
})}
|
||||
minRows={5}
|
||||
{...form.getInputProps('comment')}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
if (isOwnerDisplayed) {
|
||||
fieldNodes.push(<OwnerSelect form={form} key="owner" />);
|
||||
}
|
||||
|
||||
if (isPublicDisplayed) {
|
||||
if (server?.type === ServerType.JELLYFIN) {
|
||||
fieldNodes.push(
|
||||
<div key="jellyfin-public-note">
|
||||
{t('form.editPlaylist.publicJellyfinNote', {
|
||||
postProcess: 'sentenceCase',
|
||||
})}
|
||||
</div>,
|
||||
);
|
||||
}
|
||||
fieldNodes.push(
|
||||
<Switch
|
||||
key="public"
|
||||
label={t('form.createPlaylist.input', {
|
||||
context: 'public',
|
||||
postProcess: 'titleCase',
|
||||
})}
|
||||
{...form.getInputProps('public', { type: 'checkbox' })}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
fieldNodes.push(
|
||||
<Group justify="flex-end" key="actions">
|
||||
<ModalButton disabled={isSaving} onClick={() => closeModal(id)}>
|
||||
{t('common.cancel')}
|
||||
</ModalButton>
|
||||
<ModalButton
|
||||
disabled={isSubmitDisabled}
|
||||
loading={isSaving}
|
||||
type="submit"
|
||||
variant="filled"
|
||||
>
|
||||
{t('common.save')}
|
||||
</ModalButton>
|
||||
</Group>,
|
||||
);
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit}>
|
||||
<Stack>
|
||||
<TextInput
|
||||
data-autofocus
|
||||
label={t('form.createPlaylist.input', {
|
||||
context: 'name',
|
||||
postProcess: 'titleCase',
|
||||
})}
|
||||
required
|
||||
{...form.getInputProps('name')}
|
||||
/>
|
||||
{isCommentDisplayed && (
|
||||
<TextInput
|
||||
label={t('form.createPlaylist.input', {
|
||||
context: 'description',
|
||||
postProcess: 'titleCase',
|
||||
})}
|
||||
{...form.getInputProps('comment')}
|
||||
{isCoverImageDisplayed ? (
|
||||
<Flex align="flex-start" gap="lg" wrap="wrap">
|
||||
<PlaylistCoverField
|
||||
hadUploadedCover={hadUploadedCover}
|
||||
onClearPending={() => setPendingFile(null)}
|
||||
onFileSelect={(file) => {
|
||||
if (!file) return;
|
||||
setRemoveCustomCover(false);
|
||||
setPendingFile(file);
|
||||
}}
|
||||
onToggleRemoveCover={() => setRemoveCustomCover((v) => !v)}
|
||||
pendingFile={pendingFile}
|
||||
pendingPreviewUrl={pendingPreviewUrl}
|
||||
playlistImage={playlistImage}
|
||||
removeCustomCover={removeCustomCover}
|
||||
/>
|
||||
)}
|
||||
{isOwnerDisplayed && <OwnerSelect form={form} />}
|
||||
{isPublicDisplayed && (
|
||||
<>
|
||||
{server?.type === ServerType.JELLYFIN && (
|
||||
<div>
|
||||
{t('form.editPlaylist.publicJellyfinNote', {
|
||||
postProcess: 'sentenceCase',
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
<Switch
|
||||
label={t('form.createPlaylist.input', {
|
||||
context: 'public',
|
||||
postProcess: 'titleCase',
|
||||
})}
|
||||
{...form.getInputProps('public', { type: 'checkbox' })}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<Group justify="flex-end">
|
||||
<ModalButton onClick={() => closeModal(id)}>{t('common.cancel')}</ModalButton>
|
||||
<ModalButton
|
||||
disabled={isSubmitDisabled}
|
||||
loading={mutation.isPending}
|
||||
type="submit"
|
||||
variant="filled"
|
||||
>
|
||||
{t('common.save')}
|
||||
</ModalButton>
|
||||
</Group>
|
||||
</Stack>
|
||||
<Stack gap="md" style={{ flex: '1 1 220px', minWidth: 0 }}>
|
||||
{fieldNodes}
|
||||
</Stack>
|
||||
</Flex>
|
||||
) : (
|
||||
<Stack gap="md">{fieldNodes}</Stack>
|
||||
)}
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
const COVER_SIZE = 240;
|
||||
|
||||
function PlaylistCoverField({
|
||||
hadUploadedCover,
|
||||
onClearPending,
|
||||
onFileSelect,
|
||||
onToggleRemoveCover,
|
||||
pendingFile,
|
||||
pendingPreviewUrl,
|
||||
playlistImage,
|
||||
removeCustomCover,
|
||||
}: {
|
||||
hadUploadedCover: boolean;
|
||||
onClearPending: () => void;
|
||||
onFileSelect: (file: File | null) => void;
|
||||
onToggleRemoveCover: () => void;
|
||||
pendingFile: File | null;
|
||||
pendingPreviewUrl: null | string;
|
||||
playlistImage?: PlaylistImageProps;
|
||||
removeCustomCover: boolean;
|
||||
}) {
|
||||
const server = useCurrentServer();
|
||||
|
||||
const showServerCover = !pendingPreviewUrl && !removeCustomCover;
|
||||
const previewId = showServerCover ? playlistImage?.imageId || undefined : undefined;
|
||||
const previewSrc = pendingPreviewUrl || (showServerCover ? playlistImage?.imageUrl || '' : '');
|
||||
|
||||
const secondaryAction = () => {
|
||||
if (pendingFile) {
|
||||
onClearPending();
|
||||
return;
|
||||
}
|
||||
if (hadUploadedCover) {
|
||||
onToggleRemoveCover();
|
||||
}
|
||||
};
|
||||
|
||||
const secondaryDisabled = !pendingFile && !hadUploadedCover;
|
||||
|
||||
const secondaryIcon = pendingFile ? 'x' : removeCustomCover ? 'arrowLeft' : 'delete';
|
||||
|
||||
const iconControls = (
|
||||
<>
|
||||
<FileButton accept="image/*" onChange={onFileSelect}>
|
||||
{(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}
|
||||
icon={secondaryIcon}
|
||||
iconProps={{ size: 'lg' }}
|
||||
onClick={secondaryAction}
|
||||
radius="xl"
|
||||
size="sm"
|
||||
style={{ pointerEvents: 'auto' }}
|
||||
variant="default"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<Box
|
||||
style={{
|
||||
borderRadius: 'var(--mantine-radius-md)',
|
||||
flexShrink: 0,
|
||||
height: COVER_SIZE,
|
||||
overflow: 'hidden',
|
||||
position: 'relative',
|
||||
width: COVER_SIZE,
|
||||
}}
|
||||
>
|
||||
<DragDropZone
|
||||
accept="image/*"
|
||||
mode="file"
|
||||
onFileSelected={(file) => onFileSelect(file)}
|
||||
style={{
|
||||
height: '100%',
|
||||
overflow: 'hidden',
|
||||
position: 'relative',
|
||||
width: '100%',
|
||||
}}
|
||||
>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
const OwnerSelect = ({ form }: { form: ReturnType<typeof useForm<UpdatePlaylistBody>> }) => {
|
||||
const serverId = useCurrentServerId();
|
||||
const permissions = usePermissions();
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user