Compare commits

..

4 Commits

Author SHA1 Message Date
jeffvli 5900d41e0a handle sticky elements on new layout 2026-04-04 13:42:50 -07:00
jeffvli efe94b3a3b inset the windowbar 2026-04-04 13:25:35 -07:00
jeffvli 231b6f3865 inset the playerbar 2026-04-04 13:21:22 -07:00
jeffvli 2fbd3ab02d inset the main content / sidebars 2026-04-04 13:21:01 -07:00
138 changed files with 3485 additions and 5011 deletions
-4
View File
@@ -169,10 +169,6 @@ Feishin supports any music server that implements a [Navidrome](https://www.navi
- [Qm-Music](https://github.com/chenqimiao/qm-music) - [Qm-Music](https://github.com/chenqimiao/qm-music)
- More (?) - 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 ### 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. 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.
+2 -4
View File
@@ -43,11 +43,9 @@ mac:
icon: assets/icons/icon.icns icon: assets/icons/icon.icns
type: distribution type: distribution
hardenedRuntime: false hardenedRuntime: false
identity: '-' identity: "-"
gatekeeperAssess: false gatekeeperAssess: false
notarize: false notarize: false
extendInfo:
NSLocalNetworkUsageDescription: 'Local network is necessary for accessing servers hosted on the same system as Feishin'
dmg: dmg:
contents: [{ x: 130, y: 220 }, { x: 410, y: 220, type: link, path: /Applications }] contents: [{ x: 130, y: 220 }, { x: 410, y: 220, type: link, path: /Applications }]
@@ -62,7 +60,7 @@ linux:
artifactName: ${productName}-${os}-${arch}.${ext} artifactName: ${productName}-${os}-${arch}.${ext}
toolsets: toolsets:
appimage: '1.0.2' appimage: "1.0.2"
npmRebuild: false npmRebuild: false
afterAllArtifactBuild: scripts/after-all-artifact-build.mjs afterAllArtifactBuild: scripts/after-all-artifact-build.mjs
+2 -3
View File
@@ -1,11 +1,10 @@
import react from '@vitejs/plugin-react';
import { externalizeDepsPlugin, UserConfig } from 'electron-vite'; import { externalizeDepsPlugin, UserConfig } from 'electron-vite';
import { resolve } from 'path'; import { resolve } from 'path';
import conditionalImportPlugin from 'vite-plugin-conditional-import'; import conditionalImportPlugin from 'vite-plugin-conditional-import';
import dynamicImportPlugin from 'vite-plugin-dynamic-import'; import dynamicImportPlugin from 'vite-plugin-dynamic-import';
import { ViteEjsPlugin } from 'vite-plugin-ejs'; import { ViteEjsPlugin } from 'vite-plugin-ejs';
import { createReactPlugin } from './vite.react-plugin';
const currentOSEnv = process.platform; const currentOSEnv = process.platform;
const electronRendererTarget = 'chrome87'; const electronRendererTarget = 'chrome87';
@@ -65,7 +64,7 @@ const config: UserConfig = {
localsConvention: 'camelCase', localsConvention: 'camelCase',
}, },
}, },
plugins: [createReactPlugin(), ViteEjsPlugin({ web: false })], plugins: [react(), ViteEjsPlugin({ web: false })],
resolve: { resolve: {
alias: { alias: {
'/@/i18n': resolve('src/i18n'), '/@/i18n': resolve('src/i18n'),
+1 -1
View File
@@ -25,7 +25,7 @@ export default tseslint.config(
'react-refresh': eslintPluginReactRefresh, 'react-refresh': eslintPluginReactRefresh,
}, },
rules: { rules: {
...eslintPluginReactHooks.configs['recommended-latest'].rules, ...eslintPluginReactHooks.configs.recommended.rules,
...eslintPluginReactRefresh.configs.vite.rules, ...eslintPluginReactRefresh.configs.vite.rules,
'@typescript-eslint/explicit-function-return-type': 'off', '@typescript-eslint/explicit-function-return-type': 'off',
'@typescript-eslint/no-duplicate-enum-values': 'off', '@typescript-eslint/no-duplicate-enum-values': 'off',
+74 -78
View File
@@ -1,6 +1,6 @@
{ {
"name": "feishin", "name": "feishin",
"version": "1.10.0", "version": "1.9.0",
"description": "A modern self-hosted music player.", "description": "A modern self-hosted music player.",
"keywords": [ "keywords": [
"subsonic", "subsonic",
@@ -31,36 +31,36 @@
"i18next": "i18next -c src/i18n/i18next-parser.config.js", "i18next": "i18next -c src/i18n/i18next-parser.config.js",
"postinstall": "electron-builder install-app-deps", "postinstall": "electron-builder install-app-deps",
"lint": "pnpm run typecheck && pnpm run lint-code && pnpm run lint-styles", "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": "eslint --max-warnings=0 --cache .",
"lint-code:fix": "eslint --cache --fix .", "lint-code:fix": "eslint --cache --fix .",
"lint-styles": "stylelint --max-warnings=0 'src/**/*.{css,scss}'", "lint-styles": "stylelint --max-warnings=0 'src/**/*.{css,scss}'",
"lint-styles:fix": "stylelint 'src/**/*.{css,scss}' --fix", "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": "pnpm run build && electron-builder",
"package:dev": "pnpm run build && electron-builder --dir", "package:dev": "pnpm run build && electron-builder --dir",
"package:linux": "pnpm run build && electron-builder --linux", "package:linux": "pnpm run build && electron-builder --linux",
"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:linux-arm64:pr": "pnpm run build && electron-builder --linux --arm64 --publish never",
"package:linux:pr": "pnpm run build && electron-builder --linux --publish never",
"package:mac": "pnpm run build && electron-builder --mac", "package:mac": "pnpm run build && electron-builder --mac",
"package:mac:pr": "pnpm run build && electron-builder --mac --publish never", "package:mac:pr": "pnpm run build && electron-builder --mac --publish never",
"package:win": "pnpm run build && electron-builder --win", "package:win": "pnpm run build && electron-builder --win",
"package:win:pr": "pnpm run build && electron-builder --win --publish never",
"package:win-arm64:pr": "pnpm run build && electron-builder --win --arm64 --publish never", "package:win-arm64:pr": "pnpm run build && electron-builder --win --arm64 --publish never",
"package:win:pr": "pnpm run build && electron-builder --win --publish never",
"publish:linux": "pnpm run build && electron-builder --publish always --linux", "publish:linux": "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": "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: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-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": "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: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: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": "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": "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: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-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", "start": "electron-vite preview",
"typecheck": "pnpm run typecheck:node && pnpm run typecheck:web", "typecheck": "pnpm run typecheck:node && pnpm run typecheck:web",
"typecheck:node": "tsc --noEmit -p tsconfig.node.json --composite false", "typecheck:node": "tsc --noEmit -p tsconfig.node.json --composite false",
@@ -68,123 +68,119 @@
"version": "pnpm version --no-git-tag-version", "version": "pnpm version --no-git-tag-version",
"postversion": "node ./scripts/update-app-stream.mjs" "postversion": "node ./scripts/update-app-stream.mjs"
}, },
"resolutions": {
"react-router": "7.14.0",
"xml2js": "0.5.0"
},
"dependencies": { "dependencies": {
"@atlaskit/pragmatic-drag-and-drop": "1.7.7", "@atlaskit/pragmatic-drag-and-drop": "1.7.7",
"@atlaskit/pragmatic-drag-and-drop-auto-scroll": "^2.1.5", "@atlaskit/pragmatic-drag-and-drop-auto-scroll": "^2.1.2",
"@atlaskit/pragmatic-drag-and-drop-hitbox": "^1.1.0", "@atlaskit/pragmatic-drag-and-drop-hitbox": "^1.1.0",
"@electron-toolkit/preload": "^3.0.2", "@electron-toolkit/preload": "^3.0.1",
"@electron-toolkit/utils": "^4.0.0", "@electron-toolkit/utils": "^4.0.0",
"@mantine/colors-generator": "^8.3.18", "@mantine/colors-generator": "^8.3.8",
"@mantine/core": "^8.3.18", "@mantine/core": "^8.3.8",
"@mantine/dates": "^8.3.18", "@mantine/dates": "^8.3.8",
"@mantine/form": "^8.3.18", "@mantine/form": "^8.3.8",
"@mantine/hooks": "^8.3.18", "@mantine/hooks": "^8.3.8",
"@mantine/modals": "^8.3.18", "@mantine/modals": "^8.3.8",
"@mantine/notifications": "^8.3.18", "@mantine/notifications": "^8.3.8",
"@radix-ui/react-context-menu": "^2.2.16", "@radix-ui/react-context-menu": "^2.2.16",
"@tanstack/react-query": "^5.96.2", "@tanstack/react-query": "^5.90.9",
"@tanstack/react-query-devtools": "^5.96.2", "@tanstack/react-query-devtools": "^5.90.2",
"@tanstack/react-query-persist-client": "^5.96.2", "@tanstack/react-query-persist-client": "^5.90.11",
"@ts-rest/core": "^3.52.1", "@ts-rest/core": "^3.52.1",
"@wavesurfer/react": "^1.0.12", "@wavesurfer/react": "^1.0.11",
"@xhayper/discord-rpc": "^1.3.3", "@xhayper/discord-rpc": "^1.3.0",
"audiomotion-analyzer": "^4.5.4", "audiomotion-analyzer": "^4.5.1",
"axios": "^1.14.0", "axios": "^1.13.5",
"butterchurn": "3.0.0-beta.5", "butterchurn": "^3.0.0-beta.5",
"butterchurn-presets": "3.0.0-beta.4", "butterchurn-presets": "^3.0.0-beta.4",
"cheerio": "^1.2.0", "cheerio": "^1.1.2",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"cmdk": "^1.1.1", "cmdk": "^1.1.1",
"dayjs": "^1.11.20", "dayjs": "^1.11.19",
"dompurify": "^3.3.3", "dompurify": "^3.3.0",
"electron-debug": "^3.2.0", "electron-debug": "^3.2.0",
"electron-localshortcut": "^3.2.1", "electron-localshortcut": "^3.2.1",
"electron-log": "^5.4.3", "electron-log": "^5.4.3",
"electron-store": "^8.2.0", "electron-store": "^8.2.0",
"electron-updater": "^6.8.3", "electron-updater": "^6.6.2",
"fast-average-color": "9.5.0", "fast-average-color": "^9.5.0",
"fast-xml-parser": "^5.5.10", "fast-xml-parser": "^5.3.8",
"format-duration": "^3.0.2", "format-duration": "^3.0.2",
"fuse.js": "^7.2.0", "fuse.js": "^7.1.0",
"i18next": "^25.10.10", "i18next": "^25.6.2",
"icecast-metadata-stats": "^0.1.12", "icecast-metadata-stats": "^0.1.12",
"idb-keyval": "^6.2.2", "idb-keyval": "^6.2.2",
"immer": "^10.2.0", "immer": "^10.2.0",
"is-electron": "^2.2.2", "is-electron": "^2.2.2",
"lodash": "^4.18.1", "lodash": "^4.17.23",
"md5": "^2.3.0", "md5": "^2.3.0",
"motion": "^12.38.0", "motion": "^12.23.24",
"mpris-service": "^2.1.2", "mpris-service": "^2.1.2",
"nanoid": "^3.3.11", "nanoid": "^3.3.11",
"node-mpv": "github:jeffvli/Node-MPV#32b4d64395289ad710c41d481d2707a7acfc228f", "node-mpv": "github:jeffvli/Node-MPV#32b4d64395289ad710c41d481d2707a7acfc228f",
"overlayscrollbars": "^2.14.0", "nuqs": "^2.7.1",
"overlayscrollbars": "^2.11.1",
"overlayscrollbars-react": "^0.5.6", "overlayscrollbars-react": "^0.5.6",
"qs": "^6.15.0", "qs": "^6.14.2",
"react": "^19.2.4", "react": "^19.1.0",
"react-call": "^1.8.2", "react-call": "^1.8.1",
"react-dom": "^19.2.4", "react-dom": "^19.1.0",
"react-error-boundary": "^5.0.0", "react-error-boundary": "^5.0.0",
"react-i18next": "^16.6.6", "react-i18next": "^16.3.3",
"react-icons": "^5.6.0", "react-icons": "^5.5.0",
"react-player": "^2.16.1", "react-player": "^2.16.0",
"react-router": "^7.14.0", "react-router": "^7.13.1",
"react-split-pane": "^3.2.0", "react-split-pane": "^3.0.4",
"react-virtualized-auto-sizer": "^1.0.26", "react-virtualized-auto-sizer": "^1.0.26",
"react-window": "1.8.11", "react-window": "1.8.11",
"react-window-v2": "npm:react-window@^2.2.7", "react-window-v2": "npm:react-window@^2.2.3",
"semver": "^7.7.4", "semver": "^7.5.4",
"string-to-color": "^2.2.2", "string-to-color": "^2.2.2",
"wavesurfer.js": "^7.12.5", "wavesurfer.js": "^7.11.1",
"ws": "^8.20.0", "ws": "^8.18.2",
"zod": "^3.25.76", "zod": "^3.22.3",
"zustand": "^5.0.12" "zustand": "^5.0.5"
}, },
"devDependencies": { "devDependencies": {
"@electron-toolkit/eslint-config-prettier": "^3.0.0", "@electron-toolkit/eslint-config-prettier": "^3.0.0",
"@electron-toolkit/eslint-config-ts": "^3.1.0", "@electron-toolkit/eslint-config-ts": "^3.0.0",
"@electron-toolkit/tsconfig": "^2.0.0", "@electron-toolkit/tsconfig": "^2.0.0",
"@types/electron-localshortcut": "^3.1.3", "@types/electron-localshortcut": "^3.1.0",
"@types/lodash": "^4.17.24", "@types/lodash": "^4.17.18",
"@types/md5": "^2.3.6", "@types/md5": "^2.3.5",
"@types/node": "^24.12.2", "@types/node": "^24.10.1",
"@types/react": "^19.2.14", "@types/react": "^19.2.5",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
"@types/react-window": "^1.8.8", "@types/react-window": "^1.8.8",
"@types/source-map-support": "^0.5.10", "@types/source-map-support": "^0.5.10",
"@types/ws": "^8.18.1", "@types/ws": "^8.18.1",
"@vitejs/plugin-react": "^5.2.0", "@vitejs/plugin-react": "^5.1.1",
"babel-plugin-react-compiler": "^1.0.0",
"concurrently": "^9.2.1", "concurrently": "^9.2.1",
"cross-env": "^10.1.0", "cross-env": "^10.1.0",
"electron": "^39.8.6", "electron": "^39.4.0",
"electron-builder": "^26.8.2", "electron-builder": "^26.8.2",
"electron-devtools-installer": "^4.0.0", "electron-devtools-installer": "^4.0.0",
"electron-vite": "^4.0.1", "electron-vite": "^4.0.1",
"eslint": "^9.39.4", "eslint": "^9.24.0",
"eslint-plugin-perfectionist": "^4.15.1", "eslint-plugin-perfectionist": "^4.13.0",
"eslint-plugin-prettier": "^5.5.5", "eslint-plugin-prettier": "^5.4.0",
"eslint-plugin-react": "^7.37.5", "eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-hooks": "^7.0.1", "eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.26", "eslint-plugin-react-refresh": "^0.4.24",
"i18next-parser": "^9.4.0", "i18next-parser": "^9.3.0",
"postcss-preset-mantine": "^1.18.0", "postcss-preset-mantine": "^1.18.0",
"postcss-simple-vars": "^7.0.1", "postcss-simple-vars": "^7.0.1",
"prettier": "^3.8.1", "prettier": "^3.6.2",
"prettier-plugin-packagejson": "^2.5.22", "prettier-plugin-packagejson": "^2.5.19",
"stylelint": "^16.26.1", "stylelint": "^16.25.0",
"stylelint-config-css-modules": "^4.6.0", "stylelint-config-css-modules": "^4.5.1",
"stylelint-config-recess-order": "^7.7.0", "stylelint-config-recess-order": "^7.4.0",
"stylelint-config-standard": "^39.0.1", "stylelint-config-standard": "^39.0.1",
"typescript": "^5.9.3", "typescript": "^5.8.3",
"vite": "^7.3.1", "vite": "^7.2.2",
"vite-plugin-conditional-import": "^0.1.7", "vite-plugin-conditional-import": "^0.1.7",
"vite-plugin-dynamic-import": "^1.6.0", "vite-plugin-dynamic-import": "^1.6.0",
"vite-plugin-ejs": "^1.7.0", "vite-plugin-ejs": "^1.7.0",
"vite-plugin-pwa": "^1.2.0" "vite-plugin-pwa": "^1.1.0"
}, },
"pnpm": { "pnpm": {
"onlyBuiltDependencies": [ "onlyBuiltDependencies": [
+2400 -2432
View File
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -1,9 +1,9 @@
import react from '@vitejs/plugin-react';
import path from 'path'; import path from 'path';
import { defineConfig, normalizePath } from 'vite'; import { defineConfig, normalizePath } from 'vite';
import { ViteEjsPlugin } from 'vite-plugin-ejs'; import { ViteEjsPlugin } from 'vite-plugin-ejs';
import { version } from './package.json'; import { version } from './package.json';
import { createReactPlugin } from './vite.react-plugin';
export default defineConfig({ export default defineConfig({
build: { build: {
@@ -35,7 +35,7 @@ export default defineConfig({
}, },
}, },
plugins: [ plugins: [
createReactPlugin(), react(),
ViteEjsPlugin({ ViteEjsPlugin({
prod: process.env.NODE_ENV === 'production', prod: process.env.NODE_ENV === 'production',
root: normalizePath(path.resolve(__dirname, './src/remote')), root: normalizePath(path.resolve(__dirname, './src/remote')),
-6
View File
@@ -1214,12 +1214,6 @@
"mainText": "drop a file here" "mainText": "drop a file here"
}, },
"visualizer": { "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", "visualizerType": "Visualizer Type",
"cyclePresets": "Cycle Presets", "cyclePresets": "Cycle Presets",
"cycleTime": "Cycle Time (seconds)", "cycleTime": "Cycle Time (seconds)",
+1 -3
View File
@@ -1335,8 +1335,6 @@
"d": "D", "d": "D",
"z": "Z" "z": "Z"
} }
}, }
"systemAudioCaptureFailed": "無法開始擷取:{{message}}",
"systemAudioNoAudioTrack": "沒有回傳任何音軌。確保在提示時啟用音訊擷取。"
} }
} }
-1
View File
@@ -40,7 +40,6 @@ export const store = new Store<any>({
playbackType: 'web', playbackType: 'web',
should_prompt_accessibility: true, should_prompt_accessibility: true,
shown_accessibility_warning: false, shown_accessibility_warning: false,
visualizer_system_audio_consent_granted: false,
window_enable_tray: true, window_enable_tray: true,
window_exit_to_tray: false, window_exit_to_tray: false,
window_minimize_to_tray: false, window_minimize_to_tray: false,
-17
View File
@@ -150,23 +150,6 @@ ipcMain.on(
return; 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 = { mprisPlayer.metadata = {
'mpris:artUrl': imageUrl || null, 'mpris:artUrl': imageUrl || null,
'mpris:length': song.duration ? Math.round((song.duration || 0) * 1e3) : null, 'mpris:length': song.duration ? Math.round((song.duration || 0) * 1e3) : null,
+8 -116
View File
@@ -5,7 +5,6 @@ import {
app, app,
BrowserWindow, BrowserWindow,
BrowserWindowConstructorOptions, BrowserWindowConstructorOptions,
desktopCapturer,
globalShortcut, globalShortcut,
ipcMain, ipcMain,
Menu, Menu,
@@ -30,7 +29,7 @@ import packageJson from '../../package.json';
import { disableMediaKeys, enableMediaKeys } from './features/core/player/media-keys'; import { disableMediaKeys, enableMediaKeys } from './features/core/player/media-keys';
import { shutdownServer } from './features/core/remote'; import { shutdownServer } from './features/core/remote';
import { store } from './features/core/settings'; import { store } from './features/core/settings';
import MenuBuilder, { MenuPlaybackState } from './menu'; import MenuBuilder from './menu';
import { import {
autoUpdaterLogInterface, autoUpdaterLogInterface,
createLog, createLog,
@@ -42,7 +41,7 @@ import {
} from './utils'; } from './utils';
import './features'; import './features';
import { PlayerRepeat, PlayerStatus, PlayerType, TitleTheme } from '/@/shared/types/types'; import { PlayerType, TitleTheme } from '/@/shared/types/types';
const ALPHA_UPDATER_CONFIG: { const ALPHA_UPDATER_CONFIG: {
bucket: string; bucket: string;
@@ -278,13 +277,6 @@ let tray: null | Tray = null;
let exitFromTray = false; let exitFromTray = false;
let forceQuit = false; let forceQuit = false;
let powerSaveBlockerId: null | number = null; 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') { if (process.env.NODE_ENV === 'production') {
import('source-map-support').then((sourceMapSupport) => { import('source-map-support').then((sourceMapSupport) => {
@@ -341,23 +333,6 @@ export const getMainWindow = () => {
return mainWindow; 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 = ({ export const sendToastToRenderer = ({
message, message,
type, type,
@@ -724,8 +699,12 @@ async function createWindow(first = true): Promise<void> {
}); });
} }
menuBuilder = new MenuBuilder(mainWindow); const menuBuilder = new MenuBuilder(mainWindow);
rebuildMainMenu(); menuBuilder.buildMenu();
if (process.platform !== 'darwin') {
Menu.setApplicationMenu(null);
}
// Open URLs in the user's browser // Open URLs in the user's browser
mainWindow.webContents.setWindowOpenHandler((edata) => { mainWindow.webContents.setWindowOpenHandler((edata) => {
@@ -733,22 +712,6 @@ async function createWindow(first = true): Promise<void> {
return { action: 'deny' }; 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) { if (!disableAutoUpdates() && store.get('disable_auto_updates') !== true) {
new AppUpdater(); new AppUpdater();
} }
@@ -819,17 +782,6 @@ enum BindingActions {
VOLUME_UP = 'volumeUp', 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> = { const HOTKEY_ACTIONS: Record<BindingActions, () => void> = {
[BindingActions.GLOBAL_SEARCH]: () => {}, [BindingActions.GLOBAL_SEARCH]: () => {},
[BindingActions.LOCAL_SEARCH]: () => {}, [BindingActions.LOCAL_SEARCH]: () => {},
@@ -883,26 +835,6 @@ 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; const globalMediaKeysEnabled = store.get('global_media_hotkeys', true) as boolean;
if (globalMediaKeysEnabled) { if (globalMediaKeysEnabled) {
@@ -1043,43 +975,3 @@ if (!ipcMain.eventNames().includes('open-application-directory')) {
shell.openPath(userDataPath); 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();
});
+4 -190
View File
@@ -1,53 +1,18 @@
import { app, BrowserWindow, Menu, MenuItemConstructorOptions, shell } from 'electron'; 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 { interface DarwinMenuItemConstructorOptions extends MenuItemConstructorOptions {
selector?: string; selector?: string;
submenu?: DarwinMenuItemConstructorOptions[] | Menu; submenu?: DarwinMenuItemConstructorOptions[] | Menu;
} }
export default class MenuBuilder { export default class MenuBuilder {
developmentEnvironmentSetup = false;
mainWindow: BrowserWindow; mainWindow: BrowserWindow;
constructor(mainWindow: BrowserWindow) { constructor(mainWindow: BrowserWindow) {
this.mainWindow = mainWindow; this.mainWindow = mainWindow;
} }
buildDarwinTemplate({ buildDarwinTemplate(): MenuItemConstructorOptions[] {
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 = { const subMenuAbout: DarwinMenuItemConstructorOptions = {
label: 'Electron', label: 'Electron',
submenu: [ submenu: [
@@ -64,21 +29,6 @@ export default class MenuBuilder {
label: 'Settings', label: 'Settings',
}, },
{ type: 'separator' }, { 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: [] }, { label: 'Services', submenu: [] },
{ type: 'separator' }, { type: 'separator' },
{ {
@@ -121,22 +71,6 @@ export default class MenuBuilder {
const subMenuViewDev: MenuItemConstructorOptions = { const subMenuViewDev: MenuItemConstructorOptions = {
label: 'View', label: 'View',
submenu: [ 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', accelerator: 'Command+R',
click: () => { click: () => {
@@ -163,22 +97,6 @@ export default class MenuBuilder {
const subMenuViewProd: MenuItemConstructorOptions = { const subMenuViewProd: MenuItemConstructorOptions = {
label: 'View', label: 'View',
submenu: [ 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', accelerator: 'Ctrl+Command+F',
click: () => { click: () => {
@@ -201,89 +119,6 @@ export default class MenuBuilder {
{ label: 'Bring All to Front', selector: 'arrangeInFront:' }, { 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 = { const subMenuHelp: MenuItemConstructorOptions = {
label: 'Help', label: 'Help',
submenu: [ submenu: [
@@ -313,13 +148,6 @@ export default class MenuBuilder {
}, },
label: 'Search Issues', label: 'Search Issues',
}, },
{ type: 'separator' },
{
click: () => {
this.mainWindow.webContents.send('renderer-open-release-notes');
},
label: 'Version ' + packageJson.version,
},
], ],
}; };
@@ -328,14 +156,7 @@ export default class MenuBuilder {
? subMenuViewDev ? subMenuViewDev
: subMenuViewProd; : subMenuViewProd;
return [ return [subMenuAbout, subMenuEdit, subMenuView, subMenuWindow, subMenuHelp];
subMenuAbout,
subMenuEdit,
subMenuView,
subMenuPlayback,
subMenuWindow,
subMenuHelp,
];
} }
buildDefaultTemplate(): MenuItemConstructorOptions[] { buildDefaultTemplate(): MenuItemConstructorOptions[] {
@@ -441,14 +262,14 @@ export default class MenuBuilder {
return templateDefault; return templateDefault;
} }
buildMenu(playbackState: MenuPlaybackState = {}): Menu { buildMenu(): Menu {
if (process.env.NODE_ENV === 'development' || process.env.DEBUG_PROD === 'true') { if (process.env.NODE_ENV === 'development' || process.env.DEBUG_PROD === 'true') {
this.setupDevelopmentEnvironment(); this.setupDevelopmentEnvironment();
} }
const template = const template =
process.platform === 'darwin' process.platform === 'darwin'
? this.buildDarwinTemplate(playbackState) ? this.buildDarwinTemplate()
: this.buildDefaultTemplate(); : this.buildDefaultTemplate();
const menu = Menu.buildFromTemplate(template); const menu = Menu.buildFromTemplate(template);
@@ -458,13 +279,6 @@ export default class MenuBuilder {
} }
setupDevelopmentEnvironment(): void { 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) => { this.mainWindow.webContents.on('context-menu', (_, props) => {
const { x, y } = props; const { x, y } = props;
-25
View File
@@ -65,26 +65,6 @@ const rendererOpenSettings = (cb: (event: IpcRendererEvent) => void) => {
ipcRenderer.on('renderer-open-settings', cb); 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 = { export const utils = {
checkForUpdates, checkForUpdates,
disableAutoUpdates, disableAutoUpdates,
@@ -98,12 +78,7 @@ export const utils = {
openApplicationDirectory, openApplicationDirectory,
openItem, openItem,
playerErrorListener, playerErrorListener,
rendererOpenCommandPalette,
rendererOpenManageServers,
rendererOpenReleaseNotes,
rendererOpenSettings, rendererOpenSettings,
rendererTogglePrivateMode,
rendererToggleSidebar,
}; };
export type Utils = typeof utils; export type Utils = typeof utils;
@@ -297,8 +297,8 @@ export const NavidromeController: InternalControllerEndpoint = {
similarArtists: similarArtists:
artistInfo?.similarArtist?.map((artist) => ({ artistInfo?.similarArtist?.map((artist) => ({
id: artist.id, id: artist.id,
imageId: artist.id, imageId: null,
imageUrl: null, imageUrl: artist?.artistImageUrl?.replace(/\?size=\d+/, '') ?? null,
name: artist.name, name: artist.name,
userFavorite: Boolean(artist.starred) || false, userFavorite: Boolean(artist.starred) || false,
userRating: artist.userRating ?? null, userRating: artist.userRating ?? null,
@@ -237,27 +237,6 @@ function appendTranscodeParams(url: string, format?: string, bitrate?: number) {
return streamUrl; 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>( function sortAndPaginate<T>(
items: T[], items: T[],
options: { options: {
@@ -508,7 +487,7 @@ export const SubsonicController: InternalControllerEndpoint = {
similarArtists: similarArtists:
artistInfo?.similarArtist?.map((artist) => ({ artistInfo?.similarArtist?.map((artist) => ({
id: artist.id, id: artist.id,
imageId: artist.coverArt ?? artist.id, imageId: null,
imageUrl: null, imageUrl: null,
name: artist.name, name: artist.name,
userFavorite: Boolean(artist.starred) || false, userFavorite: Boolean(artist.starred) || false,
@@ -2034,14 +2013,20 @@ export const SubsonicController: InternalControllerEndpoint = {
return appendTranscodeParams(streamUrl, format, bitrate); return appendTranscodeParams(streamUrl, format, bitrate);
} }
const transcodeStreamUrl = buildGetTranscodeStreamUrl(server, { const transcodeStreamUrl = await ssApiClient(apiClientProps).getTranscodeStream({
mediaId: String(id), query: {
mediaType: (mediaType ?? 'song') as 'podcast' | 'song', mediaId: id,
offset: 0, mediaType,
transcodeParams: td.transcodeParams, offset: 0,
transcodeParams: td.transcodeParams,
},
}); });
return transcodeStreamUrl; if (transcodeStreamUrl.status !== 200) {
throw new Error('Failed to get transcode stream');
}
return transcodeStreamUrl.body;
} }
return streamUrl; return streamUrl;
+16 -4
View File
@@ -10,9 +10,9 @@ import isElectron from 'is-electron';
import { lazy, memo, Suspense, useEffect, useMemo, useRef, useState } from 'react'; import { lazy, memo, Suspense, useEffect, useMemo, useRef, useState } from 'react';
import i18n from '/@/i18n/i18n'; 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 { WebAudioContext } from '/@/renderer/features/player/context/webaudio-context';
import { useCheckForUpdates } from '/@/renderer/hooks/use-check-for-updates'; 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 { useSyncSettingsToMain } from '/@/renderer/hooks/use-sync-settings-to-main';
import { AppRouter } from '/@/renderer/router/app-router'; import { AppRouter } from '/@/renderer/router/app-router';
import { useCssSettings, useHotkeySettings, useLanguage } from '/@/renderer/store'; import { useCssSettings, useHotkeySettings, useLanguage } from '/@/renderer/store';
@@ -97,7 +97,7 @@ const AppEffects = () => (
<CssSettingsEffect /> <CssSettingsEffect />
<GlobalShortcutsEffect /> <GlobalShortcutsEffect />
<LanguageEffect /> <LanguageEffect />
<NativeMenuSyncEffect /> <OpenSettingsEffect />
</> </>
); );
@@ -170,8 +170,20 @@ const LanguageEffect = () => {
return null; return null;
}; };
const NativeMenuSyncEffect = () => { const OpenSettingsEffect = () => {
useNativeMenuSync(); useEffect(() => {
if (isElectron()) {
window.api.utils.rendererOpenSettings(() => {
openSettingsModal();
});
return () => {
ipc?.removeAllListeners('renderer-open-settings');
};
}
return undefined;
}, []);
return null; return null;
}; };
@@ -1,30 +0,0 @@
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,13 +40,6 @@ export const useDefaultItemListControls = (args?: UseDefaultItemListControlsArgs
const setFavorite = useSetFavorite(); const setFavorite = useSetFavorite();
const setRating = useSetRating(); 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(() => { useEffect(() => {
navigateRef.current = navigate; navigateRef.current = navigate;
}, [navigate]); }, [navigate]);
@@ -273,14 +266,14 @@ export const useDefaultItemListControls = (args?: UseDefaultItemListControlsArgs
return; return;
} }
playerRef.current.addToQueueByData(songsToAdd, playType, item.id); player.addToQueueByData(songsToAdd, playType, item.id);
return; return;
} }
if (itemType === LibraryItem.QUEUE_SONG) { if (itemType === LibraryItem.QUEUE_SONG) {
const queueSong = item as QueueSong; const queueSong = item as QueueSong;
if (queueSong._uniqueId) { if (queueSong._uniqueId) {
playerRef.current.mediaPlay(queueSong._uniqueId); player.mediaPlay(queueSong._uniqueId);
} }
} }
}, },
@@ -323,7 +316,7 @@ export const useDefaultItemListControls = (args?: UseDefaultItemListControlsArgs
return; return;
} }
setFavoriteRef.current(item._serverId, [item.id], apiItemType, favorite); setFavorite(item._serverId, [item.id], apiItemType, favorite);
}, },
onMore: ({ event, internalState, item, itemType }: DefaultItemControlProps) => { onMore: ({ event, internalState, item, itemType }: DefaultItemControlProps) => {
@@ -401,7 +394,7 @@ export const useDefaultItemListControls = (args?: UseDefaultItemListControlsArgs
return; return;
} }
playerRef.current.addToQueueByFetch(item._serverId, [item.id], itemType, playType); player.addToQueueByFetch(item._serverId, [item.id], itemType, playType);
}, },
onRating: ({ onRating: ({
@@ -427,12 +420,20 @@ export const useDefaultItemListControls = (args?: UseDefaultItemListControlsArgs
newRating = 0; newRating = 0;
} }
setRatingRef.current(item._serverId, [item.id], apiItemType, newRating); setRating(item._serverId, [item.id], apiItemType, newRating);
}, },
...overrides, ...overrides,
}; };
}, [enableMultiSelect, overrides, onColumnReordered, onColumnResized]); }, [
enableMultiSelect,
overrides,
onColumnReordered,
onColumnResized,
player,
setFavorite,
setRating,
]);
return controls; return controls;
}; };
@@ -349,12 +349,9 @@ export const useItemListInfiniteLoader = ({
mutationKey: getListRefreshMutationKey(eventKey), mutationKey: getListRefreshMutationKey(eventKey),
}); });
const refreshMutationRef = useRef(refreshMutation);
refreshMutationRef.current = refreshMutation;
const refresh = useCallback( const refresh = useCallback(
async (force?: boolean) => refreshMutationRef.current.mutateAsync(force), async (force?: boolean) => refreshMutation.mutateAsync(force),
[], [refreshMutation],
); );
const updateItems = useCallback( const updateItems = useCallback(
@@ -386,7 +383,7 @@ export const useItemListInfiniteLoader = ({
return; return;
} }
refreshMutationRef.current.mutate(true); refreshMutation.mutate(true);
}; };
eventEmitter.on('ITEM_LIST_REFRESH', handleRefresh); eventEmitter.on('ITEM_LIST_REFRESH', handleRefresh);
@@ -394,7 +391,7 @@ export const useItemListInfiniteLoader = ({
return () => { return () => {
eventEmitter.off('ITEM_LIST_REFRESH', handleRefresh); eventEmitter.off('ITEM_LIST_REFRESH', handleRefresh);
}; };
}, [eventKey]); }, [eventKey, refreshMutation]);
useEffect(() => { useEffect(() => {
const handleFavorite = (payload: UserFavoriteEventPayload) => { const handleFavorite = (payload: UserFavoriteEventPayload) => {
@@ -5,7 +5,7 @@ import {
useSuspenseQuery, useSuspenseQuery,
UseSuspenseQueryOptions, UseSuspenseQueryOptions,
} from '@tanstack/react-query'; } from '@tanstack/react-query';
import { useCallback, useEffect, useMemo, useRef } from 'react'; import { useCallback, useEffect, useMemo } from 'react';
import { queryKeys } from '/@/renderer/api/query-keys'; import { queryKeys } from '/@/renderer/api/query-keys';
import { useListContext } from '/@/renderer/context/list-context'; import { useListContext } from '/@/renderer/context/list-context';
@@ -115,9 +115,6 @@ export const useItemListPaginatedLoader = ({
mutationKey: getListRefreshMutationKey(eventKey ?? 'paginated'), mutationKey: getListRefreshMutationKey(eventKey ?? 'paginated'),
}); });
const refreshMutationRef = useRef(refreshMutation);
refreshMutationRef.current = refreshMutation;
const updateItems = useCallback( const updateItems = useCallback(
(indexes: number[], value: object) => { (indexes: number[], value: object) => {
return queryClient.setQueryData( return queryClient.setQueryData(
@@ -156,7 +153,7 @@ export const useItemListPaginatedLoader = ({
return; return;
} }
refreshMutationRef.current.mutate(true); refreshMutation.mutate(true);
}; };
const handleFavorite = (payload: UserFavoriteEventPayload) => { const handleFavorite = (payload: UserFavoriteEventPayload) => {
@@ -223,7 +220,7 @@ export const useItemListPaginatedLoader = ({
eventEmitter.off('USER_FAVORITE', handleFavorite); eventEmitter.off('USER_FAVORITE', handleFavorite);
eventEmitter.off('USER_RATING', handleRating); eventEmitter.off('USER_RATING', handleRating);
}; };
}, [data, eventKey, itemType, serverId, updateItems]); }, [data, eventKey, itemType, refreshMutation, serverId, updateItems]);
return { data: data?.items || [], pageCount, totalItemCount }; return { data: data?.items || [], pageCount, totalItemCount };
}; };
@@ -67,7 +67,6 @@ const getRowIdFromTableColumn = (tableColumn: TableColumn): null | string => {
[TableColumn.ID]: null, [TableColumn.ID]: null,
[TableColumn.IMAGE]: null, [TableColumn.IMAGE]: null,
[TableColumn.LAST_PLAYED]: 'lastPlayedAt', [TableColumn.LAST_PLAYED]: 'lastPlayedAt',
[TableColumn.LAYOUT_FILL]: null,
[TableColumn.OWNER]: null, [TableColumn.OWNER]: null,
[TableColumn.PATH]: null, [TableColumn.PATH]: null,
[TableColumn.PLAY_COUNT]: 'playCount', [TableColumn.PLAY_COUNT]: 'playCount',
@@ -179,14 +179,6 @@
opacity: 1; 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 { .resize-handle:hover {
opacity: 1; opacity: 1;
} }
@@ -911,7 +911,8 @@ const DetailListHeaderCell = memo(
const percent = col ? (columnWidthPercents[colIndex] ?? 0) : 0; const percent = col ? (columnWidthPercents[colIndex] ?? 0) : 0;
const { fixedWidth, isFixedColumn } = getTrackColumnFixed(columnId); const { fixedWidth, isFixedColumn } = getTrackColumnFixed(columnId);
const currentWidth = col?.width ?? (fixedWidth || 100); const currentWidth = col?.width ?? (fixedWidth || 100);
const showResizeHandle = enableColumnResize && !isFixedColumn; const showResizeHandle =
enableColumnResize && !isFixedColumn && !col?.autoSize && onColumnResized;
useEffect(() => { useEffect(() => {
if (!containerRef.current || !onColumnReordered) { if (!containerRef.current || !onColumnReordered) {
@@ -1025,7 +1026,6 @@ const DetailListHeaderCell = memo(
{showResizeHandle && ( {showResizeHandle && (
<DetailListColumnResizeHandle <DetailListColumnResizeHandle
columnId={columnId} columnId={columnId}
disabled={!!col?.autoSize}
initialWidth={currentWidth} initialWidth={currentWidth}
onResize={handleResize} onResize={handleResize}
side="right" side="right"
@@ -1040,7 +1040,6 @@ DetailListHeaderCell.displayName = 'DetailListHeaderCell';
interface DetailListColumnResizeHandleProps { interface DetailListColumnResizeHandleProps {
columnId: TableColumn; columnId: TableColumn;
disabled?: boolean;
initialWidth: number; initialWidth: number;
onResize: (columnId: TableColumn, width: number) => void; onResize: (columnId: TableColumn, width: number) => void;
side: 'left' | 'right'; side: 'left' | 'right';
@@ -1048,7 +1047,6 @@ interface DetailListColumnResizeHandleProps {
const DetailListColumnResizeHandle = ({ const DetailListColumnResizeHandle = ({
columnId, columnId,
disabled = false,
initialWidth, initialWidth,
onResize, onResize,
side, side,
@@ -1093,11 +1091,6 @@ const DetailListColumnResizeHandle = ({
}, [isDragging, columnId, onResize]); }, [isDragging, columnId, onResize]);
const handleMouseDown = (event: React.MouseEvent<HTMLDivElement>) => { const handleMouseDown = (event: React.MouseEvent<HTMLDivElement>) => {
if (disabled) {
event.preventDefault();
event.stopPropagation();
return;
}
event.preventDefault(); event.preventDefault();
event.stopPropagation(); event.stopPropagation();
setIsDragging(true); setIsDragging(true);
@@ -1110,7 +1103,6 @@ const DetailListColumnResizeHandle = ({
return ( return (
<div <div
className={clsx(styles.resizeHandle, { className={clsx(styles.resizeHandle, {
[styles.resizeHandleDisabled]: disabled,
[styles.resizeHandleDragging]: isDragging, [styles.resizeHandleDragging]: isDragging,
[styles.resizeHandleLeft]: side === 'left', [styles.resizeHandleLeft]: side === 'left',
[styles.resizeHandleRight]: side === 'right', [styles.resizeHandleRight]: side === 'right',
@@ -4,7 +4,6 @@
flex-direction: column !important; flex-direction: column !important;
width: 100%; width: 100%;
height: 100%; height: 100%;
padding-block: var(--theme-spacing-xs);
padding-right: var(--theme-spacing-md); padding-right: var(--theme-spacing-md);
outline: none; outline: none;
border: none; border: none;
@@ -0,0 +1,72 @@
import { useLayoutEffect, useState } from 'react';
import { useWindowSettings } from '/@/renderer/store/settings.store';
import { Platform } from '/@/shared/types/types';
export interface ItemTableStickyLayoutOffsets {
inViewMarginTop: number;
stickyTop: number;
}
export function useItemTableStickyLayoutOffsets(): ItemTableStickyLayoutOffsets {
const { windowBarStyle } = useWindowSettings();
const isWinMac = windowBarStyle === Platform.WINDOWS || windowBarStyle === Platform.MACOS;
const [offsets, setOffsets] = useState(() => ({
inViewMarginTop: getFallbackInViewMargin(windowBarStyle),
stickyTop: getFallbackStickyTop(windowBarStyle),
}));
useLayoutEffect(() => {
const read = () => {
const topVar = isWinMac
? '--item-table-sticky-top-win-mac'
: '--item-table-sticky-top-default';
const marginVar = isWinMac
? '--item-table-sticky-inview-margin-win-mac'
: '--item-table-sticky-inview-margin-default';
setOffsets({
inViewMarginTop: resolveRootCssMarginLeftVar(
marginVar,
getFallbackInViewMargin(windowBarStyle),
),
stickyTop: resolveRootCssWidthVar(topVar, getFallbackStickyTop(windowBarStyle)),
});
};
read();
window.addEventListener('resize', read);
return () => window.removeEventListener('resize', read);
}, [isWinMac, windowBarStyle]);
return offsets;
}
function getFallbackInViewMargin(windowBarStyle: Platform): number {
return windowBarStyle === Platform.WINDOWS || windowBarStyle === Platform.MACOS ? -130 : -100;
}
function getFallbackStickyTop(windowBarStyle: Platform): number {
return windowBarStyle === Platform.WINDOWS || windowBarStyle === Platform.MACOS ? 95 : 65;
}
function resolveRootCssMarginLeftVar(varName: string, fallback: number): number {
if (typeof document === 'undefined') return fallback;
const el = document.createElement('div');
el.style.cssText = `position:fixed;left:0;top:0;margin-left:var(${varName});width:1px;height:0;margin-top:0;margin-right:0;margin-bottom:0;padding:0;border:none;visibility:hidden;pointer-events:none;`;
document.body.appendChild(el);
const raw = getComputedStyle(el).marginLeft;
el.remove();
const v = parseFloat(raw);
return Number.isFinite(v) ? v : fallback;
}
function resolveRootCssWidthVar(varName: string, fallback: number): number {
if (typeof document === 'undefined') return fallback;
const el = document.createElement('div');
el.style.cssText = `position:fixed;left:-99999px;top:0;width:var(${varName});height:0;margin:0;padding:0;border:none;visibility:hidden;pointer-events:none;`;
document.body.appendChild(el);
const w = el.getBoundingClientRect().width;
el.remove();
return Number.isFinite(w) && w > 0 ? w : fallback;
}
@@ -1,9 +1,8 @@
import type { ItemTableStickyLayoutOffsets } from '/@/renderer/components/item-list/item-table-list/hooks/use-item-table-sticky-layout-offsets';
import { useInView } from 'motion/react'; import { useInView } from 'motion/react';
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { useWindowSettings } from '/@/renderer/store/settings.store';
import { Platform } from '/@/shared/types/types';
export interface GroupRowInfo { export interface GroupRowInfo {
groupIndex: number; groupIndex: number;
rowIndex: number; rowIndex: number;
@@ -18,6 +17,7 @@ export const useStickyTableGroupRows = ({
mainGridRef, mainGridRef,
shouldShowStickyHeader, shouldShowStickyHeader,
stickyHeaderTop, stickyHeaderTop,
stickyLayout,
}: { }: {
containerRef: React.RefObject<HTMLDivElement | null>; containerRef: React.RefObject<HTMLDivElement | null>;
enabled: boolean; enabled: boolean;
@@ -27,17 +27,14 @@ export const useStickyTableGroupRows = ({
mainGridRef: React.RefObject<HTMLDivElement | null>; mainGridRef: React.RefObject<HTMLDivElement | null>;
shouldShowStickyHeader?: boolean; shouldShowStickyHeader?: boolean;
stickyHeaderTop?: number; stickyHeaderTop?: number;
stickyLayout: ItemTableStickyLayoutOffsets;
}) => { }) => {
const { windowBarStyle } = useWindowSettings(); const { inViewMarginTop, stickyTop: layoutStickyTop } = stickyLayout;
const [stickyGroupIndex, setStickyGroupIndex] = useState<null | number>(null); const [stickyGroupIndex, setStickyGroupIndex] = useState<null | number>(null);
const topMargin = const groupRowsInViewMargin = `${inViewMarginTop}px 0px 0px 0px`;
windowBarStyle === Platform.WINDOWS || windowBarStyle === Platform.MACOS
? '-130px'
: '-100px';
const isTableInView = useInView(containerRef, { const isTableInView = useInView(containerRef, {
margin: `${topMargin} 0px 0px 0px`, margin: groupRowsInViewMargin as NonNullable<Parameters<typeof useInView>[1]>['margin'],
}); });
const stickyTop = useMemo(() => { const stickyTop = useMemo(() => {
@@ -46,8 +43,8 @@ export const useStickyTableGroupRows = ({
if (shouldShowStickyHeader && stickyHeaderTop !== undefined) { if (shouldShowStickyHeader && stickyHeaderTop !== undefined) {
return stickyHeaderTop + headerHeight + 1; return stickyHeaderTop + headerHeight + 1;
} }
return windowBarStyle === Platform.WINDOWS || windowBarStyle === Platform.MACOS ? 95 : 65; return layoutStickyTop;
}, [windowBarStyle, shouldShowStickyHeader, stickyHeaderTop, headerHeight]); }, [layoutStickyTop, shouldShowStickyHeader, stickyHeaderTop, headerHeight]);
// Calculate group row indexes // Calculate group row indexes
const groupRowIndexes = useMemo(() => { const groupRowIndexes = useMemo(() => {
@@ -1,9 +1,8 @@
import type { ItemTableStickyLayoutOffsets } from '/@/renderer/components/item-list/item-table-list/hooks/use-item-table-sticky-layout-offsets';
import { useInView } from 'motion/react'; import { useInView } from 'motion/react';
import { RefObject, useEffect, useMemo, useRef } from 'react'; import { RefObject, useEffect, useMemo, useRef } from 'react';
import { useWindowSettings } from '/@/renderer/store/settings.store';
import { Platform } from '/@/shared/types/types';
export const useStickyTableHeader = ({ export const useStickyTableHeader = ({
containerRef, containerRef,
enabled, enabled,
@@ -12,6 +11,7 @@ export const useStickyTableHeader = ({
pinnedLeftColumnRef, pinnedLeftColumnRef,
pinnedRightColumnRef, pinnedRightColumnRef,
stickyHeaderMainRef, stickyHeaderMainRef,
stickyLayout,
}: { }: {
containerRef: RefObject<HTMLDivElement | null>; containerRef: RefObject<HTMLDivElement | null>;
enabled: boolean; enabled: boolean;
@@ -20,8 +20,9 @@ export const useStickyTableHeader = ({
pinnedLeftColumnRef?: RefObject<HTMLDivElement | null>; pinnedLeftColumnRef?: RefObject<HTMLDivElement | null>;
pinnedRightColumnRef?: RefObject<HTMLDivElement | null>; pinnedRightColumnRef?: RefObject<HTMLDivElement | null>;
stickyHeaderMainRef?: RefObject<HTMLDivElement | null>; stickyHeaderMainRef?: RefObject<HTMLDivElement | null>;
stickyLayout: ItemTableStickyLayoutOffsets;
}) => { }) => {
const { windowBarStyle } = useWindowSettings(); const { inViewMarginTop, stickyTop } = stickyLayout;
const isScrollingRef = useRef({ const isScrollingRef = useRef({
main: false, main: false,
pinnedLeft: false, pinnedLeft: false,
@@ -29,27 +30,20 @@ export const useStickyTableHeader = ({
stickyHeader: false, stickyHeader: false,
}); });
const topMargin = const inViewRootMargin = `${inViewMarginTop}px 0px 0px 0px`;
windowBarStyle === Platform.WINDOWS || windowBarStyle === Platform.MACOS
? '-130px'
: '-100px';
const isTableHeaderInView = useInView(headerRef, { const inViewOptions = { margin: inViewRootMargin } as {
margin: `${topMargin} 0px 0px 0px`, margin: NonNullable<Parameters<typeof useInView>[1]>['margin'];
}); };
const isTableInView = useInView(containerRef, { const isTableHeaderInView = useInView(headerRef, inViewOptions);
margin: `${topMargin} 0px 0px 0px`,
}); const isTableInView = useInView(containerRef, inViewOptions);
const shouldShowStickyHeader = useMemo(() => { const shouldShowStickyHeader = useMemo(() => {
return enabled && !isTableHeaderInView && isTableInView; return enabled && !isTableHeaderInView && isTableInView;
}, [enabled, isTableHeaderInView, isTableInView]); }, [enabled, isTableHeaderInView, isTableInView]);
const stickyTop = useMemo(() => {
return windowBarStyle === Platform.WINDOWS || windowBarStyle === Platform.MACOS ? 95 : 65;
}, [windowBarStyle]);
// Sync scroll between sticky header and main grid/pinned columns // Sync scroll between sticky header and main grid/pinned columns
useEffect(() => { useEffect(() => {
if (!shouldShowStickyHeader || !stickyHeaderMainRef?.current || !mainGridRef?.current) { if (!shouldShowStickyHeader || !stickyHeaderMainRef?.current || !mainGridRef?.current) {
@@ -1,5 +1,3 @@
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 { autoScrollForElements } from '@atlaskit/pragmatic-drag-and-drop-auto-scroll/element';
import throttle from 'lodash/throttle'; import throttle from 'lodash/throttle';
import { useOverlayScrollbars } from 'overlayscrollbars-react'; import { useOverlayScrollbars } from 'overlayscrollbars-react';
@@ -20,7 +18,9 @@ export const useTablePaneSync = ({
pinnedRowRef, pinnedRowRef,
rowRef, rowRef,
scrollContainerRef, scrollContainerRef,
scrollShadowStore, setShowLeftShadow,
setShowRightShadow,
setShowTopShadow,
}: { }: {
enableDrag: boolean | undefined; enableDrag: boolean | undefined;
enableDragScroll: boolean | undefined; enableDragScroll: boolean | undefined;
@@ -36,7 +36,9 @@ export const useTablePaneSync = ({
pinnedRowRef: React.RefObject<HTMLDivElement | null>; pinnedRowRef: React.RefObject<HTMLDivElement | null>;
rowRef: React.RefObject<HTMLDivElement | null>; rowRef: React.RefObject<HTMLDivElement | null>;
scrollContainerRef: React.RefObject<HTMLDivElement | null>; scrollContainerRef: React.RefObject<HTMLDivElement | null>;
scrollShadowStore: TableScrollShadowStore; setShowLeftShadow: (v: boolean) => void;
setShowRightShadow: (v: boolean) => void;
setShowTopShadow: (v: boolean) => void;
}) => { }) => {
// Main grid overlayscrollbars - only handle X-axis if right-pinned columns exist // Main grid overlayscrollbars - only handle X-axis if right-pinned columns exist
const [initialize, osInstance] = useOverlayScrollbars({ const [initialize, osInstance] = useOverlayScrollbars({
@@ -469,10 +471,8 @@ export const useTablePaneSync = ({
if (!row) { if (!row) {
const timeout = setTimeout(() => { const timeout = setTimeout(() => {
scrollShadowStore.setSnapshot({ setShowLeftShadow(false);
showLeftShadow: false, setShowRightShadow(false);
showRightShadow: false,
});
}, 0); }, 0);
return () => clearTimeout(timeout); return () => clearTimeout(timeout);
@@ -482,10 +482,8 @@ export const useTablePaneSync = ({
const scrollLeft = row.scrollLeft; const scrollLeft = row.scrollLeft;
const maxScrollLeft = row.scrollWidth - row.clientWidth; const maxScrollLeft = row.scrollWidth - row.clientWidth;
scrollShadowStore.setSnapshot({ setShowLeftShadow(pinnedLeftColumnCount > 0 && scrollLeft > 0);
showLeftShadow: pinnedLeftColumnCount > 0 && scrollLeft > 0, setShowRightShadow(pinnedRightColumnCount > 0 && scrollLeft < maxScrollLeft);
showRightShadow: pinnedRightColumnCount > 0 && scrollLeft < maxScrollLeft,
});
}, 50); }, 50);
checkScrollPosition(); checkScrollPosition();
@@ -496,7 +494,13 @@ export const useTablePaneSync = ({
checkScrollPosition.cancel(); checkScrollPosition.cancel();
row.removeEventListener('scroll', checkScrollPosition); row.removeEventListener('scroll', checkScrollPosition);
}; };
}, [pinnedLeftColumnCount, pinnedRightColumnCount, rowRef, scrollShadowStore]); }, [
pinnedLeftColumnCount,
pinnedRightColumnCount,
rowRef,
setShowLeftShadow,
setShowRightShadow,
]);
// Handle top shadow visibility based on vertical scroll // Handle top shadow visibility based on vertical scroll
useEffect(() => { useEffect(() => {
@@ -505,7 +509,7 @@ export const useTablePaneSync = ({
if (!row || !enableHeader) { if (!row || !enableHeader) {
const timeout = setTimeout(() => { const timeout = setTimeout(() => {
scrollShadowStore.setSnapshot({ showTopShadow: false }); setShowTopShadow(false);
}, 0); }, 0);
return () => clearTimeout(timeout); return () => clearTimeout(timeout);
@@ -515,7 +519,7 @@ export const useTablePaneSync = ({
const checkScrollPosition = throttle(() => { const checkScrollPosition = throttle(() => {
const currentScrollTop = scrollElement.scrollTop; const currentScrollTop = scrollElement.scrollTop;
scrollShadowStore.setSnapshot({ showTopShadow: currentScrollTop > 0 }); setShowTopShadow(currentScrollTop > 0);
}, 50); }, 50);
checkScrollPosition(); checkScrollPosition();
@@ -526,5 +530,5 @@ export const useTablePaneSync = ({
checkScrollPosition.cancel(); checkScrollPosition.cancel();
scrollElement.removeEventListener('scroll', checkScrollPosition); scrollElement.removeEventListener('scroll', checkScrollPosition);
}; };
}, [enableHeader, pinnedRightColumnCount, pinnedRightColumnRef, rowRef, scrollShadowStore]); }, [enableHeader, pinnedRightColumnCount, pinnedRightColumnRef, rowRef, setShowTopShadow]);
}; };
@@ -366,14 +366,6 @@
opacity: 1; opacity: 1;
} }
.resize-handle.resize-handle-disabled {
cursor: not-allowed;
}
.header-container:hover .resize-handle.resize-handle-disabled {
cursor: not-allowed;
}
.resize-handle:hover { .resize-handle:hover {
opacity: 1; opacity: 1;
} }
@@ -57,7 +57,6 @@ import { TitleCombinedColumn } from '/@/renderer/components/item-list/item-table
import { YearColumn } from '/@/renderer/components/item-list/item-table-list/columns/year-column'; 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 { 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 { 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 { ItemControls, ItemListItem } from '/@/renderer/components/item-list/types';
import { Flex } from '/@/shared/components/flex/flex'; import { Flex } from '/@/shared/components/flex/flex';
import { Icon } from '/@/shared/components/icon/icon'; import { Icon } from '/@/shared/components/icon/icon';
@@ -194,14 +193,6 @@ const ItemTableListColumnBase = (props: ItemTableListColumn) => {
); );
} }
if (type === TableColumn.LAYOUT_FILL) {
return (
<TableColumnContainer {...props} {...dragProps} controls={controls} type={type}>
{null}
</TableColumnContainer>
);
}
if (itemType !== LibraryItem.FOLDER) { if (itemType !== LibraryItem.FOLDER) {
switch (type) { switch (type) {
case TableColumn.ACTIONS: case TableColumn.ACTIONS:
@@ -716,8 +707,6 @@ export const TableColumnContainer = (
interface ColumnResizeHandleProps { interface ColumnResizeHandleProps {
columnId: TableColumn; columnId: TableColumn;
columnIndex: number;
disabled?: boolean;
initialWidth: number; initialWidth: number;
onResize: (columnId: TableColumn, width: number) => void; onResize: (columnId: TableColumn, width: number) => void;
side: 'left' | 'right'; side: 'left' | 'right';
@@ -725,8 +714,6 @@ interface ColumnResizeHandleProps {
const ColumnResizeHandle = ({ const ColumnResizeHandle = ({
columnId, columnId,
columnIndex,
disabled = false,
initialWidth, initialWidth,
onResize, onResize,
side, side,
@@ -736,17 +723,6 @@ const ColumnResizeHandle = ({
const startWidthRef = useRef<number>(initialWidth); const startWidthRef = useRef<number>(initialWidth);
const startXRef = useRef<number>(0); const startXRef = useRef<number>(0);
const finalWidthRef = useRef<number>(initialWidth); 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) // Update the ref when initialWidth changes (but not during drag)
useEffect(() => { useEffect(() => {
@@ -762,7 +738,6 @@ const ColumnResizeHandle = ({
const deltaX = event.clientX - startXRef.current; const deltaX = event.clientX - startXRef.current;
const newWidth = Math.min(Math.max(10, startWidthRef.current + deltaX), 1000); const newWidth = Math.min(Math.max(10, startWidthRef.current + deltaX), 1000);
finalWidthRef.current = newWidth; finalWidthRef.current = newWidth;
columnResizeLiveRef.current?.scheduleColumnResizePreview(columnIndex, newWidth);
}; };
const handleMouseUp = () => { const handleMouseUp = () => {
@@ -771,8 +746,7 @@ const ColumnResizeHandle = ({
document.body.style.userSelect = ''; document.body.style.userSelect = '';
document.removeEventListener('mousemove', handleMouseMove); document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp); document.removeEventListener('mouseup', handleMouseUp);
onResizeRef.current(columnId, finalWidthRef.current); onResize(columnId, finalWidthRef.current);
columnResizeLiveRef.current?.clearColumnResizePreview();
}; };
document.addEventListener('mousemove', handleMouseMove); document.addEventListener('mousemove', handleMouseMove);
@@ -781,18 +755,10 @@ const ColumnResizeHandle = ({
return () => { return () => {
document.removeEventListener('mousemove', handleMouseMove); document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp); document.removeEventListener('mouseup', handleMouseUp);
document.body.style.cursor = '';
document.body.style.userSelect = '';
columnResizeLiveRef.current?.clearColumnResizePreview();
}; };
}, [isDragging, columnId, columnIndex]); }, [isDragging, columnId, onResize]);
const handleMouseDown = (event: React.MouseEvent<HTMLDivElement>) => { const handleMouseDown = (event: React.MouseEvent<HTMLDivElement>) => {
if (disabled) {
event.preventDefault();
event.stopPropagation();
return;
}
event.preventDefault(); event.preventDefault();
event.stopPropagation(); event.stopPropagation();
setIsDragging(true); setIsDragging(true);
@@ -805,7 +771,6 @@ const ColumnResizeHandle = ({
return ( return (
<div <div
className={clsx(styles.resizeHandle, { className={clsx(styles.resizeHandle, {
[styles.resizeHandleDisabled]: disabled,
[styles.resizeHandleDragging]: isDragging, [styles.resizeHandleDragging]: isDragging,
[styles.resizeHandleLeft]: side === 'left', [styles.resizeHandleLeft]: side === 'left',
[styles.resizeHandleRight]: side === 'right', [styles.resizeHandleRight]: side === 'right',
@@ -837,11 +802,7 @@ export const TableColumnHeaderContainer = (
const [isDraggedOver, setIsDraggedOver] = useState<Edge | null>(null); const [isDraggedOver, setIsDraggedOver] = useState<Edge | null>(null);
useEffect(() => { useEffect(() => {
if ( if (!containerRef.current || !props.enableColumnReorder) {
!containerRef.current ||
!props.enableColumnReorder ||
props.type === TableColumn.LAYOUT_FILL
) {
return; return;
} }
@@ -956,11 +917,9 @@ export const TableColumnHeaderContainer = (
> >
{columnLabelMap[props.type]} {columnLabelMap[props.type]}
</Text> </Text>
{props.enableColumnResize && ( {!columnConfig.autoSize && props.enableColumnResize && (
<ColumnResizeHandle <ColumnResizeHandle
columnId={props.type} columnId={props.type}
columnIndex={props.columnIndex}
disabled={!!columnConfig.autoSize}
initialWidth={currentWidth} initialWidth={currentWidth}
onResize={handleResize} onResize={handleResize}
side="right" side="right"
@@ -1023,7 +982,6 @@ export const columnLabelMap: Record<TableColumn, ReactNode | string> = {
[TableColumn.LAST_PLAYED]: i18n.t('table.column.lastPlayed', { [TableColumn.LAST_PLAYED]: i18n.t('table.column.lastPlayed', {
postProcess: 'upperCase', postProcess: 'upperCase',
}) as string, }) as string,
[TableColumn.LAYOUT_FILL]: '',
[TableColumn.OWNER]: i18n.t('table.column.owner', { postProcess: 'upperCase' }) as string, [TableColumn.OWNER]: i18n.t('table.column.owner', { postProcess: 'upperCase' }) as string,
[TableColumn.PATH]: i18n.t('table.column.path', { postProcess: 'upperCase' }) as string, [TableColumn.PATH]: i18n.t('table.column.path', { postProcess: 'upperCase' }) as string,
[TableColumn.PLAY_COUNT]: i18n.t('table.column.playCount', { [TableColumn.PLAY_COUNT]: i18n.t('table.column.playCount', {
@@ -1,14 +1,6 @@
import type { ReactElement } from 'react'; import type { ReactElement } from 'react';
import React, { import React, { createContext, useContext, useEffect, useMemo, useRef } from 'react';
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import { useSyncExternalStore } from 'react'; import { useSyncExternalStore } from 'react';
import type { TableItemProps } from './item-table-list'; import type { TableItemProps } from './item-table-list';
@@ -76,69 +68,6 @@ export const useItemTableListConfig = (): ItemTableListConfig | null => {
return useContext(ItemTableListConfigContext); 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 = { type ItemTableListStoreContextValue = {
activeRowStore: ActiveRowStore; activeRowStore: ActiveRowStore;
}; };
@@ -52,7 +52,7 @@
.item-table-pinned-rows-grid-container.header-fixed { .item-table-pinned-rows-grid-container.header-fixed {
position: fixed !important; position: fixed !important;
top: 65px; top: var(--item-table-sticky-top-default);
z-index: 15; z-index: 15;
background-color: var(--theme-bg-primary); background-color: var(--theme-bg-primary);
box-shadow: 0 -1px 0 0 var(--theme-colors-border); box-shadow: 0 -1px 0 0 var(--theme-colors-border);
@@ -60,7 +60,7 @@
} }
.item-table-pinned-rows-grid-container.header-window-bar { .item-table-pinned-rows-grid-container.header-window-bar {
top: 95px; top: var(--item-table-sticky-top-win-mac);
} }
.item-table-list-container.header-fixed-margin { .item-table-list-container.header-fixed-margin {
@@ -72,7 +72,7 @@
z-index: 15; z-index: 15;
display: flex; display: flex;
flex-direction: row; flex-direction: row;
overflow: visible; overflow: hidden;
pointer-events: none; pointer-events: none;
background-color: var(--theme-colors-background); background-color: var(--theme-colors-background);
border-bottom: 1px solid var(--theme-colors-border); border-bottom: 1px solid var(--theme-colors-border);
@@ -168,7 +168,6 @@
min-width: 0; min-width: 0;
height: 100%; height: 100%;
min-height: 0; min-height: 0;
overflow-x: visible;
} }
.no-scrollbar { .no-scrollbar {
@@ -179,10 +178,6 @@
height: 100%; height: 100%;
} }
.item-table-container :global(.os-scrollbar) {
z-index: 2;
}
.item-table-pinned-header-shadow { .item-table-pinned-header-shadow {
position: absolute; position: absolute;
top: 100%; top: 100%;
@@ -14,14 +14,12 @@ import React, {
useMemo, useMemo,
useRef, useRef,
useState, useState,
useSyncExternalStore,
} from 'react'; } from 'react';
import { useParams } from 'react-router'; import { useParams } from 'react-router';
import { type CellComponentProps, Grid } from 'react-window-v2'; import { type CellComponentProps, Grid } from 'react-window-v2';
import styles from './item-table-list.module.css'; 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 { createExtractRowId } from '/@/renderer/components/item-list/helpers/extract-row-id';
import { useDefaultItemListControls } from '/@/renderer/components/item-list/helpers/item-list-controls'; import { useDefaultItemListControls } from '/@/renderer/components/item-list/helpers/item-list-controls';
import { import {
@@ -33,6 +31,7 @@ import { parseTableColumns } from '/@/renderer/components/item-list/helpers/pars
import { useListHotkeys } from '/@/renderer/components/item-list/helpers/use-list-hotkeys'; import { useListHotkeys } from '/@/renderer/components/item-list/helpers/use-list-hotkeys';
import { useContainerWidthTracking } from '/@/renderer/components/item-list/item-table-list/hooks/use-container-width-tracking'; import { useContainerWidthTracking } from '/@/renderer/components/item-list/item-table-list/hooks/use-container-width-tracking';
import { useRowInteractionDelegate } from '/@/renderer/components/item-list/item-table-list/hooks/use-row-interaction-delegate'; import { useRowInteractionDelegate } from '/@/renderer/components/item-list/item-table-list/hooks/use-row-interaction-delegate';
import { useItemTableStickyLayoutOffsets } from '/@/renderer/components/item-list/item-table-list/hooks/use-item-table-sticky-layout-offsets';
import { useStickyGroupRowPositioning } from '/@/renderer/components/item-list/item-table-list/hooks/use-sticky-group-row-positioning'; import { useStickyGroupRowPositioning } from '/@/renderer/components/item-list/item-table-list/hooks/use-sticky-group-row-positioning';
import { useStickyHeaderPositioning } from '/@/renderer/components/item-list/item-table-list/hooks/use-sticky-header-positioning'; import { useStickyHeaderPositioning } from '/@/renderer/components/item-list/item-table-list/hooks/use-sticky-header-positioning';
import { useStickyTableGroupRows } from '/@/renderer/components/item-list/item-table-list/hooks/use-sticky-table-group-rows'; import { useStickyTableGroupRows } from '/@/renderer/components/item-list/item-table-list/hooks/use-sticky-table-group-rows';
@@ -46,20 +45,14 @@ 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 { 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 { ItemTableListColumn } from '/@/renderer/components/item-list/item-table-list/item-table-list-column';
import { import {
ItemTableListColumnResizeLiveProvider,
type ItemTableListConfig, type ItemTableListConfig,
ItemTableListConfigProvider, ItemTableListConfigProvider,
ItemTableListStoreProvider, ItemTableListStoreProvider,
useItemTableListColumnResizeLiveState,
} from '/@/renderer/components/item-list/item-table-list/item-table-list-context'; } from '/@/renderer/components/item-list/item-table-list/item-table-list-context';
import { import {
MemoizedCellRouter, MemoizedCellRouter,
useColumnCellComponents, useColumnCellComponents,
} from '/@/renderer/components/item-list/item-table-list/memoized-cell-router'; } 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 { import {
ItemControls, ItemControls,
ItemListHandle, ItemListHandle,
@@ -111,63 +104,6 @@ export enum TableItemSize {
LARGE = 88, 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 { interface VirtualizedTableGridProps {
calculatedColumnWidths: number[]; calculatedColumnWidths: number[];
CellComponent: JSXElementConstructor<CellComponentProps<TableItemProps>>; CellComponent: JSXElementConstructor<CellComponentProps<TableItemProps>>;
@@ -185,7 +121,9 @@ interface VirtualizedTableGridProps {
pinnedRightColumnRef: React.RefObject<HTMLDivElement | null>; pinnedRightColumnRef: React.RefObject<HTMLDivElement | null>;
pinnedRowCount: number; pinnedRowCount: number;
pinnedRowRef: React.RefObject<HTMLDivElement | null>; pinnedRowRef: React.RefObject<HTMLDivElement | null>;
scrollShadowStore: TableScrollShadowStore; showLeftShadow: boolean;
showRightShadow: boolean;
showTopShadow: boolean;
tableConfig: ItemTableListConfig; tableConfig: ItemTableListConfig;
totalColumnCount: number; totalColumnCount: number;
totalRowCount: number; totalRowCount: number;
@@ -208,7 +146,9 @@ const VirtualizedTableGrid = ({
pinnedRightColumnRef, pinnedRightColumnRef,
pinnedRowCount, pinnedRowCount,
pinnedRowRef, pinnedRowRef,
scrollShadowStore, showLeftShadow,
showRightShadow,
showTopShadow,
tableConfig, tableConfig,
totalColumnCount, totalColumnCount,
totalRowCount, totalRowCount,
@@ -544,7 +484,7 @@ const VirtualizedTableGrid = ({
})} })}
style={{ style={{
minHeight: `${pinnedRowsMinHeightPx}px`, minHeight: `${pinnedRowsMinHeightPx}px`,
overflow: 'visible', overflow: 'hidden',
}} }}
> >
<Grid <Grid
@@ -558,11 +498,9 @@ const VirtualizedTableGrid = ({
/> />
</div> </div>
)} )}
<ItemTableScrollShadowTop {enableHeader && enableScrollShadow && showTopShadow && (
enableHeader={!!enableHeader} <div className={styles.itemTableTopScrollShadow} />
enableScrollShadow={enableScrollShadow} )}
scrollShadowStore={scrollShadowStore}
/>
{!!pinnedLeftColumnCount && ( {!!pinnedLeftColumnCount && (
<div <div
className={styles.itemTablePinnedColumnsContainer} className={styles.itemTablePinnedColumnsContainer}
@@ -617,11 +555,9 @@ const VirtualizedTableGrid = ({
/> />
</div> </div>
)} )}
<ItemTableScrollShadowTop {enableHeader && enableScrollShadow && showTopShadow && (
enableHeader={!!enableHeader} <div className={styles.itemTableTopScrollShadow} />
enableScrollShadow={enableScrollShadow} )}
scrollShadowStore={scrollShadowStore}
/>
<div className={styles.itemTableGridContainer} ref={mergedRowRef}> <div className={styles.itemTableGridContainer} ref={mergedRowRef}>
<Grid <Grid
cellComponent={RowCell} cellComponent={RowCell}
@@ -633,16 +569,12 @@ const VirtualizedTableGrid = ({
rowCount={totalRowCount} rowCount={totalRowCount}
rowHeight={rowHeightMemoized} rowHeight={rowHeightMemoized}
/> />
<ItemTableScrollShadowLeft {pinnedLeftColumnCount > 0 && enableScrollShadow && showLeftShadow && (
enableScrollShadow={enableScrollShadow} <div className={styles.itemTableLeftScrollShadow} />
pinnedLeftColumnCount={pinnedLeftColumnCount} )}
scrollShadowStore={scrollShadowStore} {pinnedRightColumnCount > 0 && enableScrollShadow && showRightShadow && (
/> <div className={styles.itemTableRightScrollShadow} />
<ItemTableScrollShadowRight )}
enableScrollShadow={enableScrollShadow}
pinnedRightColumnCount={pinnedRightColumnCount}
scrollShadowStore={scrollShadowStore}
/>
</div> </div>
</div> </div>
{!!pinnedRightColumnCount && ( {!!pinnedRightColumnCount && (
@@ -662,7 +594,7 @@ const VirtualizedTableGrid = ({
})} })}
style={{ style={{
minHeight: `${pinnedRowsMinHeightPx}px`, minHeight: `${pinnedRowsMinHeightPx}px`,
overflow: 'visible', overflow: 'hidden',
}} }}
> >
<Grid <Grid
@@ -680,11 +612,9 @@ const VirtualizedTableGrid = ({
/> />
</div> </div>
)} )}
<ItemTableScrollShadowTop {enableHeader && enableScrollShadow && showTopShadow && (
enableHeader={!!enableHeader} <div className={styles.itemTableTopScrollShadow} />
enableScrollShadow={enableScrollShadow} )}
scrollShadowStore={scrollShadowStore}
/>
<div <div
className={styles.itemTablePinnedRightColumnsContainer} className={styles.itemTablePinnedRightColumnsContainer}
ref={pinnedRightColumnRef} ref={pinnedRightColumnRef}
@@ -737,7 +667,9 @@ const MemoizedVirtualizedTableGrid = memo(VirtualizedTableGrid, (prevProps, next
prevProps.pinnedRightColumnRef === nextProps.pinnedRightColumnRef && prevProps.pinnedRightColumnRef === nextProps.pinnedRightColumnRef &&
prevProps.pinnedRowCount === nextProps.pinnedRowCount && prevProps.pinnedRowCount === nextProps.pinnedRowCount &&
prevProps.pinnedRowRef === nextProps.pinnedRowRef && prevProps.pinnedRowRef === nextProps.pinnedRowRef &&
prevProps.scrollShadowStore === nextProps.scrollShadowStore && prevProps.showLeftShadow === nextProps.showLeftShadow &&
prevProps.showRightShadow === nextProps.showRightShadow &&
prevProps.showTopShadow === nextProps.showTopShadow &&
prevProps.totalColumnCount === nextProps.totalColumnCount && prevProps.totalColumnCount === nextProps.totalColumnCount &&
prevProps.totalRowCount === nextProps.totalRowCount && prevProps.totalRowCount === nextProps.totalRowCount &&
prevProps.CellComponent === nextProps.CellComponent prevProps.CellComponent === nextProps.CellComponent
@@ -898,6 +830,8 @@ const ItemTableListStickyUI = memo(
const stickyHeaderMainRef = useRef<HTMLDivElement | null>(null); const stickyHeaderMainRef = useRef<HTMLDivElement | null>(null);
const stickyHeaderRightRef = useRef<HTMLDivElement | null>(null); const stickyHeaderRightRef = useRef<HTMLDivElement | null>(null);
const stickyLayout = useItemTableStickyLayoutOffsets();
const { shouldShowStickyHeader, stickyTop } = useStickyTableHeader({ const { shouldShowStickyHeader, stickyTop } = useStickyTableHeader({
containerRef, containerRef,
enabled: enableHeader && enableStickyHeader, enabled: enableHeader && enableStickyHeader,
@@ -906,6 +840,7 @@ const ItemTableListStickyUI = memo(
pinnedLeftColumnRef, pinnedLeftColumnRef,
pinnedRightColumnRef, pinnedRightColumnRef,
stickyHeaderMainRef, stickyHeaderMainRef,
stickyLayout,
}); });
useStickyHeaderPositioning({ useStickyHeaderPositioning({
@@ -927,6 +862,7 @@ const ItemTableListStickyUI = memo(
mainGridRef: rowRef, mainGridRef: rowRef,
shouldShowStickyHeader, shouldShowStickyHeader,
stickyHeaderTop: stickyTop, stickyHeaderTop: stickyTop,
stickyLayout,
}); });
const shouldRenderStickyGroupRow = shouldShowStickyGroupRow; const shouldRenderStickyGroupRow = shouldShowStickyGroupRow;
@@ -971,7 +907,7 @@ const ItemTableListStickyUI = memo(
style={{ style={{
flex: '0 1 auto', flex: '0 1 auto',
minWidth: `${pinnedLeftWidth}px`, minWidth: `${pinnedLeftWidth}px`,
overflow: 'visible', overflow: 'hidden',
}} }}
> >
{parsedColumns {parsedColumns
@@ -1055,7 +991,7 @@ const ItemTableListStickyUI = memo(
style={{ style={{
flex: '0 1 auto', flex: '0 1 auto',
minWidth: `${pinnedRightWidth}px`, minWidth: `${pinnedRightWidth}px`,
overflow: 'visible', overflow: 'hidden',
}} }}
> >
{parsedColumns {parsedColumns
@@ -1279,11 +1215,6 @@ const BaseItemTableList = ({
const [centerContainerWidth, setCenterContainerWidth] = useState(0); const [centerContainerWidth, setCenterContainerWidth] = useState(0);
const [totalContainerWidth, setTotalContainerWidth] = useState(0); const [totalContainerWidth, setTotalContainerWidth] = useState(0);
const columnsForLayout = useMemo(
() => appendLayoutFillColumn(columns, autoFitColumns),
[autoFitColumns, columns],
);
const { const {
calculatedColumnWidths, calculatedColumnWidths,
parsedColumns, parsedColumns,
@@ -1293,33 +1224,9 @@ const BaseItemTableList = ({
} = useTableColumnModel({ } = useTableColumnModel({
autoFitColumns, autoFitColumns,
centerContainerWidth, centerContainerWidth,
columns: columnsForLayout, columns,
totalContainerWidth, 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 playerContext = usePlayer();
const { const {
@@ -1355,7 +1262,9 @@ const BaseItemTableList = ({
const pinnedRightColumnRef = useRef<HTMLDivElement>(null); const pinnedRightColumnRef = useRef<HTMLDivElement>(null);
const scrollContainerRef = useRef<HTMLDivElement | null>(null); const scrollContainerRef = useRef<HTMLDivElement | null>(null);
const mergedRowRef = useMergedRef(rowRef, scrollContainerRef); const mergedRowRef = useMergedRef(rowRef, scrollContainerRef);
const scrollShadowStore = useMemo(() => createTableScrollShadowStore(), []); const [showLeftShadow, setShowLeftShadow] = useState(false);
const [showRightShadow, setShowRightShadow] = useState(false);
const [showTopShadow, setShowTopShadow] = useState(false);
const handleRef = useRef<ItemListHandle | null>(null); const handleRef = useRef<ItemListHandle | null>(null);
const { focused, ref: focusRef } = useFocusWithin(); const { focused, ref: focusRef } = useFocusWithin();
const containerRef = useRef<HTMLDivElement | null>(null); const containerRef = useRef<HTMLDivElement | null>(null);
@@ -1413,7 +1322,9 @@ const BaseItemTableList = ({
pinnedRowRef, pinnedRowRef,
rowRef, rowRef,
scrollContainerRef, scrollContainerRef,
scrollShadowStore, setShowLeftShadow,
setShowRightShadow,
setShowTopShadow,
}); });
const getRowHeight = useCallback( const getRowHeight = useCallback(
@@ -1537,7 +1448,7 @@ const BaseItemTableList = ({
// Create itemProps for sticky header // Create itemProps for sticky header
const stickyHeaderItemProps: TableItemProps = useMemo( const stickyHeaderItemProps: TableItemProps = useMemo(
() => ({ () => ({
calculatedColumnWidths: displayColumnWidths, calculatedColumnWidths,
cellPadding, cellPadding,
columns: parsedColumns, columns: parsedColumns,
controls, controls,
@@ -1557,9 +1468,9 @@ const BaseItemTableList = ({
internalState, internalState,
itemType, itemType,
pinnedLeftColumnCount, pinnedLeftColumnCount,
pinnedLeftColumnWidths: displayColumnWidths.slice(0, pinnedLeftColumnCount), pinnedLeftColumnWidths: calculatedColumnWidths.slice(0, pinnedLeftColumnCount),
pinnedRightColumnCount, pinnedRightColumnCount,
pinnedRightColumnWidths: displayColumnWidths.slice( pinnedRightColumnWidths: calculatedColumnWidths.slice(
pinnedLeftColumnCount + totalColumnCount, pinnedLeftColumnCount + totalColumnCount,
), ),
playerContext, playerContext,
@@ -1568,7 +1479,7 @@ const BaseItemTableList = ({
tableId, tableId,
}), }),
[ [
displayColumnWidths, calculatedColumnWidths,
cellPadding, cellPadding,
controls, controls,
parsedColumns, parsedColumns,
@@ -1673,81 +1584,73 @@ const BaseItemTableList = ({
}; };
}, [CellComponent, columnCellComponents]); }, [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 ( return (
<ItemTableListStoreProvider activeRowId={activeRowId}> <ItemTableListStoreProvider activeRowId={activeRowId}>
<ItemTableListConfigProvider value={tableConfigValue}> <ItemTableListConfigProvider value={tableConfigValue}>
{onColumnResized ? ( <motion.div
<ItemTableListColumnResizeLiveProvider value={columnResizeLiveValue}> className={styles.itemTableListContainer}
{tableMotion} onKeyDown={handleKeyDown}
</ItemTableListColumnResizeLiveProvider> onMouseDown={(e) => {
) : ( const element = e.currentTarget as HTMLDivElement;
tableMotion // Focus without scrolling into view
)} if (element.focus) {
element.focus({ preventScroll: true });
}
}}
ref={mergedContainerRef}
tabIndex={0}
{...animationProps.fadeIn}
transition={{ duration: enableEntranceAnimation ? 0.3 : 0, ease: 'anticipate' }}
>
<ItemTableListStickyUI
calculatedColumnWidths={calculatedColumnWidths}
CellComponent={optimizedCellComponent}
containerRef={containerRef}
data={data}
enableHeader={!!enableHeader}
enableStickyGroupRows={!!enableStickyGroupRows}
enableStickyHeader={!!enableStickyHeader}
getRowHeightWrapper={getRowHeightWrapper}
groups={groups}
headerHeight={headerHeight}
internalState={internalState}
parsedColumns={parsedColumns}
pinnedLeftColumnCount={pinnedLeftColumnCount}
pinnedLeftColumnRef={pinnedLeftColumnRef}
pinnedRightColumnCount={pinnedRightColumnCount}
pinnedRightColumnRef={pinnedRightColumnRef}
pinnedRowRef={pinnedRowRef}
rowHeight={rowHeight}
rowRef={rowRef}
size={size}
stickyHeaderItemProps={stickyHeaderItemProps}
totalColumnCount={totalColumnCount}
/>
<MemoizedVirtualizedTableGrid
calculatedColumnWidths={calculatedColumnWidths}
CellComponent={optimizedCellComponent}
data={data}
dataWithGroups={dataWithGroups}
enableScrollShadow={enableScrollShadow}
getItem={getItem}
headerHeight={headerHeight}
mergedRowRef={mergedRowRef}
onRangeChanged={onRangeChanged}
parsedColumns={parsedColumns}
pinnedLeftColumnCount={pinnedLeftColumnCount}
pinnedLeftColumnRef={pinnedLeftColumnRef}
pinnedRightColumnCount={pinnedRightColumnCount}
pinnedRightColumnRef={pinnedRightColumnRef}
pinnedRowCount={pinnedRowCount}
pinnedRowRef={pinnedRowRef}
showLeftShadow={showLeftShadow}
showRightShadow={showRightShadow}
showTopShadow={showTopShadow}
tableConfig={tableConfigValue}
totalColumnCount={totalColumnCount}
totalRowCount={totalRowCount}
/>
</motion.div>
</ItemTableListConfigProvider> </ItemTableListConfigProvider>
</ItemTableListStoreProvider> </ItemTableListStoreProvider>
); );
@@ -1,36 +0,0 @@
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; justify-content: center;
width: 100%; width: 100%;
height: 100%; height: 100%;
padding: 0 var(--theme-spacing-xs); padding: var(--theme-spacing-sm);
} }
.title-wrapper.hidden { .title-wrapper.hidden {
@@ -11,10 +11,8 @@ import { useWindowSettings } from '/@/renderer/store/settings.store';
import { Flex, FlexProps } from '/@/shared/components/flex/flex'; import { Flex, FlexProps } from '/@/shared/components/flex/flex';
import { Platform } from '/@/shared/types/types'; import { Platform } from '/@/shared/types/types';
export interface PageHeaderProps extends Omit< export interface PageHeaderProps
FlexProps, extends Omit<FlexProps, 'onAnimationStart' | 'onDrag' | 'onDragEnd' | 'onDragStart'> {
'onAnimationStart' | 'onDrag' | 'onDragEnd' | 'onDragStart'
> {
animated?: boolean; animated?: boolean;
backgroundColor?: string; backgroundColor?: string;
children?: ReactNode; children?: ReactNode;
@@ -54,15 +54,15 @@ export const AlbumListHeaderFilters = ({ toggleGenreTarget }: { toggleGenreTarge
return Boolean( return Boolean(
isFilterValueSet(query[FILTER_KEYS.ALBUM._CUSTOM]) || isFilterValueSet(query[FILTER_KEYS.ALBUM._CUSTOM]) ||
isFilterValueSet(query[FILTER_KEYS.ALBUM.ARTIST_IDS]) || isFilterValueSet(query[FILTER_KEYS.ALBUM.ARTIST_IDS]) ||
query[FILTER_KEYS.ALBUM.COMPILATION] !== undefined || query[FILTER_KEYS.ALBUM.COMPILATION] !== undefined ||
query[FILTER_KEYS.ALBUM.FAVORITE] !== undefined || query[FILTER_KEYS.ALBUM.FAVORITE] !== undefined ||
isFilterValueSet(query[FILTER_KEYS.ALBUM.GENRE_ID]) || isFilterValueSet(query[FILTER_KEYS.ALBUM.GENRE_ID]) ||
query[FILTER_KEYS.ALBUM.HAS_RATING] !== undefined || query[FILTER_KEYS.ALBUM.HAS_RATING] !== undefined ||
isFilterValueSet(query[FILTER_KEYS.ALBUM.MAX_YEAR]) || isFilterValueSet(query[FILTER_KEYS.ALBUM.MAX_YEAR]) ||
isFilterValueSet(query[FILTER_KEYS.ALBUM.MIN_YEAR]) || isFilterValueSet(query[FILTER_KEYS.ALBUM.MIN_YEAR]) ||
query[FILTER_KEYS.ALBUM.RECENTLY_PLAYED] !== undefined || query[FILTER_KEYS.ALBUM.RECENTLY_PLAYED] !== undefined ||
isFilterValueSet(query[FILTER_KEYS.SHARED.SEARCH_TERM]), isFilterValueSet(query[FILTER_KEYS.SHARED.SEARCH_TERM]),
); );
}, [albumFilters.query]); }, [albumFilters.query]);
@@ -13,7 +13,6 @@ import {
setMultipleSearchParams, setMultipleSearchParams,
setSearchParam, setSearchParam,
} from '/@/renderer/utils/query-params'; } from '/@/renderer/utils/query-params';
import { runInUrlTransition } from '/@/renderer/utils/url-transition';
import { AlbumListSort, SortOrder } from '/@/shared/types/domain-types'; import { AlbumListSort, SortOrder } from '/@/shared/types/domain-types';
import { ItemListKey } from '/@/shared/types/types'; import { ItemListKey } from '/@/shared/types/types';
@@ -75,10 +74,8 @@ export const useAlbumListFilters = (listKey?: ItemListKey) => {
const setGenreId = useCallback( const setGenreId = useCallback(
(value: null | string[]) => { (value: null | string[]) => {
runInUrlTransition(() => { setSearchParams((prev) => setSearchParam(prev, FILTER_KEYS.ALBUM.GENRE_ID, value), {
setSearchParams((prev) => setSearchParam(prev, FILTER_KEYS.ALBUM.GENRE_ID, value), { replace: true,
replace: true,
});
}); });
}, },
[setSearchParams], [setSearchParams],
@@ -86,13 +83,8 @@ export const useAlbumListFilters = (listKey?: ItemListKey) => {
const setAlbumArtist = useCallback( const setAlbumArtist = useCallback(
(value: null | string[]) => { (value: null | string[]) => {
runInUrlTransition(() => { setSearchParams((prev) => setSearchParam(prev, FILTER_KEYS.ALBUM.ARTIST_IDS, value), {
setSearchParams( replace: true,
(prev) => setSearchParam(prev, FILTER_KEYS.ALBUM.ARTIST_IDS, value),
{
replace: true,
},
);
}); });
}, },
[setSearchParams], [setSearchParams],
@@ -100,10 +92,8 @@ export const useAlbumListFilters = (listKey?: ItemListKey) => {
const setMinYear = useCallback( const setMinYear = useCallback(
(value: null | number) => { (value: null | number) => {
runInUrlTransition(() => { setSearchParams((prev) => setSearchParam(prev, FILTER_KEYS.ALBUM.MIN_YEAR, value), {
setSearchParams((prev) => setSearchParam(prev, FILTER_KEYS.ALBUM.MIN_YEAR, value), { replace: true,
replace: true,
});
}); });
}, },
[setSearchParams], [setSearchParams],
@@ -111,10 +101,8 @@ export const useAlbumListFilters = (listKey?: ItemListKey) => {
const setMaxYear = useCallback( const setMaxYear = useCallback(
(value: null | number) => { (value: null | number) => {
runInUrlTransition(() => { setSearchParams((prev) => setSearchParam(prev, FILTER_KEYS.ALBUM.MAX_YEAR, value), {
setSearchParams((prev) => setSearchParam(prev, FILTER_KEYS.ALBUM.MAX_YEAR, value), { replace: true,
replace: true,
});
}); });
}, },
[setSearchParams], [setSearchParams],
@@ -122,10 +110,8 @@ export const useAlbumListFilters = (listKey?: ItemListKey) => {
const setFavorite = useCallback( const setFavorite = useCallback(
(value: boolean | null) => { (value: boolean | null) => {
runInUrlTransition(() => { setSearchParams((prev) => setSearchParam(prev, FILTER_KEYS.ALBUM.FAVORITE, value), {
setSearchParams((prev) => setSearchParam(prev, FILTER_KEYS.ALBUM.FAVORITE, value), { replace: true,
replace: true,
});
}); });
}, },
[setSearchParams], [setSearchParams],
@@ -133,13 +119,8 @@ export const useAlbumListFilters = (listKey?: ItemListKey) => {
const setCompilation = useCallback( const setCompilation = useCallback(
(value: boolean | null) => { (value: boolean | null) => {
runInUrlTransition(() => { setSearchParams((prev) => setSearchParam(prev, FILTER_KEYS.ALBUM.COMPILATION, value), {
setSearchParams( replace: true,
(prev) => setSearchParam(prev, FILTER_KEYS.ALBUM.COMPILATION, value),
{
replace: true,
},
);
}); });
}, },
[setSearchParams], [setSearchParams],
@@ -147,13 +128,8 @@ export const useAlbumListFilters = (listKey?: ItemListKey) => {
const setHasRating = useCallback( const setHasRating = useCallback(
(value: boolean | null) => { (value: boolean | null) => {
runInUrlTransition(() => { setSearchParams((prev) => setSearchParam(prev, FILTER_KEYS.ALBUM.HAS_RATING, value), {
setSearchParams( replace: true,
(prev) => setSearchParam(prev, FILTER_KEYS.ALBUM.HAS_RATING, value),
{
replace: true,
},
);
}); });
}, },
[setSearchParams], [setSearchParams],
@@ -161,71 +137,65 @@ export const useAlbumListFilters = (listKey?: ItemListKey) => {
const setRecentlyPlayed = useCallback( const setRecentlyPlayed = useCallback(
(value: boolean | null) => { (value: boolean | null) => {
runInUrlTransition(() => { setSearchParams(
setSearchParams( (prev) => setSearchParam(prev, FILTER_KEYS.ALBUM.RECENTLY_PLAYED, value),
(prev) => setSearchParam(prev, FILTER_KEYS.ALBUM.RECENTLY_PLAYED, value), {
{ replace: true,
replace: true, },
}, );
);
});
}, },
[setSearchParams], [setSearchParams],
); );
const setCustom = useCallback( const setCustom = useCallback(
(value: null | Record<string, any>) => { (value: null | Record<string, any>) => {
runInUrlTransition(() => { setSearchParams(
setSearchParams( (prev) => {
(prev) => { const previousValue = prev.get(FILTER_KEYS.ALBUM._CUSTOM);
const previousValue = prev.get(FILTER_KEYS.ALBUM._CUSTOM);
const newCustom = { const newCustom = {
...(previousValue ? JSON.parse(previousValue) : {}), ...(previousValue ? JSON.parse(previousValue) : {}),
...value, ...value,
}; };
const filteredNewCustom = Object.fromEntries( const filteredNewCustom = Object.fromEntries(
Object.entries(newCustom).filter( Object.entries(newCustom).filter(
([, value]) => value !== null && value !== undefined, ([, value]) => value !== null && value !== undefined,
), ),
); );
prev.set(FILTER_KEYS.ALBUM._CUSTOM, JSON.stringify(filteredNewCustom)); prev.set(FILTER_KEYS.ALBUM._CUSTOM, JSON.stringify(filteredNewCustom));
return prev; return prev;
}, },
{ {
replace: true, replace: true,
}, },
); );
});
}, },
[setSearchParams], [setSearchParams],
); );
const clear = useCallback(() => { const clear = useCallback(() => {
runInUrlTransition(() => { setSearchParams(
setSearchParams( (prev) =>
(prev) => setMultipleSearchParams(
setMultipleSearchParams( prev,
prev, {
{ [FILTER_KEYS.ALBUM._CUSTOM]: null,
[FILTER_KEYS.ALBUM._CUSTOM]: null, [FILTER_KEYS.ALBUM.ARTIST_IDS]: null,
[FILTER_KEYS.ALBUM.ARTIST_IDS]: null, [FILTER_KEYS.ALBUM.COMPILATION]: null,
[FILTER_KEYS.ALBUM.COMPILATION]: null, [FILTER_KEYS.ALBUM.FAVORITE]: null,
[FILTER_KEYS.ALBUM.FAVORITE]: null, [FILTER_KEYS.ALBUM.GENRE_ID]: null,
[FILTER_KEYS.ALBUM.GENRE_ID]: null, [FILTER_KEYS.ALBUM.HAS_RATING]: null,
[FILTER_KEYS.ALBUM.HAS_RATING]: null, [FILTER_KEYS.ALBUM.MAX_YEAR]: null,
[FILTER_KEYS.ALBUM.MAX_YEAR]: null, [FILTER_KEYS.ALBUM.MIN_YEAR]: null,
[FILTER_KEYS.ALBUM.MIN_YEAR]: null, [FILTER_KEYS.ALBUM.RECENTLY_PLAYED]: null,
[FILTER_KEYS.ALBUM.RECENTLY_PLAYED]: null, [FILTER_KEYS.SHARED.SEARCH_TERM]: null,
[FILTER_KEYS.SHARED.SEARCH_TERM]: null, },
}, new Set([FILTER_KEYS.ALBUM._CUSTOM]),
new Set([FILTER_KEYS.ALBUM._CUSTOM]), ),
), { replace: true },
{ replace: true }, );
);
});
}, [setSearchParams]); }, [setSearchParams]);
const query = useMemo( const query = useMemo(
@@ -1,6 +1,6 @@
import { useSuspenseQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { useRef } from 'react'; import { useRef } from 'react';
import { useParams } from 'react-router'; import { useLocation, useParams } from 'react-router';
import { useItemImageUrl } from '/@/renderer/components/item-image/item-image'; import { useItemImageUrl } from '/@/renderer/components/item-image/item-image';
import { NativeScrollArea } from '/@/renderer/components/native-scroll-area/native-scroll-area'; import { NativeScrollArea } from '/@/renderer/components/native-scroll-area/native-scroll-area';
@@ -16,7 +16,7 @@ import { LibraryContainer } from '/@/renderer/features/shared/components/library
import { LibraryHeaderBar } from '/@/renderer/features/shared/components/library-header-bar'; import { LibraryHeaderBar } from '/@/renderer/features/shared/components/library-header-bar';
import { PageErrorBoundary } from '/@/renderer/features/shared/components/page-error-boundary'; import { PageErrorBoundary } from '/@/renderer/features/shared/components/page-error-boundary';
import { useFastAverageColor } from '/@/renderer/hooks'; import { useFastAverageColor } from '/@/renderer/hooks';
import { useAlbumBackground, useCurrentServerId } from '/@/renderer/store'; import { useAlbumBackground, useCurrentServer } from '/@/renderer/store';
import { LibraryItem } from '/@/shared/types/domain-types'; import { LibraryItem } from '/@/shared/types/domain-types';
const ALBUM_DETAIL_BG_FALLBACK = 'var(--theme-colors-foreground-muted)'; const ALBUM_DETAIL_BG_FALLBACK = 'var(--theme-colors-foreground-muted)';
@@ -27,10 +27,13 @@ const AlbumDetailRoute = () => {
const { albumBackground, albumBackgroundBlur } = useAlbumBackground(); const { albumBackground, albumBackgroundBlur } = useAlbumBackground();
const { albumId } = useParams() as { albumId: string }; const { albumId } = useParams() as { albumId: string };
const serverId = useCurrentServerId(); const server = useCurrentServer();
const detailQuery = useSuspenseQuery({ const location = useLocation();
...albumQueries.detail({ query: { id: albumId }, serverId }),
const detailQuery = useQuery({
...albumQueries.detail({ query: { id: albumId }, serverId: server?.id }),
placeholderData: location.state?.item,
}); });
const imageUrl = const imageUrl =
@@ -62,7 +65,9 @@ const AlbumDetailRoute = () => {
itemType={LibraryItem.ALBUM} itemType={LibraryItem.ALBUM}
variant="default" variant="default"
/> />
<LibraryHeaderBar.Title>{detailQuery.data.name}</LibraryHeaderBar.Title> <LibraryHeaderBar.Title>
{detailQuery?.data?.name}
</LibraryHeaderBar.Title>
</LibraryHeaderBar> </LibraryHeaderBar>
), ),
offset: 200, offset: 200,
@@ -1,4 +1,4 @@
import { useSuspenseQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { Fragment } from 'react'; import { Fragment } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { generatePath, Link, useParams } from 'react-router'; import { generatePath, Link, useParams } from 'react-router';
@@ -39,7 +39,7 @@ const DummyAlbumDetailRoute = () => {
const { albumId } = useParams() as { albumId: string }; const { albumId } = useParams() as { albumId: string };
const server = useCurrentServer(); const server = useCurrentServer();
const queryKey = queryKeys.songs.detail(server?.id || '', albumId); const queryKey = queryKeys.songs.detail(server?.id || '', albumId);
const detailQuery = useSuspenseQuery({ const detailQuery = useQuery({
queryFn: ({ signal }) => { queryFn: ({ signal }) => {
return api.controller.getSongDetail({ return api.controller.getSongDetail({
apiClientProps: { serverId: server?.id || '', signal }, apiClientProps: { serverId: server?.id || '', signal },
@@ -52,7 +52,7 @@ const DummyAlbumDetailRoute = () => {
const { background, colorId } = useFastAverageColor({ const { background, colorId } = useFastAverageColor({
id: albumId, id: albumId,
src: detailQuery.data?.imageUrl, src: detailQuery.data?.imageUrl,
srcLoaded: Boolean(detailQuery.data?.imageUrl), srcLoaded: !detailQuery.isLoading,
}); });
const { addToQueueByFetch } = usePlayer(); const { addToQueueByFetch } = usePlayer();
const playButtonBehavior = usePlayButtonBehavior(); const playButtonBehavior = usePlayButtonBehavior();
@@ -16,7 +16,8 @@ import {
} from '/@/shared/types/domain-types'; } from '/@/shared/types/domain-types';
import { ItemListKey } from '/@/shared/types/types'; import { ItemListKey } from '/@/shared/types/types';
interface AlbumArtistListInfiniteGridProps extends ItemListGridComponentProps<AlbumArtistListQuery> {} interface AlbumArtistListInfiniteGridProps
extends ItemListGridComponentProps<AlbumArtistListQuery> {}
export const AlbumArtistListInfiniteGrid = ({ export const AlbumArtistListInfiniteGrid = ({
gap = 'md', gap = 'md',
@@ -17,7 +17,8 @@ import {
} from '/@/shared/types/domain-types'; } from '/@/shared/types/domain-types';
import { ItemListKey } from '/@/shared/types/types'; import { ItemListKey } from '/@/shared/types/types';
interface AlbumArtistListInfiniteTableProps extends ItemListTableComponentProps<AlbumArtistListQuery> {} interface AlbumArtistListInfiniteTableProps
extends ItemListTableComponentProps<AlbumArtistListQuery> {}
export const AlbumArtistListInfiniteTable = ({ export const AlbumArtistListInfiniteTable = ({
autoFitColumns = false, autoFitColumns = false,
@@ -18,7 +18,8 @@ import {
} from '/@/shared/types/domain-types'; } from '/@/shared/types/domain-types';
import { ItemListKey } from '/@/shared/types/types'; import { ItemListKey } from '/@/shared/types/types';
interface AlbumArtistListPaginatedGridProps extends ItemListGridComponentProps<AlbumArtistListQuery> {} interface AlbumArtistListPaginatedGridProps
extends ItemListGridComponentProps<AlbumArtistListQuery> {}
export const AlbumArtistListPaginatedGrid = ({ export const AlbumArtistListPaginatedGrid = ({
gap = 'md', gap = 'md',
@@ -19,7 +19,8 @@ import {
} from '/@/shared/types/domain-types'; } from '/@/shared/types/domain-types';
import { ItemListKey } from '/@/shared/types/types'; import { ItemListKey } from '/@/shared/types/types';
interface AlbumArtistListPaginatedTableProps extends ItemListTableComponentProps<AlbumArtistListQuery> {} interface AlbumArtistListPaginatedTableProps
extends ItemListTableComponentProps<AlbumArtistListQuery> {}
export const AlbumArtistListPaginatedTable = ({ export const AlbumArtistListPaginatedTable = ({
autoFitColumns = false, autoFitColumns = false,
@@ -6,7 +6,6 @@ import { useSortByFilter } from '/@/renderer/features/shared/hooks/use-sort-by-f
import { useSortOrderFilter } from '/@/renderer/features/shared/hooks/use-sort-order-filter'; import { useSortOrderFilter } from '/@/renderer/features/shared/hooks/use-sort-order-filter';
import { FILTER_KEYS } from '/@/renderer/features/shared/utils'; import { FILTER_KEYS } from '/@/renderer/features/shared/utils';
import { setMultipleSearchParams } from '/@/renderer/utils/query-params'; import { setMultipleSearchParams } from '/@/renderer/utils/query-params';
import { runInUrlTransition } from '/@/renderer/utils/url-transition';
import { AlbumArtistListSort } from '/@/shared/types/domain-types'; import { AlbumArtistListSort } from '/@/shared/types/domain-types';
import { ItemListKey } from '/@/shared/types/types'; import { ItemListKey } from '/@/shared/types/types';
@@ -20,15 +19,13 @@ export const useAlbumArtistListFilters = () => {
const [, setSearchParams] = useSearchParams(); const [, setSearchParams] = useSearchParams();
const clear = useCallback(() => { const clear = useCallback(() => {
runInUrlTransition(() => { setSearchParams(
setSearchParams( (prev) =>
(prev) => setMultipleSearchParams(prev, {
setMultipleSearchParams(prev, { [FILTER_KEYS.SHARED.SEARCH_TERM]: null,
[FILTER_KEYS.SHARED.SEARCH_TERM]: null, }),
}), { replace: true },
{ replace: true }, );
);
});
}, [setSearchParams]); }, [setSearchParams]);
const query = { const query = {
@@ -1,4 +1,4 @@
import { useSuspenseQueries } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { useMemo } from 'react'; import { useMemo } from 'react';
import { useParams } from 'react-router'; import { useParams } from 'react-router';
@@ -35,18 +35,20 @@ const AlbumArtistDetailFavoriteSongsListRoute = () => {
const server = useCurrentServer(); const server = useCurrentServer();
const pageKey = LibraryItem.SONG; const pageKey = LibraryItem.SONG;
const [detailQuery, favoriteSongsQuery] = useSuspenseQueries({ const detailQuery = useQuery(
queries: [ artistsQueries.albumArtistDetail({
artistsQueries.albumArtistDetail({ query: { id: routeId },
query: { id: routeId }, serverId: server?.id,
serverId: server?.id, }),
}), );
artistsQueries.favoriteSongs({
query: { artistId: routeId }, const favoriteSongsQuery = useQuery(
serverId: server?.id, artistsQueries.favoriteSongs({
}), options: { enabled: !!detailQuery?.data?.name },
], query: { artistId: routeId },
}); serverId: server?.id,
}),
);
const songs = useMemo( const songs = useMemo(
() => favoriteSongsQuery?.data?.items || [], () => favoriteSongsQuery?.data?.items || [],
@@ -166,7 +168,7 @@ const AlbumArtistDetailFavoriteSongsListRoute = () => {
); );
}; };
const AlbumArtistDetailFavoriteSongsListRouteWithBoundary = () => { const AlbumArtistDetailTopSongsListRouteWithBoundary = () => {
return ( return (
<PageErrorBoundary> <PageErrorBoundary>
<AlbumArtistDetailFavoriteSongsListRoute /> <AlbumArtistDetailFavoriteSongsListRoute />
@@ -174,4 +176,4 @@ const AlbumArtistDetailFavoriteSongsListRouteWithBoundary = () => {
); );
}; };
export default AlbumArtistDetailFavoriteSongsListRouteWithBoundary; export default AlbumArtistDetailTopSongsListRouteWithBoundary;
@@ -1,4 +1,4 @@
import { useSuspenseQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { useMemo } from 'react'; import { useMemo } from 'react';
import { useParams } from 'react-router'; import { useParams } from 'react-router';
@@ -34,15 +34,16 @@ const AlbumArtistDetailTopSongsListRoute = () => {
key: 'album-artist-top-songs-query-type', key: 'album-artist-top-songs-query-type',
}); });
const detailQuery = useSuspenseQuery( const detailQuery = useQuery(
artistsQueries.albumArtistDetail({ artistsQueries.albumArtistDetail({
query: { id: routeId }, query: { id: routeId },
serverId: server?.id, serverId: server?.id,
}), }),
); );
const topSongsQuery = useSuspenseQuery( const topSongsQuery = useQuery(
artistsQueries.topSongs({ artistsQueries.topSongs({
options: { enabled: !!detailQuery?.data?.name },
query: { query: {
artist: detailQuery?.data?.name || '', artist: detailQuery?.data?.name || '',
artistId: routeId, artistId: routeId,
@@ -347,7 +347,7 @@ export const AddToPlaylistAction = ({ items, itemType }: AddToPlaylistActionProp
innerProps: { innerProps: {
...modalProps, ...modalProps,
}, },
modal: 'addToPlaylist', modalKey: 'addToPlaylist',
size: 'lg', size: 'lg',
title: t('page.contextMenu.addToPlaylist', { postProcess: 'sentenceCase' }), title: t('page.contextMenu.addToPlaylist', { postProcess: 'sentenceCase' }),
}); });
@@ -36,7 +36,7 @@ export const ShareAction = ({ ids, itemType }: ShareActionProps) => {
itemIds: ids, itemIds: ids,
resourceType, resourceType,
}, },
modal: 'shareItem', modalKey: 'shareItem',
title: t('page.contextMenu.shareItem', { postProcess: 'titleCase' }), title: t('page.contextMenu.shareItem', { postProcess: 'titleCase' }),
}); });
}, [ids, resourceType, t]); }, [ids, resourceType, t]);
@@ -38,10 +38,10 @@ export const PlaylistContextMenu = ({ items, type }: PlaylistContextMenuProps) =
<ContextMenu.Divider /> <ContextMenu.Divider />
<AddToPlaylistAction items={ids} itemType={LibraryItem.PLAYLIST} /> <AddToPlaylistAction items={ids} itemType={LibraryItem.PLAYLIST} />
<ContextMenu.Divider /> <ContextMenu.Divider />
<GetInfoAction disabled={items.length === 0} items={items} />
<ContextMenu.Divider />
<EditPlaylistAction disabled={!canEditPlaylist} items={items} /> <EditPlaylistAction disabled={!canEditPlaylist} items={items} />
<DeletePlaylistAction disabled={!canDeletePlaylist} items={items} /> <DeletePlaylistAction disabled={!canDeletePlaylist} items={items} />
<ContextMenu.Divider />
<GetInfoAction disabled={items.length === 0} items={items} />
</ContextMenu.Content> </ContextMenu.Content>
); );
}; };
@@ -6,7 +6,6 @@ import { useSortByFilter } from '/@/renderer/features/shared/hooks/use-sort-by-f
import { useSortOrderFilter } from '/@/renderer/features/shared/hooks/use-sort-order-filter'; import { useSortOrderFilter } from '/@/renderer/features/shared/hooks/use-sort-order-filter';
import { FILTER_KEYS } from '/@/renderer/features/shared/utils'; import { FILTER_KEYS } from '/@/renderer/features/shared/utils';
import { parseJsonParam, setJsonSearchParam } from '/@/renderer/utils/query-params'; import { parseJsonParam, setJsonSearchParam } from '/@/renderer/utils/query-params';
import { runInUrlTransition } from '/@/renderer/utils/url-transition';
import { SongListSort, SortOrder } from '/@/shared/types/domain-types'; import { SongListSort, SortOrder } from '/@/shared/types/domain-types';
import { ItemListKey } from '/@/shared/types/types'; import { ItemListKey } from '/@/shared/types/types';
@@ -30,19 +29,13 @@ export const useFolderListFilters = () => {
}, [searchParams]); }, [searchParams]);
const setFolderPath = (path: FolderPathItem[]) => { const setFolderPath = (path: FolderPathItem[]) => {
runInUrlTransition(() => { setSearchParams(
setSearchParams( (prev) => {
(prev) => { const newParams = setJsonSearchParam(prev, FILTER_KEYS.FOLDER.FOLDER_PATH, path);
const newParams = setJsonSearchParam( return newParams;
prev, },
FILTER_KEYS.FOLDER.FOLDER_PATH, { replace: false },
path, );
);
return newParams;
},
{ replace: false },
);
});
}; };
// Navigate to a folder (adds to path) // Navigate to a folder (adds to path)
@@ -5,7 +5,7 @@ import i18n from '/@/i18n/i18n';
export const openLyricsSettingsModal = (settingsKey: string = 'default') => { export const openLyricsSettingsModal = (settingsKey: string = 'default') => {
openContextModal({ openContextModal({
innerProps: { settingsKey }, innerProps: { settingsKey },
modal: 'lyricsSettings', modalKey: 'lyricsSettings',
overlayProps: { overlayProps: {
blur: 0, blur: 0,
opacity: 0, opacity: 0,
@@ -1,17 +0,0 @@
.toolbar {
width: 100%;
min-width: 0;
container-type: inline-size;
}
.restore-section {
display: contents;
}
@container (max-width: 296px) {
.restore-section {
display: none;
}
}
@@ -3,8 +3,6 @@ import { t } from 'i18next';
import { RefObject } from 'react'; import { RefObject } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import styles from './play-queue-list-controls.module.css';
import { queryKeys } from '/@/renderer/api/query-keys'; import { queryKeys } from '/@/renderer/api/query-keys';
import { SONG_TABLE_COLUMNS } from '/@/renderer/components/item-list/item-table-list/default-columns'; import { SONG_TABLE_COLUMNS } from '/@/renderer/components/item-list/item-table-list/default-columns';
import { ItemListHandle } from '/@/renderer/components/item-list/types'; import { ItemListHandle } from '/@/renderer/components/item-list/types';
@@ -18,8 +16,6 @@ import { SearchInput } from '/@/renderer/features/shared/components/search-input
import { useCurrentServer, usePlayerStoreBase } from '/@/renderer/store'; import { useCurrentServer, usePlayerStoreBase } from '/@/renderer/store';
import { hasFeature } from '/@/shared/api/utils'; import { hasFeature } from '/@/shared/api/utils';
import { ActionIcon } from '/@/shared/components/action-icon/action-icon'; 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 { Group } from '/@/shared/components/group/group';
import { ServerFeature } from '/@/shared/types/features-types'; import { ServerFeature } from '/@/shared/types/features-types';
import { ItemListKey, ListDisplayType } from '/@/shared/types/types'; import { ItemListKey, ListDisplayType } from '/@/shared/types/types';
@@ -37,53 +33,6 @@ export const PlayQueueListControls = ({
tableRef, tableRef,
type, type,
}: PlayQueueListOptionsProps) => { }: 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 { t } = useTranslation();
const player = usePlayer(); const player = usePlayer();
@@ -103,29 +52,53 @@ const QueuePlaybackIcons = ({ tableRef }: { tableRef: RefObject<ItemListHandle |
}; };
return ( return (
<> <Group h="65px" justify="space-between" px="1rem" py="1rem" w="100%">
<ActionIcon <Group gap="xs">
icon="mediaShuffle" <QueueRestoreActions />
iconProps={{ size: 'lg' }} <ActionIcon
onClick={handleShuffleQueue} icon="mediaShuffle"
tooltip={{ label: t('player.shuffle', { postProcess: 'sentenceCase' }) }} iconProps={{ size: 'lg' }}
variant="subtle" onClick={handleShuffleQueue}
/> tooltip={{ label: t('player.shuffle', { postProcess: 'sentenceCase' }) }}
<ActionIcon variant="subtle"
icon="x" />
iconProps={{ size: 'lg' }} <ActionIcon
onClick={handleClearQueue} icon="x"
tooltip={{ label: t('action.clearQueue', { postProcess: 'sentenceCase' }) }} iconProps={{ size: 'lg' }}
variant="subtle" onClick={handleClearQueue}
/> tooltip={{ label: t('action.clearQueue', { postProcess: 'sentenceCase' }) }}
<ActionIcon variant="subtle"
icon="goToItem" />
iconProps={{ size: 'lg' }} <ActionIcon
onClick={handleJumpToCurrent} icon="goToItem"
tooltip={{ label: t('action.goToCurrent', { postProcess: 'sentenceCase' }) }} iconProps={{ size: 'lg' }}
variant="subtle" 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>
); );
}; };
@@ -144,7 +117,7 @@ const QueueRestoreActions = () => {
} }
return ( return (
<span className={styles.restoreSection}> <>
<ActionIcon <ActionIcon
disabled={Boolean(isFetching)} disabled={Boolean(isFetching)}
icon="upload" icon="upload"
@@ -171,6 +144,6 @@ const QueueRestoreActions = () => {
}} }}
variant="subtle" variant="subtle"
/> />
</span> </>
); );
}; };
@@ -27,7 +27,7 @@ import {
import { ActionIcon, ActionIconGroup } from '/@/shared/components/action-icon/action-icon'; import { ActionIcon, ActionIconGroup } from '/@/shared/components/action-icon/action-icon';
import { Flex } from '/@/shared/components/flex/flex'; import { Flex } from '/@/shared/components/flex/flex';
import { Stack } from '/@/shared/components/stack/stack'; import { Stack } from '/@/shared/components/stack/stack';
import { ItemListKey, Platform } from '/@/shared/types/types'; import { ItemListKey, Platform, PlayerType } from '/@/shared/types/types';
type SidebarPanelType = 'lyrics' | 'queue' | 'visualizer'; type SidebarPanelType = 'lyrics' | 'queue' | 'visualizer';
@@ -55,9 +55,9 @@ export const SidebarPlayQueue = () => {
const showLyricsInSidebar = useShowLyricsInSidebar(); const showLyricsInSidebar = useShowLyricsInSidebar();
const showVisualizerInSidebar = useShowVisualizerInSidebar(); const showVisualizerInSidebar = useShowVisualizerInSidebar();
const sidebarPanelOrder = useSidebarPanelOrder(); const sidebarPanelOrder = useSidebarPanelOrder();
const { webAudio } = usePlaybackSettings(); const { type, webAudio } = usePlaybackSettings();
const { windowBarStyle } = useWindowSettings(); const { windowBarStyle } = useWindowSettings();
const showVisualizer = showVisualizerInSidebar && webAudio; const showVisualizer = showVisualizerInSidebar && type === PlayerType.WEB && webAudio;
const showPanel = showLyricsInSidebar || showVisualizer; const showPanel = showLyricsInSidebar || showVisualizer;
const shouldAddTopMargin = isElectron() && windowBarStyle === Platform.WEB; const shouldAddTopMargin = isElectron() && windowBarStyle === Platform.WEB;
@@ -374,8 +374,8 @@ const CombinedLyricsAndVisualizerPanel = () => {
const visualizerType = useSettingsStore((store) => store.visualizer.type); const visualizerType = useSettingsStore((store) => store.visualizer.type);
const showLyricsInSidebar = useShowLyricsInSidebar(); const showLyricsInSidebar = useShowLyricsInSidebar();
const showVisualizerInSidebar = useShowVisualizerInSidebar(); const showVisualizerInSidebar = useShowVisualizerInSidebar();
const { webAudio } = usePlaybackSettings(); const { type, webAudio } = usePlaybackSettings();
const showVisualizer = showVisualizerInSidebar && webAudio; const showVisualizer = showVisualizerInSidebar && type === PlayerType.WEB && webAudio;
const { data: lyricsData } = useQuery( const { data: lyricsData } = useQuery(
lyricsQueries.songLyrics( lyricsQueries.songLyrics(
@@ -25,7 +25,6 @@ import {
useIsRadioActive, useIsRadioActive,
} from '/@/renderer/features/radio/hooks/use-radio-player'; } from '/@/renderer/features/radio/hooks/use-radio-player';
import { RemoteHook } from '/@/renderer/features/remote/hooks/use-remote'; import { RemoteHook } from '/@/renderer/features/remote/hooks/use-remote';
import { VisualizerSystemAudioBridgeHook } from '/@/renderer/features/visualizer/components/visualizer-system-audio-bridge';
import { import {
updateQueueFavorites, updateQueueFavorites,
updateQueueRatings, updateQueueRatings,
@@ -41,21 +40,11 @@ import { PlayerType } from '/@/shared/types/types';
const CODEC_PROBES = [ const CODEC_PROBES = [
{ codec: 'mp3', container: 'mp3', mime: 'audio/mpeg' }, { codec: 'mp3', container: 'mp3', mime: 'audio/mpeg' },
{ codec: 'aac', container: 'mp4', mime: 'audio/mp4; codecs="mp4a.40.2"' }, { 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: '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: 'ogg', mime: 'audio/ogg; codecs="vorbis"' },
{ codec: 'vorbis', container: 'webm', mime: 'audio/webm; codecs="vorbis"' },
{ codec: 'flac', container: 'flac', mime: 'audio/flac' }, { 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"' }, { codec: 'alac', container: 'mp4', mime: 'audio/mp4; codecs="alac"' },
]; ];
@@ -64,8 +53,6 @@ const DEFAULT_TRANSCODING_PROFILES = [
{ audioCodec: 'mp3', container: 'mp3', protocol: 'http' }, { audioCodec: 'mp3', container: 'mp3', protocol: 'http' },
]; ];
const SAFARI_TRANSCODING_PROFILES = [{ audioCodec: 'mp3', container: 'mp3', protocol: 'http' }];
const DIRECT_PLAY_PROFILES: { const DIRECT_PLAY_PROFILES: {
audioCodecs: string[]; audioCodecs: string[];
containers: string[]; containers: string[];
@@ -73,7 +60,7 @@ const DIRECT_PLAY_PROFILES: {
}[] = []; }[] = [];
export function getDefaultTranscodingProfiles() { export function getDefaultTranscodingProfiles() {
return isSafari() ? SAFARI_TRANSCODING_PROFILES : DEFAULT_TRANSCODING_PROFILES; return DEFAULT_TRANSCODING_PROFILES;
} }
export function getDirectPlayProfiles() { export function getDirectPlayProfiles() {
@@ -85,9 +72,9 @@ function detectBrowserProfile() {
const audio = new Audio(); const audio = new Audio();
for (const { codec, container, mime } of CODEC_PROBES) { for (const { codec, container, mime } of CODEC_PROBES) {
if (audio.canPlayType(mime) === 'maybe' || audio.canPlayType(mime) === 'probably') { if (audio.canPlayType(mime) === 'probably') {
DIRECT_PLAY_PROFILES.push({ DIRECT_PLAY_PROFILES.push({
audioCodecs: Array.isArray(codec) ? codec : [codec], audioCodecs: [codec],
containers: [container], containers: [container],
protocols: ['http'], protocols: ['http'],
}); });
@@ -99,11 +86,6 @@ function detectBrowserProfile() {
return 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 = () => { export const AudioPlayers = () => {
const playbackType = usePlaybackType(); const playbackType = usePlaybackType();
const serverId = useCurrentServerId(); const serverId = useCurrentServerId();
@@ -136,7 +118,6 @@ export const AudioPlayers = () => {
<UpdateCurrentSongHook /> <UpdateCurrentSongHook />
<RadioAudioInstanceHook /> <RadioAudioInstanceHook />
<RadioMetadataHook /> <RadioMetadataHook />
<VisualizerSystemAudioBridgeHook />
<AutosaveHook /> <AutosaveHook />
<AudioPlayersContent <AudioPlayersContent
audioContext={audioContext} audioContext={audioContext}
@@ -269,7 +269,25 @@ export const FullScreenPlayerImage = () => {
? radioMetadata?.title || stationName || 'Radio' ? radioMetadata?.title || stationName || 'Radio'
: currentSong?.name} : currentSong?.name}
</Text> </Text>
<Text key="fs-artists" size="xl"> {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">
{isPlayingRadio {isPlayingRadio
? radioMetadata?.artist || stationName || 'Radio' ? radioMetadata?.artist || stationName || 'Radio'
: currentSong?.artists?.map((artist, index) => ( : currentSong?.artists?.map((artist, index) => (
@@ -296,24 +314,6 @@ export const FullScreenPlayerImage = () => {
</Fragment> </Fragment>
))} ))}
</Text> </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 && ( {!isPlayingRadio && (
<Group justify="center" mt="sm"> <Group justify="center" mt="sm">
{playerItems.map((i) => !i.disabled && builtDataItems[i.id])} {playerItems.map((i) => !i.disabled && builtDataItems[i.id])}
@@ -15,7 +15,7 @@ import {
} from '/@/renderer/store/full-screen-player.store'; } from '/@/renderer/store/full-screen-player.store';
import { Button } from '/@/shared/components/button/button'; import { Button } from '/@/shared/components/button/button';
import { Group } from '/@/shared/components/group/group'; import { Group } from '/@/shared/components/group/group';
import { ItemListKey } from '/@/shared/types/types'; import { ItemListKey, PlayerType } from '/@/shared/types/types';
const AudioMotionAnalyzerVisualizer = lazy(() => const AudioMotionAnalyzerVisualizer = lazy(() =>
import('../../visualizer/components/audiomotionanalyzer/visualizer').then((module) => ({ import('../../visualizer/components/audiomotionanalyzer/visualizer').then((module) => ({
@@ -33,7 +33,7 @@ export const FullScreenPlayerQueue = () => {
const { t } = useTranslation(); const { t } = useTranslation();
const { activeTab, opacity } = useFullScreenPlayerStore(); const { activeTab, opacity } = useFullScreenPlayerStore();
const { setStore } = useFullScreenPlayerStoreActions(); const { setStore } = useFullScreenPlayerStoreActions();
const { webAudio } = usePlaybackSettings(); const { type, webAudio } = usePlaybackSettings();
const visualizerType = useSettingsStore((store) => store.visualizer.type); const visualizerType = useSettingsStore((store) => store.visualizer.type);
const headerItems = useMemo(() => { const headerItems = useMemo(() => {
@@ -55,7 +55,7 @@ export const FullScreenPlayerQueue = () => {
}, },
]; ];
if (webAudio) { if (type === PlayerType.WEB && webAudio) {
items.push({ items.push({
active: activeTab === 'visualizer', active: activeTab === 'visualizer',
label: t('page.fullscreenPlayer.visualizer', { postProcess: 'titleCase' }), label: t('page.fullscreenPlayer.visualizer', { postProcess: 'titleCase' }),
@@ -64,7 +64,7 @@ export const FullScreenPlayerQueue = () => {
} }
return items; return items;
}, [activeTab, setStore, t, webAudio]); }, [activeTab, setStore, t, type, webAudio]);
return ( return (
<div <div
@@ -119,7 +119,7 @@ export const FullScreenPlayerQueue = () => {
</div> </div>
) : activeTab === 'lyrics' ? ( ) : activeTab === 'lyrics' ? (
<Lyrics fadeOutNoLyricsMessage={false} /> <Lyrics fadeOutNoLyricsMessage={false} />
) : activeTab === 'visualizer' && webAudio ? ( ) : activeTab === 'visualizer' && type === PlayerType.WEB && webAudio ? (
<Suspense fallback={<></>}> <Suspense fallback={<></>}>
{visualizerType === 'butterchurn' ? ( {visualizerType === 'butterchurn' ? (
<ButterchurnVisualizer /> <ButterchurnVisualizer />
@@ -13,7 +13,7 @@ import {
useWindowSettings, useWindowSettings,
} from '/@/renderer/store/settings.store'; } from '/@/renderer/store/settings.store';
import { useHotkeys } from '/@/shared/hooks/use-hotkeys'; import { useHotkeys } from '/@/shared/hooks/use-hotkeys';
import { Platform } from '/@/shared/types/types'; import { Platform, PlayerType } from '/@/shared/types/types';
const AudioMotionAnalyzerVisualizer = lazy(() => const AudioMotionAnalyzerVisualizer = lazy(() =>
import('../../visualizer/components/audiomotionanalyzer/visualizer').then((module) => ({ import('../../visualizer/components/audiomotionanalyzer/visualizer').then((module) => ({
@@ -131,7 +131,7 @@ VisualizerContainer.displayName = 'VisualizerContainer';
export const FullScreenVisualizer = () => { export const FullScreenVisualizer = () => {
const { setStore } = useFullScreenPlayerStoreActions(); const { setStore } = useFullScreenPlayerStoreActions();
const { windowBarStyle } = useWindowSettings(); const { windowBarStyle } = useWindowSettings();
const { webAudio } = usePlaybackSettings(); const { type, webAudio } = usePlaybackSettings();
const visualizerType = useSettingsStore((store) => store.visualizer.type); const visualizerType = useSettingsStore((store) => store.visualizer.type);
const isMobile = useIsMobile(); const isMobile = useIsMobile();
@@ -155,7 +155,7 @@ export const FullScreenVisualizer = () => {
return ( return (
<VisualizerContainer isMobile={isMobile} windowBarStyle={windowBarStyle}> <VisualizerContainer isMobile={isMobile} windowBarStyle={windowBarStyle}>
<div className={styles.visualizerContainer}> <div className={styles.visualizerContainer}>
{webAudio ? ( {type === PlayerType.WEB && webAudio ? (
<Suspense fallback={<></>}> <Suspense fallback={<></>}>
{visualizerType === 'butterchurn' ? ( {visualizerType === 'butterchurn' ? (
<ButterchurnVisualizer /> <ButterchurnVisualizer />
@@ -8,10 +8,7 @@ import { shallow } from 'zustand/shallow';
import styles from './left-controls.module.css'; import styles from './left-controls.module.css';
import { ItemImage } from '/@/renderer/components/item-image/item-image'; import { ItemImage } from '/@/renderer/components/item-image/item-image';
import { import { JoinedArtists } from '/@/renderer/features/albums/components/joined-artists';
JOINED_ARTISTS_MUTED_PROPS,
JoinedArtists,
} from '/@/renderer/features/albums/components/joined-artists';
import { ContextMenuController } from '/@/renderer/features/context-menu/context-menu-controller'; import { ContextMenuController } from '/@/renderer/features/context-menu/context-menu-controller';
import { RadioMetadataDisplay } from '/@/renderer/features/player/components/radio-metadata-display'; import { RadioMetadataDisplay } from '/@/renderer/features/player/components/radio-metadata-display';
import { import {
@@ -269,14 +266,6 @@ export const LeftControls = () => {
<JoinedArtists <JoinedArtists
artistName={currentSong?.artistName || ''} artistName={currentSong?.artistName || ''}
artists={artists || []} artists={artists || []}
linkProps={{
...JOINED_ARTISTS_MUTED_PROPS.linkProps,
size: 'md',
}}
rootTextProps={{
...JOINED_ARTISTS_MUTED_PROPS.rootTextProps,
size: 'md',
}}
/> />
</div> </div>
<div <div
@@ -1,7 +1,6 @@
.container { .container {
width: 100vw; width: 100%;
height: 100%; height: 100%;
border-top: 1px solid alpha(var(--theme-colors-border), 0.5);
} }
.controls-grid { .controls-grid {
@@ -189,7 +189,7 @@ const randomFetchQuery = (args: {
export const openShuffleAllModal = async () => { export const openShuffleAllModal = async () => {
openContextModal({ openContextModal({
innerProps: {}, innerProps: {},
modal: 'shuffleAll', modalKey: 'shuffleAll',
size: 'sm', size: 'sm',
title: i18n.t('player.playRandom', { postProcess: 'sentenceCase' }) as string, title: i18n.t('player.playRandom', { postProcess: 'sentenceCase' }) as string,
}); });
@@ -1,17 +0,0 @@
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,6 +1,5 @@
import isElectron from 'is-electron'; import isElectron from 'is-electron';
import { debounce } from 'lodash'; import React, { useCallback, useEffect, useMemo } from 'react';
import React, { useCallback, useEffect, useMemo, useRef } from 'react';
import { getItemImageUrl } from '/@/renderer/components/item-image/item-image'; import { getItemImageUrl } from '/@/renderer/components/item-image/item-image';
import { usePlayerEvents } from '/@/renderer/features/player/audio-player/hooks/use-player-events'; import { usePlayerEvents } from '/@/renderer/features/player/audio-player/hooks/use-player-events';
@@ -10,9 +9,8 @@ import {
useRadioPlayer, useRadioPlayer,
} from '/@/renderer/features/radio/hooks/use-radio-player'; } from '/@/renderer/features/radio/hooks/use-radio-player';
import { import {
subscribeCurrentTrack,
subscribePlayerStatus,
usePlaybackSettings, usePlaybackSettings,
usePlaybackType,
usePlayerStore, usePlayerStore,
useSettingsStore, useSettingsStore,
useSkipButtons, useSkipButtons,
@@ -31,40 +29,6 @@ export const useMediaSession = () => {
const isRadioActive = useIsRadioActive(); const isRadioActive = useIsRadioActive();
const { isPlaying: isRadioPlaying, metadata: radioMetadata, stationName } = useRadioPlayer(); 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(() => { const isMediaSessionEnabled = useMemo(() => {
// Always enable media session on web // Always enable media session on web
if (!isElectron()) { if (!isElectron()) {
@@ -74,87 +38,71 @@ export const useMediaSession = () => {
return Boolean(mediaSessionEnabled && playbackType === PlayerType.WEB); return Boolean(mediaSessionEnabled && playbackType === PlayerType.WEB);
}, [mediaSessionEnabled, playbackType]); }, [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(() => { useEffect(() => {
if (!isMediaSessionEnabled) { 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; return;
} }
mediaSession.setActionHandler('nexttrack', () => { mediaSession.setActionHandler('nexttrack', () => {
if (isRadioActiveRef.current && isRadioPlayingRef.current) { if (isRadioActive && isRadioPlaying) {
return; return;
} }
playerRef.current.mediaNext(); player.mediaNext();
}); });
mediaSession.setActionHandler('pause', () => { mediaSession.setActionHandler('pause', () => {
playerRef.current.mediaPause(); player.mediaPause();
}); });
mediaSession.setActionHandler('play', () => { mediaSession.setActionHandler('play', () => {
playerRef.current.mediaPlay(); player.mediaPlay();
}); });
mediaSession.setActionHandler('previoustrack', () => { mediaSession.setActionHandler('previoustrack', () => {
if (isRadioActiveRef.current && isRadioPlayingRef.current) { if (isRadioActive && isRadioPlaying) {
return; return;
} }
playerRef.current.mediaPrevious(); player.mediaPrevious();
}); });
mediaSession.setActionHandler('seekto', (e) => { mediaSession.setActionHandler('seekto', (e) => {
if (isRadioActiveRef.current && isRadioPlayingRef.current) { if (isRadioActive && isRadioPlaying) {
return; return;
} }
if (e.seekTime) { if (e.seekTime) {
playerRef.current.mediaSeekToTimestamp(e.seekTime); player.mediaSeekToTimestamp(e.seekTime);
} else if (e.seekOffset) { } else if (e.seekOffset) {
const currentTimestamp = useTimestampStoreBase.getState().timestamp; const currentTimestamp = useTimestampStoreBase.getState().timestamp;
playerRef.current.mediaSeekToTimestamp(currentTimestamp + e.seekOffset); player.mediaSeekToTimestamp(currentTimestamp + e.seekOffset);
} }
}); });
mediaSession.setActionHandler('stop', () => { mediaSession.setActionHandler('stop', () => {
playerRef.current.mediaStop(); player.mediaStop();
}); });
mediaSession.setActionHandler('seekbackward', (e) => { mediaSession.setActionHandler('seekbackward', (e) => {
if (isRadioActiveRef.current && isRadioPlayingRef.current) { if (isRadioActive && isRadioPlaying) {
return; return;
} }
const currentTimestamp = useTimestampStoreBase.getState().timestamp; const currentTimestamp = useTimestampStoreBase.getState().timestamp;
playerRef.current.mediaSeekToTimestamp( player.mediaSeekToTimestamp(
currentTimestamp - (e.seekOffset || skipRef.current?.skipBackwardSeconds || 5), currentTimestamp - (e.seekOffset || skip?.skipBackwardSeconds || 5),
); );
}); });
mediaSession.setActionHandler('seekforward', (e) => { mediaSession.setActionHandler('seekforward', (e) => {
if (isRadioActiveRef.current && isRadioPlayingRef.current) { if (isRadioActive && isRadioPlaying) {
return; return;
} }
const currentTimestamp = useTimestampStoreBase.getState().timestamp; const currentTimestamp = useTimestampStoreBase.getState().timestamp;
playerRef.current.mediaSeekToTimestamp( player.mediaSeekToTimestamp(
currentTimestamp + (e.seekOffset || skipRef.current?.skipForwardSeconds || 5), currentTimestamp + (e.seekOffset || skip?.skipForwardSeconds || 5),
); );
}); });
@@ -168,22 +116,28 @@ export const useMediaSession = () => {
mediaSession.setActionHandler('seekbackward', null); mediaSession.setActionHandler('seekbackward', null);
mediaSession.setActionHandler('seekforward', null); mediaSession.setActionHandler('seekforward', null);
}; };
}, [isMediaSessionEnabled]); }, [
player,
skip?.skipBackwardSeconds,
skip?.skipForwardSeconds,
isMediaSessionEnabled,
isRadioActive,
isRadioPlaying,
]);
const updateMediaSessionMetadata = useCallback( const updateMediaSessionMetadata = useCallback(
(song: QueueSong | undefined) => { (song: QueueSong | undefined) => {
// Read from ref so this callback is never stale regardless of when it was created if (!isMediaSessionEnabled) {
if (!isMediaSessionEnabledRef.current) {
return; return;
} }
// Handle radio metadata when radio is active and playing // Handle radio metadata when radio is active and playing
if (isRadioActiveRef.current && isRadioPlayingRef.current) { if (isRadioActive && isRadioPlaying) {
const title = radioMetadataRef.current?.title || stationNameRef.current || 'Radio'; const title = radioMetadata?.title || stationName || 'Radio';
const artist = radioMetadataRef.current?.artist || stationNameRef.current || ''; const artist = radioMetadata?.artist || stationName || '';
mediaSession.metadata = new MediaMetadata({ mediaSession.metadata = new MediaMetadata({
album: stationNameRef.current || '', album: stationName || '',
artist: artist, artist: artist,
artwork: [], artwork: [],
title: title, title: title,
@@ -210,88 +164,62 @@ export const useMediaSession = () => {
title: song?.name ?? '', title: song?.name ?? '',
}); });
}, },
// All values are read from refs — stable callback, no stale closure risk [isMediaSessionEnabled, isRadioActive, isRadioPlaying, radioMetadata, stationName],
[],
); );
// 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 // Update metadata when radio metadata changes
useEffect(() => { useEffect(() => {
if (!isMediaSessionEnabled) { if (!isMediaSessionEnabled) {
return; return;
} }
if (isRadioActiveRef.current && isRadioPlayingRef.current) { if (isRadioActive && isRadioPlaying) {
debouncedUpdateMetadata(undefined); updateMediaSessionMetadata(undefined);
} }
}, [radioMetadata, isRadioPlaying, isMediaSessionEnabled, debouncedUpdateMetadata]); }, [
isMediaSessionEnabled,
isRadioActive,
isRadioPlaying,
radioMetadata,
stationName,
updateMediaSessionMetadata,
]);
// 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( usePlayerEvents(
{ {
onPlayerRepeated: () => { onCurrentSongChange: (properties) => {
if (!isMediaSessionEnabledRef.current) { if (!isMediaSessionEnabled) {
return; return;
} }
if (isRadioActiveRef.current && isRadioPlayingRef.current) { if (isRadioActive && isRadioPlaying) {
return;
}
updateMediaSessionMetadata(properties.song);
},
onPlayerRepeated: () => {
if (!isMediaSessionEnabled) {
return;
}
if (isRadioActive && isRadioPlaying) {
return; return;
} }
const currentSong = usePlayerStore.getState().getCurrentSong(); const currentSong = usePlayerStore.getState().getCurrentSong();
debouncedUpdateMetadata(currentSong); updateMediaSessionMetadata(currentSong);
},
onPlayerStatus: (properties) => {
if (!isMediaSessionEnabled) {
return;
}
const status = properties.status;
mediaSession.playbackState = status === PlayerStatus.PLAYING ? 'playing' : 'paused';
}, },
}, },
[], [isMediaSessionEnabled, isRadioActive, isRadioPlaying, updateMediaSessionMetadata],
); );
}; };
@@ -301,7 +229,18 @@ const MediaSessionHookInner = () => {
}; };
export const MediaSessionHook = () => { export const MediaSessionHook = () => {
// Always render the hook — let the internal guard logic decide whether to act. const isElectronEnv = isElectron();
// Conditional rendering here causes unmount/remount cycles that destroy handlers mid-session. 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;
}
return React.createElement(MediaSessionHookInner); return React.createElement(MediaSessionHookInner);
}; };
@@ -1,155 +0,0 @@
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,
]);
}
@@ -1,15 +0,0 @@
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 },
});
}
@@ -1,14 +0,0 @@
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 = () => { export const openVisualizerSettingsModal = () => {
openContextModal({ openContextModal({
innerProps: {}, innerProps: {},
modal: 'visualizerSettings', modalKey: 'visualizerSettings',
overlayProps: { overlayProps: {
blur: 0, blur: 0,
opacity: 0, opacity: 0,
@@ -20,10 +20,8 @@ import {
} from '/@/shared/types/domain-types'; } from '/@/shared/types/domain-types';
import { ItemListKey } from '/@/shared/types/types'; import { ItemListKey } from '/@/shared/types/types';
interface PlaylistDetailSongListGridProps extends Omit< interface PlaylistDetailSongListGridProps
ItemListGridComponentProps<PlaylistSongListQuery>, extends Omit<ItemListGridComponentProps<PlaylistSongListQuery>, 'query'> {
'query'
> {
currentPage?: number; currentPage?: number;
data: PlaylistSongListResponse; data: PlaylistSongListResponse;
items?: Song[]; items?: Song[];
@@ -60,12 +60,12 @@ const PlaylistSongListFiltersModal = () => {
const hasActiveFilters = useMemo(() => { const hasActiveFilters = useMemo(() => {
return Boolean( return Boolean(
isFilterValueSet(query[FILTER_KEYS.SONG.ALBUM_ARTIST_IDS]) || isFilterValueSet(query[FILTER_KEYS.SONG.ALBUM_ARTIST_IDS]) ||
isFilterValueSet(query[FILTER_KEYS.SONG.ARTIST_IDS]) || isFilterValueSet(query[FILTER_KEYS.SONG.ARTIST_IDS]) ||
query[FILTER_KEYS.SONG.FAVORITE] !== undefined || query[FILTER_KEYS.SONG.FAVORITE] !== undefined ||
isFilterValueSet(query[FILTER_KEYS.SONG.GENRE_ID]) || isFilterValueSet(query[FILTER_KEYS.SONG.GENRE_ID]) ||
query[FILTER_KEYS.SONG.HAS_RATING] !== undefined || query[FILTER_KEYS.SONG.HAS_RATING] !== undefined ||
query[FILTER_KEYS.SONG.MAX_YEAR] !== undefined || query[FILTER_KEYS.SONG.MAX_YEAR] !== undefined ||
query[FILTER_KEYS.SONG.MIN_YEAR] !== undefined, query[FILTER_KEYS.SONG.MIN_YEAR] !== undefined,
); );
}, [query]); }, [query]);
@@ -258,7 +258,7 @@ export const openSaveAndReplaceModal = (
) => { ) => {
openContextModal({ openContextModal({
innerProps: { onSuccess, playlistId, songIds }, innerProps: { onSuccess, playlistId, songIds },
modal: 'saveAndReplace', modalKey: 'saveAndReplace',
size: 'sm', size: 'sm',
title: i18n.t('common.saveAndReplace', { postProcess: 'titleCase' }) as string, title: i18n.t('common.saveAndReplace', { postProcess: 'titleCase' }) as string,
}); });
@@ -23,10 +23,8 @@ import {
} from '/@/shared/types/domain-types'; } from '/@/shared/types/domain-types';
import { ItemListKey, Play, TableColumn } from '/@/shared/types/types'; import { ItemListKey, Play, TableColumn } from '/@/shared/types/types';
interface PlaylistDetailSongListTableProps extends Omit< interface PlaylistDetailSongListTableProps
ItemListTableComponentProps<PlaylistSongListQuery>, extends Omit<ItemListTableComponentProps<PlaylistSongListQuery>, 'query'> {
'query'
> {
currentPage?: number; currentPage?: number;
data: PlaylistSongListResponse; data: PlaylistSongListResponse;
items?: Song[]; items?: Song[];
@@ -1,6 +1,5 @@
import type { UseSuspenseQueryResult } from '@tanstack/react-query';
import { closeAllModals, openModal } from '@mantine/modals'; import { closeAllModals, openModal } from '@mantine/modals';
import { useQuery } from '@tanstack/react-query';
import { useCallback, useMemo, useState } from 'react'; import { useCallback, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
@@ -26,7 +25,7 @@ import { toast } from '/@/shared/components/toast/toast';
import { SongListSort } from '/@/shared/types/domain-types'; import { SongListSort } from '/@/shared/types/domain-types';
export interface PlaylistQueryEditorProps { export interface PlaylistQueryEditorProps {
detailQuery: UseSuspenseQueryResult<any, Error>; detailQuery: ReturnType<typeof useQuery<any>>;
handleSave: ( handleSave: (
filter: Record<string, any>, filter: Record<string, any>,
extraFilters: { extraFilters: {
@@ -422,12 +421,8 @@ export const PlaylistQueryEditor = ({
minRows={8} minRows={8}
onChange={(value) => setJsonText(value)} onChange={(value) => setJsonText(value)}
placeholder='{ "all": [], "limit": 100, "sort": "+dateAdded" }' placeholder='{ "all": [], "limit": 100, "sort": "+dateAdded" }'
size="lg"
spellCheck={false} spellCheck={false}
style={{ style={{ flex: 1, minHeight: 0 }}
flex: 1,
minHeight: 0,
}}
value={jsonText} value={jsonText}
/> />
</ScrollArea> </ScrollArea>
@@ -30,7 +30,7 @@ export const openUpdatePlaylistModal = async (args: { playlist: Playlist }) => {
}, },
query: { id: playlist?.id }, query: { id: playlist?.id },
}, },
modal: 'updatePlaylist', modalKey: 'updatePlaylist',
size: hasImageUpload ? 'lg' : 'md', size: hasImageUpload ? 'lg' : 'md',
title: i18n.t('form.editPlaylist.title', { postProcess: 'titleCase' }) as string, title: i18n.t('form.editPlaylist.title', { postProcess: 'titleCase' }) as string,
}); });
@@ -6,7 +6,6 @@ import { useSortByFilter } from '/@/renderer/features/shared/hooks/use-sort-by-f
import { useSortOrderFilter } from '/@/renderer/features/shared/hooks/use-sort-order-filter'; import { useSortOrderFilter } from '/@/renderer/features/shared/hooks/use-sort-order-filter';
import { FILTER_KEYS } from '/@/renderer/features/shared/utils'; import { FILTER_KEYS } from '/@/renderer/features/shared/utils';
import { parseCustomFiltersParam } from '/@/renderer/utils/query-params'; import { parseCustomFiltersParam } from '/@/renderer/utils/query-params';
import { runInUrlTransition } from '/@/renderer/utils/url-transition';
import { PlaylistListSort } from '/@/shared/types/domain-types'; import { PlaylistListSort } from '/@/shared/types/domain-types';
import { ItemListKey } from '/@/shared/types/types'; import { ItemListKey } from '/@/shared/types/types';
@@ -25,30 +24,28 @@ export const usePlaylistListFilters = () => {
const setCustom = useCallback( const setCustom = useCallback(
(value: null | Record<string, any>) => { (value: null | Record<string, any>) => {
runInUrlTransition(() => { setSearchParams(
setSearchParams( (prev) => {
(prev) => { const previousValue = prev.get(FILTER_KEYS.ALBUM._CUSTOM);
const previousValue = prev.get(FILTER_KEYS.ALBUM._CUSTOM);
const newCustom = { const newCustom = {
...(previousValue ? JSON.parse(previousValue) : {}), ...(previousValue ? JSON.parse(previousValue) : {}),
...value, ...value,
}; };
const filteredNewCustom = Object.fromEntries( const filteredNewCustom = Object.fromEntries(
Object.entries(newCustom).filter( Object.entries(newCustom).filter(
([, value]) => value !== null && value !== undefined, ([, value]) => value !== null && value !== undefined,
), ),
); );
prev.set(FILTER_KEYS.ALBUM._CUSTOM, JSON.stringify(filteredNewCustom)); prev.set(FILTER_KEYS.ALBUM._CUSTOM, JSON.stringify(filteredNewCustom));
return prev; return prev;
}, },
{ {
replace: true, replace: true,
}, },
); );
});
}, },
[setSearchParams], [setSearchParams],
); );
@@ -14,7 +14,6 @@ import {
setMultipleSearchParams, setMultipleSearchParams,
setSearchParam, setSearchParam,
} from '/@/renderer/utils/query-params'; } from '/@/renderer/utils/query-params';
import { runInUrlTransition } from '/@/renderer/utils/url-transition';
import { SongListSort, SortOrder } from '/@/shared/types/domain-types'; import { SongListSort, SortOrder } from '/@/shared/types/domain-types';
import { ItemListKey } from '/@/shared/types/types'; import { ItemListKey } from '/@/shared/types/types';
@@ -75,22 +74,18 @@ export const usePlaylistSongListFilters = () => {
const setAlbumArtistIds = useCallback( const setAlbumArtistIds = useCallback(
(value: null | string[]) => { (value: null | string[]) => {
runInUrlTransition(() => { setSearchParams(
setSearchParams( (prev) => setSearchParam(prev, FILTER_KEYS.SONG.ALBUM_ARTIST_IDS, value),
(prev) => setSearchParam(prev, FILTER_KEYS.SONG.ALBUM_ARTIST_IDS, value), { replace: true },
{ replace: true }, );
);
});
}, },
[setSearchParams], [setSearchParams],
); );
const setGenreId = useCallback( const setGenreId = useCallback(
(value: null | string[]) => { (value: null | string[]) => {
runInUrlTransition(() => { setSearchParams((prev) => setSearchParam(prev, FILTER_KEYS.SONG.GENRE_ID, value), {
setSearchParams((prev) => setSearchParam(prev, FILTER_KEYS.SONG.GENRE_ID, value), { replace: true,
replace: true,
});
}); });
}, },
[setSearchParams], [setSearchParams],
@@ -98,13 +93,8 @@ export const usePlaylistSongListFilters = () => {
const setArtistIds = useCallback( const setArtistIds = useCallback(
(value: null | string[]) => { (value: null | string[]) => {
runInUrlTransition(() => { setSearchParams((prev) => setSearchParam(prev, FILTER_KEYS.SONG.ARTIST_IDS, value), {
setSearchParams( replace: true,
(prev) => setSearchParam(prev, FILTER_KEYS.SONG.ARTIST_IDS, value),
{
replace: true,
},
);
}); });
}, },
[setSearchParams], [setSearchParams],
@@ -112,10 +102,8 @@ export const usePlaylistSongListFilters = () => {
const setMinYear = useCallback( const setMinYear = useCallback(
(value: null | number) => { (value: null | number) => {
runInUrlTransition(() => { setSearchParams((prev) => setSearchParam(prev, FILTER_KEYS.SONG.MIN_YEAR, value), {
setSearchParams((prev) => setSearchParam(prev, FILTER_KEYS.SONG.MIN_YEAR, value), { replace: true,
replace: true,
});
}); });
}, },
[setSearchParams], [setSearchParams],
@@ -123,10 +111,8 @@ export const usePlaylistSongListFilters = () => {
const setMaxYear = useCallback( const setMaxYear = useCallback(
(value: null | number) => { (value: null | number) => {
runInUrlTransition(() => { setSearchParams((prev) => setSearchParam(prev, FILTER_KEYS.SONG.MAX_YEAR, value), {
setSearchParams((prev) => setSearchParam(prev, FILTER_KEYS.SONG.MAX_YEAR, value), { replace: true,
replace: true,
});
}); });
}, },
[setSearchParams], [setSearchParams],
@@ -134,10 +120,8 @@ export const usePlaylistSongListFilters = () => {
const setFavorite = useCallback( const setFavorite = useCallback(
(value: boolean | null) => { (value: boolean | null) => {
runInUrlTransition(() => { setSearchParams((prev) => setSearchParam(prev, FILTER_KEYS.SONG.FAVORITE, value), {
setSearchParams((prev) => setSearchParam(prev, FILTER_KEYS.SONG.FAVORITE, value), { replace: true,
replace: true,
});
}); });
}, },
[setSearchParams], [setSearchParams],
@@ -145,13 +129,8 @@ export const usePlaylistSongListFilters = () => {
const setHasRating = useCallback( const setHasRating = useCallback(
(value: boolean | null) => { (value: boolean | null) => {
runInUrlTransition(() => { setSearchParams((prev) => setSearchParam(prev, FILTER_KEYS.SONG.HAS_RATING, value), {
setSearchParams( replace: true,
(prev) => setSearchParam(prev, FILTER_KEYS.SONG.HAS_RATING, value),
{
replace: true,
},
);
}); });
}, },
[setSearchParams], [setSearchParams],
@@ -174,55 +153,51 @@ export const usePlaylistSongListFilters = () => {
const setCustom = useCallback( const setCustom = useCallback(
(value: null | Record<string, any>) => { (value: null | Record<string, any>) => {
runInUrlTransition(() => { setSearchParams(
setSearchParams( (prev) => {
(prev) => { const previousValue = prev.get(FILTER_KEYS.ALBUM._CUSTOM);
const previousValue = prev.get(FILTER_KEYS.ALBUM._CUSTOM);
const newCustom = { const newCustom = {
...(previousValue ? JSON.parse(previousValue) : {}), ...(previousValue ? JSON.parse(previousValue) : {}),
...value, ...value,
}; };
const filteredNewCustom = Object.fromEntries( const filteredNewCustom = Object.fromEntries(
Object.entries(newCustom).filter( Object.entries(newCustom).filter(
([, value]) => value !== null && value !== undefined, ([, value]) => value !== null && value !== undefined,
), ),
); );
prev.set(FILTER_KEYS.ALBUM._CUSTOM, JSON.stringify(filteredNewCustom)); prev.set(FILTER_KEYS.ALBUM._CUSTOM, JSON.stringify(filteredNewCustom));
return prev; return prev;
}, },
{ {
replace: true, replace: true,
}, },
); );
});
}, },
[setSearchParams], [setSearchParams],
); );
const clear = useCallback(() => { const clear = useCallback(() => {
runInUrlTransition(() => { setSearchParams(
setSearchParams( (prev) =>
(prev) => setMultipleSearchParams(
setMultipleSearchParams( prev,
prev, {
{ [FILTER_KEYS.SONG._CUSTOM]: null,
[FILTER_KEYS.SONG._CUSTOM]: null, [FILTER_KEYS.SONG.ALBUM_ARTIST_IDS]: null,
[FILTER_KEYS.SONG.ALBUM_ARTIST_IDS]: null, [FILTER_KEYS.SONG.ARTIST_IDS]: null,
[FILTER_KEYS.SONG.ARTIST_IDS]: null, [FILTER_KEYS.SONG.FAVORITE]: null,
[FILTER_KEYS.SONG.FAVORITE]: null, [FILTER_KEYS.SONG.GENRE_ID]: null,
[FILTER_KEYS.SONG.GENRE_ID]: null, [FILTER_KEYS.SONG.HAS_RATING]: null,
[FILTER_KEYS.SONG.HAS_RATING]: null, [FILTER_KEYS.SONG.MAX_YEAR]: null,
[FILTER_KEYS.SONG.MAX_YEAR]: null, [FILTER_KEYS.SONG.MIN_YEAR]: null,
[FILTER_KEYS.SONG.MIN_YEAR]: null, },
}, new Set([FILTER_KEYS.SONG._CUSTOM]),
new Set([FILTER_KEYS.SONG._CUSTOM]), ),
), { replace: true },
{ replace: true }, );
);
});
}, [setSearchParams]); }, [setSearchParams]);
const query = useMemo( const query = useMemo(
@@ -1,8 +1,8 @@
import { closeAllModals, openModal } from '@mantine/modals'; import { closeAllModals, openModal } from '@mantine/modals';
import { useSuspenseQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { Suspense, useMemo, useRef, useState } from 'react'; import { Suspense, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { generatePath, useNavigate, useParams } from 'react-router'; import { generatePath, useLocation, useNavigate, useParams } from 'react-router';
import { ListContext, useListContext } from '/@/renderer/context/list-context'; import { ListContext, useListContext } from '/@/renderer/context/list-context';
import { playlistsQueries } from '/@/renderer/features/playlists/api/playlists-api'; import { playlistsQueries } from '/@/renderer/features/playlists/api/playlists-api';
@@ -72,11 +72,13 @@ const PlaylistSongListFiltersSidebar = () => {
const PlaylistDetailSongListRoute = () => { const PlaylistDetailSongListRoute = () => {
const { t } = useTranslation(); const { t } = useTranslation();
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation();
const { playlistId } = useParams() as { playlistId: string }; const { playlistId } = useParams() as { playlistId: string };
const server = useCurrentServer(); const server = useCurrentServer();
const detailQuery = useSuspenseQuery({ const detailQuery = useQuery({
...playlistsQueries.detail({ query: { id: playlistId }, serverId: server?.id }), ...playlistsQueries.detail({ query: { id: playlistId }, serverId: server?.id }),
placeholderData: location.state?.item,
}); });
const deletePlaylistMutation = useDeletePlaylist({}); const deletePlaylistMutation = useDeletePlaylist({});
const updatePlaylistMutation = useUpdatePlaylist({}); const updatePlaylistMutation = useUpdatePlaylist({});
@@ -210,7 +212,9 @@ const PlaylistDetailSongListRoute = () => {
}; };
const isSmartPlaylist = Boolean( const isSmartPlaylist = Boolean(
detailQuery?.data?.rules && server?.type === ServerType.NAVIDROME, !detailQuery?.isLoading &&
detailQuery?.data?.rules &&
server?.type === ServerType.NAVIDROME,
); );
const [showQueryBuilder, setShowQueryBuilder] = useState(false); const [showQueryBuilder, setShowQueryBuilder] = useState(false);
@@ -11,6 +11,7 @@
font-size: var(--theme-font-size-sm); font-size: var(--theme-font-size-sm);
cursor: pointer; cursor: pointer;
user-select: none; user-select: none;
opacity: 0.8;
} }
.heading:hover { .heading:hover {
@@ -38,7 +39,3 @@
display: flex; display: flex;
flex-direction: column; flex-direction: column;
} }
.items button:focus-visible {
outline: 2px solid var(--theme-colors-primary);
}
@@ -1,10 +1,8 @@
import { Command } from 'cmdk'; import { Command } from 'cmdk';
import { ComponentPropsWithoutRef, ReactNode, useEffect, useRef, useState } from 'react'; import { ComponentPropsWithoutRef, ReactNode, useEffect, useRef, useState } from 'react';
interface CommandItemSelectableProps extends Omit< interface CommandItemSelectableProps
ComponentPropsWithoutRef<typeof Command.Item>, extends Omit<ComponentPropsWithoutRef<typeof Command.Item>, 'children'> {
'children'
> {
children: (args: { isHighlighted: boolean }) => ReactNode; children: (args: { isHighlighted: boolean }) => ReactNode;
} }
@@ -1,4 +1,4 @@
import { useCallback, useDeferredValue, useRef, useState } from 'react'; import { useCallback, useRef, useState } from 'react';
import { Command, CommandPalettePages } from '/@/renderer/features/search/components/command'; import { Command, CommandPalettePages } from '/@/renderer/features/search/components/command';
import { GoToCommands } from '/@/renderer/features/search/components/go-to-commands'; import { GoToCommands } from '/@/renderer/features/search/components/go-to-commands';
@@ -49,7 +49,6 @@ function CommandPaletteSearch({
setQuery, setQuery,
}: CommandPaletteSearchProps) { }: CommandPaletteSearchProps) {
const [debouncedQuery] = useDebouncedValue(query, 400); const [debouncedQuery] = useDebouncedValue(query, 400);
const deferredSearchQuery = useDeferredValue(debouncedQuery ?? '');
const searchSectionsExpanded = useAppStore( const searchSectionsExpanded = useAppStore(
(state) => state.commandPaletteSearchSectionsExpanded, (state) => state.commandPaletteSearchSectionsExpanded,
); );
@@ -84,7 +83,7 @@ function CommandPaletteSearch({
<Command.List> <Command.List>
<Stack gap="xs"> <Stack gap="xs">
<SearchAlbumsSection <SearchAlbumsSection
debouncedQuery={deferredSearchQuery} debouncedQuery={debouncedQuery ?? ''}
expanded={searchSectionsExpanded[SEARCH_SECTION_IDS.albums] ?? true} expanded={searchSectionsExpanded[SEARCH_SECTION_IDS.albums] ?? true}
isHome={isHome} isHome={isHome}
onSelectResult={onSelectResult} onSelectResult={onSelectResult}
@@ -97,7 +96,7 @@ function CommandPaletteSearch({
query={query} query={query}
/> />
<SearchAlbumArtistsSection <SearchAlbumArtistsSection
debouncedQuery={deferredSearchQuery} debouncedQuery={debouncedQuery ?? ''}
expanded={searchSectionsExpanded[SEARCH_SECTION_IDS.artists] ?? true} expanded={searchSectionsExpanded[SEARCH_SECTION_IDS.artists] ?? true}
isHome={isHome} isHome={isHome}
onSelectResult={onSelectResult} onSelectResult={onSelectResult}
@@ -110,7 +109,7 @@ function CommandPaletteSearch({
query={query} query={query}
/> />
<SearchSongsSection <SearchSongsSection
debouncedQuery={deferredSearchQuery} debouncedQuery={debouncedQuery ?? ''}
expanded={searchSectionsExpanded[SEARCH_SECTION_IDS.tracks] ?? true} expanded={searchSectionsExpanded[SEARCH_SECTION_IDS.tracks] ?? true}
isHome={isHome} isHome={isHome}
onSelectResult={onSelectResult} onSelectResult={onSelectResult}
@@ -33,7 +33,7 @@ export const ServerList = () => {
<AddServerForm onCancel={() => vars.context.closeModal(vars.id)} /> <AddServerForm onCancel={() => vars.context.closeModal(vars.id)} />
), ),
}, },
modal: 'base', modalKey: 'base',
title: t('form.addServer.title', { postProcess: 'titleCase' }), title: t('form.addServer.title', { postProcess: 'titleCase' }),
}); });
}; };
@@ -3,7 +3,7 @@ import { openContextModal } from '@mantine/modals';
export const openSettingsModal = () => { export const openSettingsModal = () => {
openContextModal({ openContextModal({
innerProps: {}, innerProps: {},
modal: 'settings', modalKey: 'settings',
overlayProps: { overlayProps: {
opacity: 1, opacity: 1,
}, },
@@ -17,7 +17,7 @@ export const AnimatedPage = forwardRef(
<motion.main <motion.main
className={styles.animatedPage} className={styles.animatedPage}
ref={ref} ref={ref}
{...{ ...animationProps.fadeIn, transition: { duration: 0.5, ease: 'anticipate' } }} {...{ ...animationProps.fadeIn, transition: { duration: 0.3, ease: 'anticipate' } }}
> >
{children} {children}
</motion.main> </motion.main>
@@ -1,5 +1,5 @@
.filter-bar { .filter-bar {
z-index: 1; z-index: 1;
padding: var(--theme-spacing-xs); padding: var(--theme-spacing-sm);
border-bottom: 1px solid var(--theme-colors-border); border-bottom: 1px solid var(--theme-colors-border);
} }
@@ -6,7 +6,7 @@
width: 100%; width: 100%;
max-width: var(--theme-content-max-width); max-width: var(--theme-content-max-width);
height: 100%; height: 100%;
padding: 0 var(--theme-spacing-sm); padding: 0 1rem;
} }
.play-button-container { .play-button-container {
@@ -1,5 +1,5 @@
import { motion } from 'motion/react'; import { motion } from 'motion/react';
import { createContext, ReactNode, useContext, useMemo, useState } from 'react'; import { createContext, ReactNode, useContext, useMemo, useRef } from 'react';
import styles from './list-with-sidebar-container.module.css'; import styles from './list-with-sidebar-container.module.css';
@@ -8,7 +8,7 @@ import { animationProps } from '/@/shared/components/animations/animation-props'
import { Portal } from '/@/shared/components/portal/portal'; import { Portal } from '/@/shared/components/portal/portal';
interface ListWithSidebarContainerContextValue { interface ListWithSidebarContainerContextValue {
sidebarElement: HTMLDivElement | null; sidebarRef: React.RefObject<HTMLDivElement | null>;
} }
const ListWithSidebarContainerContext = createContext<ListWithSidebarContainerContextValue | null>( const ListWithSidebarContainerContext = createContext<ListWithSidebarContainerContextValue | null>(
@@ -36,12 +36,12 @@ function Sidebar({ children }: SidebarProps) {
throw new Error('Sidebar must be used within ListWithSidebarContainer'); throw new Error('Sidebar must be used within ListWithSidebarContainer');
} }
if (!context.sidebarElement) { if (!context.sidebarRef?.current) {
return null; return null;
} }
return ( return (
<Portal target={context.sidebarElement}> <Portal target={context.sidebarRef.current}>
<motion.div {...animationProps.slideInLeft} style={{ height: '100%', width: '100%' }}> <motion.div {...animationProps.slideInLeft} style={{ height: '100%', width: '100%' }}>
{children} {children}
</motion.div> </motion.div>
@@ -56,25 +56,25 @@ function SidebarPortal({ children }: SidebarPortalProps) {
throw new Error('SidebarPortal must be used within ListWithSidebarContainer'); throw new Error('SidebarPortal must be used within ListWithSidebarContainer');
} }
if (!context.sidebarElement) { if (!context.sidebarRef?.current) {
return null; return null;
} }
return <Portal target={context.sidebarElement}>{children}</Portal>; return <Portal target={context.sidebarRef.current}>{children}</Portal>;
} }
export const ListWithSidebarContainer = ({ export const ListWithSidebarContainer = ({
children, children,
useBreakpoint = false, useBreakpoint = false,
}: ListWithSidebarContainerProps) => { }: ListWithSidebarContainerProps) => {
const [sidebarElement, setSidebarElement] = useState<HTMLDivElement | null>(null); const sidebarRef = useRef<HTMLDivElement>(null);
const { isSidebarOpen = false } = useListContext(); const { isSidebarOpen = false } = useListContext();
const contextValue = useMemo( const contextValue = useMemo(
() => ({ () => ({
sidebarElement, sidebarRef,
}), }),
[sidebarElement], [],
); );
return ( return (
@@ -84,7 +84,7 @@ export const ListWithSidebarContainer = ({
data-sidebar-open={useBreakpoint ? undefined : isSidebarOpen} data-sidebar-open={useBreakpoint ? undefined : isSidebarOpen}
data-use-breakpoint={useBreakpoint} data-use-breakpoint={useBreakpoint}
> >
<div className={styles.sidebarContainer} ref={setSidebarElement} /> <div className={styles.sidebarContainer} ref={sidebarRef} />
<div className={styles.contentContainer}>{children}</div> <div className={styles.contentContainer}>{children}</div>
</div> </div>
</ListWithSidebarContainerContext.Provider> </ListWithSidebarContainerContext.Provider>
@@ -19,7 +19,6 @@ import { useHotkeys } from '/@/shared/hooks/use-hotkeys';
interface SearchInputProps extends TextInputProps { interface SearchInputProps extends TextInputProps {
buttonProps?: Partial<ActionIconProps>; buttonProps?: Partial<ActionIconProps>;
enableHotkey?: boolean; enableHotkey?: boolean;
fillContainer?: boolean;
inputProps?: Partial<TextInputProps>; inputProps?: Partial<TextInputProps>;
value?: string; value?: string;
} }
@@ -27,7 +26,6 @@ interface SearchInputProps extends TextInputProps {
export const SearchInput = ({ export const SearchInput = ({
buttonProps, buttonProps,
enableHotkey = true, enableHotkey = true,
fillContainer = false,
inputProps, inputProps,
onChange, onChange,
...props ...props
@@ -106,17 +104,9 @@ export const SearchInput = ({
overflow: 'hidden', overflow: 'hidden',
position: 'relative', position: 'relative',
transition: 'width 0.3s ease-in-out', transition: 'width 0.3s ease-in-out',
...(fillContainer width: shouldExpand ? '200px' : '36px',
? {
flex: '1 1 0',
minWidth: 0,
width: shouldExpand ? '100%' : '36px',
}
: {
width: shouldExpand ? '200px' : '36px',
}),
}), }),
[fillContainer, shouldExpand], [shouldExpand],
); );
const buttonStyle: CSSProperties = useMemo( const buttonStyle: CSSProperties = useMemo(
@@ -145,7 +135,7 @@ export const SearchInput = ({
<Box style={containerStyle}> <Box style={containerStyle}>
<TextInput <TextInput
leftSection={<Icon icon="search" />} leftSection={<Icon icon="search" />}
maw={fillContainer ? '100%' : '20dvw'} maw="20dvw"
{...inputProps} {...inputProps}
onBlur={handleBlur} onBlur={handleBlur}
onChange={onChange} onChange={onChange}
@@ -3,7 +3,6 @@ import { useSearchParams } from 'react-router';
import { FILTER_KEYS } from '/@/renderer/features/shared/utils'; import { FILTER_KEYS } from '/@/renderer/features/shared/utils';
import { parseStringParam, setSearchParam } from '/@/renderer/utils/query-params'; import { parseStringParam, setSearchParam } from '/@/renderer/utils/query-params';
import { runInUrlTransition } from '/@/renderer/utils/url-transition';
import { useDebouncedCallback } from '/@/shared/hooks/use-debounced-callback'; import { useDebouncedCallback } from '/@/shared/hooks/use-debounced-callback';
export const useSearchTermFilter = (defaultValue?: string) => { export const useSearchTermFilter = (defaultValue?: string) => {
@@ -15,19 +14,17 @@ export const useSearchTermFilter = (defaultValue?: string) => {
}, [searchParams, defaultValue]); }, [searchParams, defaultValue]);
const handleSetSearchTerm = (value: null | string) => { const handleSetSearchTerm = (value: null | string) => {
runInUrlTransition(() => { setSearchParams(
setSearchParams( (prev) => {
(prev) => { const newParams = setSearchParam(
const newParams = setSearchParam( prev,
prev, FILTER_KEYS.SHARED.SEARCH_TERM,
FILTER_KEYS.SHARED.SEARCH_TERM, value === '' ? null : value,
value === '' ? null : value, );
); return newParams;
return newParams; },
}, { replace: true },
{ replace: true }, );
);
});
}; };
const debouncedSetSearchTerm = useDebouncedCallback(handleSetSearchTerm, 300); const debouncedSetSearchTerm = useDebouncedCallback(handleSetSearchTerm, 300);
@@ -1,4 +1,4 @@
import { useCallback, useRef } from 'react'; import { useCallback } from 'react';
import { useCreateFavorite } from '/@/renderer/features/shared/mutations/create-favorite-mutation'; import { useCreateFavorite } from '/@/renderer/features/shared/mutations/create-favorite-mutation';
import { useDeleteFavorite } from '/@/renderer/features/shared/mutations/delete-favorite-mutation'; import { useDeleteFavorite } from '/@/renderer/features/shared/mutations/delete-favorite-mutation';
@@ -8,26 +8,21 @@ export const useSetFavorite = () => {
const createFavoriteMutation = useCreateFavorite({}); const createFavoriteMutation = useCreateFavorite({});
const deleteFavoriteMutation = useDeleteFavorite({}); const deleteFavoriteMutation = useDeleteFavorite({});
const createFavoriteMutationRef = useRef(createFavoriteMutation);
const deleteFavoriteMutationRef = useRef(deleteFavoriteMutation);
createFavoriteMutationRef.current = createFavoriteMutation;
deleteFavoriteMutationRef.current = deleteFavoriteMutation;
const setFavorite = useCallback( const setFavorite = useCallback(
(serverId: string, id: string[], itemType: LibraryItem, isFavorite: boolean) => { (serverId: string, id: string[], itemType: LibraryItem, isFavorite: boolean) => {
if (isFavorite) { if (isFavorite) {
createFavoriteMutationRef.current.mutate({ createFavoriteMutation.mutate({
apiClientProps: { serverId }, apiClientProps: { serverId },
query: { id, type: itemType }, query: { id, type: itemType },
}); });
} else { } else {
deleteFavoriteMutationRef.current.mutate({ deleteFavoriteMutation.mutate({
apiClientProps: { serverId }, apiClientProps: { serverId },
query: { id, type: itemType }, query: { id, type: itemType },
}); });
} }
}, },
[], [createFavoriteMutation, deleteFavoriteMutation],
); );
return setFavorite; return setFavorite;
@@ -1,4 +1,4 @@
import { useCallback, useRef } from 'react'; import { useCallback } from 'react';
import { useSetRatingMutation } from '/@/renderer/features/shared/mutations/set-rating-mutation'; import { useSetRatingMutation } from '/@/renderer/features/shared/mutations/set-rating-mutation';
import { LibraryItem } from '/@/shared/types/domain-types'; import { LibraryItem } from '/@/shared/types/domain-types';
@@ -6,17 +6,14 @@ import { LibraryItem } from '/@/shared/types/domain-types';
export const useSetRating = () => { export const useSetRating = () => {
const setRatingMutation = useSetRatingMutation({}); const setRatingMutation = useSetRatingMutation({});
const setRatingMutationRef = useRef(setRatingMutation);
setRatingMutationRef.current = setRatingMutation;
const setRating = useCallback( const setRating = useCallback(
(serverId: string, id: string[], itemType: LibraryItem, rating: number) => { (serverId: string, id: string[], itemType: LibraryItem, rating: number) => {
setRatingMutationRef.current.mutate({ setRatingMutation.mutate({
apiClientProps: { serverId }, apiClientProps: { serverId },
query: { id, rating, type: itemType }, query: { id, rating, type: itemType },
}); });
}, },
[], [setRatingMutation],
); );
return setRating; return setRating;
@@ -5,7 +5,6 @@ import { useListFilterPersistence } from '/@/renderer/features/shared/hooks/use-
import { FILTER_KEYS } from '/@/renderer/features/shared/utils'; import { FILTER_KEYS } from '/@/renderer/features/shared/utils';
import { useCurrentServer } from '/@/renderer/store'; import { useCurrentServer } from '/@/renderer/store';
import { parseStringParam, setSearchParam } from '/@/renderer/utils/query-params'; import { parseStringParam, setSearchParam } from '/@/renderer/utils/query-params';
import { runInUrlTransition } from '/@/renderer/utils/url-transition';
import { ItemListKey } from '/@/shared/types/types'; import { ItemListKey } from '/@/shared/types/types';
export const useSortByFilter = <TSortBy>(defaultValue: null | string, listKey: ItemListKey) => { export const useSortByFilter = <TSortBy>(defaultValue: null | string, listKey: ItemListKey) => {
@@ -21,16 +20,14 @@ export const useSortByFilter = <TSortBy>(defaultValue: null | string, listKey: I
}, [searchParams, persisted, defaultValue]); }, [searchParams, persisted, defaultValue]);
const handleSetSortBy = (sortBy: string) => { const handleSetSortBy = (sortBy: string) => {
runInUrlTransition(() => { setSearchParams(
setSearchParams( (prev) => {
(prev) => { const newParams = setSearchParam(prev, FILTER_KEYS.SHARED.SORT_BY, sortBy);
const newParams = setSearchParam(prev, FILTER_KEYS.SHARED.SORT_BY, sortBy); return newParams;
return newParams; },
}, { replace: true },
{ replace: true }, );
); setFilter(FILTER_KEYS.SHARED.SORT_BY, sortBy);
setFilter(FILTER_KEYS.SHARED.SORT_BY, sortBy);
});
}; };
return { return {
@@ -5,7 +5,6 @@ import { useListFilterPersistence } from '/@/renderer/features/shared/hooks/use-
import { FILTER_KEYS } from '/@/renderer/features/shared/utils'; import { FILTER_KEYS } from '/@/renderer/features/shared/utils';
import { useCurrentServer } from '/@/renderer/store'; import { useCurrentServer } from '/@/renderer/store';
import { parseStringParam, setSearchParam } from '/@/renderer/utils/query-params'; import { parseStringParam, setSearchParam } from '/@/renderer/utils/query-params';
import { runInUrlTransition } from '/@/renderer/utils/url-transition';
import { SortOrder } from '/@/shared/types/domain-types'; import { SortOrder } from '/@/shared/types/domain-types';
import { ItemListKey } from '/@/shared/types/types'; import { ItemListKey } from '/@/shared/types/types';
@@ -22,20 +21,14 @@ export const useSortOrderFilter = (defaultValue: null | string, listKey: ItemLis
}, [searchParams, persisted, defaultValue]); }, [searchParams, persisted, defaultValue]);
const handleSetSortOrder = (sortOrder: SortOrder) => { const handleSetSortOrder = (sortOrder: SortOrder) => {
runInUrlTransition(() => { setSearchParams(
setSearchParams( (prev) => {
(prev) => { const newParams = setSearchParam(prev, FILTER_KEYS.SHARED.SORT_ORDER, sortOrder);
const newParams = setSearchParam( return newParams;
prev, },
FILTER_KEYS.SHARED.SORT_ORDER, { replace: true },
sortOrder, );
); setFilter(FILTER_KEYS.SHARED.SORT_ORDER, sortOrder);
return newParams;
},
{ replace: true },
);
setFilter(FILTER_KEYS.SHARED.SORT_ORDER, sortOrder);
});
}; };
return { return {
@@ -22,8 +22,7 @@
} }
.accordion-control { .accordion-control {
height: auto; height: 2.5rem;
min-height: 2.5rem;
border-radius: var(--theme-radius-md); border-radius: var(--theme-radius-md);
&:hover { &:hover {
@@ -39,3 +38,9 @@
.accordion-content:last-child { .accordion-content:last-child {
padding-bottom: var(--theme-spacing-md); padding-bottom: var(--theme-spacing-md);
} }
.server-selector-wrapper {
position: relative;
z-index: 1;
flex-shrink: 0;
}
@@ -4,6 +4,7 @@ import { useTranslation } from 'react-i18next';
import styles from './mobile-sidebar.module.css'; import styles from './mobile-sidebar.module.css';
import { ActionBar } from '/@/renderer/features/sidebar/components/action-bar'; import { ActionBar } from '/@/renderer/features/sidebar/components/action-bar';
import { ServerSelector } from '/@/renderer/features/sidebar/components/server-selector';
import { SidebarIcon } from '/@/renderer/features/sidebar/components/sidebar-icon'; import { SidebarIcon } from '/@/renderer/features/sidebar/components/sidebar-icon';
import { SidebarItem } from '/@/renderer/features/sidebar/components/sidebar-item'; import { SidebarItem } from '/@/renderer/features/sidebar/components/sidebar-item';
import { import {
@@ -103,6 +104,9 @@ export const MobileSidebar = () => {
)} )}
</Accordion> </Accordion>
</ScrollArea> </ScrollArea>
<div className={styles.serverSelectorWrapper}>
<ServerSelector />
</div>
</div> </div>
); );
}; };
@@ -1,16 +1,18 @@
.button-container { .button-container {
align-items: center; align-items: center;
width: 100%; width: 100%;
padding: var(--theme-spacing-md);
cursor: pointer; cursor: pointer;
} }
.button-container-no-bottom-padding {
padding-bottom: 0;
}
.button-group { .button-group {
padding: var(--theme-spacing-sm); padding: var(--theme-spacing-sm);
border-radius: var(--theme-radius-md);
}
.button-group:hover {
background: var(--theme-colors-surface); background: var(--theme-colors-surface);
border-radius: var(--theme-radius-md);
} }
.logo { .logo {
@@ -1,4 +1,5 @@
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { useRef } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import styles from './server-selector.module.css'; import styles from './server-selector.module.css';
@@ -8,7 +9,7 @@ import NavidromeLogo from '/@/renderer/features/servers/assets/navidrome.png';
import OpenSubsonicLogo from '/@/renderer/features/servers/assets/opensubsonic.png'; import OpenSubsonicLogo from '/@/renderer/features/servers/assets/opensubsonic.png';
import { sharedQueries } from '/@/renderer/features/shared/api/shared-api'; import { sharedQueries } from '/@/renderer/features/shared/api/shared-api';
import { ServerSelectorItems } from '/@/renderer/features/sidebar/components/server-selector-items'; import { ServerSelectorItems } from '/@/renderer/features/sidebar/components/server-selector-items';
import { useCurrentServer } from '/@/renderer/store'; import { useAppStore, useCurrentServer } from '/@/renderer/store';
import { hasFeature } from '/@/shared/api/utils'; import { hasFeature } from '/@/shared/api/utils';
import { Box } from '/@/shared/components/box/box'; import { Box } from '/@/shared/components/box/box';
import { DropdownMenu } from '/@/shared/components/dropdown-menu/dropdown-menu'; import { DropdownMenu } from '/@/shared/components/dropdown-menu/dropdown-menu';
@@ -23,6 +24,8 @@ import { ServerFeature } from '/@/shared/types/features-types';
export const ServerSelector = () => { export const ServerSelector = () => {
const { t } = useTranslation(); const { t } = useTranslation();
const currentServer = useCurrentServer(); const currentServer = useCurrentServer();
const sidebarImageEnabled = useAppStore((state) => state.sidebar.image);
const showImage = sidebarImageEnabled;
const { data: musicFolders } = useQuery( const { data: musicFolders } = useQuery(
currentServer currentServer
@@ -30,6 +33,9 @@ export const ServerSelector = () => {
: { enabled: false, queryKey: ['disabled'] }, : { enabled: false, queryKey: ['disabled'] },
); );
const targetRef = useRef<HTMLDivElement | null>(null);
const widthOfTarget = targetRef.current?.getBoundingClientRect().width;
if (!currentServer) { if (!currentServer) {
return null; return null;
} }
@@ -63,11 +69,15 @@ export const ServerSelector = () => {
: OpenSubsonicLogo; : OpenSubsonicLogo;
return ( return (
<DropdownMenu offset={0} position="right-start" withinPortal={false}> <DropdownMenu offset={0} position="right">
<DropdownMenu.Target> <DropdownMenu.Target>
<div className={styles.popoverTarget}> <div className={styles.popoverTarget}>
<Box className={styles.buttonContainer}> <Box
<Group className={styles.buttonGroup} gap="sm"> className={`${styles.buttonContainer} ${
showImage ? styles.buttonContainerNoBottomPadding : ''
}`}
>
<Group className={styles.buttonGroup} gap="sm" ref={targetRef}>
<img className={styles.logo} src={logo} /> <img className={styles.logo} src={logo} />
<Stack className={styles.buttonStack} gap={2}> <Stack className={styles.buttonStack} gap={2}>
<Text fw={600} size="sm" truncate> <Text fw={600} size="sm" truncate>
@@ -82,7 +92,7 @@ export const ServerSelector = () => {
</Box> </Box>
</div> </div>
</DropdownMenu.Target> </DropdownMenu.Target>
<DropdownMenu.Dropdown miw="16rem"> <DropdownMenu.Dropdown style={{ width: `${widthOfTarget}px` }}>
<ScrollArea className={styles.scrollArea}> <ScrollArea className={styles.scrollArea}>
<ServerSelectorItems /> <ServerSelectorItems />
</ScrollArea> </ScrollArea>
@@ -183,7 +183,7 @@ const PlaylistRowButton = memo(
openContextModal({ openContextModal({
innerProps: modalProps, innerProps: modalProps,
modal: 'addToPlaylist', modalKey: 'addToPlaylist',
size: 'lg', size: 'lg',
title: t('form.addToPlaylist.title', { postProcess: 'titleCase' }), title: t('form.addToPlaylist.title', { postProcess: 'titleCase' }),
}); });
@@ -66,8 +66,7 @@
} }
.accordion-control { .accordion-control {
height: auto; height: 2.5rem;
min-height: 2.5rem;
border-radius: var(--theme-radius-md); border-radius: var(--theme-radius-md);
&:hover { &:hover {
@@ -83,3 +82,9 @@
.accordion-content:last-child { .accordion-content:last-child {
padding-bottom: var(--theme-spacing-md); padding-bottom: var(--theme-spacing-md);
} }
.server-selector-wrapper {
position: relative;
z-index: 1;
flex-shrink: 0;
}
@@ -12,6 +12,7 @@ import {
useRadioPlayer, useRadioPlayer,
} from '/@/renderer/features/radio/hooks/use-radio-player'; } from '/@/renderer/features/radio/hooks/use-radio-player';
import { ActionBar } from '/@/renderer/features/sidebar/components/action-bar'; import { ActionBar } from '/@/renderer/features/sidebar/components/action-bar';
import { ServerSelector } from '/@/renderer/features/sidebar/components/server-selector';
import { SidebarCollectionList } from '/@/renderer/features/sidebar/components/sidebar-collection-list'; import { SidebarCollectionList } from '/@/renderer/features/sidebar/components/sidebar-collection-list';
import { SidebarIcon } from '/@/renderer/features/sidebar/components/sidebar-icon'; import { SidebarIcon } from '/@/renderer/features/sidebar/components/sidebar-icon';
import { SidebarItem } from '/@/renderer/features/sidebar/components/sidebar-item'; import { SidebarItem } from '/@/renderer/features/sidebar/components/sidebar-item';
@@ -151,6 +152,9 @@ export const Sidebar = () => {
</Accordion> </Accordion>
</ScrollArea> </ScrollArea>
<AnimatePresence initial={false} mode="popLayout"> <AnimatePresence initial={false} mode="popLayout">
<motion.div className={styles.serverSelectorWrapper} key="server-selector" layout>
<ServerSelector />
</motion.div>
{showImage && <SidebarImage />} {showImage && <SidebarImage />}
</AnimatePresence> </AnimatePresence>
</div> </div>
@@ -55,12 +55,12 @@ export const SongListHeaderFilters = ({ toggleGenreTarget }: { toggleGenreTarget
const query = songFilters.query; const query = songFilters.query;
return Boolean( return Boolean(
isFilterValueSet(query[FILTER_KEYS.SONG._CUSTOM]) || isFilterValueSet(query[FILTER_KEYS.SONG._CUSTOM]) ||
isFilterValueSet(query[FILTER_KEYS.SONG.ARTIST_IDS]) || isFilterValueSet(query[FILTER_KEYS.SONG.ARTIST_IDS]) ||
query[FILTER_KEYS.SONG.FAVORITE] !== undefined || query[FILTER_KEYS.SONG.FAVORITE] !== undefined ||
isFilterValueSet(query[FILTER_KEYS.SONG.GENRE_ID]) || isFilterValueSet(query[FILTER_KEYS.SONG.GENRE_ID]) ||
isFilterValueSet(query[FILTER_KEYS.SONG.MAX_YEAR]) || isFilterValueSet(query[FILTER_KEYS.SONG.MAX_YEAR]) ||
isFilterValueSet(query[FILTER_KEYS.SONG.MIN_YEAR]) || isFilterValueSet(query[FILTER_KEYS.SONG.MIN_YEAR]) ||
isFilterValueSet(query[FILTER_KEYS.SHARED.SEARCH_TERM]), isFilterValueSet(query[FILTER_KEYS.SHARED.SEARCH_TERM]),
); );
}, [songFilters.query]); }, [songFilters.query]);

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