Compare commits

..

68 Commits

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

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

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

* downgrade fast-average-color
2026-04-04 17:10:57 -07:00
Kendall Garner 132ac92984 chore: use consistent order of track / artist / ablum on full screen page 2026-04-04 14:46:13 -07:00
jeffvli 141a20f042 refactor item table props 2026-04-04 12:34:27 -07:00
jeffvli 1592204515 add fallback sort order for subsonic playlist list 2026-04-04 12:03:41 -07:00
jeffvli b9f5459725 fix layout shift on grid carousel page change 2026-04-03 20:25:12 -07:00
jeffvli d4e9b9b7a6 adjust bg loading on album detail page 2026-04-03 20:11:10 -07:00
jeffvli ec9e4b1339 fix type error due to new param on mediaStop 2026-04-03 19:09:42 -07:00
Hosted Weblate f09109b887 Translated using Weblate
Currently translated at 100.0% (1194 of 1194 strings) (Czech)
Translation: feishin/Translation
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/cs/

Translated using Weblate

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

Translated using Weblate

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

Translated using Weblate

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

Translated using Weblate

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

Co-authored-by: Fjuro <fjuro@users.noreply.hosted.weblate.org>
Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: KosmoMoustache <kosmomoustache@users.noreply.hosted.weblate.org>
Co-authored-by: York <goog10216922@gmail.com>
Co-authored-by: skajmer <skajmer@protonmail.com>
2026-04-04 04:05:50 +02:00
jeffvli 1494c8e044 fix mpv seek error on queue end 2026-04-03 19:05:34 -07:00
jeffvli f3a6027e6d fix mpv progress interval still running after queue ends 2026-04-03 18:58:58 -07:00
jeffvli 3c42355c1e attempt to fix mpv playback sync on song insertion (#1855) 2026-04-03 18:54:49 -07:00
jeffvli feda1bb06f remove square image param, default item id for image 2026-04-03 11:24:39 -07:00
jeffvli 72f1d2f9f9 improve date parsing for partial dates (#1683) 2026-04-02 19:39:08 -07:00
jeffvli ad11a9303c add playlist description to expanded header 2026-04-02 18:36:42 -07:00
jeffvli db06e7f601 add native nd radio endpoints, support radio station images 2026-04-02 18:26:26 -07:00
jeffvli fbf82c1ef0 add playlist image upload to edit playlist modal 2026-04-02 17:41:25 -07:00
jeffvli 92cea5dfda add log for direct play profiles 2026-04-02 01:27:14 -07:00
jeffvli 7442f9d3ca support navidrome playlist image upload 2026-04-02 01:23:09 -07:00
jeffvli 68dacea228 use resized images in artist header 2026-04-01 21:57:32 -07:00
jeffvli 51425b5e86 various performance refactors 2026-04-01 21:57:26 -07:00
jeffvli c60610cb42 lint files 2026-03-31 21:12:48 -07:00
jeffvli d3881ee3be support limitPercent for smart playlists 2026-03-31 21:09:13 -07:00
jeffvli de403ea6ac add new nd smart playlist fields
- averagerating

- albumdateloved
- albumlastplayed
- albumdaterated
- albumloved
- albumrating

- artistdateloved
 -artistlastplayed
- artistdaterated
- artistloved
- artistplaycount
2026-03-31 20:55:36 -07:00
jeffvli a30b1ec90b add OS transcoding extension 2026-03-31 20:45:22 -07:00
Hosted Weblate 7982c0e1bd Translated using Weblate
Currently translated at 100.0% (1193 of 1193 strings) (Chinese (Simplified Han script))
Translation: feishin/Translation
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/zh_Hans/

Translated using Weblate

Currently translated at 83.4% (996 of 1193 strings) (Basque)
Translation: feishin/Translation
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/eu/

Co-authored-by: Aitor Astorga <a.astorga.sdv@protonmail.com>
Co-authored-by: 無情天 <kofzhanganguo@126.com>
2026-03-31 16:09:57 +02:00
184 changed files with 8205 additions and 4373 deletions
@@ -51,5 +51,4 @@ jobs:
labels: ${{ steps.meta.outputs.labels }} labels: ${{ steps.meta.outputs.labels }}
platforms: | platforms: |
linux/amd64 linux/amd64
linux/arm/v7
linux/arm64/v8 linux/arm64/v8
+4
View File
@@ -169,6 +169,10 @@ Feishin supports any music server that implements a [Navidrome](https://www.navi
- [Qm-Music](https://github.com/chenqimiao/qm-music) - [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.
+4 -2
View File
@@ -43,9 +43,11 @@ 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 }]
@@ -60,7 +62,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
+3 -2
View File
@@ -1,10 +1,11 @@
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';
@@ -64,7 +65,7 @@ const config: UserConfig = {
localsConvention: 'camelCase', localsConvention: 'camelCase',
}, },
}, },
plugins: [react(), ViteEjsPlugin({ web: false })], plugins: [createReactPlugin(), 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.rules, ...eslintPluginReactHooks.configs['recommended-latest'].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',
+78 -74
View File
@@ -1,6 +1,6 @@
{ {
"name": "feishin", "name": "feishin",
"version": "1.9.0", "version": "1.11.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-arm64:pr": "pnpm run build && electron-builder --linux --arm64 --publish never",
"package:linux:pr": "pnpm run build && electron-builder --linux --publish never", "package:linux:pr": "pnpm run build && electron-builder --linux --publish never",
"package:linux-arm64:pr": "pnpm run build && electron-builder --linux --arm64 --publish never",
"package:mac": "pnpm run build && electron-builder --mac", "package:mac": "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-arm64:pr": "pnpm run build && electron-builder --win --arm64 --publish never",
"package:win:pr": "pnpm run build && electron-builder --win --publish never", "package:win:pr": "pnpm run build && electron-builder --win --publish never",
"package:win-arm64:pr": "pnpm run build && electron-builder --win --arm64 --publish never",
"publish:linux": "pnpm run build && electron-builder --publish always --linux", "publish:linux": "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,119 +68,123 @@
"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.2", "@atlaskit/pragmatic-drag-and-drop-auto-scroll": "^2.1.5",
"@atlaskit/pragmatic-drag-and-drop-hitbox": "^1.1.0", "@atlaskit/pragmatic-drag-and-drop-hitbox": "^1.1.0",
"@electron-toolkit/preload": "^3.0.1", "@electron-toolkit/preload": "^3.0.2",
"@electron-toolkit/utils": "^4.0.0", "@electron-toolkit/utils": "^4.0.0",
"@mantine/colors-generator": "^8.3.8", "@mantine/colors-generator": "^8.3.18",
"@mantine/core": "^8.3.8", "@mantine/core": "^8.3.18",
"@mantine/dates": "^8.3.8", "@mantine/dates": "^8.3.18",
"@mantine/form": "^8.3.8", "@mantine/form": "^8.3.18",
"@mantine/hooks": "^8.3.8", "@mantine/hooks": "^8.3.18",
"@mantine/modals": "^8.3.8", "@mantine/modals": "^8.3.18",
"@mantine/notifications": "^8.3.8", "@mantine/notifications": "^8.3.18",
"@radix-ui/react-context-menu": "^2.2.16", "@radix-ui/react-context-menu": "^2.2.16",
"@tanstack/react-query": "^5.90.9", "@tanstack/react-query": "^5.96.2",
"@tanstack/react-query-devtools": "^5.90.2", "@tanstack/react-query-devtools": "^5.96.2",
"@tanstack/react-query-persist-client": "^5.90.11", "@tanstack/react-query-persist-client": "^5.96.2",
"@ts-rest/core": "^3.52.1", "@ts-rest/core": "^3.52.1",
"@wavesurfer/react": "^1.0.11", "@wavesurfer/react": "^1.0.12",
"@xhayper/discord-rpc": "^1.3.0", "@xhayper/discord-rpc": "^1.3.3",
"audiomotion-analyzer": "^4.5.1", "audiomotion-analyzer": "^4.5.4",
"axios": "^1.13.5", "axios": "^1.14.0",
"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.1.2", "cheerio": "^1.2.0",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"cmdk": "^1.1.1", "cmdk": "^1.1.1",
"dayjs": "^1.11.19", "dayjs": "^1.11.20",
"dompurify": "^3.3.0", "dompurify": "^3.3.3",
"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.6.2", "electron-updater": "^6.8.3",
"fast-average-color": "^9.5.0", "fast-average-color": "9.5.0",
"fast-xml-parser": "^5.3.8", "fast-xml-parser": "^5.5.10",
"format-duration": "^3.0.2", "format-duration": "^3.0.2",
"fuse.js": "^7.1.0", "fuse.js": "^7.2.0",
"i18next": "^25.6.2", "i18next": "^25.10.10",
"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.17.23", "lodash": "^4.18.1",
"md5": "^2.3.0", "md5": "^2.3.0",
"motion": "^12.23.24", "motion": "^12.38.0",
"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",
"nuqs": "^2.7.1", "overlayscrollbars": "^2.14.0",
"overlayscrollbars": "^2.11.1",
"overlayscrollbars-react": "^0.5.6", "overlayscrollbars-react": "^0.5.6",
"qs": "^6.14.2", "qs": "^6.15.0",
"react": "^19.1.0", "react": "^19.2.4",
"react-call": "^1.8.1", "react-call": "^1.8.2",
"react-dom": "^19.1.0", "react-dom": "^19.2.4",
"react-error-boundary": "^5.0.0", "react-error-boundary": "^5.0.0",
"react-i18next": "^16.3.3", "react-i18next": "^16.6.6",
"react-icons": "^5.5.0", "react-icons": "^5.6.0",
"react-player": "^2.16.0", "react-player": "^2.16.1",
"react-router": "^7.13.1", "react-router": "^7.14.0",
"react-split-pane": "^3.0.4", "react-split-pane": "^3.2.0",
"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.3", "react-window-v2": "npm:react-window@^2.2.7",
"semver": "^7.5.4", "semver": "^7.7.4",
"string-to-color": "^2.2.2", "string-to-color": "^2.2.2",
"wavesurfer.js": "^7.11.1", "wavesurfer.js": "^7.12.5",
"ws": "^8.18.2", "ws": "^8.20.0",
"zod": "^3.22.3", "zod": "^3.25.76",
"zustand": "^5.0.5" "zustand": "^5.0.12"
}, },
"devDependencies": { "devDependencies": {
"@electron-toolkit/eslint-config-prettier": "^3.0.0", "@electron-toolkit/eslint-config-prettier": "^3.0.0",
"@electron-toolkit/eslint-config-ts": "^3.0.0", "@electron-toolkit/eslint-config-ts": "^3.1.0",
"@electron-toolkit/tsconfig": "^2.0.0", "@electron-toolkit/tsconfig": "^2.0.0",
"@types/electron-localshortcut": "^3.1.0", "@types/electron-localshortcut": "^3.1.3",
"@types/lodash": "^4.17.18", "@types/lodash": "^4.17.24",
"@types/md5": "^2.3.5", "@types/md5": "^2.3.6",
"@types/node": "^24.10.1", "@types/node": "^24.12.2",
"@types/react": "^19.2.5", "@types/react": "^19.2.14",
"@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.1.1", "@vitejs/plugin-react": "^5.2.0",
"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.4.0", "electron": "^39.8.6",
"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.24.0", "eslint": "^9.39.4",
"eslint-plugin-perfectionist": "^4.13.0", "eslint-plugin-perfectionist": "^4.15.1",
"eslint-plugin-prettier": "^5.4.0", "eslint-plugin-prettier": "^5.5.5",
"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.24", "eslint-plugin-react-refresh": "^0.4.26",
"i18next-parser": "^9.3.0", "i18next-parser": "^9.4.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.6.2", "prettier": "^3.8.1",
"prettier-plugin-packagejson": "^2.5.19", "prettier-plugin-packagejson": "^2.5.22",
"stylelint": "^16.25.0", "stylelint": "^16.26.1",
"stylelint-config-css-modules": "^4.5.1", "stylelint-config-css-modules": "^4.6.0",
"stylelint-config-recess-order": "^7.4.0", "stylelint-config-recess-order": "^7.7.0",
"stylelint-config-standard": "^39.0.1", "stylelint-config-standard": "^39.0.1",
"typescript": "^5.8.3", "typescript": "^5.9.3",
"vite": "^7.2.2", "vite": "^7.3.1",
"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.1.0" "vite-plugin-pwa": "^1.2.0"
}, },
"pnpm": { "pnpm": {
"onlyBuiltDependencies": [ "onlyBuiltDependencies": [
+2436 -2404
View File
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -1,9 +1,9 @@
import react from '@vitejs/plugin-react';
import path from 'path'; import 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: [
react(), createReactPlugin(),
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')),
+3
View File
@@ -1110,6 +1110,9 @@
"export": "exportovat texty", "export": "exportovat texty",
"input_synced": "exportovat synchronizované texty", "input_synced": "exportovat synchronizované texty",
"input_offset": "$t(setting.lyricOffset)" "input_offset": "$t(setting.lyricOffset)"
},
"editRadioStation": {
"success": "stanice rádia úspěšně upravena"
} }
}, },
"entity": { "entity": {
+9
View File
@@ -364,6 +364,9 @@
"input_name": "name", "input_name": "name",
"input_streamUrl": "stream url" "input_streamUrl": "stream url"
}, },
"editRadioStation": {
"success": "radio station updated successfully"
},
"deletePlaylist": { "deletePlaylist": {
"input_confirm": "type the name of the $t(entity.playlist, {\"count\": 1}) to confirm", "input_confirm": "type the name of the $t(entity.playlist, {\"count\": 1}) to confirm",
"success": "$t(entity.playlist, {\"count\": 1}) deleted successfully", "success": "$t(entity.playlist, {\"count\": 1}) deleted successfully",
@@ -1211,6 +1214,12 @@
"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)",
+51 -4
View File
@@ -574,7 +574,7 @@
"hotkey_browserForward": "nabigatzailean aurreraka", "hotkey_browserForward": "nabigatzailean aurreraka",
"imageAspectRatio": "erabili jatorrizko azaleko artearen aspektu-erlazioa", "imageAspectRatio": "erabili jatorrizko azaleko artearen aspektu-erlazioa",
"lyricFetchProvider": "letrak eskuratzeko hornitzaileak", "lyricFetchProvider": "letrak eskuratzeko hornitzaileak",
"lyricFetchProvider_description": "aukeratu letrak eskuratzeko hornitzaileak. hornitzaileen ordena kontsultatuko diren ordena da", "lyricFetchProvider_description": "aukeratu letrak eskuratzeko hornitzaileak",
"minimizeToTray": "minimizatu erretilura", "minimizeToTray": "minimizatu erretilura",
"minimizeToTray_description": "minimizatu aplikazioa sistemaren erretilura", "minimizeToTray_description": "minimizatu aplikazioa sistemaren erretilura",
"minimumScrobblePercentage": "scrobble iraupen minimoa (ehunekoa)", "minimumScrobblePercentage": "scrobble iraupen minimoa (ehunekoa)",
@@ -688,7 +688,33 @@
"remotePort_description": "urruneko kontrol zerbitzariaren portua ezartzen du", "remotePort_description": "urruneko kontrol zerbitzariaren portua ezartzen du",
"remotePort": "urruneko kontrol zerbitzariaren ataka", "remotePort": "urruneko kontrol zerbitzariaren ataka",
"remoteUsername_description": "urruneko kontrol zerbitzariaren erabiltzaile-izena ezartzen du. Erabiltzaile-izena eta pasahitza hutsik badaude, autentifikazioa desgaituta egongo da", "remoteUsername_description": "urruneko kontrol zerbitzariaren erabiltzaile-izena ezartzen du. Erabiltzaile-izena eta pasahitza hutsik badaude, autentifikazioa desgaituta egongo da",
"remoteUsername": "urruneko kontrol zerbitzariaren erabiltzaile-izena" "remoteUsername": "urruneko kontrol zerbitzariaren erabiltzaile-izena",
"logLevel_optionWarn": "abisua",
"qobuz_description": "erakutsi Qobuz-erako estekak artista/album orrialdeetan",
"qobuz": "erakutsi Qobuz-erako estekak",
"spotify_description": "erakutsi Spotify-rako estekak artista/album orrialdeetan",
"spotify": "erakutsi Spotify-rako estekak",
"nativeSpotify_description": "ireki Spotify aplikazioan, arakatzailearen ordez",
"nativeSpotify": "erabili Spotify aplikazioa",
"playerbarSlider_description": "uhin-forma ez da gomendagarria interneteko konexio motela edo neurtua baduzu",
"playerbarSliderType_optionWaveform": "uhin-forma",
"playerbarWaveformAlign": "uhin-formaren lerrokatzea",
"playerbarWaveformAlign_optionTop": "nagusia",
"playerbarWaveformBarWidth": "uhin-formako barraren zabalera",
"playerbarWaveformGap": "uhin-formaren tartea",
"playerbarWaveformRadius": "uhin-formaren erradioa",
"showLyricsInSidebar_description": "letrak erakusten dituen panel bat gehituko da erantsitako erreprodukzio-ilaran",
"showLyricsInSidebar": "erakutsi letra erreproduzitzailearen alboko barran",
"blurExplicitImages": "irudi esplizituak lausotu",
"blurExplicitImages_description": "esplizitu gisa etiketatutako albumaren eta abestiaren azalak lausotuta agertuko dira",
"enableGridMultiSelect": "gaitu sareta anitzeko hautaketa",
"enableGridMultiSelect_description": "gaituta dagoenean, sareta-ikuspegietan hainbat elementu hautatzea ahalbidetzen du. desgaituta dagoenean, sareta-elementuen irudietan klik egitean elementuaren orrialdera nabigatuko da",
"showVisualizerInSidebar_description": "bistaratzailea erakusten duen panel bat gehituko da erreproduzitzailearen alboko barran",
"preservePitch_description": "erreprodukzio-abiadura aldatzean tonua mantentzen du",
"preservePitch": "mantendu tonua",
"preventSleepOnPlayback": "erreprodukzioan loa saihestu",
"replayGainClipping_description": "Saihestu {{ReplayGain}}-k eragindako mozketa irabazpena automatikoki jaitsiz",
"replayGainMode_description": "doitu bolumenaren irabazia fitxategiaren metadatuetan gordetako {{ReplayGain}} balioen arabera"
}, },
"form": { "form": {
"addServer": { "addServer": {
@@ -943,7 +969,8 @@
"nowPlaying": "orain erreproduzitzen", "nowPlaying": "orain erreproduzitzen",
"shared": "partekatutako $t(entity.playlist, {\"count\": 2})", "shared": "partekatutako $t(entity.playlist, {\"count\": 2})",
"favorites": "$t(entity.favorite, {\"count\": 2})", "favorites": "$t(entity.favorite, {\"count\": 2})",
"radio": "$t(entity.radioStation, {\"count\": 2})" "radio": "$t(entity.radioStation, {\"count\": 2})",
"collections": "bildumak"
}, },
"trackList": { "trackList": {
"title": "$t(entity.track, {\"count\": 2})", "title": "$t(entity.track, {\"count\": 2})",
@@ -1112,6 +1139,26 @@
"saveAsPreset": "Aurrezarpen gisa gorde", "saveAsPreset": "Aurrezarpen gisa gorde",
"applyPreset": "Aurrezarpena Aplikatu", "applyPreset": "Aurrezarpena Aplikatu",
"selectPreset": "Aukeratu Aurrezarpena", "selectPreset": "Aukeratu Aurrezarpena",
"presets": "Aurrezarpenak" "presets": "Aurrezarpenak",
"visualizerType": "Bistaratzaile Mota",
"cycleTime": "Zikloaren denbora (segundoak)",
"includeAllPresets": "Aurrezarpen guztiak sartu",
"ignoredPresets": "Aurrezarpen baztertuak",
"selectedPresets": "Hautatutako aurrezarpenak",
"mode1To8": "1 - 8 modua",
"mode10": "10 modua",
"gradientLeft": "Gradientearen ezkerra",
"gradientRight": "Gradientearen eskuina",
"peakBehavior": "Gailurraren Portaera",
"peakLine": "Gailurraren lerroa",
"miscellaneousSettings": "Hainbat ezarpen",
"alphaBars": "Alfa barrak",
"ansiBands": "ANSI bandak",
"ledBars": "LED barrak",
"trueLeds": "True LED-ak",
"roundBars": "Barra biribilduak",
"lowResolution": "Erresoluzio baxua",
"showFPS": "Erakutsi FPS",
"showScaleX": "Erakutsi X eskala"
} }
} }
+10 -8
View File
@@ -891,7 +891,9 @@
"sidePlayQueueLayout": "disposition de la file d'attente", "sidePlayQueueLayout": "disposition de la file d'attente",
"sidePlayQueueLayout_description": "définit la disposition de la file d'attente attaché", "sidePlayQueueLayout_description": "définit la disposition de la file d'attente attaché",
"sidePlayQueueLayout_optionHorizontal": "horizontal", "sidePlayQueueLayout_optionHorizontal": "horizontal",
"sidePlayQueueLayout_optionVertical": "vertical" "sidePlayQueueLayout_optionVertical": "vertical",
"waveformLoadingDelay": "délai de chargement de la forme d'onde",
"waveformLoadingDelay_description": "délai en secondes avant le chargement de l'onde. augmentez cette valeur si vous rencontrez des saccades lors de l'utilisation du lecteur web."
}, },
"form": { "form": {
"deletePlaylist": { "deletePlaylist": {
@@ -1090,7 +1092,7 @@
"pagination_itemsPerPage": "entrées par page", "pagination_itemsPerPage": "entrées par page",
"pagination_infinite": "infini", "pagination_infinite": "infini",
"pagination_paginate": "paginé", "pagination_paginate": "paginé",
"alternateRowColors": "alterner les couleurs des lignes", "alternateRowColors": "alterner la couleur des lignes",
"horizontalBorders": "bordures de ligne", "horizontalBorders": "bordures de ligne",
"rowHoverHighlight": "surligner les lignes au survol", "rowHoverHighlight": "surligner les lignes au survol",
"verticalBorders": "bordure de colonne", "verticalBorders": "bordure de colonne",
@@ -1232,12 +1234,12 @@
}, },
"visualizer": { "visualizer": {
"visualizerType": "type de visualisateur", "visualizerType": "type de visualisateur",
"cyclePresets": "cycle les préréglages", "cyclePresets": "cycler les préréglages",
"cycleTime": "temps de cycle (secondes)", "cycleTime": "durée d'un cycle (secondes)",
"includeAllPresets": "inclure tous les préréglages", "includeAllPresets": "inclure tous les préréglages",
"ignoredPresets": "préréglages ignorés", "ignoredPresets": "préréglages ignorés",
"selectedPresets": "préréglages sélectionné", "selectedPresets": "préréglages sélectionnés",
"randomizeNextPreset": "randomiser le préréglage suivant", "randomizeNextPreset": "préréglage suivant aléatoire",
"blendTime": "temps de mélange", "blendTime": "temps de mélange",
"presets": "préréglages", "presets": "préréglages",
"selectPreset": "sélectionner un préréglage", "selectPreset": "sélectionner un préréglage",
@@ -1247,7 +1249,7 @@
"copyConfiguration": "copier la configuration", "copyConfiguration": "copier la configuration",
"pasteConfiguration": "coller la configuration", "pasteConfiguration": "coller la configuration",
"pasteConfigurationPlaceholder": "coller ici la configuration JSON...", "pasteConfigurationPlaceholder": "coller ici la configuration JSON...",
"pasteFromClipboard": "coller depuis le presse-papier", "pasteFromClipboard": "coller depuis le presse-papiers",
"applyConfiguration": "appliquer la configuration", "applyConfiguration": "appliquer la configuration",
"configCopied": "configuration copiée dans le presse-papiers", "configCopied": "configuration copiée dans le presse-papiers",
"configCopyFailed": "échec de la copie de la configuration", "configCopyFailed": "échec de la copie de la configuration",
@@ -1272,7 +1274,7 @@
"gradientNamePlaceholder": "nom du dégradé", "gradientNamePlaceholder": "nom du dégradé",
"vertical": "verticale", "vertical": "verticale",
"horizontal": "horizontale", "horizontal": "horizontale",
"colorStops": "couleur d'arrêts", "colorStops": "Points de Couleur",
"addColor": "ajouter un couleur", "addColor": "ajouter un couleur",
"position": "position", "position": "position",
"level": "niveau", "level": "niveau",
+8 -2
View File
@@ -169,7 +169,8 @@
"filter_single": "single", "filter_single": "single",
"rename": "zmień nazwę", "rename": "zmień nazwę",
"newVersionAvailable": "nowa wersja jest dostępna", "newVersionAvailable": "nowa wersja jest dostępna",
"numberOfResults": "{{numberOfResults}} wyników" "numberOfResults": "{{numberOfResults}} wyników",
"grouping": "grupowanie"
}, },
"entity": { "entity": {
"genre_one": "gatunek", "genre_one": "gatunek",
@@ -420,6 +421,9 @@
"export": "eksportuj tekst", "export": "eksportuj tekst",
"input_synced": "eksportuj zsynchronizowany tekst", "input_synced": "eksportuj zsynchronizowany tekst",
"input_offset": "$t(setting.lyricOffset)" "input_offset": "$t(setting.lyricOffset)"
},
"editRadioStation": {
"success": "stacja radiowa zaktualizowana pomyślnie"
} }
}, },
"page": { "page": {
@@ -1058,7 +1062,9 @@
"sidePlayQueueLayout": "układ boczny kolejki odtwarzania", "sidePlayQueueLayout": "układ boczny kolejki odtwarzania",
"sidePlayQueueLayout_description": "ustawia układ przyczepionej z boku kolejki odtwarzania", "sidePlayQueueLayout_description": "ustawia układ przyczepionej z boku kolejki odtwarzania",
"sidePlayQueueLayout_optionHorizontal": "poziomy", "sidePlayQueueLayout_optionHorizontal": "poziomy",
"sidePlayQueueLayout_optionVertical": "pionowy" "sidePlayQueueLayout_optionVertical": "pionowy",
"waveformLoadingDelay": "opóźnienie załadowania fali",
"waveformLoadingDelay_description": "opóźnienie w sekundach przed załadowaniem fali. zwiększ tą wartość jeżeli doświadczasz zawieszania się odtwarzacza przeglądarkowego."
}, },
"table": { "table": {
"config": { "config": {
+5 -2
View File
@@ -161,7 +161,8 @@
"rename": "重命名", "rename": "重命名",
"filter_multiple": "多项", "filter_multiple": "多项",
"newVersionAvailable": "新版本现已可用", "newVersionAvailable": "新版本现已可用",
"numberOfResults": "{{numberOfResults}} 结果" "numberOfResults": "{{numberOfResults}} 结果",
"grouping": "分组"
}, },
"entity": { "entity": {
"albumArtist_other": "专辑艺术家", "albumArtist_other": "专辑艺术家",
@@ -609,7 +610,9 @@
"sidePlayQueueLayout": "侧边播放队列布局", "sidePlayQueueLayout": "侧边播放队列布局",
"sidePlayQueueLayout_description": "设置附加侧边播放队列的布局", "sidePlayQueueLayout_description": "设置附加侧边播放队列的布局",
"sidePlayQueueLayout_optionHorizontal": "水平", "sidePlayQueueLayout_optionHorizontal": "水平",
"sidePlayQueueLayout_optionVertical": "垂直" "sidePlayQueueLayout_optionVertical": "垂直",
"waveformLoadingDelay": "波形加载延迟",
"waveformLoadingDelay_description": "加载波形前的延迟时间(秒)。如果在使用网页播放器时遇到卡顿现象,请增加此值。"
}, },
"error": { "error": {
"remotePortWarning": "重启服务器使新端口生效", "remotePortWarning": "重启服务器使新端口生效",
+6 -1
View File
@@ -1124,6 +1124,9 @@
"export": "匯出歌詞", "export": "匯出歌詞",
"input_synced": "匯出同步歌詞", "input_synced": "匯出同步歌詞",
"input_offset": "$t(setting.lyricOffset)" "input_offset": "$t(setting.lyricOffset)"
},
"editRadioStation": {
"success": "電臺更新成功"
} }
}, },
"releaseType": { "releaseType": {
@@ -1332,6 +1335,8 @@
"d": "D", "d": "D",
"z": "Z" "z": "Z"
} }
} },
"systemAudioCaptureFailed": "無法開始擷取:{{message}}",
"systemAudioNoAudioTrack": "沒有回傳任何音軌。確保在提示時啟用音訊擷取。"
} }
} }
+10 -2
View File
@@ -437,10 +437,18 @@ ipcMain.on('player-mute', async (_event, mute: boolean) => {
ipcMain.handle('player-get-time', async (): Promise<number | undefined> => { ipcMain.handle('player-get-time', async (): Promise<number | undefined> => {
try { try {
return getMpvInstance()?.getTimePosition(); const mpv = getMpvInstance();
if (!mpv) {
return undefined;
}
return await mpv.getTimePosition();
} catch (err: any | NodeMpvError) { } catch (err: any | NodeMpvError) {
// Err 3: IPC command invalid — e.g. time-pos unavailable when idle / between tracks
if (err?.errcode === 3) {
return undefined;
}
mpvLog({ action: `Failed to get current time` }, err); mpvLog({ action: `Failed to get current time` }, err);
return 0; return undefined;
} }
}); });
+1
View File
@@ -40,6 +40,7 @@ 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,6 +150,23 @@ 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,
+116 -8
View File
@@ -5,6 +5,7 @@ import {
app, app,
BrowserWindow, BrowserWindow,
BrowserWindowConstructorOptions, BrowserWindowConstructorOptions,
desktopCapturer,
globalShortcut, globalShortcut,
ipcMain, ipcMain,
Menu, Menu,
@@ -29,7 +30,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 from './menu'; import MenuBuilder, { MenuPlaybackState } from './menu';
import { import {
autoUpdaterLogInterface, autoUpdaterLogInterface,
createLog, createLog,
@@ -41,7 +42,7 @@ import {
} from './utils'; } from './utils';
import './features'; import './features';
import { PlayerType, TitleTheme } from '/@/shared/types/types'; import { PlayerRepeat, PlayerStatus, PlayerType, TitleTheme } from '/@/shared/types/types';
const ALPHA_UPDATER_CONFIG: { const ALPHA_UPDATER_CONFIG: {
bucket: string; bucket: string;
@@ -277,6 +278,13 @@ 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) => {
@@ -333,6 +341,23 @@ 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,
@@ -699,12 +724,8 @@ async function createWindow(first = true): Promise<void> {
}); });
} }
const menuBuilder = new MenuBuilder(mainWindow); menuBuilder = new MenuBuilder(mainWindow);
menuBuilder.buildMenu(); rebuildMainMenu();
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) => {
@@ -712,6 +733,22 @@ 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();
} }
@@ -782,6 +819,17 @@ 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]: () => {},
@@ -835,6 +883,26 @@ ipcMain.on(
} }
} }
playbackMenuAccelerators = {
next: getMenuAccelerator(data, BindingActions.NEXT),
playPause:
getMenuAccelerator(data, BindingActions.PLAY_PAUSE) ||
getMenuAccelerator(data, BindingActions.PLAY) ||
getMenuAccelerator(data, BindingActions.PAUSE),
previous: getMenuAccelerator(data, BindingActions.PREVIOUS),
repeat: getMenuAccelerator(data, BindingActions.TOGGLE_REPEAT),
seekBackward: getMenuAccelerator(data, BindingActions.SKIP_BACKWARD),
seekForward: getMenuAccelerator(data, BindingActions.SKIP_FORWARD),
shuffle: getMenuAccelerator(data, BindingActions.SHUFFLE),
stop: getMenuAccelerator(data, BindingActions.STOP),
volumeDown: getMenuAccelerator(data, BindingActions.VOLUME_DOWN),
volumeUp: getMenuAccelerator(data, BindingActions.VOLUME_UP),
};
if (isMacOS()) {
rebuildMainMenu();
}
const globalMediaKeysEnabled = store.get('global_media_hotkeys', true) as boolean; const globalMediaKeysEnabled = store.get('global_media_hotkeys', true) as boolean;
if (globalMediaKeysEnabled) { if (globalMediaKeysEnabled) {
@@ -975,3 +1043,43 @@ 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();
});
+190 -4
View File
@@ -1,18 +1,53 @@
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(): MenuItemConstructorOptions[] { buildDarwinTemplate({
accelerators,
playbackStatus = PlayerStatus.PAUSED,
privateMode = false,
repeatMode = PlayerRepeat.NONE,
shuffleEnabled = false,
sidebarCollapsed = false,
}: MenuPlaybackState = {}): MenuItemConstructorOptions[] {
const isPlaying = playbackStatus === PlayerStatus.PLAYING;
const isRepeatEnabled = repeatMode !== PlayerRepeat.NONE;
const subMenuAbout: DarwinMenuItemConstructorOptions = { const subMenuAbout: DarwinMenuItemConstructorOptions = {
label: 'Electron', label: 'Electron',
submenu: [ submenu: [
@@ -29,6 +64,21 @@ 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' },
{ {
@@ -71,6 +121,22 @@ 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: () => {
@@ -97,6 +163,22 @@ 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: () => {
@@ -119,6 +201,89 @@ 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: [
@@ -148,6 +313,13 @@ 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,
},
], ],
}; };
@@ -156,7 +328,14 @@ export default class MenuBuilder {
? subMenuViewDev ? subMenuViewDev
: subMenuViewProd; : subMenuViewProd;
return [subMenuAbout, subMenuEdit, subMenuView, subMenuWindow, subMenuHelp]; return [
subMenuAbout,
subMenuEdit,
subMenuView,
subMenuPlayback,
subMenuWindow,
subMenuHelp,
];
} }
buildDefaultTemplate(): MenuItemConstructorOptions[] { buildDefaultTemplate(): MenuItemConstructorOptions[] {
@@ -262,14 +441,14 @@ export default class MenuBuilder {
return templateDefault; return templateDefault;
} }
buildMenu(): Menu { buildMenu(playbackState: MenuPlaybackState = {}): 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() ? this.buildDarwinTemplate(playbackState)
: this.buildDefaultTemplate(); : this.buildDefaultTemplate();
const menu = Menu.buildFromTemplate(template); const menu = Menu.buildFromTemplate(template);
@@ -279,6 +458,13 @@ 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,6 +65,26 @@ 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,
@@ -78,7 +98,12 @@ 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;
+84
View File
@@ -147,6 +147,20 @@ export const controller: GeneralController = {
server.type, server.type,
)?.(addContext({ ...args, apiClientProps: { ...args.apiClientProps, server } })); )?.(addContext({ ...args, apiClientProps: { ...args.apiClientProps, server } }));
}, },
deleteArtistImage(args) {
const server = getServerById(args.apiClientProps.serverId);
if (!server) {
throw new Error(
`${i18n.t('error.apiRouteError', { postProcess: 'sentenceCase' })}: deleteArtistImage`,
);
}
return apiController(
'deleteArtistImage',
server.type,
)?.(addContext({ ...args, apiClientProps: { ...args.apiClientProps, server } }));
},
deleteFavorite(args) { deleteFavorite(args) {
const server = getServerById(args.apiClientProps.serverId); const server = getServerById(args.apiClientProps.serverId);
@@ -175,6 +189,20 @@ export const controller: GeneralController = {
server.type, server.type,
)?.(addContext({ ...args, apiClientProps: { ...args.apiClientProps, server } })); )?.(addContext({ ...args, apiClientProps: { ...args.apiClientProps, server } }));
}, },
deleteInternetRadioStationImage(args) {
const server = getServerById(args.apiClientProps.serverId);
if (!server) {
throw new Error(
`${i18n.t('error.apiRouteError', { postProcess: 'sentenceCase' })}: deleteInternetRadioStationImage`,
);
}
return apiController(
'deleteInternetRadioStationImage',
server.type,
)?.(addContext({ ...args, apiClientProps: { ...args.apiClientProps, server } }));
},
deletePlaylist(args) { deletePlaylist(args) {
const server = getServerById(args.apiClientProps.serverId); const server = getServerById(args.apiClientProps.serverId);
@@ -189,6 +217,20 @@ export const controller: GeneralController = {
server.type, server.type,
)?.(addContext({ ...args, apiClientProps: { ...args.apiClientProps, server } })); )?.(addContext({ ...args, apiClientProps: { ...args.apiClientProps, server } }));
}, },
deletePlaylistImage(args) {
const server = getServerById(args.apiClientProps.serverId);
if (!server) {
throw new Error(
`${i18n.t('error.apiRouteError', { postProcess: 'sentenceCase' })}: deletePlaylistImage`,
);
}
return apiController(
'deletePlaylistImage',
server.type,
)?.(addContext({ ...args, apiClientProps: { ...args.apiClientProps, server } }));
},
getAlbumArtistDetail(args) { getAlbumArtistDetail(args) {
const server = getServerById(args.apiClientProps.serverId); const server = getServerById(args.apiClientProps.serverId);
@@ -960,4 +1002,46 @@ export const controller: GeneralController = {
server.type, server.type,
)?.(addContext({ ...args, apiClientProps: { ...args.apiClientProps, server } })); )?.(addContext({ ...args, apiClientProps: { ...args.apiClientProps, server } }));
}, },
uploadArtistImage(args) {
const server = getServerById(args.apiClientProps.serverId);
if (!server) {
throw new Error(
`${i18n.t('error.apiRouteError', { postProcess: 'sentenceCase' })}: uploadArtistImage`,
);
}
return apiController(
'uploadArtistImage',
server.type,
)?.(addContext({ ...args, apiClientProps: { ...args.apiClientProps, server } }));
},
uploadInternetRadioStationImage(args) {
const server = getServerById(args.apiClientProps.serverId);
if (!server) {
throw new Error(
`${i18n.t('error.apiRouteError', { postProcess: 'sentenceCase' })}: uploadInternetRadioStationImage`,
);
}
return apiController(
'uploadInternetRadioStationImage',
server.type,
)?.(addContext({ ...args, apiClientProps: { ...args.apiClientProps, server } }));
},
uploadPlaylistImage(args) {
const server = getServerById(args.apiClientProps.serverId);
if (!server) {
throw new Error(
`${i18n.t('error.apiRouteError', { postProcess: 'sentenceCase' })}: uploadPlaylistImage`,
);
}
return apiController(
'uploadPlaylistImage',
server.type,
)?.(addContext({ ...args, apiClientProps: { ...args.apiClientProps, server } }));
},
}; };
@@ -46,6 +46,33 @@ export const contract = c.router({
500: resultWithHeaders(ndType._response.error), 500: resultWithHeaders(ndType._response.error),
}, },
}, },
deleteArtistImage: {
body: null,
method: 'DELETE',
path: 'artist/:id/image',
responses: {
200: resultWithHeaders(ndType._response.deleteArtistImage),
500: resultWithHeaders(ndType._response.error),
},
},
deleteInternetRadioStation: {
body: null,
method: 'DELETE',
path: 'radio/:id',
responses: {
200: resultWithHeaders(ndType._response.deleteInternetRadioStation),
500: resultWithHeaders(ndType._response.error),
},
},
deleteInternetRadioStationImage: {
body: null,
method: 'DELETE',
path: 'radio/:id/image',
responses: {
200: resultWithHeaders(ndType._response.deleteInternetRadioStationImage),
500: resultWithHeaders(ndType._response.error),
},
},
deletePlaylist: { deletePlaylist: {
body: null, body: null,
method: 'DELETE', method: 'DELETE',
@@ -55,6 +82,15 @@ export const contract = c.router({
500: resultWithHeaders(ndType._response.error), 500: resultWithHeaders(ndType._response.error),
}, },
}, },
deletePlaylistImage: {
body: null,
method: 'DELETE',
path: 'playlist/:id/image',
responses: {
200: resultWithHeaders(ndType._response.deletePlaylistImage),
500: resultWithHeaders(ndType._response.error),
},
},
getAlbumArtistDetail: { getAlbumArtistDetail: {
method: 'GET', method: 'GET',
path: 'artist/:id', path: 'artist/:id',
@@ -132,6 +168,15 @@ export const contract = c.router({
500: resultWithHeaders(ndType._response.error), 500: resultWithHeaders(ndType._response.error),
}, },
}, },
getRadioList: {
method: 'GET',
path: 'radio',
query: ndType._parameters.radioList,
responses: {
200: resultWithHeaders(ndType._response.radioList),
500: resultWithHeaders(ndType._response.error),
},
},
getSongDetail: { getSongDetail: {
method: 'GET', method: 'GET',
path: 'song/:id', path: 'song/:id',
@@ -205,6 +250,15 @@ export const contract = c.router({
500: resultWithHeaders(ndType._response.error), 500: resultWithHeaders(ndType._response.error),
}, },
}, },
updateInternetRadioStation: {
body: ndType._parameters.updateInternetRadioStation,
method: 'PUT',
path: 'radio/:id',
responses: {
200: resultWithHeaders(ndType._response.updateInternetRadioStation),
500: resultWithHeaders(ndType._response.error),
},
},
updatePlaylist: { updatePlaylist: {
body: ndType._parameters.updatePlaylist, body: ndType._parameters.updatePlaylist,
method: 'PUT', method: 'PUT',
@@ -214,6 +268,33 @@ export const contract = c.router({
500: resultWithHeaders(ndType._response.error), 500: resultWithHeaders(ndType._response.error),
}, },
}, },
uploadArtistImage: {
body: ndType._parameters.uploadArtistImage,
method: 'POST',
path: 'artist/:id/image',
responses: {
200: resultWithHeaders(ndType._response.uploadArtistImage),
500: resultWithHeaders(ndType._response.error),
},
},
uploadInternetRadioStationImage: {
body: ndType._parameters.uploadInternetRadioStationImage,
method: 'POST',
path: 'radio/:id/image',
responses: {
200: resultWithHeaders(ndType._response.uploadInternetRadioStationImage),
500: resultWithHeaders(ndType._response.error),
},
},
uploadPlaylistImage: {
body: ndType._parameters.uploadPlaylistImage,
method: 'POST',
path: 'playlist/:id/image',
responses: {
200: resultWithHeaders(ndType._response.uploadPlaylistImage),
500: resultWithHeaders(ndType._response.error),
},
},
}); });
const axiosClient = axios.create({}); const axiosClient = axios.create({});
@@ -1,3 +1,4 @@
import axios from 'axios';
import { set } from 'idb-keyval'; import { set } from 'idb-keyval';
import orderBy from 'lodash/orderBy'; import orderBy from 'lodash/orderBy';
@@ -5,13 +6,19 @@ import { ndApiClient } from '/@/renderer/api/navidrome/navidrome-api';
import { ssApiClient } from '/@/renderer/api/subsonic/subsonic-api'; import { ssApiClient } from '/@/renderer/api/subsonic/subsonic-api';
import { SubsonicController } from '/@/renderer/api/subsonic/subsonic-controller'; import { SubsonicController } from '/@/renderer/api/subsonic/subsonic-controller';
import { ndNormalize } from '/@/shared/api/navidrome/navidrome-normalize'; import { ndNormalize } from '/@/shared/api/navidrome/navidrome-normalize';
import { NDSongListSort } from '/@/shared/api/navidrome/navidrome-types'; import { NDRadioListSort, NDSongListSort } from '/@/shared/api/navidrome/navidrome-types';
import { ssNormalize } from '/@/shared/api/subsonic/subsonic-normalize'; import { ssNormalize } from '/@/shared/api/subsonic/subsonic-normalize';
import { getFeatures, hasFeature, hasFeatureWithVersion, VersionInfo } from '/@/shared/api/utils'; import { getFeatures, hasFeature, hasFeatureWithVersion, VersionInfo } from '/@/shared/api/utils';
import { import {
albumArtistListSortMap, albumArtistListSortMap,
albumListSortMap, albumListSortMap,
AuthenticationResponse, AuthenticationResponse,
DeleteArtistImageArgs,
DeleteArtistImageResponse,
DeleteInternetRadioStationImageArgs,
DeleteInternetRadioStationImageResponse,
DeletePlaylistImageArgs,
DeletePlaylistImageResponse,
genreListSortMap, genreListSortMap,
InternalControllerEndpoint, InternalControllerEndpoint,
playlistListSortMap, playlistListSortMap,
@@ -23,6 +30,12 @@ import {
SortOrder, SortOrder,
sortOrderMap, sortOrderMap,
tagListSortMap, tagListSortMap,
UploadArtistImageArgs,
UploadArtistImageResponse,
UploadInternetRadioStationImageArgs,
UploadInternetRadioStationImageResponse,
UploadPlaylistImageArgs,
UploadPlaylistImageResponse,
userListSortMap, userListSortMap,
} from '/@/shared/types/domain-types'; } from '/@/shared/types/domain-types';
import { ServerFeature } from '/@/shared/types/features-types'; import { ServerFeature } from '/@/shared/types/features-types';
@@ -30,6 +43,14 @@ import { ServerFeature } from '/@/shared/types/features-types';
const VERSION_INFO: VersionInfo = [ const VERSION_INFO: VersionInfo = [
// Why 2? Subsonic controller will return 1 for its own implementation // Why 2? Subsonic controller will return 1 for its own implementation
// Use 2 to denote that Navidrome's own API has a different endpoint // Use 2 to denote that Navidrome's own API has a different endpoint
[
'0.61.0',
{
[ServerFeature.ARTIST_IMAGE_UPLOAD]: [1],
[ServerFeature.INTERNET_RADIO_IMAGE_UPLOAD]: [1],
[ServerFeature.PLAYLIST_IMAGE_UPLOAD]: [1],
},
],
['0.60.4', { [ServerFeature.TRACK_YES_NO_RATING_FILTER]: [1] }], ['0.60.4', { [ServerFeature.TRACK_YES_NO_RATING_FILTER]: [1] }],
['0.57.0', { [ServerFeature.SERVER_PLAY_QUEUE]: [2] }], ['0.57.0', { [ServerFeature.SERVER_PLAY_QUEUE]: [2] }],
['0.56.0', { [ServerFeature.TRACK_ALBUM_ARTIST_SEARCH]: [1] }], ['0.56.0', { [ServerFeature.TRACK_ALBUM_ARTIST_SEARCH]: [1] }],
@@ -170,8 +191,54 @@ export const NavidromeController: InternalControllerEndpoint = {
id: res.body.data.id, id: res.body.data.id,
}; };
}, },
deleteArtistImage: async (args: DeleteArtistImageArgs): Promise<DeleteArtistImageResponse> => {
const { apiClientProps, query } = args;
const res = await ndApiClient(apiClientProps as any).deleteArtistImage({
params: {
id: query.id,
},
});
if (res.status !== 200) {
throw new Error('Failed to delete artist image');
}
return res.body.data.status === 'ok';
},
deleteFavorite: SubsonicController.deleteFavorite, deleteFavorite: SubsonicController.deleteFavorite,
deleteInternetRadioStation: SubsonicController.deleteInternetRadioStation, deleteInternetRadioStation: async (args) => {
const { apiClientProps, query } = args;
const res = await ndApiClient(apiClientProps).deleteInternetRadioStation({
params: {
id: query.id,
},
});
if (res.status !== 200) {
throw new Error('Failed to delete internet radio station');
}
return null;
},
deleteInternetRadioStationImage: async (
args: DeleteInternetRadioStationImageArgs,
): Promise<DeleteInternetRadioStationImageResponse> => {
const { apiClientProps, query } = args;
const res = await ndApiClient(apiClientProps as any).deleteInternetRadioStationImage({
params: {
id: query.id,
},
});
if (res.status !== 200) {
throw new Error('Failed to delete internet radio station image');
}
return res.body.data.status === 'ok';
},
deletePlaylist: async (args) => { deletePlaylist: async (args) => {
const { apiClientProps, query } = args; const { apiClientProps, query } = args;
@@ -187,6 +254,23 @@ export const NavidromeController: InternalControllerEndpoint = {
return null; return null;
}, },
deletePlaylistImage: async (
args: DeletePlaylistImageArgs,
): Promise<DeletePlaylistImageResponse> => {
const { apiClientProps, query } = args;
const res = await ndApiClient(apiClientProps as any).deletePlaylistImage({
params: {
id: query.id,
},
});
if (res.status !== 200) {
throw new Error('Failed to delete playlist image');
}
return res.body.data.status === 'ok';
},
getAlbumArtistDetail: async (args) => { getAlbumArtistDetail: async (args) => {
const { apiClientProps, query } = args; const { apiClientProps, query } = args;
@@ -233,8 +317,8 @@ export const NavidromeController: InternalControllerEndpoint = {
similarArtists: similarArtists:
artistInfo?.similarArtist?.map((artist) => ({ artistInfo?.similarArtist?.map((artist) => ({
id: artist.id, id: artist.id,
imageId: null, imageId: artist.id,
imageUrl: artist?.artistImageUrl?.replace(/\?size=\d+/, '') ?? null, imageUrl: 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,
@@ -547,7 +631,24 @@ export const NavidromeController: InternalControllerEndpoint = {
}, },
getImageRequest: SubsonicController.getImageRequest, getImageRequest: SubsonicController.getImageRequest,
getImageUrl: SubsonicController.getImageUrl, getImageUrl: SubsonicController.getImageUrl,
getInternetRadioStations: SubsonicController.getInternetRadioStations, getInternetRadioStations: async (args) => {
const { apiClientProps } = args;
const res = await ndApiClient(apiClientProps).getRadioList({
query: {
_end: -1,
_order: 'ASC',
_sort: NDRadioListSort.NAME,
_start: 0,
},
});
if (res.status !== 200) {
throw new Error('Failed to get internet radio stations');
}
return res.body.data.map((station) => ndNormalize.internetRadioStation(station));
},
getLyrics: SubsonicController.getLyrics, getLyrics: SubsonicController.getLyrics,
getMusicFolderList: SubsonicController.getMusicFolderList, getMusicFolderList: SubsonicController.getMusicFolderList,
getPlaylistDetail: async (args) => { getPlaylistDetail: async (args) => {
@@ -1145,7 +1246,26 @@ export const NavidromeController: InternalControllerEndpoint = {
id: res.body.data.id, id: res.body.data.id,
}; };
}, },
updateInternetRadioStation: SubsonicController.updateInternetRadioStation, updateInternetRadioStation: async (args) => {
const { apiClientProps, body, query } = args;
const res = await ndApiClient(apiClientProps).updateInternetRadioStation({
body: {
homePageUrl: body.homepageUrl ?? '',
name: body.name,
streamUrl: body.streamUrl,
},
params: {
id: query.id,
},
});
if (res.status !== 200) {
throw new Error('Failed to update internet radio station');
}
return null;
},
updatePlaylist: async (args) => { updatePlaylist: async (args) => {
const { apiClientProps, body, query } = args; const { apiClientProps, body, query } = args;
@@ -1170,4 +1290,110 @@ export const NavidromeController: InternalControllerEndpoint = {
return null; return null;
}, },
uploadArtistImage: async (args: UploadArtistImageArgs): Promise<UploadArtistImageResponse> => {
const { apiClientProps, body, query } = args;
const server = apiClientProps.server;
const serverUrl = server?.url?.replace(/\/$/, '');
if (!serverUrl) {
throw new Error('Server is required');
}
const form = new FormData();
const bytes = body.image as Uint8Array<ArrayBuffer>;
const fileLike =
typeof File !== 'undefined'
? new File([bytes], 'image', { type: 'application/octet-stream' })
: new Blob([bytes], { type: 'application/octet-stream' });
form.append('image', fileLike as any);
const res = await axios.post(`${serverUrl}/api/artist/${query.id}/image`, form, {
headers: {
'Content-Type': 'multipart/form-data',
...(server?.ndCredential && {
'x-nd-authorization': `Bearer ${server.ndCredential}`,
}),
},
signal: apiClientProps.signal,
});
if (res.status !== 200) {
throw new Error('Failed to upload artist image');
}
return res.data?.status === 'ok';
},
uploadInternetRadioStationImage: async (
args: UploadInternetRadioStationImageArgs,
): Promise<UploadInternetRadioStationImageResponse> => {
const { apiClientProps, body, query } = args;
const server = apiClientProps.server;
const serverUrl = server?.url?.replace(/\/$/, '');
if (!serverUrl) {
throw new Error('Server is required');
}
const form = new FormData();
const bytes = body.image as Uint8Array<ArrayBuffer>;
const fileLike =
typeof File !== 'undefined'
? new File([bytes], 'image', { type: 'application/octet-stream' })
: new Blob([bytes], { type: 'application/octet-stream' });
form.append('image', fileLike as any);
const res = await axios.post(`${serverUrl}/api/radio/${query.id}/image`, form, {
headers: {
'Content-Type': 'multipart/form-data',
...(server?.ndCredential && {
'x-nd-authorization': `Bearer ${server.ndCredential}`,
}),
},
signal: apiClientProps.signal,
});
if (res.status !== 200) {
throw new Error('Failed to upload internet radio station image');
}
return res.data?.status === 'ok';
},
uploadPlaylistImage: async (
args: UploadPlaylistImageArgs,
): Promise<UploadPlaylistImageResponse> => {
const { apiClientProps, body, query } = args;
const server = apiClientProps.server;
const serverUrl = server?.url?.replace(/\/$/, '');
if (!serverUrl) {
throw new Error('Server is required');
}
const form = new FormData();
const bytes = body.image as Uint8Array<ArrayBuffer>;
const fileLike =
typeof File !== 'undefined'
? new File([bytes], 'image', { type: 'application/octet-stream' })
: new Blob([bytes], { type: 'application/octet-stream' });
form.append('image', fileLike as any);
const res = await axios.post(`${serverUrl}/api/playlist/${query.id}/image`, form, {
headers: {
'Content-Type': 'multipart/form-data',
...(server?.ndCredential && {
'x-nd-authorization': `Bearer ${server.ndCredential}`,
}),
},
signal: apiClientProps.signal,
});
if (res.status !== 200) {
throw new Error('Failed to upload playlist image');
}
return res.data?.status === 'ok';
},
}; };
@@ -237,6 +237,27 @@ function appendTranscodeParams(url: string, format?: string, bitrate?: number) {
return streamUrl; 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: {
@@ -487,7 +508,7 @@ export const SubsonicController: InternalControllerEndpoint = {
similarArtists: similarArtists:
artistInfo?.similarArtist?.map((artist) => ({ artistInfo?.similarArtist?.map((artist) => ({
id: artist.id, id: artist.id,
imageId: null, imageId: artist.coverArt ?? artist.id,
imageUrl: null, imageUrl: null,
name: artist.name, name: artist.name,
userFavorite: Boolean(artist.starred) || false, userFavorite: Boolean(artist.starred) || false,
@@ -1185,7 +1206,7 @@ export const SubsonicController: InternalControllerEndpoint = {
return ssNormalize.playlist(res.body.playlist, apiClientProps.server); return ssNormalize.playlist(res.body.playlist, apiClientProps.server);
}, },
getPlaylistList: async ({ apiClientProps, query }) => { getPlaylistList: async ({ apiClientProps, query }) => {
const sortOrder = query.sortOrder.toLowerCase() as 'asc' | 'desc'; const sortOrder = (query.sortOrder || SortOrder.ASC).toLowerCase() as 'asc' | 'desc';
const res = await ssApiClient(apiClientProps).getPlaylists({}); const res = await ssApiClient(apiClientProps).getPlaylists({});
@@ -2013,20 +2034,14 @@ export const SubsonicController: InternalControllerEndpoint = {
return appendTranscodeParams(streamUrl, format, bitrate); return appendTranscodeParams(streamUrl, format, bitrate);
} }
const transcodeStreamUrl = await ssApiClient(apiClientProps).getTranscodeStream({ const transcodeStreamUrl = buildGetTranscodeStreamUrl(server, {
query: { mediaId: String(id),
mediaId: id, mediaType: (mediaType ?? 'song') as 'podcast' | 'song',
mediaType, offset: 0,
offset: 0, transcodeParams: td.transcodeParams,
transcodeParams: td.transcodeParams,
},
}); });
if (transcodeStreamUrl.status !== 200) { return transcodeStreamUrl;
throw new Error('Failed to get transcode stream');
}
return transcodeStreamUrl.body;
} }
return streamUrl; return streamUrl;
+102 -56
View File
@@ -7,12 +7,12 @@ import '@mantine/core/styles.css';
import '@mantine/dates/styles.css'; import '@mantine/dates/styles.css';
import '@mantine/notifications/styles.css'; import '@mantine/notifications/styles.css';
import isElectron from 'is-electron'; import isElectron from 'is-electron';
import { lazy, Suspense, useEffect, useMemo, useRef, useState } from 'react'; import { lazy, memo, Suspense, useEffect, useMemo, useRef, useState } from 'react';
import i18n from '/@/i18n/i18n'; import 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';
@@ -38,67 +38,26 @@ const UpdateAvailableDialog = lazy(() =>
const ipc = isElectron() ? window.api.ipc : null; const ipc = isElectron() ? window.api.ipc : null;
export const App = () => { export const App = () => {
return <ThemedApp />;
};
const ThemedApp = () => {
const { mode, theme } = useAppTheme(); const { mode, theme } = useAppTheme();
const language = useLanguage();
const { content, enabled } = useCssSettings(); return (
const { bindings } = useHotkeySettings(); <MantineProvider forceColorScheme={mode} theme={theme}>
const cssRef = useRef<HTMLStyleElement | null>(null); <AppShell />
</MantineProvider>
useSyncSettingsToMain(); );
useCheckForUpdates(); };
const AppShell = memo(function AppShell() {
const [webAudio, setWebAudio] = useState<WebAudio>(); const [webAudio, setWebAudio] = useState<WebAudio>();
useEffect(() => {
if (enabled && content) {
// Yes, CSS is sanitized here as well. Prevent a suer from changing the
// localStorage to bypass sanitizing.
const sanitized = sanitizeCss(content);
if (!cssRef.current) {
cssRef.current = document.createElement('style');
document.body.appendChild(cssRef.current);
}
cssRef.current.textContent = sanitized;
return () => {
cssRef.current!.textContent = '';
};
}
return () => {};
}, [content, enabled]);
const webAudioProvider = useMemo(() => { const webAudioProvider = useMemo(() => {
return { setWebAudio, webAudio }; return { setWebAudio, webAudio };
}, [webAudio]); }, [webAudio]);
useEffect(() => {
if (isElectron()) {
ipc?.send('set-global-shortcuts', bindings);
}
}, [bindings]);
useEffect(() => {
if (language) {
i18n.changeLanguage(language);
}
}, [language]);
useEffect(() => {
if (isElectron()) {
window.api.utils.rendererOpenSettings(() => {
openSettingsModal();
});
return () => {
ipc?.removeAllListeners('renderer-open-settings');
};
}
return undefined;
}, []);
const notificationStyles = useMemo( const notificationStyles = useMemo(
() => ({ () => ({
root: { root: {
@@ -109,7 +68,8 @@ export const App = () => {
); );
return ( return (
<MantineProvider forceColorScheme={mode} theme={theme}> <>
<AppEffects />
<Notifications <Notifications
containerWidth="300px" containerWidth="300px"
position="bottom-center" position="bottom-center"
@@ -126,6 +86,92 @@ export const App = () => {
<ReleaseNotesModal /> <ReleaseNotesModal />
<UpdateAvailableDialog /> <UpdateAvailableDialog />
</Suspense> </Suspense>
</MantineProvider> </>
); );
});
const AppEffects = () => (
<>
<SyncSettingsEffect />
<UpdateCheckEffect />
<CssSettingsEffect />
<GlobalShortcutsEffect />
<LanguageEffect />
<NativeMenuSyncEffect />
</>
);
const SyncSettingsEffect = () => {
useSyncSettingsToMain();
return null;
};
const UpdateCheckEffect = () => {
useCheckForUpdates();
return null;
};
const CssSettingsEffect = () => {
const { content, enabled } = useCssSettings();
const cssRef = useRef<HTMLStyleElement | null>(null);
useEffect(() => {
if (!enabled || !content) {
if (cssRef.current) {
cssRef.current.textContent = '';
}
return;
}
// Yes, CSS is sanitized here as well. Prevent a user from changing the
// localStorage to bypass sanitizing.
const sanitized = sanitizeCss(content);
if (!cssRef.current) {
cssRef.current = document.createElement('style');
document.body.appendChild(cssRef.current);
}
cssRef.current.textContent = sanitized;
return () => {
if (cssRef.current) {
cssRef.current.textContent = '';
}
};
}, [content, enabled]);
return null;
};
const GlobalShortcutsEffect = () => {
const { bindings } = useHotkeySettings();
useEffect(() => {
if (isElectron()) {
ipc?.send('set-global-shortcuts', bindings);
}
}, [bindings]);
return null;
};
const LanguageEffect = () => {
const language = useLanguage();
useEffect(() => {
if (language) {
i18n.changeLanguage(language);
}
}, [language]);
return null;
};
const NativeMenuSyncEffect = () => {
useNativeMenuSync();
return null;
}; };
@@ -36,12 +36,16 @@
min-width: 0; min-width: 0;
} }
.grid-carousel-viewport {
width: 100%;
min-height: 0;
}
.grid { .grid {
display: grid; display: grid;
grid-template-columns: repeat(var(--cards-to-show, 2), minmax(0, 1fr)); grid-template-columns: repeat(var(--cards-to-show, 2), minmax(0, 1fr));
gap: var(--theme-spacing-md); gap: var(--theme-spacing-md);
contain: layout paint; contain: layout paint;
content-visibility: auto;
overflow: hidden; overflow: hidden;
will-change: transform; will-change: transform;
} }
@@ -22,9 +22,9 @@ import { AppRoute } from '/@/renderer/router/routes';
import { useShowRatings } from '/@/renderer/store'; import { useShowRatings } from '/@/renderer/store';
import { import {
formatDateAbsolute, formatDateAbsolute,
formatDateAbsoluteUTC,
formatDateRelative, formatDateRelative,
formatDurationString, formatDurationString,
formatPartialIsoDateUTC,
formatRating, formatRating,
} from '/@/renderer/utils/format'; } from '/@/renderer/utils/format';
import { SEPARATOR_STRING } from '/@/shared/api/utils'; import { SEPARATOR_STRING } from '/@/shared/api/utils';
@@ -1161,12 +1161,10 @@ export const getDataRows = (type?: 'compact' | 'default' | 'poster'): DataRow[]
}, },
{ {
format: (data) => { format: (data) => {
if ('releaseYear' in data && data.releaseYear !== null) { if ('releaseYear' in data && data.releaseYear != null) {
const releaseYear = data.releaseYear; const releaseYear = data.releaseYear;
const originalYear = const originalYear =
'originalYear' in data && data.originalYear !== null 'originalYear' in data && data.originalYear > 0 ? data.originalYear : null;
? data.originalYear
: null;
if (originalYear !== null && originalYear !== releaseYear) { if (originalYear !== null && originalYear !== releaseYear) {
return `${originalYear}${SEPARATOR_STRING}${releaseYear}`; return `${originalYear}${SEPARATOR_STRING}${releaseYear}`;
@@ -1186,10 +1184,10 @@ export const getDataRows = (type?: 'compact' | 'default' | 'poster'): DataRow[]
data.originalDate && data.originalDate &&
data.originalDate !== data.releaseDate data.originalDate !== data.releaseDate
) { ) {
return `${formatDateAbsoluteUTC(data.originalDate)}${SEPARATOR_STRING}${formatDateAbsoluteUTC(data.releaseDate)}`; return `${formatPartialIsoDateUTC(data.originalDate)}${SEPARATOR_STRING}${formatPartialIsoDateUTC(data.releaseDate)}`;
} }
return `${formatDateAbsoluteUTC(data.releaseDate)}`; return `${formatPartialIsoDateUTC(data.releaseDate)}`;
} }
return ''; return '';
}, },
@@ -0,0 +1,30 @@
import { ItemTableListColumnConfig } from '/@/renderer/components/item-list/types';
import { TableColumn } from '/@/shared/types/types';
const LAYOUT_FILL_COLUMN: ItemTableListColumnConfig = {
align: 'start',
autoSize: true,
id: TableColumn.LAYOUT_FILL,
isEnabled: true,
pinned: null,
width: 0,
};
export const appendLayoutFillColumn = (
columns: ItemTableListColumnConfig[],
autoFitColumns: boolean,
): ItemTableListColumnConfig[] => {
if (autoFitColumns || columns.length === 0) {
return columns;
}
const unpinnedEnabled = columns.filter((c) => c.pinned === null && c.isEnabled !== false);
if (unpinnedEnabled.length === 0) {
return columns;
}
if (unpinnedEnabled.some((c) => c.autoSize === true)) {
return columns;
}
return [...columns, LAYOUT_FILL_COLUMN];
};
@@ -40,6 +40,13 @@ export const useDefaultItemListControls = (args?: UseDefaultItemListControlsArgs
const setFavorite = useSetFavorite(); const 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]);
@@ -266,14 +273,14 @@ export const useDefaultItemListControls = (args?: UseDefaultItemListControlsArgs
return; return;
} }
player.addToQueueByData(songsToAdd, playType, item.id); playerRef.current.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) {
player.mediaPlay(queueSong._uniqueId); playerRef.current.mediaPlay(queueSong._uniqueId);
} }
} }
}, },
@@ -316,7 +323,7 @@ export const useDefaultItemListControls = (args?: UseDefaultItemListControlsArgs
return; return;
} }
setFavorite(item._serverId, [item.id], apiItemType, favorite); setFavoriteRef.current(item._serverId, [item.id], apiItemType, favorite);
}, },
onMore: ({ event, internalState, item, itemType }: DefaultItemControlProps) => { onMore: ({ event, internalState, item, itemType }: DefaultItemControlProps) => {
@@ -394,7 +401,7 @@ export const useDefaultItemListControls = (args?: UseDefaultItemListControlsArgs
return; return;
} }
player.addToQueueByFetch(item._serverId, [item.id], itemType, playType); playerRef.current.addToQueueByFetch(item._serverId, [item.id], itemType, playType);
}, },
onRating: ({ onRating: ({
@@ -420,20 +427,12 @@ export const useDefaultItemListControls = (args?: UseDefaultItemListControlsArgs
newRating = 0; newRating = 0;
} }
setRating(item._serverId, [item.id], apiItemType, newRating); setRatingRef.current(item._serverId, [item.id], apiItemType, newRating);
}, },
...overrides, ...overrides,
}; };
}, [ }, [enableMultiSelect, overrides, onColumnReordered, onColumnResized]);
enableMultiSelect,
overrides,
onColumnReordered,
onColumnResized,
player,
setFavorite,
setRating,
]);
return controls; return controls;
}; };
@@ -349,9 +349,12 @@ 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) => refreshMutation.mutateAsync(force), async (force?: boolean) => refreshMutationRef.current.mutateAsync(force),
[refreshMutation], [],
); );
const updateItems = useCallback( const updateItems = useCallback(
@@ -383,7 +386,7 @@ export const useItemListInfiniteLoader = ({
return; return;
} }
refreshMutation.mutate(true); refreshMutationRef.current.mutate(true);
}; };
eventEmitter.on('ITEM_LIST_REFRESH', handleRefresh); eventEmitter.on('ITEM_LIST_REFRESH', handleRefresh);
@@ -391,7 +394,7 @@ export const useItemListInfiniteLoader = ({
return () => { return () => {
eventEmitter.off('ITEM_LIST_REFRESH', handleRefresh); eventEmitter.off('ITEM_LIST_REFRESH', handleRefresh);
}; };
}, [eventKey, refreshMutation]); }, [eventKey]);
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 } from 'react'; import { useCallback, useEffect, useMemo, useRef } 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,6 +115,9 @@ 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(
@@ -153,7 +156,7 @@ export const useItemListPaginatedLoader = ({
return; return;
} }
refreshMutation.mutate(true); refreshMutationRef.current.mutate(true);
}; };
const handleFavorite = (payload: UserFavoriteEventPayload) => { const handleFavorite = (payload: UserFavoriteEventPayload) => {
@@ -220,7 +223,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, refreshMutation, serverId, updateItems]); }, [data, eventKey, itemType, serverId, updateItems]);
return { data: data?.items || [], pageCount, totalItemCount }; return { data: data?.items || [], pageCount, totalItemCount };
}; };
@@ -67,6 +67,7 @@ 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',
@@ -1,6 +1,21 @@
import { ItemDetailListCellProps } from './types'; import { ItemDetailListCellProps } from './types';
import { formatDateAbsoluteUTC } from '/@/renderer/utils/format'; import { formatPartialIsoDateUTC } from '/@/renderer/utils/format';
import { SEPARATOR_STRING } from '/@/shared/api/utils';
export const ReleaseDateColumn = ({ song }: ItemDetailListCellProps) => export const ReleaseDateColumn = ({ song }: ItemDetailListCellProps) => {
song.releaseDate ? formatDateAbsoluteUTC(song.releaseDate) : <>&nbsp;</>; const row = song as typeof song & { originalDate?: null | string };
const releaseDate = row.releaseDate;
if (!releaseDate) {
return <>&nbsp;</>;
}
const originalDate =
row.originalDate && row.originalDate !== releaseDate ? row.originalDate : null;
if (originalDate) {
return `${formatPartialIsoDateUTC(originalDate)}${SEPARATOR_STRING}${formatPartialIsoDateUTC(releaseDate)}`;
}
return formatPartialIsoDateUTC(releaseDate);
};
@@ -179,6 +179,14 @@
opacity: 1; 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;
} }
@@ -67,7 +67,7 @@ import { songsQueries } from '/@/renderer/features/songs/api/songs-api';
import { useDragDrop } from '/@/renderer/hooks/use-drag-drop'; import { useDragDrop } from '/@/renderer/hooks/use-drag-drop';
import { AppRoute } from '/@/renderer/router/routes'; import { AppRoute } from '/@/renderer/router/routes';
import { useSettingsStore, useShowRatings } from '/@/renderer/store'; import { useSettingsStore, useShowRatings } from '/@/renderer/store';
import { formatDateAbsoluteUTC, formatDurationString } from '/@/renderer/utils'; import { formatDurationString, formatPartialIsoDateUTC } from '/@/renderer/utils';
import { SEPARATOR_STRING } from '/@/shared/api/utils'; import { SEPARATOR_STRING } from '/@/shared/api/utils';
import { ExplicitIndicator } from '/@/shared/components/explicit-indicator/explicit-indicator'; import { ExplicitIndicator } from '/@/shared/components/explicit-indicator/explicit-indicator';
import { Skeleton } from '/@/shared/components/skeleton/skeleton'; import { Skeleton } from '/@/shared/components/skeleton/skeleton';
@@ -489,9 +489,9 @@ const MetadataSection = memo(
let releaseStr = ''; let releaseStr = '';
if (item.releaseDate) { if (item.releaseDate) {
if (item.originalDate && item.originalDate !== item.releaseDate) { if (item.originalDate && item.originalDate !== item.releaseDate) {
releaseStr = `${formatDateAbsoluteUTC(item.originalDate)}${SEPARATOR_STRING}${formatDateAbsoluteUTC(item.releaseDate)}`; releaseStr = `${formatPartialIsoDateUTC(item.originalDate)}${SEPARATOR_STRING}${formatPartialIsoDateUTC(item.releaseDate)}`;
} else { } else {
releaseStr = formatDateAbsoluteUTC(item.releaseDate); releaseStr = formatPartialIsoDateUTC(item.releaseDate);
} }
} else if (item.releaseYear != null) { } else if (item.releaseYear != null) {
releaseStr = String(item.releaseYear); releaseStr = String(item.releaseYear);
@@ -911,8 +911,7 @@ 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 = const showResizeHandle = enableColumnResize && !isFixedColumn;
enableColumnResize && !isFixedColumn && !col?.autoSize && onColumnResized;
useEffect(() => { useEffect(() => {
if (!containerRef.current || !onColumnReordered) { if (!containerRef.current || !onColumnReordered) {
@@ -1026,6 +1025,7 @@ 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,6 +1040,7 @@ 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';
@@ -1047,6 +1048,7 @@ interface DetailListColumnResizeHandleProps {
const DetailListColumnResizeHandle = ({ const DetailListColumnResizeHandle = ({
columnId, columnId,
disabled = false,
initialWidth, initialWidth,
onResize, onResize,
side, side,
@@ -1091,6 +1093,11 @@ 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);
@@ -1103,6 +1110,7 @@ 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,6 +4,7 @@
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;
@@ -20,7 +20,8 @@ export const createColumnCellComponent = (
prevProps.columnIndex === nextProps.columnIndex && prevProps.columnIndex === nextProps.columnIndex &&
prevProps.data === nextProps.data && prevProps.data === nextProps.data &&
prevProps.style === nextProps.style && prevProps.style === nextProps.style &&
prevProps.columns === nextProps.columns prevProps.columns === nextProps.columns &&
prevProps.playlistId === nextProps.playlistId
); );
}, },
); );
@@ -8,49 +8,25 @@ import {
} from '/@/renderer/components/item-list/item-table-list/item-table-list-column'; } from '/@/renderer/components/item-list/item-table-list/item-table-list-column';
import { import {
formatDateAbsolute, formatDateAbsolute,
formatDateAbsoluteUTC,
formatDateRelative, formatDateRelative,
formatHrDateTime, formatPartialIsoDateUTC,
} from '/@/renderer/utils/format'; } from '/@/renderer/utils/format';
import { SEPARATOR_STRING } from '/@/shared/api/utils'; import { SEPARATOR_STRING } from '/@/shared/api/utils';
import { Stack } from '/@/shared/components/stack/stack';
import { Text } from '/@/shared/components/text/text';
import { Tooltip } from '/@/shared/components/tooltip/tooltip';
import { TableColumn } from '/@/shared/types/types'; import { TableColumn } from '/@/shared/types/types';
const getDateTooltipLabel = (utcString: string) => {
return (
<Stack gap="xs" justify="center">
<Text size="md" ta="center">
{formatHrDateTime(utcString)}
</Text>
<Text isMuted size="sm" ta="center">
{utcString}
</Text>
</Stack>
);
};
const DateColumnBase = (props: ItemTableListInnerColumn) => { const DateColumnBase = (props: ItemTableListInnerColumn) => {
const rowItem = props.getRowItem?.(props.rowIndex) ?? (props.data as any[])[props.rowIndex]; const rowItem = props.getRowItem?.(props.rowIndex) ?? (props.data as any[])[props.rowIndex];
const row: string | undefined = (rowItem as any)?.[props.columns[props.columnIndex].id]; const row: string | undefined = (rowItem as any)?.[props.columns[props.columnIndex].id];
const { formattedDate, tooltipLabel } = useMemo(() => { const formattedAbsolute = useMemo(
if (typeof row === 'string' && row) { () => (typeof row === 'string' && row ? formatDateAbsolute(row) : null),
return { [row],
formattedDate: formatDateAbsolute(row), );
tooltipLabel: getDateTooltipLabel(row),
};
}
return { formattedDate: null, tooltipLabel: null };
}, [row]);
if (typeof row === 'string' && row) { if (formattedAbsolute) {
return ( return (
<TableColumnTextContainer {...props}> <TableColumnTextContainer {...props}>
<Tooltip label={tooltipLabel} multiline={false}> <span>{formattedAbsolute}</span>
<span>{formattedDate}</span>
</Tooltip>
</TableColumnTextContainer> </TableColumnTextContainer>
); );
} }
@@ -79,44 +55,37 @@ const AbsoluteDateColumnBase = (props: ItemTableListInnerColumn) => {
: null; : null;
if (originalDate) { if (originalDate) {
const formattedOriginalDate = formatDateAbsoluteUTC(originalDate); const formattedOriginalDate = formatPartialIsoDateUTC(originalDate);
const formattedReleaseDate = formatDateAbsoluteUTC(releaseDate); const formattedReleaseDate = formatPartialIsoDateUTC(releaseDate);
const displayText = `${formattedOriginalDate}${SEPARATOR_STRING}${formattedReleaseDate}`; return `${formattedOriginalDate}${SEPARATOR_STRING}${formattedReleaseDate}`;
return {
displayText,
tooltipLabel: getDateTooltipLabel(releaseDate),
};
} }
if (typeof releaseDate === 'string' && releaseDate) { if (typeof releaseDate === 'string' && releaseDate) {
return { return formatPartialIsoDateUTC(releaseDate);
displayText: formatDateAbsoluteUTC(releaseDate),
tooltipLabel: getDateTooltipLabel(releaseDate),
};
} }
} }
} }
return null; return null;
}, [props.type, rowItem]); }, [props.type, rowItem]);
const { formattedDate, tooltipLabel } = useMemo(() => { const formattedIsoFallback = useMemo(
if (typeof row === 'string' && row) { () => (typeof row === 'string' && row ? formatPartialIsoDateUTC(row) : null),
return { [row],
formattedDate: formatDateAbsoluteUTC(row), );
tooltipLabel: getDateTooltipLabel(row),
};
}
return { formattedDate: null, tooltipLabel: null };
}, [row]);
if (props.type === TableColumn.RELEASE_DATE) { if (props.type === TableColumn.RELEASE_DATE) {
if (releaseDateContent) { if (releaseDateContent) {
return ( return (
<TableColumnTextContainer {...props}> <TableColumnTextContainer {...props}>
<Tooltip label={releaseDateContent.tooltipLabel} multiline={false}> <span>{releaseDateContent}</span>
<span>{releaseDateContent.displayText}</span> </TableColumnTextContainer>
</Tooltip> );
}
if (formattedIsoFallback) {
return (
<TableColumnTextContainer {...props}>
<span>{formattedIsoFallback}</span>
</TableColumnTextContainer> </TableColumnTextContainer>
); );
} }
@@ -128,20 +97,6 @@ const AbsoluteDateColumnBase = (props: ItemTableListInnerColumn) => {
return <ColumnSkeletonFixed {...props} />; return <ColumnSkeletonFixed {...props} />;
} }
if (typeof row === 'string' && row) {
return (
<TableColumnTextContainer {...props}>
<Tooltip label={tooltipLabel} multiline={false}>
<span>{formattedDate}</span>
</Tooltip>
</TableColumnTextContainer>
);
}
if (row === null) {
return <ColumnNullFallback {...props} />;
}
return <ColumnSkeletonFixed {...props} />; return <ColumnSkeletonFixed {...props} />;
}; };
@@ -151,22 +106,15 @@ const RelativeDateColumnBase = (props: ItemTableListInnerColumn) => {
const rowItem = props.getRowItem?.(props.rowIndex) ?? (props.data as any[])[props.rowIndex]; const rowItem = props.getRowItem?.(props.rowIndex) ?? (props.data as any[])[props.rowIndex];
const row: string | undefined = (rowItem as any)?.[props.columns[props.columnIndex].id]; const row: string | undefined = (rowItem as any)?.[props.columns[props.columnIndex].id];
const { formattedDate, tooltipLabel } = useMemo(() => { const formattedRelative = useMemo(() => {
if (typeof row === 'string') { if (typeof row !== 'string') return null;
return { return formatDateRelative(row);
formattedDate: formatDateRelative(row),
tooltipLabel: getDateTooltipLabel(row),
};
}
return { formattedDate: null, tooltipLabel: null };
}, [row]); }, [row]);
if (typeof row === 'string') { if (formattedRelative !== null) {
return ( return (
<TableColumnTextContainer {...props}> <TableColumnTextContainer {...props}>
<Tooltip label={tooltipLabel} multiline={false}> <span>{formattedRelative}</span>
<span>{formattedDate}</span>
</Tooltip>
</TableColumnTextContainer> </TableColumnTextContainer>
); );
} }
@@ -1,4 +1,5 @@
import clsx from 'clsx'; import clsx from 'clsx';
import { useMemo } from 'react';
import { Link } from 'react-router'; import { Link } from 'react-router';
import styles from './title-column.module.css'; import styles from './title-column.module.css';
@@ -35,8 +36,12 @@ function DefaultTitleColumn(props: ItemTableListInnerColumn) {
const rowItem = props.getRowItem?.(props.rowIndex) ?? (props.data as any[])[props.rowIndex]; const rowItem = props.getRowItem?.(props.rowIndex) ?? (props.data as any[])[props.rowIndex];
const row: string | undefined = rowItem?.[props.columns[props.columnIndex].id]; const row: string | undefined = rowItem?.[props.columns[props.columnIndex].id];
const path = useMemo(() => {
if (typeof row !== 'string' || !rowItem || !(rowItem as any).id) return undefined;
return getTitlePath(props.itemType, (rowItem as any).id as string);
}, [props.itemType, row, rowItem]);
if (typeof row === 'string') { if (typeof row === 'string') {
const path = getTitlePath(props.itemType, (rowItem as any).id as string);
const item = rowItem as any; const item = rowItem as any;
const titleLinkProps = path const titleLinkProps = path
@@ -80,8 +85,12 @@ function QueueSongTitleColumn(props: ItemTableListInnerColumn) {
const song = rowItem as QueueSong; const song = rowItem as QueueSong;
const isActive = useIsActiveRow(song?.id, song?._uniqueId); const isActive = useIsActiveRow(song?.id, song?._uniqueId);
const path = useMemo(() => {
if (typeof row !== 'string' || !rowItem || !(rowItem as any).id) return undefined;
return getTitlePath(props.itemType, (rowItem as any).id as string);
}, [props.itemType, row, rowItem]);
if (typeof row === 'string') { if (typeof row === 'string') {
const path = getTitlePath(props.itemType, (rowItem as any).id as string);
const item = rowItem as any; const item = rowItem as any;
const titleLinkProps = path const titleLinkProps = path
@@ -13,10 +13,10 @@ const YearColumnBase = (props: ItemTableListInnerColumn) => {
const item = rowItem as any; const item = rowItem as any;
const yearDisplay = useMemo(() => { const yearDisplay = useMemo(() => {
if (item && 'releaseYear' in item && item.releaseYear !== null) { if (item && 'releaseYear' in item && item.releaseYear != null) {
const releaseYear = item.releaseYear; const releaseYear = item.releaseYear;
const originalYear = const originalYear =
'originalYear' in item && item.originalYear !== null ? item.originalYear : null; 'originalYear' in item && item.originalYear > 0 ? item.originalYear : null;
if (originalYear !== null && originalYear !== releaseYear) { if (originalYear !== null && originalYear !== releaseYear) {
return `${originalYear}${SEPARATOR_STRING}${releaseYear}`; return `${originalYear}${SEPARATOR_STRING}${releaseYear}`;
@@ -34,256 +34,268 @@ export const useItemDragDropState = <TElement extends HTMLElement = HTMLDivEleme
}: UseItemDragDropStateProps): DragDropState<TElement> => { }: UseItemDragDropStateProps): DragDropState<TElement> => {
const shouldEnableDrag = enableDrag && isDataRow && !!item; const shouldEnableDrag = enableDrag && isDataRow && !!item;
const needsDropRegistration =
shouldEnableDrag &&
(itemType === LibraryItem.QUEUE_SONG || itemType === LibraryItem.PLAYLIST_SONG);
const { const {
isDraggedOver, isDraggedOver,
isDragging: isDraggingLocal, isDragging: isDraggingLocal,
ref: dragRef, ref: dragRef,
} = useDragDrop<TElement>({ } = useDragDrop<TElement>({
drag: { drag: shouldEnableDrag
getId: () => { ? {
if (!item || !isDataRow) { getId: () => {
return []; if (!item || !isDataRow) {
} return [];
}
const draggedItems = getDraggedItems(item as any, internalState); const draggedItems = getDraggedItems(item as any, internalState);
return draggedItems.map((draggedItem) => draggedItem.id); return draggedItems.map((draggedItem) => draggedItem.id);
}, },
getItem: () => { getItem: () => {
if (!item || !isDataRow) { if (!item || !isDataRow) {
return []; return [];
} }
const draggedItems = getDraggedItems(item as any, internalState); const draggedItems = getDraggedItems(item as any, internalState);
return draggedItems; return draggedItems;
}, },
itemType, itemType,
onDragStart: () => { onDragStart: () => {
if (!item || !isDataRow) { if (!item || !isDataRow) {
return; return;
} }
const draggedItems = getDraggedItems(item as any, internalState); const draggedItems = getDraggedItems(item as any, internalState);
if (internalState) { if (internalState) {
internalState.setDragging(draggedItems); internalState.setDragging(draggedItems);
} }
}, },
onDrop: () => { onDrop: () => {
if (internalState) { if (internalState) {
internalState.setDragging([]); internalState.setDragging([]);
} }
}, },
operation: operation:
itemType === LibraryItem.QUEUE_SONG itemType === LibraryItem.QUEUE_SONG
? [DragOperation.REORDER, DragOperation.ADD] ? [DragOperation.REORDER, DragOperation.ADD]
: itemType === LibraryItem.PLAYLIST_SONG : itemType === LibraryItem.PLAYLIST_SONG
? [DragOperation.REORDER, DragOperation.ADD] ? [DragOperation.REORDER, DragOperation.ADD]
: [DragOperation.ADD], : [DragOperation.ADD],
target: DragTargetMap[itemType] || DragTarget.GENERIC, target: DragTargetMap[itemType] || DragTarget.GENERIC,
}, }
drop: { : undefined,
canDrop: (args) => { drop: needsDropRegistration
if (args.source.type === DragTarget.TABLE_COLUMN) { ? {
return false; canDrop: (args) => {
} if (args.source.type === DragTarget.TABLE_COLUMN) {
return false;
}
// Allow drops for QUEUE_SONG (queue reordering) // Allow drops for QUEUE_SONG (queue reordering)
if (itemType === LibraryItem.QUEUE_SONG) { if (itemType === LibraryItem.QUEUE_SONG) {
return true; return true;
} }
// Allow drops for PLAYLIST_SONG (playlist reordering) // Allow drops for PLAYLIST_SONG (playlist reordering)
// Only allow drops when drag is started from the reorder handle // Only allow drops when drag is started from the reorder handle
if ( if (
itemType === LibraryItem.PLAYLIST_SONG && itemType === LibraryItem.PLAYLIST_SONG &&
args.source.itemType === LibraryItem.PLAYLIST_SONG && args.source.itemType === LibraryItem.PLAYLIST_SONG &&
args.source.metadata?.fromReorderHandle === true args.source.metadata?.fromReorderHandle === true
) { ) {
return true; return true;
} }
return false; return false;
}, },
getData: () => { getData: () => {
return { return {
id: [(item as unknown as { id: string }).id], id: [(item as unknown as { id: string }).id],
item: [item as unknown as unknown[]], item: [item as unknown as unknown[]],
itemType, itemType,
type: DragTargetMap[itemType] || DragTarget.GENERIC, type: DragTargetMap[itemType] || DragTarget.GENERIC,
}; };
}, },
onDrag: () => { onDrag: () => {
return; return;
}, },
onDragLeave: () => { onDragLeave: () => {
return; return;
}, },
onDrop: (args) => { onDrop: (args) => {
if (args.self.type === DragTarget.QUEUE_SONG) { if (args.self.type === DragTarget.QUEUE_SONG) {
const sourceServerId = ( const sourceServerId = (
args.source.item?.[0] as unknown as { _serverId: string } args.source.item?.[0] as unknown as { _serverId: string }
)._serverId; )._serverId;
const sourceItemType = args.source.itemType as LibraryItem; const sourceItemType = args.source.itemType as LibraryItem;
const droppedOnUniqueId = ( const droppedOnUniqueId = (
args.self.item?.[0] as unknown as { _uniqueId: string } args.self.item?.[0] as unknown as { _uniqueId: string }
)._uniqueId; )._uniqueId;
switch (args.source.type) { switch (args.source.type) {
case DragTarget.ALBUM: { case DragTarget.ALBUM: {
playerContext.addToQueueByFetch( playerContext.addToQueueByFetch(
sourceServerId, sourceServerId,
args.source.id, args.source.id,
sourceItemType, sourceItemType,
{ edge: args.edge, uniqueId: droppedOnUniqueId }, { edge: args.edge, uniqueId: droppedOnUniqueId },
); );
break; break;
} }
case DragTarget.ALBUM_ARTIST: { case DragTarget.ALBUM_ARTIST: {
playerContext.addToQueueByFetch( playerContext.addToQueueByFetch(
sourceServerId, sourceServerId,
args.source.id, args.source.id,
sourceItemType, sourceItemType,
{ edge: args.edge, uniqueId: droppedOnUniqueId }, { edge: args.edge, uniqueId: droppedOnUniqueId },
); );
break; break;
} }
case DragTarget.ARTIST: { case DragTarget.ARTIST: {
playerContext.addToQueueByFetch( playerContext.addToQueueByFetch(
sourceServerId, sourceServerId,
args.source.id, args.source.id,
sourceItemType, sourceItemType,
{ edge: args.edge, uniqueId: droppedOnUniqueId }, { edge: args.edge, uniqueId: droppedOnUniqueId },
); );
break; break;
} }
case DragTarget.FOLDER: { case DragTarget.FOLDER: {
const items = args.source.item; const items = args.source.item;
const { folders, songs } = (items || []).reduce<{ const { folders, songs } = (items || []).reduce<{
folders: Folder[]; folders: Folder[];
songs: Song[]; songs: Song[];
}>( }>(
(acc, item) => { (acc, item) => {
if ((item as unknown as Song)._itemType === LibraryItem.SONG) { if (
acc.songs.push(item as unknown as Song); (item as unknown as Song)._itemType ===
} else if ( LibraryItem.SONG
(item as unknown as Folder)._itemType === LibraryItem.FOLDER ) {
) { acc.songs.push(item as unknown as Song);
acc.folders.push(item as unknown as Folder); } else if (
} (item as unknown as Folder)._itemType ===
return acc; LibraryItem.FOLDER
}, ) {
{ folders: [], songs: [] }, acc.folders.push(item as unknown as Folder);
); }
return acc;
},
{ folders: [], songs: [] },
);
const folderIds = folders.map((folder) => folder.id); const folderIds = folders.map((folder) => folder.id);
// Handle folders: fetch and add to queue // Handle folders: fetch and add to queue
if (folderIds.length > 0) { if (folderIds.length > 0) {
playerContext.addToQueueByFetch( playerContext.addToQueueByFetch(
sourceServerId, sourceServerId,
folderIds, folderIds,
LibraryItem.FOLDER, LibraryItem.FOLDER,
{ edge: args.edge, uniqueId: droppedOnUniqueId }, { edge: args.edge, uniqueId: droppedOnUniqueId },
); );
} }
// Handle songs: add directly to queue // Handle songs: add directly to queue
if (songs.length > 0) { if (songs.length > 0) {
playerContext.addToQueueByData(songs, { playerContext.addToQueueByData(songs, {
edge: args.edge, edge: args.edge,
uniqueId: droppedOnUniqueId, uniqueId: droppedOnUniqueId,
}); });
} }
break; break;
} }
case DragTarget.GENRE: { case DragTarget.GENRE: {
playerContext.addToQueueByFetch( playerContext.addToQueueByFetch(
sourceServerId, sourceServerId,
args.source.id, args.source.id,
sourceItemType, sourceItemType,
{ edge: args.edge, uniqueId: droppedOnUniqueId }, { edge: args.edge, uniqueId: droppedOnUniqueId },
); );
break; break;
} }
case DragTarget.PLAYLIST: { case DragTarget.PLAYLIST: {
playerContext.addToQueueByFetch( playerContext.addToQueueByFetch(
sourceServerId, sourceServerId,
args.source.id, args.source.id,
sourceItemType, sourceItemType,
{ edge: args.edge, uniqueId: droppedOnUniqueId }, { edge: args.edge, uniqueId: droppedOnUniqueId },
); );
break; break;
} }
case DragTarget.QUEUE_SONG: { case DragTarget.QUEUE_SONG: {
const sourceItems = (args.source.item || []) as QueueSong[]; const sourceItems = (args.source.item || []) as QueueSong[];
if ( if (
sourceItems.length > 0 && sourceItems.length > 0 &&
args.edge && args.edge &&
(args.edge === 'top' || args.edge === 'bottom') (args.edge === 'top' || args.edge === 'bottom')
) { ) {
playerContext.moveSelectedTo( playerContext.moveSelectedTo(
sourceItems, sourceItems,
args.edge, args.edge,
droppedOnUniqueId, droppedOnUniqueId,
); );
} }
break; break;
} }
case DragTarget.SONG: { case DragTarget.SONG: {
const sourceItems = (args.source.item || []) as Song[]; const sourceItems = (args.source.item || []) as Song[];
if (sourceItems.length > 0) { if (sourceItems.length > 0) {
playerContext.addToQueueByData(sourceItems, { playerContext.addToQueueByData(sourceItems, {
edge: args.edge, edge: args.edge,
uniqueId: droppedOnUniqueId, uniqueId: droppedOnUniqueId,
}); });
} }
break; break;
} }
default: { default: {
break; break;
} }
} }
} }
// Handle PLAYLIST_SONG reordering // Handle PLAYLIST_SONG reordering
// Only allow drops when drag is started from the reorder handle // Only allow drops when drag is started from the reorder handle
if ( if (
args.self.itemType === LibraryItem.PLAYLIST_SONG && args.self.itemType === LibraryItem.PLAYLIST_SONG &&
args.source.itemType === LibraryItem.PLAYLIST_SONG && args.source.itemType === LibraryItem.PLAYLIST_SONG &&
args.source.metadata?.fromReorderHandle === true && args.source.metadata?.fromReorderHandle === true &&
playlistId playlistId
) { ) {
const sourceItems = (args.source.item || []) as any[]; const sourceItems = (args.source.item || []) as any[];
const targetItem = item as any; const targetItem = item as any;
if ( if (
sourceItems.length > 0 && sourceItems.length > 0 &&
args.edge && args.edge &&
(args.edge === 'top' || args.edge === 'bottom') && (args.edge === 'top' || args.edge === 'bottom') &&
targetItem targetItem
) { ) {
// Emit event to reorder playlist songs // Emit event to reorder playlist songs
eventEmitter.emit('PLAYLIST_REORDER', { eventEmitter.emit('PLAYLIST_REORDER', {
edge: args.edge, edge: args.edge,
playlistId, playlistId,
sourceIds: args.source.id, sourceIds: args.source.id,
targetId: targetItem.id, targetId: targetItem.id,
}); });
} }
} }
if (internalState) { if (internalState) {
internalState.setDragging([]); internalState.setDragging([]);
} }
return; return;
}, },
}, }
: undefined,
isEnabled: shouldEnableDrag, isEnabled: shouldEnableDrag,
}); });
@@ -1,3 +1,5 @@
import type { TableScrollShadowStore } from '/@/renderer/components/item-list/item-table-list/table-scroll-shadow-store';
import { autoScrollForElements } from '@atlaskit/pragmatic-drag-and-drop-auto-scroll/element'; import { 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';
@@ -18,9 +20,7 @@ export const useTablePaneSync = ({
pinnedRowRef, pinnedRowRef,
rowRef, rowRef,
scrollContainerRef, scrollContainerRef,
setShowLeftShadow, scrollShadowStore,
setShowRightShadow,
setShowTopShadow,
}: { }: {
enableDrag: boolean | undefined; enableDrag: boolean | undefined;
enableDragScroll: boolean | undefined; enableDragScroll: boolean | undefined;
@@ -36,9 +36,7 @@ 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>;
setShowLeftShadow: (v: boolean) => void; scrollShadowStore: TableScrollShadowStore;
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({
@@ -471,8 +469,10 @@ export const useTablePaneSync = ({
if (!row) { if (!row) {
const timeout = setTimeout(() => { const timeout = setTimeout(() => {
setShowLeftShadow(false); scrollShadowStore.setSnapshot({
setShowRightShadow(false); showLeftShadow: false,
showRightShadow: false,
});
}, 0); }, 0);
return () => clearTimeout(timeout); return () => clearTimeout(timeout);
@@ -482,8 +482,10 @@ export const useTablePaneSync = ({
const scrollLeft = row.scrollLeft; const scrollLeft = row.scrollLeft;
const maxScrollLeft = row.scrollWidth - row.clientWidth; const maxScrollLeft = row.scrollWidth - row.clientWidth;
setShowLeftShadow(pinnedLeftColumnCount > 0 && scrollLeft > 0); scrollShadowStore.setSnapshot({
setShowRightShadow(pinnedRightColumnCount > 0 && scrollLeft < maxScrollLeft); showLeftShadow: pinnedLeftColumnCount > 0 && scrollLeft > 0,
showRightShadow: pinnedRightColumnCount > 0 && scrollLeft < maxScrollLeft,
});
}, 50); }, 50);
checkScrollPosition(); checkScrollPosition();
@@ -494,13 +496,7 @@ 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(() => {
@@ -509,7 +505,7 @@ export const useTablePaneSync = ({
if (!row || !enableHeader) { if (!row || !enableHeader) {
const timeout = setTimeout(() => { const timeout = setTimeout(() => {
setShowTopShadow(false); scrollShadowStore.setSnapshot({ showTopShadow: false });
}, 0); }, 0);
return () => clearTimeout(timeout); return () => clearTimeout(timeout);
@@ -519,7 +515,7 @@ export const useTablePaneSync = ({
const checkScrollPosition = throttle(() => { const checkScrollPosition = throttle(() => {
const currentScrollTop = scrollElement.scrollTop; const currentScrollTop = scrollElement.scrollTop;
setShowTopShadow(currentScrollTop > 0); scrollShadowStore.setSnapshot({ showTopShadow: currentScrollTop > 0 });
}, 50); }, 50);
checkScrollPosition(); checkScrollPosition();
@@ -530,5 +526,5 @@ export const useTablePaneSync = ({
checkScrollPosition.cancel(); checkScrollPosition.cancel();
scrollElement.removeEventListener('scroll', checkScrollPosition); scrollElement.removeEventListener('scroll', checkScrollPosition);
}; };
}, [enableHeader, pinnedRightColumnCount, pinnedRightColumnRef, rowRef, setShowTopShadow]); }, [enableHeader, pinnedRightColumnCount, pinnedRightColumnRef, rowRef, scrollShadowStore]);
}; };
@@ -366,6 +366,14 @@
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;
} }
@@ -19,7 +19,6 @@ import React, {
useRef, useRef,
useState, useState,
} from 'react'; } from 'react';
import { useParams } from 'react-router';
import { CellComponentProps } from 'react-window-v2'; import { CellComponentProps } from 'react-window-v2';
import styles from './item-table-list-column.module.css'; import styles from './item-table-list-column.module.css';
@@ -58,6 +57,7 @@ import { TitleCombinedColumn } from '/@/renderer/components/item-list/item-table
import { YearColumn } from '/@/renderer/components/item-list/item-table-list/columns/year-column'; import { 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';
@@ -82,7 +82,6 @@ export interface ItemTableListInnerColumn extends ItemTableListColumn {
} }
const ItemTableListColumnBase = (props: ItemTableListColumn) => { const ItemTableListColumnBase = (props: ItemTableListColumn) => {
const { playlistId } = useParams() as { playlistId?: string };
const type = props.columnType ?? (props.columns[props.columnIndex].id as TableColumn); const type = props.columnType ?? (props.columns[props.columnIndex].id as TableColumn);
const isHeaderEnabled = !!props.enableHeader; const isHeaderEnabled = !!props.enableHeader;
@@ -135,7 +134,7 @@ const ItemTableListColumnBase = (props: ItemTableListColumn) => {
item, item,
itemType: props.itemType, itemType: props.itemType,
playerContext: props.playerContext, playerContext: props.playerContext,
playlistId, playlistId: props.playlistId,
}); });
const controls = props.controls; const controls = props.controls;
@@ -195,6 +194,14 @@ const ItemTableListColumnBase = (props: ItemTableListColumn) => {
); );
} }
if (type === TableColumn.LAYOUT_FILL) {
return (
<TableColumnContainer {...props} {...dragProps} controls={controls} type={type}>
{null}
</TableColumnContainer>
);
}
if (itemType !== LibraryItem.FOLDER) { if (itemType !== LibraryItem.FOLDER) {
switch (type) { switch (type) {
case TableColumn.ACTIONS: case TableColumn.ACTIONS:
@@ -362,6 +369,7 @@ export const ItemTableListColumn = memo(ItemTableListColumnBase, (prevProps, nex
prevProps.enableColumnResize === nextProps.enableColumnResize && prevProps.enableColumnResize === nextProps.enableColumnResize &&
prevProps.enableColumnReorder === nextProps.enableColumnReorder && prevProps.enableColumnReorder === nextProps.enableColumnReorder &&
prevProps.cellPadding === nextProps.cellPadding && prevProps.cellPadding === nextProps.cellPadding &&
prevProps.playlistId === nextProps.playlistId &&
prevItem === nextItem prevItem === nextItem
); );
}); });
@@ -708,6 +716,8 @@ 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';
@@ -715,6 +725,8 @@ interface ColumnResizeHandleProps {
const ColumnResizeHandle = ({ const ColumnResizeHandle = ({
columnId, columnId,
columnIndex,
disabled = false,
initialWidth, initialWidth,
onResize, onResize,
side, side,
@@ -724,6 +736,17 @@ 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(() => {
@@ -739,6 +762,7 @@ 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 = () => {
@@ -747,7 +771,8 @@ 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);
onResize(columnId, finalWidthRef.current); onResizeRef.current(columnId, finalWidthRef.current);
columnResizeLiveRef.current?.clearColumnResizePreview();
}; };
document.addEventListener('mousemove', handleMouseMove); document.addEventListener('mousemove', handleMouseMove);
@@ -756,10 +781,18 @@ 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, onResize]); }, [isDragging, columnId, columnIndex]);
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);
@@ -772,6 +805,7 @@ 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',
@@ -803,7 +837,11 @@ export const TableColumnHeaderContainer = (
const [isDraggedOver, setIsDraggedOver] = useState<Edge | null>(null); const [isDraggedOver, setIsDraggedOver] = useState<Edge | null>(null);
useEffect(() => { useEffect(() => {
if (!containerRef.current || !props.enableColumnReorder) { if (
!containerRef.current ||
!props.enableColumnReorder ||
props.type === TableColumn.LAYOUT_FILL
) {
return; return;
} }
@@ -918,9 +956,11 @@ export const TableColumnHeaderContainer = (
> >
{columnLabelMap[props.type]} {columnLabelMap[props.type]}
</Text> </Text>
{!columnConfig.autoSize && props.enableColumnResize && ( {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"
@@ -983,6 +1023,7 @@ 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,31 +1,59 @@
import React, { createContext, useContext, useEffect, useMemo, useRef } from 'react'; import type { ReactElement } from 'react';
import React, {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import { useSyncExternalStore } from 'react'; import { useSyncExternalStore } from 'react';
import type { TableItemProps } from './item-table-list';
import { ItemListStateActions } from '/@/renderer/components/item-list/helpers/item-list-state'; import { ItemListStateActions } from '/@/renderer/components/item-list/helpers/item-list-state';
import { ItemControls, ItemTableListColumnConfig } from '/@/renderer/components/item-list/types'; import { ItemControls, ItemTableListColumnConfig } from '/@/renderer/components/item-list/types';
import { PlayerContext } from '/@/renderer/features/player/context/player-context'; import { PlayerContext } from '/@/renderer/features/player/context/player-context';
import { LibraryItem } from '/@/shared/types/domain-types'; import { LibraryItem } from '/@/shared/types/domain-types';
/**
* Stage A/B: Provide table-scoped config + external stores so churny values can update
* without forcing `cellProps` identity changes (and therefore without rerendering every visible cell).
*/
export type ItemTableListConfig = { export type ItemTableListConfig = {
cellPadding: 'lg' | 'md' | 'sm' | 'xl' | 'xs'; cellPadding: 'lg' | 'md' | 'sm' | 'xl' | 'xs';
columns: ItemTableListColumnConfig[]; columns: ItemTableListColumnConfig[];
controls: ItemControls; controls: ItemControls;
enableAlternateRowColors: boolean;
enableColumnReorder: boolean;
enableColumnResize: boolean;
enableDrag: boolean;
enableExpansion: boolean;
enableHeader: boolean; enableHeader: boolean;
enableHorizontalBorders: boolean;
enableRowHoverHighlight: boolean; enableRowHoverHighlight: boolean;
enableSelection: boolean; enableSelection: boolean;
enableVerticalBorders: boolean;
getRowHeight: (index: number, cellProps: TableItemProps) => number;
groups?: ItemTableListGroupHeader[];
internalState: ItemListStateActions; internalState: ItemListStateActions;
itemType: LibraryItem; itemType: LibraryItem;
playerContext: PlayerContext; playerContext: PlayerContext;
playlistId?: string;
size: 'compact' | 'default' | 'large'; size: 'compact' | 'default' | 'large';
startRowIndex?: number; startRowIndex?: number;
tableId: string; tableId: string;
}; };
export type ItemTableListGroupHeader = {
itemCount: number;
render: (props: {
data: unknown[];
groupIndex: number;
index: number;
internalState: ItemListStateActions;
startDataIndex: number;
}) => ReactElement;
};
const ItemTableListConfigContext = createContext<ItemTableListConfig | null>(null); const ItemTableListConfigContext = createContext<ItemTableListConfig | null>(null);
export const ItemTableListConfigProvider = ({ export const ItemTableListConfigProvider = ({
@@ -48,6 +76,69 @@ 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;
}; };
@@ -72,7 +72,7 @@
z-index: 15; z-index: 15;
display: flex; display: flex;
flex-direction: row; flex-direction: row;
overflow: hidden; overflow: visible;
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,6 +168,7 @@
min-width: 0; min-width: 0;
height: 100%; height: 100%;
min-height: 0; min-height: 0;
overflow-x: visible;
} }
.no-scrollbar { .no-scrollbar {
@@ -178,6 +179,10 @@
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,11 +14,14 @@ import React, {
useMemo, useMemo,
useRef, useRef,
useState, useState,
useSyncExternalStore,
} from 'react'; } from 'react';
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 {
@@ -43,13 +46,20 @@ import { useTableRowModel } from '/@/renderer/components/item-list/item-table-li
import { useTableScrollToIndex } from '/@/renderer/components/item-list/item-table-list/hooks/use-table-scroll-to-index'; import { 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,
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,
@@ -101,30 +111,71 @@ 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>>;
cellPadding: 'lg' | 'md' | 'sm' | 'xl' | 'xs';
controls: ItemControls;
data: unknown[]; data: unknown[];
dataWithGroups: (null | unknown)[]; dataWithGroups: (null | unknown)[];
enableAlternateRowColors: boolean;
enableColumnReorder: boolean;
enableColumnResize: boolean;
enableDrag?: boolean;
enableExpansion: boolean;
enableHeader: boolean;
enableHorizontalBorders: boolean;
enableRowHoverHighlight: boolean;
enableScrollShadow: boolean; enableScrollShadow: boolean;
enableSelection: boolean;
enableVerticalBorders: boolean;
getItem?: (index: number) => undefined | unknown; getItem?: (index: number) => undefined | unknown;
getRowHeight: (index: number, cellProps: TableItemProps) => number;
groups?: TableGroupHeader[];
headerHeight: number; headerHeight: number;
internalState: ItemListStateActions;
itemType: LibraryItem;
mergedRowRef: React.Ref<HTMLDivElement>; mergedRowRef: React.Ref<HTMLDivElement>;
onRangeChanged?: ItemTableListProps['onRangeChanged']; onRangeChanged?: ItemTableListProps['onRangeChanged'];
parsedColumns: ReturnType<typeof parseTableColumns>; parsedColumns: ReturnType<typeof parseTableColumns>;
@@ -134,13 +185,8 @@ 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>;
playerContext: PlayerContext; scrollShadowStore: TableScrollShadowStore;
showLeftShadow: boolean; tableConfig: ItemTableListConfig;
showRightShadow: boolean;
showTopShadow: boolean;
size: 'compact' | 'default' | 'large';
startRowIndex?: number;
tableId: string;
totalColumnCount: number; totalColumnCount: number;
totalRowCount: number; totalRowCount: number;
} }
@@ -148,27 +194,11 @@ interface VirtualizedTableGridProps {
const VirtualizedTableGrid = ({ const VirtualizedTableGrid = ({
calculatedColumnWidths, calculatedColumnWidths,
CellComponent, CellComponent,
cellPadding,
controls,
data, data,
dataWithGroups, dataWithGroups,
enableAlternateRowColors,
enableColumnReorder,
enableColumnResize,
enableDrag,
enableExpansion,
enableHeader,
enableHorizontalBorders,
enableRowHoverHighlight,
enableScrollShadow, enableScrollShadow,
enableSelection,
enableVerticalBorders,
getItem, getItem,
getRowHeight,
groups,
headerHeight, headerHeight,
internalState,
itemType,
mergedRowRef, mergedRowRef,
onRangeChanged, onRangeChanged,
parsedColumns, parsedColumns,
@@ -178,16 +208,12 @@ const VirtualizedTableGrid = ({
pinnedRightColumnRef, pinnedRightColumnRef,
pinnedRowCount, pinnedRowCount,
pinnedRowRef, pinnedRowRef,
playerContext, scrollShadowStore,
showLeftShadow, tableConfig,
showRightShadow,
showTopShadow,
size,
startRowIndex,
tableId,
totalColumnCount, totalColumnCount,
totalRowCount, totalRowCount,
}: VirtualizedTableGridProps) => { }: VirtualizedTableGridProps) => {
const { enableHeader, enableRowHoverHighlight, getRowHeight, groups } = tableConfig;
const hoverDelegateRef = useRef<HTMLDivElement | null>(null); const hoverDelegateRef = useRef<HTMLDivElement | null>(null);
useRowInteractionDelegate({ useRowInteractionDelegate({
@@ -345,35 +371,7 @@ const VirtualizedTableGrid = ({
], ],
); );
const stableConfigProps = useMemo( const gridOnlyProps = useMemo(
() => ({
cellPadding,
columns: parsedColumns,
controls,
enableHeader,
getRowHeight,
hasAlbumGroupColumn: parsedColumns.some((col) => col.id === TableColumn.ALBUM_GROUP),
internalState,
itemType,
playerContext,
size,
tableId,
}),
[
cellPadding,
parsedColumns,
controls,
enableHeader,
getRowHeight,
internalState,
itemType,
playerContext,
size,
tableId,
],
);
const dynamicDataProps = useMemo(
() => ({ () => ({
calculatedColumnWidths, calculatedColumnWidths,
data: dataWithGroups, data: dataWithGroups,
@@ -381,11 +379,11 @@ const VirtualizedTableGrid = ({
getGroupRenderData, getGroupRenderData,
getRowItem, getRowItem,
groupHeaderInfoByRowIndex, groupHeaderInfoByRowIndex,
hasAlbumGroupColumn: parsedColumns.some((col) => col.id === TableColumn.ALBUM_GROUP),
pinnedLeftColumnCount, pinnedLeftColumnCount,
pinnedLeftColumnWidths, pinnedLeftColumnWidths,
pinnedRightColumnCount, pinnedRightColumnCount,
pinnedRightColumnWidths, pinnedRightColumnWidths,
startRowIndex,
}), }),
[ [
calculatedColumnWidths, calculatedColumnWidths,
@@ -394,50 +392,68 @@ const VirtualizedTableGrid = ({
getAdjustedRowIndex, getAdjustedRowIndex,
getGroupRenderData, getGroupRenderData,
groupHeaderInfoByRowIndex, groupHeaderInfoByRowIndex,
parsedColumns,
pinnedLeftColumnCount, pinnedLeftColumnCount,
pinnedLeftColumnWidths, pinnedLeftColumnWidths,
pinnedRightColumnCount, pinnedRightColumnCount,
pinnedRightColumnWidths, pinnedRightColumnWidths,
startRowIndex,
],
);
const featureFlags = useMemo(
() => ({
enableAlternateRowColors,
enableColumnReorder,
enableColumnResize,
enableDrag,
enableExpansion,
enableHorizontalBorders,
enableRowHoverHighlight,
enableSelection,
enableVerticalBorders,
groups,
}),
[
enableAlternateRowColors,
enableColumnReorder,
enableColumnResize,
enableDrag,
enableExpansion,
enableHorizontalBorders,
enableRowHoverHighlight,
enableSelection,
enableVerticalBorders,
groups,
], ],
); );
const itemProps: TableItemProps = useMemo( const itemProps: TableItemProps = useMemo(
() => ({ () => ({
...stableConfigProps, cellPadding: tableConfig.cellPadding,
...dynamicDataProps, columns: tableConfig.columns,
...featureFlags, controls: tableConfig.controls,
enableAlternateRowColors: tableConfig.enableAlternateRowColors,
enableColumnReorder: tableConfig.enableColumnReorder,
enableColumnResize: tableConfig.enableColumnResize,
enableDrag: tableConfig.enableDrag,
enableExpansion: tableConfig.enableExpansion,
enableHeader: tableConfig.enableHeader,
enableHorizontalBorders: tableConfig.enableHorizontalBorders,
enableRowHoverHighlight: tableConfig.enableRowHoverHighlight,
enableSelection: tableConfig.enableSelection,
enableVerticalBorders: tableConfig.enableVerticalBorders,
getRowHeight: tableConfig.getRowHeight,
groups: tableConfig.groups,
internalState: tableConfig.internalState,
itemType: tableConfig.itemType,
playerContext: tableConfig.playerContext,
playlistId: tableConfig.playlistId,
size: tableConfig.size,
startRowIndex: tableConfig.startRowIndex,
tableId: tableConfig.tableId,
...gridOnlyProps,
}), }),
[stableConfigProps, dynamicDataProps, featureFlags], [gridOnlyProps, tableConfig],
); );
const pinnedLeftGridMinWidthPx = useMemo(() => {
let sum = 0;
for (let i = 0; i < pinnedLeftColumnCount; i++) {
sum += calculatedColumnWidths[i] ?? 0;
}
return sum;
}, [calculatedColumnWidths, pinnedLeftColumnCount]);
const pinnedRightGridMinWidthPx = useMemo(() => {
let sum = 0;
const start = pinnedLeftColumnCount + totalColumnCount;
for (let i = 0; i < pinnedRightColumnCount; i++) {
sum += calculatedColumnWidths[start + i] ?? 0;
}
return sum;
}, [calculatedColumnWidths, pinnedLeftColumnCount, pinnedRightColumnCount, totalColumnCount]);
const pinnedRowsMinHeightPx = useMemo(() => {
let sum = 0;
for (let i = 0; i < pinnedRowCount; i++) {
sum += getRowHeight(i, itemProps);
}
return sum;
}, [getRowHeight, itemProps, pinnedRowCount]);
const PinnedRowCell = useCallback( const PinnedRowCell = useCallback(
(cellProps: CellComponentProps & TableItemProps) => { (cellProps: CellComponentProps & TableItemProps) => {
return ( return (
@@ -447,16 +463,14 @@ const VirtualizedTableGrid = ({
/> />
); );
}, },
// eslint-disable-next-line react-hooks/exhaustive-deps [pinnedLeftColumnCount, CellComponent],
[pinnedLeftColumnCount, CellComponent, featureFlags, calculatedColumnWidths],
); );
const PinnedColumnCell = useCallback( const PinnedColumnCell = useCallback(
(cellProps: CellComponentProps & TableItemProps) => { (cellProps: CellComponentProps & TableItemProps) => {
return <CellComponent {...cellProps} rowIndex={cellProps.rowIndex + pinnedRowCount} />; return <CellComponent {...cellProps} rowIndex={cellProps.rowIndex + pinnedRowCount} />;
}, },
// eslint-disable-next-line react-hooks/exhaustive-deps [pinnedRowCount, CellComponent],
[pinnedRowCount, CellComponent, featureFlags, calculatedColumnWidths],
); );
const PinnedRightColumnCell = useCallback( const PinnedRightColumnCell = useCallback(
@@ -469,15 +483,7 @@ const VirtualizedTableGrid = ({
/> />
); );
}, },
// eslint-disable-next-line react-hooks/exhaustive-deps [pinnedLeftColumnCount, pinnedRowCount, totalColumnCount, CellComponent],
[
pinnedLeftColumnCount,
pinnedRowCount,
totalColumnCount,
CellComponent,
featureFlags,
calculatedColumnWidths,
],
); );
const PinnedRightIntersectionCell = useCallback( const PinnedRightIntersectionCell = useCallback(
@@ -489,14 +495,7 @@ const VirtualizedTableGrid = ({
/> />
); );
}, },
// eslint-disable-next-line react-hooks/exhaustive-deps [pinnedLeftColumnCount, totalColumnCount, CellComponent],
[
pinnedLeftColumnCount,
totalColumnCount,
CellComponent,
featureFlags,
calculatedColumnWidths,
],
); );
const RowCell = useCallback( const RowCell = useCallback(
@@ -509,14 +508,7 @@ const VirtualizedTableGrid = ({
/> />
); );
}, },
// eslint-disable-next-line react-hooks/exhaustive-deps [pinnedLeftColumnCount, pinnedRowCount, CellComponent],
[
pinnedLeftColumnCount,
pinnedRowCount,
CellComponent,
featureFlags,
calculatedColumnWidths,
],
); );
const handleOnCellsRendered = useCallback( const handleOnCellsRendered = useCallback(
@@ -541,10 +533,7 @@ const VirtualizedTableGrid = ({
style={ style={
{ {
'--header-height': `${headerHeight}px`, '--header-height': `${headerHeight}px`,
minWidth: `${Array.from({ length: pinnedLeftColumnCount }, () => 0).reduce( minWidth: `${pinnedLeftGridMinWidthPx}px`,
(a, _, i) => a + columnWidth(i),
0,
)}px`,
} as React.CSSProperties } as React.CSSProperties
} }
> >
@@ -554,11 +543,8 @@ const VirtualizedTableGrid = ({
[styles.withHeader]: enableHeader, [styles.withHeader]: enableHeader,
})} })}
style={{ style={{
minHeight: `${Array.from({ length: pinnedRowCount }, () => 0).reduce( minHeight: `${pinnedRowsMinHeightPx}px`,
(a, _, i) => a + getRowHeight(i, itemProps), overflow: 'visible',
0,
)}px`,
overflow: 'hidden',
}} }}
> >
<Grid <Grid
@@ -572,9 +558,11 @@ const VirtualizedTableGrid = ({
/> />
</div> </div>
)} )}
{enableHeader && enableScrollShadow && showTopShadow && ( <ItemTableScrollShadowTop
<div className={styles.itemTableTopScrollShadow} /> enableHeader={!!enableHeader}
)} enableScrollShadow={enableScrollShadow}
scrollShadowStore={scrollShadowStore}
/>
{!!pinnedLeftColumnCount && ( {!!pinnedLeftColumnCount && (
<div <div
className={styles.itemTablePinnedColumnsContainer} className={styles.itemTablePinnedColumnsContainer}
@@ -611,10 +599,7 @@ const VirtualizedTableGrid = ({
style={ style={
{ {
'--header-height': `${headerHeight}px`, '--header-height': `${headerHeight}px`,
minHeight: `${Array.from( minHeight: `${pinnedRowsMinHeightPx}px`,
{ length: pinnedRowCount },
() => 0,
).reduce((a, _, i) => a + getRowHeight(i, itemProps), 0)}px`,
overflow: 'hidden', overflow: 'hidden',
} as React.CSSProperties } as React.CSSProperties
} }
@@ -627,14 +612,16 @@ const VirtualizedTableGrid = ({
columnWidth={(index) => { columnWidth={(index) => {
return columnWidth(index + pinnedLeftColumnCount); return columnWidth(index + pinnedLeftColumnCount);
}} }}
rowCount={Array.from({ length: pinnedRowCount }, () => 0).length} rowCount={pinnedRowCount}
rowHeight={getRowHeight} rowHeight={getRowHeight}
/> />
</div> </div>
)} )}
{enableHeader && enableScrollShadow && showTopShadow && ( <ItemTableScrollShadowTop
<div className={styles.itemTableTopScrollShadow} /> enableHeader={!!enableHeader}
)} enableScrollShadow={enableScrollShadow}
scrollShadowStore={scrollShadowStore}
/>
<div className={styles.itemTableGridContainer} ref={mergedRowRef}> <div className={styles.itemTableGridContainer} ref={mergedRowRef}>
<Grid <Grid
cellComponent={RowCell} cellComponent={RowCell}
@@ -646,12 +633,16 @@ const VirtualizedTableGrid = ({
rowCount={totalRowCount} rowCount={totalRowCount}
rowHeight={rowHeightMemoized} rowHeight={rowHeightMemoized}
/> />
{pinnedLeftColumnCount > 0 && enableScrollShadow && showLeftShadow && ( <ItemTableScrollShadowLeft
<div className={styles.itemTableLeftScrollShadow} /> enableScrollShadow={enableScrollShadow}
)} pinnedLeftColumnCount={pinnedLeftColumnCount}
{pinnedRightColumnCount > 0 && enableScrollShadow && showRightShadow && ( scrollShadowStore={scrollShadowStore}
<div className={styles.itemTableRightScrollShadow} /> />
)} <ItemTableScrollShadowRight
enableScrollShadow={enableScrollShadow}
pinnedRightColumnCount={pinnedRightColumnCount}
scrollShadowStore={scrollShadowStore}
/>
</div> </div>
</div> </div>
{!!pinnedRightColumnCount && ( {!!pinnedRightColumnCount && (
@@ -660,14 +651,7 @@ const VirtualizedTableGrid = ({
style={ style={
{ {
'--header-height': `${headerHeight}px`, '--header-height': `${headerHeight}px`,
minWidth: `${Array.from( minWidth: `${pinnedRightGridMinWidthPx}px`,
{ length: pinnedRightColumnCount },
() => 0,
).reduce(
(a, _, i) =>
a + columnWidth(i + pinnedLeftColumnCount + totalColumnCount),
0,
)}px`,
} as React.CSSProperties } as React.CSSProperties
} }
> >
@@ -677,11 +661,8 @@ const VirtualizedTableGrid = ({
[styles.withHeader]: enableHeader, [styles.withHeader]: enableHeader,
})} })}
style={{ style={{
minHeight: `${Array.from( minHeight: `${pinnedRowsMinHeightPx}px`,
{ length: pinnedRowCount }, overflow: 'visible',
() => 0,
).reduce((a, _, i) => a + getRowHeight(i, itemProps), 0)}px`,
overflow: 'hidden',
}} }}
> >
<Grid <Grid
@@ -699,9 +680,11 @@ const VirtualizedTableGrid = ({
/> />
</div> </div>
)} )}
{enableHeader && enableScrollShadow && showTopShadow && ( <ItemTableScrollShadowTop
<div className={styles.itemTableTopScrollShadow} /> enableHeader={!!enableHeader}
)} enableScrollShadow={enableScrollShadow}
scrollShadowStore={scrollShadowStore}
/>
<div <div
className={styles.itemTablePinnedRightColumnsContainer} className={styles.itemTablePinnedRightColumnsContainer}
ref={pinnedRightColumnRef} ref={pinnedRightColumnRef}
@@ -739,27 +722,12 @@ const MemoizedVirtualizedTableGrid = memo(VirtualizedTableGrid, (prevProps, next
prevProps.calculatedColumnWidths, prevProps.calculatedColumnWidths,
nextProps.calculatedColumnWidths, nextProps.calculatedColumnWidths,
) && ) &&
prevProps.cellPadding === nextProps.cellPadding && prevProps.tableConfig === nextProps.tableConfig &&
prevProps.controls === nextProps.controls &&
prevProps.data === nextProps.data && prevProps.data === nextProps.data &&
prevProps.dataWithGroups === nextProps.dataWithGroups && prevProps.dataWithGroups === nextProps.dataWithGroups &&
prevProps.enableAlternateRowColors === nextProps.enableAlternateRowColors &&
prevProps.enableColumnReorder === nextProps.enableColumnReorder &&
prevProps.enableColumnResize === nextProps.enableColumnResize &&
prevProps.enableDrag === nextProps.enableDrag &&
prevProps.enableExpansion === nextProps.enableExpansion &&
prevProps.enableHeader === nextProps.enableHeader &&
prevProps.enableHorizontalBorders === nextProps.enableHorizontalBorders &&
prevProps.enableRowHoverHighlight === nextProps.enableRowHoverHighlight &&
prevProps.enableScrollShadow === nextProps.enableScrollShadow && prevProps.enableScrollShadow === nextProps.enableScrollShadow &&
prevProps.enableSelection === nextProps.enableSelection &&
prevProps.enableVerticalBorders === nextProps.enableVerticalBorders &&
prevProps.getItem === nextProps.getItem && prevProps.getItem === nextProps.getItem &&
prevProps.getRowHeight === nextProps.getRowHeight &&
prevProps.groups === nextProps.groups &&
prevProps.headerHeight === nextProps.headerHeight && prevProps.headerHeight === nextProps.headerHeight &&
prevProps.internalState === nextProps.internalState &&
prevProps.itemType === nextProps.itemType &&
prevProps.mergedRowRef === nextProps.mergedRowRef && prevProps.mergedRowRef === nextProps.mergedRowRef &&
prevProps.onRangeChanged === nextProps.onRangeChanged && prevProps.onRangeChanged === nextProps.onRangeChanged &&
prevProps.parsedColumns === nextProps.parsedColumns && prevProps.parsedColumns === nextProps.parsedColumns &&
@@ -769,13 +737,7 @@ 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.playerContext === nextProps.playerContext && prevProps.scrollShadowStore === nextProps.scrollShadowStore &&
prevProps.showLeftShadow === nextProps.showLeftShadow &&
prevProps.showRightShadow === nextProps.showRightShadow &&
prevProps.showTopShadow === nextProps.showTopShadow &&
prevProps.size === nextProps.size &&
prevProps.startRowIndex === nextProps.startRowIndex &&
prevProps.tableId === nextProps.tableId &&
prevProps.totalColumnCount === nextProps.totalColumnCount && prevProps.totalColumnCount === nextProps.totalColumnCount &&
prevProps.totalRowCount === nextProps.totalRowCount && prevProps.totalRowCount === nextProps.totalRowCount &&
prevProps.CellComponent === nextProps.CellComponent prevProps.CellComponent === nextProps.CellComponent
@@ -828,6 +790,7 @@ export interface TableItemProps {
pinnedRightColumnCount?: number; pinnedRightColumnCount?: number;
pinnedRightColumnWidths?: number[]; pinnedRightColumnWidths?: number[];
playerContext: PlayerContext; playerContext: PlayerContext;
playlistId?: string;
size?: ItemTableListProps['size']; size?: ItemTableListProps['size'];
startRowIndex?: number; startRowIndex?: number;
tableId: string; tableId: string;
@@ -1008,7 +971,7 @@ const ItemTableListStickyUI = memo(
style={{ style={{
flex: '0 1 auto', flex: '0 1 auto',
minWidth: `${pinnedLeftWidth}px`, minWidth: `${pinnedLeftWidth}px`,
overflow: 'hidden', overflow: 'visible',
}} }}
> >
{parsedColumns {parsedColumns
@@ -1092,7 +1055,7 @@ const ItemTableListStickyUI = memo(
style={{ style={{
flex: '0 1 auto', flex: '0 1 auto',
minWidth: `${pinnedRightWidth}px`, minWidth: `${pinnedRightWidth}px`,
overflow: 'hidden', overflow: 'visible',
}} }}
> >
{parsedColumns {parsedColumns
@@ -1309,12 +1272,18 @@ const BaseItemTableList = ({
size = 'default', size = 'default',
startRowIndex, startRowIndex,
}: ItemTableListProps) => { }: ItemTableListProps) => {
const { playlistId: routePlaylistId } = useParams() as { playlistId?: string };
const tableId = useId(); const tableId = useId();
const baseItemCount = itemCount ?? data.length; const baseItemCount = itemCount ?? data.length;
const totalItemCount = enableHeader ? baseItemCount + 1 : baseItemCount; const totalItemCount = enableHeader ? baseItemCount + 1 : baseItemCount;
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,
@@ -1324,9 +1293,33 @@ const BaseItemTableList = ({
} = useTableColumnModel({ } = useTableColumnModel({
autoFitColumns, autoFitColumns,
centerContainerWidth, centerContainerWidth,
columns, columns: columnsForLayout,
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 {
@@ -1362,9 +1355,7 @@ 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 [showLeftShadow, setShowLeftShadow] = useState(false); const scrollShadowStore = useMemo(() => createTableScrollShadowStore(), []);
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);
@@ -1422,9 +1413,7 @@ const BaseItemTableList = ({
pinnedRowRef, pinnedRowRef,
rowRef, rowRef,
scrollContainerRef, scrollContainerRef,
setShowLeftShadow, scrollShadowStore,
setShowRightShadow,
setShowTopShadow,
}); });
const getRowHeight = useCallback( const getRowHeight = useCallback(
@@ -1548,7 +1537,7 @@ const BaseItemTableList = ({
// Create itemProps for sticky header // Create itemProps for sticky header
const stickyHeaderItemProps: TableItemProps = useMemo( const stickyHeaderItemProps: TableItemProps = useMemo(
() => ({ () => ({
calculatedColumnWidths, calculatedColumnWidths: displayColumnWidths,
cellPadding, cellPadding,
columns: parsedColumns, columns: parsedColumns,
controls, controls,
@@ -1568,17 +1557,18 @@ const BaseItemTableList = ({
internalState, internalState,
itemType, itemType,
pinnedLeftColumnCount, pinnedLeftColumnCount,
pinnedLeftColumnWidths: calculatedColumnWidths.slice(0, pinnedLeftColumnCount), pinnedLeftColumnWidths: displayColumnWidths.slice(0, pinnedLeftColumnCount),
pinnedRightColumnCount, pinnedRightColumnCount,
pinnedRightColumnWidths: calculatedColumnWidths.slice( pinnedRightColumnWidths: displayColumnWidths.slice(
pinnedLeftColumnCount + totalColumnCount, pinnedLeftColumnCount + totalColumnCount,
), ),
playerContext, playerContext,
playlistId: routePlaylistId,
size, size,
tableId, tableId,
}), }),
[ [
calculatedColumnWidths, displayColumnWidths,
cellPadding, cellPadding,
controls, controls,
parsedColumns, parsedColumns,
@@ -1599,6 +1589,7 @@ const BaseItemTableList = ({
pinnedLeftColumnCount, pinnedLeftColumnCount,
pinnedRightColumnCount, pinnedRightColumnCount,
playerContext, playerContext,
routePlaylistId,
size, size,
tableId, tableId,
totalColumnCount, totalColumnCount,
@@ -1612,17 +1603,27 @@ const BaseItemTableList = ({
itemType, itemType,
}); });
const tableConfigValue = useMemo( const tableConfigValue = useMemo<ItemTableListConfig>(
() => ({ () => ({
cellPadding, cellPadding,
columns: parsedColumns, columns: parsedColumns,
controls, controls,
enableAlternateRowColors,
enableColumnReorder: !!onColumnReordered,
enableColumnResize: !!onColumnResized,
enableDrag,
enableExpansion,
enableHeader, enableHeader,
enableHorizontalBorders,
enableRowHoverHighlight, enableRowHoverHighlight,
enableSelection, enableSelection,
enableVerticalBorders,
getRowHeight,
groups,
internalState, internalState,
itemType, itemType,
playerContext, playerContext,
playlistId: routePlaylistId,
size, size,
startRowIndex, startRowIndex,
tableId, tableId,
@@ -1631,12 +1632,22 @@ const BaseItemTableList = ({
cellPadding, cellPadding,
parsedColumns, parsedColumns,
controls, controls,
enableAlternateRowColors,
onColumnReordered,
onColumnResized,
enableDrag,
enableExpansion,
enableHeader, enableHeader,
enableHorizontalBorders,
enableRowHoverHighlight, enableRowHoverHighlight,
enableSelection, enableSelection,
enableVerticalBorders,
getRowHeight,
groups,
internalState, internalState,
itemType, itemType,
playerContext, playerContext,
routePlaylistId,
size, size,
startRowIndex, startRowIndex,
tableId, tableId,
@@ -1662,92 +1673,81 @@ 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}>
<motion.div {onColumnResized ? (
className={styles.itemTableListContainer} <ItemTableListColumnResizeLiveProvider value={columnResizeLiveValue}>
onKeyDown={handleKeyDown} {tableMotion}
onMouseDown={(e) => { </ItemTableListColumnResizeLiveProvider>
const element = e.currentTarget as HTMLDivElement; ) : (
// Focus without scrolling into view tableMotion
if (element.focus) { )}
element.focus({ preventScroll: true });
}
}}
ref={mergedContainerRef}
tabIndex={0}
{...animationProps.fadeIn}
transition={{ duration: enableEntranceAnimation ? 0.3 : 0, ease: 'anticipate' }}
>
<ItemTableListStickyUI
calculatedColumnWidths={calculatedColumnWidths}
CellComponent={optimizedCellComponent}
containerRef={containerRef}
data={data}
enableHeader={!!enableHeader}
enableStickyGroupRows={!!enableStickyGroupRows}
enableStickyHeader={!!enableStickyHeader}
getRowHeightWrapper={getRowHeightWrapper}
groups={groups}
headerHeight={headerHeight}
internalState={internalState}
parsedColumns={parsedColumns}
pinnedLeftColumnCount={pinnedLeftColumnCount}
pinnedLeftColumnRef={pinnedLeftColumnRef}
pinnedRightColumnCount={pinnedRightColumnCount}
pinnedRightColumnRef={pinnedRightColumnRef}
pinnedRowRef={pinnedRowRef}
rowHeight={rowHeight}
rowRef={rowRef}
size={size}
stickyHeaderItemProps={stickyHeaderItemProps}
totalColumnCount={totalColumnCount}
/>
<MemoizedVirtualizedTableGrid
calculatedColumnWidths={calculatedColumnWidths}
CellComponent={optimizedCellComponent}
cellPadding={cellPadding}
controls={controls}
data={data}
dataWithGroups={dataWithGroups}
enableAlternateRowColors={enableAlternateRowColors}
enableColumnReorder={!!onColumnReordered}
enableColumnResize={!!onColumnResized}
enableDrag={enableDrag}
enableExpansion={enableExpansion}
enableHeader={enableHeader}
enableHorizontalBorders={enableHorizontalBorders}
enableRowHoverHighlight={enableRowHoverHighlight}
enableScrollShadow={enableScrollShadow}
enableSelection={enableSelection}
enableVerticalBorders={enableVerticalBorders}
getItem={getItem}
getRowHeight={getRowHeight}
groups={groups}
headerHeight={headerHeight}
internalState={internalState}
itemType={itemType}
mergedRowRef={mergedRowRef}
onRangeChanged={onRangeChanged}
parsedColumns={parsedColumns}
pinnedLeftColumnCount={pinnedLeftColumnCount}
pinnedLeftColumnRef={pinnedLeftColumnRef}
pinnedRightColumnCount={pinnedRightColumnCount}
pinnedRightColumnRef={pinnedRightColumnRef}
pinnedRowCount={pinnedRowCount}
pinnedRowRef={pinnedRowRef}
playerContext={playerContext}
showLeftShadow={showLeftShadow}
showRightShadow={showRightShadow}
showTopShadow={showTopShadow}
size={size}
startRowIndex={startRowIndex}
tableId={tableId}
totalColumnCount={totalColumnCount}
totalRowCount={totalRowCount}
/>
</motion.div>
</ItemTableListConfigProvider> </ItemTableListConfigProvider>
</ItemTableListStoreProvider> </ItemTableListStoreProvider>
); );
@@ -1,4 +1,4 @@
import React, { memo, useMemo } from 'react'; import React, { useMemo } from 'react';
import { CellComponentProps } from 'react-window-v2'; import { CellComponentProps } from 'react-window-v2';
import { createColumnCellComponents } from './cell-component-factory'; import { createColumnCellComponents } from './cell-component-factory';
@@ -24,24 +24,7 @@ const MemoizedCellRouterBase = (props: MemoizedCellRouterProps) => {
return <ItemTableListColumn {...props} />; return <ItemTableListColumn {...props} />;
}; };
export const MemoizedCellRouter = memo(MemoizedCellRouterBase, (prevProps, nextProps) => { export const MemoizedCellRouter = MemoizedCellRouterBase;
return (
prevProps.rowIndex === nextProps.rowIndex &&
prevProps.columnIndex === nextProps.columnIndex &&
prevProps.data === nextProps.data &&
prevProps.columns === nextProps.columns &&
prevProps.columnCellComponents === nextProps.columnCellComponents &&
prevProps.size === nextProps.size &&
prevProps.enableAlternateRowColors === nextProps.enableAlternateRowColors &&
prevProps.enableHorizontalBorders === nextProps.enableHorizontalBorders &&
prevProps.enableVerticalBorders === nextProps.enableVerticalBorders &&
prevProps.enableRowHoverHighlight === nextProps.enableRowHoverHighlight &&
prevProps.enableSelection === nextProps.enableSelection &&
prevProps.enableColumnResize === nextProps.enableColumnResize &&
prevProps.enableColumnReorder === nextProps.enableColumnReorder &&
prevProps.cellPadding === nextProps.cellPadding
);
});
export const useColumnCellComponents = ( export const useColumnCellComponents = (
columns: TableColumn[], columns: TableColumn[],
@@ -0,0 +1,36 @@
export interface TableScrollShadowSnapshot {
showLeftShadow: boolean;
showRightShadow: boolean;
showTopShadow: boolean;
}
export type TableScrollShadowStore = ReturnType<typeof createTableScrollShadowStore>;
export function createTableScrollShadowStore() {
let snapshot: TableScrollShadowSnapshot = {
showLeftShadow: false,
showRightShadow: false,
showTopShadow: false,
};
const listeners = new Set<() => void>();
return {
getSnapshot: (): TableScrollShadowSnapshot => snapshot,
setSnapshot: (patch: Partial<TableScrollShadowSnapshot>) => {
const next: TableScrollShadowSnapshot = { ...snapshot, ...patch };
if (
next.showLeftShadow === snapshot.showLeftShadow &&
next.showRightShadow === snapshot.showRightShadow &&
next.showTopShadow === snapshot.showTopShadow
) {
return;
}
snapshot = next;
listeners.forEach((l) => l());
},
subscribe: (listener: () => void) => {
listeners.add(listener);
return () => listeners.delete(listener);
},
};
}
@@ -97,7 +97,7 @@
justify-content: center; justify-content: center;
width: 100%; width: 100%;
height: 100%; height: 100%;
padding: var(--theme-spacing-sm); padding: 0 var(--theme-spacing-xs);
} }
.title-wrapper.hidden { .title-wrapper.hidden {
@@ -11,8 +11,10 @@ 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 export interface PageHeaderProps extends Omit<
extends Omit<FlexProps, 'onAnimationStart' | 'onDrag' | 'onDragEnd' | 'onDragStart'> { FlexProps,
'onAnimationStart' | 'onDrag' | 'onDragEnd' | 'onDragStart'
> {
animated?: boolean; animated?: boolean;
backgroundColor?: string; backgroundColor?: string;
children?: ReactNode; children?: ReactNode;
@@ -20,7 +20,7 @@ import { songsQueries } from '/@/renderer/features/songs/api/songs-api';
import { AppRoute } from '/@/renderer/router/routes'; import { AppRoute } from '/@/renderer/router/routes';
import { useCurrentServer, useShowRatings } from '/@/renderer/store'; import { useCurrentServer, useShowRatings } from '/@/renderer/store';
import { useArtistRadioCount, usePlayButtonBehavior } from '/@/renderer/store/settings.store'; import { useArtistRadioCount, usePlayButtonBehavior } from '/@/renderer/store/settings.store';
import { formatDateAbsoluteUTC, formatDurationString, formatSizeString } from '/@/renderer/utils'; import { formatDurationString, formatPartialIsoDateUTC, formatSizeString } from '/@/renderer/utils';
import { normalizeReleaseTypes } from '/@/renderer/utils/normalize-release-types'; import { normalizeReleaseTypes } from '/@/renderer/utils/normalize-release-types';
import { Group } from '/@/shared/components/group/group'; import { Group } from '/@/shared/components/group/group';
import { Separator } from '/@/shared/components/separator/separator'; import { Separator } from '/@/shared/components/separator/separator';
@@ -131,7 +131,10 @@ export const AlbumDetailHeader = forwardRef<HTMLDivElement>((_props, ref) => {
const originalDifferentFromRelease = const originalDifferentFromRelease =
album?.originalDate && album?.originalDate !== album?.releaseDate; album?.originalDate && album?.originalDate !== album?.releaseDate;
const originalYearDifferentFromRelease = album?.originalYear !== album?.releaseYear; const originalYearDifferentFromRelease =
album.originalYear > 0 &&
album.releaseYear != null &&
album.originalYear !== album.releaseYear;
const playCount = album?.playCount; const playCount = album?.playCount;
@@ -147,17 +150,17 @@ export const AlbumDetailHeader = forwardRef<HTMLDivElement>((_props, ref) => {
if (originalDifferentFromRelease) { if (originalDifferentFromRelease) {
items.push({ items.push({
id: 'originalDate', id: 'originalDate',
value: `${formatDateAbsoluteUTC(album.originalDate)}`, value: `${formatPartialIsoDateUTC(album.originalDate)}`,
}); });
} }
if (releaseDate) { if (releaseDate) {
items.push({ items.push({
id: 'releaseDate', id: 'releaseDate',
value: `${releasePrefix} ${formatDateAbsoluteUTC(releaseDate)}`, value: `${releasePrefix} ${formatPartialIsoDateUTC(releaseDate)}`,
}); });
} }
} else if (album.originalYear) { } else if (album.originalYear > 0) {
if (originalYearDifferentFromRelease) { if (originalYearDifferentFromRelease) {
items.push({ items.push({
id: 'originalYear', id: 'originalYear',
@@ -168,14 +171,24 @@ export const AlbumDetailHeader = forwardRef<HTMLDivElement>((_props, ref) => {
if (releaseDate) { if (releaseDate) {
items.push({ items.push({
id: 'releaseDate', id: 'releaseDate',
value: `${releaseYearPrefix} ${formatDateAbsoluteUTC(releaseDate)}`, value: `${releaseYearPrefix} ${formatPartialIsoDateUTC(releaseDate)}`,
}); });
} else if (releaseYear) { } else if (releaseYear != null && releaseYear > 0) {
items.push({ items.push({
id: 'releaseYear', id: 'releaseYear',
value: `${releaseYearPrefix} ${releaseYear}`, value: `${releaseYearPrefix} ${releaseYear}`,
}); });
} }
} else if (releaseDate) {
items.push({
id: 'releaseDate',
value: `${formatPartialIsoDateUTC(releaseDate)}`,
});
} else if (releaseYear != null && releaseYear > 0) {
items.push({
id: 'releaseYear',
value: `${releaseYear}`,
});
} }
items.push( items.push(
@@ -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,6 +13,7 @@ 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';
@@ -74,8 +75,10 @@ export const useAlbumListFilters = (listKey?: ItemListKey) => {
const setGenreId = useCallback( const setGenreId = useCallback(
(value: null | string[]) => { (value: null | string[]) => {
setSearchParams((prev) => setSearchParam(prev, FILTER_KEYS.ALBUM.GENRE_ID, value), { runInUrlTransition(() => {
replace: true, setSearchParams((prev) => setSearchParam(prev, FILTER_KEYS.ALBUM.GENRE_ID, value), {
replace: true,
});
}); });
}, },
[setSearchParams], [setSearchParams],
@@ -83,8 +86,13 @@ export const useAlbumListFilters = (listKey?: ItemListKey) => {
const setAlbumArtist = useCallback( const setAlbumArtist = useCallback(
(value: null | string[]) => { (value: null | string[]) => {
setSearchParams((prev) => setSearchParam(prev, FILTER_KEYS.ALBUM.ARTIST_IDS, value), { runInUrlTransition(() => {
replace: true, setSearchParams(
(prev) => setSearchParam(prev, FILTER_KEYS.ALBUM.ARTIST_IDS, value),
{
replace: true,
},
);
}); });
}, },
[setSearchParams], [setSearchParams],
@@ -92,8 +100,10 @@ export const useAlbumListFilters = (listKey?: ItemListKey) => {
const setMinYear = useCallback( const setMinYear = useCallback(
(value: null | number) => { (value: null | number) => {
setSearchParams((prev) => setSearchParam(prev, FILTER_KEYS.ALBUM.MIN_YEAR, value), { runInUrlTransition(() => {
replace: true, setSearchParams((prev) => setSearchParam(prev, FILTER_KEYS.ALBUM.MIN_YEAR, value), {
replace: true,
});
}); });
}, },
[setSearchParams], [setSearchParams],
@@ -101,8 +111,10 @@ export const useAlbumListFilters = (listKey?: ItemListKey) => {
const setMaxYear = useCallback( const setMaxYear = useCallback(
(value: null | number) => { (value: null | number) => {
setSearchParams((prev) => setSearchParam(prev, FILTER_KEYS.ALBUM.MAX_YEAR, value), { runInUrlTransition(() => {
replace: true, setSearchParams((prev) => setSearchParam(prev, FILTER_KEYS.ALBUM.MAX_YEAR, value), {
replace: true,
});
}); });
}, },
[setSearchParams], [setSearchParams],
@@ -110,8 +122,10 @@ export const useAlbumListFilters = (listKey?: ItemListKey) => {
const setFavorite = useCallback( const setFavorite = useCallback(
(value: boolean | null) => { (value: boolean | null) => {
setSearchParams((prev) => setSearchParam(prev, FILTER_KEYS.ALBUM.FAVORITE, value), { runInUrlTransition(() => {
replace: true, setSearchParams((prev) => setSearchParam(prev, FILTER_KEYS.ALBUM.FAVORITE, value), {
replace: true,
});
}); });
}, },
[setSearchParams], [setSearchParams],
@@ -119,8 +133,13 @@ export const useAlbumListFilters = (listKey?: ItemListKey) => {
const setCompilation = useCallback( const setCompilation = useCallback(
(value: boolean | null) => { (value: boolean | null) => {
setSearchParams((prev) => setSearchParam(prev, FILTER_KEYS.ALBUM.COMPILATION, value), { runInUrlTransition(() => {
replace: true, setSearchParams(
(prev) => setSearchParam(prev, FILTER_KEYS.ALBUM.COMPILATION, value),
{
replace: true,
},
);
}); });
}, },
[setSearchParams], [setSearchParams],
@@ -128,8 +147,13 @@ export const useAlbumListFilters = (listKey?: ItemListKey) => {
const setHasRating = useCallback( const setHasRating = useCallback(
(value: boolean | null) => { (value: boolean | null) => {
setSearchParams((prev) => setSearchParam(prev, FILTER_KEYS.ALBUM.HAS_RATING, value), { runInUrlTransition(() => {
replace: true, setSearchParams(
(prev) => setSearchParam(prev, FILTER_KEYS.ALBUM.HAS_RATING, value),
{
replace: true,
},
);
}); });
}, },
[setSearchParams], [setSearchParams],
@@ -137,65 +161,71 @@ export const useAlbumListFilters = (listKey?: ItemListKey) => {
const setRecentlyPlayed = useCallback( const setRecentlyPlayed = useCallback(
(value: boolean | null) => { (value: boolean | null) => {
setSearchParams( runInUrlTransition(() => {
(prev) => setSearchParam(prev, FILTER_KEYS.ALBUM.RECENTLY_PLAYED, value), setSearchParams(
{ (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>) => {
setSearchParams( runInUrlTransition(() => {
(prev) => { setSearchParams(
const previousValue = prev.get(FILTER_KEYS.ALBUM._CUSTOM); (prev) => {
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(() => {
setSearchParams( runInUrlTransition(() => {
(prev) => setSearchParams(
setMultipleSearchParams( (prev) =>
prev, setMultipleSearchParams(
{ prev,
[FILTER_KEYS.ALBUM._CUSTOM]: null, {
[FILTER_KEYS.ALBUM.ARTIST_IDS]: null, [FILTER_KEYS.ALBUM._CUSTOM]: null,
[FILTER_KEYS.ALBUM.COMPILATION]: null, [FILTER_KEYS.ALBUM.ARTIST_IDS]: null,
[FILTER_KEYS.ALBUM.FAVORITE]: null, [FILTER_KEYS.ALBUM.COMPILATION]: null,
[FILTER_KEYS.ALBUM.GENRE_ID]: null, [FILTER_KEYS.ALBUM.FAVORITE]: null,
[FILTER_KEYS.ALBUM.HAS_RATING]: null, [FILTER_KEYS.ALBUM.GENRE_ID]: null,
[FILTER_KEYS.ALBUM.MAX_YEAR]: null, [FILTER_KEYS.ALBUM.HAS_RATING]: null,
[FILTER_KEYS.ALBUM.MIN_YEAR]: null, [FILTER_KEYS.ALBUM.MAX_YEAR]: null,
[FILTER_KEYS.ALBUM.RECENTLY_PLAYED]: null, [FILTER_KEYS.ALBUM.MIN_YEAR]: null,
[FILTER_KEYS.SHARED.SEARCH_TERM]: null, [FILTER_KEYS.ALBUM.RECENTLY_PLAYED]: 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 { useQuery } from '@tanstack/react-query'; import { useSuspenseQuery } from '@tanstack/react-query';
import { useRef } from 'react'; import { useRef } from 'react';
import { useLocation, useParams } from 'react-router'; import { useParams } from 'react-router';
import { useItemImageUrl } from '/@/renderer/components/item-image/item-image'; import { 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,23 +16,21 @@ 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, useCurrentServer } from '/@/renderer/store'; import { useAlbumBackground, useCurrentServerId } from '/@/renderer/store';
import { Spinner } from '/@/shared/components/spinner/spinner';
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 AlbumDetailRoute = () => { const AlbumDetailRoute = () => {
const scrollAreaRef = useRef<HTMLDivElement>(null); const scrollAreaRef = useRef<HTMLDivElement>(null);
const headerRef = useRef<HTMLDivElement>(null); const headerRef = useRef<HTMLDivElement>(null);
const { albumBackground, albumBackgroundBlur } = useAlbumBackground(); const { albumBackground, albumBackgroundBlur } = useAlbumBackground();
const { albumId } = useParams() as { albumId: string }; const { albumId } = useParams() as { albumId: string };
const server = useCurrentServer(); const serverId = useCurrentServerId();
const location = useLocation(); const detailQuery = useSuspenseQuery({
...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 =
@@ -42,25 +40,21 @@ const AlbumDetailRoute = () => {
type: 'itemCard', type: 'itemCard',
}) || ''; }) || '';
const { background: backgroundColor, isLoading: isColorLoading } = useFastAverageColor({ const { background: backgroundColor } = useFastAverageColor({
id: albumId, id: albumId,
src: imageUrl, src: imageUrl,
srcLoaded: true, srcLoaded: true,
}); });
const background = backgroundColor; const background = backgroundColor ?? ALBUM_DETAIL_BG_FALLBACK;
const showBlurredImage = albumBackground; const showBlurredImage = albumBackground;
if (isColorLoading) {
return <Spinner container />;
}
return ( return (
<AnimatedPage key={`album-detail-${albumId}`}> <AnimatedPage key={`album-detail-${albumId}`}>
<NativeScrollArea <NativeScrollArea
pageHeaderProps={{ pageHeaderProps={{
backgroundColor: backgroundColor || undefined, backgroundColor: backgroundColor ?? ALBUM_DETAIL_BG_FALLBACK,
children: ( children: (
<LibraryHeaderBar> <LibraryHeaderBar>
<LibraryHeaderBar.PlayButton <LibraryHeaderBar.PlayButton
@@ -68,9 +62,7 @@ const AlbumDetailRoute = () => {
itemType={LibraryItem.ALBUM} itemType={LibraryItem.ALBUM}
variant="default" variant="default"
/> />
<LibraryHeaderBar.Title> <LibraryHeaderBar.Title>{detailQuery.data.name}</LibraryHeaderBar.Title>
{detailQuery?.data?.name}
</LibraryHeaderBar.Title>
</LibraryHeaderBar> </LibraryHeaderBar>
), ),
offset: 200, offset: 200,
@@ -1,4 +1,4 @@
import { useQuery } from '@tanstack/react-query'; import { useSuspenseQuery } 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 = useQuery({ const detailQuery = useSuspenseQuery({
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: !detailQuery.isLoading, srcLoaded: Boolean(detailQuery.data?.imageUrl),
}); });
const { addToQueueByFetch } = usePlayer(); const { addToQueueByFetch } = usePlayer();
const playButtonBehavior = usePlayButtonBehavior(); const playButtonBehavior = usePlayButtonBehavior();
@@ -1,5 +1,5 @@
import { useQuery, useSuspenseQuery, UseSuspenseQueryResult } from '@tanstack/react-query'; import { useSuspenseQuery, UseSuspenseQueryResult } from '@tanstack/react-query';
import { forwardRef, Fragment, useCallback, useMemo } from 'react'; import { forwardRef, Fragment, useCallback } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useParams } from 'react-router'; import { useParams } from 'react-router';
@@ -8,6 +8,8 @@ import styles from './album-artist-detail-header.module.css';
import { useItemImageUrl } from '/@/renderer/components/item-image/item-image'; import { useItemImageUrl } from '/@/renderer/components/item-image/item-image';
import { artistsQueries } from '/@/renderer/features/artists/api/artists-api'; import { artistsQueries } from '/@/renderer/features/artists/api/artists-api';
import { getArtistAlbumsGrouped } from '/@/renderer/features/artists/hooks/use-artist-albums-grouped'; import { getArtistAlbumsGrouped } from '/@/renderer/features/artists/hooks/use-artist-albums-grouped';
import { useDeleteArtistImage } from '/@/renderer/features/artists/mutations/delete-artist-image-mutation';
import { useUploadArtistImage } from '/@/renderer/features/artists/mutations/upload-artist-image-mutation';
import { ContextMenuController } from '/@/renderer/features/context-menu/context-menu-controller'; import { ContextMenuController } from '/@/renderer/features/context-menu/context-menu-controller';
import { usePlayer } from '/@/renderer/features/player/context/player-context'; import { usePlayer } from '/@/renderer/features/player/context/player-context';
import { import {
@@ -20,17 +22,80 @@ import { AppRoute } from '/@/renderer/router/routes';
import { useAppStore, useCurrentServer, useShowRatings } from '/@/renderer/store'; import { useAppStore, useCurrentServer, useShowRatings } from '/@/renderer/store';
import { useArtistReleaseTypeItems, usePlayButtonBehavior } from '/@/renderer/store/settings.store'; import { useArtistReleaseTypeItems, usePlayButtonBehavior } from '/@/renderer/store/settings.store';
import { formatDurationString } from '/@/renderer/utils'; import { formatDurationString } from '/@/renderer/utils';
import { SEPARATOR_STRING, sortAlbumList } from '/@/shared/api/utils'; import { hasFeature, SEPARATOR_STRING, sortAlbumList } from '/@/shared/api/utils';
import { ActionIcon } from '/@/shared/components/action-icon/action-icon';
import { FileButton } from '/@/shared/components/file-button/file-button';
import { Group } from '/@/shared/components/group/group'; import { Group } from '/@/shared/components/group/group';
import { Stack } from '/@/shared/components/stack/stack'; import { Stack } from '/@/shared/components/stack/stack';
import { Text } from '/@/shared/components/text/text'; import { Text } from '/@/shared/components/text/text';
import { AlbumListResponse, LibraryItem, ServerType } from '/@/shared/types/domain-types'; import {
AlbumArtistDetailResponse,
AlbumListResponse,
LibraryItem,
ServerType,
} from '/@/shared/types/domain-types';
import { ServerFeature } from '/@/shared/types/features-types';
import { Play } from '/@/shared/types/types'; import { Play } from '/@/shared/types/types';
interface AlbumArtistDetailHeaderProps { interface AlbumArtistDetailHeaderProps {
albumsQuery: UseSuspenseQueryResult<AlbumListResponse, Error>; albumsQuery: UseSuspenseQueryResult<AlbumListResponse, Error>;
} }
function ArtistImageUploadOverlay({
data,
onUploadFile,
}: {
data?: AlbumArtistDetailResponse;
onUploadFile: (file: File) => Promise<void>;
}) {
const deleteArtistImageMutation = useDeleteArtistImage({});
const server = useCurrentServer();
if (!data) return null;
if (!hasFeature(server, ServerFeature.ARTIST_IMAGE_UPLOAD)) return null;
return (
<Group gap="xs">
<FileButton
accept="image/*"
onChange={async (file) => {
if (!file) return;
await onUploadFile(file);
}}
>
{(props) => (
<ActionIcon
icon="uploadImage"
iconProps={{ size: 'lg' }}
radius="xl"
size="xs"
variant="default"
{...props}
/>
)}
</FileButton>
<ActionIcon
disabled={!data?.uploadedImage}
icon="delete"
iconProps={{ size: 'lg' }}
onClick={(e) => {
e.stopPropagation();
if (!data?._serverId) return;
deleteArtistImageMutation.mutate({
apiClientProps: {
serverId: data._serverId,
},
query: { id: data.id },
});
}}
radius="xl"
size="xs"
variant="default"
/>
</Group>
);
}
export const AlbumArtistDetailHeader = forwardRef<HTMLDivElement, AlbumArtistDetailHeaderProps>( export const AlbumArtistDetailHeader = forwardRef<HTMLDivElement, AlbumArtistDetailHeaderProps>(
({ albumsQuery }, ref) => { ({ albumsQuery }, ref) => {
const { albumArtistId, artistId } = useParams() as { const { albumArtistId, artistId } = useParams() as {
@@ -78,6 +143,7 @@ export const AlbumArtistDetailHeader = forwardRef<HTMLDivElement, AlbumArtistDet
const playButtonBehavior = usePlayButtonBehavior(); const playButtonBehavior = usePlayButtonBehavior();
const setFavorite = useSetFavorite(); const setFavorite = useSetFavorite();
const setRating = useSetRating(); const setRating = useSetRating();
const uploadArtistImageMutation = useUploadArtistImage({});
const albumArtistDetailSort = useAppStore((state) => state.albumArtistDetailSort); const albumArtistDetailSort = useAppStore((state) => state.albumArtistDetailSort);
const sortBy = albumArtistDetailSort.sortBy; const sortBy = albumArtistDetailSort.sortBy;
@@ -167,38 +233,52 @@ export const AlbumArtistDetailHeader = forwardRef<HTMLDivElement, AlbumArtistDet
[detailQuery.data], [detailQuery.data],
); );
const imageUrl = useItemImageUrl({ const headerImageUrl = useItemImageUrl({
id: detailQuery.data?.imageId || undefined, id: detailQuery.data?.imageId || undefined,
imageUrl: detailQuery.data?.imageUrl, imageUrl: detailQuery.data?.imageUrl,
itemType: LibraryItem.ALBUM_ARTIST, itemType: LibraryItem.ALBUM_ARTIST,
type: 'itemCard', type: 'header',
});
const artistInfoQuery = useQuery({
...artistsQueries.albumArtistInfo({
query: { id: routeId, limit: 10 },
serverId: server?.id,
}),
enabled: Boolean(server?.id && routeId),
}); });
const showRating = showRatings && detailQuery?.data?._serverType === ServerType.NAVIDROME; const showRating = showRatings && detailQuery?.data?._serverType === ServerType.NAVIDROME;
const selectedImageUrl = useMemo(() => { const canUploadArtistImage =
return detailQuery.data?.imageUrl || imageUrl; hasFeature(server, ServerFeature.ARTIST_IMAGE_UPLOAD) &&
}, [detailQuery.data?.imageUrl, imageUrl]); Boolean(detailQuery.data?._serverId);
const alternateImageUrl = artistInfoQuery.data?.imageUrl; const handleArtistImageUpload = useCallback(
async (file: File) => {
const artist = detailQuery.data;
if (!artist?._serverId) return;
const buffer = await file.arrayBuffer();
uploadArtistImageMutation.mutate({
apiClientProps: {
serverId: artist._serverId,
},
body: { image: new Uint8Array(buffer) },
query: { id: artist.id },
});
},
[detailQuery.data, uploadArtistImageMutation],
);
return ( return (
<LibraryHeader <LibraryHeader
imageUrl={alternateImageUrl || selectedImageUrl} imageOverlay={
<ArtistImageUploadOverlay
data={detailQuery.data}
onUploadFile={handleArtistImageUpload}
/>
}
imageUrl={headerImageUrl}
item={{ item={{
imageId: detailQuery.data?.imageId, imageId: detailQuery.data?.imageId,
imageUrl: alternateImageUrl || detailQuery.data?.imageUrl, imageUrl: detailQuery.data?.imageUrl,
route: AppRoute.LIBRARY_ALBUM_ARTISTS, route: AppRoute.LIBRARY_ALBUM_ARTISTS,
type: LibraryItem.ALBUM_ARTIST, type: LibraryItem.ALBUM_ARTIST,
}} }}
onImageFileDrop={canUploadArtistImage ? handleArtistImageUpload : undefined}
ref={ref} ref={ref}
title={detailQuery.data?.name || ''} title={detailQuery.data?.name || ''}
> >
@@ -16,8 +16,7 @@ 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 interface AlbumArtistListInfiniteGridProps extends ItemListGridComponentProps<AlbumArtistListQuery> {}
extends ItemListGridComponentProps<AlbumArtistListQuery> {}
export const AlbumArtistListInfiniteGrid = ({ export const AlbumArtistListInfiniteGrid = ({
gap = 'md', gap = 'md',
@@ -17,8 +17,7 @@ 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 interface AlbumArtistListInfiniteTableProps extends ItemListTableComponentProps<AlbumArtistListQuery> {}
extends ItemListTableComponentProps<AlbumArtistListQuery> {}
export const AlbumArtistListInfiniteTable = ({ export const AlbumArtistListInfiniteTable = ({
autoFitColumns = false, autoFitColumns = false,
@@ -18,8 +18,7 @@ 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 interface AlbumArtistListPaginatedGridProps extends ItemListGridComponentProps<AlbumArtistListQuery> {}
extends ItemListGridComponentProps<AlbumArtistListQuery> {}
export const AlbumArtistListPaginatedGrid = ({ export const AlbumArtistListPaginatedGrid = ({
gap = 'md', gap = 'md',
@@ -19,8 +19,7 @@ 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 interface AlbumArtistListPaginatedTableProps extends ItemListTableComponentProps<AlbumArtistListQuery> {}
extends ItemListTableComponentProps<AlbumArtistListQuery> {}
export const AlbumArtistListPaginatedTable = ({ export const AlbumArtistListPaginatedTable = ({
autoFitColumns = false, autoFitColumns = false,
@@ -6,6 +6,7 @@ import { useSortByFilter } from '/@/renderer/features/shared/hooks/use-sort-by-f
import { useSortOrderFilter } from '/@/renderer/features/shared/hooks/use-sort-order-filter'; import { 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';
@@ -19,13 +20,15 @@ export const useAlbumArtistListFilters = () => {
const [, setSearchParams] = useSearchParams(); const [, setSearchParams] = useSearchParams();
const clear = useCallback(() => { const clear = useCallback(() => {
setSearchParams( runInUrlTransition(() => {
(prev) => setSearchParams(
setMultipleSearchParams(prev, { (prev) =>
[FILTER_KEYS.SHARED.SEARCH_TERM]: null, setMultipleSearchParams(prev, {
}), [FILTER_KEYS.SHARED.SEARCH_TERM]: null,
{ replace: true }, }),
); { replace: true },
);
});
}, [setSearchParams]); }, [setSearchParams]);
const query = { const query = {
@@ -0,0 +1,41 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { AxiosError } from 'axios';
import { api } from '/@/renderer/api';
import { queryKeys } from '/@/renderer/api/query-keys';
import { MutationHookArgs } from '/@/renderer/lib/react-query';
import { DeleteArtistImageArgs, DeleteArtistImageResponse } from '/@/shared/types/domain-types';
export const useDeleteArtistImage = (args: MutationHookArgs) => {
const { options } = args || {};
const queryClient = useQueryClient();
return useMutation<DeleteArtistImageResponse, AxiosError, DeleteArtistImageArgs, null>({
mutationFn: (args) => {
return api.controller.deleteArtistImage({
...args,
apiClientProps: { serverId: args.apiClientProps.serverId },
});
},
onSuccess: (_data, variables) => {
const { apiClientProps, query } = variables;
const serverId = apiClientProps.serverId;
if (!serverId) return;
queryClient.invalidateQueries({
queryKey: queryKeys.albumArtists.list(serverId),
});
if (query?.id) {
queryClient.invalidateQueries({
queryKey: queryKeys.albumArtists.detail(serverId, { id: query.id }),
});
queryClient.invalidateQueries({
queryKey: queryKeys.albumArtists.info(serverId, { id: query.id }),
});
}
},
...options,
});
};
@@ -0,0 +1,41 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { AxiosError } from 'axios';
import { api } from '/@/renderer/api';
import { queryKeys } from '/@/renderer/api/query-keys';
import { MutationHookArgs } from '/@/renderer/lib/react-query';
import { UploadArtistImageArgs, UploadArtistImageResponse } from '/@/shared/types/domain-types';
export const useUploadArtistImage = (args: MutationHookArgs) => {
const { options } = args || {};
const queryClient = useQueryClient();
return useMutation<UploadArtistImageResponse, AxiosError, UploadArtistImageArgs, null>({
mutationFn: (args) => {
return api.controller.uploadArtistImage({
...args,
apiClientProps: { serverId: args.apiClientProps.serverId },
});
},
onSuccess: (_data, variables) => {
const { apiClientProps, query } = variables;
const serverId = apiClientProps.serverId;
if (!serverId) return;
queryClient.invalidateQueries({
queryKey: queryKeys.albumArtists.list(serverId),
});
if (query?.id) {
queryClient.invalidateQueries({
queryKey: queryKeys.albumArtists.detail(serverId, { id: query.id }),
});
queryClient.invalidateQueries({
queryKey: queryKeys.albumArtists.info(serverId, { id: query.id }),
});
}
},
...options,
});
};
@@ -1,4 +1,4 @@
import { useQuery } from '@tanstack/react-query'; import { useSuspenseQueries } from '@tanstack/react-query';
import { useMemo } from 'react'; import { useMemo } from 'react';
import { useParams } from 'react-router'; import { useParams } from 'react-router';
@@ -35,20 +35,18 @@ const AlbumArtistDetailFavoriteSongsListRoute = () => {
const server = useCurrentServer(); const server = useCurrentServer();
const pageKey = LibraryItem.SONG; const pageKey = LibraryItem.SONG;
const detailQuery = useQuery( const [detailQuery, favoriteSongsQuery] = useSuspenseQueries({
artistsQueries.albumArtistDetail({ queries: [
query: { id: routeId }, artistsQueries.albumArtistDetail({
serverId: server?.id, query: { id: routeId },
}), serverId: server?.id,
); }),
artistsQueries.favoriteSongs({
const favoriteSongsQuery = useQuery( query: { artistId: routeId },
artistsQueries.favoriteSongs({ serverId: server?.id,
options: { enabled: !!detailQuery?.data?.name }, }),
query: { artistId: routeId }, ],
serverId: server?.id, });
}),
);
const songs = useMemo( const songs = useMemo(
() => favoriteSongsQuery?.data?.items || [], () => favoriteSongsQuery?.data?.items || [],
@@ -168,7 +166,7 @@ const AlbumArtistDetailFavoriteSongsListRoute = () => {
); );
}; };
const AlbumArtistDetailTopSongsListRouteWithBoundary = () => { const AlbumArtistDetailFavoriteSongsListRouteWithBoundary = () => {
return ( return (
<PageErrorBoundary> <PageErrorBoundary>
<AlbumArtistDetailFavoriteSongsListRoute /> <AlbumArtistDetailFavoriteSongsListRoute />
@@ -176,4 +174,4 @@ const AlbumArtistDetailTopSongsListRouteWithBoundary = () => {
); );
}; };
export default AlbumArtistDetailTopSongsListRouteWithBoundary; export default AlbumArtistDetailFavoriteSongsListRouteWithBoundary;
@@ -1,4 +1,4 @@
import { useQuery } from '@tanstack/react-query'; import { useSuspenseQuery } from '@tanstack/react-query';
import { useMemo } from 'react'; import { useMemo } from 'react';
import { useParams } from 'react-router'; import { useParams } from 'react-router';
@@ -34,16 +34,15 @@ const AlbumArtistDetailTopSongsListRoute = () => {
key: 'album-artist-top-songs-query-type', key: 'album-artist-top-songs-query-type',
}); });
const detailQuery = useQuery( const detailQuery = useSuspenseQuery(
artistsQueries.albumArtistDetail({ artistsQueries.albumArtistDetail({
query: { id: routeId }, query: { id: routeId },
serverId: server?.id, serverId: server?.id,
}), }),
); );
const topSongsQuery = useQuery( const topSongsQuery = useSuspenseQuery(
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,
}, },
modalKey: 'addToPlaylist', modal: '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,
}, },
modalKey: 'shareItem', modal: '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,6 +6,7 @@ import { useSortByFilter } from '/@/renderer/features/shared/hooks/use-sort-by-f
import { useSortOrderFilter } from '/@/renderer/features/shared/hooks/use-sort-order-filter'; import { 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';
@@ -29,13 +30,19 @@ export const useFolderListFilters = () => {
}, [searchParams]); }, [searchParams]);
const setFolderPath = (path: FolderPathItem[]) => { const setFolderPath = (path: FolderPathItem[]) => {
setSearchParams( runInUrlTransition(() => {
(prev) => { setSearchParams(
const newParams = setJsonSearchParam(prev, FILTER_KEYS.FOLDER.FOLDER_PATH, path); (prev) => {
return newParams; const newParams = setJsonSearchParam(
}, prev,
{ replace: false }, FILTER_KEYS.FOLDER.FOLDER_PATH,
); path,
);
return newParams;
},
{ replace: false },
);
});
}; };
// Navigate to a folder (adds to path) // Navigate to a folder (adds to path)
@@ -131,7 +131,9 @@ export const LyricsActions = ({
uppercase uppercase
variant="subtle" variant="subtle"
> >
{t('common.clear', { postProcess: 'sentenceCase' })} {hasLyrics
? t('common.clear', { postProcess: 'sentenceCase' })
: t('common.refresh', { postProcess: 'sentenceCase' })}
</Button> </Button>
) : null} ) : null}
</Group> </Group>
@@ -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 },
modalKey: 'lyricsSettings', modal: 'lyricsSettings',
overlayProps: { overlayProps: {
blur: 0, blur: 0,
opacity: 0, opacity: 0,
@@ -0,0 +1,17 @@
.toolbar {
width: 100%;
min-width: 0;
container-type: inline-size;
}
.restore-section {
display: contents;
}
@container (max-width: 296px) {
.restore-section {
display: none;
}
}
@@ -3,6 +3,8 @@ import { t } from 'i18next';
import { RefObject } from 'react'; import { 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';
@@ -16,6 +18,8 @@ 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';
@@ -33,6 +37,53 @@ 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();
@@ -52,53 +103,29 @@ export const PlayQueueListControls = ({
}; };
return ( return (
<Group h="65px" justify="space-between" px="1rem" py="1rem" w="100%"> <>
<Group gap="xs"> <ActionIcon
<QueueRestoreActions /> icon="mediaShuffle"
<ActionIcon iconProps={{ size: 'lg' }}
icon="mediaShuffle" onClick={handleShuffleQueue}
iconProps={{ size: 'lg' }} tooltip={{ label: t('player.shuffle', { postProcess: 'sentenceCase' }) }}
onClick={handleShuffleQueue} variant="subtle"
tooltip={{ label: t('player.shuffle', { postProcess: 'sentenceCase' }) }} />
variant="subtle" <ActionIcon
/> icon="x"
<ActionIcon iconProps={{ size: 'lg' }}
icon="x" onClick={handleClearQueue}
iconProps={{ size: 'lg' }} tooltip={{ label: t('action.clearQueue', { postProcess: 'sentenceCase' }) }}
onClick={handleClearQueue} variant="subtle"
tooltip={{ label: t('action.clearQueue', { postProcess: 'sentenceCase' }) }} />
variant="subtle" <ActionIcon
/> icon="goToItem"
<ActionIcon iconProps={{ size: 'lg' }}
icon="goToItem" onClick={handleJumpToCurrent}
iconProps={{ size: 'lg' }} tooltip={{ label: t('action.goToCurrent', { postProcess: 'sentenceCase' }) }}
onClick={handleJumpToCurrent} variant="subtle"
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>
); );
}; };
@@ -117,7 +144,7 @@ const QueueRestoreActions = () => {
} }
return ( return (
<> <span className={styles.restoreSection}>
<ActionIcon <ActionIcon
disabled={Boolean(isFetching)} disabled={Boolean(isFetching)}
icon="upload" icon="upload"
@@ -144,6 +171,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, PlayerType } from '/@/shared/types/types'; import { ItemListKey, Platform } 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 { type, webAudio } = usePlaybackSettings(); const { webAudio } = usePlaybackSettings();
const { windowBarStyle } = useWindowSettings(); const { windowBarStyle } = useWindowSettings();
const showVisualizer = showVisualizerInSidebar && type === PlayerType.WEB && webAudio; const showVisualizer = showVisualizerInSidebar && 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 { type, webAudio } = usePlaybackSettings(); const { webAudio } = usePlaybackSettings();
const showVisualizer = showVisualizerInSidebar && type === PlayerType.WEB && webAudio; const showVisualizer = showVisualizerInSidebar && webAudio;
const { data: lyricsData } = useQuery( const { data: lyricsData } = useQuery(
lyricsQueries.songLyrics( lyricsQueries.songLyrics(
@@ -216,6 +216,10 @@ export const MpvPlayerEngine = (props: MpvPlayerEngineProps) => {
return; return;
} }
if (playerStatus !== PlayerStatus.PLAYING) {
return;
}
const updateProgress = async () => { const updateProgress = async () => {
if (!mpvPlayer || !isMountedRef.current) { if (!mpvPlayer || !isMountedRef.current) {
return; return;
@@ -245,7 +249,7 @@ export const MpvPlayerEngine = (props: MpvPlayerEngineProps) => {
progressIntervalRef.current = null; progressIntervalRef.current = null;
} }
}; };
}, [hasCurrentSong, isTransitioning, onProgress]); }, [hasCurrentSong, isTransitioning, onProgress, playerStatus]);
const { mediaAutoNext } = usePlayerActions(); const { mediaAutoNext } = usePlayerActions();
@@ -80,7 +80,7 @@ export const useMainPlayerListener = () => {
mpvPlayerListener.rendererStop(() => { mpvPlayerListener.rendererStop(() => {
if (!isRadioActive) { if (!isRadioActive) {
mediaStop(); mediaStop({ reset: false });
} }
}); });
@@ -25,6 +25,7 @@ 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,
@@ -33,25 +34,39 @@ import {
usePlaybackType, usePlaybackType,
useSettingsStoreActions, useSettingsStoreActions,
} from '/@/renderer/store'; } from '/@/renderer/store';
import { logFn } from '/@/renderer/utils/logger';
import { toast } from '/@/shared/components/toast/toast'; import { toast } from '/@/shared/components/toast/toast';
import { LibraryItem } from '/@/shared/types/domain-types'; import { LibraryItem } from '/@/shared/types/domain-types';
import { PlayerType } from '/@/shared/types/types'; 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"' },
]; ];
const DEFAULT_TRANSCODING_PROFILES = [ const DEFAULT_TRANSCODING_PROFILES = [
{ audioCodec: 'flac', container: 'flac', protocol: 'http' },
{ audioCodec: 'opus', container: 'ogg', protocol: 'http' }, { audioCodec: 'opus', container: 'ogg', protocol: 'http' },
{ 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[];
@@ -59,7 +74,7 @@ const DIRECT_PLAY_PROFILES: {
}[] = []; }[] = [];
export function getDefaultTranscodingProfiles() { export function getDefaultTranscodingProfiles() {
return DEFAULT_TRANSCODING_PROFILES; return isSafari() ? SAFARI_TRANSCODING_PROFILES : DEFAULT_TRANSCODING_PROFILES;
} }
export function getDirectPlayProfiles() { export function getDirectPlayProfiles() {
@@ -71,18 +86,25 @@ 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) === 'probably') { if (audio.canPlayType(mime) === 'maybe' || audio.canPlayType(mime) === 'probably') {
DIRECT_PLAY_PROFILES.push({ DIRECT_PLAY_PROFILES.push({
audioCodecs: [codec], audioCodecs: Array.isArray(codec) ? codec : [codec],
containers: [container], containers: [container],
protocols: ['http'], protocols: ['http'],
}); });
} }
} }
logFn.info('DIRECT_PLAY_PROFILES', { meta: DIRECT_PLAY_PROFILES });
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();
@@ -96,7 +118,6 @@ export const AudioPlayers = () => {
const { setWebAudio, webAudio: audioContext } = useWebAudio(); const { setWebAudio, webAudio: audioContext } = useWebAudio();
useEffect(() => { useEffect(() => {
console.log('getDirectPlayProfiles');
detectBrowserProfile(); detectBrowserProfile();
}, []); }, []);
@@ -116,6 +137,7 @@ export const AudioPlayers = () => {
<UpdateCurrentSongHook /> <UpdateCurrentSongHook />
<RadioAudioInstanceHook /> <RadioAudioInstanceHook />
<RadioMetadataHook /> <RadioMetadataHook />
<VisualizerSystemAudioBridgeHook />
<AutosaveHook /> <AutosaveHook />
<AudioPlayersContent <AudioPlayersContent
audioContext={audioContext} audioContext={audioContext}
@@ -112,7 +112,7 @@ const StopButton = ({ disabled }: { disabled?: boolean }) => {
<PlayerButton <PlayerButton
disabled={disabled} disabled={disabled}
icon={<Icon fill="default" icon="mediaStop" size={buttonSize - 2} />} icon={<Icon fill="default" icon="mediaStop" size={buttonSize - 2} />}
onClick={mediaStop} onClick={() => mediaStop()}
tooltip={{ tooltip={{
label: t('player.stop', { postProcess: 'sentenceCase' }), label: t('player.stop', { postProcess: 'sentenceCase' }),
openDelay: 0, openDelay: 0,
@@ -269,25 +269,7 @@ export const FullScreenPlayerImage = () => {
? radioMetadata?.title || stationName || 'Radio' ? radioMetadata?.title || stationName || 'Radio'
: currentSong?.name} : currentSong?.name}
</Text> </Text>
{isPlayingRadio ? ( <Text key="fs-artists" size="xl">
<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) => (
@@ -314,6 +296,24 @@ 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, PlayerType } from '/@/shared/types/types'; import { ItemListKey } 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 { type, webAudio } = usePlaybackSettings(); const { 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 (type === PlayerType.WEB && webAudio) { if (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, type, webAudio]); }, [activeTab, setStore, t, 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' && type === PlayerType.WEB && webAudio ? ( ) : activeTab === 'visualizer' && 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, PlayerType } from '/@/shared/types/types'; import { Platform } 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 { type, webAudio } = usePlaybackSettings(); const { 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}>
{type === PlayerType.WEB && webAudio ? ( {webAudio ? (
<Suspense fallback={<></>}> <Suspense fallback={<></>}>
{visualizerType === 'butterchurn' ? ( {visualizerType === 'butterchurn' ? (
<ButterchurnVisualizer /> <ButterchurnVisualizer />
@@ -8,10 +8,16 @@ 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 { JoinedArtists } from '/@/renderer/features/albums/components/joined-artists'; import {
JOINED_ARTISTS_MUTED_PROPS,
JoinedArtists,
} from '/@/renderer/features/albums/components/joined-artists';
import { ContextMenuController } from '/@/renderer/features/context-menu/context-menu-controller'; import { 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 { useIsRadioActive } from '/@/renderer/features/radio/hooks/use-radio-player'; import {
useIsRadioActive,
useRadioPlayer,
} from '/@/renderer/features/radio/hooks/use-radio-player';
import { AppRoute } from '/@/renderer/router/routes'; import { AppRoute } from '/@/renderer/router/routes';
import { import {
useAppStore, useAppStore,
@@ -50,9 +56,11 @@ export const LeftControls = () => {
const currentSong = usePlayerSong(); const currentSong = usePlayerSong();
const isRadioActive = useIsRadioActive(); const isRadioActive = useIsRadioActive();
const { currentStationArt } = useRadioPlayer();
const { bindings } = useHotkeySettings(); const { bindings } = useHotkeySettings();
const isRadioMode = isRadioActive; const isRadioMode = isRadioActive;
const hasRadioStationImage = Boolean(currentStationArt?.imageId || currentStationArt?.imageUrl);
const hideImage = image && !collapsed; const hideImage = image && !collapsed;
const isSongDefined = Boolean(currentSong?.id) && !isRadioMode; const isSongDefined = Boolean(currentSong?.id) && !isRadioMode;
const title = currentSong?.name; const title = currentSong?.name;
@@ -128,7 +136,22 @@ export const LeftControls = () => {
})} })}
openDelay={0} openDelay={0}
> >
{isRadioMode ? ( {isRadioMode && hasRadioStationImage ? (
<ItemImage
className={clsx(
styles.playerbarImage,
PlaybackSelectors.playerCoverArt,
)}
enableDebounce={false}
enableViewport={false}
fetchPriority="high"
id={currentStationArt?.imageId ?? undefined}
itemType={LibraryItem.RADIO_STATION}
serverId={currentStationArt?.serverId}
src={currentStationArt?.imageUrl ?? ''}
type="table"
/>
) : isRadioMode ? (
<Center <Center
className={clsx( className={clsx(
styles.playerbarImage, styles.playerbarImage,
@@ -246,6 +269,14 @@ 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
@@ -189,7 +189,7 @@ const randomFetchQuery = (args: {
export const openShuffleAllModal = async () => { export const openShuffleAllModal = async () => {
openContextModal({ openContextModal({
innerProps: {}, innerProps: {},
modalKey: 'shuffleAll', modal: 'shuffleAll',
size: 'sm', size: 'sm',
title: i18n.t('player.playRandom', { postProcess: 'sentenceCase' }) as string, title: i18n.t('player.playRandom', { postProcess: 'sentenceCase' }) as string,
}); });
@@ -64,7 +64,7 @@ export interface PlayerContext {
mediaSeekToTimestamp: (timestamp: number) => void; mediaSeekToTimestamp: (timestamp: number) => void;
mediaSkipBackward: () => void; mediaSkipBackward: () => void;
mediaSkipForward: () => void; mediaSkipForward: () => void;
mediaStop: () => void; mediaStop: (options?: { reset?: boolean }) => void;
mediaToggleMute: () => void; mediaToggleMute: () => void;
mediaTogglePlayPause: () => void; mediaTogglePlayPause: () => void;
moveSelectedTo: (items: QueueSong[], edge: 'bottom' | 'top', uniqueId: string) => void; moveSelectedTo: (items: QueueSong[], edge: 'bottom' | 'top', uniqueId: string) => void;
@@ -596,13 +596,17 @@ export const PlayerProvider = ({ children }: { children: React.ReactNode }) => {
storeActions.mediaPrevious(); storeActions.mediaPrevious();
}, [storeActions]); }, [storeActions]);
const mediaStop = useCallback(() => { const mediaStop = useCallback(
logFn.debug(logMsg[LogCategory.PLAYER].mediaStop, { (options?: { reset?: boolean }) => {
category: LogCategory.PLAYER, logFn.debug(logMsg[LogCategory.PLAYER].mediaStop, {
}); category: LogCategory.PLAYER,
meta: { reset: options?.reset },
});
storeActions.mediaStop(); storeActions.mediaStop(options);
}, [storeActions]); },
[storeActions],
);
const mediaSeekToTimestamp = useCallback( const mediaSeekToTimestamp = useCallback(
(timestamp: number) => { (timestamp: number) => {
@@ -0,0 +1,17 @@
import {
useFullScreenPlayerStore,
usePlaybackSettings,
useShowVisualizerInSidebar,
} from '/@/renderer/store';
export function useIsLocalVisualizerSurfaceVisible(): boolean {
const { webAudio: webAudioEnabled } = usePlaybackSettings();
const showVisualizerInSidebar = useShowVisualizerInSidebar();
const { activeTab, expanded, visualizerExpanded } = useFullScreenPlayerStore();
const sidebarVisualizer = showVisualizerInSidebar && webAudioEnabled;
const fullScreenPlayerVisualizerTab = expanded && activeTab === 'visualizer' && webAudioEnabled;
const fullScreenVisualizerOverlay = visualizerExpanded && webAudioEnabled;
return sidebarVisualizer || fullScreenPlayerVisualizerTab || fullScreenVisualizerOverlay;
}
@@ -1,5 +1,6 @@
import isElectron from 'is-electron'; import isElectron from 'is-electron';
import React, { useCallback, useEffect, useMemo } from 'react'; import { debounce } from 'lodash';
import React, { useCallback, useEffect, useMemo, useRef } from 'react';
import { getItemImageUrl } from '/@/renderer/components/item-image/item-image'; import { 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';
@@ -9,8 +10,9 @@ 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,
@@ -29,6 +31,40 @@ 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()) {
@@ -38,71 +74,87 @@ 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 (isRadioActive && isRadioPlaying) { if (isRadioActiveRef.current && isRadioPlayingRef.current) {
return; return;
} }
player.mediaNext(); playerRef.current.mediaNext();
}); });
mediaSession.setActionHandler('pause', () => { mediaSession.setActionHandler('pause', () => {
player.mediaPause(); playerRef.current.mediaPause();
}); });
mediaSession.setActionHandler('play', () => { mediaSession.setActionHandler('play', () => {
player.mediaPlay(); playerRef.current.mediaPlay();
}); });
mediaSession.setActionHandler('previoustrack', () => { mediaSession.setActionHandler('previoustrack', () => {
if (isRadioActive && isRadioPlaying) { if (isRadioActiveRef.current && isRadioPlayingRef.current) {
return; return;
} }
player.mediaPrevious(); playerRef.current.mediaPrevious();
}); });
mediaSession.setActionHandler('seekto', (e) => { mediaSession.setActionHandler('seekto', (e) => {
if (isRadioActive && isRadioPlaying) { if (isRadioActiveRef.current && isRadioPlayingRef.current) {
return; return;
} }
if (e.seekTime) { if (e.seekTime) {
player.mediaSeekToTimestamp(e.seekTime); playerRef.current.mediaSeekToTimestamp(e.seekTime);
} else if (e.seekOffset) { } else if (e.seekOffset) {
const currentTimestamp = useTimestampStoreBase.getState().timestamp; const currentTimestamp = useTimestampStoreBase.getState().timestamp;
player.mediaSeekToTimestamp(currentTimestamp + e.seekOffset); playerRef.current.mediaSeekToTimestamp(currentTimestamp + e.seekOffset);
} }
}); });
mediaSession.setActionHandler('stop', () => { mediaSession.setActionHandler('stop', () => {
player.mediaStop(); playerRef.current.mediaStop();
}); });
mediaSession.setActionHandler('seekbackward', (e) => { mediaSession.setActionHandler('seekbackward', (e) => {
if (isRadioActive && isRadioPlaying) { if (isRadioActiveRef.current && isRadioPlayingRef.current) {
return; return;
} }
const currentTimestamp = useTimestampStoreBase.getState().timestamp; const currentTimestamp = useTimestampStoreBase.getState().timestamp;
player.mediaSeekToTimestamp( playerRef.current.mediaSeekToTimestamp(
currentTimestamp - (e.seekOffset || skip?.skipBackwardSeconds || 5), currentTimestamp - (e.seekOffset || skipRef.current?.skipBackwardSeconds || 5),
); );
}); });
mediaSession.setActionHandler('seekforward', (e) => { mediaSession.setActionHandler('seekforward', (e) => {
if (isRadioActive && isRadioPlaying) { if (isRadioActiveRef.current && isRadioPlayingRef.current) {
return; return;
} }
const currentTimestamp = useTimestampStoreBase.getState().timestamp; const currentTimestamp = useTimestampStoreBase.getState().timestamp;
player.mediaSeekToTimestamp( playerRef.current.mediaSeekToTimestamp(
currentTimestamp + (e.seekOffset || skip?.skipForwardSeconds || 5), currentTimestamp + (e.seekOffset || skipRef.current?.skipForwardSeconds || 5),
); );
}); });
@@ -116,28 +168,22 @@ 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) => {
if (!isMediaSessionEnabled) { // Read from ref so this callback is never stale regardless of when it was created
if (!isMediaSessionEnabledRef.current) {
return; return;
} }
// Handle radio metadata when radio is active and playing // Handle radio metadata when radio is active and playing
if (isRadioActive && isRadioPlaying) { if (isRadioActiveRef.current && isRadioPlayingRef.current) {
const title = radioMetadata?.title || stationName || 'Radio'; const title = radioMetadataRef.current?.title || stationNameRef.current || 'Radio';
const artist = radioMetadata?.artist || stationName || ''; const artist = radioMetadataRef.current?.artist || stationNameRef.current || '';
mediaSession.metadata = new MediaMetadata({ mediaSession.metadata = new MediaMetadata({
album: stationName || '', album: stationNameRef.current || '',
artist: artist, artist: artist,
artwork: [], artwork: [],
title: title, title: title,
@@ -164,62 +210,88 @@ export const useMediaSession = () => {
title: song?.name ?? '', title: song?.name ?? '',
}); });
}, },
[isMediaSessionEnabled, isRadioActive, isRadioPlaying, radioMetadata, stationName], // All values are read from refs — stable callback, no stale closure risk
[],
); );
// Debounced version to handle rapid skipping — only the last skip in a burst commits
// to the media session. Without this, rapid MediaMetadata assignments can tear the
// browser's media session state and permanently drop the handlers.
const debouncedUpdateMetadata = useRef(
debounce((song: QueueSong | undefined) => {
updateMediaSessionMetadata(song);
}, 100),
).current;
// Cancel any pending debounced update on unmount
useEffect(() => {
return () => {
debouncedUpdateMetadata.cancel();
};
}, [debouncedUpdateMetadata]);
// Update metadata when radio metadata changes // Update metadata when radio metadata changes
useEffect(() => { useEffect(() => {
if (!isMediaSessionEnabled) { if (!isMediaSessionEnabled) {
return; return;
} }
if (isRadioActive && isRadioPlaying) { if (isRadioActiveRef.current && isRadioPlayingRef.current) {
updateMediaSessionMetadata(undefined); debouncedUpdateMetadata(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(
{ {
onCurrentSongChange: (properties) => {
if (!isMediaSessionEnabled) {
return;
}
if (isRadioActive && isRadioPlaying) {
return;
}
updateMediaSessionMetadata(properties.song);
},
onPlayerRepeated: () => { onPlayerRepeated: () => {
if (!isMediaSessionEnabled) { if (!isMediaSessionEnabledRef.current) {
return; return;
} }
if (isRadioActive && isRadioPlaying) { if (isRadioActiveRef.current && isRadioPlayingRef.current) {
return; return;
} }
const currentSong = usePlayerStore.getState().getCurrentSong(); const currentSong = usePlayerStore.getState().getCurrentSong();
updateMediaSessionMetadata(currentSong); debouncedUpdateMetadata(currentSong);
},
onPlayerStatus: (properties) => {
if (!isMediaSessionEnabled) {
return;
}
const status = properties.status;
mediaSession.playbackState = status === PlayerStatus.PLAYING ? 'playing' : 'paused';
}, },
}, },
[isMediaSessionEnabled, isRadioActive, isRadioPlaying, updateMediaSessionMetadata], [],
); );
}; };
@@ -229,18 +301,7 @@ const MediaSessionHookInner = () => {
}; };
export const MediaSessionHook = () => { export const MediaSessionHook = () => {
const isElectronEnv = isElectron(); // Always render the hook — let the internal guard logic decide whether to act.
const playbackType = usePlaybackType(); // Conditional rendering here causes unmount/remount cycles that destroy handlers mid-session.
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);
}; };
@@ -0,0 +1,155 @@
import isElectron from 'is-electron';
import { useCallback, useEffect, useRef } from 'react';
import i18n from '/@/i18n/i18n';
import { useWebAudio } from '/@/renderer/features/player/hooks/use-webaudio';
import { usePlaybackType } from '/@/renderer/store/settings.store';
import { toast } from '/@/shared/components/toast/toast';
import { PlayerType } from '/@/shared/types/types';
export function useVisualizerSystemAudio(options: {
onSystemAudioCaptureDenied?: () => void;
onSystemAudioCaptureSuccess?: () => void;
shouldAttemptConnection: boolean;
}) {
const { onSystemAudioCaptureDenied, onSystemAudioCaptureSuccess, shouldAttemptConnection } =
options;
const onDeniedRef = useRef(onSystemAudioCaptureDenied);
const onSuccessRef = useRef(onSystemAudioCaptureSuccess);
onDeniedRef.current = onSystemAudioCaptureDenied;
onSuccessRef.current = onSystemAudioCaptureSuccess;
const playbackType = usePlaybackType();
const { setWebAudio, webAudio } = useWebAudio();
const webAudioRef = useRef(webAudio);
const streamRef = useRef<MediaStream | null>(null);
const sourceRef = useRef<MediaStreamAudioSourceNode | null>(null);
const connectInFlightRef = useRef(false);
useEffect(() => {
webAudioRef.current = webAudio;
}, [webAudio]);
const disconnect = useCallback(() => {
if (streamRef.current) {
streamRef.current.getTracks().forEach((t) => t.stop());
streamRef.current = null;
}
if (sourceRef.current) {
try {
sourceRef.current.disconnect();
} catch {
// ignore
}
sourceRef.current = null;
}
const w = webAudioRef.current;
if (w?.visualizerInputs?.length && setWebAudio) {
const next = { ...w, visualizerInputs: undefined };
setWebAudio(next);
webAudioRef.current = next;
}
}, [setWebAudio]);
useEffect(() => {
if (playbackType === PlayerType.WEB || !shouldAttemptConnection) {
disconnect();
}
}, [playbackType, shouldAttemptConnection, disconnect]);
const connect = useCallback(async () => {
if (!isElectron()) {
return;
}
const w = webAudioRef.current;
if (!w?.context || w.context.state === 'closed') {
return;
}
if (!setWebAudio) return;
disconnect();
const wAfterDisconnect = webAudioRef.current;
if (!wAfterDisconnect?.context || wAfterDisconnect.context.state === 'closed') {
return;
}
connectInFlightRef.current = true;
try {
const stream = await navigator.mediaDevices.getDisplayMedia({
audio: true,
video: false,
});
const audioTracks = stream.getAudioTracks();
if (audioTracks.length === 0) {
stream.getTracks().forEach((t) => t.stop());
onDeniedRef.current?.();
return;
}
const latest = webAudioRef.current;
if (!latest?.context || latest.context.state === 'closed') {
stream.getTracks().forEach((t) => t.stop());
return;
}
try {
await latest.context.resume();
} catch {
// ignore
}
const source = latest.context.createMediaStreamSource(stream);
streamRef.current = stream;
sourceRef.current = source;
const next = { ...latest, visualizerInputs: [source] };
setWebAudio(next);
webAudioRef.current = next;
onSuccessRef.current?.();
} catch (e) {
const name = (e as DOMException)?.name;
if (name === 'NotAllowedError' || name === 'AbortError') {
onDeniedRef.current?.();
return;
}
toast.error({
message: i18n.t('visualizer.systemAudioCaptureFailed', {
message: (e as Error).message,
}),
});
} finally {
connectInFlightRef.current = false;
}
}, [disconnect, setWebAudio]);
const connectRef = useRef(connect);
connectRef.current = connect;
useEffect(() => {
if (playbackType !== PlayerType.LOCAL || !isElectron() || !shouldAttemptConnection) {
return;
}
const w = webAudioRef.current;
if (!w?.context || w.context.state === 'closed') {
return;
}
if (w.visualizerInputs?.length) {
return;
}
if (connectInFlightRef.current) {
return;
}
void connectRef.current();
}, [
playbackType,
shouldAttemptConnection,
webAudio?.context,
webAudio?.visualizerInputs?.length,
]);
}
@@ -0,0 +1,15 @@
import { useFullScreenPlayerStore, useSettingsStore } from '/@/renderer/store';
export function closeLocalVisualizerSurfaces(): void {
const fullScreen = useFullScreenPlayerStore.getState();
fullScreen.actions.setStore({
...(fullScreen.expanded && fullScreen.activeTab === 'visualizer'
? { activeTab: 'queue' as const }
: {}),
visualizerExpanded: false,
});
useSettingsStore.getState().actions.setSettings({
general: { showVisualizerInSidebar: false },
});
}
@@ -0,0 +1,14 @@
import type { WebAudio } from '/@/shared/types/types';
import { PlayerType } from '/@/shared/types/types';
export function getVisualizerAudioNodes(
webAudio: undefined | WebAudio,
playbackType: PlayerType,
): AudioNode[] {
if (!webAudio) return [];
if (playbackType === PlayerType.LOCAL) {
return webAudio.visualizerInputs ?? [];
}
return webAudio.gains;
}
@@ -5,7 +5,7 @@ import i18n from '/@/i18n/i18n';
export const openVisualizerSettingsModal = () => { export const openVisualizerSettingsModal = () => {
openContextModal({ openContextModal({
innerProps: {}, innerProps: {},
modalKey: 'visualizerSettings', modal: 'visualizerSettings',
overlayProps: { overlayProps: {
blur: 0, blur: 0,
opacity: 0, opacity: 0,
@@ -72,6 +72,7 @@ export const CreatePlaylistForm = ({ onCancel }: CreatePlaylistFormProps) => {
? { ? {
...convertQueryGroupToNDQuery(smartPlaylist.filters), ...convertQueryGroupToNDQuery(smartPlaylist.filters),
limit: smartPlaylist.extraFilters.limit, limit: smartPlaylist.extraFilters.limit,
limitPercent: smartPlaylist.extraFilters.limitPercent,
// order field is now optional - sort direction is embedded in sort field // order field is now optional - sort direction is embedded in sort field
sort: sortValue || '+dateAdded', sort: sortValue || '+dateAdded',
} }
@@ -20,8 +20,10 @@ 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 interface PlaylistDetailSongListGridProps extends Omit<
extends Omit<ItemListGridComponentProps<PlaylistSongListQuery>, 'query'> { ItemListGridComponentProps<PlaylistSongListQuery>,
'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 },
modalKey: 'saveAndReplace', modal: 'saveAndReplace',
size: 'sm', size: 'sm',
title: i18n.t('common.saveAndReplace', { postProcess: 'titleCase' }) as string, title: i18n.t('common.saveAndReplace', { postProcess: 'titleCase' }) as string,
}); });
@@ -1,4 +1,5 @@
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { useCallback } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useLocation, useParams } from 'react-router'; import { useLocation, useParams } from 'react-router';
@@ -8,6 +9,8 @@ import { useListContext } from '/@/renderer/context/list-context';
import { usePlayer } from '/@/renderer/features/player/context/player-context'; import { usePlayer } from '/@/renderer/features/player/context/player-context';
import { playlistsQueries } from '/@/renderer/features/playlists/api/playlists-api'; import { playlistsQueries } from '/@/renderer/features/playlists/api/playlists-api';
import { PlaylistDetailSongListHeaderFilters } from '/@/renderer/features/playlists/components/playlist-detail-song-list-header-filters'; import { PlaylistDetailSongListHeaderFilters } from '/@/renderer/features/playlists/components/playlist-detail-song-list-header-filters';
import { useDeletePlaylistImage } from '/@/renderer/features/playlists/mutations/delete-playlist-image-mutation';
import { useUploadPlaylistImage } from '/@/renderer/features/playlists/mutations/upload-playlist-image-mutation';
import { FilterBar } from '/@/renderer/features/shared/components/filter-bar'; import { FilterBar } from '/@/renderer/features/shared/components/filter-bar';
import { import {
LibraryHeader, LibraryHeader,
@@ -18,9 +21,17 @@ import { ListSearchInput } from '/@/renderer/features/shared/components/list-sea
import { AppRoute } from '/@/renderer/router/routes'; import { AppRoute } from '/@/renderer/router/routes';
import { useCurrentServer } from '/@/renderer/store'; import { useCurrentServer } from '/@/renderer/store';
import { formatDurationString } from '/@/renderer/utils'; import { formatDurationString } from '/@/renderer/utils';
import { replaceURLWithHTMLLinks } from '/@/renderer/utils/linkify';
import { hasFeature } from '/@/shared/api/utils';
import { ActionIcon } from '/@/shared/components/action-icon/action-icon';
import { FileButton } from '/@/shared/components/file-button/file-button';
import { Group } from '/@/shared/components/group/group';
import { Spoiler } from '/@/shared/components/spoiler/spoiler';
import { Stack } from '/@/shared/components/stack/stack'; import { Stack } from '/@/shared/components/stack/stack';
import { Text } from '/@/shared/components/text/text';
import { useLocalStorage } from '/@/shared/hooks/use-local-storage'; import { useLocalStorage } from '/@/shared/hooks/use-local-storage';
import { LibraryItem, Song } from '/@/shared/types/domain-types'; import { LibraryItem, Playlist, Song } from '/@/shared/types/domain-types';
import { ServerFeature } from '/@/shared/types/features-types';
import { Play } from '/@/shared/types/types'; import { Play } from '/@/shared/types/types';
interface PlaylistDetailSongListHeaderProps { interface PlaylistDetailSongListHeaderProps {
@@ -30,6 +41,61 @@ interface PlaylistDetailSongListHeaderProps {
onToggleQueryBuilder?: () => void; onToggleQueryBuilder?: () => void;
} }
function ImageUploadOverlay({
data,
onUploadFile,
}: {
data?: Playlist;
onUploadFile: (file: File) => Promise<void>;
}) {
const deletePlaylistImageMutation = useDeletePlaylistImage({});
const server = useCurrentServer();
if (!data) return null;
if (!hasFeature(server, ServerFeature.PLAYLIST_IMAGE_UPLOAD)) return null;
return (
<Group gap="xs">
<FileButton
accept="image/*"
onChange={async (file) => {
if (!file) return;
await onUploadFile(file);
}}
>
{(props) => (
<ActionIcon
icon="uploadImage"
iconProps={{ size: 'lg' }}
radius="xl"
size="xs"
variant="default"
{...props}
/>
)}
</FileButton>
<ActionIcon
disabled={!data?.uploadedImage}
icon="delete"
iconProps={{ size: 'lg' }}
onClick={(e) => {
e.stopPropagation();
if (!data?._serverId) return;
deletePlaylistImageMutation.mutate({
apiClientProps: {
serverId: data._serverId,
},
query: { id: data.id },
});
}}
radius="xl"
size="xs"
variant="default"
/>
</Group>
);
}
export const PlaylistDetailSongListHeader = ({ export const PlaylistDetailSongListHeader = ({
isSmartPlaylist, isSmartPlaylist,
}: PlaylistDetailSongListHeaderProps) => { }: PlaylistDetailSongListHeaderProps) => {
@@ -45,6 +111,7 @@ export const PlaylistDetailSongListHeader = ({
}); });
const playlistDuration = detailQuery?.data?.duration; const playlistDuration = detailQuery?.data?.duration;
const playlistDescription = detailQuery?.data?.description?.trim();
const [collapsed] = useLocalStorage<boolean>({ const [collapsed] = useLocalStorage<boolean>({
defaultValue: false, defaultValue: false,
@@ -52,11 +119,33 @@ export const PlaylistDetailSongListHeader = ({
}); });
const player = usePlayer(); const player = usePlayer();
const uploadPlaylistImageMutation = useUploadPlaylistImage({});
const handlePlay = (type?: Play) => { const handlePlay = (type?: Play) => {
player.addToQueueByData(listData as Song[], type || Play.NOW); player.addToQueueByData(listData as Song[], type || Play.NOW);
}; };
const canUploadPlaylistImage =
hasFeature(server, ServerFeature.PLAYLIST_IMAGE_UPLOAD) &&
Boolean(detailQuery?.data?._serverId);
const handlePlaylistImageUpload = useCallback(
async (file: File) => {
const playlist = detailQuery?.data;
if (!playlist?._serverId) return;
const buffer = await file.arrayBuffer();
uploadPlaylistImageMutation.mutate({
apiClientProps: {
serverId: playlist._serverId,
},
body: { image: new Uint8Array(buffer) },
query: { id: playlist.id },
});
},
[detailQuery?.data, uploadPlaylistImageMutation],
);
const imageUrl = useItemImageUrl({ const imageUrl = useItemImageUrl({
id: detailQuery?.data?.imageId || undefined, id: detailQuery?.data?.imageId || undefined,
itemType: LibraryItem.PLAYLIST, itemType: LibraryItem.PLAYLIST,
@@ -94,6 +183,12 @@ export const PlaylistDetailSongListHeader = ({
) : ( ) : (
<LibraryHeader <LibraryHeader
compact compact
imageOverlay={
<ImageUploadOverlay
data={detailQuery?.data}
onUploadFile={handlePlaylistImageUpload}
/>
}
imageUrl={imageUrl} imageUrl={imageUrl}
item={{ item={{
imageId: detailQuery?.data?.imageId, imageId: detailQuery?.data?.imageId,
@@ -101,13 +196,36 @@ export const PlaylistDetailSongListHeader = ({
route: AppRoute.PLAYLISTS, route: AppRoute.PLAYLISTS,
type: LibraryItem.PLAYLIST, type: LibraryItem.PLAYLIST,
}} }}
onImageFileDrop={canUploadPlaylistImage ? handlePlaylistImageUpload : undefined}
title={detailQuery?.data?.name || ''} title={detailQuery?.data?.name || ''}
topRight={<ListSearchInput />} topRight={<ListSearchInput />}
> >
<LibraryHeaderMenu <Stack gap="md" w="100%">
onPlay={(type) => handlePlay(type)} {playlistDescription ? (
onShuffle={() => handlePlay(Play.SHUFFLE)} <Spoiler
/> hideLabel={<></>}
maxHeight={16}
showLabel={<></>}
style={{ marginBottom: 0 }}
>
<Text
isMuted
size="sm"
style={{
maxWidth: '100%',
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
}}
>
{replaceURLWithHTMLLinks(playlistDescription)}
</Text>
</Spoiler>
) : null}
<LibraryHeaderMenu
onPlay={(type) => handlePlay(type)}
onShuffle={() => handlePlay(Play.SHUFFLE)}
/>
</Stack>
</LibraryHeader> </LibraryHeader>
)} )}
<FilterBar> <FilterBar>
@@ -23,8 +23,10 @@ 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 interface PlaylistDetailSongListTableProps extends Omit<
extends Omit<ItemListTableComponentProps<PlaylistSongListQuery>, 'query'> { ItemListTableComponentProps<PlaylistSongListQuery>,
'query'
> {
currentPage?: number; currentPage?: number;
data: PlaylistSongListResponse; data: PlaylistSongListResponse;
items?: Song[]; items?: Song[];
@@ -32,6 +32,7 @@ import { Flex } from '/@/shared/components/flex/flex';
import { Group } from '/@/shared/components/group/group'; import { Group } from '/@/shared/components/group/group';
import { NumberInput } from '/@/shared/components/number-input/number-input'; import { NumberInput } from '/@/shared/components/number-input/number-input';
import { ScrollArea } from '/@/shared/components/scroll-area/scroll-area'; import { ScrollArea } from '/@/shared/components/scroll-area/scroll-area';
import { SegmentedControl } from '/@/shared/components/segmented-control/segmented-control';
import { Select } from '/@/shared/components/select/select'; import { Select } from '/@/shared/components/select/select';
import { Stack } from '/@/shared/components/stack/stack'; import { Stack } from '/@/shared/components/stack/stack';
import { useForm } from '/@/shared/hooks/use-form'; import { useForm } from '/@/shared/hooks/use-form';
@@ -51,6 +52,7 @@ type DeleteArgs = {
interface PlaylistQueryBuilderProps { interface PlaylistQueryBuilderProps {
limit?: number; limit?: number;
limitPercent?: number;
playlistId?: string; playlistId?: string;
query: any; query: any;
sortBy: SongListSort | SongListSort[]; sortBy: SongListSort | SongListSort[];
@@ -155,6 +157,7 @@ export type PlaylistQueryBuilderRef = {
getFilters: () => { getFilters: () => {
extraFilters: { extraFilters: {
limit?: number; limit?: number;
limitPercent?: number;
sortBy?: string[]; sortBy?: string[];
sortOrder?: string; sortOrder?: string;
}; };
@@ -164,7 +167,7 @@ export type PlaylistQueryBuilderRef = {
export const PlaylistQueryBuilder = forwardRef( export const PlaylistQueryBuilder = forwardRef(
( (
{ limit, playlistId, query, sortBy, sortOrder }: PlaylistQueryBuilderProps, { limit, limitPercent, playlistId, query, sortBy, sortOrder }: PlaylistQueryBuilderProps,
ref: Ref<PlaylistQueryBuilderRef>, ref: Ref<PlaylistQueryBuilderRef>,
) => { ) => {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -213,6 +216,8 @@ export const PlaylistQueryBuilder = forwardRef(
const extraFiltersForm = useForm({ const extraFiltersForm = useForm({
initialValues: { initialValues: {
limit, limit,
limitMode: limitPercent != null ? 'limitPercent' : 'limit',
limitPercent,
sortEntries: initialSortEntries, sortEntries: initialSortEntries,
}, },
}); });
@@ -224,16 +229,26 @@ export const PlaylistQueryBuilder = forwardRef(
const sortString = convertSortEntriesToSortString( const sortString = convertSortEntriesToSortString(
extraFiltersForm.values.sortEntries, extraFiltersForm.values.sortEntries,
); );
const isLimitPercent = extraFiltersForm.values.limitMode === 'limitPercent';
return { return {
extraFilters: { extraFilters: {
limit: extraFiltersForm.values.limit, limit: isLimitPercent ? undefined : extraFiltersForm.values.limit,
limitPercent: isLimitPercent
? extraFiltersForm.values.limitPercent
: undefined,
sortBy: sortString ? [sortString] : undefined, sortBy: sortString ? [sortString] : undefined,
}, },
filters, filters,
}; };
}, },
}), }),
[extraFiltersForm.values.sortEntries, extraFiltersForm.values.limit, filters], [
extraFiltersForm.values.sortEntries,
extraFiltersForm.values.limit,
extraFiltersForm.values.limitMode,
extraFiltersForm.values.limitPercent,
filters,
],
); );
const handleResetFilters = useCallback(() => { const handleResetFilters = useCallback(() => {
@@ -608,10 +623,50 @@ export const PlaylistQueryBuilder = forwardRef(
))} ))}
</Stack> </Stack>
<NumberInput <NumberInput
label={t('common.limit', { postProcess: 'titleCase' })} label={
maxWidth="20%" <Group align="center" gap="xs" wrap="nowrap">
{t('common.limit', { postProcess: 'titleCase' })}
<SegmentedControl
data={[
{ label: '#', value: 'limit' },
{ label: '%', value: 'limitPercent' },
]}
onChange={(value) =>
extraFiltersForm.setFieldValue(
'limitMode',
value as 'limit' | 'limitPercent',
)
}
size="xs"
value={extraFiltersForm.values.limitMode}
/>
</Group>
}
max={
extraFiltersForm.values.limitMode === 'limitPercent'
? 100
: undefined
}
min={
extraFiltersForm.values.limitMode === 'limitPercent'
? 0
: undefined
}
onChange={(value) => {
const nextValue =
value === '' || value == null ? undefined : Number(value);
if (extraFiltersForm.values.limitMode === 'limitPercent') {
extraFiltersForm.setFieldValue('limitPercent', nextValue);
} else {
extraFiltersForm.setFieldValue('limit', nextValue);
}
}}
value={
extraFiltersForm.values.limitMode === 'limitPercent'
? extraFiltersForm.values.limitPercent
: extraFiltersForm.values.limit
}
width={75} width={75}
{...extraFiltersForm.getInputProps('limit')}
/> />
</Group> </Group>
</Stack> </Stack>
@@ -1,5 +1,6 @@
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';
@@ -25,14 +26,24 @@ 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: ReturnType<typeof useQuery<any>>; detailQuery: UseSuspenseQueryResult<any, Error>;
handleSave: ( handleSave: (
filter: Record<string, any>, filter: Record<string, any>,
extraFilters: { limit?: number; sortBy?: string[]; sortOrder?: string }, extraFilters: {
limit?: number;
limitPercent?: number;
sortBy?: string[];
sortOrder?: string;
},
) => void; ) => void;
handleSaveAs: ( handleSaveAs: (
filter: Record<string, any>, filter: Record<string, any>,
extraFilters: { limit?: number; sortBy?: string[]; sortOrder?: string }, extraFilters: {
limit?: number;
limitPercent?: number;
sortBy?: string[];
sortOrder?: string;
},
) => void; ) => void;
isQueryBuilderExpanded: boolean; isQueryBuilderExpanded: boolean;
onToggleExpand: () => void; onToggleExpand: () => void;
@@ -43,6 +54,7 @@ export interface PlaylistQueryEditorProps {
type AppliedJsonState = { type AppliedJsonState = {
limit?: number; limit?: number;
limitPercent?: number;
query: Record<string, any>; query: Record<string, any>;
sort?: string; sort?: string;
}; };
@@ -50,7 +62,7 @@ type AppliedJsonState = {
type EditorMode = 'builder' | 'json'; type EditorMode = 'builder' | 'json';
const serializeFiltersToRulesJson = (filters: { const serializeFiltersToRulesJson = (filters: {
extraFilters: { limit?: number; sortBy?: string[] }; extraFilters: { limit?: number; limitPercent?: number; sortBy?: string[] };
filters: any; filters: any;
}): Record<string, any> => { }): Record<string, any> => {
const queryValue = convertQueryGroupToNDQuery(filters.filters); const queryValue = convertQueryGroupToNDQuery(filters.filters);
@@ -58,18 +70,25 @@ const serializeFiltersToRulesJson = (filters: {
return { return {
...queryValue, ...queryValue,
...(filters.extraFilters.limit != null && { limit: filters.extraFilters.limit }), ...(filters.extraFilters.limit != null && { limit: filters.extraFilters.limit }),
...(filters.extraFilters.limitPercent != null && {
limitPercent: filters.extraFilters.limitPercent,
}),
...(sortString && { sort: sortString }), ...(sortString && { sort: sortString }),
}; };
}; };
const parseRulesJsonToSaveArgs = ( const parseRulesJsonToSaveArgs = (
parsed: Record<string, any>, parsed: Record<string, any>,
): { extraFilters: { limit?: number; sortBy?: string[] }; filter: Record<string, any> } => { ): {
extraFilters: { limit?: number; limitPercent?: number; sortBy?: string[] };
filter: Record<string, any>;
} => {
const rootKey = parsed.all ? 'all' : 'any'; const rootKey = parsed.all ? 'all' : 'any';
const filter = rootKey in parsed ? { [rootKey]: parsed[rootKey] } : { all: [] }; const filter = rootKey in parsed ? { [rootKey]: parsed[rootKey] } : { all: [] };
return { return {
extraFilters: { extraFilters: {
...(parsed.limit != null && { limit: parsed.limit }), ...(parsed.limit != null && { limit: parsed.limit }),
...(parsed.limitPercent != null && { limitPercent: parsed.limitPercent }),
...(parsed.sort != null && { sortBy: [parsed.sort] }), ...(parsed.sort != null && { sortBy: [parsed.sort] }),
}, },
filter, filter,
@@ -93,7 +112,12 @@ export const PlaylistQueryEditor = ({
const [appliedJsonState, setAppliedJsonState] = useState<AppliedJsonState | null>(null); const [appliedJsonState, setAppliedJsonState] = useState<AppliedJsonState | null>(null);
const getFiltersForSave = useCallback((): null | { const getFiltersForSave = useCallback((): null | {
extraFilters: { limit?: number; sortBy?: string[]; sortOrder?: string }; extraFilters: {
limit?: number;
limitPercent?: number;
sortBy?: string[];
sortOrder?: string;
};
filter: Record<string, any>; filter: Record<string, any>;
} => { } => {
if (editorMode === 'json') { if (editorMode === 'json') {
@@ -124,6 +148,9 @@ export const PlaylistQueryEditor = ({
const previewValue = { const previewValue = {
...payload.filter, ...payload.filter,
...(payload.extraFilters.limit != null && { limit: payload.extraFilters.limit }), ...(payload.extraFilters.limit != null && { limit: payload.extraFilters.limit }),
...(payload.extraFilters.limitPercent != null && {
limitPercent: payload.extraFilters.limitPercent,
}),
...(payload.extraFilters.sortBy?.[0] && { sort: payload.extraFilters.sortBy[0] }), ...(payload.extraFilters.sortBy?.[0] && { sort: payload.extraFilters.sortBy[0] }),
}; };
openModal({ openModal({
@@ -208,6 +235,8 @@ export const PlaylistQueryEditor = ({
[appliedJsonState?.query, detailQuery?.data?.rules], [appliedJsonState?.query, detailQuery?.data?.rules],
); );
const effectiveLimit = appliedJsonState?.limit ?? detailQuery?.data?.rules?.limit; const effectiveLimit = appliedJsonState?.limit ?? detailQuery?.data?.rules?.limit;
const effectiveLimitPercent =
appliedJsonState?.limitPercent ?? detailQuery?.data?.rules?.limitPercent;
const effectiveSortBy = useMemo( const effectiveSortBy = useMemo(
() => () =>
(appliedJsonState?.sort ? [appliedJsonState.sort] : parseSortBy()) as (appliedJsonState?.sort ? [appliedJsonState.sort] : parseSortBy()) as
@@ -233,6 +262,8 @@ export const PlaylistQueryEditor = ({
? { ...effectiveQuery } ? { ...effectiveQuery }
: { all: [] }; : { all: [] };
if (effectiveLimit != null) fallback.limit = effectiveLimit; if (effectiveLimit != null) fallback.limit = effectiveLimit;
if (effectiveLimitPercent != null)
fallback.limitPercent = effectiveLimitPercent;
if (effectiveSortBy?.[0]) fallback.sort = effectiveSortBy[0]; if (effectiveSortBy?.[0]) fallback.sort = effectiveSortBy[0];
if (!fallback.sort) fallback.sort = '+dateAdded'; if (!fallback.sort) fallback.sort = '+dateAdded';
setJsonText(JSON.stringify(fallback, null, 2)); setJsonText(JSON.stringify(fallback, null, 2));
@@ -248,6 +279,7 @@ export const PlaylistQueryEditor = ({
} }
setAppliedJsonState({ setAppliedJsonState({
limit: parsed.limit, limit: parsed.limit,
limitPercent: parsed.limitPercent,
query: { [rootKey]: parsed[rootKey] }, query: { [rootKey]: parsed[rootKey] },
sort: parsed.sort, sort: parsed.sort,
}); });
@@ -263,7 +295,16 @@ export const PlaylistQueryEditor = ({
setEditorMode('builder'); setEditorMode('builder');
} }
}, },
[editorMode, effectiveLimit, effectiveQuery, effectiveSortBy, jsonText, queryBuilderRef, t], [
editorMode,
effectiveLimit,
effectiveLimitPercent,
effectiveQuery,
effectiveSortBy,
jsonText,
queryBuilderRef,
t,
],
); );
return ( return (
@@ -367,6 +408,7 @@ export const PlaylistQueryEditor = ({
<PlaylistQueryBuilder <PlaylistQueryBuilder
key={JSON.stringify(appliedJsonState ?? detailQuery?.data?.rules)} key={JSON.stringify(appliedJsonState ?? detailQuery?.data?.rules)}
limit={effectiveLimit} limit={effectiveLimit}
limitPercent={effectiveLimitPercent}
playlistId={playlistId} playlistId={playlistId}
query={effectiveQuery} query={effectiveQuery}
ref={queryBuilderRef} ref={queryBuilderRef}
@@ -380,8 +422,12 @@ 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={{ flex: 1, minHeight: 0 }} style={{
flex: 1,
minHeight: 0,
}}
value={jsonText} value={jsonText}
/> />
</ScrollArea> </ScrollArea>
@@ -1,21 +1,32 @@
import { closeModal, ContextModalProps } from '@mantine/modals'; import { closeModal, ContextModalProps } from '@mantine/modals';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { t } from 'i18next'; import { t } from 'i18next';
import { type ReactNode, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { ItemImage } from '/@/renderer/components/item-image/item-image';
import { useDeletePlaylistImage } from '/@/renderer/features/playlists/mutations/delete-playlist-image-mutation';
import { useUpdatePlaylist } from '/@/renderer/features/playlists/mutations/update-playlist-mutation'; import { useUpdatePlaylist } from '/@/renderer/features/playlists/mutations/update-playlist-mutation';
import { useUploadPlaylistImage } from '/@/renderer/features/playlists/mutations/upload-playlist-image-mutation';
import { sharedQueries } from '/@/renderer/features/shared/api/shared-api'; import { sharedQueries } from '/@/renderer/features/shared/api/shared-api';
import { useCurrentServer, useCurrentServerId, usePermissions } from '/@/renderer/store'; import { useCurrentServer, useCurrentServerId, usePermissions } 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 { Box } from '/@/shared/components/box/box';
import { DragDropZone } from '/@/shared/components/drag-drop-zone/drag-drop-zone';
import { FileButton } from '/@/shared/components/file-button/file-button';
import { Flex } from '/@/shared/components/flex/flex';
import { Group } from '/@/shared/components/group/group'; import { Group } from '/@/shared/components/group/group';
import { ModalButton } from '/@/shared/components/modal/model-shared'; import { ModalButton } from '/@/shared/components/modal/model-shared';
import { Select } from '/@/shared/components/select/select'; import { Select } from '/@/shared/components/select/select';
import { Stack } from '/@/shared/components/stack/stack'; import { Stack } from '/@/shared/components/stack/stack';
import { Switch } from '/@/shared/components/switch/switch'; import { Switch } from '/@/shared/components/switch/switch';
import { TextInput } from '/@/shared/components/text-input/text-input'; import { TextInput } from '/@/shared/components/text-input/text-input';
import { Textarea } from '/@/shared/components/textarea/textarea';
import { toast } from '/@/shared/components/toast/toast'; import { toast } from '/@/shared/components/toast/toast';
import { useForm } from '/@/shared/hooks/use-form'; import { useForm } from '/@/shared/hooks/use-form';
import { import {
LibraryItem,
ServerType, ServerType,
SortOrder, SortOrder,
UpdatePlaylistBody, UpdatePlaylistBody,
@@ -24,17 +35,41 @@ import {
} from '/@/shared/types/domain-types'; } from '/@/shared/types/domain-types';
import { ServerFeature } from '/@/shared/types/features-types'; import { ServerFeature } from '/@/shared/types/features-types';
type PlaylistImageProps = {
imageId: null | string;
imageUrl: null | string;
uploadedImage?: string;
};
export const UpdatePlaylistContextModal = ({ export const UpdatePlaylistContextModal = ({
id, id,
innerProps, innerProps,
}: ContextModalProps<{ }: ContextModalProps<{
body: Partial<UpdatePlaylistBody>; body: Partial<UpdatePlaylistBody>;
playlistImage?: PlaylistImageProps;
query: UpdatePlaylistQuery; query: UpdatePlaylistQuery;
}>) => { }>) => {
const { t } = useTranslation(); const { t } = useTranslation();
const mutation = useUpdatePlaylist({}); const updateMutation = useUpdatePlaylist({});
const uploadImageMutation = useUploadPlaylistImage({});
const deleteImageMutation = useDeletePlaylistImage({});
const server = useCurrentServer(); const server = useCurrentServer();
const { body, query } = innerProps; const { body, playlistImage, query } = innerProps;
const [pendingFile, setPendingFile] = useState<File | null>(null);
const [pendingPreviewUrl, setPendingPreviewUrl] = useState<null | string>(null);
const [removeCustomCover, setRemoveCustomCover] = useState(false);
const [isSaving, setIsSaving] = useState(false);
useEffect(() => {
if (!pendingFile) {
setPendingPreviewUrl(null);
return;
}
const url = URL.createObjectURL(pendingFile);
setPendingPreviewUrl(url);
return () => URL.revokeObjectURL(url);
}, [pendingFile]);
const form = useForm<UpdatePlaylistBody>({ const form = useForm<UpdatePlaylistBody>({
initialValues: { initialValues: {
@@ -47,91 +82,273 @@ export const UpdatePlaylistContextModal = ({
}, },
}); });
const handleSubmit = form.onSubmit((values) => { const handleSubmit = form.onSubmit(async (values) => {
mutation.mutate( if (!server?.id) return;
{
apiClientProps: { serverId: server?.id || '' }, setIsSaving(true);
try {
await updateMutation.mutateAsync({
apiClientProps: { serverId: server.id },
body: values, body: values,
query, query,
}, });
{
onError: (err) => { if (pendingFile) {
toast.error({ const buffer = await pendingFile.arrayBuffer();
message: err.message, await uploadImageMutation.mutateAsync({
title: t('error.genericError', { postProcess: 'sentenceCase' }), apiClientProps: { serverId: server.id },
}); body: { image: new Uint8Array(buffer) },
}, query: { id: query.id },
onSuccess: () => { });
toast.success({ } else if (removeCustomCover && playlistImage?.uploadedImage) {
message: t('form.editPlaylist.success', { postProcess: 'sentenceCase' }), await deleteImageMutation.mutateAsync({
}); apiClientProps: { serverId: server.id },
closeModal(id); query: { id: query.id },
}, });
}, }
);
toast.success({
message: t('form.editPlaylist.success', { postProcess: 'sentenceCase' }),
});
closeModal(id);
} catch (err: any) {
toast.error({
message: err?.message,
title: t('error.genericError', { postProcess: 'sentenceCase' }),
});
} finally {
setIsSaving(false);
}
}); });
const isPublicDisplayed = hasFeature(server, ServerFeature.PUBLIC_PLAYLIST); const isPublicDisplayed = hasFeature(server, ServerFeature.PUBLIC_PLAYLIST);
const isOwnerDisplayed = server?.type === ServerType.NAVIDROME; const isOwnerDisplayed = server?.type === ServerType.NAVIDROME;
const isCommentDisplayed = server?.type === ServerType.NAVIDROME; const isCommentDisplayed = server?.type === ServerType.NAVIDROME;
const isSubmitDisabled = !form.values.name || mutation.isPending; const isCoverImageDisplayed = hasFeature(server, ServerFeature.PLAYLIST_IMAGE_UPLOAD);
const isSubmitDisabled = !form.values.name || isSaving;
const hadUploadedCover = !!playlistImage?.uploadedImage;
const fieldNodes: ReactNode[] = [
<TextInput
data-autofocus
key="name"
label={t('form.createPlaylist.input', {
context: 'name',
postProcess: 'titleCase',
})}
required
{...form.getInputProps('name')}
/>,
];
if (isCommentDisplayed) {
fieldNodes.push(
<Textarea
autosize
key="comment"
label={t('form.createPlaylist.input', {
context: 'description',
postProcess: 'titleCase',
})}
minRows={5}
{...form.getInputProps('comment')}
/>,
);
}
if (isOwnerDisplayed) {
fieldNodes.push(<OwnerSelect form={form} key="owner" />);
}
if (isPublicDisplayed) {
if (server?.type === ServerType.JELLYFIN) {
fieldNodes.push(
<div key="jellyfin-public-note">
{t('form.editPlaylist.publicJellyfinNote', {
postProcess: 'sentenceCase',
})}
</div>,
);
}
fieldNodes.push(
<Switch
key="public"
label={t('form.createPlaylist.input', {
context: 'public',
postProcess: 'titleCase',
})}
{...form.getInputProps('public', { type: 'checkbox' })}
/>,
);
}
fieldNodes.push(
<Group justify="flex-end" key="actions">
<ModalButton disabled={isSaving} onClick={() => closeModal(id)}>
{t('common.cancel')}
</ModalButton>
<ModalButton
disabled={isSubmitDisabled}
loading={isSaving}
type="submit"
variant="filled"
>
{t('common.save')}
</ModalButton>
</Group>,
);
return ( return (
<form onSubmit={handleSubmit}> <form onSubmit={handleSubmit}>
<Stack> {isCoverImageDisplayed ? (
<TextInput <Flex align="flex-start" gap="lg" wrap="wrap">
data-autofocus <PlaylistCoverField
label={t('form.createPlaylist.input', { hadUploadedCover={hadUploadedCover}
context: 'name', onClearPending={() => setPendingFile(null)}
postProcess: 'titleCase', onFileSelect={(file) => {
})} if (!file) return;
required setRemoveCustomCover(false);
{...form.getInputProps('name')} setPendingFile(file);
/> }}
{isCommentDisplayed && ( onToggleRemoveCover={() => setRemoveCustomCover((v) => !v)}
<TextInput pendingFile={pendingFile}
label={t('form.createPlaylist.input', { pendingPreviewUrl={pendingPreviewUrl}
context: 'description', playlistImage={playlistImage}
postProcess: 'titleCase', removeCustomCover={removeCustomCover}
})}
{...form.getInputProps('comment')}
/> />
)} <Stack gap="md" style={{ flex: '1 1 220px', minWidth: 0 }}>
{isOwnerDisplayed && <OwnerSelect form={form} />} {fieldNodes}
{isPublicDisplayed && ( </Stack>
<> </Flex>
{server?.type === ServerType.JELLYFIN && ( ) : (
<div> <Stack gap="md">{fieldNodes}</Stack>
{t('form.editPlaylist.publicJellyfinNote', { )}
postProcess: 'sentenceCase',
})}
</div>
)}
<Switch
label={t('form.createPlaylist.input', {
context: 'public',
postProcess: 'titleCase',
})}
{...form.getInputProps('public', { type: 'checkbox' })}
/>
</>
)}
<Group justify="flex-end">
<ModalButton onClick={() => closeModal(id)}>{t('common.cancel')}</ModalButton>
<ModalButton
disabled={isSubmitDisabled}
loading={mutation.isPending}
type="submit"
variant="filled"
>
{t('common.save')}
</ModalButton>
</Group>
</Stack>
</form> </form>
); );
}; };
const COVER_SIZE = 240;
function PlaylistCoverField({
hadUploadedCover,
onClearPending,
onFileSelect,
onToggleRemoveCover,
pendingFile,
pendingPreviewUrl,
playlistImage,
removeCustomCover,
}: {
hadUploadedCover: boolean;
onClearPending: () => void;
onFileSelect: (file: File | null) => void;
onToggleRemoveCover: () => void;
pendingFile: File | null;
pendingPreviewUrl: null | string;
playlistImage?: PlaylistImageProps;
removeCustomCover: boolean;
}) {
const server = useCurrentServer();
const showServerCover = !pendingPreviewUrl && !removeCustomCover;
const previewId = showServerCover ? playlistImage?.imageId || undefined : undefined;
const previewSrc = pendingPreviewUrl || (showServerCover ? playlistImage?.imageUrl || '' : '');
const secondaryAction = () => {
if (pendingFile) {
onClearPending();
return;
}
if (hadUploadedCover) {
onToggleRemoveCover();
}
};
const secondaryDisabled = !pendingFile && !hadUploadedCover;
const secondaryIcon = pendingFile ? 'x' : removeCustomCover ? 'arrowLeft' : 'delete';
const iconControls = (
<>
<FileButton accept="image/*" onChange={onFileSelect}>
{(props) => {
const { ...triggerRest } = props;
return (
<ActionIcon
icon="uploadImage"
iconProps={{ size: 'lg' }}
radius="xl"
size="sm"
variant="default"
{...triggerRest}
style={{ pointerEvents: 'auto' }}
/>
);
}}
</FileButton>
<ActionIcon
disabled={secondaryDisabled}
icon={secondaryIcon}
iconProps={{ size: 'lg' }}
onClick={secondaryAction}
radius="xl"
size="sm"
style={{ pointerEvents: 'auto' }}
variant="default"
/>
</>
);
return (
<Box
style={{
borderRadius: 'var(--mantine-radius-md)',
flexShrink: 0,
height: COVER_SIZE,
overflow: 'hidden',
position: 'relative',
width: COVER_SIZE,
}}
>
<DragDropZone
accept="image/*"
mode="file"
onFileSelected={(file) => onFileSelect(file)}
style={{
height: '100%',
overflow: 'hidden',
position: 'relative',
width: '100%',
}}
>
<ItemImage
enableViewport={false}
id={previewId}
itemType={LibraryItem.PLAYLIST}
serverId={server?.id}
src={previewSrc}
type="header"
/>
<Group
gap={4}
style={{
background: 'rgba(0, 0, 0, 0.55)',
bottom: 6,
padding: 4,
pointerEvents: 'none',
position: 'absolute',
right: 6,
zIndex: 2,
}}
wrap="nowrap"
>
{iconControls}
</Group>
</DragDropZone>
</Box>
);
}
const OwnerSelect = ({ form }: { form: ReturnType<typeof useForm<UpdatePlaylistBody>> }) => { const OwnerSelect = ({ form }: { form: ReturnType<typeof useForm<UpdatePlaylistBody>> }) => {
const serverId = useCurrentServerId(); const serverId = useCurrentServerId();
const permissions = usePermissions(); const permissions = usePermissions();

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