mirror of
https://github.com/jeffvli/feishin.git
synced 2026-05-07 12:30:12 +02:00
Compare commits
243 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8eec6b6b8a | |||
| 60219c2522 | |||
| cdb5cdf442 | |||
| 23f84d68e8 | |||
| 0f7f4b969f | |||
| 563a4b3a7c | |||
| 4700772e6d | |||
| ffb7f915c3 | |||
| 17d5ef1f6b | |||
| 9dcc42ff28 | |||
| 2845476d83 | |||
| 147b155d60 | |||
| 8b5e463546 | |||
| 822dcd8429 | |||
| 9f2e873366 | |||
| d2c0d4c11f | |||
| 48b6e8bf93 | |||
| 17a6b37545 | |||
| eedcef8f52 | |||
| 757eddd6f1 | |||
| 13f48711a9 | |||
| 1bbdf09dcc | |||
| 3b7c6ce25e | |||
| 38118e74ae | |||
| 6872a7e8b2 | |||
| ab3236230b | |||
| 6ef88e56ec | |||
| 18c18ea322 | |||
| 22fec8f9d3 | |||
| f50ec5cf31 | |||
| 4cbc28a087 | |||
| 01fdd25406 | |||
| 320f583660 | |||
| 8cc5ec6797 | |||
| edfbc2538d | |||
| 484c96187c | |||
| 85bf910d65 | |||
| 5ddd0872ef | |||
| 2700774469 | |||
| c79a992829 | |||
| 5e693313d8 | |||
| 59f4f43e84 | |||
| be39c2bc1f | |||
| b49ba2d04c | |||
| d7c87efe10 | |||
| 44a4b88809 | |||
| 768269f074 | |||
| d23ae2a8db | |||
| 88591697a2 | |||
| dcbb00f7c4 | |||
| e063ee0c29 | |||
| e5f478218e | |||
| 9a809a61dd | |||
| 4058ab7491 | |||
| 48ccebd4c2 | |||
| fcf00b9de1 | |||
| f7919b296b | |||
| 0b6af1fd21 | |||
| 3f424b72f6 | |||
| fc9d4616ba | |||
| 2e74f7533a | |||
| 784da2f8b9 | |||
| dbc29568ca | |||
| 5614ad54f2 | |||
| 67523f1e7b | |||
| fc57605219 | |||
| a31a2ffdbf | |||
| 900d47d6f9 | |||
| 6bdf0736ec | |||
| 92cde507d9 | |||
| 8afd626806 | |||
| 53f3758d2a | |||
| 1e6eb33408 | |||
| 51e20a81b7 | |||
| 4e8dc868bb | |||
| 9b8bcb05bd | |||
| 55e2a9bf37 | |||
| a82b087969 | |||
| 45aef104fe | |||
| 6746903808 | |||
| 36c1f4e736 | |||
| b0ca7ab127 | |||
| 2026bc8f48 | |||
| 6da8663a1d | |||
| b4e9f48667 | |||
| d58ba92cbd | |||
| c51194cd03 | |||
| ba0ec909c8 | |||
| 0db1c36d86 | |||
| 3e9fb521f0 | |||
| b5ad30a9bc | |||
| 5344493845 | |||
| 6f2108940e | |||
| 4f3e732891 | |||
| 0e2678575a | |||
| d6035a5f97 | |||
| b82a5eda78 | |||
| d17f30f5e6 | |||
| 7a3bdb531d | |||
| c6f3b49a6e | |||
| 99cd48ca6d | |||
| 02ed9b7a5c | |||
| 0f66687843 | |||
| 586f42867d | |||
| 14a7f0254d | |||
| 0aa0e51daa | |||
| f4f06abd72 | |||
| 7d8cb0bb45 | |||
| 2edffa02d0 | |||
| cfa4e5e45c | |||
| f879171398 | |||
| 6bfebd2923 | |||
| 3c60f406ea | |||
| af1c16ee51 | |||
| 1b25d88692 | |||
| 8a48abbbc8 | |||
| 6deab38c67 | |||
| 915b0eb372 | |||
| 6bb0474d62 | |||
| 2df96c0d31 | |||
| d94d7b5ee5 | |||
| 2f7f6bead9 | |||
| 3bbddcf092 | |||
| 838c6a8b6a | |||
| 5889b8976c | |||
| d06ddc9560 | |||
| 9b1f4e7154 | |||
| b569ec31ae | |||
| f7b8e34905 | |||
| 3cf7127f56 | |||
| cb823d94e5 | |||
| 4d0620c5df | |||
| 01371d0227 | |||
| 9e6a81cb62 | |||
| 7b616b44fa | |||
| 4c275ea878 | |||
| b59c86f78f | |||
| 14e6b4e7d6 | |||
| 63cdefcb27 | |||
| 11f9721abe | |||
| 0a82438beb | |||
| 8bd1cc80bc | |||
| 624b1bb94d | |||
| cf904b5d51 | |||
| 2ab48f5c97 | |||
| d56799e519 | |||
| 083e219ed2 | |||
| 1b379882f5 | |||
| ab031820f6 | |||
| d1dfbaedaa | |||
| 3070586104 | |||
| bcfb9dbec3 | |||
| 888bab50c9 | |||
| 715ee0fa3f | |||
| 1963e93d2e | |||
| ad3728a55d | |||
| df4f05b14c | |||
| 0c7a0cc88a | |||
| 98ef0b44ec | |||
| 24f06db2b8 | |||
| d63e5f5784 | |||
| 75ef43dffb | |||
| 65974dbf28 | |||
| 16433457ad | |||
| 19eaf44394 | |||
| 861fcec14f | |||
| 72cbd23089 | |||
| 1048431742 | |||
| 633c9f59d9 | |||
| 0ed13c75af | |||
| b0bc4c3cf3 | |||
| b8b8ca9f66 | |||
| f2e6a418b0 | |||
| 7fef7e4689 | |||
| 21bf995335 | |||
| bd13fb63ae | |||
| 67ccc20147 | |||
| 83991cf5a1 | |||
| dfb0ff42b3 | |||
| 008c12626d | |||
| 19e3f435c4 | |||
| ac6242ea94 | |||
| b87d7778df | |||
| acb906aad9 | |||
| 196cb1bd48 | |||
| 3981ad3eb5 | |||
| d54131b34a | |||
| 6ad6617d88 | |||
| 52163534db | |||
| 5dd65b18b7 | |||
| 1e77e1074a | |||
| 9537309fe2 | |||
| 4dc8920ff4 | |||
| 26e6f479b7 | |||
| d93e6a612e | |||
| 0baa6f4488 | |||
| 6490118741 | |||
| 58827a1dcf | |||
| a3804808b4 | |||
| d49bba42ef | |||
| c56f6a355d | |||
| 7b13e24ce4 | |||
| 088f1d0f99 | |||
| d7d611c6d1 | |||
| 65465d6cae | |||
| 3d8ba2e808 | |||
| 152be5d7e6 | |||
| 4326f6cf91 | |||
| 90dec929f4 | |||
| d6dc880ef4 | |||
| d0e2a798fe | |||
| fecaa2e6b8 | |||
| cdbd3f8c7b | |||
| b037329377 | |||
| 8b04f70106 | |||
| 737a05e2c5 | |||
| 78a30c2db4 | |||
| 5cef23944f | |||
| aa1cd742ad | |||
| 0f364f7c5c | |||
| 11be5c811f | |||
| 6174dc128d | |||
| 81455602ef | |||
| d6936634db | |||
| 88f53c17db | |||
| 82f107d835 | |||
| 1fee4c1946 | |||
| ec79d91d30 | |||
| 00a21269dd | |||
| 58ed2f3706 | |||
| 0a9dcf36b9 | |||
| dc1e728a2e | |||
| 085a3856e0 | |||
| a693981333 | |||
| 2a797bd6c9 | |||
| 4a64f5fe9b | |||
| 1f232fa4da | |||
| b3d95f765c | |||
| f298e60929 | |||
| 4745c4a42d | |||
| 6fddea552d | |||
| 24af17b8fe | |||
| 185175aa89 |
@@ -0,0 +1,54 @@
|
||||
name: Comment on pull request
|
||||
on:
|
||||
workflow_run:
|
||||
workflows: ['Publish (PR)']
|
||||
types: [completed]
|
||||
jobs:
|
||||
pr_comment:
|
||||
if: github.event.workflow_run.event == 'pull_request' && github.event.workflow_run.conclusion == 'success'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/github-script@v6
|
||||
with:
|
||||
# This snippet is public-domain, taken from
|
||||
# https://github.com/oprypin/nightly.link/blob/master/.github/workflows/pr-comment.yml
|
||||
script: |
|
||||
async function upsertComment(owner, repo, issue_number, purpose, body) {
|
||||
const {data: comments} = await github.rest.issues.listComments(
|
||||
{owner, repo, issue_number});
|
||||
const marker = `<!-- bot: ${purpose} -->`;
|
||||
body = marker + "\n" + body;
|
||||
const existing = comments.filter((c) => c.body.includes(marker));
|
||||
if (existing.length > 0) {
|
||||
const last = existing[existing.length - 1];
|
||||
core.info(`Updating comment ${last.id}`);
|
||||
await github.rest.issues.updateComment({
|
||||
owner, repo,
|
||||
body,
|
||||
comment_id: last.id,
|
||||
});
|
||||
} else {
|
||||
core.info(`Creating a comment in issue / PR #${issue_number}`);
|
||||
await github.rest.issues.createComment({issue_number, body, owner, repo});
|
||||
}
|
||||
}
|
||||
const {owner, repo} = context.repo;
|
||||
const run_id = ${{github.event.workflow_run.id}};
|
||||
const pull_requests = ${{ toJSON(github.event.workflow_run.pull_requests) }};
|
||||
if (!pull_requests.length) {
|
||||
return core.error("This workflow doesn't match any pull requests!");
|
||||
}
|
||||
const artifacts = await github.paginate(
|
||||
github.rest.actions.listWorkflowRunArtifacts, {owner, repo, run_id});
|
||||
if (!artifacts.length) {
|
||||
return core.error(`No artifacts found`);
|
||||
}
|
||||
let body = `Download the artifacts for this pull request:\n`;
|
||||
for (const art of artifacts) {
|
||||
body += `\n* [${art.name}.zip](https://nightly.link/${owner}/${repo}/actions/artifacts/${art.id}.zip)`;
|
||||
}
|
||||
core.info("Review thread message body:", body);
|
||||
for (const pr of pull_requests) {
|
||||
await upsertComment(owner, repo, pr.number,
|
||||
"nightly-link", body);
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
name: Publish (PR)
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches:
|
||||
- development
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
runs-on: ${{ matrix.os }}
|
||||
|
||||
strategy:
|
||||
matrix:
|
||||
os: [macos-latest]
|
||||
|
||||
steps:
|
||||
- name: Checkout git repo
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Install Node and NPM
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 16
|
||||
cache: npm
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
npm install --legacy-peer-deps
|
||||
|
||||
- name: Build releases
|
||||
uses: nick-invision/retry@v2.8.2
|
||||
with:
|
||||
timeout_minutes: 30
|
||||
max_attempts: 3
|
||||
retry_on: error
|
||||
command: |
|
||||
npm run postinstall
|
||||
npm run build
|
||||
npm run package:pr
|
||||
on_retry_command: npm cache clean --force
|
||||
|
||||
- uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: windows-binaries
|
||||
path: |
|
||||
release/build/*.exe
|
||||
|
||||
- uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: linux-binaries
|
||||
path: |
|
||||
release/build/*.AppImage
|
||||
release/build/*.deb
|
||||
release/build/*.rpm
|
||||
|
||||
- uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: macos-binaries
|
||||
path: |
|
||||
release/build/*.dmg
|
||||
Generated
+1141
-1133
File diff suppressed because it is too large
Load Diff
+15
-15
@@ -2,7 +2,7 @@
|
||||
"name": "feishin",
|
||||
"productName": "Feishin",
|
||||
"description": "Feishin music server",
|
||||
"version": "0.0.1-alpha2",
|
||||
"version": "0.0.1-alpha5",
|
||||
"scripts": {
|
||||
"build": "concurrently \"npm run build:main\" \"npm run build:renderer\"",
|
||||
"build:main": "cross-env NODE_ENV=production TS_NODE_TRANSPILE_ONLY=true webpack --config ./.erb/configs/webpack.config.main.prod.ts",
|
||||
@@ -11,10 +11,11 @@
|
||||
"lint": "cross-env NODE_ENV=development eslint . --ext .js,.jsx,.ts,.tsx",
|
||||
"lint:styles": "npx stylelint **/*.tsx",
|
||||
"package": "ts-node ./.erb/scripts/clean.js dist && npm run build && electron-builder build --publish never",
|
||||
"package:pr": "ts-node ./.erb/scripts/clean.js dist && npm run build && electron-builder build --publish never --win --mac --linux",
|
||||
"package:dev": "ts-node ./.erb/scripts/clean.js dist && npm run build && electron-builder build --publish never --dir",
|
||||
"postinstall": "ts-node .erb/scripts/check-native-dep.js && electron-builder install-app-deps && cross-env NODE_ENV=development TS_NODE_TRANSPILE_ONLY=true webpack --config ./.erb/configs/webpack.config.renderer.dev.dll.ts",
|
||||
"start": "ts-node ./.erb/scripts/check-port-in-use.js && npm run start:renderer",
|
||||
"start:main": "cross-env NODE_ENV=development electron -r ts-node/register/transpile-only ./src/main/main.ts",
|
||||
"start:main": "cross-env NODE_ENV=development electronmon -r ts-node/register/transpile-only ./src/main/main.ts",
|
||||
"start:preload": "cross-env NODE_ENV=development TS_NODE_TRANSPILE_ONLY=true webpack --config ./.erb/configs/webpack.config.preload.dev.ts",
|
||||
"start:renderer": "cross-env NODE_ENV=development TS_NODE_TRANSPILE_ONLY=true webpack serve --config ./.erb/configs/webpack.config.renderer.dev.ts",
|
||||
"start:web": "cross-env NODE_ENV=development TS_NODE_TRANSPILE_ONLY=true webpack serve --config ./.erb/configs/webpack.config.renderer.web.ts",
|
||||
@@ -169,7 +170,6 @@
|
||||
"@types/node": "^17.0.23",
|
||||
"@types/react": "^18.0.25",
|
||||
"@types/react-dom": "^18.0.8",
|
||||
"@types/react-slider": "^1.3.1",
|
||||
"@types/react-test-renderer": "^17.0.1",
|
||||
"@types/react-virtualized-auto-sizer": "^1.0.1",
|
||||
"@types/react-window": "^1.8.5",
|
||||
@@ -253,16 +253,16 @@
|
||||
"@ag-grid-community/react": "^28.2.1",
|
||||
"@ag-grid-community/styles": "^28.2.1",
|
||||
"@emotion/react": "^11.10.4",
|
||||
"@mantine/core": "^5.9.5",
|
||||
"@mantine/dates": "^5.9.5",
|
||||
"@mantine/dropzone": "^5.9.5",
|
||||
"@mantine/form": "^5.9.5",
|
||||
"@mantine/hooks": "^5.9.5",
|
||||
"@mantine/modals": "^5.9.5",
|
||||
"@mantine/notifications": "^5.9.5",
|
||||
"@mantine/spotlight": "^5.9.5",
|
||||
"@tanstack/react-query": "^4.16.1",
|
||||
"@tanstack/react-query-devtools": "^4.16.1",
|
||||
"@mantine/core": "^6.0.0-alpha.5",
|
||||
"@mantine/dates": "^6.0.0-alpha.5",
|
||||
"@mantine/dropzone": "^6.0.0-alpha.5",
|
||||
"@mantine/form": "^6.0.0-alpha.5",
|
||||
"@mantine/hooks": "^6.0.0-alpha.5",
|
||||
"@mantine/modals": "^6.0.0-alpha.5",
|
||||
"@mantine/notifications": "^6.0.0-alpha.5",
|
||||
"@mantine/utils": "^6.0.0-alpha.5",
|
||||
"@tanstack/react-query": "^4.24.4",
|
||||
"@tanstack/react-query-devtools": "^4.24.4",
|
||||
"dayjs": "^1.11.6",
|
||||
"electron-debug": "^3.2.0",
|
||||
"electron-localshortcut": "^3.2.1",
|
||||
@@ -271,7 +271,7 @@
|
||||
"electron-updater": "^4.6.5",
|
||||
"fast-average-color": "^9.2.0",
|
||||
"format-duration": "^2.0.0",
|
||||
"framer-motion": "^7.10.2",
|
||||
"framer-motion": "^8.1.3",
|
||||
"history": "^5.3.0",
|
||||
"i18next": "^21.6.16",
|
||||
"immer": "^9.0.15",
|
||||
@@ -280,6 +280,7 @@
|
||||
"lodash": "^4.17.21",
|
||||
"md5": "^2.3.0",
|
||||
"memoize-one": "^6.0.0",
|
||||
"mpris-service": "^2.1.2",
|
||||
"nanoid": "^3.3.3",
|
||||
"net": "^1.0.2",
|
||||
"node-mpv": "^2.0.0-beta.2",
|
||||
@@ -292,7 +293,6 @@
|
||||
"react-router": "^6.5.0",
|
||||
"react-router-dom": "^6.5.0",
|
||||
"react-simple-img": "^3.0.0",
|
||||
"react-slider": "^2.0.4",
|
||||
"react-virtualized-auto-sizer": "^1.0.6",
|
||||
"react-window": "^1.8.8",
|
||||
"react-window-infinite-loader": "^1.0.8",
|
||||
|
||||
Generated
+732
-3
@@ -1,14 +1,743 @@
|
||||
{
|
||||
"name": "feishin",
|
||||
"version": "0.0.1-alpha2",
|
||||
"version": "0.0.1-alpha5",
|
||||
"lockfileVersion": 2,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "feishin",
|
||||
"version": "0.0.1-alpha2",
|
||||
"version": "0.0.1-alpha5",
|
||||
"hasInstallScript": true,
|
||||
"license": "GPL-3.0"
|
||||
"license": "GPL-3.0",
|
||||
"dependencies": {
|
||||
"mpris-service": "^2.1.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@nornagon/put": {
|
||||
"version": "0.0.8",
|
||||
"resolved": "https://registry.npmjs.org/@nornagon/put/-/put-0.0.8.tgz",
|
||||
"integrity": "sha512-ugvXJjwF5ldtUpa7D95kruNJ41yFQDEKyF5CW4TgKJnh+W/zmlBzXXeKTyqIgwMFrkePN2JqOBqcF0M0oOunow==",
|
||||
"engines": {
|
||||
"node": ">=0.3.0"
|
||||
}
|
||||
},
|
||||
"node_modules/abstract-socket": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/abstract-socket/-/abstract-socket-2.1.1.tgz",
|
||||
"integrity": "sha512-YZJizsvS1aBua5Gd01woe4zuyYBGgSMeqDOB6/ChwdTI904KP6QGtJswXl4hcqWxbz86hQBe++HWV0hF1aGUtA==",
|
||||
"hasInstallScript": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"dependencies": {
|
||||
"bindings": "^1.2.1",
|
||||
"nan": "^2.12.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/bindings": {
|
||||
"version": "1.5.0",
|
||||
"resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz",
|
||||
"integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"file-uri-to-path": "1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/buffer-from": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
|
||||
"integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="
|
||||
},
|
||||
"node_modules/call-bind": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.2.tgz",
|
||||
"integrity": "sha512-7O+FbCihrB5WGbFYesctwmTKae6rOiIzmz1icreWJ+0aA7LJfuqhEso2T9ncpcFtzMQtzXf2QGGueWJGTYsqrA==",
|
||||
"dependencies": {
|
||||
"function-bind": "^1.1.1",
|
||||
"get-intrinsic": "^1.0.2"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/dbus-next": {
|
||||
"version": "0.9.2",
|
||||
"resolved": "https://registry.npmjs.org/dbus-next/-/dbus-next-0.9.2.tgz",
|
||||
"integrity": "sha512-tzQq/+wrTZ2yU+U5PoeXc97KABhX2v55C/T0finH3tSKYuI8H/SqppIFymBBrUHcK13LvEGY3vdj3ikPPenL5g==",
|
||||
"dependencies": {
|
||||
"@nornagon/put": "0.0.8",
|
||||
"event-stream": "3.3.4",
|
||||
"hexy": "^0.2.10",
|
||||
"jsbi": "^2.0.5",
|
||||
"long": "^4.0.0",
|
||||
"safe-buffer": "^5.1.1",
|
||||
"xml2js": "^0.4.17"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"abstract-socket": "^2.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/deep-equal": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/deep-equal/-/deep-equal-1.1.1.tgz",
|
||||
"integrity": "sha512-yd9c5AdiqVcR+JjcwUQb9DkhJc8ngNr0MahEBGvDiJw8puWab2yZlh+nkasOnZP+EGTAP6rRp2JzJhJZzvNF8g==",
|
||||
"dependencies": {
|
||||
"is-arguments": "^1.0.4",
|
||||
"is-date-object": "^1.0.1",
|
||||
"is-regex": "^1.0.4",
|
||||
"object-is": "^1.0.1",
|
||||
"object-keys": "^1.1.1",
|
||||
"regexp.prototype.flags": "^1.2.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/define-properties": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.1.4.tgz",
|
||||
"integrity": "sha512-uckOqKcfaVvtBdsVkdPv3XjveQJsNQqmhXgRi8uhvWWuPYZCNlzT8qAyblUgNoXdHdjMTzAqeGjAoli8f+bzPA==",
|
||||
"dependencies": {
|
||||
"has-property-descriptors": "^1.0.0",
|
||||
"object-keys": "^1.1.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/duplexer": {
|
||||
"version": "0.1.2",
|
||||
"resolved": "https://registry.npmjs.org/duplexer/-/duplexer-0.1.2.tgz",
|
||||
"integrity": "sha512-jtD6YG370ZCIi/9GTaJKQxWTZD045+4R4hTk/x1UyoqadyJ9x9CgSi1RlVDQF8U2sxLLSnFkCaMihqljHIWgMg=="
|
||||
},
|
||||
"node_modules/event-stream": {
|
||||
"version": "3.3.4",
|
||||
"resolved": "https://registry.npmjs.org/event-stream/-/event-stream-3.3.4.tgz",
|
||||
"integrity": "sha512-QHpkERcGsR0T7Qm3HNJSyXKEEj8AHNxkY3PK8TS2KJvQ7NiSHe3DDpwVKKtoYprL/AreyzFBeIkBIWChAqn60g==",
|
||||
"dependencies": {
|
||||
"duplexer": "~0.1.1",
|
||||
"from": "~0",
|
||||
"map-stream": "~0.1.0",
|
||||
"pause-stream": "0.0.11",
|
||||
"split": "0.3",
|
||||
"stream-combiner": "~0.0.4",
|
||||
"through": "~2.3.1"
|
||||
}
|
||||
},
|
||||
"node_modules/file-uri-to-path": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz",
|
||||
"integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/from": {
|
||||
"version": "0.1.7",
|
||||
"resolved": "https://registry.npmjs.org/from/-/from-0.1.7.tgz",
|
||||
"integrity": "sha512-twe20eF1OxVxp/ML/kq2p1uc6KvFK/+vs8WjEbeKmV2He22MKm7YF2ANIt+EOqhJ5L3K/SuuPhk0hWQDjOM23g=="
|
||||
},
|
||||
"node_modules/function-bind": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz",
|
||||
"integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A=="
|
||||
},
|
||||
"node_modules/functions-have-names": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz",
|
||||
"integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/get-intrinsic": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.0.tgz",
|
||||
"integrity": "sha512-L049y6nFOuom5wGyRc3/gdTLO94dySVKRACj1RmJZBQXlbTMhtNIgkWkUHq+jYmZvKf14EW1EoJnnjbmoHij0Q==",
|
||||
"dependencies": {
|
||||
"function-bind": "^1.1.1",
|
||||
"has": "^1.0.3",
|
||||
"has-symbols": "^1.0.3"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/has": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/has/-/has-1.0.3.tgz",
|
||||
"integrity": "sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw==",
|
||||
"dependencies": {
|
||||
"function-bind": "^1.1.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/has-property-descriptors": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.0.tgz",
|
||||
"integrity": "sha512-62DVLZGoiEBDHQyqG4w9xCuZ7eJEwNmJRWw2VY84Oedb7WFcA27fiEVe8oUQx9hAUJ4ekurquucTGwsyO1XGdQ==",
|
||||
"dependencies": {
|
||||
"get-intrinsic": "^1.1.1"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/has-symbols": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.3.tgz",
|
||||
"integrity": "sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A==",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/has-tostringtag": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.0.tgz",
|
||||
"integrity": "sha512-kFjcSNhnlGV1kyoGk7OXKSawH5JOb/LzUc5w9B02hOTO0dfFRjbHQKvg1d6cf3HbeUmtU9VbbV3qzZ2Teh97WQ==",
|
||||
"dependencies": {
|
||||
"has-symbols": "^1.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/hexy": {
|
||||
"version": "0.2.11",
|
||||
"resolved": "https://registry.npmjs.org/hexy/-/hexy-0.2.11.tgz",
|
||||
"integrity": "sha512-ciq6hFsSG/Bpt2DmrZJtv+56zpPdnq+NQ4ijEFrveKN0ZG1mhl/LdT1NQZ9se6ty1fACcI4d4vYqC9v8EYpH2A==",
|
||||
"bin": {
|
||||
"hexy": "bin/hexy_cmd.js"
|
||||
}
|
||||
},
|
||||
"node_modules/is-arguments": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/is-arguments/-/is-arguments-1.1.1.tgz",
|
||||
"integrity": "sha512-8Q7EARjzEnKpt/PCD7e1cgUS0a6X8u5tdSiMqXhojOdoV9TsMsiO+9VLC5vAmO8N7/GmXn7yjR8qnA6bVAEzfA==",
|
||||
"dependencies": {
|
||||
"call-bind": "^1.0.2",
|
||||
"has-tostringtag": "^1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/is-date-object": {
|
||||
"version": "1.0.5",
|
||||
"resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.0.5.tgz",
|
||||
"integrity": "sha512-9YQaSxsAiSwcvS33MBk3wTCVnWK+HhF8VZR2jRxehM16QcVOdHqPn4VPHmRK4lSr38n9JriurInLcP90xsYNfQ==",
|
||||
"dependencies": {
|
||||
"has-tostringtag": "^1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/is-regex": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.1.4.tgz",
|
||||
"integrity": "sha512-kvRdxDsxZjhzUX07ZnLydzS1TU/TJlTUHHY4YLL87e37oUA49DfkLqgy+VjFocowy29cKvcSiu+kIv728jTTVg==",
|
||||
"dependencies": {
|
||||
"call-bind": "^1.0.2",
|
||||
"has-tostringtag": "^1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/jsbi": {
|
||||
"version": "2.0.5",
|
||||
"resolved": "https://registry.npmjs.org/jsbi/-/jsbi-2.0.5.tgz",
|
||||
"integrity": "sha512-TzO/62Hxeb26QMb4IGlI/5X+QLr9Uqp1FPkwp2+KOICW+Q+vSuFj61c8pkT6wAns4WcK56X7CmSHhJeDGWOqxQ=="
|
||||
},
|
||||
"node_modules/long": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/long/-/long-4.0.0.tgz",
|
||||
"integrity": "sha512-XsP+KhQif4bjX1kbuSiySJFNAehNxgLb6hPRGJ9QsUr8ajHkuXGdrHmFUTUUXhDwVX2R5bY4JNZEwbUiMhV+MA=="
|
||||
},
|
||||
"node_modules/map-stream": {
|
||||
"version": "0.1.0",
|
||||
"resolved": "https://registry.npmjs.org/map-stream/-/map-stream-0.1.0.tgz",
|
||||
"integrity": "sha512-CkYQrPYZfWnu/DAmVCpTSX/xHpKZ80eKh2lAkyA6AJTef6bW+6JpbQZN5rofum7da+SyN1bi5ctTm+lTfcCW3g=="
|
||||
},
|
||||
"node_modules/mpris-service": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/mpris-service/-/mpris-service-2.1.2.tgz",
|
||||
"integrity": "sha512-AC6WepCnFWwOME9OWplHZ8ps/BB+g9QrEpUKCv7wX82fDPzR3nPrypOFmL/Fm0JloEAu6QTWSfDLLc6mM/jinw==",
|
||||
"dependencies": {
|
||||
"dbus-next": "^0.9.2",
|
||||
"deep-equal": "^1.0.1",
|
||||
"source-map-support": "^0.5.11"
|
||||
}
|
||||
},
|
||||
"node_modules/nan": {
|
||||
"version": "2.17.0",
|
||||
"resolved": "https://registry.npmjs.org/nan/-/nan-2.17.0.tgz",
|
||||
"integrity": "sha512-2ZTgtl0nJsO0KQCjEpxcIr5D+Yv90plTitZt9JBfQvVJDS5seMl3FOvsh3+9CoYWXf/1l5OaZzzF6nDm4cagaQ==",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/object-is": {
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/object-is/-/object-is-1.1.5.tgz",
|
||||
"integrity": "sha512-3cyDsyHgtmi7I7DfSSI2LDp6SK2lwvtbg0p0R1e0RvTqF5ceGx+K2dfSjm1bKDMVCFEDAQvy+o8c6a7VujOddw==",
|
||||
"dependencies": {
|
||||
"call-bind": "^1.0.2",
|
||||
"define-properties": "^1.1.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/object-keys": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz",
|
||||
"integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/pause-stream": {
|
||||
"version": "0.0.11",
|
||||
"resolved": "https://registry.npmjs.org/pause-stream/-/pause-stream-0.0.11.tgz",
|
||||
"integrity": "sha512-e3FBlXLmN/D1S+zHzanP4E/4Z60oFAa3O051qt1pxa7DEJWKAyil6upYVXCWadEnuoqa4Pkc9oUx9zsxYeRv8A==",
|
||||
"dependencies": {
|
||||
"through": "~2.3"
|
||||
}
|
||||
},
|
||||
"node_modules/regexp.prototype.flags": {
|
||||
"version": "1.4.3",
|
||||
"resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.4.3.tgz",
|
||||
"integrity": "sha512-fjggEOO3slI6Wvgjwflkc4NFRCTZAu5CnNfBd5qOMYhWdn67nJBBu34/TkD++eeFmd8C9r9jfXJ27+nSiRkSUA==",
|
||||
"dependencies": {
|
||||
"call-bind": "^1.0.2",
|
||||
"define-properties": "^1.1.3",
|
||||
"functions-have-names": "^1.2.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/safe-buffer": {
|
||||
"version": "5.2.1",
|
||||
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
|
||||
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/feross"
|
||||
},
|
||||
{
|
||||
"type": "patreon",
|
||||
"url": "https://www.patreon.com/feross"
|
||||
},
|
||||
{
|
||||
"type": "consulting",
|
||||
"url": "https://feross.org/support"
|
||||
}
|
||||
]
|
||||
},
|
||||
"node_modules/sax": {
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/sax/-/sax-1.2.4.tgz",
|
||||
"integrity": "sha512-NqVDv9TpANUjFm0N8uM5GxL36UgKi9/atZw+x7YFnQ8ckwFGKrl4xX4yWtrey3UJm5nP1kUbnYgLopqWNSRhWw=="
|
||||
},
|
||||
"node_modules/source-map": {
|
||||
"version": "0.6.1",
|
||||
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
|
||||
"integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/source-map-support": {
|
||||
"version": "0.5.21",
|
||||
"resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz",
|
||||
"integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==",
|
||||
"dependencies": {
|
||||
"buffer-from": "^1.0.0",
|
||||
"source-map": "^0.6.0"
|
||||
}
|
||||
},
|
||||
"node_modules/split": {
|
||||
"version": "0.3.3",
|
||||
"resolved": "https://registry.npmjs.org/split/-/split-0.3.3.tgz",
|
||||
"integrity": "sha512-wD2AeVmxXRBoX44wAycgjVpMhvbwdI2aZjCkvfNcH1YqHQvJVa1duWc73OyVGJUc05fhFaTZeQ/PYsrmyH0JVA==",
|
||||
"dependencies": {
|
||||
"through": "2"
|
||||
},
|
||||
"engines": {
|
||||
"node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/stream-combiner": {
|
||||
"version": "0.0.4",
|
||||
"resolved": "https://registry.npmjs.org/stream-combiner/-/stream-combiner-0.0.4.tgz",
|
||||
"integrity": "sha512-rT00SPnTVyRsaSz5zgSPma/aHSOic5U1prhYdRy5HS2kTZviFpmDgzilbtsJsxiroqACmayynDN/9VzIbX5DOw==",
|
||||
"dependencies": {
|
||||
"duplexer": "~0.1.1"
|
||||
}
|
||||
},
|
||||
"node_modules/through": {
|
||||
"version": "2.3.8",
|
||||
"resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz",
|
||||
"integrity": "sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg=="
|
||||
},
|
||||
"node_modules/xml2js": {
|
||||
"version": "0.4.23",
|
||||
"resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.4.23.tgz",
|
||||
"integrity": "sha512-ySPiMjM0+pLDftHgXY4By0uswI3SPKLDw/i3UXbnO8M/p28zqexCUoPmQFrYD+/1BzhGJSs2i1ERWKJAtiLrug==",
|
||||
"dependencies": {
|
||||
"sax": ">=0.6.0",
|
||||
"xmlbuilder": "~11.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/xmlbuilder": {
|
||||
"version": "11.0.1",
|
||||
"resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-11.0.1.tgz",
|
||||
"integrity": "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==",
|
||||
"engines": {
|
||||
"node": ">=4.0"
|
||||
}
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"@nornagon/put": {
|
||||
"version": "0.0.8",
|
||||
"resolved": "https://registry.npmjs.org/@nornagon/put/-/put-0.0.8.tgz",
|
||||
"integrity": "sha512-ugvXJjwF5ldtUpa7D95kruNJ41yFQDEKyF5CW4TgKJnh+W/zmlBzXXeKTyqIgwMFrkePN2JqOBqcF0M0oOunow=="
|
||||
},
|
||||
"abstract-socket": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/abstract-socket/-/abstract-socket-2.1.1.tgz",
|
||||
"integrity": "sha512-YZJizsvS1aBua5Gd01woe4zuyYBGgSMeqDOB6/ChwdTI904KP6QGtJswXl4hcqWxbz86hQBe++HWV0hF1aGUtA==",
|
||||
"optional": true,
|
||||
"requires": {
|
||||
"bindings": "^1.2.1",
|
||||
"nan": "^2.12.1"
|
||||
}
|
||||
},
|
||||
"bindings": {
|
||||
"version": "1.5.0",
|
||||
"resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz",
|
||||
"integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==",
|
||||
"optional": true,
|
||||
"requires": {
|
||||
"file-uri-to-path": "1.0.0"
|
||||
}
|
||||
},
|
||||
"buffer-from": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
|
||||
"integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="
|
||||
},
|
||||
"call-bind": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.2.tgz",
|
||||
"integrity": "sha512-7O+FbCihrB5WGbFYesctwmTKae6rOiIzmz1icreWJ+0aA7LJfuqhEso2T9ncpcFtzMQtzXf2QGGueWJGTYsqrA==",
|
||||
"requires": {
|
||||
"function-bind": "^1.1.1",
|
||||
"get-intrinsic": "^1.0.2"
|
||||
}
|
||||
},
|
||||
"dbus-next": {
|
||||
"version": "0.9.2",
|
||||
"resolved": "https://registry.npmjs.org/dbus-next/-/dbus-next-0.9.2.tgz",
|
||||
"integrity": "sha512-tzQq/+wrTZ2yU+U5PoeXc97KABhX2v55C/T0finH3tSKYuI8H/SqppIFymBBrUHcK13LvEGY3vdj3ikPPenL5g==",
|
||||
"requires": {
|
||||
"@nornagon/put": "0.0.8",
|
||||
"abstract-socket": "^2.0.0",
|
||||
"event-stream": "3.3.4",
|
||||
"hexy": "^0.2.10",
|
||||
"jsbi": "^2.0.5",
|
||||
"long": "^4.0.0",
|
||||
"safe-buffer": "^5.1.1",
|
||||
"xml2js": "^0.4.17"
|
||||
}
|
||||
},
|
||||
"deep-equal": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/deep-equal/-/deep-equal-1.1.1.tgz",
|
||||
"integrity": "sha512-yd9c5AdiqVcR+JjcwUQb9DkhJc8ngNr0MahEBGvDiJw8puWab2yZlh+nkasOnZP+EGTAP6rRp2JzJhJZzvNF8g==",
|
||||
"requires": {
|
||||
"is-arguments": "^1.0.4",
|
||||
"is-date-object": "^1.0.1",
|
||||
"is-regex": "^1.0.4",
|
||||
"object-is": "^1.0.1",
|
||||
"object-keys": "^1.1.1",
|
||||
"regexp.prototype.flags": "^1.2.0"
|
||||
}
|
||||
},
|
||||
"define-properties": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.1.4.tgz",
|
||||
"integrity": "sha512-uckOqKcfaVvtBdsVkdPv3XjveQJsNQqmhXgRi8uhvWWuPYZCNlzT8qAyblUgNoXdHdjMTzAqeGjAoli8f+bzPA==",
|
||||
"requires": {
|
||||
"has-property-descriptors": "^1.0.0",
|
||||
"object-keys": "^1.1.1"
|
||||
}
|
||||
},
|
||||
"duplexer": {
|
||||
"version": "0.1.2",
|
||||
"resolved": "https://registry.npmjs.org/duplexer/-/duplexer-0.1.2.tgz",
|
||||
"integrity": "sha512-jtD6YG370ZCIi/9GTaJKQxWTZD045+4R4hTk/x1UyoqadyJ9x9CgSi1RlVDQF8U2sxLLSnFkCaMihqljHIWgMg=="
|
||||
},
|
||||
"event-stream": {
|
||||
"version": "3.3.4",
|
||||
"resolved": "https://registry.npmjs.org/event-stream/-/event-stream-3.3.4.tgz",
|
||||
"integrity": "sha512-QHpkERcGsR0T7Qm3HNJSyXKEEj8AHNxkY3PK8TS2KJvQ7NiSHe3DDpwVKKtoYprL/AreyzFBeIkBIWChAqn60g==",
|
||||
"requires": {
|
||||
"duplexer": "~0.1.1",
|
||||
"from": "~0",
|
||||
"map-stream": "~0.1.0",
|
||||
"pause-stream": "0.0.11",
|
||||
"split": "0.3",
|
||||
"stream-combiner": "~0.0.4",
|
||||
"through": "~2.3.1"
|
||||
}
|
||||
},
|
||||
"file-uri-to-path": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz",
|
||||
"integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==",
|
||||
"optional": true
|
||||
},
|
||||
"from": {
|
||||
"version": "0.1.7",
|
||||
"resolved": "https://registry.npmjs.org/from/-/from-0.1.7.tgz",
|
||||
"integrity": "sha512-twe20eF1OxVxp/ML/kq2p1uc6KvFK/+vs8WjEbeKmV2He22MKm7YF2ANIt+EOqhJ5L3K/SuuPhk0hWQDjOM23g=="
|
||||
},
|
||||
"function-bind": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz",
|
||||
"integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A=="
|
||||
},
|
||||
"functions-have-names": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz",
|
||||
"integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ=="
|
||||
},
|
||||
"get-intrinsic": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.0.tgz",
|
||||
"integrity": "sha512-L049y6nFOuom5wGyRc3/gdTLO94dySVKRACj1RmJZBQXlbTMhtNIgkWkUHq+jYmZvKf14EW1EoJnnjbmoHij0Q==",
|
||||
"requires": {
|
||||
"function-bind": "^1.1.1",
|
||||
"has": "^1.0.3",
|
||||
"has-symbols": "^1.0.3"
|
||||
}
|
||||
},
|
||||
"has": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/has/-/has-1.0.3.tgz",
|
||||
"integrity": "sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw==",
|
||||
"requires": {
|
||||
"function-bind": "^1.1.1"
|
||||
}
|
||||
},
|
||||
"has-property-descriptors": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.0.tgz",
|
||||
"integrity": "sha512-62DVLZGoiEBDHQyqG4w9xCuZ7eJEwNmJRWw2VY84Oedb7WFcA27fiEVe8oUQx9hAUJ4ekurquucTGwsyO1XGdQ==",
|
||||
"requires": {
|
||||
"get-intrinsic": "^1.1.1"
|
||||
}
|
||||
},
|
||||
"has-symbols": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.3.tgz",
|
||||
"integrity": "sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A=="
|
||||
},
|
||||
"has-tostringtag": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.0.tgz",
|
||||
"integrity": "sha512-kFjcSNhnlGV1kyoGk7OXKSawH5JOb/LzUc5w9B02hOTO0dfFRjbHQKvg1d6cf3HbeUmtU9VbbV3qzZ2Teh97WQ==",
|
||||
"requires": {
|
||||
"has-symbols": "^1.0.2"
|
||||
}
|
||||
},
|
||||
"hexy": {
|
||||
"version": "0.2.11",
|
||||
"resolved": "https://registry.npmjs.org/hexy/-/hexy-0.2.11.tgz",
|
||||
"integrity": "sha512-ciq6hFsSG/Bpt2DmrZJtv+56zpPdnq+NQ4ijEFrveKN0ZG1mhl/LdT1NQZ9se6ty1fACcI4d4vYqC9v8EYpH2A=="
|
||||
},
|
||||
"is-arguments": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/is-arguments/-/is-arguments-1.1.1.tgz",
|
||||
"integrity": "sha512-8Q7EARjzEnKpt/PCD7e1cgUS0a6X8u5tdSiMqXhojOdoV9TsMsiO+9VLC5vAmO8N7/GmXn7yjR8qnA6bVAEzfA==",
|
||||
"requires": {
|
||||
"call-bind": "^1.0.2",
|
||||
"has-tostringtag": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"is-date-object": {
|
||||
"version": "1.0.5",
|
||||
"resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.0.5.tgz",
|
||||
"integrity": "sha512-9YQaSxsAiSwcvS33MBk3wTCVnWK+HhF8VZR2jRxehM16QcVOdHqPn4VPHmRK4lSr38n9JriurInLcP90xsYNfQ==",
|
||||
"requires": {
|
||||
"has-tostringtag": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"is-regex": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.1.4.tgz",
|
||||
"integrity": "sha512-kvRdxDsxZjhzUX07ZnLydzS1TU/TJlTUHHY4YLL87e37oUA49DfkLqgy+VjFocowy29cKvcSiu+kIv728jTTVg==",
|
||||
"requires": {
|
||||
"call-bind": "^1.0.2",
|
||||
"has-tostringtag": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"jsbi": {
|
||||
"version": "2.0.5",
|
||||
"resolved": "https://registry.npmjs.org/jsbi/-/jsbi-2.0.5.tgz",
|
||||
"integrity": "sha512-TzO/62Hxeb26QMb4IGlI/5X+QLr9Uqp1FPkwp2+KOICW+Q+vSuFj61c8pkT6wAns4WcK56X7CmSHhJeDGWOqxQ=="
|
||||
},
|
||||
"long": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/long/-/long-4.0.0.tgz",
|
||||
"integrity": "sha512-XsP+KhQif4bjX1kbuSiySJFNAehNxgLb6hPRGJ9QsUr8ajHkuXGdrHmFUTUUXhDwVX2R5bY4JNZEwbUiMhV+MA=="
|
||||
},
|
||||
"map-stream": {
|
||||
"version": "0.1.0",
|
||||
"resolved": "https://registry.npmjs.org/map-stream/-/map-stream-0.1.0.tgz",
|
||||
"integrity": "sha512-CkYQrPYZfWnu/DAmVCpTSX/xHpKZ80eKh2lAkyA6AJTef6bW+6JpbQZN5rofum7da+SyN1bi5ctTm+lTfcCW3g=="
|
||||
},
|
||||
"mpris-service": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/mpris-service/-/mpris-service-2.1.2.tgz",
|
||||
"integrity": "sha512-AC6WepCnFWwOME9OWplHZ8ps/BB+g9QrEpUKCv7wX82fDPzR3nPrypOFmL/Fm0JloEAu6QTWSfDLLc6mM/jinw==",
|
||||
"requires": {
|
||||
"dbus-next": "^0.9.2",
|
||||
"deep-equal": "^1.0.1",
|
||||
"source-map-support": "^0.5.11"
|
||||
}
|
||||
},
|
||||
"nan": {
|
||||
"version": "2.17.0",
|
||||
"resolved": "https://registry.npmjs.org/nan/-/nan-2.17.0.tgz",
|
||||
"integrity": "sha512-2ZTgtl0nJsO0KQCjEpxcIr5D+Yv90plTitZt9JBfQvVJDS5seMl3FOvsh3+9CoYWXf/1l5OaZzzF6nDm4cagaQ==",
|
||||
"optional": true
|
||||
},
|
||||
"object-is": {
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/object-is/-/object-is-1.1.5.tgz",
|
||||
"integrity": "sha512-3cyDsyHgtmi7I7DfSSI2LDp6SK2lwvtbg0p0R1e0RvTqF5ceGx+K2dfSjm1bKDMVCFEDAQvy+o8c6a7VujOddw==",
|
||||
"requires": {
|
||||
"call-bind": "^1.0.2",
|
||||
"define-properties": "^1.1.3"
|
||||
}
|
||||
},
|
||||
"object-keys": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz",
|
||||
"integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA=="
|
||||
},
|
||||
"pause-stream": {
|
||||
"version": "0.0.11",
|
||||
"resolved": "https://registry.npmjs.org/pause-stream/-/pause-stream-0.0.11.tgz",
|
||||
"integrity": "sha512-e3FBlXLmN/D1S+zHzanP4E/4Z60oFAa3O051qt1pxa7DEJWKAyil6upYVXCWadEnuoqa4Pkc9oUx9zsxYeRv8A==",
|
||||
"requires": {
|
||||
"through": "~2.3"
|
||||
}
|
||||
},
|
||||
"regexp.prototype.flags": {
|
||||
"version": "1.4.3",
|
||||
"resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.4.3.tgz",
|
||||
"integrity": "sha512-fjggEOO3slI6Wvgjwflkc4NFRCTZAu5CnNfBd5qOMYhWdn67nJBBu34/TkD++eeFmd8C9r9jfXJ27+nSiRkSUA==",
|
||||
"requires": {
|
||||
"call-bind": "^1.0.2",
|
||||
"define-properties": "^1.1.3",
|
||||
"functions-have-names": "^1.2.2"
|
||||
}
|
||||
},
|
||||
"safe-buffer": {
|
||||
"version": "5.2.1",
|
||||
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
|
||||
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="
|
||||
},
|
||||
"sax": {
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/sax/-/sax-1.2.4.tgz",
|
||||
"integrity": "sha512-NqVDv9TpANUjFm0N8uM5GxL36UgKi9/atZw+x7YFnQ8ckwFGKrl4xX4yWtrey3UJm5nP1kUbnYgLopqWNSRhWw=="
|
||||
},
|
||||
"source-map": {
|
||||
"version": "0.6.1",
|
||||
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
|
||||
"integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="
|
||||
},
|
||||
"source-map-support": {
|
||||
"version": "0.5.21",
|
||||
"resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz",
|
||||
"integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==",
|
||||
"requires": {
|
||||
"buffer-from": "^1.0.0",
|
||||
"source-map": "^0.6.0"
|
||||
}
|
||||
},
|
||||
"split": {
|
||||
"version": "0.3.3",
|
||||
"resolved": "https://registry.npmjs.org/split/-/split-0.3.3.tgz",
|
||||
"integrity": "sha512-wD2AeVmxXRBoX44wAycgjVpMhvbwdI2aZjCkvfNcH1YqHQvJVa1duWc73OyVGJUc05fhFaTZeQ/PYsrmyH0JVA==",
|
||||
"requires": {
|
||||
"through": "2"
|
||||
}
|
||||
},
|
||||
"stream-combiner": {
|
||||
"version": "0.0.4",
|
||||
"resolved": "https://registry.npmjs.org/stream-combiner/-/stream-combiner-0.0.4.tgz",
|
||||
"integrity": "sha512-rT00SPnTVyRsaSz5zgSPma/aHSOic5U1prhYdRy5HS2kTZviFpmDgzilbtsJsxiroqACmayynDN/9VzIbX5DOw==",
|
||||
"requires": {
|
||||
"duplexer": "~0.1.1"
|
||||
}
|
||||
},
|
||||
"through": {
|
||||
"version": "2.3.8",
|
||||
"resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz",
|
||||
"integrity": "sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg=="
|
||||
},
|
||||
"xml2js": {
|
||||
"version": "0.4.23",
|
||||
"resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.4.23.tgz",
|
||||
"integrity": "sha512-ySPiMjM0+pLDftHgXY4By0uswI3SPKLDw/i3UXbnO8M/p28zqexCUoPmQFrYD+/1BzhGJSs2i1ERWKJAtiLrug==",
|
||||
"requires": {
|
||||
"sax": ">=0.6.0",
|
||||
"xmlbuilder": "~11.0.0"
|
||||
}
|
||||
},
|
||||
"xmlbuilder": {
|
||||
"version": "11.0.1",
|
||||
"resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-11.0.1.tgz",
|
||||
"integrity": "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA=="
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "feishin",
|
||||
"version": "0.0.1-alpha2",
|
||||
"version": "0.0.1-alpha5",
|
||||
"description": "",
|
||||
"main": "./dist/main/main.js",
|
||||
"author": {
|
||||
@@ -12,6 +12,8 @@
|
||||
"link-modules": "node -r ts-node/register ../../.erb/scripts/link-modules.ts",
|
||||
"postinstall": "npm run electron-rebuild && npm run link-modules"
|
||||
},
|
||||
"dependencies": {},
|
||||
"dependencies": {
|
||||
"mpris-service": "^2.1.2"
|
||||
},
|
||||
"license": "GPL-3.0"
|
||||
}
|
||||
|
||||
@@ -1,84 +1,9 @@
|
||||
import { ipcMain } from 'electron';
|
||||
import uniq from 'lodash/uniq';
|
||||
import MpvAPI from 'node-mpv';
|
||||
import { store } from '../settings';
|
||||
import { getMainWindow } from '../../../main';
|
||||
import { mpv } from '../../../main';
|
||||
import { PlayerData } from '/@/renderer/store';
|
||||
|
||||
declare module 'node-mpv';
|
||||
|
||||
const BINARY_PATH = store.get('mpv_path') as string | undefined;
|
||||
const MPV_PARAMETERS = store.get('mpv_parameters') as Array<string> | undefined;
|
||||
const DEFAULT_MPV_PARAMETERS = () => {
|
||||
const parameters = [];
|
||||
if (
|
||||
!MPV_PARAMETERS?.includes('--gapless-audio=weak') ||
|
||||
!MPV_PARAMETERS?.includes('--gapless-audio=no') ||
|
||||
!MPV_PARAMETERS?.includes('--gapless-audio=yes') ||
|
||||
!MPV_PARAMETERS?.includes('--gapless-audio')
|
||||
) {
|
||||
parameters.push('--gapless-audio=yes');
|
||||
}
|
||||
|
||||
if (
|
||||
!MPV_PARAMETERS?.includes('--prefetch-playlist=no') ||
|
||||
!MPV_PARAMETERS?.includes('--prefetch-playlist=yes') ||
|
||||
!MPV_PARAMETERS?.includes('--prefetch-playlist')
|
||||
) {
|
||||
parameters.push('--prefetch-playlist=yes');
|
||||
}
|
||||
|
||||
return parameters;
|
||||
};
|
||||
|
||||
const mpv = new MpvAPI(
|
||||
{
|
||||
audio_only: true,
|
||||
auto_restart: true,
|
||||
binary: BINARY_PATH || '',
|
||||
time_update: 1,
|
||||
},
|
||||
MPV_PARAMETERS
|
||||
? uniq([...DEFAULT_MPV_PARAMETERS(), ...MPV_PARAMETERS])
|
||||
: DEFAULT_MPV_PARAMETERS(),
|
||||
);
|
||||
|
||||
mpv.start().catch((error) => {
|
||||
console.log('error starting mpv', error);
|
||||
});
|
||||
|
||||
mpv.on('status', (status) => {
|
||||
if (status.property === 'playlist-pos') {
|
||||
if (status.value !== 0) {
|
||||
getMainWindow()?.webContents.send('renderer-player-auto-next');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Automatically updates the play button when the player is playing
|
||||
mpv.on('resumed', () => {
|
||||
getMainWindow()?.webContents.send('renderer-player-play');
|
||||
});
|
||||
|
||||
// Automatically updates the play button when the player is stopped
|
||||
mpv.on('stopped', () => {
|
||||
getMainWindow()?.webContents.send('renderer-player-stop');
|
||||
});
|
||||
|
||||
// Automatically updates the play button when the player is paused
|
||||
mpv.on('paused', () => {
|
||||
getMainWindow()?.webContents.send('renderer-player-pause');
|
||||
});
|
||||
|
||||
mpv.on('quit', () => {
|
||||
console.log('mpv quit');
|
||||
});
|
||||
|
||||
// Event output every interval set by time_update, used to update the current time
|
||||
mpv.on('timeposition', (time: number) => {
|
||||
getMainWindow()?.webContents.send('renderer-player-current-time', time);
|
||||
});
|
||||
|
||||
// Starts the player
|
||||
ipcMain.on('player-play', async () => {
|
||||
await mpv.play();
|
||||
@@ -116,6 +41,12 @@ ipcMain.on('player-seek-to', async (_event, time: number) => {
|
||||
|
||||
// Sets the queue in position 0 and 1 to the given data. Used when manually starting a song or using the next/prev buttons
|
||||
ipcMain.on('player-set-queue', async (_event, data: PlayerData) => {
|
||||
if (!data.queue.current && !data.queue.next) {
|
||||
await mpv.clearPlaylist();
|
||||
await mpv.pause();
|
||||
return;
|
||||
}
|
||||
|
||||
if (data.queue.current) {
|
||||
await mpv.load(data.queue.current.streamUrl, 'replace');
|
||||
}
|
||||
@@ -123,6 +54,8 @@ ipcMain.on('player-set-queue', async (_event, data: PlayerData) => {
|
||||
if (data.queue.next) {
|
||||
await mpv.load(data.queue.next.streamUrl, 'append');
|
||||
}
|
||||
|
||||
await mpv.play();
|
||||
});
|
||||
|
||||
// Replaces the queue in position 1 to the given data
|
||||
@@ -161,5 +94,5 @@ ipcMain.on('player-mute', async () => {
|
||||
});
|
||||
|
||||
ipcMain.on('player-quit', async () => {
|
||||
await mpv.quit();
|
||||
await mpv.stop();
|
||||
});
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
import './mpris';
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
import { ipcMain } from 'electron';
|
||||
import Player from 'mpris-service';
|
||||
import { QueueSong, RelatedArtist } from '../../../renderer/api/types';
|
||||
import { getMainWindow } from '../../main';
|
||||
import { PlayerRepeat, PlayerShuffle, PlayerStatus } from '/@/renderer/types';
|
||||
|
||||
const mprisPlayer = Player({
|
||||
identity: 'Feishin',
|
||||
maximumRate: 1.0,
|
||||
minimumRate: 1.0,
|
||||
name: 'Feishin',
|
||||
rate: 1.0,
|
||||
supportedInterfaces: ['player'],
|
||||
supportedMimeTypes: ['audio/mpeg', 'application/ogg'],
|
||||
supportedUriSchemes: ['file'],
|
||||
});
|
||||
|
||||
mprisPlayer.on('quit', () => {
|
||||
process.exit();
|
||||
});
|
||||
|
||||
mprisPlayer.on('stop', () => {
|
||||
getMainWindow()?.webContents.send('renderer-player-stop');
|
||||
mprisPlayer.playbackStatus = 'Paused';
|
||||
});
|
||||
|
||||
mprisPlayer.on('pause', () => {
|
||||
getMainWindow()?.webContents.send('renderer-player-pause');
|
||||
mprisPlayer.playbackStatus = 'Paused';
|
||||
});
|
||||
|
||||
mprisPlayer.on('play', () => {
|
||||
getMainWindow()?.webContents.send('renderer-player-play');
|
||||
mprisPlayer.playbackStatus = 'Playing';
|
||||
});
|
||||
|
||||
mprisPlayer.on('playpause', () => {
|
||||
getMainWindow()?.webContents.send('renderer-player-play-pause');
|
||||
if (mprisPlayer.playbackStatus !== 'Playing') {
|
||||
mprisPlayer.playbackStatus = 'Playing';
|
||||
} else {
|
||||
mprisPlayer.playbackStatus = 'Paused';
|
||||
}
|
||||
});
|
||||
|
||||
mprisPlayer.on('next', () => {
|
||||
getMainWindow()?.webContents.send('renderer-player-next');
|
||||
|
||||
if (mprisPlayer.playbackStatus !== 'Playing') {
|
||||
mprisPlayer.playbackStatus = 'Playing';
|
||||
}
|
||||
});
|
||||
|
||||
mprisPlayer.on('previous', () => {
|
||||
getMainWindow()?.webContents.send('renderer-player-previous');
|
||||
|
||||
if (mprisPlayer.playbackStatus !== 'Playing') {
|
||||
mprisPlayer.playbackStatus = Player.PLAYBACK_STATUS_PLAYING;
|
||||
}
|
||||
});
|
||||
|
||||
mprisPlayer.on('volume', (event: any) => {
|
||||
getMainWindow()?.webContents.send('mpris-request-volume', {
|
||||
volume: event,
|
||||
});
|
||||
});
|
||||
|
||||
mprisPlayer.on('shuffle', (event: boolean) => {
|
||||
getMainWindow()?.webContents.send('mpris-request-toggle-shuffle', { shuffle: event });
|
||||
mprisPlayer.shuffle = event;
|
||||
});
|
||||
|
||||
mprisPlayer.on('loopStatus', (event: string) => {
|
||||
getMainWindow()?.webContents.send('mpris-request-toggle-repeat', { repeat: event });
|
||||
mprisPlayer.loopStatus = event;
|
||||
});
|
||||
|
||||
mprisPlayer.on('position', (event: any) => {
|
||||
getMainWindow()?.webContents.send('mpris-request-position', {
|
||||
position: event.position / 1e6,
|
||||
});
|
||||
});
|
||||
|
||||
mprisPlayer.on('seek', (event: number) => {
|
||||
getMainWindow()?.webContents.send('mpris-request-seek', {
|
||||
offset: event / 1e6,
|
||||
});
|
||||
});
|
||||
|
||||
ipcMain.on('mpris-update-position', (_event, arg) => {
|
||||
mprisPlayer.getPosition = () => arg * 1e6;
|
||||
});
|
||||
|
||||
ipcMain.on('mpris-update-seek', (_event, arg) => {
|
||||
mprisPlayer.seeked(arg * 1e6);
|
||||
});
|
||||
|
||||
ipcMain.on('mpris-update-volume', (_event, arg) => {
|
||||
mprisPlayer.volume = Number(arg);
|
||||
});
|
||||
|
||||
ipcMain.on('mpris-update-repeat', (_event, arg) => {
|
||||
mprisPlayer.loopStatus = arg;
|
||||
});
|
||||
|
||||
ipcMain.on('mpris-update-shuffle', (_event, arg) => {
|
||||
mprisPlayer.shuffle = arg;
|
||||
});
|
||||
|
||||
ipcMain.on(
|
||||
'mpris-update-song',
|
||||
(
|
||||
_event,
|
||||
args: {
|
||||
currentTime: number;
|
||||
repeat: PlayerRepeat;
|
||||
shuffle: PlayerShuffle;
|
||||
song: QueueSong;
|
||||
status: PlayerStatus;
|
||||
},
|
||||
) => {
|
||||
const { song, status, repeat, shuffle } = args || {};
|
||||
|
||||
try {
|
||||
mprisPlayer.playbackStatus = status;
|
||||
|
||||
if (repeat) {
|
||||
mprisPlayer.loopStatus =
|
||||
repeat === 'all' ? 'Playlist' : repeat === 'one' ? 'Track' : 'None';
|
||||
}
|
||||
|
||||
if (shuffle) {
|
||||
mprisPlayer.shuffle = shuffle;
|
||||
}
|
||||
|
||||
if (!song) return;
|
||||
|
||||
const upsizedImageUrl = song.imageUrl
|
||||
? song.imageUrl
|
||||
?.replace(/&size=\d+/, '&size=300')
|
||||
.replace(/\?width=\d+/, '?width=300')
|
||||
.replace(/&height=\d+/, '&height=300')
|
||||
: null;
|
||||
|
||||
mprisPlayer.metadata = {
|
||||
'mpris:artUrl': upsizedImageUrl,
|
||||
'mpris:length': song.duration ? Math.round((song.duration || 0) * 1e6) : null,
|
||||
'mpris:trackid': song?.id
|
||||
? mprisPlayer.objectPath(`track/${song.id?.replace('-', '')}`)
|
||||
: '',
|
||||
'xesam:album': song.album || null,
|
||||
'xesam:albumArtist': song.albumArtists?.length ? song.albumArtists[0].name : null,
|
||||
'xesam:artist':
|
||||
song.artists?.length !== 0
|
||||
? song.artists?.map((artist: RelatedArtist) => artist.name)
|
||||
: null,
|
||||
'xesam:discNumber': song.discNumber ? song.discNumber : null,
|
||||
'xesam:genre': song.genres?.length ? song.genres.map((genre: any) => genre.name) : null,
|
||||
'xesam:title': song.name || null,
|
||||
'xesam:trackNumber': song.trackNumber ? song.trackNumber : null,
|
||||
'xesam:useCount':
|
||||
song.playCount !== null && song.playCount !== undefined ? song.playCount : null,
|
||||
};
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
}
|
||||
},
|
||||
);
|
||||
+84
-2
@@ -13,12 +13,16 @@ import { app, BrowserWindow, shell, ipcMain, globalShortcut } from 'electron';
|
||||
import electronLocalShortcut from 'electron-localshortcut';
|
||||
import log from 'electron-log';
|
||||
import { autoUpdater } from 'electron-updater';
|
||||
import uniq from 'lodash/uniq';
|
||||
import MpvAPI from 'node-mpv';
|
||||
import { disableMediaKeys, enableMediaKeys } from './features/core/player/media-keys';
|
||||
import { store } from './features/core/settings/index';
|
||||
import MenuBuilder from './menu';
|
||||
import { resolveHtmlPath } from './utils';
|
||||
import './features';
|
||||
|
||||
declare module 'node-mpv';
|
||||
|
||||
export default class AppUpdater {
|
||||
constructor() {
|
||||
log.transports.file.level = 'info';
|
||||
@@ -89,8 +93,13 @@ const createWindow = async () => {
|
||||
mainWindow?.webContents.openDevTools();
|
||||
});
|
||||
|
||||
ipcMain.on('window-dev-tools', () => {
|
||||
mainWindow?.webContents.openDevTools();
|
||||
});
|
||||
|
||||
ipcMain.on('window-maximize', () => {
|
||||
mainWindow?.maximize();
|
||||
mainWindow?.webContents.send('renderer-player-quit');
|
||||
});
|
||||
|
||||
ipcMain.on('window-unmaximize', () => {
|
||||
@@ -138,6 +147,7 @@ const createWindow = async () => {
|
||||
});
|
||||
|
||||
mainWindow.on('closed', () => {
|
||||
// mainWindow?.webContents.send('renderer-player-quit');
|
||||
mainWindow = null;
|
||||
});
|
||||
|
||||
@@ -165,16 +175,88 @@ export const getMainWindow = () => {
|
||||
return mainWindow;
|
||||
};
|
||||
|
||||
const BINARY_PATH = store.get('mpv_path') as string | undefined;
|
||||
const MPV_PARAMETERS = store.get('mpv_parameters') as Array<string> | undefined;
|
||||
const DEFAULT_MPV_PARAMETERS = () => {
|
||||
const parameters = [];
|
||||
if (
|
||||
!MPV_PARAMETERS?.includes('--gapless-audio=weak') ||
|
||||
!MPV_PARAMETERS?.includes('--gapless-audio=no') ||
|
||||
!MPV_PARAMETERS?.includes('--gapless-audio=yes') ||
|
||||
!MPV_PARAMETERS?.includes('--gapless-audio')
|
||||
) {
|
||||
parameters.push('--gapless-audio=yes');
|
||||
}
|
||||
|
||||
if (
|
||||
!MPV_PARAMETERS?.includes('--prefetch-playlist=no') ||
|
||||
!MPV_PARAMETERS?.includes('--prefetch-playlist=yes') ||
|
||||
!MPV_PARAMETERS?.includes('--prefetch-playlist')
|
||||
) {
|
||||
parameters.push('--prefetch-playlist=yes');
|
||||
}
|
||||
|
||||
return parameters;
|
||||
};
|
||||
|
||||
export const mpv = new MpvAPI(
|
||||
{
|
||||
audio_only: true,
|
||||
auto_restart: true,
|
||||
binary: BINARY_PATH || '',
|
||||
time_update: 1,
|
||||
},
|
||||
MPV_PARAMETERS
|
||||
? uniq([...DEFAULT_MPV_PARAMETERS(), ...MPV_PARAMETERS])
|
||||
: DEFAULT_MPV_PARAMETERS(),
|
||||
);
|
||||
|
||||
mpv.start().catch((error) => {
|
||||
console.log('error starting mpv', error);
|
||||
});
|
||||
|
||||
mpv.on('status', (status) => {
|
||||
if (status.property === 'playlist-pos') {
|
||||
if (status.value !== 0) {
|
||||
getMainWindow()?.webContents.send('renderer-player-auto-next');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Automatically updates the play button when the player is playing
|
||||
mpv.on('resumed', () => {
|
||||
getMainWindow()?.webContents.send('renderer-player-play');
|
||||
});
|
||||
|
||||
// Automatically updates the play button when the player is stopped
|
||||
mpv.on('stopped', () => {
|
||||
getMainWindow()?.webContents.send('renderer-player-stop');
|
||||
});
|
||||
|
||||
// Automatically updates the play button when the player is paused
|
||||
mpv.on('paused', () => {
|
||||
getMainWindow()?.webContents.send('renderer-player-pause');
|
||||
});
|
||||
|
||||
// Event output every interval set by time_update, used to update the current time
|
||||
mpv.on('timeposition', (time: number) => {
|
||||
getMainWindow()?.webContents.send('renderer-player-current-time', time);
|
||||
});
|
||||
|
||||
app.on('before-quit', () => {
|
||||
mainWindow?.webContents.send('renderer-player-quit');
|
||||
mpv.stop();
|
||||
});
|
||||
|
||||
app.on('window-all-closed', () => {
|
||||
globalShortcut.unregisterAll();
|
||||
|
||||
// Respect the OSX convention of having the application in memory even
|
||||
// after all windows have been closed
|
||||
globalShortcut.unregisterAll();
|
||||
if (process.platform !== 'darwin') {
|
||||
app.quit();
|
||||
} else {
|
||||
mpv.stop();
|
||||
mainWindow = null;
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -3,7 +3,9 @@ import { PlayerData } from '../renderer/store';
|
||||
import { browser } from './preload/browser';
|
||||
import { ipc } from './preload/ipc';
|
||||
import { localSettings } from './preload/local-settings';
|
||||
import { mpris } from './preload/mpris';
|
||||
import { mpvPlayer, mpvPlayerListener } from './preload/mpv-player';
|
||||
import { utils } from './preload/utils';
|
||||
|
||||
contextBridge.exposeInMainWorld('electron', {
|
||||
browser,
|
||||
@@ -104,6 +106,8 @@ contextBridge.exposeInMainWorld('electron', {
|
||||
},
|
||||
},
|
||||
localSettings,
|
||||
mpris,
|
||||
mpvPlayer,
|
||||
mpvPlayerListener,
|
||||
utils,
|
||||
});
|
||||
|
||||
@@ -13,7 +13,12 @@ const unmaximize = () => {
|
||||
ipcRenderer.send('window-unmaximize');
|
||||
};
|
||||
|
||||
const devtools = () => {
|
||||
ipcRenderer.send('window-dev-tools');
|
||||
};
|
||||
|
||||
export const browser = {
|
||||
devtools,
|
||||
exit,
|
||||
maximize,
|
||||
minimize,
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import { IpcRendererEvent, ipcRenderer } from 'electron';
|
||||
import { QueueSong } from '/@/renderer/api/types';
|
||||
|
||||
const updateSong = (args: { currentTime: number; song: QueueSong }) => {
|
||||
ipcRenderer.send('mpris-update-song', args);
|
||||
};
|
||||
|
||||
const updatePosition = (timeSec: number) => {
|
||||
ipcRenderer.send('mpris-update-position', timeSec);
|
||||
};
|
||||
|
||||
const updateSeek = (timeSec: number) => {
|
||||
ipcRenderer.send('mpris-update-seek', timeSec);
|
||||
};
|
||||
|
||||
const updateVolume = (volume: number) => {
|
||||
ipcRenderer.send('mpris-update-volume', volume);
|
||||
};
|
||||
|
||||
const updateRepeat = (repeat: string) => {
|
||||
ipcRenderer.send('mpris-update-repeat', repeat);
|
||||
};
|
||||
|
||||
const updateShuffle = (shuffle: boolean) => {
|
||||
ipcRenderer.send('mpris-update-shuffle', shuffle);
|
||||
};
|
||||
|
||||
const toggleRepeat = () => {
|
||||
ipcRenderer.send('mpris-toggle-repeat');
|
||||
};
|
||||
|
||||
const toggleShuffle = () => {
|
||||
ipcRenderer.send('mpris-toggle-shuffle');
|
||||
};
|
||||
|
||||
const requestPosition = (cb: (event: IpcRendererEvent, data: { position: number }) => void) => {
|
||||
ipcRenderer.on('mpris-request-position', cb);
|
||||
};
|
||||
|
||||
const requestSeek = (cb: (event: IpcRendererEvent, data: { offset: number }) => void) => {
|
||||
ipcRenderer.on('mpris-request-seek', cb);
|
||||
};
|
||||
|
||||
const requestVolume = (cb: (event: IpcRendererEvent, data: { volume: number }) => void) => {
|
||||
ipcRenderer.on('mpris-request-volume', cb);
|
||||
};
|
||||
|
||||
const requestToggleRepeat = (cb: (event: IpcRendererEvent) => void) => {
|
||||
ipcRenderer.on('mpris-request-toggle-repeat', cb);
|
||||
};
|
||||
|
||||
const requestToggleShuffle = (cb: (event: IpcRendererEvent) => void) => {
|
||||
ipcRenderer.on('mpris-request-toggle-shuffle', cb);
|
||||
};
|
||||
|
||||
export const mpris = {
|
||||
requestPosition,
|
||||
requestSeek,
|
||||
requestToggleRepeat,
|
||||
requestToggleShuffle,
|
||||
requestVolume,
|
||||
toggleRepeat,
|
||||
toggleShuffle,
|
||||
updatePosition,
|
||||
updateRepeat,
|
||||
updateSeek,
|
||||
updateShuffle,
|
||||
updateSong,
|
||||
updateVolume,
|
||||
};
|
||||
@@ -0,0 +1,7 @@
|
||||
import { isMacOS, isWindows, isLinux } from '../utils';
|
||||
|
||||
export const utils = {
|
||||
isLinux,
|
||||
isMacOS,
|
||||
isWindows,
|
||||
};
|
||||
@@ -16,7 +16,6 @@ import type {
|
||||
RawAlbumArtistListResponse,
|
||||
RatingArgs,
|
||||
RawRatingResponse,
|
||||
FavoriteArgs,
|
||||
RawFavoriteResponse,
|
||||
GenreListArgs,
|
||||
RawGenreListResponse,
|
||||
@@ -33,11 +32,26 @@ import type {
|
||||
PlaylistSongListArgs,
|
||||
ArtistListArgs,
|
||||
RawArtistListResponse,
|
||||
UpdatePlaylistArgs,
|
||||
RawUpdatePlaylistResponse,
|
||||
UserListArgs,
|
||||
RawUserListResponse,
|
||||
FavoriteArgs,
|
||||
TopSongListArgs,
|
||||
RawTopSongListResponse,
|
||||
AddToPlaylistArgs,
|
||||
RawAddToPlaylistResponse,
|
||||
RemoveFromPlaylistArgs,
|
||||
RawRemoveFromPlaylistResponse,
|
||||
ScrobbleArgs,
|
||||
RawScrobbleResponse,
|
||||
} from '/@/renderer/api/types';
|
||||
import { subsonicApi } from '/@/renderer/api/subsonic.api';
|
||||
import { jellyfinApi } from '/@/renderer/api/jellyfin.api';
|
||||
import { ServerListItem } from '/@/renderer/types';
|
||||
|
||||
export type ControllerEndpoint = Partial<{
|
||||
addToPlaylist: (args: AddToPlaylistArgs) => Promise<RawAddToPlaylistResponse>;
|
||||
clearPlaylist: () => void;
|
||||
createFavorite: (args: FavoriteArgs) => Promise<RawFavoriteResponse>;
|
||||
createPlaylist: (args: CreatePlaylistArgs) => Promise<RawCreatePlaylistResponse>;
|
||||
@@ -48,6 +62,7 @@ export type ControllerEndpoint = Partial<{
|
||||
getAlbumDetail: (args: AlbumDetailArgs) => Promise<RawAlbumDetailResponse>;
|
||||
getAlbumList: (args: AlbumListArgs) => Promise<RawAlbumListResponse>;
|
||||
getArtistDetail: () => void;
|
||||
getArtistInfo: (args: any) => void;
|
||||
getArtistList: (args: ArtistListArgs) => Promise<RawArtistListResponse>;
|
||||
getFavoritesList: () => void;
|
||||
getFolderItemList: () => void;
|
||||
@@ -60,7 +75,11 @@ export type ControllerEndpoint = Partial<{
|
||||
getPlaylistSongList: (args: PlaylistSongListArgs) => Promise<RawSongListResponse>;
|
||||
getSongDetail: (args: SongDetailArgs) => Promise<RawSongDetailResponse>;
|
||||
getSongList: (args: SongListArgs) => Promise<RawSongListResponse>;
|
||||
updatePlaylist: () => void;
|
||||
getTopSongs: (args: TopSongListArgs) => Promise<RawTopSongListResponse>;
|
||||
getUserList: (args: UserListArgs) => Promise<RawUserListResponse>;
|
||||
removeFromPlaylist: (args: RemoveFromPlaylistArgs) => Promise<RawRemoveFromPlaylistResponse>;
|
||||
scrobble: (args: ScrobbleArgs) => Promise<RawScrobbleResponse>;
|
||||
updatePlaylist: (args: UpdatePlaylistArgs) => Promise<RawUpdatePlaylistResponse>;
|
||||
updateRating: (args: RatingArgs) => Promise<RawRatingResponse>;
|
||||
}>;
|
||||
|
||||
@@ -72,6 +91,7 @@ type ApiController = {
|
||||
|
||||
const endpoints: ApiController = {
|
||||
jellyfin: {
|
||||
addToPlaylist: jellyfinApi.addToPlaylist,
|
||||
clearPlaylist: undefined,
|
||||
createFavorite: jellyfinApi.createFavorite,
|
||||
createPlaylist: jellyfinApi.createPlaylist,
|
||||
@@ -82,6 +102,7 @@ const endpoints: ApiController = {
|
||||
getAlbumDetail: jellyfinApi.getAlbumDetail,
|
||||
getAlbumList: jellyfinApi.getAlbumList,
|
||||
getArtistDetail: undefined,
|
||||
getArtistInfo: undefined,
|
||||
getArtistList: jellyfinApi.getArtistList,
|
||||
getFavoritesList: undefined,
|
||||
getFolderItemList: undefined,
|
||||
@@ -94,10 +115,15 @@ const endpoints: ApiController = {
|
||||
getPlaylistSongList: jellyfinApi.getPlaylistSongList,
|
||||
getSongDetail: undefined,
|
||||
getSongList: jellyfinApi.getSongList,
|
||||
updatePlaylist: undefined,
|
||||
getTopSongs: undefined,
|
||||
getUserList: undefined,
|
||||
removeFromPlaylist: jellyfinApi.removeFromPlaylist,
|
||||
scrobble: jellyfinApi.scrobble,
|
||||
updatePlaylist: jellyfinApi.updatePlaylist,
|
||||
updateRating: undefined,
|
||||
},
|
||||
navidrome: {
|
||||
addToPlaylist: navidromeApi.addToPlaylist,
|
||||
clearPlaylist: undefined,
|
||||
createFavorite: subsonicApi.createFavorite,
|
||||
createPlaylist: navidromeApi.createPlaylist,
|
||||
@@ -108,6 +134,7 @@ const endpoints: ApiController = {
|
||||
getAlbumDetail: navidromeApi.getAlbumDetail,
|
||||
getAlbumList: navidromeApi.getAlbumList,
|
||||
getArtistDetail: undefined,
|
||||
getArtistInfo: undefined,
|
||||
getArtistList: undefined,
|
||||
getFavoritesList: undefined,
|
||||
getFolderItemList: undefined,
|
||||
@@ -120,7 +147,11 @@ const endpoints: ApiController = {
|
||||
getPlaylistSongList: navidromeApi.getPlaylistSongList,
|
||||
getSongDetail: navidromeApi.getSongDetail,
|
||||
getSongList: navidromeApi.getSongList,
|
||||
updatePlaylist: undefined,
|
||||
getTopSongs: subsonicApi.getTopSongList,
|
||||
getUserList: navidromeApi.getUserList,
|
||||
removeFromPlaylist: navidromeApi.removeFromPlaylist,
|
||||
scrobble: subsonicApi.scrobble,
|
||||
updatePlaylist: navidromeApi.updatePlaylist,
|
||||
updateRating: subsonicApi.updateRating,
|
||||
},
|
||||
subsonic: {
|
||||
@@ -134,6 +165,7 @@ const endpoints: ApiController = {
|
||||
getAlbumDetail: subsonicApi.getAlbumDetail,
|
||||
getAlbumList: subsonicApi.getAlbumList,
|
||||
getArtistDetail: undefined,
|
||||
getArtistInfo: undefined,
|
||||
getArtistList: undefined,
|
||||
getFavoritesList: undefined,
|
||||
getFolderItemList: undefined,
|
||||
@@ -145,13 +177,16 @@ const endpoints: ApiController = {
|
||||
getPlaylistList: undefined,
|
||||
getSongDetail: undefined,
|
||||
getSongList: undefined,
|
||||
getTopSongs: subsonicApi.getTopSongList,
|
||||
getUserList: undefined,
|
||||
scrobble: subsonicApi.scrobble,
|
||||
updatePlaylist: undefined,
|
||||
updateRating: undefined,
|
||||
},
|
||||
};
|
||||
|
||||
const apiController = (endpoint: keyof ControllerEndpoint) => {
|
||||
const serverType = useAuthStore.getState().currentServer?.type;
|
||||
const apiController = (endpoint: keyof ControllerEndpoint, server?: ServerListItem | null) => {
|
||||
const serverType = server?.type || useAuthStore.getState().currentServer?.type;
|
||||
|
||||
if (!serverType) {
|
||||
toast.error({ message: 'No server selected', title: 'Unable to route request' });
|
||||
@@ -191,10 +226,99 @@ const getGenreList = async (args: GenreListArgs) => {
|
||||
return (apiController('getGenreList') as ControllerEndpoint['getGenreList'])?.(args);
|
||||
};
|
||||
|
||||
const getAlbumArtistDetail = async (args: AlbumArtistDetailArgs) => {
|
||||
return (apiController('getAlbumArtistDetail') as ControllerEndpoint['getAlbumArtistDetail'])?.(
|
||||
args,
|
||||
);
|
||||
};
|
||||
|
||||
const getAlbumArtistList = async (args: AlbumArtistListArgs) => {
|
||||
return (apiController('getAlbumArtistList') as ControllerEndpoint['getAlbumArtistList'])?.(args);
|
||||
};
|
||||
|
||||
const getArtistList = async (args: ArtistListArgs) => {
|
||||
return (apiController('getArtistList') as ControllerEndpoint['getArtistList'])?.(args);
|
||||
};
|
||||
|
||||
const getPlaylistList = async (args: PlaylistListArgs) => {
|
||||
return (apiController('getPlaylistList') as ControllerEndpoint['getPlaylistList'])?.(args);
|
||||
};
|
||||
|
||||
const createPlaylist = async (args: CreatePlaylistArgs) => {
|
||||
return (apiController('createPlaylist') as ControllerEndpoint['createPlaylist'])?.(args);
|
||||
};
|
||||
|
||||
const updatePlaylist = async (args: UpdatePlaylistArgs) => {
|
||||
return (apiController('updatePlaylist') as ControllerEndpoint['updatePlaylist'])?.(args);
|
||||
};
|
||||
|
||||
const deletePlaylist = async (args: DeletePlaylistArgs) => {
|
||||
return (apiController('deletePlaylist') as ControllerEndpoint['deletePlaylist'])?.(args);
|
||||
};
|
||||
|
||||
const addToPlaylist = async (args: AddToPlaylistArgs) => {
|
||||
return (apiController('addToPlaylist') as ControllerEndpoint['addToPlaylist'])?.(args);
|
||||
};
|
||||
|
||||
const removeFromPlaylist = async (args: RemoveFromPlaylistArgs) => {
|
||||
return (apiController('removeFromPlaylist') as ControllerEndpoint['removeFromPlaylist'])?.(args);
|
||||
};
|
||||
|
||||
const getPlaylistDetail = async (args: PlaylistDetailArgs) => {
|
||||
return (apiController('getPlaylistDetail') as ControllerEndpoint['getPlaylistDetail'])?.(args);
|
||||
};
|
||||
|
||||
const getPlaylistSongList = async (args: PlaylistSongListArgs) => {
|
||||
return (apiController('getPlaylistSongList') as ControllerEndpoint['getPlaylistSongList'])?.(
|
||||
args,
|
||||
);
|
||||
};
|
||||
|
||||
const getUserList = async (args: UserListArgs) => {
|
||||
return (apiController('getUserList') as ControllerEndpoint['getUserList'])?.(args);
|
||||
};
|
||||
|
||||
const createFavorite = async (args: FavoriteArgs) => {
|
||||
return (apiController('createFavorite') as ControllerEndpoint['createFavorite'])?.(args);
|
||||
};
|
||||
|
||||
const deleteFavorite = async (args: FavoriteArgs) => {
|
||||
return (apiController('deleteFavorite') as ControllerEndpoint['deleteFavorite'])?.(args);
|
||||
};
|
||||
|
||||
const updateRating = async (args: RatingArgs) => {
|
||||
return (apiController('updateRating') as ControllerEndpoint['updateRating'])?.(args);
|
||||
};
|
||||
|
||||
const getTopSongList = async (args: TopSongListArgs) => {
|
||||
return (apiController('getTopSongs') as ControllerEndpoint['getTopSongs'])?.(args);
|
||||
};
|
||||
|
||||
const scrobble = async (args: ScrobbleArgs) => {
|
||||
return (apiController('scrobble', args.server) as ControllerEndpoint['scrobble'])?.(args);
|
||||
};
|
||||
|
||||
export const controller = {
|
||||
addToPlaylist,
|
||||
createFavorite,
|
||||
createPlaylist,
|
||||
deleteFavorite,
|
||||
deletePlaylist,
|
||||
getAlbumArtistDetail,
|
||||
getAlbumArtistList,
|
||||
getAlbumDetail,
|
||||
getAlbumList,
|
||||
getArtistList,
|
||||
getGenreList,
|
||||
getMusicFolderList,
|
||||
getPlaylistDetail,
|
||||
getPlaylistList,
|
||||
getPlaylistSongList,
|
||||
getSongList,
|
||||
getTopSongList,
|
||||
getUserList,
|
||||
removeFromPlaylist,
|
||||
scrobble,
|
||||
updatePlaylist,
|
||||
updateRating,
|
||||
};
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import ky from 'ky';
|
||||
import { nanoid } from 'nanoid/non-secure';
|
||||
import type {
|
||||
JFAddToPlaylist,
|
||||
JFAddToPlaylistParams,
|
||||
JFAlbum,
|
||||
JFAlbumArtist,
|
||||
JFAlbumArtistDetail,
|
||||
JFAlbumArtistDetailResponse,
|
||||
JFAlbumArtistList,
|
||||
@@ -21,18 +24,22 @@ import type {
|
||||
JFGenreListResponse,
|
||||
JFMusicFolderList,
|
||||
JFMusicFolderListResponse,
|
||||
JFPlaylist,
|
||||
JFPlaylistDetail,
|
||||
JFPlaylistDetailResponse,
|
||||
JFPlaylistList,
|
||||
JFPlaylistListResponse,
|
||||
JFRemoveFromPlaylist,
|
||||
JFRemoveFromPlaylistParams,
|
||||
JFSong,
|
||||
JFSongList,
|
||||
JFSongListParams,
|
||||
JFSongListResponse,
|
||||
} from '/@/renderer/api/jellyfin.types';
|
||||
import { JFCollectionType } from '/@/renderer/api/jellyfin.types';
|
||||
import type {
|
||||
import {
|
||||
Album,
|
||||
AlbumArtist,
|
||||
AlbumArtistDetailArgs,
|
||||
AlbumArtistListArgs,
|
||||
AlbumDetailArgs,
|
||||
@@ -46,22 +53,30 @@ import type {
|
||||
FavoriteResponse,
|
||||
GenreListArgs,
|
||||
MusicFolderListArgs,
|
||||
Playlist,
|
||||
PlaylistDetailArgs,
|
||||
PlaylistListArgs,
|
||||
playlistListSortMap,
|
||||
PlaylistSongListArgs,
|
||||
Song,
|
||||
SongListArgs,
|
||||
} from '/@/renderer/api/types';
|
||||
import {
|
||||
songListSortMap,
|
||||
albumListSortMap,
|
||||
artistListSortMap,
|
||||
sortOrderMap,
|
||||
albumArtistListSortMap,
|
||||
UpdatePlaylistArgs,
|
||||
UpdatePlaylistResponse,
|
||||
LibraryItem,
|
||||
RemoveFromPlaylistArgs,
|
||||
AddToPlaylistArgs,
|
||||
ScrobbleArgs,
|
||||
RawScrobbleResponse,
|
||||
} from '/@/renderer/api/types';
|
||||
import { useAuthStore } from '/@/renderer/store';
|
||||
import { ServerListItem, ServerType } from '/@/renderer/types';
|
||||
import { parseSearchParams } from '/@/renderer/utils';
|
||||
import packageJson from '../../../package.json';
|
||||
|
||||
const getCommaDelimitedString = (value: string[]) => {
|
||||
return value.join(',');
|
||||
@@ -81,8 +96,7 @@ const authenticate = async (
|
||||
const data = await ky
|
||||
.post(`${cleanServerUrl}/users/authenticatebyname`, {
|
||||
headers: {
|
||||
'X-Emby-Authorization':
|
||||
'MediaBrowser Client="Feishin", Device="PC", DeviceId="Feishin", Version="0.0.1-alpha2"',
|
||||
'X-Emby-Authorization': `MediaBrowser Client="Feishin", Device="PC", DeviceId="Feishin", Version="${packageJson.version}"`,
|
||||
},
|
||||
json: {
|
||||
pw: body.password,
|
||||
@@ -134,11 +148,11 @@ const getAlbumArtistDetail = async (args: AlbumArtistDetailArgs): Promise<JFAlbu
|
||||
const { query, server, signal } = args;
|
||||
|
||||
const searchParams = {
|
||||
fields: 'Genres',
|
||||
fields: 'Genres, Overview',
|
||||
};
|
||||
|
||||
const data = await api
|
||||
.get(`/users/${server?.userId}/items/${query.id}`, {
|
||||
.get(`users/${server?.userId}/items/${query.id}`, {
|
||||
headers: { 'X-MediaBrowser-Token': server?.credential },
|
||||
prefixUrl: server?.url,
|
||||
searchParams: parseSearchParams(searchParams),
|
||||
@@ -146,7 +160,16 @@ const getAlbumArtistDetail = async (args: AlbumArtistDetailArgs): Promise<JFAlbu
|
||||
})
|
||||
.json<JFAlbumArtistDetailResponse>();
|
||||
|
||||
return data;
|
||||
const similarArtists = await api
|
||||
.get(`artists/${query.id}/similar`, {
|
||||
headers: { 'X-MediaBrowser-Token': server?.credential },
|
||||
prefixUrl: server?.url,
|
||||
searchParams: parseSearchParams({ limit: 10 }),
|
||||
signal,
|
||||
})
|
||||
.json<JFAlbumArtistListResponse>();
|
||||
|
||||
return { ...data, similarArtists: { items: similarArtists.Items } };
|
||||
};
|
||||
|
||||
// const getAlbumArtistAlbums = () => {
|
||||
@@ -170,12 +193,16 @@ const getAlbumArtistList = async (args: AlbumArtistListArgs): Promise<JFAlbumArt
|
||||
const { query, server, signal } = args;
|
||||
|
||||
const searchParams: JFAlbumArtistListParams = {
|
||||
fields: 'Genres, DateCreated, ExternalUrls, Overview',
|
||||
imageTypeLimit: 1,
|
||||
limit: query.limit,
|
||||
parentId: query.musicFolderId,
|
||||
recursive: true,
|
||||
searchTerm: query.searchTerm,
|
||||
sortBy: albumArtistListSortMap.jellyfin[query.sortBy],
|
||||
sortOrder: sortOrderMap.jellyfin[query.sortOrder],
|
||||
startIndex: query.startIndex,
|
||||
userId: server?.userId || undefined,
|
||||
};
|
||||
|
||||
const data = await api
|
||||
@@ -187,7 +214,11 @@ const getAlbumArtistList = async (args: AlbumArtistListArgs): Promise<JFAlbumArt
|
||||
})
|
||||
.json<JFAlbumArtistListResponse>();
|
||||
|
||||
return data;
|
||||
return {
|
||||
items: data.Items,
|
||||
startIndex: query.startIndex,
|
||||
totalRecordCount: data.TotalRecordCount,
|
||||
};
|
||||
};
|
||||
|
||||
const getArtistList = async (args: ArtistListArgs): Promise<JFArtistList> => {
|
||||
@@ -303,9 +334,11 @@ const getSongList = async (args: SongListArgs): Promise<JFSongList> => {
|
||||
|
||||
const yearsFilter = yearsGroup.length ? getCommaDelimitedString(yearsGroup) : undefined;
|
||||
const albumIdsFilter = query.albumIds ? getCommaDelimitedString(query.albumIds) : undefined;
|
||||
const artistIdsFilter = query.artistIds ? getCommaDelimitedString(query.artistIds) : undefined;
|
||||
|
||||
const searchParams: JFSongListParams & { maxYear?: number; minYear?: number } = {
|
||||
albumIds: albumIdsFilter,
|
||||
artistIds: artistIdsFilter,
|
||||
fields: 'Genres, DateCreated, MediaSources, ParentId',
|
||||
includeItemTypes: 'Audio',
|
||||
limit: query.limit,
|
||||
@@ -337,6 +370,45 @@ const getSongList = async (args: SongListArgs): Promise<JFSongList> => {
|
||||
};
|
||||
};
|
||||
|
||||
const addToPlaylist = async (args: AddToPlaylistArgs): Promise<JFAddToPlaylist> => {
|
||||
const { query, body, server, signal } = args;
|
||||
|
||||
const searchParams: JFAddToPlaylistParams = {
|
||||
ids: body.songId,
|
||||
userId: server?.userId || '',
|
||||
};
|
||||
|
||||
await api
|
||||
.post(`playlists/${query.id}/items`, {
|
||||
headers: { 'X-MediaBrowser-Token': server?.credential },
|
||||
prefixUrl: server?.url,
|
||||
searchParams: parseSearchParams(searchParams),
|
||||
signal,
|
||||
})
|
||||
.json<JFPlaylistDetailResponse>();
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const removeFromPlaylist = async (args: RemoveFromPlaylistArgs): Promise<JFRemoveFromPlaylist> => {
|
||||
const { query, server, signal } = args;
|
||||
|
||||
const searchParams: JFRemoveFromPlaylistParams = {
|
||||
entryIds: query.songId,
|
||||
};
|
||||
|
||||
await api
|
||||
.delete(`playlists/${query.id}/items`, {
|
||||
headers: { 'X-MediaBrowser-Token': server?.credential },
|
||||
prefixUrl: server?.url,
|
||||
searchParams: parseSearchParams(searchParams),
|
||||
signal,
|
||||
})
|
||||
.json<JFPlaylistDetailResponse>();
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const getPlaylistDetail = async (args: PlaylistDetailArgs): Promise<JFPlaylistDetail> => {
|
||||
const { query, server, signal } = args;
|
||||
|
||||
@@ -363,8 +435,11 @@ const getPlaylistSongList = async (args: PlaylistSongListArgs): Promise<JFSongLi
|
||||
const searchParams: JFSongListParams = {
|
||||
fields: 'Genres, DateCreated, MediaSources, UserData, ParentId',
|
||||
includeItemTypes: 'Audio',
|
||||
limit: query.limit,
|
||||
sortBy: query.sortBy ? songListSortMap.jellyfin[query.sortBy] : undefined,
|
||||
sortOrder: query.sortOrder ? sortOrderMap.jellyfin[query.sortOrder] : undefined,
|
||||
startIndex: 0,
|
||||
userId: server?.userId || '',
|
||||
};
|
||||
|
||||
const data = await api
|
||||
@@ -384,18 +459,20 @@ const getPlaylistSongList = async (args: PlaylistSongListArgs): Promise<JFSongLi
|
||||
};
|
||||
|
||||
const getPlaylistList = async (args: PlaylistListArgs): Promise<JFPlaylistList> => {
|
||||
const { server, signal } = args;
|
||||
const { query, server, signal } = args;
|
||||
|
||||
const searchParams = {
|
||||
fields: 'ChildCount, Genres, DateCreated, ParentId, Overview',
|
||||
includeItemTypes: 'Playlist',
|
||||
limit: query.limit,
|
||||
recursive: true,
|
||||
sortBy: 'SortName',
|
||||
sortOrder: 'Ascending',
|
||||
sortBy: playlistListSortMap.jellyfin[query.sortBy],
|
||||
sortOrder: sortOrderMap.jellyfin[query.sortOrder],
|
||||
startIndex: query.startIndex,
|
||||
};
|
||||
|
||||
const data = await api
|
||||
.get(`/users/${server?.userId}/items`, {
|
||||
.get(`users/${server?.userId}/items`, {
|
||||
headers: { 'X-MediaBrowser-Token': server?.credential },
|
||||
prefixUrl: server?.url,
|
||||
searchParams: parseSearchParams(searchParams),
|
||||
@@ -403,35 +480,63 @@ const getPlaylistList = async (args: PlaylistListArgs): Promise<JFPlaylistList>
|
||||
})
|
||||
.json<JFPlaylistListResponse>();
|
||||
|
||||
const playlistData = data.Items.filter((item) => item.MediaType === 'Audio');
|
||||
const playlistItems = data.Items.filter((item) => item.MediaType === 'Audio');
|
||||
|
||||
return {
|
||||
Items: playlistData,
|
||||
StartIndex: 0,
|
||||
TotalRecordCount: playlistData.length,
|
||||
items: playlistItems,
|
||||
startIndex: 0,
|
||||
totalRecordCount: playlistItems.length,
|
||||
};
|
||||
};
|
||||
|
||||
const createPlaylist = async (args: CreatePlaylistArgs): Promise<CreatePlaylistResponse> => {
|
||||
const { query, server } = args;
|
||||
const { body, server } = args;
|
||||
|
||||
const body = {
|
||||
const json = {
|
||||
MediaType: 'Audio',
|
||||
Name: query.name,
|
||||
Name: body.name,
|
||||
Overview: body.comment || '',
|
||||
UserId: server?.userId,
|
||||
};
|
||||
|
||||
const data = await api
|
||||
.post('playlists', {
|
||||
headers: { 'X-MediaBrowser-Token': server?.credential },
|
||||
json: body,
|
||||
json,
|
||||
prefixUrl: server?.url,
|
||||
})
|
||||
.json<JFCreatePlaylistResponse>();
|
||||
|
||||
return {
|
||||
id: data.Id,
|
||||
name: query.name,
|
||||
name: body.name,
|
||||
};
|
||||
};
|
||||
|
||||
const updatePlaylist = async (args: UpdatePlaylistArgs): Promise<UpdatePlaylistResponse> => {
|
||||
const { query, body, server } = args;
|
||||
|
||||
const json = {
|
||||
Genres: body.genres?.map((item) => ({ Id: item.id, Name: item.name })) || [],
|
||||
MediaType: 'Audio',
|
||||
Name: body.name,
|
||||
Overview: body.comment || '',
|
||||
PremiereDate: null,
|
||||
ProviderIds: {},
|
||||
Tags: [],
|
||||
UserId: server?.userId, // Required
|
||||
};
|
||||
|
||||
await api
|
||||
.post(`items/${query.id}`, {
|
||||
headers: { 'X-MediaBrowser-Token': server?.credential },
|
||||
json,
|
||||
prefixUrl: server?.url,
|
||||
})
|
||||
.json<null>();
|
||||
|
||||
return {
|
||||
id: query.id,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -449,29 +554,110 @@ const deletePlaylist = async (args: DeletePlaylistArgs): Promise<null> => {
|
||||
const createFavorite = async (args: FavoriteArgs): Promise<FavoriteResponse> => {
|
||||
const { query, server } = args;
|
||||
|
||||
await api.post(`users/${server?.userId}/favoriteitems/${query.id}`, {
|
||||
headers: { 'X-MediaBrowser-Token': server?.credential },
|
||||
prefixUrl: server?.url,
|
||||
});
|
||||
for (const id of query.id) {
|
||||
await api.post(`users/${server?.userId}/favoriteitems/${id}`, {
|
||||
headers: { 'X-MediaBrowser-Token': server?.credential },
|
||||
prefixUrl: server?.url,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
id: query.id,
|
||||
type: query.type,
|
||||
};
|
||||
};
|
||||
|
||||
const deleteFavorite = async (args: FavoriteArgs): Promise<FavoriteResponse> => {
|
||||
const { query, server } = args;
|
||||
|
||||
await api.delete(`users/${server?.userId}/favoriteitems/${query.id}`, {
|
||||
headers: { 'X-MediaBrowser-Token': server?.credential },
|
||||
prefixUrl: server?.url,
|
||||
});
|
||||
for (const id of query.id) {
|
||||
await api.delete(`users/${server?.userId}/favoriteitems/${id}`, {
|
||||
headers: { 'X-MediaBrowser-Token': server?.credential },
|
||||
prefixUrl: server?.url,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
id: query.id,
|
||||
type: query.type,
|
||||
};
|
||||
};
|
||||
|
||||
const scrobble = async (args: ScrobbleArgs): Promise<RawScrobbleResponse> => {
|
||||
const { query, server } = args;
|
||||
|
||||
const position = query.position && Math.round(query.position);
|
||||
|
||||
if (query.submission) {
|
||||
// Checked by jellyfin-plugin-lastfm for whether or not to send the "finished" scrobble (uses PositionTicks)
|
||||
api.post(`sessions/playing/stopped`, {
|
||||
headers: { 'X-MediaBrowser-Token': server?.credential },
|
||||
json: {
|
||||
IsPaused: true,
|
||||
ItemId: query.id,
|
||||
PositionTicks: position,
|
||||
},
|
||||
prefixUrl: server?.url,
|
||||
});
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
if (query.event === 'start') {
|
||||
await api.post(`sessions/playing`, {
|
||||
headers: { 'X-MediaBrowser-Token': server?.credential },
|
||||
json: {
|
||||
ItemId: query.id,
|
||||
PositionTicks: position,
|
||||
},
|
||||
prefixUrl: server?.url,
|
||||
});
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
if (query.event === 'pause') {
|
||||
await api.post(`sessions/playing/progress`, {
|
||||
headers: { 'X-MediaBrowser-Token': server?.credential },
|
||||
json: {
|
||||
EventName: query.event,
|
||||
IsPaused: true,
|
||||
ItemId: query.id,
|
||||
PositionTicks: position,
|
||||
},
|
||||
prefixUrl: server?.url,
|
||||
});
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
if (query.event === 'unpause') {
|
||||
await api.post(`sessions/playing/progress`, {
|
||||
headers: { 'X-MediaBrowser-Token': server?.credential },
|
||||
json: {
|
||||
EventName: query.event,
|
||||
IsPaused: false,
|
||||
ItemId: query.id,
|
||||
PositionTicks: position,
|
||||
},
|
||||
prefixUrl: server?.url,
|
||||
});
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
await api.post(`sessions/playing/progress`, {
|
||||
headers: { 'X-MediaBrowser-Token': server?.credential },
|
||||
json: {
|
||||
ItemId: query.id,
|
||||
PositionTicks: position,
|
||||
},
|
||||
prefixUrl: server?.url,
|
||||
});
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const getStreamUrl = (args: {
|
||||
container?: string;
|
||||
deviceId: string;
|
||||
@@ -496,6 +682,26 @@ const getStreamUrl = (args: {
|
||||
);
|
||||
};
|
||||
|
||||
const getAlbumArtistCoverArtUrl = (args: {
|
||||
baseUrl: string;
|
||||
item: JFAlbumArtist;
|
||||
size: number;
|
||||
}) => {
|
||||
const size = args.size ? args.size : 300;
|
||||
|
||||
if (!args.item.ImageTags?.Primary) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
`${args.baseUrl}/Items` +
|
||||
`/${args.item.Id}` +
|
||||
'/Images/Primary' +
|
||||
`?width=${size}&height=${size}` +
|
||||
'&quality=96'
|
||||
);
|
||||
};
|
||||
|
||||
const getAlbumCoverArtUrl = (args: { baseUrl: string; item: JFAlbum; size: number }) => {
|
||||
const size = args.size ? args.size : 300;
|
||||
|
||||
@@ -513,7 +719,7 @@ const getAlbumCoverArtUrl = (args: { baseUrl: string; item: JFAlbum; size: numbe
|
||||
};
|
||||
|
||||
const getSongCoverArtUrl = (args: { baseUrl: string; item: JFSong; size: number }) => {
|
||||
const size = args.size ? args.size : 300;
|
||||
const size = args.size ? args.size : 100;
|
||||
|
||||
if (!args.item.ImageTags?.Primary) {
|
||||
return null;
|
||||
@@ -543,6 +749,22 @@ const getSongCoverArtUrl = (args: { baseUrl: string; item: JFSong; size: number
|
||||
);
|
||||
};
|
||||
|
||||
const getPlaylistCoverArtUrl = (args: { baseUrl: string; item: JFPlaylist; size: number }) => {
|
||||
const size = args.size ? args.size : 300;
|
||||
|
||||
if (!args.item.ImageTags?.Primary) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
`${args.baseUrl}/Items` +
|
||||
`/${args.item.Id}` +
|
||||
'/Images/Primary' +
|
||||
`?width=${size}&height=${size}` +
|
||||
'&quality=96'
|
||||
);
|
||||
};
|
||||
|
||||
const normalizeSong = (
|
||||
item: JFSong,
|
||||
server: ServerListItem,
|
||||
@@ -551,13 +773,18 @@ const normalizeSong = (
|
||||
): Song => {
|
||||
return {
|
||||
album: item.Album,
|
||||
albumArtists: item.AlbumArtists?.map((entry) => ({ id: entry.Id, name: entry.Name })),
|
||||
albumArtists: item.AlbumArtists?.map((entry) => ({
|
||||
id: entry.Id,
|
||||
imageUrl: null,
|
||||
name: entry.Name,
|
||||
})),
|
||||
albumId: item.AlbumId,
|
||||
artistName: item.ArtistItems[0]?.Name,
|
||||
artists: item.ArtistItems.map((entry) => ({ id: entry.Id, name: entry.Name })),
|
||||
artists: item.ArtistItems.map((entry) => ({ id: entry.Id, imageUrl: null, name: entry.Name })),
|
||||
bitRate: item.MediaSources && Number(Math.trunc(item.MediaSources[0]?.Bitrate / 1000)),
|
||||
bpm: null,
|
||||
channels: null,
|
||||
comment: null,
|
||||
compilation: null,
|
||||
container: (item.MediaSources && item.MediaSources[0]?.Container) || null,
|
||||
createdAt: item.DateCreated,
|
||||
@@ -565,17 +792,19 @@ const normalizeSong = (
|
||||
duration: item.RunTimeTicks / 10000000,
|
||||
genres: item.GenreItems.map((entry: any) => ({ id: entry.Id, name: entry.Name })),
|
||||
id: item.Id,
|
||||
imageUrl: getSongCoverArtUrl({ baseUrl: server.url, item, size: imageSize || 300 }),
|
||||
isFavorite: (item.UserData && item.UserData.IsFavorite) || false,
|
||||
imagePlaceholderUrl: null,
|
||||
imageUrl: getSongCoverArtUrl({ baseUrl: server.url, item, size: imageSize || 100 }),
|
||||
itemType: LibraryItem.SONG,
|
||||
lastPlayedAt: null,
|
||||
name: item.Name,
|
||||
note: null,
|
||||
path: (item.MediaSources && item.MediaSources[0]?.Path) || null,
|
||||
playCount: (item.UserData && item.UserData.PlayCount) || 0,
|
||||
playlistItemId: item.PlaylistItemId,
|
||||
// releaseDate: (item.ProductionYear && new Date(item.ProductionYear, 0, 1).toISOString()) || null,
|
||||
releaseDate: null,
|
||||
releaseYear: (item.ProductionYear && String(item.ProductionYear)) || null,
|
||||
releaseYear: item.ProductionYear ? String(item.ProductionYear) : null,
|
||||
serverId: server.id,
|
||||
serverType: ServerType.JELLYFIN,
|
||||
size: item.MediaSources && item.MediaSources[0]?.Size,
|
||||
streamUrl: getStreamUrl({
|
||||
container: item.MediaSources[0]?.Container,
|
||||
@@ -586,9 +815,10 @@ const normalizeSong = (
|
||||
server,
|
||||
}),
|
||||
trackNumber: item.IndexNumber,
|
||||
type: ServerType.JELLYFIN,
|
||||
uniqueId: nanoid(),
|
||||
updatedAt: item.DateCreated,
|
||||
userFavorite: (item.UserData && item.UserData.IsFavorite) || false,
|
||||
userRating: null,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -597,12 +827,13 @@ const normalizeAlbum = (item: JFAlbum, server: ServerListItem, imageSize?: numbe
|
||||
albumArtists:
|
||||
item.AlbumArtists.map((entry) => ({
|
||||
id: entry.Id,
|
||||
imageUrl: null,
|
||||
name: entry.Name,
|
||||
})) || [],
|
||||
artists: item.ArtistItems?.map((entry) => ({ id: entry.Id, name: entry.Name })),
|
||||
artists: item.ArtistItems?.map((entry) => ({ id: entry.Id, imageUrl: null, name: entry.Name })),
|
||||
backdropImageUrl: null,
|
||||
createdAt: item.DateCreated,
|
||||
duration: item.RunTimeTicks / 10000000,
|
||||
duration: item.RunTimeTicks / 10000,
|
||||
genres: item.GenreItems?.map((entry) => ({ id: entry.Id, name: entry.Name })),
|
||||
id: item.Id,
|
||||
imagePlaceholderUrl: null,
|
||||
@@ -612,19 +843,95 @@ const normalizeAlbum = (item: JFAlbum, server: ServerListItem, imageSize?: numbe
|
||||
size: imageSize || 300,
|
||||
}),
|
||||
isCompilation: null,
|
||||
isFavorite: item.UserData?.IsFavorite || false,
|
||||
itemType: LibraryItem.ALBUM,
|
||||
lastPlayedAt: null,
|
||||
name: item.Name,
|
||||
playCount: item.UserData?.PlayCount || 0,
|
||||
rating: null,
|
||||
releaseDate: item.PremiereDate?.split('T')[0] || null,
|
||||
releaseYear: item.ProductionYear,
|
||||
releaseYear: item.ProductionYear || null,
|
||||
serverId: server.id,
|
||||
serverType: ServerType.JELLYFIN,
|
||||
size: null,
|
||||
songCount: item?.ChildCount || null,
|
||||
songs: item.songs?.map((song) => normalizeSong(song, server, '', imageSize)),
|
||||
uniqueId: nanoid(),
|
||||
updatedAt: item?.DateLastMediaAdded || item.DateCreated,
|
||||
userFavorite: item.UserData?.IsFavorite || false,
|
||||
userRating: null,
|
||||
};
|
||||
};
|
||||
|
||||
const normalizeAlbumArtist = (
|
||||
item: JFAlbumArtist,
|
||||
server: ServerListItem,
|
||||
imageSize?: number,
|
||||
): AlbumArtist => {
|
||||
return {
|
||||
albumCount: null,
|
||||
backgroundImageUrl: null,
|
||||
biography: item.Overview || null,
|
||||
duration: item.RunTimeTicks / 10000,
|
||||
genres: item.GenreItems?.map((entry) => ({ id: entry.Id, name: entry.Name })),
|
||||
id: item.Id,
|
||||
imageUrl: getAlbumArtistCoverArtUrl({
|
||||
baseUrl: server.url,
|
||||
item,
|
||||
size: imageSize || 300,
|
||||
}),
|
||||
itemType: LibraryItem.ALBUM_ARTIST,
|
||||
lastPlayedAt: null,
|
||||
name: item.Name,
|
||||
playCount: item.UserData.PlayCount,
|
||||
serverId: server.id,
|
||||
serverType: ServerType.JELLYFIN,
|
||||
similarArtists: item.similarArtists?.items
|
||||
?.filter((entry) => entry.Name !== 'Various Artists')
|
||||
.map((entry) => ({
|
||||
id: entry.Id,
|
||||
imageUrl: getAlbumArtistCoverArtUrl({
|
||||
baseUrl: server.url,
|
||||
item: entry,
|
||||
size: imageSize || 300,
|
||||
}),
|
||||
name: entry.Name,
|
||||
})),
|
||||
songCount: null,
|
||||
userFavorite: item.UserData.IsFavorite || false,
|
||||
userRating: null,
|
||||
};
|
||||
};
|
||||
|
||||
const normalizePlaylist = (
|
||||
item: JFPlaylist,
|
||||
server: ServerListItem,
|
||||
imageSize?: number,
|
||||
): Playlist => {
|
||||
const imageUrl = getPlaylistCoverArtUrl({
|
||||
baseUrl: server.url,
|
||||
item,
|
||||
size: imageSize || 300,
|
||||
});
|
||||
|
||||
const imagePlaceholderUrl = null;
|
||||
|
||||
return {
|
||||
description: item.Overview || null,
|
||||
duration: item.RunTimeTicks / 10000,
|
||||
genres: item.GenreItems?.map((entry) => ({ id: entry.Id, name: entry.Name })),
|
||||
id: item.Id,
|
||||
imagePlaceholderUrl,
|
||||
imageUrl: imageUrl || null,
|
||||
itemType: LibraryItem.PLAYLIST,
|
||||
name: item.Name,
|
||||
owner: null,
|
||||
ownerId: null,
|
||||
public: null,
|
||||
rules: null,
|
||||
serverId: server.id,
|
||||
serverType: ServerType.JELLYFIN,
|
||||
size: null,
|
||||
songCount: item?.ChildCount || null,
|
||||
sync: null,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -648,24 +955,6 @@ const normalizeAlbum = (item: JFAlbum, server: ServerListItem, imageSize?: numbe
|
||||
// };
|
||||
// };
|
||||
|
||||
// const normalizePlaylist = (item: any) => {
|
||||
// return {
|
||||
// changed: item.DateLastMediaAdded,
|
||||
// comment: item.Overview,
|
||||
// created: item.DateCreated,
|
||||
// duration: item.RunTimeTicks / 10000000,
|
||||
// genre: item.GenreItems && item.GenreItems.map((entry: any) => normalizeItem(entry)),
|
||||
// id: item.Id,
|
||||
// image: getCoverArtUrl(item, 350),
|
||||
// owner: undefined,
|
||||
// public: undefined,
|
||||
// song: [],
|
||||
// songCount: item.ChildCount,
|
||||
// title: item.Name,
|
||||
// uniqueId: nanoid(),
|
||||
// };
|
||||
// };
|
||||
|
||||
// const normalizeGenre = (item: any) => {
|
||||
// return {
|
||||
// albumCount: undefined,
|
||||
@@ -697,6 +986,7 @@ const normalizeAlbum = (item: JFAlbum, server: ServerListItem, imageSize?: numbe
|
||||
// };
|
||||
|
||||
export const jellyfinApi = {
|
||||
addToPlaylist,
|
||||
authenticate,
|
||||
createFavorite,
|
||||
createPlaylist,
|
||||
@@ -713,9 +1003,14 @@ export const jellyfinApi = {
|
||||
getPlaylistList,
|
||||
getPlaylistSongList,
|
||||
getSongList,
|
||||
removeFromPlaylist,
|
||||
scrobble,
|
||||
updatePlaylist,
|
||||
};
|
||||
|
||||
export const jfNormalize = {
|
||||
album: normalizeAlbum,
|
||||
albumArtist: normalizeAlbumArtist,
|
||||
playlist: normalizePlaylist,
|
||||
song: normalizeSong,
|
||||
};
|
||||
|
||||
@@ -23,7 +23,11 @@ export interface JFAlbumArtistListResponse extends JFBasePaginatedResponse {
|
||||
Items: JFAlbumArtist[];
|
||||
}
|
||||
|
||||
export type JFAlbumArtistList = JFAlbumArtistListResponse;
|
||||
export type JFAlbumArtistList = {
|
||||
items: JFAlbumArtist[];
|
||||
startIndex: number;
|
||||
totalRecordCount: number;
|
||||
};
|
||||
|
||||
export interface JFArtistListResponse extends JFBasePaginatedResponse {
|
||||
Items: JFAlbumArtist[];
|
||||
@@ -55,11 +59,42 @@ export type JFSongList = {
|
||||
totalRecordCount: number;
|
||||
};
|
||||
|
||||
export type JFAddToPlaylistResponse = {
|
||||
added: number;
|
||||
};
|
||||
|
||||
export type JFAddToPlaylistParams = {
|
||||
ids: string[];
|
||||
userId: string;
|
||||
};
|
||||
|
||||
export type JFAddToPlaylist = null;
|
||||
|
||||
export type JFRemoveFromPlaylistResponse = null;
|
||||
|
||||
export type JFRemoveFromPlaylistParams = {
|
||||
entryIds: string[];
|
||||
};
|
||||
|
||||
export type JFRemoveFromPlaylist = null;
|
||||
|
||||
export interface JFPlaylistListResponse extends JFBasePaginatedResponse {
|
||||
Items: JFPlaylist[];
|
||||
}
|
||||
|
||||
export type JFPlaylistList = JFPlaylistListResponse;
|
||||
export type JFPlaylistList = {
|
||||
items: JFPlaylist[];
|
||||
startIndex: number;
|
||||
totalRecordCount: number;
|
||||
};
|
||||
|
||||
export enum JFPlaylistListSort {
|
||||
ALBUM_ARTIST = 'AlbumArtist,SortName',
|
||||
DURATION = 'Runtime',
|
||||
NAME = 'SortName',
|
||||
RECENTLY_ADDED = 'DateCreated,SortName',
|
||||
SONG_COUNT = 'ChildCount',
|
||||
}
|
||||
|
||||
export type JFPlaylistDetailResponse = JFPlaylist;
|
||||
|
||||
@@ -79,6 +114,7 @@ export type JFPlaylist = {
|
||||
LocationType: string;
|
||||
MediaType: string;
|
||||
Name: string;
|
||||
Overview?: string;
|
||||
RunTimeTicks: number;
|
||||
ServerId: string;
|
||||
Type: string;
|
||||
@@ -149,6 +185,17 @@ export type JFAlbumArtist = {
|
||||
RunTimeTicks: number;
|
||||
ServerId: string;
|
||||
Type: string;
|
||||
UserData: {
|
||||
IsFavorite: boolean;
|
||||
Key: string;
|
||||
PlayCount: number;
|
||||
PlaybackPositionTicks: number;
|
||||
Played: boolean;
|
||||
};
|
||||
} & {
|
||||
similarArtists: {
|
||||
items: JFAlbumArtist[];
|
||||
};
|
||||
};
|
||||
|
||||
export type JFArtist = {
|
||||
@@ -224,6 +271,7 @@ export type JFSong = {
|
||||
MediaType: string;
|
||||
Name: string;
|
||||
ParentIndexNumber: number;
|
||||
PlaylistItemId?: string;
|
||||
PremiereDate?: string;
|
||||
ProductionYear: number;
|
||||
RunTimeTicks: number;
|
||||
@@ -474,6 +522,8 @@ type JFBaseParams = {
|
||||
imageTypeLimit?: number;
|
||||
parentId?: string;
|
||||
recursive?: boolean;
|
||||
searchTerm?: string;
|
||||
userId?: string;
|
||||
};
|
||||
|
||||
type JFPaginationParams = {
|
||||
|
||||
@@ -30,9 +30,26 @@ import type {
|
||||
NDPlaylistDetailResponse,
|
||||
NDSongList,
|
||||
NDSongListResponse,
|
||||
NDAlbumArtist,
|
||||
NDPlaylist,
|
||||
NDUpdatePlaylistParams,
|
||||
NDUpdatePlaylistResponse,
|
||||
NDPlaylistSongListResponse,
|
||||
NDPlaylistSongList,
|
||||
NDPlaylistSong,
|
||||
NDUserList,
|
||||
NDUserListResponse,
|
||||
NDUserListParams,
|
||||
NDUser,
|
||||
NDAddToPlaylist,
|
||||
NDAddToPlaylistBody,
|
||||
NDAddToPlaylistResponse,
|
||||
NDRemoveFromPlaylistParams,
|
||||
NDRemoveFromPlaylistResponse,
|
||||
NDRemoveFromPlaylist,
|
||||
} from '/@/renderer/api/navidrome.types';
|
||||
import { NDPlaylistListSort, NDSongListSort, NDSortOrder } from '/@/renderer/api/navidrome.types';
|
||||
import type {
|
||||
import { NDSongListSort, NDSortOrder } from '/@/renderer/api/navidrome.types';
|
||||
import {
|
||||
Album,
|
||||
Song,
|
||||
AuthenticationResponse,
|
||||
@@ -49,18 +66,27 @@ import type {
|
||||
PlaylistDetailArgs,
|
||||
CreatePlaylistResponse,
|
||||
PlaylistSongListArgs,
|
||||
} from '/@/renderer/api/types';
|
||||
import {
|
||||
AlbumArtist,
|
||||
Playlist,
|
||||
UpdatePlaylistResponse,
|
||||
UpdatePlaylistArgs,
|
||||
UserListArgs,
|
||||
userListSortMap,
|
||||
playlistListSortMap,
|
||||
albumArtistListSortMap,
|
||||
songListSortMap,
|
||||
albumListSortMap,
|
||||
sortOrderMap,
|
||||
User,
|
||||
LibraryItem,
|
||||
AddToPlaylistArgs,
|
||||
RemoveFromPlaylistArgs,
|
||||
} from '/@/renderer/api/types';
|
||||
import { toast } from '/@/renderer/components/toast';
|
||||
import { useAuthStore } from '/@/renderer/store';
|
||||
import { ServerListItem, ServerType } from '/@/renderer/types';
|
||||
import { parseSearchParams } from '/@/renderer/utils';
|
||||
import { subsonicApi } from '/@/renderer/api/subsonic.api';
|
||||
|
||||
const api = ky.create({
|
||||
hooks: {
|
||||
@@ -121,6 +147,34 @@ const authenticate = async (
|
||||
};
|
||||
};
|
||||
|
||||
const getUserList = async (args: UserListArgs): Promise<NDUserList> => {
|
||||
const { query, server, signal } = args;
|
||||
|
||||
const searchParams: NDUserListParams = {
|
||||
_end: query.startIndex + (query.limit || 0),
|
||||
_order: sortOrderMap.navidrome[query.sortOrder],
|
||||
_sort: userListSortMap.navidrome[query.sortBy],
|
||||
_start: query.startIndex,
|
||||
...query.ndParams,
|
||||
};
|
||||
|
||||
const res = await api.get('api/user', {
|
||||
headers: { 'x-nd-authorization': `Bearer ${server?.ndCredential}` },
|
||||
prefixUrl: server?.url,
|
||||
searchParams: parseSearchParams(searchParams),
|
||||
signal,
|
||||
});
|
||||
|
||||
const data = await res.json<NDUserListResponse>();
|
||||
const itemCount = res.headers.get('x-total-count');
|
||||
|
||||
return {
|
||||
items: data,
|
||||
startIndex: query?.startIndex || 0,
|
||||
totalRecordCount: Number(itemCount),
|
||||
};
|
||||
};
|
||||
|
||||
const getGenreList = async (args: GenreListArgs): Promise<NDGenreList> => {
|
||||
const { server, signal } = args;
|
||||
|
||||
@@ -138,6 +192,15 @@ const getGenreList = async (args: GenreListArgs): Promise<NDGenreList> => {
|
||||
const getAlbumArtistDetail = async (args: AlbumArtistDetailArgs): Promise<NDAlbumArtistDetail> => {
|
||||
const { query, server, signal } = args;
|
||||
|
||||
const artistInfo = await subsonicApi.getArtistInfo({
|
||||
query: {
|
||||
artistId: query.id,
|
||||
limit: 15,
|
||||
},
|
||||
server,
|
||||
signal,
|
||||
});
|
||||
|
||||
const data = await api
|
||||
.get(`api/artist/${query.id}`, {
|
||||
headers: { 'x-nd-authorization': `Bearer ${server?.ndCredential}` },
|
||||
@@ -146,7 +209,7 @@ const getAlbumArtistDetail = async (args: AlbumArtistDetailArgs): Promise<NDAlbu
|
||||
})
|
||||
.json<NDAlbumArtistDetailResponse>();
|
||||
|
||||
return { ...data };
|
||||
return { ...data, similarArtists: artistInfo.similarArtist };
|
||||
};
|
||||
|
||||
const getAlbumArtistList = async (args: AlbumArtistListArgs): Promise<NDAlbumArtistList> => {
|
||||
@@ -157,18 +220,25 @@ const getAlbumArtistList = async (args: AlbumArtistListArgs): Promise<NDAlbumArt
|
||||
_order: sortOrderMap.navidrome[query.sortOrder],
|
||||
_sort: albumArtistListSortMap.navidrome[query.sortBy],
|
||||
_start: query.startIndex,
|
||||
name: query.searchTerm,
|
||||
...query.ndParams,
|
||||
};
|
||||
|
||||
const data = await api
|
||||
.get('api/artist', {
|
||||
headers: { 'x-nd-authorization': `Bearer ${server?.ndCredential}` },
|
||||
searchParams,
|
||||
signal,
|
||||
})
|
||||
.json<NDArtistListResponse>();
|
||||
const res = await api.get('api/artist', {
|
||||
headers: { 'x-nd-authorization': `Bearer ${server?.ndCredential}` },
|
||||
prefixUrl: server?.url,
|
||||
searchParams: parseSearchParams(searchParams),
|
||||
signal,
|
||||
});
|
||||
|
||||
return data;
|
||||
const data = await res.json<NDArtistListResponse>();
|
||||
const itemCount = res.headers.get('x-total-count');
|
||||
|
||||
return {
|
||||
items: data,
|
||||
startIndex: query.startIndex,
|
||||
totalRecordCount: Number(itemCount),
|
||||
};
|
||||
};
|
||||
|
||||
const getAlbumDetail = async (args: AlbumDetailArgs): Promise<NDAlbumDetail> => {
|
||||
@@ -233,11 +303,12 @@ const getSongList = async (args: SongListArgs): Promise<NDSongList> => {
|
||||
const { query, server, signal } = args;
|
||||
|
||||
const searchParams: NDSongListParams = {
|
||||
_end: query.startIndex + (query.limit || 0),
|
||||
_end: query.startIndex + (query.limit || -1),
|
||||
_order: sortOrderMap.navidrome[query.sortOrder],
|
||||
_sort: songListSortMap.navidrome[query.sortBy],
|
||||
_start: query.startIndex,
|
||||
album_id: query.albumIds,
|
||||
artist_id: query.artistIds,
|
||||
title: query.searchTerm,
|
||||
...query.ndParams,
|
||||
};
|
||||
@@ -274,12 +345,14 @@ const getSongDetail = async (args: SongDetailArgs): Promise<NDSongDetail> => {
|
||||
};
|
||||
|
||||
const createPlaylist = async (args: CreatePlaylistArgs): Promise<CreatePlaylistResponse> => {
|
||||
const { query, server, signal } = args;
|
||||
const { body, server } = args;
|
||||
|
||||
const json: NDCreatePlaylistParams = {
|
||||
comment: query.comment,
|
||||
name: query.name,
|
||||
public: query.public || false,
|
||||
comment: body.comment,
|
||||
name: body.name,
|
||||
...body.ndParams,
|
||||
public: body.ndParams?.public || false,
|
||||
rules: body.ndParams?.rules ? body.ndParams.rules : undefined,
|
||||
};
|
||||
|
||||
const data = await api
|
||||
@@ -287,13 +360,39 @@ const createPlaylist = async (args: CreatePlaylistArgs): Promise<CreatePlaylistR
|
||||
headers: { 'x-nd-authorization': `Bearer ${server?.ndCredential}` },
|
||||
json,
|
||||
prefixUrl: server?.url,
|
||||
signal,
|
||||
})
|
||||
.json<NDCreatePlaylistResponse>();
|
||||
|
||||
return {
|
||||
id: data.id,
|
||||
name: query.name,
|
||||
name: body.name,
|
||||
};
|
||||
};
|
||||
|
||||
const updatePlaylist = async (args: UpdatePlaylistArgs): Promise<UpdatePlaylistResponse> => {
|
||||
const { query, body, server, signal } = args;
|
||||
|
||||
const json: NDUpdatePlaylistParams = {
|
||||
comment: body.comment || '',
|
||||
name: body.name,
|
||||
ownerId: body.ndParams?.ownerId || undefined,
|
||||
ownerName: body.ndParams?.owner || undefined,
|
||||
public: body.ndParams?.public || false,
|
||||
rules: body.ndParams?.rules ? body.ndParams?.rules : undefined,
|
||||
sync: body.ndParams?.sync || undefined,
|
||||
};
|
||||
|
||||
const data = await api
|
||||
.put(`api/playlist/${query.id}`, {
|
||||
headers: { 'x-nd-authorization': `Bearer ${server?.ndCredential}` },
|
||||
json,
|
||||
prefixUrl: server?.url,
|
||||
signal,
|
||||
})
|
||||
.json<NDUpdatePlaylistResponse>();
|
||||
|
||||
return {
|
||||
id: data.id,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -316,15 +415,16 @@ const getPlaylistList = async (args: PlaylistListArgs): Promise<NDPlaylistList>
|
||||
|
||||
const searchParams: NDPlaylistListParams = {
|
||||
_end: query.startIndex + (query.limit || 0),
|
||||
_order: query.sortOrder ? sortOrderMap.navidrome[query.sortOrder] : NDSortOrder.ASC,
|
||||
_sort: query.sortBy ? playlistListSortMap.navidrome[query.sortBy] : NDPlaylistListSort.NAME,
|
||||
_order: query.sortOrder ? sortOrderMap.navidrome[query.sortOrder] : undefined,
|
||||
_sort: query.sortBy ? playlistListSortMap.navidrome[query.sortBy] : undefined,
|
||||
_start: query.startIndex,
|
||||
...query.ndParams,
|
||||
};
|
||||
|
||||
const res = await api.get('api/playlist', {
|
||||
headers: { 'x-nd-authorization': `Bearer ${server?.ndCredential}` },
|
||||
prefixUrl: server?.url,
|
||||
searchParams,
|
||||
searchParams: parseSearchParams(searchParams),
|
||||
signal,
|
||||
});
|
||||
|
||||
@@ -352,7 +452,7 @@ const getPlaylistDetail = async (args: PlaylistDetailArgs): Promise<NDPlaylistDe
|
||||
return data;
|
||||
};
|
||||
|
||||
const getPlaylistSongList = async (args: PlaylistSongListArgs): Promise<NDSongList> => {
|
||||
const getPlaylistSongList = async (args: PlaylistSongListArgs): Promise<NDPlaylistSongList> => {
|
||||
const { query, server, signal } = args;
|
||||
|
||||
const searchParams: NDSongListParams & { playlist_id: string } = {
|
||||
@@ -363,20 +463,59 @@ const getPlaylistSongList = async (args: PlaylistSongListArgs): Promise<NDSongLi
|
||||
playlist_id: query.id,
|
||||
};
|
||||
|
||||
const data = await api
|
||||
.get(`api/playlist/${query.id}/tracks`, {
|
||||
const res = await api.get(`api/playlist/${query.id}/tracks`, {
|
||||
headers: { 'x-nd-authorization': `Bearer ${server?.ndCredential}` },
|
||||
prefixUrl: server?.url,
|
||||
searchParams: parseSearchParams(searchParams),
|
||||
signal,
|
||||
});
|
||||
|
||||
const data = await res.json<NDPlaylistSongListResponse>();
|
||||
const itemCount = res.headers.get('x-total-count');
|
||||
|
||||
return {
|
||||
items: data,
|
||||
startIndex: query?.startIndex || 0,
|
||||
totalRecordCount: Number(itemCount),
|
||||
};
|
||||
};
|
||||
|
||||
const addToPlaylist = async (args: AddToPlaylistArgs): Promise<NDAddToPlaylist> => {
|
||||
const { query, body, server, signal } = args;
|
||||
|
||||
const json: NDAddToPlaylistBody = {
|
||||
ids: body.songId,
|
||||
};
|
||||
|
||||
await api
|
||||
.post(`api/playlist/${query.id}/tracks`, {
|
||||
headers: { 'x-nd-authorization': `Bearer ${server?.ndCredential}` },
|
||||
json,
|
||||
prefixUrl: server?.url,
|
||||
signal,
|
||||
})
|
||||
.json<NDAddToPlaylistResponse>();
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const removeFromPlaylist = async (args: RemoveFromPlaylistArgs): Promise<NDRemoveFromPlaylist> => {
|
||||
const { query, server, signal } = args;
|
||||
|
||||
const searchParams: NDRemoveFromPlaylistParams = {
|
||||
id: query.songId,
|
||||
};
|
||||
|
||||
await api
|
||||
.delete(`api/playlist/${query.id}/tracks`, {
|
||||
headers: { 'x-nd-authorization': `Bearer ${server?.ndCredential}` },
|
||||
prefixUrl: server?.url,
|
||||
searchParams: parseSearchParams(searchParams),
|
||||
signal,
|
||||
})
|
||||
.json<NDSongListResponse>();
|
||||
.json<NDRemoveFromPlaylistResponse>();
|
||||
|
||||
return {
|
||||
items: data,
|
||||
startIndex: query?.startIndex || 0,
|
||||
totalRecordCount: data.length,
|
||||
};
|
||||
return null;
|
||||
};
|
||||
|
||||
const getCoverArtUrl = (args: {
|
||||
@@ -402,92 +541,190 @@ const getCoverArtUrl = (args: {
|
||||
};
|
||||
|
||||
const normalizeSong = (
|
||||
item: NDSong,
|
||||
item: NDSong | NDPlaylistSong,
|
||||
server: ServerListItem,
|
||||
deviceId: string,
|
||||
imageSize?: number,
|
||||
): Song => {
|
||||
let id;
|
||||
let playlistItemId;
|
||||
|
||||
// Dynamically determine the id field based on whether or not the item is a playlist song
|
||||
if ('mediaFileId' in item) {
|
||||
id = item.mediaFileId;
|
||||
playlistItemId = item.id;
|
||||
} else {
|
||||
id = item.id;
|
||||
}
|
||||
|
||||
const imageUrl = getCoverArtUrl({
|
||||
baseUrl: server.url,
|
||||
coverArtId: item.albumId,
|
||||
coverArtId: id,
|
||||
credential: server.credential,
|
||||
size: imageSize || 300,
|
||||
size: imageSize || 100,
|
||||
});
|
||||
|
||||
const imagePlaceholderUrl = null;
|
||||
|
||||
return {
|
||||
album: item.album,
|
||||
albumArtists: [{ id: item.artistId, name: item.artist }],
|
||||
albumArtists: [{ id: item.artistId, imageUrl: null, name: item.artist }],
|
||||
albumId: item.albumId,
|
||||
artistName: item.artist,
|
||||
artists: [{ id: item.artistId, name: item.artist }],
|
||||
artists: [{ id: item.artistId, imageUrl: null, name: item.artist }],
|
||||
bitRate: item.bitRate,
|
||||
bpm: item.bpm ? item.bpm : null,
|
||||
channels: item.channels ? item.channels : null,
|
||||
comment: item.comment ? item.comment : null,
|
||||
compilation: item.compilation,
|
||||
container: item.suffix,
|
||||
createdAt: item.createdAt.split('T')[0],
|
||||
discNumber: item.discNumber,
|
||||
duration: item.duration,
|
||||
genres: item.genres,
|
||||
id: item.id,
|
||||
id,
|
||||
imagePlaceholderUrl,
|
||||
imageUrl,
|
||||
isFavorite: item.starred,
|
||||
lastPlayedAt: item.playDate ? item.playDate : null,
|
||||
itemType: LibraryItem.SONG,
|
||||
lastPlayedAt: item.playDate.includes('0001-') ? null : item.playDate,
|
||||
name: item.title,
|
||||
note: item.comment ? item.comment : null,
|
||||
path: item.path,
|
||||
playCount: item.playCount,
|
||||
playlistItemId,
|
||||
releaseDate: new Date(item.year, 0, 1).toISOString(),
|
||||
releaseYear: String(item.year),
|
||||
serverId: server.id,
|
||||
serverType: ServerType.NAVIDROME,
|
||||
size: item.size,
|
||||
streamUrl: `${server.url}/rest/stream.view?id=${item.id}&v=1.13.0&c=feishin_${deviceId}&${server.credential}`,
|
||||
streamUrl: `${server.url}/rest/stream.view?id=${id}&v=1.13.0&c=feishin_${deviceId}&${server.credential}`,
|
||||
trackNumber: item.trackNumber,
|
||||
type: ServerType.NAVIDROME,
|
||||
uniqueId: nanoid(),
|
||||
updatedAt: item.updatedAt,
|
||||
userFavorite: item.starred || false,
|
||||
userRating: item.rating || null,
|
||||
};
|
||||
};
|
||||
|
||||
const normalizeAlbum = (item: NDAlbum, server: ServerListItem, imageSize?: number): Album => {
|
||||
const imageUrl = getCoverArtUrl({
|
||||
baseUrl: server.url,
|
||||
coverArtId: item.coverArtId,
|
||||
coverArtId: item.coverArtId || item.id,
|
||||
credential: server.credential,
|
||||
size: imageSize || 300,
|
||||
});
|
||||
|
||||
const imagePlaceholderUrl = imageUrl?.replace(/size=\d+/, 'size=50') || null;
|
||||
const imagePlaceholderUrl = null;
|
||||
|
||||
const imageBackdropUrl = imageUrl?.replace(/size=\d+/, 'size=1000') || null;
|
||||
|
||||
return {
|
||||
albumArtists: [{ id: item.albumArtistId, name: item.albumArtist }],
|
||||
artists: [{ id: item.artistId, name: item.artist }],
|
||||
albumArtists: [{ id: item.albumArtistId, imageUrl: null, name: item.albumArtist }],
|
||||
artists: [{ id: item.artistId, imageUrl: null, name: item.artist }],
|
||||
backdropImageUrl: imageBackdropUrl,
|
||||
createdAt: item.createdAt.split('T')[0],
|
||||
duration: item.duration || null,
|
||||
duration: item.duration * 1000 || null,
|
||||
genres: item.genres,
|
||||
id: item.id,
|
||||
imagePlaceholderUrl,
|
||||
imageUrl,
|
||||
isCompilation: item.compilation,
|
||||
isFavorite: item.starred,
|
||||
lastPlayedAt: item.playDate ? item.playDate.split('T')[0] : null,
|
||||
itemType: LibraryItem.ALBUM,
|
||||
lastPlayedAt: item.playDate.includes('0001-') ? null : item.playDate,
|
||||
name: item.name,
|
||||
playCount: item.playCount,
|
||||
rating: item.rating,
|
||||
releaseDate: new Date(item.minYear, 0, 1).toISOString(),
|
||||
releaseYear: item.minYear,
|
||||
serverId: server.id,
|
||||
serverType: ServerType.NAVIDROME,
|
||||
size: item.size,
|
||||
songCount: item.songCount,
|
||||
songs: item.songs ? item.songs.map((song) => normalizeSong(song, server, '')) : undefined,
|
||||
uniqueId: nanoid(),
|
||||
updatedAt: item.updatedAt,
|
||||
userFavorite: item.starred,
|
||||
userRating: item.rating,
|
||||
};
|
||||
};
|
||||
|
||||
const normalizeAlbumArtist = (item: NDAlbumArtist, server: ServerListItem): AlbumArtist => {
|
||||
const imageUrl =
|
||||
item.largeImageUrl === '/app/artist-placeholder.webp' ? null : item.largeImageUrl;
|
||||
|
||||
return {
|
||||
albumCount: item.albumCount,
|
||||
backgroundImageUrl: null,
|
||||
biography: item.biography || null,
|
||||
duration: null,
|
||||
genres: item.genres,
|
||||
id: item.id,
|
||||
imageUrl: imageUrl || null,
|
||||
itemType: LibraryItem.ALBUM_ARTIST,
|
||||
lastPlayedAt: item.playDate.includes('0001-') ? null : item.playDate,
|
||||
name: item.name,
|
||||
playCount: item.playCount,
|
||||
serverId: server.id,
|
||||
serverType: ServerType.NAVIDROME,
|
||||
similarArtists:
|
||||
item.similarArtists?.map((artist) => ({
|
||||
id: artist.id,
|
||||
imageUrl: artist?.artistImageUrl || null,
|
||||
name: artist.name,
|
||||
})) || null,
|
||||
songCount: item.songCount,
|
||||
userFavorite: item.starred,
|
||||
userRating: item.rating,
|
||||
};
|
||||
};
|
||||
|
||||
const normalizePlaylist = (
|
||||
item: NDPlaylist,
|
||||
server: ServerListItem,
|
||||
imageSize?: number,
|
||||
): Playlist => {
|
||||
const imageUrl = getCoverArtUrl({
|
||||
baseUrl: server.url,
|
||||
coverArtId: item.id,
|
||||
credential: server.credential,
|
||||
size: imageSize || 300,
|
||||
});
|
||||
|
||||
const imagePlaceholderUrl = null;
|
||||
|
||||
return {
|
||||
description: item.comment,
|
||||
duration: item.duration * 1000,
|
||||
genres: [],
|
||||
id: item.id,
|
||||
imagePlaceholderUrl,
|
||||
imageUrl,
|
||||
itemType: LibraryItem.PLAYLIST,
|
||||
name: item.name,
|
||||
owner: item.ownerName,
|
||||
ownerId: item.ownerId,
|
||||
public: item.public,
|
||||
rules: item?.rules || null,
|
||||
serverId: server.id,
|
||||
serverType: ServerType.NAVIDROME,
|
||||
size: item.size,
|
||||
songCount: item.songCount,
|
||||
sync: item.sync,
|
||||
};
|
||||
};
|
||||
|
||||
const normalizeUser = (item: NDUser): User => {
|
||||
return {
|
||||
createdAt: item.createdAt,
|
||||
email: item.email,
|
||||
id: item.id,
|
||||
isAdmin: item.isAdmin,
|
||||
lastLoginAt: item.lastLoginAt,
|
||||
name: item.userName,
|
||||
updatedAt: item.updatedAt,
|
||||
};
|
||||
};
|
||||
|
||||
export const navidromeApi = {
|
||||
addToPlaylist,
|
||||
authenticate,
|
||||
createPlaylist,
|
||||
deletePlaylist,
|
||||
@@ -501,9 +738,15 @@ export const navidromeApi = {
|
||||
getPlaylistSongList,
|
||||
getSongDetail,
|
||||
getSongList,
|
||||
getUserList,
|
||||
removeFromPlaylist,
|
||||
updatePlaylist,
|
||||
};
|
||||
|
||||
export const ndNormalize = {
|
||||
album: normalizeAlbum,
|
||||
albumArtist: normalizeAlbumArtist,
|
||||
playlist: normalizePlaylist,
|
||||
song: normalizeSong,
|
||||
user: normalizeUser,
|
||||
};
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { SSArtistInfo } from '/@/renderer/api/subsonic.types';
|
||||
|
||||
export type NDAuthenticate = {
|
||||
id: string;
|
||||
isAdmin: boolean;
|
||||
@@ -8,6 +10,18 @@ export type NDAuthenticate = {
|
||||
username: string;
|
||||
};
|
||||
|
||||
export type NDUser = {
|
||||
createdAt: string;
|
||||
email: string;
|
||||
id: string;
|
||||
isAdmin: boolean;
|
||||
lastAccessAt: string;
|
||||
lastLoginAt: string;
|
||||
name: string;
|
||||
updatedAt: string;
|
||||
userName: string;
|
||||
};
|
||||
|
||||
export type NDGenre = {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -20,8 +34,8 @@ export type NDAlbum = {
|
||||
artist: string;
|
||||
artistId: string;
|
||||
compilation: boolean;
|
||||
coverArtId: string;
|
||||
coverArtPath: string;
|
||||
coverArtId?: string; // Removed after v0.48.0
|
||||
coverArtPath?: string; // Removed after v0.48.0
|
||||
createdAt: string;
|
||||
duration: number;
|
||||
fullText: string;
|
||||
@@ -101,24 +115,30 @@ export type NDAlbumArtist = {
|
||||
fullText: string;
|
||||
genres: NDGenre[];
|
||||
id: string;
|
||||
largeImageUrl: string;
|
||||
largeImageUrl?: string;
|
||||
mbzArtistId: string;
|
||||
mediumImageUrl: string;
|
||||
mediumImageUrl?: string;
|
||||
name: string;
|
||||
orderArtistName: string;
|
||||
playCount: number;
|
||||
playDate: string;
|
||||
rating: number;
|
||||
size: number;
|
||||
smallImageUrl: string;
|
||||
smallImageUrl?: string;
|
||||
songCount: number;
|
||||
starred: boolean;
|
||||
starredAt: string;
|
||||
} & {
|
||||
similarArtists?: SSArtistInfo['similarArtist'];
|
||||
};
|
||||
|
||||
export type NDAuthenticationResponse = NDAuthenticate;
|
||||
|
||||
export type NDAlbumArtistList = NDAlbumArtist[];
|
||||
export type NDAlbumArtistList = {
|
||||
items: NDAlbumArtist[];
|
||||
startIndex: number;
|
||||
totalRecordCount: number;
|
||||
};
|
||||
|
||||
export type NDAlbumArtistDetail = NDAlbumArtist;
|
||||
|
||||
@@ -210,7 +230,8 @@ export type NDAlbumListParams = {
|
||||
|
||||
export enum NDSongListSort {
|
||||
ALBUM = 'album, order_album_artist_name, disc_number, track_number, title',
|
||||
ALBUM_ARTIST = 'albumArtist',
|
||||
ALBUM_ARTIST = 'order_album_artist_name, album, disc_number, track_number, title',
|
||||
ALBUM_SONGS = 'album, discNumber, trackNumber',
|
||||
ARTIST = 'artist',
|
||||
BPM = 'bpm',
|
||||
CHANNELS = 'channels',
|
||||
@@ -222,14 +243,16 @@ export enum NDSongListSort {
|
||||
PLAY_COUNT = 'playCount',
|
||||
PLAY_DATE = 'playDate',
|
||||
RATING = 'rating',
|
||||
RECENTLY_ADDED = 'createdAt',
|
||||
TITLE = 'title',
|
||||
TRACK = 'track',
|
||||
YEAR = 'year',
|
||||
YEAR = 'year, album, discNumber, trackNumber',
|
||||
}
|
||||
|
||||
export type NDSongListParams = {
|
||||
_sort?: NDSongListSort;
|
||||
album_id?: string[];
|
||||
artist_id?: string[];
|
||||
genre_id?: string;
|
||||
starred?: boolean;
|
||||
} & NDPagination &
|
||||
@@ -251,10 +274,31 @@ export type NDAlbumArtistListParams = {
|
||||
} & NDPagination &
|
||||
NDOrder;
|
||||
|
||||
export type NDAddToPlaylistResponse = {
|
||||
added: number;
|
||||
};
|
||||
|
||||
export type NDAddToPlaylistBody = {
|
||||
ids: string[];
|
||||
};
|
||||
|
||||
export type NDAddToPlaylist = null;
|
||||
|
||||
export type NDRemoveFromPlaylistResponse = {
|
||||
ids: string[];
|
||||
};
|
||||
|
||||
export type NDRemoveFromPlaylistParams = {
|
||||
id: string[];
|
||||
};
|
||||
|
||||
export type NDRemoveFromPlaylist = null;
|
||||
|
||||
export type NDCreatePlaylistParams = {
|
||||
comment?: string;
|
||||
name: string;
|
||||
public: boolean;
|
||||
public?: boolean;
|
||||
rules?: Record<string, any> | null;
|
||||
};
|
||||
|
||||
export type NDCreatePlaylistResponse = {
|
||||
@@ -263,6 +307,10 @@ export type NDCreatePlaylistResponse = {
|
||||
|
||||
export type NDCreatePlaylist = NDCreatePlaylistResponse;
|
||||
|
||||
export type NDUpdatePlaylistParams = Partial<NDPlaylist>;
|
||||
|
||||
export type NDUpdatePlaylistResponse = NDPlaylist;
|
||||
|
||||
export type NDDeletePlaylistParams = {
|
||||
id: string;
|
||||
};
|
||||
@@ -282,7 +330,7 @@ export type NDPlaylist = {
|
||||
ownerName: string;
|
||||
path: string;
|
||||
public: boolean;
|
||||
rules: null;
|
||||
rules: Record<string, any> | null;
|
||||
size: number;
|
||||
songCount: number;
|
||||
sync: boolean;
|
||||
@@ -304,7 +352,7 @@ export type NDPlaylistListResponse = NDPlaylist[];
|
||||
export enum NDPlaylistListSort {
|
||||
DURATION = 'duration',
|
||||
NAME = 'name',
|
||||
OWNER = 'owner',
|
||||
OWNER = 'ownerName',
|
||||
PUBLIC = 'public',
|
||||
SONG_COUNT = 'songCount',
|
||||
UPDATED_AT = 'updatedAt',
|
||||
@@ -315,3 +363,104 @@ export type NDPlaylistListParams = {
|
||||
owner_id?: string;
|
||||
} & NDPagination &
|
||||
NDOrder;
|
||||
|
||||
export type NDPlaylistSong = NDSong & {
|
||||
mediaFileId: string;
|
||||
playlistId: string;
|
||||
};
|
||||
|
||||
export type NDPlaylistSongListResponse = NDPlaylistSong[];
|
||||
|
||||
export type NDPlaylistSongList = {
|
||||
items: NDPlaylistSong[];
|
||||
startIndex: number;
|
||||
totalRecordCount: number;
|
||||
};
|
||||
|
||||
export const NDSongQueryFields = [
|
||||
{ label: 'Album', type: 'string', value: 'album' },
|
||||
{ label: 'Album Artist', type: 'string', value: 'albumartist' },
|
||||
{ label: 'Album Comment', type: 'string', value: 'albumcomment' },
|
||||
{ label: 'Album Type', type: 'string', value: 'albumtype' },
|
||||
{ label: 'Artist', type: 'string', value: 'artist' },
|
||||
{ label: 'Bitrate', type: 'number', value: 'bitrate' },
|
||||
{ label: 'BPM', type: 'number', value: 'bpm' },
|
||||
{ label: 'Catalog Number', type: 'string', value: 'catalognumber' },
|
||||
{ label: 'Channels', type: 'number', value: 'channels' },
|
||||
{ label: 'Comment', type: 'string', value: 'comment' },
|
||||
{ label: 'Date Added', type: 'date', value: 'dateadded' },
|
||||
{ label: 'Date Favorited', type: 'date', value: 'dateloved' },
|
||||
{ label: 'Date Last Played', type: 'date', value: 'lastplayed' },
|
||||
{ label: 'Date Modified', type: 'date', value: 'datemodified' },
|
||||
{ label: 'Disc Subtitle', type: 'string', value: 'discsubtitle' },
|
||||
{ label: 'Disc Number', type: 'number', value: 'discnumber' },
|
||||
{ label: 'Duration', type: 'number', value: 'duration' },
|
||||
{ label: 'File Path', type: 'string', value: 'filepath' },
|
||||
{ label: 'File Type', type: 'string', value: 'filetype' },
|
||||
{ label: 'Genre', type: 'string', value: 'genre' },
|
||||
{ label: 'Has CoverArt', type: 'boolean', value: 'hascoverart' },
|
||||
{ label: 'Is Compilation', type: 'boolean', value: 'compilation' },
|
||||
{ label: 'Is Favorite', type: 'boolean', value: 'loved' },
|
||||
{ label: 'Lyrics', type: 'string', value: 'lyrics' },
|
||||
{ label: 'Name', type: 'string', value: 'title' },
|
||||
{ label: 'Play Count', type: 'number', value: 'playcount' },
|
||||
{ label: 'Rating', type: 'number', value: 'rating' },
|
||||
{ label: 'Size', type: 'number', value: 'size' },
|
||||
{ label: 'Sort Album', type: 'string', value: 'sortalbum' },
|
||||
{ label: 'Sort Album Artist', type: 'string', value: 'sortalbumartist' },
|
||||
{ label: 'Sort Artist', type: 'string', value: 'sortartist' },
|
||||
{ label: 'Sort Name', type: 'string', value: 'sorttitle' },
|
||||
{ label: 'Track Number', type: 'number', value: 'tracknumber' },
|
||||
{ label: 'Year', type: 'number', value: 'year' },
|
||||
];
|
||||
|
||||
export const NDSongQueryDateOperators = [
|
||||
{ label: 'is', value: 'is' },
|
||||
{ label: 'is not', value: 'isNot' },
|
||||
{ label: 'is before', value: 'before' },
|
||||
{ label: 'is after', value: 'after' },
|
||||
{ label: 'is in the last', value: 'inTheLast' },
|
||||
{ label: 'is not in the last', value: 'notInTheLast' },
|
||||
{ label: 'is in the range', value: 'inTheRange' },
|
||||
];
|
||||
|
||||
export const NDSongQueryStringOperators = [
|
||||
{ label: 'is', value: 'is' },
|
||||
{ label: 'is not', value: 'isNot' },
|
||||
{ label: 'contains', value: 'contains' },
|
||||
{ label: 'does not contain', value: 'notContains' },
|
||||
{ label: 'starts with', value: 'startsWith' },
|
||||
{ label: 'ends with', value: 'endsWith' },
|
||||
];
|
||||
|
||||
export const NDSongQueryBooleanOperators = [
|
||||
{ label: 'is', value: 'is' },
|
||||
{ label: 'is not', value: 'isNot' },
|
||||
];
|
||||
|
||||
export const NDSongQueryNumberOperators = [
|
||||
{ label: 'is', value: 'is' },
|
||||
{ label: 'is not', value: 'isNot' },
|
||||
{ label: 'contains', value: 'contains' },
|
||||
{ label: 'does not contain', value: 'notContains' },
|
||||
{ label: 'is greater than', value: 'gt' },
|
||||
{ label: 'is less than', value: 'lt' },
|
||||
{ label: 'is in the range', value: 'inTheRange' },
|
||||
];
|
||||
|
||||
export type NDUserListParams = {
|
||||
_sort?: NDUserListSort;
|
||||
} & NDPagination &
|
||||
NDOrder;
|
||||
|
||||
export type NDUserListResponse = NDUser[];
|
||||
|
||||
export type NDUserList = {
|
||||
items: NDUser[];
|
||||
startIndex: number;
|
||||
totalRecordCount: number;
|
||||
};
|
||||
|
||||
export enum NDUserListSort {
|
||||
NAME = 'name',
|
||||
}
|
||||
|
||||
@@ -1,20 +1,37 @@
|
||||
import { jfNormalize } from '/@/renderer/api/jellyfin.api';
|
||||
import type {
|
||||
JFAlbum,
|
||||
JFAlbumArtist,
|
||||
JFGenreList,
|
||||
JFMusicFolderList,
|
||||
JFPlaylist,
|
||||
JFSong,
|
||||
} from '/@/renderer/api/jellyfin.types';
|
||||
import { ndNormalize } from '/@/renderer/api/navidrome.api';
|
||||
import type { NDAlbum, NDGenreList, NDSong } from '/@/renderer/api/navidrome.types';
|
||||
import { SSGenreList, SSMusicFolderList } from '/@/renderer/api/subsonic.types';
|
||||
import type {
|
||||
NDAlbum,
|
||||
NDAlbumArtist,
|
||||
NDGenreList,
|
||||
NDPlaylist,
|
||||
NDSong,
|
||||
NDUser,
|
||||
} from '/@/renderer/api/navidrome.types';
|
||||
import { ssNormalize } from '/@/renderer/api/subsonic.api';
|
||||
import { SSGenreList, SSMusicFolderList, SSSong } from '/@/renderer/api/subsonic.types';
|
||||
import type {
|
||||
Album,
|
||||
AlbumArtist,
|
||||
RawAlbumArtistDetailResponse,
|
||||
RawAlbumArtistListResponse,
|
||||
RawAlbumDetailResponse,
|
||||
RawAlbumListResponse,
|
||||
RawGenreListResponse,
|
||||
RawMusicFolderListResponse,
|
||||
RawPlaylistDetailResponse,
|
||||
RawPlaylistListResponse,
|
||||
RawSongListResponse,
|
||||
RawTopSongListResponse,
|
||||
RawUserListResponse,
|
||||
} from '/@/renderer/api/types';
|
||||
import { ServerListItem } from '/@/renderer/types';
|
||||
|
||||
@@ -77,6 +94,25 @@ const songList = (data: RawSongListResponse | undefined, server: ServerListItem
|
||||
};
|
||||
};
|
||||
|
||||
const topSongList = (data: RawTopSongListResponse | undefined, server: ServerListItem | null) => {
|
||||
let songs;
|
||||
|
||||
switch (server?.type) {
|
||||
case 'jellyfin':
|
||||
break;
|
||||
case 'navidrome':
|
||||
songs = data?.items?.map((item) => ssNormalize.song(item as SSSong, server, ''));
|
||||
break;
|
||||
case 'subsonic':
|
||||
songs = data?.items?.map((item) => ssNormalize.song(item as SSSong, server, ''));
|
||||
break;
|
||||
}
|
||||
|
||||
return {
|
||||
items: songs,
|
||||
};
|
||||
};
|
||||
|
||||
const musicFolderList = (
|
||||
data: RawMusicFolderListResponse | undefined,
|
||||
server: ServerListItem | null,
|
||||
@@ -136,10 +172,120 @@ const genreList = (data: RawGenreListResponse | undefined, server: ServerListIte
|
||||
return genres;
|
||||
};
|
||||
|
||||
const albumArtistDetail = (
|
||||
data: RawAlbumArtistDetailResponse | undefined,
|
||||
server: ServerListItem | null,
|
||||
): AlbumArtist | undefined => {
|
||||
let albumArtist: AlbumArtist | undefined;
|
||||
switch (server?.type) {
|
||||
case 'jellyfin':
|
||||
albumArtist = jfNormalize.albumArtist(data as JFAlbumArtist, server);
|
||||
break;
|
||||
case 'navidrome':
|
||||
albumArtist = ndNormalize.albumArtist(data as NDAlbumArtist, server);
|
||||
break;
|
||||
case 'subsonic':
|
||||
break;
|
||||
}
|
||||
|
||||
return albumArtist;
|
||||
};
|
||||
|
||||
const albumArtistList = (
|
||||
data: RawAlbumArtistListResponse | undefined,
|
||||
server: ServerListItem | null,
|
||||
) => {
|
||||
let albumArtists;
|
||||
switch (server?.type) {
|
||||
case 'jellyfin':
|
||||
albumArtists = data?.items.map((item) =>
|
||||
jfNormalize.albumArtist(item as JFAlbumArtist, server),
|
||||
);
|
||||
break;
|
||||
case 'navidrome':
|
||||
albumArtists = data?.items.map((item) =>
|
||||
ndNormalize.albumArtist(item as NDAlbumArtist, server),
|
||||
);
|
||||
break;
|
||||
case 'subsonic':
|
||||
break;
|
||||
}
|
||||
|
||||
return {
|
||||
items: albumArtists,
|
||||
startIndex: data?.startIndex,
|
||||
totalRecordCount: data?.totalRecordCount,
|
||||
};
|
||||
};
|
||||
|
||||
const playlistList = (data: RawPlaylistListResponse | undefined, server: ServerListItem | null) => {
|
||||
let playlists;
|
||||
switch (server?.type) {
|
||||
case 'jellyfin':
|
||||
playlists = data?.items.map((item) => jfNormalize.playlist(item as JFPlaylist, server));
|
||||
break;
|
||||
case 'navidrome':
|
||||
playlists = data?.items.map((item) => ndNormalize.playlist(item as NDPlaylist, server));
|
||||
break;
|
||||
case 'subsonic':
|
||||
break;
|
||||
}
|
||||
|
||||
return {
|
||||
items: playlists,
|
||||
startIndex: data?.startIndex,
|
||||
totalRecordCount: data?.totalRecordCount,
|
||||
};
|
||||
};
|
||||
|
||||
const playlistDetail = (
|
||||
data: RawPlaylistDetailResponse | undefined,
|
||||
server: ServerListItem | null,
|
||||
) => {
|
||||
let playlist;
|
||||
switch (server?.type) {
|
||||
case 'jellyfin':
|
||||
playlist = jfNormalize.playlist(data as JFPlaylist, server);
|
||||
break;
|
||||
case 'navidrome':
|
||||
playlist = ndNormalize.playlist(data as NDPlaylist, server);
|
||||
break;
|
||||
case 'subsonic':
|
||||
break;
|
||||
}
|
||||
|
||||
return playlist;
|
||||
};
|
||||
|
||||
const userList = (data: RawUserListResponse | undefined, server: ServerListItem | null) => {
|
||||
let users;
|
||||
switch (server?.type) {
|
||||
case 'jellyfin':
|
||||
break;
|
||||
case 'navidrome':
|
||||
users = data?.items.map((item) => ndNormalize.user(item as NDUser));
|
||||
break;
|
||||
case 'subsonic':
|
||||
break;
|
||||
}
|
||||
|
||||
return {
|
||||
items: users,
|
||||
startIndex: data?.startIndex,
|
||||
totalRecordCount: data?.totalRecordCount,
|
||||
};
|
||||
};
|
||||
|
||||
export const normalize = {
|
||||
albumArtistDetail,
|
||||
albumArtistList,
|
||||
albumDetail,
|
||||
albumList,
|
||||
genreList,
|
||||
musicFolderList,
|
||||
playlistDetail,
|
||||
playlistList,
|
||||
songList,
|
||||
topSongList,
|
||||
userList,
|
||||
};
|
||||
|
||||
@@ -1,15 +1,52 @@
|
||||
import type { AlbumListQuery, SongListQuery, AlbumDetailQuery } from './types';
|
||||
import type {
|
||||
AlbumListQuery,
|
||||
SongListQuery,
|
||||
AlbumDetailQuery,
|
||||
AlbumArtistListQuery,
|
||||
ArtistListQuery,
|
||||
PlaylistListQuery,
|
||||
PlaylistDetailQuery,
|
||||
PlaylistSongListQuery,
|
||||
UserListQuery,
|
||||
AlbumArtistDetailQuery,
|
||||
TopSongListQuery,
|
||||
} from './types';
|
||||
|
||||
export const queryKeys = {
|
||||
albumArtists: {
|
||||
detail: (serverId: string, query?: AlbumArtistDetailQuery) => {
|
||||
if (query) return [serverId, 'albumArtists', 'detail', query] as const;
|
||||
return [serverId, 'albumArtists', 'detail'] as const;
|
||||
},
|
||||
list: (serverId: string, query?: AlbumArtistListQuery) => {
|
||||
if (query) return [serverId, 'albumArtists', 'list', query] as const;
|
||||
return [serverId, 'albumArtists', 'list'] as const;
|
||||
},
|
||||
root: (serverId: string) => [serverId, 'albumArtists'] as const,
|
||||
topSongs: (serverId: string, query?: TopSongListQuery) => {
|
||||
if (query) return [serverId, 'albumArtists', 'topSongs', query] as const;
|
||||
return [serverId, 'albumArtists', 'topSongs'] as const;
|
||||
},
|
||||
},
|
||||
albums: {
|
||||
detail: (serverId: string, query: AlbumDetailQuery) =>
|
||||
detail: (serverId: string, query?: AlbumDetailQuery) =>
|
||||
[serverId, 'albums', 'detail', query] as const,
|
||||
list: (serverId: string, query: AlbumListQuery) => [serverId, 'albums', 'list', query] as const,
|
||||
root: ['albums'],
|
||||
list: (serverId: string, query?: AlbumListQuery) => {
|
||||
if (query) return [serverId, 'albums', 'list', query] as const;
|
||||
return [serverId, 'albums', 'list'] as const;
|
||||
},
|
||||
root: (serverId: string) => [serverId, 'albums'],
|
||||
serverRoot: (serverId: string) => [serverId, 'albums'],
|
||||
songs: (serverId: string, query: SongListQuery) =>
|
||||
[serverId, 'albums', 'songs', query] as const,
|
||||
},
|
||||
artists: {
|
||||
list: (serverId: string, query?: ArtistListQuery) => {
|
||||
if (query) return [serverId, 'artists', 'list', query] as const;
|
||||
return [serverId, 'artists', 'list'] as const;
|
||||
},
|
||||
root: (serverId: string) => [serverId, 'artists'] as const,
|
||||
},
|
||||
genres: {
|
||||
list: (serverId: string) => [serverId, 'genres', 'list'] as const,
|
||||
root: (serverId: string) => [serverId, 'genres'] as const,
|
||||
@@ -17,10 +54,43 @@ export const queryKeys = {
|
||||
musicFolders: {
|
||||
list: (serverId: string) => [serverId, 'musicFolders', 'list'] as const,
|
||||
},
|
||||
playlists: {
|
||||
detail: (serverId: string, id?: string, query?: PlaylistDetailQuery) => {
|
||||
if (query) return [serverId, 'playlists', id, 'detail', query] as const;
|
||||
if (id) return [serverId, 'playlists', id, 'detail'] as const;
|
||||
return [serverId, 'playlists', 'detail'] as const;
|
||||
},
|
||||
detailSongList: (serverId: string, id: string, query?: PlaylistSongListQuery) => {
|
||||
if (query) return [serverId, 'playlists', id, 'detailSongList', query] as const;
|
||||
if (id) return [serverId, 'playlists', id, 'detailSongList'] as const;
|
||||
return [serverId, 'playlists', 'detailSongList'] as const;
|
||||
},
|
||||
list: (serverId: string, query?: PlaylistListQuery) => {
|
||||
if (query) return [serverId, 'playlists', 'list', query] as const;
|
||||
return [serverId, 'playlists', 'list'] as const;
|
||||
},
|
||||
root: (serverId: string) => [serverId, 'playlists'] as const,
|
||||
songList: (serverId: string, id: string, query?: PlaylistSongListQuery) => {
|
||||
if (query) return [serverId, 'playlists', id, 'songList', query] as const;
|
||||
if (id) return [serverId, 'playlists', id, 'songList'] as const;
|
||||
return [serverId, 'playlists', 'songList'] as const;
|
||||
},
|
||||
},
|
||||
server: {
|
||||
root: (serverId: string) => [serverId] as const,
|
||||
},
|
||||
songs: {
|
||||
list: (serverId: string, query: SongListQuery) => [serverId, 'songs', 'list', query] as const,
|
||||
list: (serverId: string, query?: SongListQuery) => {
|
||||
if (query) return [serverId, 'songs', 'list', query] as const;
|
||||
return [serverId, 'songs', 'list'] as const;
|
||||
},
|
||||
root: (serverId: string) => [serverId, 'songs'] as const,
|
||||
},
|
||||
users: {
|
||||
list: (serverId: string, query?: UserListQuery) => {
|
||||
if (query) return [serverId, 'users', 'list', query] as const;
|
||||
return [serverId, 'users', 'list'] as const;
|
||||
},
|
||||
root: (serverId: string) => [serverId, 'users'] as const,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import ky from 'ky';
|
||||
import md5 from 'md5';
|
||||
import { randomString } from '/@/renderer/utils';
|
||||
import { parseSearchParams, randomString } from '/@/renderer/utils';
|
||||
import type {
|
||||
SSAlbumListResponse,
|
||||
SSAlbumDetailResponse,
|
||||
@@ -16,26 +16,41 @@ import type {
|
||||
SSAlbumArtistDetail,
|
||||
SSAlbumArtistDetailResponse,
|
||||
SSFavoriteParams,
|
||||
SSFavoriteResponse,
|
||||
SSRatingParams,
|
||||
SSRatingResponse,
|
||||
SSAlbumArtistDetailParams,
|
||||
SSAlbumArtistListParams,
|
||||
SSTopSongListParams,
|
||||
SSTopSongListResponse,
|
||||
SSArtistInfoParams,
|
||||
SSArtistInfoResponse,
|
||||
SSArtistInfo,
|
||||
SSSong,
|
||||
SSTopSongList,
|
||||
SSScrobbleParams,
|
||||
} from '/@/renderer/api/subsonic.types';
|
||||
import type {
|
||||
import {
|
||||
AlbumArtistDetailArgs,
|
||||
AlbumArtistListArgs,
|
||||
AlbumDetailArgs,
|
||||
AlbumListArgs,
|
||||
ArtistInfoArgs,
|
||||
AuthenticationResponse,
|
||||
FavoriteArgs,
|
||||
FavoriteResponse,
|
||||
GenreListArgs,
|
||||
LibraryItem,
|
||||
MusicFolderListArgs,
|
||||
QueueSong,
|
||||
RatingArgs,
|
||||
RatingResponse,
|
||||
RawScrobbleResponse,
|
||||
ScrobbleArgs,
|
||||
ServerListItem,
|
||||
ServerType,
|
||||
TopSongListArgs,
|
||||
} from '/@/renderer/api/types';
|
||||
import { useAuthStore } from '/@/renderer/store';
|
||||
import { toast } from '/@/renderer/components/toast';
|
||||
import { nanoid } from 'nanoid/non-secure';
|
||||
|
||||
const getCoverArtUrl = (args: {
|
||||
baseUrl: string;
|
||||
@@ -50,7 +65,7 @@ const getCoverArtUrl = (args: {
|
||||
}
|
||||
|
||||
return (
|
||||
`${args.baseUrl}/getCoverArt.view` +
|
||||
`${args.baseUrl}/rest/getCoverArt.view` +
|
||||
`?id=${args.coverArtId}` +
|
||||
`&${args.credential}` +
|
||||
'&v=1.13.0' +
|
||||
@@ -65,40 +80,43 @@ const api = ky.create({
|
||||
async (_request, _options, response) => {
|
||||
const data = await response.json();
|
||||
if (data['subsonic-response'].status !== 'ok') {
|
||||
toast.warn({ message: 'Issue from Subsonic API' });
|
||||
// Suppress code related to non-linked lastfm or spotify from Navidrome
|
||||
if (data['subsonic-response'].error.code !== 0) {
|
||||
toast.error({
|
||||
message: data['subsonic-response'].error.message,
|
||||
title: 'Issue from Subsonic API',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return new Response(JSON.stringify(data['subsonic-response']), { status: 200 });
|
||||
},
|
||||
],
|
||||
beforeRequest: [
|
||||
(request) => {
|
||||
const server = useAuthStore.getState().currentServer;
|
||||
|
||||
const searchParams = new URLSearchParams();
|
||||
|
||||
if (server) {
|
||||
const authParams = server.credential.split(/&?\w=/gm);
|
||||
|
||||
searchParams.set('u', server.username);
|
||||
searchParams.set('v', '1.13.0');
|
||||
searchParams.set('c', 'Feishin');
|
||||
searchParams.set('f', 'json');
|
||||
|
||||
if (authParams?.length === 4) {
|
||||
searchParams.set('s', authParams[2]);
|
||||
searchParams.set('t', authParams[3]);
|
||||
} else if (authParams?.length === 3) {
|
||||
searchParams.set('p', authParams[2]);
|
||||
}
|
||||
}
|
||||
|
||||
return ky(request, { searchParams });
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const getDefaultParams = (server: ServerListItem | null) => {
|
||||
if (!server) return {};
|
||||
|
||||
const authParams = server.credential.split(/&?\w=/gm);
|
||||
|
||||
const params: Record<string, string> = {
|
||||
c: 'Feishin',
|
||||
f: 'json',
|
||||
u: server.username,
|
||||
v: '1.13.0',
|
||||
};
|
||||
|
||||
if (authParams?.length === 4) {
|
||||
params.s = authParams[2];
|
||||
params.t = authParams[3];
|
||||
} else if (authParams?.length === 3) {
|
||||
params.p = authParams[2];
|
||||
}
|
||||
|
||||
return params;
|
||||
};
|
||||
|
||||
const authenticate = async (
|
||||
url: string,
|
||||
body: {
|
||||
@@ -129,10 +147,12 @@ const authenticate = async (
|
||||
|
||||
const getMusicFolderList = async (args: MusicFolderListArgs): Promise<SSMusicFolderList> => {
|
||||
const { signal, server } = args;
|
||||
const defaultParams = getDefaultParams(server);
|
||||
|
||||
const data = await api
|
||||
.get('rest/getMusicFolders.view', {
|
||||
prefixUrl: server?.url,
|
||||
searchParams: defaultParams,
|
||||
signal,
|
||||
})
|
||||
.json<SSMusicFolderListResponse>();
|
||||
@@ -144,9 +164,11 @@ export const getAlbumArtistDetail = async (
|
||||
args: AlbumArtistDetailArgs,
|
||||
): Promise<SSAlbumArtistDetail> => {
|
||||
const { server, signal, query } = args;
|
||||
const defaultParams = getDefaultParams(server);
|
||||
|
||||
const searchParams: SSAlbumArtistDetailParams = {
|
||||
id: query.id,
|
||||
...defaultParams,
|
||||
};
|
||||
|
||||
const data = await api
|
||||
@@ -162,9 +184,11 @@ export const getAlbumArtistDetail = async (
|
||||
|
||||
const getAlbumArtistList = async (args: AlbumArtistListArgs): Promise<SSAlbumArtistList> => {
|
||||
const { signal, server, query } = args;
|
||||
const defaultParams = getDefaultParams(server);
|
||||
|
||||
const searchParams: SSAlbumArtistListParams = {
|
||||
musicFolderId: query.musicFolderId,
|
||||
...defaultParams,
|
||||
};
|
||||
|
||||
const data = await api
|
||||
@@ -177,15 +201,21 @@ const getAlbumArtistList = async (args: AlbumArtistListArgs): Promise<SSAlbumArt
|
||||
|
||||
const artists = (data.artists?.index || []).flatMap((index: SSArtistIndex) => index.artist);
|
||||
|
||||
return artists;
|
||||
return {
|
||||
items: artists,
|
||||
startIndex: query.startIndex,
|
||||
totalRecordCount: null,
|
||||
};
|
||||
};
|
||||
|
||||
const getGenreList = async (args: GenreListArgs): Promise<SSGenreList> => {
|
||||
const { server, signal } = args;
|
||||
const defaultParams = getDefaultParams(server);
|
||||
|
||||
const data = await api
|
||||
.get('rest/getGenres.view', {
|
||||
prefixUrl: server?.url,
|
||||
searchParams: defaultParams,
|
||||
signal,
|
||||
})
|
||||
.json<SSGenreListResponse>();
|
||||
@@ -195,11 +225,17 @@ const getGenreList = async (args: GenreListArgs): Promise<SSGenreList> => {
|
||||
|
||||
const getAlbumDetail = async (args: AlbumDetailArgs): Promise<SSAlbumDetail> => {
|
||||
const { server, query, signal } = args;
|
||||
const defaultParams = getDefaultParams(server);
|
||||
|
||||
const searchParams = {
|
||||
id: query.id,
|
||||
...defaultParams,
|
||||
};
|
||||
|
||||
const data = await api
|
||||
.get('rest/getAlbum.view', {
|
||||
prefixUrl: server?.url,
|
||||
searchParams: { id: query.id },
|
||||
searchParams: parseSearchParams(searchParams),
|
||||
signal,
|
||||
})
|
||||
.json<SSAlbumDetailResponse>();
|
||||
@@ -210,12 +246,15 @@ const getAlbumDetail = async (args: AlbumDetailArgs): Promise<SSAlbumDetail> =>
|
||||
|
||||
const getAlbumList = async (args: AlbumListArgs): Promise<SSAlbumList> => {
|
||||
const { server, query, signal } = args;
|
||||
const defaultParams = getDefaultParams(server);
|
||||
|
||||
const normalizedParams = {};
|
||||
const searchParams = {
|
||||
...defaultParams,
|
||||
};
|
||||
const data = await api
|
||||
.get('rest/getAlbumList2.view', {
|
||||
prefixUrl: server?.url,
|
||||
searchParams: normalizedParams,
|
||||
searchParams: parseSearchParams(searchParams),
|
||||
signal,
|
||||
})
|
||||
.json<SSAlbumListResponse>();
|
||||
@@ -229,65 +268,208 @@ const getAlbumList = async (args: AlbumListArgs): Promise<SSAlbumList> => {
|
||||
|
||||
const createFavorite = async (args: FavoriteArgs): Promise<FavoriteResponse> => {
|
||||
const { server, query, signal } = args;
|
||||
const defaultParams = getDefaultParams(server);
|
||||
|
||||
const searchParams: SSFavoriteParams = {
|
||||
albumId: query.type === 'album' ? query.id : undefined,
|
||||
artistId: query.type === 'albumArtist' ? query.id : undefined,
|
||||
id: query.type === 'song' ? query.id : undefined,
|
||||
};
|
||||
for (const id of query.id) {
|
||||
const searchParams: SSFavoriteParams = {
|
||||
albumId: query.type === LibraryItem.ALBUM ? id : undefined,
|
||||
artistId: query.type === LibraryItem.ALBUM_ARTIST ? id : undefined,
|
||||
id: query.type === LibraryItem.SONG ? id : undefined,
|
||||
...defaultParams,
|
||||
};
|
||||
|
||||
await api
|
||||
.get('rest/star.view', {
|
||||
await api.get('rest/star.view', {
|
||||
prefixUrl: server?.url,
|
||||
searchParams,
|
||||
searchParams: parseSearchParams(searchParams),
|
||||
signal,
|
||||
})
|
||||
.json<SSFavoriteResponse>();
|
||||
});
|
||||
// .json<SSFavoriteResponse>();
|
||||
}
|
||||
|
||||
return {
|
||||
id: query.id,
|
||||
type: query.type,
|
||||
};
|
||||
};
|
||||
|
||||
const deleteFavorite = async (args: FavoriteArgs): Promise<FavoriteResponse> => {
|
||||
const { server, query, signal } = args;
|
||||
const defaultParams = getDefaultParams(server);
|
||||
|
||||
const searchParams: SSFavoriteParams = {
|
||||
albumId: query.type === 'album' ? query.id : undefined,
|
||||
artistId: query.type === 'albumArtist' ? query.id : undefined,
|
||||
id: query.type === 'song' ? query.id : undefined,
|
||||
};
|
||||
for (const id of query.id) {
|
||||
const searchParams: SSFavoriteParams = {
|
||||
albumId: query.type === LibraryItem.ALBUM ? id : undefined,
|
||||
artistId: query.type === LibraryItem.ALBUM_ARTIST ? id : undefined,
|
||||
id: query.type === LibraryItem.SONG ? id : undefined,
|
||||
...defaultParams,
|
||||
};
|
||||
|
||||
await api
|
||||
.get('rest/unstar.view', {
|
||||
await api.get('rest/unstar.view', {
|
||||
prefixUrl: server?.url,
|
||||
searchParams,
|
||||
searchParams: parseSearchParams(searchParams),
|
||||
signal,
|
||||
})
|
||||
.json<SSFavoriteResponse>();
|
||||
});
|
||||
// .json<SSFavoriteResponse>();
|
||||
}
|
||||
|
||||
return {
|
||||
id: query.id,
|
||||
type: query.type,
|
||||
};
|
||||
};
|
||||
|
||||
const updateRating = async (args: RatingArgs) => {
|
||||
const updateRating = async (args: RatingArgs): Promise<RatingResponse> => {
|
||||
const { server, query, signal } = args;
|
||||
const defaultParams = getDefaultParams(server);
|
||||
|
||||
const searchParams: SSRatingParams = {
|
||||
id: query.id,
|
||||
rating: query.rating,
|
||||
const itemIds = query.item.map((item) => item.id);
|
||||
|
||||
for (const id of itemIds) {
|
||||
const searchParams: SSRatingParams = {
|
||||
id,
|
||||
rating: query.rating,
|
||||
...defaultParams,
|
||||
};
|
||||
|
||||
await api.get('rest/setRating.view', {
|
||||
prefixUrl: server?.url,
|
||||
searchParams: parseSearchParams(searchParams),
|
||||
signal,
|
||||
});
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const getTopSongList = async (args: TopSongListArgs): Promise<SSTopSongList> => {
|
||||
const { signal, server, query } = args;
|
||||
const defaultParams = getDefaultParams(server);
|
||||
|
||||
const searchParams: SSTopSongListParams = {
|
||||
artist: query.artist,
|
||||
count: query.limit,
|
||||
...defaultParams,
|
||||
};
|
||||
|
||||
const data = await api
|
||||
.get('rest/setRating.view', {
|
||||
.get('rest/getTopSongs.view', {
|
||||
prefixUrl: server?.url,
|
||||
searchParams: parseSearchParams(searchParams),
|
||||
signal,
|
||||
})
|
||||
.json<SSTopSongListResponse>();
|
||||
|
||||
return {
|
||||
items: data?.topSongs?.song,
|
||||
startIndex: 0,
|
||||
totalRecordCount: data?.topSongs?.song?.length || 0,
|
||||
};
|
||||
};
|
||||
|
||||
const getArtistInfo = async (args: ArtistInfoArgs): Promise<SSArtistInfo> => {
|
||||
const { signal, server, query } = args;
|
||||
const defaultParams = getDefaultParams(server);
|
||||
|
||||
const searchParams: SSArtistInfoParams = {
|
||||
count: query.limit,
|
||||
id: query.artistId,
|
||||
...defaultParams,
|
||||
};
|
||||
|
||||
const data = await api
|
||||
.get('rest/getArtistInfo2.view', {
|
||||
prefixUrl: server?.url,
|
||||
searchParams,
|
||||
signal,
|
||||
})
|
||||
.json<SSRatingResponse>();
|
||||
.json<SSArtistInfoResponse>();
|
||||
|
||||
return data;
|
||||
return data.artistInfo2;
|
||||
};
|
||||
|
||||
const scrobble = async (args: ScrobbleArgs): Promise<RawScrobbleResponse> => {
|
||||
const { signal, server, query } = args;
|
||||
const defaultParams = getDefaultParams(server);
|
||||
|
||||
const searchParams: SSScrobbleParams = {
|
||||
id: query.id,
|
||||
submission: query.submission,
|
||||
...defaultParams,
|
||||
};
|
||||
|
||||
await api.get('rest/scrobble.view', {
|
||||
prefixUrl: server?.url,
|
||||
searchParams,
|
||||
signal,
|
||||
});
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const normalizeSong = (item: SSSong, server: ServerListItem, deviceId: string): QueueSong => {
|
||||
const imageUrl =
|
||||
getCoverArtUrl({
|
||||
baseUrl: server.url,
|
||||
coverArtId: item.coverArt,
|
||||
credential: server.credential,
|
||||
size: 300,
|
||||
}) || null;
|
||||
|
||||
const streamUrl = `${server.url}/rest/stream.view?id=${item.id}&v=1.13.0&c=feishin_${deviceId}&${server.credential}`;
|
||||
|
||||
return {
|
||||
album: item.album,
|
||||
albumArtists: [
|
||||
{
|
||||
id: item.artistId || '',
|
||||
imageUrl: null,
|
||||
name: item.artist,
|
||||
},
|
||||
],
|
||||
albumId: item.albumId,
|
||||
artistName: item.artist,
|
||||
artists: [
|
||||
{
|
||||
id: item.artistId || '',
|
||||
imageUrl: null,
|
||||
name: item.artist,
|
||||
},
|
||||
],
|
||||
bitRate: item.bitRate,
|
||||
bpm: null,
|
||||
channels: null,
|
||||
comment: null,
|
||||
compilation: null,
|
||||
container: item.contentType,
|
||||
createdAt: item.created,
|
||||
discNumber: item.discNumber || 1,
|
||||
duration: item.duration,
|
||||
genres: [
|
||||
{
|
||||
id: item.genre,
|
||||
name: item.genre,
|
||||
},
|
||||
],
|
||||
id: item.id,
|
||||
imagePlaceholderUrl: null,
|
||||
imageUrl,
|
||||
itemType: LibraryItem.SONG,
|
||||
lastPlayedAt: null,
|
||||
name: item.title,
|
||||
path: item.path,
|
||||
playCount: item?.playCount || 0,
|
||||
releaseDate: null,
|
||||
releaseYear: item.year ? String(item.year) : null,
|
||||
serverId: server.id,
|
||||
serverType: ServerType.SUBSONIC,
|
||||
size: item.size,
|
||||
streamUrl,
|
||||
trackNumber: item.track,
|
||||
uniqueId: nanoid(),
|
||||
updatedAt: '',
|
||||
userFavorite: item.starred || false,
|
||||
userRating: item.userRating || null,
|
||||
};
|
||||
};
|
||||
|
||||
export const subsonicApi = {
|
||||
@@ -298,8 +480,15 @@ export const subsonicApi = {
|
||||
getAlbumArtistList,
|
||||
getAlbumDetail,
|
||||
getAlbumList,
|
||||
getArtistInfo,
|
||||
getCoverArtUrl,
|
||||
getGenreList,
|
||||
getMusicFolderList,
|
||||
getTopSongList,
|
||||
scrobble,
|
||||
updateRating,
|
||||
};
|
||||
|
||||
export const ssNormalize = {
|
||||
song: normalizeSong,
|
||||
};
|
||||
|
||||
@@ -33,7 +33,11 @@ export type SSAlbumArtistDetailResponse = {
|
||||
};
|
||||
};
|
||||
|
||||
export type SSAlbumArtistList = SSAlbumArtistListEntry[];
|
||||
export type SSAlbumArtistList = {
|
||||
items: SSAlbumArtistListEntry[];
|
||||
startIndex: number;
|
||||
totalRecordCount: number | null;
|
||||
};
|
||||
|
||||
export type SSAlbumArtistListResponse = {
|
||||
artists: {
|
||||
@@ -61,6 +65,12 @@ export type SSAlbumDetailResponse = {
|
||||
album: SSAlbum;
|
||||
};
|
||||
|
||||
export type SSArtistInfoParams = {
|
||||
count?: number;
|
||||
id: string;
|
||||
includeNotPresent?: boolean;
|
||||
};
|
||||
|
||||
export type SSArtistInfoResponse = {
|
||||
artistInfo2: SSArtistInfo;
|
||||
};
|
||||
@@ -71,6 +81,13 @@ export type SSArtistInfo = {
|
||||
lastFmUrl?: string;
|
||||
mediumImageUrl?: string;
|
||||
musicBrainzId?: string;
|
||||
similarArtist?: {
|
||||
albumCount: string;
|
||||
artistImageUrl?: string;
|
||||
coverArt?: string;
|
||||
id: string;
|
||||
name: string;
|
||||
}[];
|
||||
smallImageUrl?: string;
|
||||
};
|
||||
|
||||
@@ -182,3 +199,26 @@ export type SSRatingParams = {
|
||||
export type SSRating = null;
|
||||
|
||||
export type SSRatingResponse = null;
|
||||
|
||||
export type SSTopSongListParams = {
|
||||
artist: string;
|
||||
count?: number;
|
||||
};
|
||||
|
||||
export type SSTopSongListResponse = {
|
||||
topSongs: {
|
||||
song: SSSong[];
|
||||
};
|
||||
};
|
||||
|
||||
export type SSTopSongList = {
|
||||
items: SSSong[];
|
||||
startIndex: number;
|
||||
totalRecordCount: number | null;
|
||||
};
|
||||
|
||||
export type SSScrobbleParams = {
|
||||
id: string;
|
||||
submission?: boolean;
|
||||
time?: number;
|
||||
};
|
||||
|
||||
+255
-46
@@ -14,6 +14,7 @@ import {
|
||||
JFPlaylistList,
|
||||
JFPlaylistDetail,
|
||||
JFMusicFolderList,
|
||||
JFPlaylistListSort,
|
||||
} from '/@/renderer/api/jellyfin.types';
|
||||
import {
|
||||
NDSortOrder,
|
||||
@@ -23,7 +24,6 @@ import {
|
||||
NDAlbumListSort,
|
||||
NDAlbumDetail,
|
||||
NDSongList,
|
||||
NDSongListSort,
|
||||
NDSongDetail,
|
||||
NDAlbumArtistList,
|
||||
NDAlbumArtistListSort,
|
||||
@@ -32,6 +32,9 @@ import {
|
||||
NDPlaylistList,
|
||||
NDPlaylistListSort,
|
||||
NDPlaylistDetail,
|
||||
NDSongListSort,
|
||||
NDUserList,
|
||||
NDUserListSort,
|
||||
} from '/@/renderer/api/navidrome.types';
|
||||
import {
|
||||
SSAlbumList,
|
||||
@@ -40,13 +43,42 @@ import {
|
||||
SSAlbumArtistDetail,
|
||||
SSMusicFolderList,
|
||||
SSGenreList,
|
||||
SSTopSongList,
|
||||
} from '/@/renderer/api/subsonic.types';
|
||||
|
||||
export enum LibraryItem {
|
||||
ALBUM = 'album',
|
||||
ALBUM_ARTIST = 'albumArtist',
|
||||
ARTIST = 'artist',
|
||||
PLAYLIST = 'playlist',
|
||||
SONG = 'song',
|
||||
}
|
||||
|
||||
export type AnyLibraryItem = Album | AlbumArtist | Artist | Playlist | Song | QueueSong;
|
||||
|
||||
export type AnyLibraryItems =
|
||||
| Album[]
|
||||
| AlbumArtist[]
|
||||
| Artist[]
|
||||
| Playlist[]
|
||||
| Song[]
|
||||
| QueueSong[];
|
||||
|
||||
export enum SortOrder {
|
||||
ASC = 'ASC',
|
||||
DESC = 'DESC',
|
||||
}
|
||||
|
||||
export type User = {
|
||||
createdAt: string | null;
|
||||
email: string | null;
|
||||
id: string;
|
||||
isAdmin: boolean | null;
|
||||
lastLoginAt: string | null;
|
||||
name: string;
|
||||
updatedAt: string | null;
|
||||
};
|
||||
|
||||
export type ServerListItem = {
|
||||
credential: string;
|
||||
id: string;
|
||||
@@ -119,16 +151,6 @@ export interface BasePaginatedResponse<T> {
|
||||
totalRecordCount: number;
|
||||
}
|
||||
|
||||
export type ApiError = {
|
||||
error: {
|
||||
message: string;
|
||||
path: string;
|
||||
trace: string[];
|
||||
};
|
||||
response: string;
|
||||
statusCode: number;
|
||||
};
|
||||
|
||||
export type AuthenticationResponse = {
|
||||
credential: string;
|
||||
ndCredential?: string;
|
||||
@@ -152,19 +174,21 @@ export type Album = {
|
||||
imagePlaceholderUrl: string | null;
|
||||
imageUrl: string | null;
|
||||
isCompilation: boolean | null;
|
||||
isFavorite: boolean;
|
||||
itemType: LibraryItem.ALBUM;
|
||||
lastPlayedAt: string | null;
|
||||
name: string;
|
||||
playCount: number | null;
|
||||
rating: number | null;
|
||||
releaseDate: string | null;
|
||||
releaseYear: number | null;
|
||||
serverId: string;
|
||||
serverType: ServerType;
|
||||
size: number | null;
|
||||
songCount: number | null;
|
||||
songs?: Song[];
|
||||
uniqueId: string;
|
||||
updatedAt: string;
|
||||
userFavorite: boolean;
|
||||
userRating: number | null;
|
||||
} & { songs?: Song[] };
|
||||
|
||||
export type Song = {
|
||||
@@ -176,6 +200,7 @@ export type Song = {
|
||||
bitRate: number;
|
||||
bpm: number | null;
|
||||
channels: number | null;
|
||||
comment: string | null;
|
||||
compilation: boolean | null;
|
||||
container: string | null;
|
||||
createdAt: string;
|
||||
@@ -183,32 +208,45 @@ export type Song = {
|
||||
duration: number;
|
||||
genres: Genre[];
|
||||
id: string;
|
||||
imagePlaceholderUrl: string | null;
|
||||
imageUrl: string | null;
|
||||
isFavorite: boolean;
|
||||
itemType: LibraryItem.SONG;
|
||||
lastPlayedAt: string | null;
|
||||
name: string;
|
||||
note: string | null;
|
||||
path: string | null;
|
||||
playCount: number;
|
||||
playlistItemId?: string;
|
||||
releaseDate: string | null;
|
||||
releaseYear: string | null;
|
||||
serverId: string;
|
||||
serverType: ServerType;
|
||||
size: number;
|
||||
streamUrl: string;
|
||||
trackNumber: number;
|
||||
type: ServerType;
|
||||
uniqueId: string;
|
||||
updatedAt: string;
|
||||
userFavorite: boolean;
|
||||
userRating: number | null;
|
||||
};
|
||||
|
||||
export type AlbumArtist = {
|
||||
albumCount: number | null;
|
||||
backgroundImageUrl: string | null;
|
||||
biography: string | null;
|
||||
createdAt: string;
|
||||
duration: number | null;
|
||||
genres: Genre[];
|
||||
id: string;
|
||||
imageUrl: string | null;
|
||||
itemType: LibraryItem.ALBUM_ARTIST;
|
||||
lastPlayedAt: string | null;
|
||||
name: string;
|
||||
remoteCreatedAt: string | null;
|
||||
serverFolderId: string;
|
||||
updatedAt: string;
|
||||
playCount: number | null;
|
||||
serverId: string;
|
||||
serverType: ServerType;
|
||||
similarArtists: RelatedArtist[] | null;
|
||||
songCount: number | null;
|
||||
userFavorite: boolean;
|
||||
userRating: number | null;
|
||||
};
|
||||
|
||||
export type RelatedAlbumArtist = {
|
||||
@@ -220,14 +258,18 @@ export type Artist = {
|
||||
biography: string | null;
|
||||
createdAt: string;
|
||||
id: string;
|
||||
itemType: LibraryItem.ARTIST;
|
||||
name: string;
|
||||
remoteCreatedAt: string | null;
|
||||
serverFolderId: string;
|
||||
serverId: string;
|
||||
serverType: ServerType;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
export type RelatedArtist = {
|
||||
id: string;
|
||||
imageUrl: string | null;
|
||||
name: string;
|
||||
};
|
||||
|
||||
@@ -237,14 +279,23 @@ export type MusicFolder = {
|
||||
};
|
||||
|
||||
export type Playlist = {
|
||||
duration?: number;
|
||||
description: string | null;
|
||||
duration: number | null;
|
||||
genres: Genre[];
|
||||
id: string;
|
||||
imagePlaceholderUrl: string | null;
|
||||
imageUrl: string | null;
|
||||
itemType: LibraryItem.PLAYLIST;
|
||||
name: string;
|
||||
public?: boolean;
|
||||
size?: number;
|
||||
songCount?: number;
|
||||
userId: string;
|
||||
username: string;
|
||||
owner: string | null;
|
||||
ownerId: string | null;
|
||||
public: boolean | null;
|
||||
rules?: Record<string, any> | null;
|
||||
serverId: string;
|
||||
serverType: ServerType;
|
||||
size: number | null;
|
||||
songCount: number | null;
|
||||
sync?: boolean | null;
|
||||
};
|
||||
|
||||
export type GenresResponse = Genre[];
|
||||
@@ -254,6 +305,7 @@ export type MusicFoldersResponse = MusicFolder[];
|
||||
export type ListSortOrder = NDOrder | JFSortOrder;
|
||||
|
||||
type BaseEndpointArgs = {
|
||||
_serverId?: string;
|
||||
server: ServerListItem | null;
|
||||
signal?: AbortSignal;
|
||||
};
|
||||
@@ -294,6 +346,7 @@ export type AlbumListQuery = {
|
||||
jfParams?: {
|
||||
albumArtistIds?: string;
|
||||
artistIds?: string;
|
||||
contributingArtistIds?: string;
|
||||
filters?: string;
|
||||
genreIds?: string;
|
||||
genres?: string;
|
||||
@@ -406,6 +459,7 @@ export enum SongListSort {
|
||||
DURATION = 'duration',
|
||||
FAVORITED = 'favorited',
|
||||
GENRE = 'genre',
|
||||
ID = 'id',
|
||||
NAME = 'name',
|
||||
PLAY_COUNT = 'playCount',
|
||||
RANDOM = 'random',
|
||||
@@ -418,7 +472,10 @@ export enum SongListSort {
|
||||
|
||||
export type SongListQuery = {
|
||||
albumIds?: string[];
|
||||
artistIds?: string[];
|
||||
jfParams?: {
|
||||
artistIds?: string;
|
||||
contributingArtistIds?: string;
|
||||
filters?: string;
|
||||
genreIds?: string;
|
||||
genres?: string;
|
||||
@@ -432,7 +489,8 @@ export type SongListQuery = {
|
||||
limit?: number;
|
||||
musicFolderId?: string;
|
||||
ndParams?: {
|
||||
artist_id?: string;
|
||||
album_id?: string[];
|
||||
artist_id?: string[];
|
||||
compilation?: boolean;
|
||||
genre_id?: string;
|
||||
has_rating?: boolean;
|
||||
@@ -465,6 +523,7 @@ export const songListSortMap: SongListSortMap = {
|
||||
duration: JFSongListSort.DURATION,
|
||||
favorited: undefined,
|
||||
genre: undefined,
|
||||
id: undefined,
|
||||
name: JFSongListSort.NAME,
|
||||
playCount: JFSongListSort.PLAY_COUNT,
|
||||
random: JFSongListSort.RANDOM,
|
||||
@@ -475,7 +534,7 @@ export const songListSortMap: SongListSortMap = {
|
||||
year: undefined,
|
||||
},
|
||||
navidrome: {
|
||||
album: NDSongListSort.ALBUM,
|
||||
album: NDSongListSort.ALBUM_SONGS,
|
||||
albumArtist: NDSongListSort.ALBUM_ARTIST,
|
||||
artist: NDSongListSort.ARTIST,
|
||||
bpm: NDSongListSort.BPM,
|
||||
@@ -484,11 +543,12 @@ export const songListSortMap: SongListSortMap = {
|
||||
duration: NDSongListSort.DURATION,
|
||||
favorited: NDSongListSort.FAVORITED,
|
||||
genre: NDSongListSort.GENRE,
|
||||
id: NDSongListSort.ID,
|
||||
name: NDSongListSort.TITLE,
|
||||
playCount: NDSongListSort.PLAY_COUNT,
|
||||
random: undefined,
|
||||
rating: NDSongListSort.RATING,
|
||||
recentlyAdded: NDSongListSort.PLAY_DATE,
|
||||
recentlyAdded: NDSongListSort.RECENTLY_ADDED,
|
||||
recentlyPlayed: NDSongListSort.PLAY_DATE,
|
||||
releaseDate: undefined,
|
||||
year: NDSongListSort.YEAR,
|
||||
@@ -503,6 +563,7 @@ export const songListSortMap: SongListSortMap = {
|
||||
duration: undefined,
|
||||
favorited: undefined,
|
||||
genre: undefined,
|
||||
id: undefined,
|
||||
name: undefined,
|
||||
playCount: undefined,
|
||||
random: undefined,
|
||||
@@ -554,6 +615,7 @@ export type AlbumArtistListQuery = {
|
||||
name?: string;
|
||||
starred?: boolean;
|
||||
};
|
||||
searchTerm?: string;
|
||||
sortBy: AlbumArtistListSort;
|
||||
sortOrder: SortOrder;
|
||||
startIndex: number;
|
||||
@@ -709,29 +771,98 @@ export const artistListSortMap: ArtistListSortMap = {
|
||||
// Favorite
|
||||
export type RawFavoriteResponse = FavoriteResponse | undefined;
|
||||
|
||||
export type FavoriteResponse = { id: string };
|
||||
export type FavoriteResponse = { id: string[]; type: LibraryItem };
|
||||
|
||||
export type FavoriteQuery = { id: string; type?: 'song' | 'album' | 'albumArtist' };
|
||||
export type FavoriteQuery = {
|
||||
id: string[];
|
||||
type: LibraryItem;
|
||||
};
|
||||
|
||||
export type FavoriteArgs = { query: FavoriteQuery } & BaseEndpointArgs;
|
||||
|
||||
// Rating
|
||||
export type RawRatingResponse = null | undefined;
|
||||
export type RawRatingResponse = RatingResponse | undefined;
|
||||
|
||||
export type RatingResponse = null;
|
||||
|
||||
export type RatingQuery = { id: string; rating: number };
|
||||
export type RatingQuery = {
|
||||
item: AnyLibraryItems;
|
||||
rating: number;
|
||||
};
|
||||
|
||||
export type RatingArgs = { query: RatingQuery } & BaseEndpointArgs;
|
||||
|
||||
// Add to playlist
|
||||
export type RawAddToPlaylistResponse = null | undefined;
|
||||
|
||||
export type AddToPlaylistQuery = {
|
||||
id: string;
|
||||
};
|
||||
|
||||
export type AddToPlaylistBody = {
|
||||
songId: string[];
|
||||
};
|
||||
|
||||
export type AddToPlaylistArgs = {
|
||||
body: AddToPlaylistBody;
|
||||
query: AddToPlaylistQuery;
|
||||
} & BaseEndpointArgs;
|
||||
|
||||
// Remove from playlist
|
||||
export type RawRemoveFromPlaylistResponse = null | undefined;
|
||||
|
||||
export type RemoveFromPlaylistQuery = {
|
||||
id: string;
|
||||
songId: string[];
|
||||
};
|
||||
|
||||
export type RemoveFromPlaylistArgs = { query: RemoveFromPlaylistQuery } & BaseEndpointArgs;
|
||||
|
||||
// Create Playlist
|
||||
export type RawCreatePlaylistResponse = CreatePlaylistResponse | undefined;
|
||||
|
||||
export type CreatePlaylistResponse = { id: string; name: string };
|
||||
|
||||
export type CreatePlaylistQuery = { comment?: string; name: string; public?: boolean };
|
||||
export type CreatePlaylistBody = {
|
||||
comment?: string;
|
||||
name: string;
|
||||
ndParams?: {
|
||||
owner?: string;
|
||||
ownerId?: string;
|
||||
public?: boolean;
|
||||
rules?: Record<string, any>;
|
||||
sync?: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
export type CreatePlaylistArgs = { query: CreatePlaylistQuery } & BaseEndpointArgs;
|
||||
export type CreatePlaylistArgs = { body: CreatePlaylistBody } & BaseEndpointArgs;
|
||||
|
||||
// Update Playlist
|
||||
export type RawUpdatePlaylistResponse = UpdatePlaylistResponse | undefined;
|
||||
|
||||
export type UpdatePlaylistResponse = { id: string };
|
||||
|
||||
export type UpdatePlaylistQuery = {
|
||||
id: string;
|
||||
};
|
||||
|
||||
export type UpdatePlaylistBody = {
|
||||
comment?: string;
|
||||
genres?: Genre[];
|
||||
name: string;
|
||||
ndParams?: {
|
||||
owner?: string;
|
||||
ownerId?: string;
|
||||
public?: boolean;
|
||||
rules?: Record<string, any>;
|
||||
sync?: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
export type UpdatePlaylistArgs = {
|
||||
body: UpdatePlaylistBody;
|
||||
query: UpdatePlaylistQuery;
|
||||
} & BaseEndpointArgs;
|
||||
|
||||
// Delete Playlist
|
||||
export type RawDeletePlaylistResponse = NDDeletePlaylist | undefined;
|
||||
@@ -747,11 +878,22 @@ export type RawPlaylistListResponse = NDPlaylistList | JFPlaylistList | undefine
|
||||
|
||||
export type PlaylistListResponse = BasePaginatedResponse<Playlist[]>;
|
||||
|
||||
export type PlaylistListSort = NDPlaylistListSort;
|
||||
export enum PlaylistListSort {
|
||||
DURATION = 'duration',
|
||||
NAME = 'name',
|
||||
OWNER = 'owner',
|
||||
PUBLIC = 'public',
|
||||
SONG_COUNT = 'songCount',
|
||||
UPDATED_AT = 'updatedAt',
|
||||
}
|
||||
|
||||
export type PlaylistListQuery = {
|
||||
limit?: number;
|
||||
musicFolderId?: string;
|
||||
ndParams?: {
|
||||
owner_id?: string;
|
||||
smart?: boolean;
|
||||
};
|
||||
searchTerm?: string;
|
||||
sortBy: PlaylistListSort;
|
||||
sortOrder: SortOrder;
|
||||
startIndex: number;
|
||||
@@ -760,18 +902,18 @@ export type PlaylistListQuery = {
|
||||
export type PlaylistListArgs = { query: PlaylistListQuery } & BaseEndpointArgs;
|
||||
|
||||
type PlaylistListSortMap = {
|
||||
jellyfin: Record<PlaylistListSort, undefined>;
|
||||
jellyfin: Record<PlaylistListSort, JFPlaylistListSort | undefined>;
|
||||
navidrome: Record<PlaylistListSort, NDPlaylistListSort | undefined>;
|
||||
subsonic: Record<PlaylistListSort, undefined>;
|
||||
};
|
||||
|
||||
export const playlistListSortMap: PlaylistListSortMap = {
|
||||
jellyfin: {
|
||||
duration: undefined,
|
||||
name: undefined,
|
||||
duration: JFPlaylistListSort.DURATION,
|
||||
name: JFPlaylistListSort.NAME,
|
||||
owner: undefined,
|
||||
public: undefined,
|
||||
songCount: undefined,
|
||||
songCount: JFPlaylistListSort.SONG_COUNT,
|
||||
updatedAt: undefined,
|
||||
},
|
||||
navidrome: {
|
||||
@@ -825,11 +967,78 @@ export type MusicFolderListResponse = BasePaginatedResponse<Playlist[]>;
|
||||
|
||||
export type MusicFolderListArgs = BaseEndpointArgs;
|
||||
|
||||
// Create Favorite
|
||||
export type RawCreateFavoriteResponse = CreateFavoriteResponse | undefined;
|
||||
// User list
|
||||
// Playlist List
|
||||
export type RawUserListResponse = NDUserList | undefined;
|
||||
|
||||
export type CreateFavoriteResponse = { id: string };
|
||||
export type UserListResponse = BasePaginatedResponse<User[]>;
|
||||
|
||||
export type CreateFavoriteQuery = { comment?: string; name: string; public?: boolean };
|
||||
export enum UserListSort {
|
||||
NAME = 'name',
|
||||
}
|
||||
|
||||
export type CreateFavoriteArgs = { query: CreateFavoriteQuery } & BaseEndpointArgs;
|
||||
export type UserListQuery = {
|
||||
limit?: number;
|
||||
ndParams?: {
|
||||
owner_id?: string;
|
||||
};
|
||||
searchTerm?: string;
|
||||
sortBy: UserListSort;
|
||||
sortOrder: SortOrder;
|
||||
startIndex: number;
|
||||
};
|
||||
|
||||
export type UserListArgs = { query: UserListQuery } & BaseEndpointArgs;
|
||||
|
||||
type UserListSortMap = {
|
||||
jellyfin: Record<UserListSort, undefined>;
|
||||
navidrome: Record<UserListSort, NDUserListSort | undefined>;
|
||||
subsonic: Record<UserListSort, undefined>;
|
||||
};
|
||||
|
||||
export const userListSortMap: UserListSortMap = {
|
||||
jellyfin: {
|
||||
name: undefined,
|
||||
},
|
||||
navidrome: {
|
||||
name: NDUserListSort.NAME,
|
||||
},
|
||||
subsonic: {
|
||||
name: undefined,
|
||||
},
|
||||
};
|
||||
|
||||
// Top Songs List
|
||||
export type RawTopSongListResponse = SSTopSongList | undefined;
|
||||
|
||||
export type TopSongListResponse = BasePaginatedResponse<Song[]>;
|
||||
|
||||
export type TopSongListQuery = {
|
||||
artist: string;
|
||||
limit?: number;
|
||||
};
|
||||
|
||||
export type TopSongListArgs = { query: TopSongListQuery } & BaseEndpointArgs;
|
||||
|
||||
// Artist Info
|
||||
export type ArtistInfoQuery = {
|
||||
artistId: string;
|
||||
limit: number;
|
||||
musicFolderId?: string;
|
||||
};
|
||||
|
||||
export type ArtistInfoArgs = { query: ArtistInfoQuery } & BaseEndpointArgs;
|
||||
|
||||
// Scrobble
|
||||
export type RawScrobbleResponse = null | undefined;
|
||||
|
||||
export type ScrobbleArgs = {
|
||||
query: ScrobbleQuery;
|
||||
} & BaseEndpointArgs;
|
||||
|
||||
export type ScrobbleQuery = {
|
||||
event?: 'pause' | 'unpause' | 'timeupdate' | 'start';
|
||||
id: string;
|
||||
position?: number;
|
||||
submission: boolean;
|
||||
};
|
||||
|
||||
+80
-73
@@ -4,17 +4,17 @@ import { ModuleRegistry } from '@ag-grid-community/core';
|
||||
import { InfiniteRowModelModule } from '@ag-grid-community/infinite-row-model';
|
||||
import { MantineProvider } from '@mantine/core';
|
||||
import { ModalsProvider } from '@mantine/modals';
|
||||
import { NotificationsProvider } from '@mantine/notifications';
|
||||
import { QueryClientProvider } from '@tanstack/react-query';
|
||||
import { initSimpleImg } from 'react-simple-img';
|
||||
import { BaseContextModal } from './components';
|
||||
import { useTheme } from './hooks';
|
||||
import { queryClient } from './lib/react-query';
|
||||
import { AppRouter } from './router/app-router';
|
||||
import { useSettingsStore } from './store/settings.store';
|
||||
import './styles/global.scss';
|
||||
import '@ag-grid-community/styles/ag-grid.css';
|
||||
import { ContextMenuProvider } from '/@/renderer/features/context-menu';
|
||||
import { useHandlePlayQueueAdd } from '/@/renderer/features/player/hooks/use-handle-playqueue-add';
|
||||
import { PlayQueueHandlerContext } from '/@/renderer/features/player';
|
||||
import { AddToPlaylistContextModal } from '/@/renderer/features/playlists';
|
||||
|
||||
ModuleRegistry.registerModules([ClientSideRowModelModule, InfiniteRowModelModule]);
|
||||
|
||||
@@ -24,87 +24,94 @@ export const App = () => {
|
||||
const theme = useTheme();
|
||||
const contentFont = useSettingsStore((state) => state.general.fontContent);
|
||||
|
||||
const handlePlayQueueAdd = useHandlePlayQueueAdd();
|
||||
|
||||
useEffect(() => {
|
||||
const root = document.documentElement;
|
||||
root.style.setProperty('--content-font-family', contentFont);
|
||||
}, [contentFont]);
|
||||
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<MantineProvider
|
||||
withGlobalStyles
|
||||
withNormalizeCSS
|
||||
theme={{
|
||||
breakpoints: {
|
||||
lg: 1200,
|
||||
md: 1000,
|
||||
sm: 800,
|
||||
xl: 1400,
|
||||
xs: 500,
|
||||
},
|
||||
colorScheme: theme as 'light' | 'dark',
|
||||
components: { Modal: { styles: { body: { padding: '.5rem' } } } },
|
||||
defaultRadius: 'xs',
|
||||
dir: 'ltr',
|
||||
focusRing: 'auto',
|
||||
focusRingStyles: {
|
||||
inputStyles: () => ({
|
||||
border: '1px solid var(--primary-color)',
|
||||
}),
|
||||
resetStyles: () => ({ outline: 'none' }),
|
||||
styles: () => ({
|
||||
outline: '1px solid var(--primary-color)',
|
||||
outlineOffset: '-1px',
|
||||
}),
|
||||
<MantineProvider
|
||||
withGlobalStyles
|
||||
withNormalizeCSS
|
||||
theme={{
|
||||
colorScheme: theme as 'light' | 'dark',
|
||||
components: {
|
||||
Modal: {
|
||||
styles: {
|
||||
body: { background: 'var(--modal-bg)', padding: '1rem !important' },
|
||||
close: { marginRight: '0.5rem' },
|
||||
content: { borderRadius: '10px' },
|
||||
header: {
|
||||
background: 'var(--modal-bg)',
|
||||
borderBottom: '1px solid var(--generic-border-color)',
|
||||
paddingBottom: '1rem',
|
||||
},
|
||||
title: { fontSize: 'medium', fontWeight: 'bold' },
|
||||
},
|
||||
},
|
||||
},
|
||||
defaultRadius: 'xs',
|
||||
dir: 'ltr',
|
||||
focusRing: 'auto',
|
||||
focusRingStyles: {
|
||||
inputStyles: () => ({
|
||||
border: '1px solid var(--primary-color)',
|
||||
}),
|
||||
resetStyles: () => ({ outline: 'none' }),
|
||||
styles: () => ({
|
||||
outline: '1px solid var(--primary-color)',
|
||||
outlineOffset: '-1px',
|
||||
}),
|
||||
},
|
||||
fontFamily: 'var(--content-font-family)',
|
||||
fontSizes: {
|
||||
lg: '1.1rem',
|
||||
md: '1rem',
|
||||
sm: '0.9rem',
|
||||
xl: '1.5rem',
|
||||
xs: '0.8rem',
|
||||
},
|
||||
headings: {
|
||||
fontFamily: 'var(--content-font-family)',
|
||||
fontSizes: {
|
||||
lg: 16,
|
||||
md: 14,
|
||||
sm: 12,
|
||||
xl: 18,
|
||||
xs: 10,
|
||||
fontWeight: 700,
|
||||
sizes: {
|
||||
h1: '6rem',
|
||||
h2: '4rem',
|
||||
h3: '3rem',
|
||||
h4: '1.5rem',
|
||||
h5: '1.2rem',
|
||||
h6: '1rem',
|
||||
},
|
||||
headings: { fontFamily: 'var(--content-font-family)' },
|
||||
other: {},
|
||||
spacing: {
|
||||
lg: 12,
|
||||
md: 8,
|
||||
sm: 4,
|
||||
xl: 16,
|
||||
xs: 2,
|
||||
},
|
||||
other: {},
|
||||
spacing: {
|
||||
lg: '2rem',
|
||||
md: '1rem',
|
||||
sm: '0.5rem',
|
||||
xl: '4rem',
|
||||
xs: '0rem',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<ModalsProvider
|
||||
modalProps={{
|
||||
centered: true,
|
||||
transitionProps: {
|
||||
duration: 300,
|
||||
exitDuration: 300,
|
||||
transition: 'slide-down',
|
||||
},
|
||||
}}
|
||||
modals={{ addToPlaylist: AddToPlaylistContextModal, base: BaseContextModal }}
|
||||
>
|
||||
<NotificationsProvider
|
||||
autoClose={1500}
|
||||
position="bottom-right"
|
||||
style={{
|
||||
marginBottom: '85px',
|
||||
opacity: '.8',
|
||||
userSelect: 'none',
|
||||
width: '250px',
|
||||
}}
|
||||
transitionDuration={200}
|
||||
>
|
||||
<ModalsProvider
|
||||
modalProps={{
|
||||
centered: true,
|
||||
exitTransitionDuration: 300,
|
||||
overflow: 'inside',
|
||||
overlayBlur: 0,
|
||||
overlayOpacity: 0.8,
|
||||
transition: 'slide-down',
|
||||
transitionDuration: 300,
|
||||
}}
|
||||
modals={{ base: BaseContextModal }}
|
||||
>
|
||||
<ContextMenuProvider>
|
||||
<AppRouter />
|
||||
</ContextMenuProvider>
|
||||
</ModalsProvider>
|
||||
</NotificationsProvider>
|
||||
</MantineProvider>
|
||||
</QueryClientProvider>
|
||||
<PlayQueueHandlerContext.Provider value={{ handlePlayQueueAdd }}>
|
||||
<ContextMenuProvider>
|
||||
<AppRouter />
|
||||
</ContextMenuProvider>
|
||||
</PlayQueueHandlerContext.Provider>
|
||||
</ModalsProvider>
|
||||
</MantineProvider>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -10,7 +10,6 @@ const StyledBadge = styled(MantineBadge)<BadgeProps>`
|
||||
}
|
||||
|
||||
.mantine-Badge-inner {
|
||||
padding: 0 0.5rem;
|
||||
color: var(--badge-fg);
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -92,7 +92,7 @@ const StyledButton = styled(MantineButton)<StyledButtonProps>`
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
&:not([data-disabled])&:hover {
|
||||
color: ${(props) => {
|
||||
switch (props.variant) {
|
||||
case 'default':
|
||||
@@ -100,7 +100,7 @@ const StyledButton = styled(MantineButton)<StyledButtonProps>`
|
||||
case 'filled':
|
||||
return 'var(--btn-primary-fg-hover)';
|
||||
case 'subtle':
|
||||
return 'var(--btn-subtle-fg-hover)';
|
||||
return 'var(--btn-subtle-fg-hover) !important';
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
@@ -134,7 +134,7 @@ const StyledButton = styled(MantineButton)<StyledButtonProps>`
|
||||
}
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
&:not([data-disabled])&:focus-visible {
|
||||
color: ${(props) => {
|
||||
switch (props.variant) {
|
||||
case 'default':
|
||||
|
||||
@@ -1,15 +1,14 @@
|
||||
import React, { useCallback } from 'react';
|
||||
import { useCallback } from 'react';
|
||||
import { Center } from '@mantine/core';
|
||||
import { RiAlbumFill } from 'react-icons/ri';
|
||||
import { generatePath, useNavigate } from 'react-router';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { SimpleImg } from 'react-simple-img';
|
||||
import styled from 'styled-components';
|
||||
import { Text } from '/@/renderer/components/text';
|
||||
import type { LibraryItem, CardRow, CardRoute, Play, PlayQueueAddOptions } from '/@/renderer/types';
|
||||
import type { CardRow, CardRoute, Play, PlayQueueAddOptions } from '/@/renderer/types';
|
||||
import { Skeleton } from '/@/renderer/components/skeleton';
|
||||
import { CardControls } from '/@/renderer/components/card/card-controls';
|
||||
import { Album } from '/@/renderer/api/types';
|
||||
import { Album, AlbumArtist, Artist, LibraryItem } from '/@/renderer/api/types';
|
||||
import { CardRows } from '/@/renderer/components/card/card-rows';
|
||||
|
||||
const CardWrapper = styled.div<{
|
||||
link?: boolean;
|
||||
@@ -103,13 +102,13 @@ const Row = styled.div<{ $secondary?: boolean }>`
|
||||
|
||||
interface BaseGridCardProps {
|
||||
controls: {
|
||||
cardRows: CardRow<Album>[];
|
||||
cardRows: CardRow<Album | Artist | AlbumArtist>[];
|
||||
itemType: LibraryItem;
|
||||
playButtonBehavior: Play;
|
||||
route: CardRoute;
|
||||
};
|
||||
data: any;
|
||||
handlePlayQueueAdd: (options: PlayQueueAddOptions) => void;
|
||||
handlePlayQueueAdd?: (options: PlayQueueAddOptions) => void;
|
||||
loading?: boolean;
|
||||
size: number;
|
||||
}
|
||||
@@ -151,7 +150,7 @@ export const AlbumCard = ({
|
||||
animationDuration={0.3}
|
||||
height={size}
|
||||
imgStyle={{ objectFit: 'cover' }}
|
||||
placeholder="var(--card-default-bg)"
|
||||
placeholder={data?.imagePlaceholderUrl || 'var(--card-default-bg)'}
|
||||
src={data?.imageUrl}
|
||||
width={size}
|
||||
/>
|
||||
@@ -179,104 +178,10 @@ export const AlbumCard = ({
|
||||
</ControlsContainer>
|
||||
</ImageSection>
|
||||
<DetailSection>
|
||||
{cardRows.map((row: CardRow<Album>, index: number) => {
|
||||
if (row.arrayProperty && row.route) {
|
||||
return (
|
||||
<Row
|
||||
key={`row-${row.property}-${index}`}
|
||||
$secondary={index > 0}
|
||||
>
|
||||
{data[row.property].map((item: any, itemIndex: number) => (
|
||||
<React.Fragment key={`${data.id}-${item.id}`}>
|
||||
{itemIndex > 0 && (
|
||||
<Text
|
||||
$noSelect
|
||||
sx={{
|
||||
display: 'inline-block',
|
||||
padding: '0 2px 0 1px',
|
||||
}}
|
||||
>
|
||||
,
|
||||
</Text>
|
||||
)}{' '}
|
||||
<Text
|
||||
$link
|
||||
$noSelect
|
||||
$secondary={index > 0}
|
||||
component={Link}
|
||||
overflow="hidden"
|
||||
size={index > 0 ? 'xs' : 'md'}
|
||||
to={generatePath(
|
||||
row.route!.route,
|
||||
row.route!.slugs?.reduce((acc, slug) => {
|
||||
return {
|
||||
...acc,
|
||||
[slug.slugProperty]: data[slug.idProperty],
|
||||
};
|
||||
}, {}),
|
||||
)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{row.arrayProperty && item[row.arrayProperty]}
|
||||
</Text>
|
||||
</React.Fragment>
|
||||
))}
|
||||
</Row>
|
||||
);
|
||||
}
|
||||
|
||||
if (row.arrayProperty) {
|
||||
return (
|
||||
<Row key={`row-${row.property}`}>
|
||||
{data[row.property].map((item: any) => (
|
||||
<Text
|
||||
key={`${data.id}-${item.id}`}
|
||||
$noSelect
|
||||
$secondary={index > 0}
|
||||
overflow="hidden"
|
||||
size={index > 0 ? 'xs' : 'md'}
|
||||
>
|
||||
{row.arrayProperty && item[row.arrayProperty]}
|
||||
</Text>
|
||||
))}
|
||||
</Row>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Row key={`row-${row.property}`}>
|
||||
{row.route ? (
|
||||
<Text
|
||||
$link
|
||||
$noSelect
|
||||
component={Link}
|
||||
overflow="hidden"
|
||||
to={generatePath(
|
||||
row.route.route,
|
||||
row.route.slugs?.reduce((acc, slug) => {
|
||||
return {
|
||||
...acc,
|
||||
[slug.slugProperty]: data[slug.idProperty],
|
||||
};
|
||||
}, {}),
|
||||
)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{data && data[row.property]}
|
||||
</Text>
|
||||
) : (
|
||||
<Text
|
||||
$noSelect
|
||||
$secondary={index > 0}
|
||||
overflow="hidden"
|
||||
size={index > 0 ? 'xs' : 'md'}
|
||||
>
|
||||
{data && data[row.property]}
|
||||
</Text>
|
||||
)}
|
||||
</Row>
|
||||
);
|
||||
})}
|
||||
<CardRows
|
||||
data={data}
|
||||
rows={cardRows}
|
||||
/>
|
||||
</DetailSection>
|
||||
</StyledCard>
|
||||
</CardWrapper>
|
||||
|
||||
@@ -5,10 +5,15 @@ import { Group } from '@mantine/core';
|
||||
import { RiPlayFill, RiMore2Fill, RiHeartFill, RiHeartLine } from 'react-icons/ri';
|
||||
import styled from 'styled-components';
|
||||
import { _Button } from '/@/renderer/components/button';
|
||||
import { DropdownMenu } from '/@/renderer/components/dropdown-menu';
|
||||
import type { LibraryItem, PlayQueueAddOptions } from '/@/renderer/types';
|
||||
import type { PlayQueueAddOptions } from '/@/renderer/types';
|
||||
import { Play } from '/@/renderer/types';
|
||||
import { useSettingsStore } from '/@/renderer/store/settings.store';
|
||||
import { LibraryItem } from '/@/renderer/api/types';
|
||||
import { useHandleGeneralContextMenu } from '/@/renderer/features/context-menu/hooks/use-handle-context-menu';
|
||||
import {
|
||||
ALBUM_CONTEXT_MENU_ITEMS,
|
||||
ARTIST_CONTEXT_MENU_ITEMS,
|
||||
} from '/@/renderer/features/context-menu/context-menu-items';
|
||||
|
||||
type PlayButtonType = UnstyledButtonProps & React.ComponentPropsWithoutRef<'button'>;
|
||||
|
||||
@@ -98,27 +103,12 @@ const FavoriteWrapper = styled.span<{ isFavorite: boolean }>`
|
||||
}
|
||||
`;
|
||||
|
||||
const PLAY_TYPES = [
|
||||
{
|
||||
label: 'Play',
|
||||
play: Play.NOW,
|
||||
},
|
||||
{
|
||||
label: 'Add to queue (last)',
|
||||
play: Play.LAST,
|
||||
},
|
||||
{
|
||||
label: 'Add to queue (next)',
|
||||
play: Play.NEXT,
|
||||
},
|
||||
];
|
||||
|
||||
export const CardControls = ({
|
||||
itemData,
|
||||
itemType,
|
||||
handlePlayQueueAdd,
|
||||
}: {
|
||||
handlePlayQueueAdd: (options: PlayQueueAddOptions) => void;
|
||||
handlePlayQueueAdd?: (options: PlayQueueAddOptions) => void;
|
||||
itemData: any;
|
||||
itemType: LibraryItem;
|
||||
}) => {
|
||||
@@ -127,15 +117,20 @@ export const CardControls = ({
|
||||
const handlePlay = (e: MouseEvent<HTMLButtonElement>, playType?: Play) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
handlePlayQueueAdd({
|
||||
handlePlayQueueAdd?.({
|
||||
byItemType: {
|
||||
id: itemData.id,
|
||||
id: [itemData.id],
|
||||
type: itemType,
|
||||
},
|
||||
play: playType || playButtonBehavior,
|
||||
});
|
||||
};
|
||||
|
||||
const handleContextMenu = useHandleGeneralContextMenu(
|
||||
itemType,
|
||||
itemType === LibraryItem.ALBUM ? ALBUM_CONTEXT_MENU_ITEMS : ARTIST_CONTEXT_MENU_ITEMS,
|
||||
);
|
||||
|
||||
return (
|
||||
<GridCardControlsContainer>
|
||||
<BottomControls>
|
||||
@@ -160,39 +155,21 @@ export const CardControls = ({
|
||||
)}
|
||||
</FavoriteWrapper>
|
||||
</SecondaryButton>
|
||||
<DropdownMenu
|
||||
withinPortal
|
||||
position="bottom-start"
|
||||
<SecondaryButton
|
||||
p={5}
|
||||
sx={{ svg: { fill: 'white !important' } }}
|
||||
variant="subtle"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
handleContextMenu(e, [itemData]);
|
||||
}}
|
||||
>
|
||||
<DropdownMenu.Target>
|
||||
<SecondaryButton
|
||||
p={5}
|
||||
sx={{ svg: { fill: 'white !important' } }}
|
||||
variant="subtle"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}}
|
||||
>
|
||||
<RiMore2Fill
|
||||
color="white"
|
||||
size={20}
|
||||
/>
|
||||
</SecondaryButton>
|
||||
</DropdownMenu.Target>
|
||||
<DropdownMenu.Dropdown>
|
||||
{PLAY_TYPES.filter((type) => type.play !== playButtonBehavior).map((type) => (
|
||||
<DropdownMenu.Item
|
||||
key={`playtype-${type.play}`}
|
||||
onClick={(e: MouseEvent<HTMLButtonElement>) => handlePlay(e, type.play)}
|
||||
>
|
||||
{type.label}
|
||||
</DropdownMenu.Item>
|
||||
))}
|
||||
<DropdownMenu.Item disabled>Add to playlist</DropdownMenu.Item>
|
||||
<DropdownMenu.Item disabled>Refresh metadata</DropdownMenu.Item>
|
||||
</DropdownMenu.Dropdown>
|
||||
</DropdownMenu>
|
||||
<RiMore2Fill
|
||||
color="white"
|
||||
size={20}
|
||||
/>
|
||||
</SecondaryButton>
|
||||
</Group>
|
||||
</BottomControls>
|
||||
</GridCardControlsContainer>
|
||||
|
||||
@@ -39,6 +39,7 @@ export const CardRows = ({ data, rows }: CardRowsProps) => {
|
||||
{itemIndex > 0 && (
|
||||
<Text
|
||||
$noSelect
|
||||
$secondary
|
||||
sx={{
|
||||
display: 'inline-block',
|
||||
padding: '0 2px 0 1px',
|
||||
@@ -59,7 +60,7 @@ export const CardRows = ({ data, rows }: CardRowsProps) => {
|
||||
row.route!.slugs?.reduce((acc, slug) => {
|
||||
return {
|
||||
...acc,
|
||||
[slug.slugProperty]: data[slug.idProperty],
|
||||
[slug.slugProperty]: data[row.property][itemIndex][slug.idProperty],
|
||||
};
|
||||
}, {}),
|
||||
)}
|
||||
@@ -134,7 +135,7 @@ export const ALBUM_CARD_ROWS: { [key: string]: CardRow<Album> } = {
|
||||
arrayProperty: 'name',
|
||||
property: 'albumArtists',
|
||||
route: {
|
||||
route: AppRoute.LIBRARY_ALBUMARTISTS_DETAIL,
|
||||
route: AppRoute.LIBRARY_ALBUM_ARTISTS_DETAIL,
|
||||
slugs: [{ idProperty: 'id', slugProperty: 'albumArtistId' }],
|
||||
},
|
||||
},
|
||||
@@ -142,7 +143,7 @@ export const ALBUM_CARD_ROWS: { [key: string]: CardRow<Album> } = {
|
||||
arrayProperty: 'name',
|
||||
property: 'artists',
|
||||
route: {
|
||||
route: AppRoute.LIBRARY_ALBUMARTISTS_DETAIL,
|
||||
route: AppRoute.LIBRARY_ALBUM_ARTISTS_DETAIL,
|
||||
slugs: [{ idProperty: 'id', slugProperty: 'albumArtistId' }],
|
||||
},
|
||||
},
|
||||
@@ -166,7 +167,7 @@ export const ALBUM_CARD_ROWS: { [key: string]: CardRow<Album> } = {
|
||||
property: 'playCount',
|
||||
},
|
||||
rating: {
|
||||
property: 'rating',
|
||||
property: 'userRating',
|
||||
},
|
||||
releaseDate: {
|
||||
property: 'releaseDate',
|
||||
@@ -178,3 +179,34 @@ export const ALBUM_CARD_ROWS: { [key: string]: CardRow<Album> } = {
|
||||
property: 'songCount',
|
||||
},
|
||||
};
|
||||
|
||||
export const ALBUMARTIST_CARD_ROWS: { [key: string]: CardRow<AlbumArtist> } = {
|
||||
albumCount: {
|
||||
property: 'albumCount',
|
||||
},
|
||||
duration: {
|
||||
property: 'duration',
|
||||
},
|
||||
genres: {
|
||||
property: 'genres',
|
||||
},
|
||||
lastPlayedAt: {
|
||||
property: 'lastPlayedAt',
|
||||
},
|
||||
name: {
|
||||
property: 'name',
|
||||
route: {
|
||||
route: AppRoute.LIBRARY_ALBUM_ARTISTS_DETAIL,
|
||||
slugs: [{ idProperty: 'id', slugProperty: 'albumArtistId' }],
|
||||
},
|
||||
},
|
||||
playCount: {
|
||||
property: 'playCount',
|
||||
},
|
||||
rating: {
|
||||
property: 'userRating',
|
||||
},
|
||||
songCount: {
|
||||
property: 'songCount',
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { forwardRef, ReactNode, Ref } from 'react';
|
||||
import { Portal } from '@mantine/core';
|
||||
import { motion } from 'framer-motion';
|
||||
import { Box, Group, UnstyledButton, UnstyledButtonProps } from '@mantine/core';
|
||||
import { motion, Variants } from 'framer-motion';
|
||||
import styled from 'styled-components';
|
||||
import { _Button } from '/@/renderer/components/button';
|
||||
|
||||
interface ContextMenuProps {
|
||||
children: ReactNode;
|
||||
@@ -19,40 +18,105 @@ const ContextMenuContainer = styled(motion.div)<Omit<ContextMenuProps, 'children
|
||||
z-index: 1000;
|
||||
min-width: ${({ minWidth }) => minWidth}px;
|
||||
max-width: ${({ maxWidth }) => maxWidth}px;
|
||||
padding: 0.5rem;
|
||||
background: var(--dropdown-menu-bg);
|
||||
border-radius: 5px;
|
||||
border-radius: 10px;
|
||||
box-shadow: 2px 2px 10px 2px rgba(0, 0, 0, 40%);
|
||||
|
||||
button:first-child {
|
||||
border-top-left-radius: 10px;
|
||||
border-top-right-radius: 10px;
|
||||
}
|
||||
`;
|
||||
|
||||
export const ContextMenuButton = styled(_Button)`
|
||||
padding: 0.5rem;
|
||||
export const StyledContextMenuButton = styled(UnstyledButton)`
|
||||
padding: var(--dropdown-menu-item-padding);
|
||||
color: var(--dropdown-menu-fg);
|
||||
font-weight: 500;
|
||||
font-family: var(--content-font-family);
|
||||
text-align: left;
|
||||
background: var(--dropdown-menu-bg);
|
||||
border: none;
|
||||
cursor: default;
|
||||
|
||||
& .mantine-Button-inner {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: var(--dropdown-menu-bg-hover);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
background: transparent;
|
||||
opacity: 0.6;
|
||||
}
|
||||
`;
|
||||
|
||||
export const ContextMenuButton = forwardRef(
|
||||
(
|
||||
{
|
||||
children,
|
||||
rightIcon,
|
||||
leftIcon,
|
||||
...props
|
||||
}: UnstyledButtonProps &
|
||||
React.ComponentPropsWithoutRef<'button'> & {
|
||||
leftIcon?: ReactNode;
|
||||
rightIcon?: ReactNode;
|
||||
},
|
||||
ref: any,
|
||||
) => {
|
||||
return (
|
||||
<StyledContextMenuButton
|
||||
{...props}
|
||||
key={props.key}
|
||||
ref={ref}
|
||||
as="button"
|
||||
disabled={props.disabled}
|
||||
onClick={props.onClick}
|
||||
>
|
||||
<Group position="apart">
|
||||
<Group spacing="md">
|
||||
<Box>{leftIcon}</Box>
|
||||
<Box mr="2rem">{children}</Box>
|
||||
</Group>
|
||||
<Box>{rightIcon}</Box>
|
||||
</Group>
|
||||
</StyledContextMenuButton>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
const variants: Variants = {
|
||||
closed: {
|
||||
opacity: 0,
|
||||
transition: {
|
||||
duration: 0.1,
|
||||
},
|
||||
},
|
||||
open: {
|
||||
opacity: 1,
|
||||
transition: {
|
||||
duration: 0.1,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const ContextMenu = forwardRef(
|
||||
({ yPos, xPos, minWidth, maxWidth, children }: ContextMenuProps, ref: Ref<HTMLDivElement>) => {
|
||||
return (
|
||||
<Portal>
|
||||
<ContextMenuContainer
|
||||
ref={ref}
|
||||
maxWidth={maxWidth}
|
||||
minWidth={minWidth}
|
||||
xPos={xPos}
|
||||
yPos={yPos}
|
||||
>
|
||||
{children}
|
||||
</ContextMenuContainer>
|
||||
</Portal>
|
||||
<ContextMenuContainer
|
||||
ref={ref}
|
||||
animate="open"
|
||||
initial="closed"
|
||||
maxWidth={maxWidth}
|
||||
minWidth={minWidth}
|
||||
variants={variants}
|
||||
xPos={xPos}
|
||||
yPos={yPos}
|
||||
>
|
||||
{children}
|
||||
</ContextMenuContainer>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -6,12 +6,12 @@ import type {
|
||||
MenuDropdownProps as MantineMenuDropdownProps,
|
||||
} from '@mantine/core';
|
||||
import { Menu as MantineMenu, createPolymorphicComponent } from '@mantine/core';
|
||||
import { RiArrowLeftSFill } from 'react-icons/ri';
|
||||
import styled from 'styled-components';
|
||||
|
||||
type MenuProps = MantineMenuProps;
|
||||
type MenuLabelProps = MantineMenuLabelProps;
|
||||
interface MenuItemProps extends MantineMenuItemProps {
|
||||
$danger?: boolean;
|
||||
$isActive?: boolean;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
@@ -21,17 +21,32 @@ type MenuDropdownProps = MantineMenuDropdownProps;
|
||||
const StyledMenu = styled(MantineMenu)<MenuProps>``;
|
||||
|
||||
const StyledMenuLabel = styled(MantineMenu.Label)<MenuLabelProps>`
|
||||
padding: 0.5rem;
|
||||
font-family: var(--content-font-family);
|
||||
`;
|
||||
|
||||
const StyledMenuItem = styled(MantineMenu.Item)<MenuItemProps>`
|
||||
padding: 0.8rem;
|
||||
font-size: 0.9em;
|
||||
position: relative;
|
||||
padding: var(--dropdown-menu-item-padding);
|
||||
font-size: var(--dropdown-menu-item-font-size);
|
||||
font-family: var(--content-font-family);
|
||||
background-color: ${({ $isActive }) => {
|
||||
if (!$isActive) return undefined;
|
||||
return 'var(--dropdown-menu-bg-hover)';
|
||||
}};
|
||||
|
||||
${(props) =>
|
||||
props.$isActive &&
|
||||
`
|
||||
&::before {
|
||||
content: ''; // ::before and ::after both require content
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: var(--dropdown-menu-bg-hover);
|
||||
opacity: 0.5;
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
`}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.6;
|
||||
@@ -41,44 +56,48 @@ const StyledMenuItem = styled(MantineMenu.Item)<MenuItemProps>`
|
||||
background-color: var(--dropdown-menu-bg-hover);
|
||||
}
|
||||
|
||||
& .mantine-Menu-itemIcon {
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
|
||||
& .mantine-Menu-itemLabel {
|
||||
color: var(--dropdown-menu-fg);
|
||||
font-weight: 500;
|
||||
font-size: 1em;
|
||||
margin-right: 2rem;
|
||||
margin-left: 1rem;
|
||||
color: ${(props) => (props.$danger ? 'var(--danger-color)' : 'var(--dropdown-menu-fg)')};
|
||||
}
|
||||
|
||||
& .mantine-Menu-itemRightSection {
|
||||
display: flex;
|
||||
margin-left: 2rem !important;
|
||||
}
|
||||
cursor: default;
|
||||
`;
|
||||
|
||||
const StyledMenuDropdown = styled(MantineMenu.Dropdown)`
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background: var(--dropdown-menu-bg);
|
||||
border: var(--dropdown-menu-border);
|
||||
border-radius: var(--dropdown-menu-border-radius);
|
||||
filter: drop-shadow(0 0 5px rgb(0, 0, 0, 50%));
|
||||
|
||||
*:first-child {
|
||||
border-top-left-radius: var(--dropdown-menu-border-radius);
|
||||
border-top-right-radius: var(--dropdown-menu-border-radius);
|
||||
}
|
||||
|
||||
*:last-child {
|
||||
border-bottom-right-radius: var(--dropdown-menu-border-radius);
|
||||
border-bottom-left-radius: var(--dropdown-menu-border-radius);
|
||||
}
|
||||
`;
|
||||
|
||||
const StyledMenuDivider = styled(MantineMenu.Divider)`
|
||||
margin: 0.3rem 0;
|
||||
/* margin: 0.3rem 0; */
|
||||
`;
|
||||
|
||||
export const DropdownMenu = ({ children, ...props }: MenuProps) => {
|
||||
return (
|
||||
<StyledMenu
|
||||
withinPortal
|
||||
radius="sm"
|
||||
styles={{
|
||||
dropdown: {
|
||||
filter: 'drop-shadow(0 0 5px rgb(0, 0, 0, 50%))',
|
||||
},
|
||||
}}
|
||||
transition="scale-y"
|
||||
transition="fade"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
@@ -90,11 +109,12 @@ const MenuLabel = ({ children, ...props }: MenuLabelProps) => {
|
||||
return <StyledMenuLabel {...props}>{children}</StyledMenuLabel>;
|
||||
};
|
||||
|
||||
const pMenuItem = ({ $isActive, children, ...props }: MenuItemProps) => {
|
||||
const pMenuItem = ({ $isActive, $danger, children, ...props }: MenuItemProps) => {
|
||||
return (
|
||||
<StyledMenuItem
|
||||
$danger={$danger}
|
||||
$isActive={$isActive}
|
||||
rightSection={$isActive && <RiArrowLeftSFill size={20} />}
|
||||
// rightSection={$isActive && <RiArrowLeftSFill size={20} />}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
|
||||
@@ -27,8 +27,7 @@ const Grid = styled.div`
|
||||
grid-auto-columns: 1fr;
|
||||
grid-template-areas: 'image info';
|
||||
grid-template-rows: 1fr;
|
||||
grid-template-columns: 225px 1fr;
|
||||
gap: 0.5rem;
|
||||
grid-template-columns: 200px 1fr;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
height: 100%;
|
||||
@@ -152,16 +151,27 @@ export const FeatureCarousel = ({ data }: FeatureCarouselProps) => {
|
||||
/>
|
||||
</ImageColumn>
|
||||
<InfoColumn>
|
||||
<Stack sx={{ width: '100%' }}>
|
||||
<Stack
|
||||
spacing="md"
|
||||
sx={{ width: '100%' }}
|
||||
>
|
||||
<TitleWrapper>
|
||||
<TextTitle fw="bold">{currentItem?.name}</TextTitle>
|
||||
<TextTitle
|
||||
lh="4rem"
|
||||
lineClamp={2}
|
||||
order={1}
|
||||
sx={{ fontSize: '4rem' }}
|
||||
weight={900}
|
||||
>
|
||||
{currentItem?.name}
|
||||
</TextTitle>
|
||||
</TitleWrapper>
|
||||
<TitleWrapper>
|
||||
{currentItem?.albumArtists.map((artist) => (
|
||||
<TextTitle
|
||||
key={`carousel-artist-${artist.id}`}
|
||||
fw="600"
|
||||
order={3}
|
||||
order={2}
|
||||
weight={600}
|
||||
>
|
||||
{artist.name}
|
||||
</TextTitle>
|
||||
@@ -169,10 +179,15 @@ export const FeatureCarousel = ({ data }: FeatureCarouselProps) => {
|
||||
</TitleWrapper>
|
||||
<Group>
|
||||
{currentItem?.genres?.map((genre) => (
|
||||
<Badge key={`carousel-genre-${genre.id}`}>{genre.name}</Badge>
|
||||
<Badge
|
||||
key={`carousel-genre-${genre.id}`}
|
||||
size="lg"
|
||||
>
|
||||
{genre.name}
|
||||
</Badge>
|
||||
))}
|
||||
<Badge>{currentItem?.releaseYear}</Badge>
|
||||
<Badge>{currentItem?.songCount} tracks</Badge>
|
||||
<Badge size="lg">{currentItem?.releaseYear}</Badge>
|
||||
<Badge size="lg">{currentItem?.songCount} tracks</Badge>
|
||||
</Group>
|
||||
</Stack>
|
||||
</InfoColumn>
|
||||
@@ -187,25 +202,25 @@ export const FeatureCarousel = ({ data }: FeatureCarouselProps) => {
|
||||
</AnimatePresence>
|
||||
<Group
|
||||
spacing="xs"
|
||||
sx={{ bottom: 0, position: 'absolute', right: 0, zIndex: 20 }}
|
||||
sx={{ bottom: '1rem', position: 'absolute', right: 0, zIndex: 20 }}
|
||||
>
|
||||
<Button
|
||||
disabled={itemIndex === 0}
|
||||
px="lg"
|
||||
radius={100}
|
||||
variant="subtle"
|
||||
size="md"
|
||||
variant="default"
|
||||
onClick={handlePrevious}
|
||||
>
|
||||
<RiArrowLeftSLine size={15} />
|
||||
<RiArrowLeftSLine size="2rem" />
|
||||
</Button>
|
||||
<Button
|
||||
disabled={itemIndex === (data?.length || 1) - 1}
|
||||
px="lg"
|
||||
radius={100}
|
||||
variant="subtle"
|
||||
size="md"
|
||||
variant="default"
|
||||
onClick={handleNext}
|
||||
>
|
||||
<RiArrowRightSLine size={15} />
|
||||
<RiArrowRightSLine size="2rem" />
|
||||
</Button>
|
||||
</Group>
|
||||
</Wrapper>
|
||||
|
||||
@@ -6,16 +6,19 @@ import { RiArrowLeftSLine, RiArrowRightSLine } from 'react-icons/ri';
|
||||
import { Button } from '/@/renderer/components/button';
|
||||
import { AppRoute } from '/@/renderer/router/routes';
|
||||
import type { CardRow } from '/@/renderer/types';
|
||||
import { LibraryItem, Play } from '/@/renderer/types';
|
||||
import { Play } from '/@/renderer/types';
|
||||
import styled from 'styled-components';
|
||||
import { AlbumCard } from '/@/renderer/components/card';
|
||||
import { useHandlePlayQueueAdd } from '/@/renderer/features/player/hooks/use-handle-playqueue-add';
|
||||
import { usePlayQueueAdd } from '/@/renderer/features/player/hooks/use-playqueue-add';
|
||||
import { LibraryItem } from '/@/renderer/api/types';
|
||||
import { usePlayButtonBehavior } from '/@/renderer/store/settings.store';
|
||||
|
||||
interface GridCarouselProps {
|
||||
cardRows: CardRow<any>[];
|
||||
children: React.ReactElement;
|
||||
containerWidth: number;
|
||||
data: any[] | undefined;
|
||||
itemType: LibraryItem;
|
||||
loading?: boolean;
|
||||
pagination?: {
|
||||
handleNextPage?: () => void;
|
||||
@@ -77,10 +80,12 @@ const variants: Variants = {
|
||||
};
|
||||
|
||||
const Carousel = ({ data, cardRows }: any) => {
|
||||
const { loading, pagination, gridHeight, imageSize, direction, uniqueId } =
|
||||
const { loading, pagination, gridHeight, imageSize, direction, uniqueId, itemType } =
|
||||
useContext(GridCarouselContext);
|
||||
|
||||
const handlePlayQueueAdd = useHandlePlayQueueAdd();
|
||||
const playButtonBehavior = usePlayButtonBehavior();
|
||||
|
||||
const handlePlayQueueAdd = usePlayQueueAdd();
|
||||
|
||||
return (
|
||||
<Wrapper>
|
||||
@@ -104,9 +109,9 @@ const Carousel = ({ data, cardRows }: any) => {
|
||||
key={`card-${uniqueId}-${index}`}
|
||||
controls={{
|
||||
cardRows,
|
||||
itemType: LibraryItem.ALBUM,
|
||||
playButtonBehavior: Play.NOW,
|
||||
route: {
|
||||
itemType: itemType || LibraryItem.ALBUM,
|
||||
playButtonBehavior: playButtonBehavior || Play.NOW,
|
||||
route: cardRows[0]?.route || {
|
||||
route: AppRoute.LIBRARY_ALBUMS_DETAIL,
|
||||
slugs: [{ idProperty: 'id', slugProperty: 'albumId' }],
|
||||
},
|
||||
@@ -130,6 +135,7 @@ export const GridCarousel = ({
|
||||
children,
|
||||
containerWidth,
|
||||
uniqueId,
|
||||
itemType,
|
||||
}: GridCarouselProps) => {
|
||||
const [direction, setDirection] = useState(0);
|
||||
|
||||
@@ -147,12 +153,13 @@ export const GridCarousel = ({
|
||||
direction,
|
||||
gridHeight,
|
||||
imageSize,
|
||||
itemType,
|
||||
loading,
|
||||
pagination,
|
||||
setDirection,
|
||||
uniqueId,
|
||||
}),
|
||||
[cardRows, data, direction, gridHeight, imageSize, loading, pagination, uniqueId],
|
||||
[cardRows, data, direction, gridHeight, imageSize, itemType, loading, pagination, uniqueId],
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -176,6 +183,7 @@ interface TitleProps {
|
||||
|
||||
const Title = ({ children }: TitleProps) => {
|
||||
const { pagination, setDirection } = useContext(GridCarouselContext);
|
||||
const showPaginationButtons = pagination?.handleNextPage && pagination?.handlePreviousPage;
|
||||
|
||||
const handleNextPage = useCallback(() => {
|
||||
setDirection(1);
|
||||
@@ -190,23 +198,27 @@ const Title = ({ children }: TitleProps) => {
|
||||
return (
|
||||
<Group position="apart">
|
||||
{children}
|
||||
<Group>
|
||||
<Button
|
||||
compact
|
||||
disabled={!pagination?.hasPreviousPage}
|
||||
variant="default"
|
||||
onClick={handlePreviousPage}
|
||||
>
|
||||
<RiArrowLeftSLine size={15} />
|
||||
</Button>
|
||||
<Button
|
||||
compact
|
||||
variant="default"
|
||||
onClick={handleNextPage}
|
||||
>
|
||||
<RiArrowRightSLine size={15} />
|
||||
</Button>
|
||||
</Group>
|
||||
{showPaginationButtons && (
|
||||
<Group spacing="sm">
|
||||
<Button
|
||||
compact
|
||||
disabled={!pagination?.hasPreviousPage}
|
||||
size="md"
|
||||
variant="default"
|
||||
onClick={handlePreviousPage}
|
||||
>
|
||||
<RiArrowLeftSLine size={15} />
|
||||
</Button>
|
||||
<Button
|
||||
compact
|
||||
size="md"
|
||||
variant="default"
|
||||
onClick={handleNextPage}
|
||||
>
|
||||
<RiArrowRightSLine size={15} />
|
||||
</Button>
|
||||
</Group>
|
||||
)}
|
||||
</Group>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { HoverCard as MantineHoverCard, HoverCardProps } from '@mantine/core';
|
||||
|
||||
export const HoverCard = ({ children, ...props }: HoverCardProps) => {
|
||||
return (
|
||||
<MantineHoverCard
|
||||
styles={{
|
||||
dropdown: {
|
||||
background: 'var(--dropdown-menu-bg)',
|
||||
border: 'none',
|
||||
borderRadius: 'var(--dropdown-menu-border-radius)',
|
||||
boxShadow: '2px 2px 10px 2px rgba(0, 0, 0, 40%)',
|
||||
margin: 0,
|
||||
padding: 0,
|
||||
},
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</MantineHoverCard>
|
||||
);
|
||||
};
|
||||
|
||||
HoverCard.Target = MantineHoverCard.Target;
|
||||
HoverCard.Dropdown = MantineHoverCard.Dropdown;
|
||||
@@ -31,3 +31,6 @@ export * from './virtual-grid';
|
||||
export * from './virtual-table';
|
||||
export * from './motion';
|
||||
export * from './context-menu';
|
||||
export * from './query-builder';
|
||||
export * from './rating';
|
||||
export * from './hover-card';
|
||||
|
||||
@@ -83,6 +83,10 @@ const StyledTextInput = styled(MantineTextInput)<TextInputProps>`
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
& [data-disabled='true'] {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
transition: width 0.3s ease-in-out;
|
||||
`;
|
||||
|
||||
@@ -130,6 +134,10 @@ const StyledNumberInput = styled(MantineNumberInput)<NumberInputProps>`
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
& [data-disabled='true'] {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
transition: width 0.3s ease-in-out;
|
||||
`;
|
||||
|
||||
@@ -159,6 +167,10 @@ const StyledPasswordInput = styled(MantinePasswordInput)<PasswordInputProps>`
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
& [data-disabled='true'] {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
transition: width 0.3s ease-in-out;
|
||||
`;
|
||||
|
||||
@@ -188,6 +200,10 @@ const StyledFileInput = styled(MantineFileInput)<FileInputProps>`
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
& [data-disabled='true'] {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
transition: width 0.3s ease-in-out;
|
||||
`;
|
||||
|
||||
@@ -217,6 +233,10 @@ const StyledJsonInput = styled(MantineJsonInput)<JsonInputProps>`
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
& [data-disabled='true'] {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
transition: width 0.3s ease-in-out;
|
||||
`;
|
||||
|
||||
@@ -242,6 +262,10 @@ const StyledTextarea = styled(MantineTextarea)<TextareaProps>`
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
& [data-disabled='true'] {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
transition: width 0.3s ease-in-out;
|
||||
`;
|
||||
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
import React from 'react';
|
||||
import type { ModalProps as MantineModalProps } from '@mantine/core';
|
||||
import { Modal as MantineModal } from '@mantine/core';
|
||||
import type { ContextModalProps } from '@mantine/modals';
|
||||
import React, { ReactNode } from 'react';
|
||||
import {
|
||||
ModalProps as MantineModalProps,
|
||||
Stack,
|
||||
Modal as MantineModal,
|
||||
Flex,
|
||||
Group,
|
||||
} from '@mantine/core';
|
||||
import { closeAllModals, ContextModalProps } from '@mantine/modals';
|
||||
import { Button } from '/@/renderer/components/button';
|
||||
|
||||
export interface ModalProps extends Omit<MantineModalProps, 'onClose'> {
|
||||
children?: React.ReactNode;
|
||||
children?: ReactNode;
|
||||
handlers: {
|
||||
close: () => void;
|
||||
open: () => void;
|
||||
@@ -15,8 +21,6 @@ export interface ModalProps extends Omit<MantineModalProps, 'onClose'> {
|
||||
export const Modal = ({ children, handlers, ...rest }: ModalProps) => {
|
||||
return (
|
||||
<MantineModal
|
||||
overlayBlur={2}
|
||||
overlayOpacity={0.2}
|
||||
{...rest}
|
||||
onClose={handlers.close}
|
||||
>
|
||||
@@ -41,3 +45,55 @@ export const BaseContextModal = ({
|
||||
Modal.defaultProps = {
|
||||
children: undefined,
|
||||
};
|
||||
|
||||
interface ConfirmModalProps {
|
||||
children: ReactNode;
|
||||
disabled?: boolean;
|
||||
labels?: {
|
||||
cancel?: string;
|
||||
confirm?: string;
|
||||
};
|
||||
loading?: boolean;
|
||||
onCancel?: () => void;
|
||||
onConfirm: () => void;
|
||||
}
|
||||
|
||||
export const ConfirmModal = ({
|
||||
loading,
|
||||
disabled,
|
||||
labels,
|
||||
onCancel,
|
||||
onConfirm,
|
||||
children,
|
||||
}: ConfirmModalProps) => {
|
||||
const handleCancel = () => {
|
||||
if (onCancel) {
|
||||
onCancel();
|
||||
} else {
|
||||
closeAllModals();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack>
|
||||
<Flex>{children}</Flex>
|
||||
<Group position="right">
|
||||
<Button
|
||||
data-focus
|
||||
variant="default"
|
||||
onClick={handleCancel}
|
||||
>
|
||||
{labels?.cancel ? labels.cancel : 'Cancel'}
|
||||
</Button>
|
||||
<Button
|
||||
disabled={disabled}
|
||||
loading={loading}
|
||||
variant="filled"
|
||||
onClick={onConfirm}
|
||||
>
|
||||
{labels?.confirm ? labels.confirm : 'Confirm'}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,22 +1,28 @@
|
||||
import { motion } from 'framer-motion';
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { Flex, FlexProps } from '@mantine/core';
|
||||
import { AnimatePresence, motion, Variants } from 'framer-motion';
|
||||
import { useRef } from 'react';
|
||||
import styled from 'styled-components';
|
||||
import { useShouldPadTitlebar } from '/@/renderer/hooks';
|
||||
import { useShouldPadTitlebar, useTheme } from '/@/renderer/hooks';
|
||||
|
||||
const Container = styled(motion.div)<{ $useOpacity?: boolean; height?: string; position?: string }>`
|
||||
position: ${(props) => props.position};
|
||||
z-index: 100;
|
||||
const Container = styled(motion(Flex))<{
|
||||
height?: string;
|
||||
position?: string;
|
||||
}>`
|
||||
position: ${(props) => props.position || 'relative'};
|
||||
z-index: 2000;
|
||||
width: 100%;
|
||||
height: ${(props) => props.height || '60px'};
|
||||
opacity: ${(props) => props.$useOpacity && 'var(--header-opacity)'};
|
||||
transition: opacity 0.3s ease-in-out;
|
||||
height: ${(props) => props.height || '65px'};
|
||||
background: var(--titlebar-bg);
|
||||
`;
|
||||
|
||||
const Header = styled(motion.div)<{ $padRight?: boolean }>`
|
||||
const Header = styled(motion.div)<{ $isHidden?: boolean; $padRight?: boolean }>`
|
||||
position: relative;
|
||||
z-index: 15;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
margin-right: ${(props) => props.$padRight && '170px'};
|
||||
padding: 1rem;
|
||||
margin-right: ${(props) => (props.$padRight ? '140px' : '1rem')};
|
||||
user-select: ${(props) => (props.$isHidden ? 'none' : 'auto')};
|
||||
pointer-events: ${(props) => (props.$isHidden ? 'none' : 'auto')};
|
||||
-webkit-app-region: drag;
|
||||
|
||||
button {
|
||||
@@ -28,63 +34,92 @@ const Header = styled(motion.div)<{ $padRight?: boolean }>`
|
||||
}
|
||||
`;
|
||||
|
||||
// const BackgroundImage = styled.div<{ background: string }>`
|
||||
// position: absolute;
|
||||
// top: 0;
|
||||
// z-index: -1;
|
||||
// width: 100%;
|
||||
// height: 100%;
|
||||
// background: ${(props) => props.background};
|
||||
// `;
|
||||
const BackgroundImage = styled.div<{ background: string }>`
|
||||
position: absolute;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: ${(props) => props.background || 'var(--titlebar-bg)'};
|
||||
`;
|
||||
|
||||
// const BackgroundImageOverlay = styled.div`
|
||||
// position: absolute;
|
||||
// top: 0;
|
||||
// left: 0;
|
||||
// z-index: -1;
|
||||
// width: 100%;
|
||||
// height: 100%;
|
||||
// /* background: linear-gradient(180deg, rgba(25, 26, 28, 0%), var(--main-bg)); */
|
||||
// /* background: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIzMDAiIGhlaWdodD0iMzAwIj48ZmlsdGVyIGlkPSJhIiB4PSIwIiB5PSIwIj48ZmVUdXJidWxlbmNlIHR5cGU9ImZyYWN0YWxOb2lzZSIgYmFzZUZyZXF1ZW5jeT0iLjc1IiBzdGl0Y2hUaWxlcz0ic3RpdGNoIi8+PGZlQ29sb3JNYXRyaXggdHlwZT0ic2F0dXJhdGUiIHZhbHVlcz0iMCIvPjwvZmlsdGVyPjxwYXRoIGZpbHRlcj0idXJsKCNhKSIgb3BhY2l0eT0iLjA1IiBkPSJNMCAwaDMwMHYzMDBIMHoiLz48L3N2Zz4='); */
|
||||
// `;
|
||||
const BackgroundImageOverlay = styled.div<{ theme: 'light' | 'dark' }>`
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
z-index: 10;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: ${(props) =>
|
||||
props.theme === 'light'
|
||||
? 'linear-gradient(rgba(255, 255, 255, 25%), rgba(255, 255, 255, 25%))'
|
||||
: 'linear-gradient(rgba(0, 0, 0, 50%), rgba(0, 0, 0, 50%))'};
|
||||
`;
|
||||
|
||||
interface PageHeaderProps {
|
||||
export interface PageHeaderProps
|
||||
extends Omit<FlexProps, 'onAnimationStart' | 'onDragStart' | 'onDragEnd' | 'onDrag'> {
|
||||
backgroundColor?: string;
|
||||
children?: React.ReactNode;
|
||||
height?: string;
|
||||
isHidden?: boolean;
|
||||
position?: string;
|
||||
useOpacity?: boolean;
|
||||
}
|
||||
|
||||
const TitleWrapper = styled(motion.div)`
|
||||
position: absolute;
|
||||
display: flex;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
`;
|
||||
|
||||
const variants: Variants = {
|
||||
animate: { opacity: 1 },
|
||||
exit: { opacity: 0 },
|
||||
initial: { opacity: 0 },
|
||||
};
|
||||
|
||||
export const PageHeader = ({
|
||||
position,
|
||||
height,
|
||||
backgroundColor,
|
||||
useOpacity,
|
||||
isHidden,
|
||||
children,
|
||||
...props
|
||||
}: PageHeaderProps) => {
|
||||
const ref = useRef(null);
|
||||
const padRight = useShouldPadTitlebar();
|
||||
|
||||
useEffect(() => {
|
||||
const rootElement = document.querySelector(':root') as HTMLElement;
|
||||
rootElement?.style?.setProperty('--header-opacity', '0');
|
||||
}, []);
|
||||
const theme = useTheme();
|
||||
|
||||
return (
|
||||
<Container
|
||||
ref={ref}
|
||||
$useOpacity={useOpacity}
|
||||
animate={{
|
||||
backgroundColor,
|
||||
transition: { duration: 1.5 },
|
||||
}}
|
||||
height={height}
|
||||
position={position}
|
||||
{...props}
|
||||
>
|
||||
<Header $padRight={padRight}>{children}</Header>
|
||||
{/* <BackgroundImage background={backgroundColor} /> */}
|
||||
{/* <BackgroundImageOverlay /> */}
|
||||
<Header
|
||||
$isHidden={isHidden}
|
||||
$padRight={padRight}
|
||||
>
|
||||
<AnimatePresence>
|
||||
{!isHidden && (
|
||||
<TitleWrapper
|
||||
animate="animate"
|
||||
exit="exit"
|
||||
initial="initial"
|
||||
variants={variants}
|
||||
>
|
||||
{children}
|
||||
</TitleWrapper>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</Header>
|
||||
{backgroundColor && (
|
||||
<>
|
||||
<BackgroundImage background={backgroundColor || 'var(--titlebar-bg)'} />
|
||||
<BackgroundImageOverlay theme={theme} />
|
||||
</>
|
||||
)}
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -46,7 +46,6 @@ export const Pagination = ({ $hideDividers, ...props }: PaginationProps) => {
|
||||
<StyledPagination
|
||||
$hideDividers={$hideDividers}
|
||||
radius="xl"
|
||||
size="md"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -27,7 +27,7 @@ export const Popover = ({ children, ...props }: PopoverProps) => {
|
||||
filter: 'drop-shadow(0 0 5px rgb(0, 0, 0, 50%))',
|
||||
},
|
||||
}}
|
||||
transition="scale-y"
|
||||
transition="fade"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
import { Group, Stack } from '@mantine/core';
|
||||
import { Select } from '/@/renderer/components/select';
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
import { RiAddFill, RiAddLine, RiDeleteBinFill, RiMore2Line, RiRestartLine } from 'react-icons/ri';
|
||||
import { Button } from '/@/renderer/components/button';
|
||||
import { DropdownMenu } from '/@/renderer/components/dropdown-menu';
|
||||
import { QueryBuilderOption } from '/@/renderer/components/query-builder/query-builder-option';
|
||||
import { QueryBuilderGroup, QueryBuilderRule } from '/@/renderer/types';
|
||||
|
||||
const FILTER_GROUP_OPTIONS_DATA = [
|
||||
{
|
||||
label: 'Match all',
|
||||
value: 'all',
|
||||
},
|
||||
{
|
||||
label: 'Match any',
|
||||
value: 'any',
|
||||
},
|
||||
];
|
||||
|
||||
type AddArgs = {
|
||||
groupIndex: number[];
|
||||
level: number;
|
||||
};
|
||||
|
||||
type DeleteArgs = {
|
||||
groupIndex: number[];
|
||||
level: number;
|
||||
uniqueId: string;
|
||||
};
|
||||
interface QueryBuilderProps {
|
||||
data: Record<string, any>;
|
||||
filters: { label: string; type: string; value: string }[];
|
||||
groupIndex: number[];
|
||||
level: number;
|
||||
onAddRule: (args: AddArgs) => void;
|
||||
onAddRuleGroup: (args: AddArgs) => void;
|
||||
onChangeField: (args: any) => void;
|
||||
onChangeOperator: (args: any) => void;
|
||||
onChangeType: (args: any) => void;
|
||||
onChangeValue: (args: any) => void;
|
||||
onClearFilters: () => void;
|
||||
onDeleteRule: (args: DeleteArgs) => void;
|
||||
onDeleteRuleGroup: (args: DeleteArgs) => void;
|
||||
onResetFilters: () => void;
|
||||
operators: {
|
||||
boolean: { label: string; value: string }[];
|
||||
date: { label: string; value: string }[];
|
||||
number: { label: string; value: string }[];
|
||||
string: { label: string; value: string }[];
|
||||
};
|
||||
uniqueId: string;
|
||||
}
|
||||
|
||||
export const QueryBuilder = ({
|
||||
data,
|
||||
level,
|
||||
onAddRule,
|
||||
onDeleteRuleGroup,
|
||||
onDeleteRule,
|
||||
onAddRuleGroup,
|
||||
onChangeType,
|
||||
onChangeField,
|
||||
operators,
|
||||
onChangeOperator,
|
||||
onChangeValue,
|
||||
onClearFilters,
|
||||
onResetFilters,
|
||||
groupIndex,
|
||||
uniqueId,
|
||||
filters,
|
||||
}: QueryBuilderProps) => {
|
||||
const handleAddRule = () => {
|
||||
onAddRule({ groupIndex, level });
|
||||
};
|
||||
|
||||
const handleAddRuleGroup = () => {
|
||||
onAddRuleGroup({ groupIndex, level });
|
||||
};
|
||||
|
||||
const handleDeleteRuleGroup = () => {
|
||||
onDeleteRuleGroup({ groupIndex, level, uniqueId });
|
||||
};
|
||||
|
||||
const handleChangeType = (value: string | null) => {
|
||||
onChangeType({ groupIndex, level, value });
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack
|
||||
ml={`${level * 10}px`}
|
||||
spacing="sm"
|
||||
>
|
||||
<Group spacing="sm">
|
||||
<Select
|
||||
data={FILTER_GROUP_OPTIONS_DATA}
|
||||
maxWidth={175}
|
||||
size="sm"
|
||||
value={data.type}
|
||||
width="20%"
|
||||
onChange={handleChangeType}
|
||||
/>
|
||||
<Button
|
||||
px={5}
|
||||
size="sm"
|
||||
tooltip={{ label: 'Add rule' }}
|
||||
variant="default"
|
||||
onClick={handleAddRule}
|
||||
>
|
||||
<RiAddLine size={20} />
|
||||
</Button>
|
||||
<DropdownMenu position="bottom-start">
|
||||
<DropdownMenu.Target>
|
||||
<Button
|
||||
p={0}
|
||||
size="sm"
|
||||
variant="subtle"
|
||||
>
|
||||
<RiMore2Line size={20} />
|
||||
</Button>
|
||||
</DropdownMenu.Target>
|
||||
<DropdownMenu.Dropdown>
|
||||
<DropdownMenu.Item
|
||||
icon={<RiAddFill />}
|
||||
onClick={handleAddRuleGroup}
|
||||
>
|
||||
Add rule group
|
||||
</DropdownMenu.Item>
|
||||
|
||||
{level > 0 && (
|
||||
<DropdownMenu.Item
|
||||
icon={<RiDeleteBinFill />}
|
||||
onClick={handleDeleteRuleGroup}
|
||||
>
|
||||
Remove rule group
|
||||
</DropdownMenu.Item>
|
||||
)}
|
||||
{level === 0 && (
|
||||
<>
|
||||
<DropdownMenu.Divider />
|
||||
<DropdownMenu.Item
|
||||
$danger
|
||||
icon={<RiRestartLine color="var(--danger-color)" />}
|
||||
onClick={onResetFilters}
|
||||
>
|
||||
Reset to default
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item
|
||||
$danger
|
||||
icon={<RiDeleteBinFill color="var(--danger-color)" />}
|
||||
onClick={onClearFilters}
|
||||
>
|
||||
Clear filters
|
||||
</DropdownMenu.Item>
|
||||
</>
|
||||
)}
|
||||
</DropdownMenu.Dropdown>
|
||||
</DropdownMenu>
|
||||
</Group>
|
||||
<AnimatePresence initial={false}>
|
||||
{data?.rules?.map((rule: QueryBuilderRule) => (
|
||||
<motion.div
|
||||
key={rule.uniqueId}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
exit={{ opacity: 0, x: -25 }}
|
||||
initial={{ opacity: 0, x: -25 }}
|
||||
transition={{ duration: 0.2, ease: 'easeInOut' }}
|
||||
>
|
||||
<QueryBuilderOption
|
||||
data={rule}
|
||||
filters={filters}
|
||||
groupIndex={groupIndex || []}
|
||||
level={level}
|
||||
noRemove={data?.rules?.length === 1}
|
||||
operators={operators}
|
||||
onChangeField={onChangeField}
|
||||
onChangeOperator={onChangeOperator}
|
||||
onChangeValue={onChangeValue}
|
||||
onDeleteRule={onDeleteRule}
|
||||
/>
|
||||
</motion.div>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
{data?.group && (
|
||||
<AnimatePresence initial={false}>
|
||||
{data.group?.map((group: QueryBuilderGroup, index: number) => (
|
||||
<motion.div
|
||||
key={group.uniqueId}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
exit={{ opacity: 0, x: -25 }}
|
||||
initial={{ opacity: 0, x: -25 }}
|
||||
transition={{ duration: 0.2, ease: 'easeInOut' }}
|
||||
>
|
||||
<QueryBuilder
|
||||
data={group}
|
||||
filters={filters}
|
||||
groupIndex={[...(groupIndex || []), index]}
|
||||
level={level + 1}
|
||||
operators={operators}
|
||||
uniqueId={group.uniqueId}
|
||||
onAddRule={onAddRule}
|
||||
onAddRuleGroup={onAddRuleGroup}
|
||||
onChangeField={onChangeField}
|
||||
onChangeOperator={onChangeOperator}
|
||||
onChangeType={onChangeType}
|
||||
onChangeValue={onChangeValue}
|
||||
onClearFilters={onClearFilters}
|
||||
onDeleteRule={onDeleteRule}
|
||||
onDeleteRuleGroup={onDeleteRuleGroup}
|
||||
onResetFilters={onResetFilters}
|
||||
/>
|
||||
</motion.div>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,239 @@
|
||||
import { Group } from '@mantine/core';
|
||||
import { useState } from 'react';
|
||||
import { RiSubtractLine } from 'react-icons/ri';
|
||||
import { Button } from '/@/renderer/components/button';
|
||||
import { NumberInput, TextInput } from '/@/renderer/components/input';
|
||||
import { Select } from '/@/renderer/components/select';
|
||||
import { QueryBuilderRule } from '/@/renderer/types';
|
||||
|
||||
type DeleteArgs = {
|
||||
groupIndex: number[];
|
||||
level: number;
|
||||
uniqueId: string;
|
||||
};
|
||||
|
||||
interface QueryOptionProps {
|
||||
data: QueryBuilderRule;
|
||||
filters: { label: string; type: string; value: string }[];
|
||||
groupIndex: number[];
|
||||
level: number;
|
||||
noRemove: boolean;
|
||||
onChangeField: (args: any) => void;
|
||||
onChangeOperator: (args: any) => void;
|
||||
onChangeValue: (args: any) => void;
|
||||
onDeleteRule: (args: DeleteArgs) => void;
|
||||
operators: {
|
||||
boolean: { label: string; value: string }[];
|
||||
date: { label: string; value: string }[];
|
||||
number: { label: string; value: string }[];
|
||||
string: { label: string; value: string }[];
|
||||
};
|
||||
}
|
||||
|
||||
const QueryValueInput = ({ onChange, type, ...props }: any) => {
|
||||
const [numberRange, setNumberRange] = useState([0, 0]);
|
||||
|
||||
switch (type) {
|
||||
case 'string':
|
||||
return (
|
||||
<TextInput
|
||||
size="sm"
|
||||
onChange={onChange}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
case 'number':
|
||||
return (
|
||||
<NumberInput
|
||||
size="sm"
|
||||
onChange={onChange}
|
||||
{...props}
|
||||
defaultValue={props.defaultValue && Number(props.defaultValue)}
|
||||
/>
|
||||
);
|
||||
case 'date':
|
||||
return (
|
||||
<TextInput
|
||||
size="sm"
|
||||
onChange={onChange}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
case 'dateRange':
|
||||
return (
|
||||
<>
|
||||
<NumberInput
|
||||
{...props}
|
||||
defaultValue={props.defaultValue && Number(props.defaultValue?.[0])}
|
||||
maxWidth={81}
|
||||
width="10%"
|
||||
onChange={(e) => {
|
||||
const newRange = [e || 0, numberRange[1]];
|
||||
setNumberRange(newRange);
|
||||
onChange(newRange);
|
||||
}}
|
||||
/>
|
||||
<NumberInput
|
||||
{...props}
|
||||
defaultValue={props.defaultValue && Number(props.defaultValue?.[1])}
|
||||
maxWidth={81}
|
||||
width="10%"
|
||||
onChange={(e) => {
|
||||
const newRange = [numberRange[0], e || 0];
|
||||
setNumberRange(newRange);
|
||||
onChange(newRange);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
case 'boolean':
|
||||
return (
|
||||
<Select
|
||||
data={[
|
||||
{ label: 'true', value: 'true' },
|
||||
{ label: 'false', value: 'false' },
|
||||
]}
|
||||
onChange={onChange}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
default:
|
||||
return <></>;
|
||||
}
|
||||
};
|
||||
|
||||
export const QueryBuilderOption = ({
|
||||
data,
|
||||
filters,
|
||||
level,
|
||||
onDeleteRule,
|
||||
operators,
|
||||
groupIndex,
|
||||
noRemove,
|
||||
onChangeField,
|
||||
onChangeOperator,
|
||||
onChangeValue,
|
||||
}: QueryOptionProps) => {
|
||||
const { field, operator, uniqueId, value } = data;
|
||||
|
||||
const handleDeleteRule = () => {
|
||||
onDeleteRule({ groupIndex, level, uniqueId });
|
||||
};
|
||||
|
||||
const handleChangeField = (e: any) => {
|
||||
onChangeField({ groupIndex, level, uniqueId, value: e });
|
||||
};
|
||||
|
||||
const handleChangeOperator = (e: any) => {
|
||||
onChangeOperator({ groupIndex, level, uniqueId, value: e });
|
||||
};
|
||||
|
||||
const handleChangeValue = (e: any) => {
|
||||
const isDirectValue =
|
||||
typeof e === 'string' ||
|
||||
typeof e === 'number' ||
|
||||
typeof e === 'undefined' ||
|
||||
typeof e === null;
|
||||
|
||||
if (isDirectValue) {
|
||||
return onChangeValue({
|
||||
groupIndex,
|
||||
level,
|
||||
uniqueId,
|
||||
value: e,
|
||||
});
|
||||
}
|
||||
|
||||
// const isDate = e instanceof Date;
|
||||
|
||||
// if (isDate) {
|
||||
// return onChangeValue({
|
||||
// groupIndex,
|
||||
// level,
|
||||
// uniqueId,
|
||||
// value: dayjs(e).format('YYYY-MM-DD'),
|
||||
// });
|
||||
// }
|
||||
|
||||
const isArray = Array.isArray(e);
|
||||
|
||||
if (isArray) {
|
||||
return onChangeValue({
|
||||
groupIndex,
|
||||
level,
|
||||
uniqueId,
|
||||
value: e,
|
||||
});
|
||||
}
|
||||
|
||||
return onChangeValue({
|
||||
groupIndex,
|
||||
level,
|
||||
uniqueId,
|
||||
value: e.currentTarget.value,
|
||||
});
|
||||
};
|
||||
|
||||
const fieldType = filters.find((f) => f.value === field)?.type;
|
||||
const operatorsByFieldType = operators[fieldType as keyof typeof operators];
|
||||
const ml = (level + 1) * 10;
|
||||
|
||||
return (
|
||||
<Group
|
||||
ml={ml}
|
||||
spacing="sm"
|
||||
>
|
||||
<Select
|
||||
searchable
|
||||
data={filters}
|
||||
maxWidth={170}
|
||||
size="sm"
|
||||
value={field}
|
||||
width="25%"
|
||||
onChange={handleChangeField}
|
||||
/>
|
||||
<Select
|
||||
searchable
|
||||
data={operatorsByFieldType || []}
|
||||
disabled={!field}
|
||||
maxWidth={170}
|
||||
size="sm"
|
||||
value={operator}
|
||||
width="25%"
|
||||
onChange={handleChangeOperator}
|
||||
/>
|
||||
{field ? (
|
||||
<QueryValueInput
|
||||
defaultValue={value}
|
||||
maxWidth={170}
|
||||
size="sm"
|
||||
type={operator === 'inTheRange' ? 'dateRange' : fieldType}
|
||||
width="25%"
|
||||
onChange={handleChangeValue}
|
||||
/>
|
||||
) : (
|
||||
<TextInput
|
||||
disabled
|
||||
defaultValue={value}
|
||||
maxWidth={170}
|
||||
size="sm"
|
||||
width="25%"
|
||||
onChange={handleChangeValue}
|
||||
/>
|
||||
)}
|
||||
<Button
|
||||
disabled={noRemove}
|
||||
px={5}
|
||||
size="sm"
|
||||
tooltip={{ label: 'Remove rule' }}
|
||||
variant="default"
|
||||
onClick={handleDeleteRule}
|
||||
>
|
||||
<RiSubtractLine size={20} />
|
||||
</Button>
|
||||
</Group>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,33 @@
|
||||
/* eslint-disable jsx-a11y/no-static-element-interactions */
|
||||
import { MouseEvent } from 'react';
|
||||
import { Rating as MantineRating, RatingProps as MantineRatingProps } from '@mantine/core';
|
||||
import styled from 'styled-components';
|
||||
import { Tooltip } from '/@/renderer/components/tooltip';
|
||||
|
||||
interface RatingProps extends Omit<MantineRatingProps, 'onClick'> {
|
||||
onClick: (e: MouseEvent<HTMLDivElement>, value: number | undefined) => void;
|
||||
}
|
||||
|
||||
const StyledRating = styled(MantineRating)`
|
||||
& .mantine-Rating-symbolBody {
|
||||
svg {
|
||||
stroke: var(--main-fg-secondary);
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const Rating = ({ onClick, ...props }: RatingProps) => {
|
||||
// const debouncedOnClick = debounce(onClick, 100);
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
label="Double click to clear"
|
||||
openDelay={1000}
|
||||
>
|
||||
<StyledRating
|
||||
{...props}
|
||||
onDoubleClick={(e) => onClick(e, props.value)}
|
||||
/>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
@@ -1,6 +1,10 @@
|
||||
import { forwardRef, Ref, useEffect, useRef, useState } from 'react';
|
||||
import type { ScrollAreaProps as MantineScrollAreaProps } from '@mantine/core';
|
||||
import { ScrollArea as MantineScrollArea } from '@mantine/core';
|
||||
import { useMergedRef, useTimeout } from '@mantine/hooks';
|
||||
import { motion, useScroll } from 'framer-motion';
|
||||
import styled from 'styled-components';
|
||||
import { PageHeader, PageHeaderProps } from '/@/renderer/components/page-header';
|
||||
|
||||
interface ScrollAreaProps extends MantineScrollAreaProps {
|
||||
children: React.ReactNode;
|
||||
@@ -13,19 +17,147 @@ const StyledScrollArea = styled(MantineScrollArea)`
|
||||
}
|
||||
|
||||
& .mantine-ScrollArea-scrollbar {
|
||||
width: 12px;
|
||||
padding: 0;
|
||||
background: var(--scrollbar-track-bg);
|
||||
}
|
||||
|
||||
& .mantine-ScrollArea-viewport > div {
|
||||
display: block !important;
|
||||
}
|
||||
`;
|
||||
|
||||
export const ScrollArea = ({ children, ...props }: ScrollAreaProps) => {
|
||||
const StyledNativeScrollArea = styled.div<{ scrollBarOffset?: string }>`
|
||||
height: 100%;
|
||||
overflow-y: overlay !important;
|
||||
|
||||
&::-webkit-scrollbar-track {
|
||||
margin-top: ${(props) => props.scrollBarOffset || '65px'};
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
margin-top: ${(props) => props.scrollBarOffset || '65px'};
|
||||
}
|
||||
`;
|
||||
|
||||
export const ScrollArea = forwardRef(({ children, ...props }: ScrollAreaProps, ref: Ref<any>) => {
|
||||
return (
|
||||
<StyledScrollArea
|
||||
offsetScrollbars
|
||||
ref={ref}
|
||||
scrollbarSize={12}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</StyledScrollArea>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
||||
interface NativeScrollAreaProps {
|
||||
children: React.ReactNode;
|
||||
debugScrollPosition?: boolean;
|
||||
noHeader?: boolean;
|
||||
pageHeaderProps?: PageHeaderProps & { offset?: any; target?: any };
|
||||
scrollBarOffset?: string;
|
||||
scrollHideDelay?: number;
|
||||
style?: React.CSSProperties;
|
||||
}
|
||||
|
||||
export const NativeScrollArea = forwardRef(
|
||||
(
|
||||
{
|
||||
children,
|
||||
pageHeaderProps,
|
||||
debugScrollPosition,
|
||||
scrollBarOffset,
|
||||
scrollHideDelay,
|
||||
noHeader,
|
||||
...props
|
||||
}: NativeScrollAreaProps,
|
||||
ref: Ref<HTMLDivElement>,
|
||||
) => {
|
||||
const [hideScrollbar, setHideScrollbar] = useState(false);
|
||||
const [hideHeader, setHideHeader] = useState(true);
|
||||
const { start, clear } = useTimeout(
|
||||
() => setHideScrollbar(true),
|
||||
scrollHideDelay !== undefined ? scrollHideDelay * 1000 : 0,
|
||||
);
|
||||
|
||||
const containerRef = useRef(null);
|
||||
const mergedRef = useMergedRef(ref, containerRef);
|
||||
|
||||
const { scrollYProgress } = useScroll({
|
||||
container: containerRef,
|
||||
offset: pageHeaderProps?.offset || ['center start', 'end start'],
|
||||
target: pageHeaderProps?.target,
|
||||
});
|
||||
|
||||
// Automatically hide the scrollbar after the timeout duration
|
||||
useEffect(() => {
|
||||
start();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const setHeaderVisibility = (v: number) => {
|
||||
if (v === 1) {
|
||||
return setHideHeader(false);
|
||||
}
|
||||
|
||||
if (hideHeader === false) {
|
||||
return setHideHeader(true);
|
||||
}
|
||||
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const unsubscribe = scrollYProgress.on('change', setHeaderVisibility);
|
||||
|
||||
return () => {
|
||||
unsubscribe();
|
||||
};
|
||||
}, [hideHeader, scrollYProgress]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{!noHeader && (
|
||||
<PageHeader
|
||||
isHidden={hideHeader}
|
||||
position="absolute"
|
||||
style={{ opacity: scrollYProgress as any }}
|
||||
{...pageHeaderProps}
|
||||
/>
|
||||
)}
|
||||
<StyledNativeScrollArea
|
||||
ref={mergedRef}
|
||||
className={hideScrollbar ? 'hide-scrollbar' : undefined}
|
||||
scrollBarOffset={scrollBarOffset}
|
||||
onMouseEnter={() => {
|
||||
setHideScrollbar(false);
|
||||
clear();
|
||||
}}
|
||||
onMouseLeave={() => {
|
||||
start();
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</StyledNativeScrollArea>
|
||||
{debugScrollPosition && (
|
||||
<motion.div
|
||||
style={{
|
||||
background: 'red',
|
||||
height: '10px',
|
||||
left: 0,
|
||||
position: 'fixed',
|
||||
right: 0,
|
||||
scaleX: scrollYProgress,
|
||||
top: 0,
|
||||
transformOrigin: '0%',
|
||||
width: '100%',
|
||||
zIndex: 5000,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -43,9 +43,10 @@ export const SearchInput = ({
|
||||
<TextInput
|
||||
ref={mergedRef}
|
||||
{...props}
|
||||
icon={showIcon && <RiSearchLine size={15} />}
|
||||
icon={showIcon && <RiSearchLine />}
|
||||
size="md"
|
||||
styles={{
|
||||
icon: { svg: { fill: 'var(--btn-default-fg)' } },
|
||||
icon: { svg: { fill: 'var(--titlebar-fg)' } },
|
||||
input: {
|
||||
backgroundColor: isOpened ? 'inherit' : 'transparent !important',
|
||||
border: 'none !important',
|
||||
|
||||
@@ -17,6 +17,10 @@ const StyledSegmentedControl = styled(MantineSegmentedControl)<MantineSegmentedC
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
& [data-disabled='true'] {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
& .mantine-SegmentedControl-active {
|
||||
color: var(--input-active-fg);
|
||||
background-color: var(--input-active-bg);
|
||||
|
||||
@@ -20,7 +20,7 @@ const StyledSelect = styled(MantineSelect)`
|
||||
background: var(--input-bg);
|
||||
}
|
||||
|
||||
& .mantine-Select-disabled {
|
||||
& [data-disabled='true'] {
|
||||
background: var(--input-bg);
|
||||
opacity: 0.6;
|
||||
}
|
||||
@@ -64,7 +64,7 @@ export const Select = ({ width, maxWidth, ...props }: SelectProps) => {
|
||||
},
|
||||
}}
|
||||
sx={{ maxWidth, width }}
|
||||
transition="pop"
|
||||
transition="fade"
|
||||
transitionDuration={100}
|
||||
{...props}
|
||||
/>
|
||||
@@ -76,8 +76,8 @@ const StyledMultiSelect = styled(MantineMultiSelect)`
|
||||
background: var(--input-select-bg);
|
||||
}
|
||||
|
||||
& .mantine-MultiSelect-disabled {
|
||||
background: var(--input-select-bg);
|
||||
& [data-disabled='true'] {
|
||||
background: var(--input-bg);
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
@@ -126,7 +126,7 @@ export const MultiSelect = ({ width, maxWidth, ...props }: MultiSelectProps) =>
|
||||
},
|
||||
}}
|
||||
sx={{ maxWidth, width }}
|
||||
transition="pop"
|
||||
transition="fade"
|
||||
transitionDuration={100}
|
||||
{...props}
|
||||
/>
|
||||
|
||||
@@ -10,6 +10,10 @@ const StyledSlider = styled(MantineSlider)`
|
||||
background-color: var(--slider-track-bg);
|
||||
}
|
||||
|
||||
& .mantine-Slider-bar {
|
||||
background-color: var(--primary-color);
|
||||
}
|
||||
|
||||
& .mantine-Slider-thumb {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
|
||||
@@ -22,6 +22,7 @@ const StyledTabs = styled(MantineTabs)`
|
||||
button {
|
||||
padding: 1rem;
|
||||
color: var(--btn-subtle-fg);
|
||||
border-radius: 0;
|
||||
|
||||
&:hover {
|
||||
color: var(--btn-subtle-fg-hover);
|
||||
@@ -32,15 +33,23 @@ const StyledTabs = styled(MantineTabs)`
|
||||
}
|
||||
|
||||
button[data-active] {
|
||||
color: var(--btn-primary-fg);
|
||||
background: var(--primary-color);
|
||||
border-color: var(--primary-color);
|
||||
color: var(--btn-subtle-fg);
|
||||
background: none;
|
||||
box-shadow: 2px 0 0 var(--primary-color) inset;
|
||||
|
||||
&:hover {
|
||||
background: var(--btn-primary-bg-hover);
|
||||
border-color: var(--primary-color);
|
||||
background: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* button[data-active]::before {
|
||||
content: '';
|
||||
border-left: 2px solid var(--primary-color);
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
} */
|
||||
`;
|
||||
|
||||
export const Tabs = ({ children, ...props }: TabsProps) => {
|
||||
|
||||
@@ -10,18 +10,19 @@ interface TextTitleProps extends MantineTextTitleDivProps {
|
||||
$link?: boolean;
|
||||
$noSelect?: boolean;
|
||||
$secondary?: boolean;
|
||||
children: ReactNode;
|
||||
children?: ReactNode;
|
||||
overflow?: 'hidden' | 'visible';
|
||||
to?: string;
|
||||
weight?: number;
|
||||
}
|
||||
|
||||
const StyledTextTitle = styled(MantineHeader)<TextTitleProps>`
|
||||
overflow: ${(props) => props.overflow};
|
||||
color: ${(props) => (props.$secondary ? 'var(--main-fg-secondary)' : 'var(--main-fg)')};
|
||||
cursor: ${(props) => props.$link && 'cursor'};
|
||||
transition: color 0.2s ease-in-out;
|
||||
user-select: ${(props) => (props.$noSelect ? 'none' : 'auto')};
|
||||
${(props) => props.overflow === 'hidden' && textEllipsis}
|
||||
${(props) => props.overflow === 'hidden' && !props.lineClamp && textEllipsis}
|
||||
|
||||
&:hover {
|
||||
color: ${(props) => props.$link && 'var(--main-fg)'};
|
||||
|
||||
@@ -11,7 +11,7 @@ interface TextProps extends MantineTextDivProps {
|
||||
$link?: boolean;
|
||||
$noSelect?: boolean;
|
||||
$secondary?: boolean;
|
||||
children: ReactNode;
|
||||
children?: ReactNode;
|
||||
font?: Font;
|
||||
overflow?: 'hidden' | 'visible';
|
||||
to?: string;
|
||||
@@ -19,12 +19,13 @@ interface TextProps extends MantineTextDivProps {
|
||||
}
|
||||
|
||||
const StyledText = styled(MantineText)<TextProps>`
|
||||
overflow: ${(props) => props.overflow};
|
||||
color: ${(props) => (props.$secondary ? 'var(--main-fg-secondary)' : 'var(--main-fg)')};
|
||||
font-family: ${(props) => props.font};
|
||||
cursor: ${(props) => props.$link && 'cursor'};
|
||||
transition: color 0.2s ease-in-out;
|
||||
user-select: ${(props) => (props.$noSelect ? 'none' : 'auto')};
|
||||
${(props) => props.overflow === 'hidden' && textEllipsis}
|
||||
${(props) => props.overflow === 'hidden' && !props.lineClamp && textEllipsis}
|
||||
|
||||
&:hover {
|
||||
color: ${(props) => props.$link && 'var(--main-fg)'};
|
||||
@@ -32,7 +33,7 @@ const StyledText = styled(MantineText)<TextProps>`
|
||||
}
|
||||
`;
|
||||
|
||||
const _Text = ({ children, $secondary, overflow, font, $noSelect, ...rest }: TextProps) => {
|
||||
export const _Text = ({ children, $secondary, overflow, font, $noSelect, ...rest }: TextProps) => {
|
||||
return (
|
||||
<StyledText
|
||||
$noSelect={$noSelect}
|
||||
|
||||
@@ -30,16 +30,19 @@ const showToast = ({ type, ...props }: NotificationProps) => {
|
||||
? 'Error'
|
||||
: 'Info';
|
||||
|
||||
const defaultDuration = type === 'error' ? 3500 : 2000;
|
||||
const defaultDuration = type === 'error' ? 4000 : 2000;
|
||||
|
||||
return showNotification({
|
||||
autoClose: defaultDuration,
|
||||
disallowClose: true,
|
||||
styles: () => ({
|
||||
closeButton: {},
|
||||
closeButton: {
|
||||
'&:hover': {
|
||||
background: 'transparent',
|
||||
},
|
||||
},
|
||||
description: {
|
||||
color: 'var(--toast-description-fg)',
|
||||
fontSize: '.9em',
|
||||
fontSize: '1rem',
|
||||
},
|
||||
loader: {
|
||||
margin: '1rem',
|
||||
@@ -47,10 +50,12 @@ const showToast = ({ type, ...props }: NotificationProps) => {
|
||||
root: {
|
||||
'&::before': { backgroundColor: color },
|
||||
background: 'var(--toast-bg)',
|
||||
border: '2px solid var(--generic-border-color)',
|
||||
bottom: '90px',
|
||||
},
|
||||
title: {
|
||||
color: 'var(--toast-title-fg)',
|
||||
fontSize: '1em',
|
||||
fontSize: '1.3rem',
|
||||
},
|
||||
}),
|
||||
title: defaultTitle,
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
import { Center } from '@mantine/core';
|
||||
import { RiAlbumFill } from 'react-icons/ri';
|
||||
import { generatePath, useNavigate } from 'react-router';
|
||||
import { SimpleImg } from 'react-simple-img';
|
||||
import type { ListChildComponentProps } from 'react-window';
|
||||
import styled from 'styled-components';
|
||||
import type { LibraryItem, CardRow, CardRoute, Play, PlayQueueAddOptions } from '/@/renderer/types';
|
||||
import type { CardRow, CardRoute, Play, PlayQueueAddOptions } from '/@/renderer/types';
|
||||
import { Skeleton } from '/@/renderer/components/skeleton';
|
||||
import { GridCardControls } from '/@/renderer/components/virtual-grid/grid-card/grid-card-controls';
|
||||
import { Album, AlbumArtist, Artist } from '/@/renderer/api/types';
|
||||
import { Album, AlbumArtist, Artist, LibraryItem } from '/@/renderer/api/types';
|
||||
import { CardRows } from '/@/renderer/components/card';
|
||||
|
||||
const CardWrapper = styled.div<{
|
||||
@@ -78,7 +77,8 @@ const ImageSection = styled.div<{ size?: number }>`
|
||||
}
|
||||
`;
|
||||
|
||||
const Image = styled(SimpleImg)`
|
||||
const Image = styled.img`
|
||||
object-fit: cover;
|
||||
border-radius: var(--card-default-radius);
|
||||
box-shadow: 2px 2px 10px 2px rgba(0, 0, 0, 20%);
|
||||
`;
|
||||
@@ -101,6 +101,7 @@ interface BaseGridCardProps {
|
||||
columnIndex: number;
|
||||
controls: {
|
||||
cardRows: CardRow<Album | AlbumArtist | Artist>[];
|
||||
handleFavorite: (options: { id: string[]; isFavorite: boolean; itemType: LibraryItem }) => void;
|
||||
handlePlayQueueAdd: (options: PlayQueueAddOptions) => void;
|
||||
itemType: LibraryItem;
|
||||
playButtonBehavior: Play;
|
||||
@@ -155,10 +156,8 @@ export const DefaultCard = ({
|
||||
<ImageSection size={itemWidth}>
|
||||
{data?.imageUrl ? (
|
||||
<Image
|
||||
animationDuration={0.3}
|
||||
height={cardSize}
|
||||
imgStyle={{ objectFit: 'cover' }}
|
||||
placeholder="var(--card-default-bg)"
|
||||
placeholder={data?.imagePlaceholderUrl || 'var(--card-default-bg)'}
|
||||
src={data?.imageUrl}
|
||||
width={cardSize}
|
||||
/>
|
||||
@@ -179,6 +178,7 @@ export const DefaultCard = ({
|
||||
)}
|
||||
<ControlsContainer>
|
||||
<GridCardControls
|
||||
handleFavorite={controls.handleFavorite}
|
||||
handlePlayQueueAdd={handlePlayQueueAdd}
|
||||
itemData={data}
|
||||
itemType={itemType}
|
||||
|
||||
@@ -5,10 +5,15 @@ import { Group } from '@mantine/core';
|
||||
import { RiPlayFill, RiMore2Fill, RiHeartFill, RiHeartLine } from 'react-icons/ri';
|
||||
import styled from 'styled-components';
|
||||
import { _Button } from '/@/renderer/components/button';
|
||||
import { DropdownMenu } from '/@/renderer/components/dropdown-menu';
|
||||
import type { LibraryItem, PlayQueueAddOptions } from '/@/renderer/types';
|
||||
import type { PlayQueueAddOptions } from '/@/renderer/types';
|
||||
import { Play } from '/@/renderer/types';
|
||||
import { useSettingsStore } from '/@/renderer/store/settings.store';
|
||||
import { LibraryItem } from '/@/renderer/api/types';
|
||||
import { useHandleGeneralContextMenu } from '/@/renderer/features/context-menu/hooks/use-handle-context-menu';
|
||||
import {
|
||||
ALBUM_CONTEXT_MENU_ITEMS,
|
||||
ARTIST_CONTEXT_MENU_ITEMS,
|
||||
} from '/@/renderer/features/context-menu/context-menu-items';
|
||||
|
||||
type PlayButtonType = UnstyledButtonProps & React.ComponentPropsWithoutRef<'button'>;
|
||||
|
||||
@@ -98,26 +103,13 @@ const FavoriteWrapper = styled.span<{ isFavorite: boolean }>`
|
||||
}
|
||||
`;
|
||||
|
||||
const PLAY_TYPES = [
|
||||
{
|
||||
label: 'Play',
|
||||
play: Play.NOW,
|
||||
},
|
||||
{
|
||||
label: 'Add to queue (last)',
|
||||
play: Play.LAST,
|
||||
},
|
||||
{
|
||||
label: 'Add to queue (next)',
|
||||
play: Play.NEXT,
|
||||
},
|
||||
];
|
||||
|
||||
export const GridCardControls = ({
|
||||
itemData,
|
||||
itemType,
|
||||
handlePlayQueueAdd,
|
||||
handleFavorite,
|
||||
}: {
|
||||
handleFavorite: (options: { id: string[]; isFavorite: boolean; itemType: LibraryItem }) => void;
|
||||
handlePlayQueueAdd?: (options: PlayQueueAddOptions) => void;
|
||||
itemData: any;
|
||||
itemType: LibraryItem;
|
||||
@@ -137,23 +129,37 @@ export const GridCardControls = ({
|
||||
});
|
||||
};
|
||||
|
||||
const handleFavorites = async (e: MouseEvent<HTMLButtonElement>) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
handleFavorite?.({
|
||||
id: [itemData.id],
|
||||
isFavorite: itemData.userFavorite,
|
||||
itemType,
|
||||
});
|
||||
};
|
||||
|
||||
const handleContextMenu = useHandleGeneralContextMenu(
|
||||
itemType,
|
||||
itemType === LibraryItem.ALBUM ? ALBUM_CONTEXT_MENU_ITEMS : ARTIST_CONTEXT_MENU_ITEMS,
|
||||
);
|
||||
|
||||
return (
|
||||
<GridCardControlsContainer>
|
||||
{/* <TopControls /> */}
|
||||
{/* <CenterControls /> */}
|
||||
<BottomControls>
|
||||
<PlayButton onClick={handlePlay}>
|
||||
<RiPlayFill size={25} />
|
||||
</PlayButton>
|
||||
<Group spacing="xs">
|
||||
<SecondaryButton
|
||||
disabled
|
||||
p={5}
|
||||
sx={{ svg: { fill: 'white !important' } }}
|
||||
variant="subtle"
|
||||
onClick={handleFavorites}
|
||||
>
|
||||
<FavoriteWrapper isFavorite={itemData?.isFavorite}>
|
||||
{itemData?.isFavorite ? (
|
||||
{itemData?.userFavorite ? (
|
||||
<RiHeartFill size={20} />
|
||||
) : (
|
||||
<RiHeartLine
|
||||
@@ -163,39 +169,21 @@ export const GridCardControls = ({
|
||||
)}
|
||||
</FavoriteWrapper>
|
||||
</SecondaryButton>
|
||||
<DropdownMenu
|
||||
withinPortal
|
||||
position="bottom-start"
|
||||
<SecondaryButton
|
||||
p={5}
|
||||
sx={{ svg: { fill: 'white !important' } }}
|
||||
variant="subtle"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
handleContextMenu(e, [itemData]);
|
||||
}}
|
||||
>
|
||||
<DropdownMenu.Target>
|
||||
<SecondaryButton
|
||||
p={5}
|
||||
sx={{ svg: { fill: 'white !important' } }}
|
||||
variant="subtle"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}}
|
||||
>
|
||||
<RiMore2Fill
|
||||
color="white"
|
||||
size={20}
|
||||
/>
|
||||
</SecondaryButton>
|
||||
</DropdownMenu.Target>
|
||||
<DropdownMenu.Dropdown>
|
||||
{PLAY_TYPES.filter((type) => type.play !== playButtonBehavior).map((type) => (
|
||||
<DropdownMenu.Item
|
||||
key={`playtype-${type.play}`}
|
||||
onClick={(e: MouseEvent<HTMLButtonElement>) => handlePlay(e, type.play)}
|
||||
>
|
||||
{type.label}
|
||||
</DropdownMenu.Item>
|
||||
))}
|
||||
<DropdownMenu.Item disabled>Add to playlist</DropdownMenu.Item>
|
||||
<DropdownMenu.Item disabled>Refresh metadata</DropdownMenu.Item>
|
||||
</DropdownMenu.Dropdown>
|
||||
</DropdownMenu>
|
||||
<RiMore2Fill
|
||||
color="white"
|
||||
size={20}
|
||||
/>
|
||||
</SecondaryButton>
|
||||
</Group>
|
||||
</BottomControls>
|
||||
</GridCardControlsContainer>
|
||||
|
||||
@@ -18,6 +18,7 @@ export const GridCard = memo(({ data, index, style }: ListChildComponentProps) =
|
||||
itemType,
|
||||
playButtonBehavior,
|
||||
handlePlayQueueAdd,
|
||||
handleFavorite,
|
||||
route,
|
||||
display,
|
||||
} = data as GridCardData;
|
||||
@@ -35,6 +36,7 @@ export const GridCard = memo(({ data, index, style }: ListChildComponentProps) =
|
||||
columnIndex={i}
|
||||
controls={{
|
||||
cardRows,
|
||||
handleFavorite,
|
||||
handlePlayQueueAdd,
|
||||
itemType,
|
||||
playButtonBehavior,
|
||||
|
||||
@@ -2,13 +2,12 @@ import { Center } from '@mantine/core';
|
||||
import { RiAlbumFill } from 'react-icons/ri';
|
||||
import { generatePath } from 'react-router';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { SimpleImg } from 'react-simple-img';
|
||||
import type { ListChildComponentProps } from 'react-window';
|
||||
import styled from 'styled-components';
|
||||
import { Skeleton } from '/@/renderer/components/skeleton';
|
||||
import type { LibraryItem, CardRow, CardRoute, Play, PlayQueueAddOptions } from '/@/renderer/types';
|
||||
import type { CardRow, CardRoute, Play, PlayQueueAddOptions } from '/@/renderer/types';
|
||||
import { GridCardControls } from '/@/renderer/components/virtual-grid/grid-card/grid-card-controls';
|
||||
import { Album, Artist, AlbumArtist } from '/@/renderer/api/types';
|
||||
import { Album, Artist, AlbumArtist, LibraryItem } from '/@/renderer/api/types';
|
||||
import { CardRows } from '/@/renderer/components/card';
|
||||
|
||||
const CardWrapper = styled.div<{
|
||||
@@ -78,13 +77,10 @@ interface ImageProps {
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
const Image = styled(SimpleImg)<ImageProps>`
|
||||
const Image = styled.img<ImageProps>`
|
||||
object-fit: cover;
|
||||
border: 0;
|
||||
border-radius: var(--card-poster-radius);
|
||||
|
||||
img {
|
||||
object-fit: cover;
|
||||
}
|
||||
`;
|
||||
|
||||
const ControlsContainer = styled.div`
|
||||
@@ -105,6 +101,7 @@ interface BaseGridCardProps {
|
||||
columnIndex: number;
|
||||
controls: {
|
||||
cardRows: CardRow<Album | AlbumArtist | Artist>[];
|
||||
handleFavorite: (options: { id: string[]; isFavorite: boolean; itemType: LibraryItem }) => void;
|
||||
handlePlayQueueAdd: (options: PlayQueueAddOptions) => void;
|
||||
itemType: LibraryItem;
|
||||
playButtonBehavior: Play;
|
||||
@@ -150,10 +147,8 @@ export const PosterCard = ({
|
||||
<ImageSection style={{ height: `${sizes.itemWidth}px` }}>
|
||||
{data?.imageUrl ? (
|
||||
<Image
|
||||
animationDuration={0.3}
|
||||
height={sizes.itemWidth}
|
||||
importance="auto"
|
||||
placeholder="var(--card-default-bg)"
|
||||
placeholder={data?.imagePlaceholderUrl || 'var(--card-default-bg)'}
|
||||
src={data?.imageUrl}
|
||||
width={sizes.itemWidth}
|
||||
/>
|
||||
@@ -173,6 +168,7 @@ export const PosterCard = ({
|
||||
)}
|
||||
<ControlsContainer>
|
||||
<GridCardControls
|
||||
handleFavorite={controls.handleFavorite}
|
||||
handlePlayQueueAdd={controls.handlePlayQueueAdd}
|
||||
itemData={data}
|
||||
itemType={controls.itemType}
|
||||
|
||||
@@ -5,14 +5,8 @@ import type { FixedSizeListProps } from 'react-window';
|
||||
import { FixedSizeList } from 'react-window';
|
||||
import styled from 'styled-components';
|
||||
import { GridCard } from '/@/renderer/components/virtual-grid/grid-card';
|
||||
import type {
|
||||
CardRow,
|
||||
LibraryItem,
|
||||
ListDisplayType,
|
||||
CardRoute,
|
||||
PlayQueueAddOptions,
|
||||
} from '/@/renderer/types';
|
||||
import { Album, AlbumArtist, Artist } from '/@/renderer/api/types';
|
||||
import type { CardRow, ListDisplayType, CardRoute, PlayQueueAddOptions } from '/@/renderer/types';
|
||||
import { Album, AlbumArtist, Artist, LibraryItem } from '/@/renderer/api/types';
|
||||
|
||||
const createItemData = memoize(
|
||||
(
|
||||
@@ -27,10 +21,12 @@ const createItemData = memoize(
|
||||
itemWidth,
|
||||
route,
|
||||
handlePlayQueueAdd,
|
||||
handleFavorite,
|
||||
) => ({
|
||||
cardRows,
|
||||
columnCount,
|
||||
display,
|
||||
handleFavorite,
|
||||
handlePlayQueueAdd,
|
||||
itemCount,
|
||||
itemData,
|
||||
@@ -56,6 +52,7 @@ export const VirtualGridWrapper = ({
|
||||
columnCount,
|
||||
rowCount,
|
||||
initialScrollOffset,
|
||||
handleFavorite,
|
||||
handlePlayQueueAdd,
|
||||
itemData,
|
||||
route,
|
||||
@@ -65,6 +62,7 @@ export const VirtualGridWrapper = ({
|
||||
cardRows: CardRow<Album | AlbumArtist | Artist>[];
|
||||
columnCount: number;
|
||||
display: ListDisplayType;
|
||||
handleFavorite?: (options: { id: string[]; isFavorite: boolean; itemType: LibraryItem }) => void;
|
||||
handlePlayQueueAdd?: (options: PlayQueueAddOptions) => void;
|
||||
itemData: any[];
|
||||
itemGap: number;
|
||||
@@ -87,6 +85,7 @@ export const VirtualGridWrapper = ({
|
||||
itemWidth,
|
||||
route,
|
||||
handlePlayQueueAdd,
|
||||
handleFavorite,
|
||||
);
|
||||
|
||||
const memoizedOnScroll = createScrollHandler(onScroll);
|
||||
|
||||
@@ -1,18 +1,11 @@
|
||||
import {
|
||||
useState,
|
||||
useRef,
|
||||
useMemo,
|
||||
useCallback,
|
||||
forwardRef,
|
||||
Ref,
|
||||
useImperativeHandle,
|
||||
} from 'react';
|
||||
import { useRef, useMemo, useCallback, forwardRef, Ref, useImperativeHandle } from 'react';
|
||||
import debounce from 'lodash/debounce';
|
||||
import type { FixedSizeListProps } from 'react-window';
|
||||
import InfiniteLoader from 'react-window-infinite-loader';
|
||||
import { VirtualGridWrapper } from '/@/renderer/components/virtual-grid/virtual-grid-wrapper';
|
||||
import type { CardRoute, CardRow, LibraryItem, PlayQueueAddOptions } from '/@/renderer/types';
|
||||
import type { CardRoute, CardRow, PlayQueueAddOptions } from '/@/renderer/types';
|
||||
import { ListDisplayType } from '/@/renderer/types';
|
||||
import { LibraryItem } from '/@/renderer/api/types';
|
||||
|
||||
export type VirtualInfiniteGridRef = {
|
||||
resetLoadMoreItemsCache: () => void;
|
||||
@@ -24,21 +17,25 @@ interface VirtualGridProps extends Omit<FixedSizeListProps, 'children' | 'itemSi
|
||||
cardRows: CardRow<any>[];
|
||||
display?: ListDisplayType;
|
||||
fetchFn: (options: { columnCount: number; skip: number; take: number }) => Promise<any>;
|
||||
handleFavorite?: (options: { id: string[]; isFavorite: boolean; itemType: LibraryItem }) => void;
|
||||
handlePlayQueueAdd?: (options: PlayQueueAddOptions) => void;
|
||||
itemData: any[];
|
||||
itemGap: number;
|
||||
itemSize: number;
|
||||
itemType: LibraryItem;
|
||||
loading?: boolean;
|
||||
minimumBatchSize?: number;
|
||||
route?: CardRoute;
|
||||
setItemData: (data: any[]) => void;
|
||||
}
|
||||
|
||||
const constrainWidth = (width: number) => {
|
||||
if (width < 1920) {
|
||||
return width;
|
||||
}
|
||||
// const constrainWidth = (width: number) => {
|
||||
// if (width < 1920) {
|
||||
// return width;
|
||||
// }
|
||||
|
||||
return 1920;
|
||||
};
|
||||
// return 1920;
|
||||
// };
|
||||
|
||||
export const VirtualInfiniteGrid = forwardRef(
|
||||
(
|
||||
@@ -48,26 +45,27 @@ export const VirtualInfiniteGrid = forwardRef(
|
||||
itemSize,
|
||||
itemType,
|
||||
cardRows,
|
||||
itemData,
|
||||
setItemData,
|
||||
route,
|
||||
onScroll,
|
||||
display,
|
||||
handlePlayQueueAdd,
|
||||
minimumBatchSize,
|
||||
fetchFn,
|
||||
loading,
|
||||
initialScrollOffset,
|
||||
handleFavorite,
|
||||
height,
|
||||
width,
|
||||
}: VirtualGridProps,
|
||||
ref: Ref<VirtualInfiniteGridRef>,
|
||||
) => {
|
||||
const [itemData, setItemData] = useState<any[]>([]);
|
||||
const listRef = useRef<any>(null);
|
||||
const loader = useRef<InfiniteLoader>(null);
|
||||
|
||||
const { itemHeight, rowCount, columnCount } = useMemo(() => {
|
||||
const itemsPerRow = Math.floor(
|
||||
(constrainWidth(Number(width)) - itemGap + 3) / (itemSize! + itemGap + 2),
|
||||
);
|
||||
const itemsPerRow = Math.floor((Number(width) - itemGap + 3) / (itemSize! + itemGap + 2));
|
||||
|
||||
return {
|
||||
columnCount: itemsPerRow,
|
||||
@@ -111,7 +109,7 @@ export const VirtualInfiniteGrid = forwardRef(
|
||||
|
||||
setItemData(newData);
|
||||
},
|
||||
[columnCount, fetchFn, itemData],
|
||||
[columnCount, fetchFn, itemData, setItemData],
|
||||
);
|
||||
|
||||
const debouncedLoadMoreItems = debounce(loadMoreItems, 500);
|
||||
@@ -120,52 +118,57 @@ export const VirtualInfiniteGrid = forwardRef(
|
||||
resetLoadMoreItemsCache: () => {
|
||||
if (loader.current) {
|
||||
loader.current.resetloadMoreItemsCache(false);
|
||||
setItemData(() => []);
|
||||
setItemData([]);
|
||||
}
|
||||
},
|
||||
scrollTo: (index: number) => {
|
||||
listRef.current.scrollToItem(index);
|
||||
listRef?.current?.scrollToItem(index);
|
||||
},
|
||||
setItemData: (data: any[]) => {
|
||||
setItemData(data);
|
||||
},
|
||||
}));
|
||||
|
||||
if (loading) return null;
|
||||
|
||||
return (
|
||||
<InfiniteLoader
|
||||
ref={loader}
|
||||
isItemLoaded={(index) => isItemLoaded(index)}
|
||||
itemCount={itemCount || 0}
|
||||
loadMoreItems={debouncedLoadMoreItems}
|
||||
minimumBatchSize={minimumBatchSize}
|
||||
threshold={30}
|
||||
>
|
||||
{({ onItemsRendered, ref: infiniteLoaderRef }) => (
|
||||
<VirtualGridWrapper
|
||||
cardRows={cardRows}
|
||||
columnCount={columnCount}
|
||||
display={display || ListDisplayType.CARD}
|
||||
handlePlayQueueAdd={handlePlayQueueAdd}
|
||||
height={height}
|
||||
initialScrollOffset={initialScrollOffset}
|
||||
itemCount={itemCount || 0}
|
||||
itemData={itemData}
|
||||
itemGap={itemGap}
|
||||
itemHeight={itemHeight + itemGap / 2}
|
||||
itemType={itemType}
|
||||
itemWidth={itemSize}
|
||||
refInstance={(list) => {
|
||||
infiniteLoaderRef(list);
|
||||
listRef.current = list;
|
||||
}}
|
||||
route={route}
|
||||
rowCount={rowCount}
|
||||
width={width}
|
||||
onItemsRendered={onItemsRendered}
|
||||
onScroll={onScroll}
|
||||
/>
|
||||
)}
|
||||
</InfiniteLoader>
|
||||
<>
|
||||
<InfiniteLoader
|
||||
ref={loader}
|
||||
isItemLoaded={(index) => isItemLoaded(index)}
|
||||
itemCount={itemCount || 0}
|
||||
loadMoreItems={debouncedLoadMoreItems}
|
||||
minimumBatchSize={minimumBatchSize}
|
||||
threshold={30}
|
||||
>
|
||||
{({ onItemsRendered, ref: infiniteLoaderRef }) => (
|
||||
<VirtualGridWrapper
|
||||
cardRows={cardRows}
|
||||
columnCount={columnCount}
|
||||
display={display || ListDisplayType.CARD}
|
||||
handleFavorite={handleFavorite}
|
||||
handlePlayQueueAdd={handlePlayQueueAdd}
|
||||
height={height}
|
||||
initialScrollOffset={initialScrollOffset}
|
||||
itemCount={itemCount || 0}
|
||||
itemData={itemData}
|
||||
itemGap={itemGap}
|
||||
itemHeight={itemHeight + itemGap / 2}
|
||||
itemType={itemType}
|
||||
itemWidth={itemSize}
|
||||
refInstance={(list) => {
|
||||
infiniteLoaderRef(list);
|
||||
listRef.current = list;
|
||||
}}
|
||||
route={route}
|
||||
rowCount={rowCount}
|
||||
width={width}
|
||||
onItemsRendered={onItemsRendered}
|
||||
onScroll={onScroll}
|
||||
/>
|
||||
)}
|
||||
</InfiniteLoader>
|
||||
</>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -25,14 +25,14 @@ export const AlbumArtistCell = ({ value, data }: ICellRendererParams) => {
|
||||
<Text
|
||||
$secondary
|
||||
overflow="hidden"
|
||||
size="sm"
|
||||
size="md"
|
||||
>
|
||||
{value?.map((item: Artist | AlbumArtist, index: number) => (
|
||||
<React.Fragment key={`row-${item.id}-${data.uniqueId}`}>
|
||||
{index > 0 && (
|
||||
<Text
|
||||
$secondary
|
||||
size="sm"
|
||||
size="md"
|
||||
style={{ display: 'inline-block' }}
|
||||
>
|
||||
,
|
||||
@@ -43,8 +43,8 @@ export const AlbumArtistCell = ({ value, data }: ICellRendererParams) => {
|
||||
$secondary
|
||||
component={Link}
|
||||
overflow="hidden"
|
||||
size="sm"
|
||||
to={generatePath(AppRoute.LIBRARY_ALBUMARTISTS_DETAIL, {
|
||||
size="md"
|
||||
to={generatePath(AppRoute.LIBRARY_ALBUM_ARTISTS_DETAIL, {
|
||||
albumArtistId: item.id,
|
||||
})}
|
||||
>
|
||||
|
||||
@@ -25,14 +25,14 @@ export const ArtistCell = ({ value, data }: ICellRendererParams) => {
|
||||
<Text
|
||||
$secondary
|
||||
overflow="hidden"
|
||||
size="sm"
|
||||
size="md"
|
||||
>
|
||||
{value?.map((item: Artist | AlbumArtist, index: number) => (
|
||||
<React.Fragment key={`row-${item.id}-${data.uniqueId}`}>
|
||||
{index > 0 && (
|
||||
<Text
|
||||
$secondary
|
||||
size="sm"
|
||||
size="md"
|
||||
style={{ display: 'inline-block' }}
|
||||
>
|
||||
,
|
||||
@@ -43,7 +43,7 @@ export const ArtistCell = ({ value, data }: ICellRendererParams) => {
|
||||
$secondary
|
||||
component={Link}
|
||||
overflow="hidden"
|
||||
size="sm"
|
||||
size="md"
|
||||
to={generatePath(AppRoute.LIBRARY_ARTISTS_DETAIL, {
|
||||
artistId: item.id,
|
||||
})}
|
||||
|
||||
@@ -22,6 +22,7 @@ const CellContainer = styled(motion.div)<{ height: number }>`
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
height: 100%;
|
||||
letter-spacing: 0.5px;
|
||||
`;
|
||||
|
||||
const ImageWrapper = styled.div`
|
||||
@@ -78,6 +79,7 @@ export const CombinedTitleCell = ({ value, rowIndex, node }: ICellRendererParams
|
||||
<StyledImage
|
||||
alt="cover"
|
||||
height={(node.rowHeight || 40) - 10}
|
||||
placeholder={value.imagePlaceholderUrl || 'var(--placeholder-bg)'}
|
||||
src={value.imageUrl}
|
||||
style={{}}
|
||||
width={(node.rowHeight || 40) - 10}
|
||||
@@ -101,14 +103,14 @@ export const CombinedTitleCell = ({ value, rowIndex, node }: ICellRendererParams
|
||||
<MetadataWrapper>
|
||||
<Text
|
||||
overflow="hidden"
|
||||
size="sm"
|
||||
size="md"
|
||||
>
|
||||
{value.name}
|
||||
</Text>
|
||||
<Text
|
||||
$secondary
|
||||
overflow="hidden"
|
||||
size="sm"
|
||||
size="md"
|
||||
>
|
||||
{artists?.length ? (
|
||||
artists.map((artist: Artist | AlbumArtist, index: number) => (
|
||||
@@ -119,9 +121,9 @@ export const CombinedTitleCell = ({ value, rowIndex, node }: ICellRendererParams
|
||||
$secondary
|
||||
component={Link}
|
||||
overflow="hidden"
|
||||
size="sm"
|
||||
size="md"
|
||||
sx={{ width: 'fit-content' }}
|
||||
to={generatePath(AppRoute.LIBRARY_ALBUMARTISTS_DETAIL, {
|
||||
to={generatePath(AppRoute.LIBRARY_ALBUM_ARTISTS_DETAIL, {
|
||||
albumArtistId: artist.id,
|
||||
})}
|
||||
>
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
import type { ICellRendererParams } from '@ag-grid-community/core';
|
||||
import { RiHeartFill, RiHeartLine } from 'react-icons/ri';
|
||||
import { Button } from '/@/renderer/components/button';
|
||||
import { CellContainer } from '/@/renderer/components/virtual-table/cells/generic-cell';
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import { HTTPError } from 'ky';
|
||||
import { api } from '/@/renderer/api';
|
||||
import { RawFavoriteResponse, FavoriteArgs, LibraryItem } from '/@/renderer/api/types';
|
||||
import { useCurrentServer, useSetAlbumListItemDataById } from '/@/renderer/store';
|
||||
|
||||
const useCreateFavorite = () => {
|
||||
const server = useCurrentServer();
|
||||
const setAlbumListData = useSetAlbumListItemDataById();
|
||||
|
||||
return useMutation<RawFavoriteResponse, HTTPError, Omit<FavoriteArgs, 'server'>, null>({
|
||||
mutationFn: (args) => api.controller.createFavorite({ ...args, server }),
|
||||
onSuccess: (_data, variables) => {
|
||||
for (const id of variables.query.id) {
|
||||
// Set the userFavorite property to true for the album in the album list data store
|
||||
if (variables.query.type === LibraryItem.ALBUM) {
|
||||
setAlbumListData(id, { userFavorite: true });
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const useDeleteFavorite = () => {
|
||||
const server = useCurrentServer();
|
||||
const setAlbumListData = useSetAlbumListItemDataById();
|
||||
|
||||
return useMutation<RawFavoriteResponse, HTTPError, Omit<FavoriteArgs, 'server'>, null>({
|
||||
mutationFn: (args) => api.controller.deleteFavorite({ ...args, server }),
|
||||
onSuccess: (_data, variables) => {
|
||||
for (const id of variables.query.id) {
|
||||
// Set the userFavorite property to false for the album in the album list data store
|
||||
if (variables.query.type === LibraryItem.ALBUM) {
|
||||
setAlbumListData(id, { userFavorite: false });
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const FavoriteCell = ({ value, data, node }: ICellRendererParams) => {
|
||||
const createMutation = useCreateFavorite();
|
||||
const deleteMutation = useDeleteFavorite();
|
||||
|
||||
const handleToggleFavorite = () => {
|
||||
const newFavoriteValue = !value;
|
||||
|
||||
if (newFavoriteValue) {
|
||||
createMutation.mutate(
|
||||
{
|
||||
query: {
|
||||
id: [data.id],
|
||||
type: data.itemType,
|
||||
},
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
node.setData({ ...data, userFavorite: newFavoriteValue });
|
||||
},
|
||||
},
|
||||
);
|
||||
} else {
|
||||
deleteMutation.mutate(
|
||||
{
|
||||
query: {
|
||||
id: [data.id],
|
||||
type: data.itemType,
|
||||
},
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
node.setData({ ...data, userFavorite: newFavoriteValue });
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<CellContainer position="center">
|
||||
<Button
|
||||
compact
|
||||
sx={{
|
||||
svg: {
|
||||
fill: !value
|
||||
? 'var(--main-fg-secondary) !important'
|
||||
: 'var(--primary-color) !important',
|
||||
},
|
||||
}}
|
||||
variant="subtle"
|
||||
onClick={handleToggleFavorite}
|
||||
>
|
||||
{!value ? <RiHeartLine size="1.3em" /> : <RiHeartFill size="1.3em" />}
|
||||
</Button>
|
||||
</CellContainer>
|
||||
);
|
||||
};
|
||||
@@ -25,6 +25,7 @@ export const CellContainer = styled(motion.div)<{ position?: 'left' | 'center' |
|
||||
: 'flex-start'};
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
letter-spacing: 0.5px;
|
||||
`;
|
||||
|
||||
type Options = {
|
||||
@@ -60,7 +61,7 @@ export const GenericCell = (
|
||||
$secondary={!primary}
|
||||
component={Link}
|
||||
overflow="hidden"
|
||||
size="sm"
|
||||
size="md"
|
||||
to={displayedValue.link}
|
||||
>
|
||||
{isLink ? displayedValue.value : displayedValue}
|
||||
@@ -69,7 +70,7 @@ export const GenericCell = (
|
||||
<Text
|
||||
$secondary={!primary}
|
||||
overflow="hidden"
|
||||
size="sm"
|
||||
size="md"
|
||||
>
|
||||
{displayedValue}
|
||||
</Text>
|
||||
|
||||
@@ -11,14 +11,14 @@ export const GenreCell = ({ value, data }: ICellRendererParams) => {
|
||||
<Text
|
||||
$secondary
|
||||
overflow="hidden"
|
||||
size="sm"
|
||||
size="md"
|
||||
>
|
||||
{value?.map((item: Artist | AlbumArtist, index: number) => (
|
||||
<React.Fragment key={`row-${item.id}-${data.uniqueId}`}>
|
||||
{index > 0 && (
|
||||
<Text
|
||||
$secondary
|
||||
size="sm"
|
||||
size="md"
|
||||
style={{ display: 'inline-block' }}
|
||||
>
|
||||
,
|
||||
@@ -29,7 +29,7 @@ export const GenreCell = ({ value, data }: ICellRendererParams) => {
|
||||
$secondary
|
||||
component={Link}
|
||||
overflow="hidden"
|
||||
size="sm"
|
||||
size="md"
|
||||
to="/"
|
||||
>
|
||||
{item.name || '—'}
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import { MouseEvent } from 'react';
|
||||
import type { ICellRendererParams } from '@ag-grid-community/core';
|
||||
import { Rating } from '/@/renderer/components/rating';
|
||||
import { CellContainer } from '/@/renderer/components/virtual-table/cells/generic-cell';
|
||||
import { useUpdateRating } from '/@/renderer/components/virtual-table/hooks/use-rating';
|
||||
|
||||
export const RatingCell = ({ value, node }: ICellRendererParams) => {
|
||||
const updateRatingMutation = useUpdateRating();
|
||||
|
||||
const handleUpdateRating = (rating: number) => {
|
||||
if (!value) return;
|
||||
|
||||
updateRatingMutation.mutate(
|
||||
{
|
||||
_serverId: value?.serverId,
|
||||
query: {
|
||||
item: [value],
|
||||
rating,
|
||||
},
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
node.setData({ ...node.data, userRating: rating });
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const handleClearRating = (e: MouseEvent<HTMLDivElement>) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
updateRatingMutation.mutate(
|
||||
{
|
||||
_serverId: value?.serverId,
|
||||
query: {
|
||||
item: [value],
|
||||
rating: 0,
|
||||
},
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
node.setData({ ...node.data, userRating: 0 });
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<CellContainer position="center">
|
||||
<Rating
|
||||
size="xs"
|
||||
value={value?.userRating}
|
||||
onChange={handleUpdateRating}
|
||||
onClick={handleClearRating}
|
||||
/>
|
||||
</CellContainer>
|
||||
);
|
||||
};
|
||||
@@ -2,9 +2,11 @@ import type { ReactNode } from 'react';
|
||||
import type { IHeaderParams } from '@ag-grid-community/core';
|
||||
import { AiOutlineNumber } from 'react-icons/ai';
|
||||
import { FiClock } from 'react-icons/fi';
|
||||
import { RiHeartLine, RiStarLine } from 'react-icons/ri';
|
||||
import styled from 'styled-components';
|
||||
import { _Text } from '/@/renderer/components/text';
|
||||
|
||||
type Presets = 'duration' | 'rowIndex';
|
||||
type Presets = 'duration' | 'rowIndex' | 'userFavorite' | 'userRating';
|
||||
|
||||
type Options = {
|
||||
children?: ReactNode;
|
||||
@@ -12,7 +14,7 @@ type Options = {
|
||||
preset?: Presets;
|
||||
};
|
||||
|
||||
const HeaderWrapper = styled.div<{ position: 'left' | 'center' | 'right' }>`
|
||||
const HeaderWrapper = styled.div<{ position: Options['position'] }>`
|
||||
display: flex;
|
||||
justify-content: ${(props) =>
|
||||
props.position === 'right'
|
||||
@@ -25,17 +27,63 @@ const HeaderWrapper = styled.div<{ position: 'left' | 'center' | 'right' }>`
|
||||
text-transform: uppercase;
|
||||
`;
|
||||
|
||||
const headerPresets = { duration: <FiClock size={15} />, rowIndex: <AiOutlineNumber size={15} /> };
|
||||
const TextHeaderWrapper = styled(_Text)<{ position: Options['position'] }>`
|
||||
width: 100%;
|
||||
color: var(--ag-header-foreground-color);
|
||||
font-weight: 500;
|
||||
text-align: ${(props) =>
|
||||
props.position === 'right'
|
||||
? 'flex-end'
|
||||
: props.position === 'center'
|
||||
? 'center'
|
||||
: 'flex-start'};
|
||||
text-transform: uppercase;
|
||||
`;
|
||||
|
||||
const headerPresets = {
|
||||
duration: (
|
||||
<FiClock
|
||||
color="var(--ag-header-foreground-color)"
|
||||
size="1em"
|
||||
/>
|
||||
),
|
||||
rowIndex: (
|
||||
<AiOutlineNumber
|
||||
color="var(--ag-header-foreground-color)"
|
||||
size="1em"
|
||||
/>
|
||||
),
|
||||
userFavorite: (
|
||||
<RiHeartLine
|
||||
color="var(--ag-header-foreground-color)"
|
||||
size="1em"
|
||||
/>
|
||||
),
|
||||
userRating: (
|
||||
<RiStarLine
|
||||
color="var(--ag-header-foreground-color)"
|
||||
size="1em"
|
||||
/>
|
||||
),
|
||||
};
|
||||
|
||||
export const GenericTableHeader = (
|
||||
{ displayName }: IHeaderParams,
|
||||
{ preset, children, position }: Options,
|
||||
) => {
|
||||
if (preset) {
|
||||
return <HeaderWrapper position={position || 'left'}>{headerPresets[preset]}</HeaderWrapper>;
|
||||
return <HeaderWrapper position={position}>{headerPresets[preset]}</HeaderWrapper>;
|
||||
}
|
||||
|
||||
return <HeaderWrapper position={position || 'left'}>{children || displayName}</HeaderWrapper>;
|
||||
return (
|
||||
<TextHeaderWrapper
|
||||
overflow="hidden"
|
||||
position={position}
|
||||
weight={500}
|
||||
>
|
||||
{children || displayName}
|
||||
</TextHeaderWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
GenericTableHeader.defaultProps = {
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import { MutableRefObject } from 'react';
|
||||
import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/lib/agGridReact';
|
||||
import { useClickOutside } from '@mantine/hooks';
|
||||
|
||||
export const useClickOutsideDeselect = (tableRef: MutableRefObject<AgGridReactType | null>) => {
|
||||
const handleDeselect = () => {
|
||||
if (tableRef.current) {
|
||||
tableRef.current.api.deselectAll();
|
||||
}
|
||||
};
|
||||
|
||||
const ref = useClickOutside(handleDeselect);
|
||||
|
||||
return ref;
|
||||
};
|
||||
@@ -0,0 +1,30 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useInView } from 'framer-motion';
|
||||
|
||||
export const useFixedTableHeader = () => {
|
||||
const intersectRef = useRef<HTMLDivElement | null>(null);
|
||||
const tableContainerRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const isNotPastTableIntersection = useInView(intersectRef, {
|
||||
margin: '-68px 0px 0px 0px',
|
||||
});
|
||||
|
||||
const tableInView = useInView(tableContainerRef, {
|
||||
margin: '-128px 0px 0px 0px',
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const header = document.querySelector('main .ag-header');
|
||||
const root = document.querySelector('main .ag-root');
|
||||
|
||||
if (isNotPastTableIntersection || !tableInView) {
|
||||
header?.classList.remove('ag-header-fixed');
|
||||
root?.classList.remove('ag-header-fixed-margin');
|
||||
} else {
|
||||
header?.classList.add('ag-header-fixed');
|
||||
root?.classList.add('ag-header-fixed-margin');
|
||||
}
|
||||
}, [isNotPastTableIntersection, tableInView]);
|
||||
|
||||
return { intersectRef, tableContainerRef };
|
||||
};
|
||||
@@ -0,0 +1,131 @@
|
||||
import { useQueryClient, useMutation } from '@tanstack/react-query';
|
||||
import { HTTPError } from 'ky';
|
||||
import { api } from '/@/renderer/api';
|
||||
import { NDAlbumDetail, NDAlbumArtistDetail } from '/@/renderer/api/navidrome.types';
|
||||
import { queryKeys } from '/@/renderer/api/query-keys';
|
||||
import { SSAlbumDetail, SSAlbumArtistDetail } from '/@/renderer/api/subsonic.types';
|
||||
import {
|
||||
RawRatingResponse,
|
||||
RatingArgs,
|
||||
Album,
|
||||
Song,
|
||||
AlbumArtist,
|
||||
Artist,
|
||||
LibraryItem,
|
||||
} from '/@/renderer/api/types';
|
||||
import {
|
||||
useCurrentServer,
|
||||
useSetAlbumListItemDataById,
|
||||
useSetQueueRating,
|
||||
useAuthStore,
|
||||
} from '/@/renderer/store';
|
||||
import { ServerType } from '/@/renderer/types';
|
||||
|
||||
export const useUpdateRating = () => {
|
||||
const queryClient = useQueryClient();
|
||||
const currentServer = useCurrentServer();
|
||||
const setAlbumListData = useSetAlbumListItemDataById();
|
||||
const setQueueRating = useSetQueueRating();
|
||||
|
||||
return useMutation<
|
||||
RawRatingResponse,
|
||||
HTTPError,
|
||||
Omit<RatingArgs, 'server'>,
|
||||
{ previous: { items: Album[] | Song[] | AlbumArtist[] | Artist[] } | undefined }
|
||||
>({
|
||||
mutationFn: (args) => {
|
||||
const server = useAuthStore.getState().actions.getServer(args._serverId) || currentServer;
|
||||
return api.controller.updateRating({ ...args, server });
|
||||
},
|
||||
onError: (_error, _variables, context) => {
|
||||
for (const item of context?.previous?.items || []) {
|
||||
switch (item.itemType) {
|
||||
case LibraryItem.ALBUM:
|
||||
setAlbumListData(item.id, { userRating: item.userRating });
|
||||
break;
|
||||
case LibraryItem.SONG:
|
||||
setQueueRating([item.id], item.userRating);
|
||||
break;
|
||||
}
|
||||
}
|
||||
},
|
||||
onMutate: (variables) => {
|
||||
for (const item of variables.query.item) {
|
||||
switch (item.itemType) {
|
||||
case LibraryItem.ALBUM:
|
||||
setAlbumListData(item.id, { userRating: variables.query.rating });
|
||||
break;
|
||||
case LibraryItem.SONG:
|
||||
setQueueRating([item.id], variables.query.rating);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return { previous: { items: variables.query.item } };
|
||||
},
|
||||
onSuccess: (_data, variables) => {
|
||||
// We only need to set if we're already on the album detail page
|
||||
const isAlbumDetailPage =
|
||||
variables.query.item.length === 1 && variables.query.item[0].itemType === LibraryItem.ALBUM;
|
||||
|
||||
if (isAlbumDetailPage) {
|
||||
const { serverType, id: albumId, serverId } = variables.query.item[0] as Album;
|
||||
|
||||
const queryKey = queryKeys.albums.detail(serverId || '', { id: albumId });
|
||||
const previous = queryClient.getQueryData<any>(queryKey);
|
||||
if (previous) {
|
||||
switch (serverType) {
|
||||
case ServerType.NAVIDROME:
|
||||
queryClient.setQueryData<NDAlbumDetail>(queryKey, {
|
||||
...previous,
|
||||
userRating: variables.query.rating,
|
||||
});
|
||||
break;
|
||||
case ServerType.SUBSONIC:
|
||||
queryClient.setQueryData<SSAlbumDetail>(queryKey, {
|
||||
...previous,
|
||||
userRating: variables.query.rating,
|
||||
});
|
||||
break;
|
||||
case ServerType.JELLYFIN:
|
||||
// Jellyfin does not support ratings
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// We only need to set if we're already on the album detail page
|
||||
const isAlbumArtistDetailPage =
|
||||
variables.query.item.length === 1 &&
|
||||
variables.query.item[0].itemType === LibraryItem.ALBUM_ARTIST;
|
||||
|
||||
if (isAlbumArtistDetailPage) {
|
||||
const { serverType, id: albumArtistId, serverId } = variables.query.item[0] as AlbumArtist;
|
||||
|
||||
const queryKey = queryKeys.albumArtists.detail(serverId || '', {
|
||||
id: albumArtistId,
|
||||
});
|
||||
const previous = queryClient.getQueryData<any>(queryKey);
|
||||
if (previous) {
|
||||
switch (serverType) {
|
||||
case ServerType.NAVIDROME:
|
||||
queryClient.setQueryData<NDAlbumArtistDetail>(queryKey, {
|
||||
...previous,
|
||||
userRating: variables.query.rating,
|
||||
});
|
||||
break;
|
||||
case ServerType.SUBSONIC:
|
||||
queryClient.setQueryData<SSAlbumArtistDetail>(queryKey, {
|
||||
...previous,
|
||||
userRating: variables.query.rating,
|
||||
});
|
||||
break;
|
||||
case ServerType.JELLYFIN:
|
||||
// Jellyfin does not support ratings
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -1,16 +1,21 @@
|
||||
import type { Ref } from 'react';
|
||||
import { forwardRef, useRef } from 'react';
|
||||
import { Ref, forwardRef, useRef, useEffect, useCallback, useMemo } from 'react';
|
||||
import type {
|
||||
ICellRendererParams,
|
||||
ValueGetterParams,
|
||||
IHeaderParams,
|
||||
ValueFormatterParams,
|
||||
ColDef,
|
||||
ColumnMovedEvent,
|
||||
NewColumnsLoadedEvent,
|
||||
GridReadyEvent,
|
||||
GridSizeChangedEvent,
|
||||
} from '@ag-grid-community/core';
|
||||
import type { AgGridReactProps } from '@ag-grid-community/react';
|
||||
import { AgGridReact } from '@ag-grid-community/react';
|
||||
import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/lib/agGridReact';
|
||||
import { useMergedRef } from '@mantine/hooks';
|
||||
import { useClickOutside, useMergedRef } from '@mantine/hooks';
|
||||
import dayjs from 'dayjs';
|
||||
import relativeTime from 'dayjs/plugin/relativeTime';
|
||||
import formatDuration from 'format-duration';
|
||||
import { generatePath } from 'react-router';
|
||||
import styled from 'styled-components';
|
||||
@@ -23,9 +28,13 @@ import { GenericTableHeader } from '/@/renderer/components/virtual-table/headers
|
||||
import { AppRoute } from '/@/renderer/router/routes';
|
||||
import { PersistedTableColumn } from '/@/renderer/store/settings.store';
|
||||
import { TableColumn } from '/@/renderer/types';
|
||||
import { RatingCell } from '/@/renderer/components/virtual-table/cells/rating-cell';
|
||||
import { FavoriteCell } from '/@/renderer/components/virtual-table/cells/favorite-cell';
|
||||
|
||||
export * from './table-config-dropdown';
|
||||
export * from './table-pagination';
|
||||
export * from './hooks/use-fixed-table-header';
|
||||
export * from './hooks/use-click-outside-deselect';
|
||||
|
||||
const TableWrapper = styled.div`
|
||||
display: flex;
|
||||
@@ -34,6 +43,8 @@ const TableWrapper = styled.div`
|
||||
height: 100%;
|
||||
`;
|
||||
|
||||
dayjs.extend(relativeTime);
|
||||
|
||||
const tableColumns: { [key: string]: ColDef } = {
|
||||
album: {
|
||||
cellRenderer: (params: ICellRendererParams) =>
|
||||
@@ -49,6 +60,7 @@ const tableColumns: { [key: string]: ColDef } = {
|
||||
value: params.data?.album,
|
||||
}
|
||||
: undefined,
|
||||
width: 200,
|
||||
},
|
||||
albumArtist: {
|
||||
cellRenderer: AlbumArtistCell,
|
||||
@@ -56,27 +68,51 @@ const tableColumns: { [key: string]: ColDef } = {
|
||||
headerName: 'Album Artist',
|
||||
valueGetter: (params: ValueGetterParams) =>
|
||||
params.data ? params.data.albumArtists : undefined,
|
||||
width: 150,
|
||||
},
|
||||
albumCount: {
|
||||
cellRenderer: (params: ICellRendererParams) => GenericCell(params, { position: 'center' }),
|
||||
colId: TableColumn.ALBUM_COUNT,
|
||||
field: 'albumCount',
|
||||
headerComponent: (params: IHeaderParams) => GenericTableHeader(params, { position: 'center' }),
|
||||
headerName: 'Albums',
|
||||
suppressSizeToFit: true,
|
||||
valueGetter: (params: ValueGetterParams) => (params.data ? params.data.albumCount : undefined),
|
||||
width: 80,
|
||||
},
|
||||
artist: {
|
||||
cellRenderer: ArtistCell,
|
||||
colId: TableColumn.ARTIST,
|
||||
headerName: 'Artist',
|
||||
valueGetter: (params: ValueGetterParams) => (params.data ? params.data.artists : undefined),
|
||||
width: 150,
|
||||
},
|
||||
biography: {
|
||||
cellRenderer: (params: ICellRendererParams) => GenericCell(params, { position: 'left' }),
|
||||
colId: TableColumn.BIOGRAPHY,
|
||||
field: 'biography',
|
||||
headerName: 'Biography',
|
||||
valueGetter: (params: ValueGetterParams) => (params.data ? params.data.biography : ''),
|
||||
width: 200,
|
||||
},
|
||||
bitRate: {
|
||||
cellRenderer: (params: ICellRendererParams) => GenericCell(params, { position: 'left' }),
|
||||
cellRenderer: (params: ICellRendererParams) => GenericCell(params, { position: 'center' }),
|
||||
colId: TableColumn.BIT_RATE,
|
||||
field: 'bitRate',
|
||||
headerComponent: (params: IHeaderParams) => GenericTableHeader(params, { position: 'left' }),
|
||||
headerComponent: (params: IHeaderParams) => GenericTableHeader(params, { position: 'center' }),
|
||||
suppressSizeToFit: true,
|
||||
valueFormatter: (params: ValueFormatterParams) => `${params.value} kbps`,
|
||||
valueGetter: (params: ValueGetterParams) => (params.data ? params.data.bitRate : undefined),
|
||||
width: 90,
|
||||
},
|
||||
bpm: {
|
||||
cellRenderer: (params: ICellRendererParams) => GenericCell(params, { position: 'center' }),
|
||||
colId: TableColumn.BPM,
|
||||
headerComponent: (params: IHeaderParams) => GenericTableHeader(params, { position: 'center' }),
|
||||
headerName: 'BPM',
|
||||
suppressSizeToFit: true,
|
||||
valueGetter: (params: ValueGetterParams) => (params.data ? params.data.bpm : undefined),
|
||||
width: 60,
|
||||
},
|
||||
channels: {
|
||||
cellRenderer: (params: ICellRendererParams) => GenericCell(params, { position: 'center' }),
|
||||
@@ -84,21 +120,26 @@ const tableColumns: { [key: string]: ColDef } = {
|
||||
field: 'channels',
|
||||
headerComponent: (params: IHeaderParams) => GenericTableHeader(params, { position: 'center' }),
|
||||
valueGetter: (params: ValueGetterParams) => (params.data ? params.data.channels : undefined),
|
||||
width: 100,
|
||||
},
|
||||
comment: {
|
||||
cellRenderer: GenericCell,
|
||||
colId: TableColumn.COMMENT,
|
||||
headerName: 'Note',
|
||||
valueGetter: (params: ValueGetterParams) => (params.data ? params.data.comment : undefined),
|
||||
width: 150,
|
||||
},
|
||||
dateAdded: {
|
||||
cellRenderer: (params: ICellRendererParams) => GenericCell(params, { position: 'left' }),
|
||||
cellRenderer: (params: ICellRendererParams) => GenericCell(params, { position: 'center' }),
|
||||
colId: TableColumn.DATE_ADDED,
|
||||
field: 'createdAt',
|
||||
headerComponent: (params: IHeaderParams) => GenericTableHeader(params, { position: 'left' }),
|
||||
headerComponent: (params: IHeaderParams) => GenericTableHeader(params, { position: 'center' }),
|
||||
headerName: 'Date Added',
|
||||
valueFormatter: (params: ValueFormatterParams) => params.value?.split('T')[0],
|
||||
suppressSizeToFit: true,
|
||||
valueFormatter: (params: ValueFormatterParams) =>
|
||||
params.value ? dayjs(params.value).format('MMM D, YYYY') : '',
|
||||
valueGetter: (params: ValueGetterParams) => (params.data ? params.data.createdAt : undefined),
|
||||
width: 130,
|
||||
},
|
||||
discNumber: {
|
||||
cellRenderer: (params: ICellRendererParams) => GenericCell(params, { position: 'center' }),
|
||||
@@ -106,9 +147,9 @@ const tableColumns: { [key: string]: ColDef } = {
|
||||
field: 'discNumber',
|
||||
headerComponent: (params: IHeaderParams) => GenericTableHeader(params, { position: 'center' }),
|
||||
headerName: 'Disc',
|
||||
initialWidth: 75,
|
||||
suppressSizeToFit: true,
|
||||
valueGetter: (params: ValueGetterParams) => (params.data ? params.data.discNumber : undefined),
|
||||
width: 60,
|
||||
},
|
||||
duration: {
|
||||
cellRenderer: (params: ICellRendererParams) => GenericCell(params, { position: 'center' }),
|
||||
@@ -116,28 +157,35 @@ const tableColumns: { [key: string]: ColDef } = {
|
||||
field: 'duration',
|
||||
headerComponent: (params: IHeaderParams) =>
|
||||
GenericTableHeader(params, { position: 'center', preset: 'duration' }),
|
||||
initialWidth: 100,
|
||||
suppressSizeToFit: true,
|
||||
valueFormatter: (params: ValueFormatterParams) => formatDuration(params.value * 1000),
|
||||
valueGetter: (params: ValueGetterParams) => (params.data ? params.data.duration : undefined),
|
||||
width: 70,
|
||||
},
|
||||
genre: {
|
||||
cellRenderer: GenreCell,
|
||||
colId: TableColumn.GENRE,
|
||||
headerName: 'Genre',
|
||||
valueGetter: (params: ValueGetterParams) => (params.data ? params.data.genres : undefined),
|
||||
width: 100,
|
||||
},
|
||||
lastPlayedAt: {
|
||||
cellRenderer: GenericCell,
|
||||
cellRenderer: (params: ICellRendererParams) => GenericCell(params, { position: 'center' }),
|
||||
colId: TableColumn.LAST_PLAYED,
|
||||
headerComponent: (params: IHeaderParams) => GenericTableHeader(params, { position: 'center' }),
|
||||
headerName: 'Last Played',
|
||||
valueFormatter: (params: ValueFormatterParams) =>
|
||||
params.value ? dayjs(params.value).fromNow() : '',
|
||||
valueGetter: (params: ValueGetterParams) =>
|
||||
params.data ? params.data.lastPlayedAt : undefined,
|
||||
width: 130,
|
||||
},
|
||||
path: {
|
||||
cellRenderer: GenericCell,
|
||||
colId: TableColumn.PATH,
|
||||
headerName: 'Path',
|
||||
valueGetter: (params: ValueGetterParams) => (params.data ? params.data.path : undefined),
|
||||
width: 200,
|
||||
},
|
||||
playCount: {
|
||||
cellRenderer: (params: ICellRendererParams) => GenericCell(params, { position: 'center' }),
|
||||
@@ -145,7 +193,9 @@ const tableColumns: { [key: string]: ColDef } = {
|
||||
field: 'playCount',
|
||||
headerComponent: (params: IHeaderParams) => GenericTableHeader(params, { position: 'center' }),
|
||||
headerName: 'Plays',
|
||||
suppressSizeToFit: true,
|
||||
valueGetter: (params: ValueGetterParams) => (params.data ? params.data.playCount : undefined),
|
||||
width: 90,
|
||||
},
|
||||
releaseDate: {
|
||||
cellRenderer: (params: ICellRendererParams) => GenericCell(params, { position: 'center' }),
|
||||
@@ -153,8 +203,11 @@ const tableColumns: { [key: string]: ColDef } = {
|
||||
field: 'releaseDate',
|
||||
headerComponent: (params: IHeaderParams) => GenericTableHeader(params, { position: 'center' }),
|
||||
headerName: 'Release Date',
|
||||
valueFormatter: (params: ValueFormatterParams) => params.value?.split('T')[0],
|
||||
suppressSizeToFit: true,
|
||||
valueFormatter: (params: ValueFormatterParams) =>
|
||||
params.value ? dayjs(params.value).format('MMM D, YYYY') : '',
|
||||
valueGetter: (params: ValueGetterParams) => (params.data ? params.data.releaseDate : undefined),
|
||||
width: 130,
|
||||
},
|
||||
releaseYear: {
|
||||
cellRenderer: (params: ICellRendererParams) => GenericCell(params, { position: 'center' }),
|
||||
@@ -162,18 +215,30 @@ const tableColumns: { [key: string]: ColDef } = {
|
||||
field: 'releaseYear',
|
||||
headerComponent: (params: IHeaderParams) => GenericTableHeader(params, { position: 'center' }),
|
||||
headerName: 'Year',
|
||||
suppressSizeToFit: true,
|
||||
valueGetter: (params: ValueGetterParams) => (params.data ? params.data.releaseYear : undefined),
|
||||
width: 80,
|
||||
},
|
||||
rowIndex: {
|
||||
cellRenderer: (params: ICellRendererParams) => GenericCell(params, { position: 'left' }),
|
||||
cellRenderer: (params: ICellRendererParams) => GenericCell(params, { position: 'right' }),
|
||||
colId: TableColumn.ROW_INDEX,
|
||||
headerComponent: (params: IHeaderParams) =>
|
||||
GenericTableHeader(params, { position: 'left', preset: 'rowIndex' }),
|
||||
initialWidth: 50,
|
||||
GenericTableHeader(params, { position: 'right', preset: 'rowIndex' }),
|
||||
suppressSizeToFit: true,
|
||||
valueGetter: (params) => {
|
||||
return (params.node?.rowIndex || 0) + 1;
|
||||
},
|
||||
width: 65,
|
||||
},
|
||||
songCount: {
|
||||
cellRenderer: (params: ICellRendererParams) => GenericCell(params, { position: 'center' }),
|
||||
colId: TableColumn.SONG_COUNT,
|
||||
field: 'songCount',
|
||||
headerComponent: (params: IHeaderParams) => GenericTableHeader(params, { position: 'center' }),
|
||||
headerName: 'Songs',
|
||||
suppressSizeToFit: true,
|
||||
valueGetter: (params: ValueGetterParams) => (params.data ? params.data.songCount : undefined),
|
||||
width: 80,
|
||||
},
|
||||
title: {
|
||||
cellRenderer: (params: ICellRendererParams) =>
|
||||
@@ -182,23 +247,27 @@ const tableColumns: { [key: string]: ColDef } = {
|
||||
field: 'name',
|
||||
headerName: 'Title',
|
||||
valueGetter: (params: ValueGetterParams) => (params.data ? params.data.name : undefined),
|
||||
width: 250,
|
||||
},
|
||||
titleCombined: {
|
||||
cellRenderer: CombinedTitleCell,
|
||||
colId: TableColumn.TITLE_COMBINED,
|
||||
headerName: 'Title',
|
||||
initialWidth: 500,
|
||||
minWidth: 150,
|
||||
valueGetter: (params: ValueGetterParams) =>
|
||||
params.data
|
||||
? {
|
||||
albumArtists: params.data?.albumArtists,
|
||||
artists: params.data?.artists,
|
||||
imagePlaceholderUrl: params.data?.imagePlaceholderUrl,
|
||||
imageUrl: params.data?.imageUrl,
|
||||
name: params.data?.name,
|
||||
rowHeight: params.node?.rowHeight,
|
||||
type: params.data?.type,
|
||||
}
|
||||
: undefined,
|
||||
width: 250,
|
||||
},
|
||||
trackNumber: {
|
||||
cellRenderer: (params: ICellRendererParams) => GenericCell(params, { position: 'center' }),
|
||||
@@ -206,9 +275,34 @@ const tableColumns: { [key: string]: ColDef } = {
|
||||
field: 'trackNumber',
|
||||
headerComponent: (params: IHeaderParams) => GenericTableHeader(params, { position: 'center' }),
|
||||
headerName: 'Track',
|
||||
initialWidth: 75,
|
||||
suppressSizeToFit: true,
|
||||
valueGetter: (params: ValueGetterParams) => (params.data ? params.data.trackNumber : undefined),
|
||||
width: 80,
|
||||
},
|
||||
userFavorite: {
|
||||
cellClass: (params) => (params.value ? 'visible ag-cell-favorite' : 'ag-cell-favorite'),
|
||||
cellRenderer: FavoriteCell,
|
||||
colId: TableColumn.USER_FAVORITE,
|
||||
field: 'userFavorite',
|
||||
headerComponent: (params: IHeaderParams) =>
|
||||
GenericTableHeader(params, { position: 'center', preset: 'userFavorite' }),
|
||||
headerName: 'Favorite',
|
||||
suppressSizeToFit: true,
|
||||
valueGetter: (params: ValueGetterParams) =>
|
||||
params.data ? params.data.userFavorite : undefined,
|
||||
width: 50,
|
||||
},
|
||||
userRating: {
|
||||
cellClass: (params) => (params.value.userRating ? 'visible ag-cell-rating' : 'ag-cell-rating'),
|
||||
cellRenderer: RatingCell,
|
||||
colId: TableColumn.USER_RATING,
|
||||
field: 'userRating',
|
||||
headerComponent: (params: IHeaderParams) =>
|
||||
GenericTableHeader(params, { position: 'center', preset: 'userRating' }),
|
||||
headerName: 'Rating',
|
||||
suppressSizeToFit: true,
|
||||
valueGetter: (params: ValueGetterParams) => (params.data ? params.data : undefined),
|
||||
width: 95,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -231,20 +325,128 @@ export const getColumnDefs = (columns: PersistedTableColumn[]) => {
|
||||
return columnDefs;
|
||||
};
|
||||
|
||||
interface VirtualTableProps extends AgGridReactProps {
|
||||
autoFitColumns?: boolean;
|
||||
autoHeight?: boolean;
|
||||
deselectOnClickOutside?: boolean;
|
||||
transparentHeader?: boolean;
|
||||
}
|
||||
|
||||
export const VirtualTable = forwardRef(
|
||||
({ ...rest }: AgGridReactProps, ref: Ref<AgGridReactType | null>) => {
|
||||
(
|
||||
{
|
||||
autoFitColumns,
|
||||
deselectOnClickOutside,
|
||||
autoHeight,
|
||||
transparentHeader,
|
||||
onColumnMoved,
|
||||
onNewColumnsLoaded,
|
||||
onGridReady,
|
||||
onGridSizeChanged,
|
||||
...rest
|
||||
}: VirtualTableProps,
|
||||
ref: Ref<AgGridReactType | null>,
|
||||
) => {
|
||||
const tableRef = useRef<AgGridReactType | null>(null);
|
||||
|
||||
const mergedRef = useMergedRef(ref, tableRef);
|
||||
|
||||
const deselectRef = useClickOutside(() => {
|
||||
if (tableRef?.current?.api && deselectOnClickOutside) {
|
||||
tableRef?.current?.api?.deselectAll();
|
||||
}
|
||||
});
|
||||
|
||||
const defaultColumnDefs: ColDef = useMemo(() => {
|
||||
return {
|
||||
lockPinned: true,
|
||||
lockVisible: true,
|
||||
resizable: true,
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Auto fit columns on column change
|
||||
useEffect(() => {
|
||||
if (!tableRef?.current?.api) return;
|
||||
if (autoFitColumns && tableRef?.current?.api) {
|
||||
tableRef?.current?.api?.sizeColumnsToFit?.();
|
||||
}
|
||||
}, [autoFitColumns]);
|
||||
|
||||
// Reset row heights on row height change
|
||||
useEffect(() => {
|
||||
if (!tableRef?.current?.api) return;
|
||||
tableRef?.current?.api?.resetRowHeights();
|
||||
tableRef?.current?.api?.redrawRows();
|
||||
}, [rest.rowHeight]);
|
||||
|
||||
const handleColumnMoved = useCallback(
|
||||
(e: ColumnMovedEvent) => {
|
||||
if (!e?.api) return;
|
||||
onColumnMoved?.(e);
|
||||
if (autoFitColumns) e.api?.sizeColumnsToFit?.();
|
||||
},
|
||||
[autoFitColumns, onColumnMoved],
|
||||
);
|
||||
|
||||
const handleNewColumnsLoaded = useCallback(
|
||||
(e: NewColumnsLoadedEvent) => {
|
||||
if (!e?.api) return;
|
||||
onNewColumnsLoaded?.(e);
|
||||
if (autoFitColumns) e.api?.sizeColumnsToFit?.();
|
||||
},
|
||||
[autoFitColumns, onNewColumnsLoaded],
|
||||
);
|
||||
|
||||
const handleGridReady = useCallback(
|
||||
(e: GridReadyEvent) => {
|
||||
if (!e?.api) return;
|
||||
onGridReady?.(e);
|
||||
if (autoHeight) e.api.setDomLayout('autoHeight');
|
||||
if (autoFitColumns) e.api?.sizeColumnsToFit?.();
|
||||
},
|
||||
[autoHeight, autoFitColumns, onGridReady],
|
||||
);
|
||||
|
||||
const handleGridSizeChanged = useCallback(
|
||||
(e: GridSizeChangedEvent) => {
|
||||
if (!e?.api) return;
|
||||
onGridSizeChanged?.(e);
|
||||
if (autoFitColumns) e.api?.sizeColumnsToFit?.();
|
||||
},
|
||||
[autoFitColumns, onGridSizeChanged],
|
||||
);
|
||||
|
||||
return (
|
||||
<TableWrapper className="ag-theme-alpine-dark">
|
||||
<TableWrapper
|
||||
ref={deselectRef}
|
||||
className={
|
||||
transparentHeader ? 'ag-header-transparent ag-theme-alpine-dark' : 'ag-theme-alpine-dark'
|
||||
}
|
||||
>
|
||||
<AgGridReact
|
||||
ref={mergedRef}
|
||||
animateRows
|
||||
maintainColumnOrder
|
||||
suppressAsyncEvents
|
||||
suppressContextMenu
|
||||
suppressCopyRowsToClipboard
|
||||
suppressMoveWhenRowDragging
|
||||
suppressPaginationPanel
|
||||
suppressScrollOnNewData
|
||||
blockLoadDebounceMillis={200}
|
||||
cacheBlockSize={300}
|
||||
cacheOverflowSize={1}
|
||||
defaultColDef={defaultColumnDefs}
|
||||
enableCellChangeFlash={false}
|
||||
headerHeight={36}
|
||||
rowBuffer={30}
|
||||
rowSelection="multiple"
|
||||
{...rest}
|
||||
onColumnMoved={handleColumnMoved}
|
||||
onGridReady={handleGridReady}
|
||||
onGridSizeChanged={handleGridSizeChanged}
|
||||
onNewColumnsLoaded={handleNewColumnsLoaded}
|
||||
/>
|
||||
</TableWrapper>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { ChangeEvent } from 'react';
|
||||
import { Stack } from '@mantine/core';
|
||||
import { Divider, Stack } from '@mantine/core';
|
||||
import { MultiSelect } from '/@/renderer/components/select';
|
||||
import { Slider } from '/@/renderer/components/slider';
|
||||
import { Switch } from '/@/renderer/components/switch';
|
||||
@@ -28,9 +28,9 @@ export const SONG_TABLE_COLUMNS = [
|
||||
{ label: 'Date Added', value: TableColumn.DATE_ADDED },
|
||||
{ label: 'Path', value: TableColumn.PATH },
|
||||
{ label: 'Plays', value: TableColumn.PLAY_COUNT },
|
||||
// { label: 'Favorite', value: TableColumn.FAVORITE },
|
||||
// { label: 'Rating', value: TableColumn.RATING },
|
||||
// { label: 'Size', value: TableColumn.SIZE },
|
||||
{ label: 'Size', value: TableColumn.SIZE },
|
||||
{ label: 'Favorite', value: TableColumn.USER_FAVORITE },
|
||||
{ label: 'Rating', value: TableColumn.USER_RATING },
|
||||
// { label: 'Skip', value: TableColumn.SKIP },
|
||||
];
|
||||
|
||||
@@ -47,6 +47,33 @@ export const ALBUM_TABLE_COLUMNS = [
|
||||
{ label: 'Last Played', value: TableColumn.LAST_PLAYED },
|
||||
{ label: 'Date Added', value: TableColumn.DATE_ADDED },
|
||||
{ label: 'Plays', value: TableColumn.PLAY_COUNT },
|
||||
{ label: 'Favorite', value: TableColumn.USER_FAVORITE },
|
||||
{ label: 'Rating', value: TableColumn.USER_RATING },
|
||||
];
|
||||
|
||||
export const ALBUMARTIST_TABLE_COLUMNS = [
|
||||
{ label: 'Row Index', value: TableColumn.ROW_INDEX },
|
||||
{ label: 'Title', value: TableColumn.TITLE },
|
||||
{ label: 'Title (Combined)', value: TableColumn.TITLE_COMBINED },
|
||||
{ label: 'Duration', value: TableColumn.DURATION },
|
||||
{ label: 'Biography', value: TableColumn.BIOGRAPHY },
|
||||
{ label: 'Genre', value: TableColumn.GENRE },
|
||||
{ label: 'Last Played', value: TableColumn.LAST_PLAYED },
|
||||
{ label: 'Plays', value: TableColumn.PLAY_COUNT },
|
||||
{ label: 'Album Count', value: TableColumn.ALBUM_COUNT },
|
||||
{ label: 'Song Count', value: TableColumn.SONG_COUNT },
|
||||
{ label: 'Favorite', value: TableColumn.USER_FAVORITE },
|
||||
{ label: 'Rating', value: TableColumn.USER_RATING },
|
||||
];
|
||||
|
||||
export const PLAYLIST_TABLE_COLUMNS = [
|
||||
{ label: 'Row Index', value: TableColumn.ROW_INDEX },
|
||||
{ label: 'Title', value: TableColumn.TITLE },
|
||||
{ label: 'Title (Combined)', value: TableColumn.TITLE_COMBINED },
|
||||
{ label: 'Duration', value: TableColumn.DURATION },
|
||||
{ label: 'Owner', value: TableColumn.OWNER },
|
||||
// { label: 'Genre', value: TableColumn.GENRE },
|
||||
{ label: 'Song Count', value: TableColumn.SONG_COUNT },
|
||||
];
|
||||
|
||||
interface TableConfigDropdownProps {
|
||||
@@ -143,7 +170,7 @@ export const TableConfigDropdown = ({ type }: TableConfigDropdownProps) => {
|
||||
return (
|
||||
<Stack
|
||||
p="1rem"
|
||||
spacing="xl"
|
||||
spacing="md"
|
||||
>
|
||||
<Stack spacing="xs">
|
||||
<Text>Table Columns</Text>
|
||||
@@ -166,20 +193,17 @@ export const TableConfigDropdown = ({ type }: TableConfigDropdownProps) => {
|
||||
onChangeEnd={handleUpdateRowHeight}
|
||||
/>
|
||||
</Stack>
|
||||
<Stack spacing="xs">
|
||||
<Text>Auto Fit Columns</Text>
|
||||
<Switch
|
||||
defaultChecked={tableConfig[type]?.autoFit}
|
||||
onChange={handleUpdateAutoFit}
|
||||
/>
|
||||
</Stack>
|
||||
<Stack spacing="xs">
|
||||
<Text>Follow Current Song</Text>
|
||||
<Switch
|
||||
defaultChecked={tableConfig[type]?.followCurrentSong}
|
||||
onChange={handleUpdateFollow}
|
||||
/>
|
||||
</Stack>
|
||||
<Divider my="0.5rem" />
|
||||
<Switch
|
||||
defaultChecked={tableConfig[type]?.autoFit}
|
||||
label="Auto-fit columns"
|
||||
onChange={handleUpdateAutoFit}
|
||||
/>
|
||||
<Switch
|
||||
defaultChecked={tableConfig[type]?.followCurrentSong}
|
||||
label="Follow current song"
|
||||
onChange={handleUpdateFollow}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -14,12 +14,20 @@ import { useContainerQuery } from '/@/renderer/hooks';
|
||||
import { TablePagination as TablePaginationType } from '/@/renderer/types';
|
||||
|
||||
interface TablePaginationProps {
|
||||
id?: string;
|
||||
pagination: TablePaginationType;
|
||||
setPagination: (pagination: Partial<TablePaginationType>) => void;
|
||||
setIdPagination?: (id: string, pagination: Partial<TablePaginationType>) => void;
|
||||
setPagination?: (pagination: Partial<TablePaginationType>) => void;
|
||||
tableRef: MutableRefObject<AgGridReactType | null>;
|
||||
}
|
||||
|
||||
export const TablePagination = ({ tableRef, pagination, setPagination }: TablePaginationProps) => {
|
||||
export const TablePagination = ({
|
||||
id,
|
||||
tableRef,
|
||||
pagination,
|
||||
setPagination,
|
||||
setIdPagination,
|
||||
}: TablePaginationProps) => {
|
||||
const [isGoToPageOpen, handlers] = useDisclosure(false);
|
||||
const containerQuery = useContainerQuery();
|
||||
|
||||
@@ -32,7 +40,8 @@ export const TablePagination = ({ tableRef, pagination, setPagination }: TablePa
|
||||
const handlePagination = (index: number) => {
|
||||
const newPage = index - 1;
|
||||
tableRef.current?.api.paginationGoToPage(newPage);
|
||||
setPagination({ currentPage: newPage });
|
||||
setPagination?.({ currentPage: newPage });
|
||||
setIdPagination?.(id || '', { currentPage: newPage });
|
||||
};
|
||||
|
||||
const handleGoSubmit = goToForm.onSubmit((values) => {
|
||||
@@ -43,11 +52,14 @@ export const TablePagination = ({ tableRef, pagination, setPagination }: TablePa
|
||||
|
||||
const newPage = values.pageNumber - 1;
|
||||
tableRef.current?.api.paginationGoToPage(newPage);
|
||||
setPagination({ currentPage: newPage });
|
||||
setPagination?.({ currentPage: newPage });
|
||||
setIdPagination?.(id || '', { currentPage: newPage });
|
||||
});
|
||||
|
||||
const currentPageStartIndex = pagination.currentPage * pagination.itemsPerPage + 1;
|
||||
const currentPageStopIndex = (pagination.currentPage + 1) * pagination.itemsPerPage;
|
||||
const currentPageMaxIndex = (pagination.currentPage + 1) * pagination.itemsPerPage;
|
||||
const currentPageStopIndex =
|
||||
currentPageMaxIndex > pagination.totalItems ? pagination.totalItems : currentPageMaxIndex;
|
||||
|
||||
return (
|
||||
<MotionFlex
|
||||
@@ -96,10 +108,9 @@ export const TablePagination = ({ tableRef, pagination, setPagination }: TablePa
|
||||
>
|
||||
<Popover.Target>
|
||||
<Button
|
||||
compact
|
||||
radius="sm"
|
||||
size="lg"
|
||||
sx={{ height: '32px', padding: 0, width: '32px' }}
|
||||
size="sm"
|
||||
sx={{ height: '26px', padding: '0', width: '26px' }}
|
||||
tooltip={{ label: 'Go to page' }}
|
||||
variant="default"
|
||||
onClick={() => handlers.toggle()}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Box, Center, Divider, Group, Stack } from '@mantine/core';
|
||||
import { Box, Center, Group, Stack } from '@mantine/core';
|
||||
import type { FallbackProps } from 'react-error-boundary';
|
||||
import { RiErrorWarningLine, RiArrowLeftLine } from 'react-icons/ri';
|
||||
import { useNavigate, useRouteError } from 'react-router';
|
||||
import { RiErrorWarningLine } from 'react-icons/ri';
|
||||
import { useRouteError } from 'react-router';
|
||||
import styled from 'styled-components';
|
||||
import { Button, Text } from '/@/renderer/components';
|
||||
|
||||
@@ -9,7 +9,9 @@ const Container = styled(Box)`
|
||||
background: var(--main-bg);
|
||||
`;
|
||||
|
||||
export const ErrorFallback = ({ error, resetErrorBoundary }: FallbackProps) => {
|
||||
export const ErrorFallback = ({ resetErrorBoundary }: FallbackProps) => {
|
||||
const error = useRouteError() as any;
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<Center sx={{ height: '100vh' }}>
|
||||
@@ -33,51 +35,3 @@ export const ErrorFallback = ({ error, resetErrorBoundary }: FallbackProps) => {
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
|
||||
export const RouteErrorBoundary = () => {
|
||||
const navigate = useNavigate();
|
||||
const error = useRouteError() as any;
|
||||
console.log('error', error);
|
||||
|
||||
const handleReload = () => {
|
||||
navigate(0);
|
||||
};
|
||||
|
||||
const handleReturn = () => {
|
||||
navigate(-1);
|
||||
};
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<Center sx={{ height: '100vh' }}>
|
||||
<Stack sx={{ maxWidth: '50%' }}>
|
||||
<Group>
|
||||
<RiErrorWarningLine
|
||||
color="var(--danger-color)"
|
||||
size={30}
|
||||
/>
|
||||
<Text size="lg">Something went wrong</Text>
|
||||
</Group>
|
||||
<Divider my={5} />
|
||||
<Text size="sm">{error?.message}</Text>
|
||||
<Group grow>
|
||||
<Button
|
||||
leftIcon={<RiArrowLeftLine />}
|
||||
sx={{ flex: 0.5 }}
|
||||
variant="default"
|
||||
onClick={handleReturn}
|
||||
>
|
||||
Go back
|
||||
</Button>
|
||||
<Button
|
||||
variant="filled"
|
||||
onClick={handleReload}
|
||||
>
|
||||
Reload
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Center>
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -22,7 +22,7 @@ export const MpvRequired = () => {
|
||||
|
||||
return (
|
||||
<>
|
||||
<Text size="lg">Set your MPV executable location below and restart the application.</Text>
|
||||
<Text>Set your MPV executable location below and restart the application.</Text>
|
||||
<Text>
|
||||
MPV is available at the following:{' '}
|
||||
<a
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import { Center, Stack, Group, Divider, Box } from '@mantine/core';
|
||||
import { RiArrowLeftSLine, RiErrorWarningLine, RiHome4Line } from 'react-icons/ri';
|
||||
import { useNavigate, useRouteError } from 'react-router';
|
||||
import { Button, Text } from '/@/renderer/components';
|
||||
import { AppRoute } from '/@/renderer/router/routes';
|
||||
|
||||
const RouteErrorBoundary = () => {
|
||||
const navigate = useNavigate();
|
||||
const error = useRouteError() as any;
|
||||
console.log('error', error);
|
||||
|
||||
const handleReload = () => {
|
||||
navigate(0);
|
||||
};
|
||||
|
||||
const handleReturn = () => {
|
||||
navigate(-1);
|
||||
};
|
||||
|
||||
const handleHome = () => {
|
||||
navigate(AppRoute.HOME);
|
||||
};
|
||||
|
||||
return (
|
||||
<Box bg="var(--main-bg)">
|
||||
<Center sx={{ height: '100vh' }}>
|
||||
<Stack sx={{ maxWidth: '50%' }}>
|
||||
<Group>
|
||||
<Button
|
||||
px={10}
|
||||
variant="subtle"
|
||||
onClick={handleReturn}
|
||||
>
|
||||
<RiArrowLeftSLine size={20} />
|
||||
</Button>
|
||||
<RiErrorWarningLine
|
||||
color="var(--danger-color)"
|
||||
size={30}
|
||||
/>
|
||||
<Text size="lg">Something went wrong</Text>
|
||||
</Group>
|
||||
<Divider my={5} />
|
||||
<Text size="sm">{error?.message}</Text>
|
||||
<Group
|
||||
grow
|
||||
spacing="sm"
|
||||
>
|
||||
<Button
|
||||
leftIcon={<RiHome4Line />}
|
||||
size="md"
|
||||
sx={{ flex: 0.5 }}
|
||||
variant="default"
|
||||
onClick={handleHome}
|
||||
>
|
||||
Go home
|
||||
</Button>
|
||||
<Button
|
||||
size="md"
|
||||
variant="filled"
|
||||
onClick={handleReload}
|
||||
>
|
||||
Reload
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Center>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default RouteErrorBoundary;
|
||||
@@ -6,11 +6,11 @@ export const ServerCredentialRequired = () => {
|
||||
|
||||
return (
|
||||
<>
|
||||
<Text size="lg">
|
||||
<Text>
|
||||
The selected server '{currentServer?.name}' requires an additional login to
|
||||
access.
|
||||
</Text>
|
||||
<Text size="lg">
|
||||
<Text>
|
||||
Add your credentials in the 'manage servers' menu or switch to a different server.
|
||||
</Text>
|
||||
</>
|
||||
|
||||
@@ -1,10 +1,24 @@
|
||||
import { Text } from '/@/renderer/components';
|
||||
import { RiMenuFill } from 'react-icons/ri';
|
||||
import { Button, DropdownMenu, Text } from '/@/renderer/components';
|
||||
import { AppMenu } from '/@/renderer/features/titlebar/components/app-menu';
|
||||
|
||||
export const ServerRequired = () => {
|
||||
return (
|
||||
<>
|
||||
<Text size="xl">No server selected.</Text>
|
||||
<Text>Add or select a server in the file menu.</Text>
|
||||
<Text>No server selected.</Text>
|
||||
<DropdownMenu>
|
||||
<DropdownMenu.Target>
|
||||
<Button
|
||||
leftIcon={<RiMenuFill />}
|
||||
variant="filled"
|
||||
>
|
||||
Open menu
|
||||
</Button>
|
||||
</DropdownMenu.Target>
|
||||
<DropdownMenu.Dropdown>
|
||||
<AppMenu />
|
||||
</DropdownMenu.Dropdown>
|
||||
</DropdownMenu>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useState, useEffect } from 'react';
|
||||
import { Center, Group, Stack } from '@mantine/core';
|
||||
import isElectron from 'is-electron';
|
||||
import { RiCheckFill } from 'react-icons/ri';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Link, Navigate } from 'react-router-dom';
|
||||
import { Button, PageHeader, Text } from '/@/renderer/components';
|
||||
import { ActionRequiredContainer } from '/@/renderer/features/action-required/components/action-required-container';
|
||||
import { MpvRequired } from '/@/renderer/features/action-required/components/mpv-required';
|
||||
@@ -24,7 +24,12 @@ const ActionRequiredRoute = () => {
|
||||
const getMpvPath = async () => {
|
||||
if (!isElectron()) return setIsMpvRequired(false);
|
||||
const mpvPath = await localSettings.get('mpv_path');
|
||||
return setIsMpvRequired(!mpvPath);
|
||||
|
||||
if (mpvPath) {
|
||||
return setIsMpvRequired(false);
|
||||
}
|
||||
|
||||
return setIsMpvRequired(true);
|
||||
};
|
||||
|
||||
getMpvPath();
|
||||
@@ -48,6 +53,8 @@ const ActionRequiredRoute = () => {
|
||||
},
|
||||
];
|
||||
|
||||
console.log(checks);
|
||||
|
||||
const canReturnHome = checks.every((c) => c.valid);
|
||||
const displayedCheck = checks.find((c) => !c.valid);
|
||||
|
||||
@@ -69,6 +76,7 @@ const ActionRequiredRoute = () => {
|
||||
<Stack mt="2rem">
|
||||
{canReturnHome && (
|
||||
<>
|
||||
<Navigate to={AppRoute.HOME} />
|
||||
<Group
|
||||
noWrap
|
||||
position="center"
|
||||
|
||||
@@ -1,53 +1,56 @@
|
||||
import { MutableRefObject, useCallback, useMemo } from 'react';
|
||||
import {
|
||||
Button,
|
||||
DropdownMenu,
|
||||
getColumnDefs,
|
||||
GridCarousel,
|
||||
Text,
|
||||
TextTitle,
|
||||
useFixedTableHeader,
|
||||
VirtualTable,
|
||||
} from '/@/renderer/components';
|
||||
import { CellContextMenuEvent, ColDef } from '@ag-grid-community/core';
|
||||
import { ColDef, RowDoubleClickedEvent, RowHeightParams, RowNode } from '@ag-grid-community/core';
|
||||
import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/lib/agGridReact';
|
||||
import { Group, Stack } from '@mantine/core';
|
||||
import { Box, Group, Stack } from '@mantine/core';
|
||||
import { useSetState } from '@mantine/hooks';
|
||||
import sortBy from 'lodash/sortBy';
|
||||
import { RiHeartLine, RiMoreFill } from 'react-icons/ri';
|
||||
import { useParams } from 'react-router';
|
||||
import { RiDiscFill, RiHeartFill, RiHeartLine, RiMoreFill } from 'react-icons/ri';
|
||||
import { generatePath, useParams } from 'react-router';
|
||||
import { useAlbumDetail } from '/@/renderer/features/albums/queries/album-detail-query';
|
||||
import { useSongListStore } from '/@/renderer/store';
|
||||
import { Link } from 'react-router-dom';
|
||||
import styled from 'styled-components';
|
||||
import { AppRoute } from '/@/renderer/router/routes';
|
||||
import { useContainerQuery } from '/@/renderer/hooks';
|
||||
import { useHandlePlayQueueAdd } from '/@/renderer/features/player/hooks/use-handle-playqueue-add';
|
||||
import { usePlayButtonBehavior } from '/@/renderer/store/settings.store';
|
||||
import { openContextMenu } from '/@/renderer/features/context-menu';
|
||||
import { LibraryItem, Play } from '/@/renderer/types';
|
||||
import { SONG_CONTEXT_MENU_ITEMS } from '/@/renderer/features/context-menu/context-menu-items';
|
||||
import { PlayButton, PLAY_TYPES } from '/@/renderer/features/shared';
|
||||
import { PersistedTableColumn, usePlayButtonBehavior } from '/@/renderer/store/settings.store';
|
||||
import {
|
||||
useHandleGeneralContextMenu,
|
||||
useHandleTableContextMenu,
|
||||
} from '/@/renderer/features/context-menu';
|
||||
import { Play, ServerType, TableColumn } from '/@/renderer/types';
|
||||
import {
|
||||
ALBUM_CONTEXT_MENU_ITEMS,
|
||||
SONG_CONTEXT_MENU_ITEMS,
|
||||
} from '/@/renderer/features/context-menu/context-menu-items';
|
||||
import { PlayButton, useCreateFavorite, useDeleteFavorite } from '/@/renderer/features/shared';
|
||||
import { useAlbumList } from '/@/renderer/features/albums/queries/album-list-query';
|
||||
import { AlbumListSort, SortOrder } from '/@/renderer/api/types';
|
||||
import { AlbumListSort, LibraryItem, QueueSong, SortOrder } from '/@/renderer/api/types';
|
||||
import { usePlayQueueAdd } from '/@/renderer/features/player';
|
||||
|
||||
const isFullWidthRow = (node: RowNode) => {
|
||||
return node.id?.includes('disc-');
|
||||
};
|
||||
|
||||
const ContentContainer = styled.div`
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-width: 1920px;
|
||||
padding: 1rem 2rem;
|
||||
padding: 1rem 2rem 5rem;
|
||||
overflow: hidden;
|
||||
|
||||
.ag-theme-alpine-dark {
|
||||
--ag-header-background-color: rgba(0, 0, 0, 0%);
|
||||
--ag-header-background-color: rgba(0, 0, 0, 0%) !important;
|
||||
}
|
||||
|
||||
.ag-header-container {
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.ag-header-cell-resize {
|
||||
top: 25%;
|
||||
width: 7px;
|
||||
height: 50%;
|
||||
background-color: rgb(70, 70, 70, 20%);
|
||||
.ag-header {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -59,24 +62,85 @@ export const AlbumDetailContent = ({ tableRef }: AlbumDetailContentProps) => {
|
||||
const { albumId } = useParams() as { albumId: string };
|
||||
const detailQuery = useAlbumDetail({ id: albumId });
|
||||
const cq = useContainerQuery();
|
||||
const handlePlayQueueAdd = useHandlePlayQueueAdd();
|
||||
const handlePlayQueueAdd = usePlayQueueAdd();
|
||||
|
||||
const page = useSongListStore();
|
||||
// TODO: Make this customizable
|
||||
const columnDefs: ColDef[] = useMemo(() => {
|
||||
const userRatingColumn =
|
||||
detailQuery?.data?.serverType !== ServerType.JELLYFIN
|
||||
? [
|
||||
{
|
||||
column: TableColumn.USER_RATING,
|
||||
width: 0,
|
||||
},
|
||||
]
|
||||
: [];
|
||||
|
||||
const columnDefs: ColDef[] = useMemo(
|
||||
() =>
|
||||
getColumnDefs(page.table.columns).filter((c) => c.colId !== 'album' && c.colId !== 'artist'),
|
||||
[page.table.columns],
|
||||
);
|
||||
const cols: PersistedTableColumn[] = [
|
||||
{
|
||||
column: TableColumn.TRACK_NUMBER,
|
||||
width: 0,
|
||||
},
|
||||
{
|
||||
column: TableColumn.TITLE_COMBINED,
|
||||
width: 0,
|
||||
},
|
||||
|
||||
const defaultColumnDefs: ColDef = useMemo(() => {
|
||||
return {
|
||||
lockPinned: true,
|
||||
lockVisible: true,
|
||||
resizable: true,
|
||||
};
|
||||
{
|
||||
column: TableColumn.DURATION,
|
||||
width: 0,
|
||||
},
|
||||
{
|
||||
column: TableColumn.BIT_RATE,
|
||||
width: 0,
|
||||
},
|
||||
{
|
||||
column: TableColumn.PLAY_COUNT,
|
||||
width: 0,
|
||||
},
|
||||
{
|
||||
column: TableColumn.LAST_PLAYED,
|
||||
width: 0,
|
||||
},
|
||||
...userRatingColumn,
|
||||
{
|
||||
column: TableColumn.USER_FAVORITE,
|
||||
width: 0,
|
||||
},
|
||||
];
|
||||
return getColumnDefs(cols).filter((c) => c.colId !== 'album' && c.colId !== 'artist');
|
||||
}, [detailQuery?.data?.serverType]);
|
||||
|
||||
const getRowHeight = useCallback((params: RowHeightParams) => {
|
||||
if (isFullWidthRow(params.node)) {
|
||||
return 45;
|
||||
}
|
||||
|
||||
return 60;
|
||||
}, []);
|
||||
|
||||
const songsRowData = useMemo(() => {
|
||||
if (!detailQuery.data?.songs) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const uniqueDiscNumbers = new Set(detailQuery.data?.songs.map((s) => s.discNumber));
|
||||
|
||||
if (uniqueDiscNumbers.size === 1) {
|
||||
return detailQuery.data?.songs;
|
||||
}
|
||||
|
||||
const rowData: (QueueSong | { id: string; name: string })[] = [];
|
||||
|
||||
for (const discNumber of uniqueDiscNumbers.values()) {
|
||||
const songsByDiscNumber = detailQuery.data?.songs.filter((s) => s.discNumber === discNumber);
|
||||
rowData.push({ id: `disc-${discNumber}`, name: `DISC ${discNumber}` });
|
||||
rowData.push(...songsByDiscNumber);
|
||||
}
|
||||
|
||||
return rowData;
|
||||
}, [detailQuery.data?.songs]);
|
||||
|
||||
const [pagination, setPagination] = useSetState({
|
||||
artist: 0,
|
||||
});
|
||||
@@ -134,8 +198,8 @@ export const AlbumDetailContent = ({ tableRef }: AlbumDetailContentProps) => {
|
||||
},
|
||||
title: (
|
||||
<TextTitle
|
||||
fw="bold"
|
||||
order={3}
|
||||
order={2}
|
||||
weight={700}
|
||||
>
|
||||
More from this artist
|
||||
</TextTitle>
|
||||
@@ -153,99 +217,150 @@ export const AlbumDetailContent = ({ tableRef }: AlbumDetailContentProps) => {
|
||||
});
|
||||
};
|
||||
|
||||
const handleContextMenu = (e: CellContextMenuEvent) => {
|
||||
if (!e.event) return;
|
||||
const clickEvent = e.event as MouseEvent;
|
||||
clickEvent.preventDefault();
|
||||
const handleContextMenu = useHandleTableContextMenu(LibraryItem.SONG, SONG_CONTEXT_MENU_ITEMS);
|
||||
|
||||
const selectedNodes = e.api.getSelectedNodes();
|
||||
const selectedIds = selectedNodes.map((node) => node.data.id);
|
||||
let selectedRows = sortBy(selectedNodes, ['rowIndex']).map((node) => node.data);
|
||||
|
||||
if (!selectedIds.includes(e.data.id)) {
|
||||
e.api.deselectAll();
|
||||
e.node.setSelected(true);
|
||||
selectedRows = [e.data];
|
||||
}
|
||||
|
||||
openContextMenu({
|
||||
data: selectedRows,
|
||||
menuItems: SONG_CONTEXT_MENU_ITEMS,
|
||||
type: LibraryItem.SONG,
|
||||
xPos: clickEvent.clientX,
|
||||
yPos: clickEvent.clientY,
|
||||
const handleRowDoubleClick = (e: RowDoubleClickedEvent<QueueSong>) => {
|
||||
if (!e.data) return;
|
||||
handlePlayQueueAdd?.({
|
||||
byData: [e.data],
|
||||
play: playButtonBehavior,
|
||||
});
|
||||
};
|
||||
|
||||
const createFavoriteMutation = useCreateFavorite();
|
||||
const deleteFavoriteMutation = useDeleteFavorite();
|
||||
|
||||
const handleFavorite = () => {
|
||||
if (!detailQuery?.data) return;
|
||||
|
||||
if (detailQuery.data.userFavorite) {
|
||||
deleteFavoriteMutation.mutate({
|
||||
query: {
|
||||
id: [detailQuery.data.id],
|
||||
type: LibraryItem.ALBUM,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
createFavoriteMutation.mutate({
|
||||
query: {
|
||||
id: [detailQuery.data.id],
|
||||
type: LibraryItem.ALBUM,
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const showGenres = detailQuery?.data?.genres ? detailQuery?.data?.genres.length !== 0 : false;
|
||||
|
||||
const { intersectRef, tableContainerRef } = useFixedTableHeader();
|
||||
|
||||
const handleGeneralContextMenu = useHandleGeneralContextMenu(
|
||||
LibraryItem.ALBUM,
|
||||
ALBUM_CONTEXT_MENU_ITEMS,
|
||||
);
|
||||
|
||||
return (
|
||||
<ContentContainer>
|
||||
<Group
|
||||
pb="2rem"
|
||||
pt="1rem"
|
||||
spacing="lg"
|
||||
>
|
||||
<PlayButton onClick={() => handlePlay(playButtonBehavior)} />
|
||||
<Group spacing="xs">
|
||||
<Button
|
||||
compact
|
||||
disabled
|
||||
variant="subtle"
|
||||
>
|
||||
<RiHeartLine size={20} />
|
||||
</Button>
|
||||
<DropdownMenu position="bottom-start">
|
||||
<DropdownMenu.Target>
|
||||
<Button
|
||||
compact
|
||||
variant="subtle"
|
||||
>
|
||||
<RiMoreFill size={20} />
|
||||
</Button>
|
||||
</DropdownMenu.Target>
|
||||
<DropdownMenu.Dropdown>
|
||||
{PLAY_TYPES.filter((type) => type.play !== playButtonBehavior).map((type) => (
|
||||
<DropdownMenu.Item
|
||||
key={`playtype-${type.play}`}
|
||||
onClick={() => handlePlay(type.play)}
|
||||
>
|
||||
{type.label}
|
||||
</DropdownMenu.Item>
|
||||
))}
|
||||
<DropdownMenu.Divider />
|
||||
<DropdownMenu.Item disabled>Add to playlist</DropdownMenu.Item>
|
||||
</DropdownMenu.Dropdown>
|
||||
</DropdownMenu>
|
||||
<Box component="section">
|
||||
<Group
|
||||
ref={showGenres ? null : intersectRef}
|
||||
className="test"
|
||||
py="1rem"
|
||||
spacing="md"
|
||||
>
|
||||
<PlayButton onClick={() => handlePlay(playButtonBehavior)} />
|
||||
<Group spacing="xs">
|
||||
<Button
|
||||
compact
|
||||
loading={createFavoriteMutation.isLoading || deleteFavoriteMutation.isLoading}
|
||||
variant="subtle"
|
||||
onClick={handleFavorite}
|
||||
>
|
||||
{detailQuery?.data?.userFavorite ? (
|
||||
<RiHeartFill
|
||||
color="red"
|
||||
size={20}
|
||||
/>
|
||||
) : (
|
||||
<RiHeartLine size={20} />
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
compact
|
||||
variant="subtle"
|
||||
onClick={(e) => {
|
||||
if (!detailQuery?.data) return;
|
||||
handleGeneralContextMenu(e, [detailQuery.data!]);
|
||||
}}
|
||||
>
|
||||
<RiMoreFill size={20} />
|
||||
</Button>
|
||||
</Group>
|
||||
</Group>
|
||||
</Group>
|
||||
<VirtualTable
|
||||
ref={tableRef}
|
||||
animateRows
|
||||
detailRowAutoHeight
|
||||
maintainColumnOrder
|
||||
suppressCellFocus
|
||||
suppressCopyRowsToClipboard
|
||||
suppressLoadingOverlay
|
||||
suppressMoveWhenRowDragging
|
||||
suppressPaginationPanel
|
||||
suppressRowDrag
|
||||
suppressScrollOnNewData
|
||||
columnDefs={columnDefs}
|
||||
defaultColDef={defaultColumnDefs}
|
||||
enableCellChangeFlash={false}
|
||||
getRowId={(data) => data.data.id}
|
||||
rowData={detailQuery.data?.songs}
|
||||
rowHeight={60}
|
||||
rowSelection="multiple"
|
||||
onCellContextMenu={handleContextMenu}
|
||||
onColumnResized={() => console.log('resize')}
|
||||
onGridReady={(params) => {
|
||||
params.api.setDomLayout('autoHeight');
|
||||
params.api.sizeColumnsToFit();
|
||||
}}
|
||||
onGridSizeChanged={(params) => {
|
||||
params.api.sizeColumnsToFit();
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
{showGenres && (
|
||||
<Box
|
||||
ref={showGenres ? intersectRef : null}
|
||||
component="section"
|
||||
py="1rem"
|
||||
>
|
||||
<Group>
|
||||
{detailQuery?.data?.genres?.map((genre) => (
|
||||
<Button
|
||||
key={`genre-${genre.id}`}
|
||||
compact
|
||||
component={Link}
|
||||
radius="md"
|
||||
size="sm"
|
||||
to={generatePath(`${AppRoute.LIBRARY_ALBUMS}?genre=${genre.id}`, { albumId })}
|
||||
variant="default"
|
||||
>
|
||||
{genre.name}
|
||||
</Button>
|
||||
))}
|
||||
</Group>
|
||||
</Box>
|
||||
)}
|
||||
<Box ref={tableContainerRef}>
|
||||
<VirtualTable
|
||||
ref={tableRef}
|
||||
autoFitColumns
|
||||
autoHeight
|
||||
deselectOnClickOutside
|
||||
suppressCellFocus
|
||||
suppressHorizontalScroll
|
||||
suppressLoadingOverlay
|
||||
suppressRowDrag
|
||||
columnDefs={columnDefs}
|
||||
enableCellChangeFlash={false}
|
||||
fullWidthCellRenderer={(data: any) => {
|
||||
if (!data.data) return null;
|
||||
return (
|
||||
<Group
|
||||
align="center"
|
||||
h="100%"
|
||||
spacing="sm"
|
||||
>
|
||||
<RiDiscFill />
|
||||
<Text>{data.data.name}</Text>
|
||||
</Group>
|
||||
);
|
||||
}}
|
||||
getRowHeight={getRowHeight}
|
||||
getRowId={(data) => data.data.id}
|
||||
isFullWidthRow={(data) => {
|
||||
return isFullWidthRow(data.rowNode) || false;
|
||||
}}
|
||||
isRowSelectable={(data) => {
|
||||
if (isFullWidthRow(data.data)) return false;
|
||||
return true;
|
||||
}}
|
||||
rowData={songsRowData}
|
||||
rowSelection="multiple"
|
||||
onCellContextMenu={handleContextMenu}
|
||||
onRowDoubleClicked={handleRowDoubleClick}
|
||||
/>
|
||||
</Box>
|
||||
<Stack
|
||||
ref={cq.ref}
|
||||
mt="5rem"
|
||||
@@ -265,13 +380,14 @@ export const AlbumDetailContent = ({ tableRef }: AlbumDetailContentProps) => {
|
||||
arrayProperty: 'name',
|
||||
property: 'albumArtists',
|
||||
route: {
|
||||
route: AppRoute.LIBRARY_ALBUMARTISTS_DETAIL,
|
||||
route: AppRoute.LIBRARY_ALBUM_ARTISTS_DETAIL,
|
||||
slugs: [{ idProperty: 'id', slugProperty: 'albumArtistId' }],
|
||||
},
|
||||
},
|
||||
]}
|
||||
containerWidth={cq.width}
|
||||
data={carousel.data}
|
||||
itemType={LibraryItem.ALBUM}
|
||||
loading={carousel.loading}
|
||||
pagination={carousel.pagination}
|
||||
uniqueId={carousel.uniqueId}
|
||||
|
||||
@@ -1,182 +1,137 @@
|
||||
import { Center, Group } from '@mantine/core';
|
||||
import { Fragment } from 'react';
|
||||
import { RiAlbumFill } from 'react-icons/ri';
|
||||
import { Group, Stack } from '@mantine/core';
|
||||
import { forwardRef, Fragment, Ref } from 'react';
|
||||
import { generatePath, useParams } from 'react-router';
|
||||
import { Link } from 'react-router-dom';
|
||||
import styled from 'styled-components';
|
||||
import { Text, TextTitle } from '/@/renderer/components';
|
||||
import { LibraryItem, ServerType } from '/@/renderer/api/types';
|
||||
import { Rating, Text } from '/@/renderer/components';
|
||||
import { useAlbumDetail } from '/@/renderer/features/albums/queries/album-detail-query';
|
||||
import { LibraryHeader, useUpdateRating } from '/@/renderer/features/shared';
|
||||
import { useContainerQuery } from '/@/renderer/hooks';
|
||||
import { AppRoute } from '/@/renderer/router/routes';
|
||||
|
||||
const HeaderContainer = styled.div`
|
||||
position: relative;
|
||||
display: grid;
|
||||
grid-auto-columns: 1fr;
|
||||
grid-template-areas: 'image info';
|
||||
grid-template-rows: 1fr;
|
||||
grid-template-columns: 250px minmax(0, 1fr);
|
||||
gap: 0.5rem;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
height: 30vh;
|
||||
min-height: 340px;
|
||||
max-height: 500px;
|
||||
padding: 5rem 2rem 2rem;
|
||||
`;
|
||||
|
||||
const CoverImageWrapper = styled.div`
|
||||
z-index: 15;
|
||||
display: flex;
|
||||
grid-area: image;
|
||||
align-items: flex-end;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
filter: drop-shadow(0 0 8px rgb(0, 0, 0, 50%));
|
||||
`;
|
||||
|
||||
const MetadataWrapper = styled.div`
|
||||
z-index: 15;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
grid-area: info;
|
||||
justify-content: flex-end;
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
const StyledImage = styled.img`
|
||||
object-fit: cover;
|
||||
`;
|
||||
|
||||
const BackgroundImage = styled.div<{ background: string }>`
|
||||
position: absolute;
|
||||
top: 0;
|
||||
z-index: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: ${(props) => props.background};
|
||||
`;
|
||||
|
||||
const BackgroundImageOverlay = styled.div`
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
z-index: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: linear-gradient(180deg, rgba(25, 26, 28, 5%), var(--main-bg));
|
||||
`;
|
||||
import { formatDurationString } from '/@/renderer/utils';
|
||||
|
||||
interface AlbumDetailHeaderProps {
|
||||
background: string;
|
||||
}
|
||||
|
||||
export const AlbumDetailHeader = ({ background }: AlbumDetailHeaderProps) => {
|
||||
const { albumId } = useParams() as { albumId: string };
|
||||
const detailQuery = useAlbumDetail({ id: albumId });
|
||||
const cq = useContainerQuery();
|
||||
export const AlbumDetailHeader = forwardRef(
|
||||
({ background }: AlbumDetailHeaderProps, ref: Ref<HTMLDivElement>) => {
|
||||
const { albumId } = useParams() as { albumId: string };
|
||||
const detailQuery = useAlbumDetail({ id: albumId });
|
||||
const cq = useContainerQuery();
|
||||
|
||||
const titleSize = cq.isXl
|
||||
? '6rem'
|
||||
: cq.isLg
|
||||
? '5.5rem'
|
||||
: cq.isMd
|
||||
? '4.5rem'
|
||||
: cq.isSm
|
||||
? '3.5rem'
|
||||
: '2rem';
|
||||
const metadataItems = [
|
||||
{
|
||||
id: 'releaseYear',
|
||||
secondary: false,
|
||||
value: detailQuery?.data?.releaseYear,
|
||||
},
|
||||
{
|
||||
id: 'songCount',
|
||||
secondary: false,
|
||||
value: `${detailQuery?.data?.songCount} songs`,
|
||||
},
|
||||
{
|
||||
id: 'duration',
|
||||
secondary: true,
|
||||
value: detailQuery?.data?.duration && formatDurationString(detailQuery.data.duration),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<HeaderContainer ref={cq.ref}>
|
||||
<BackgroundImage background={background} />
|
||||
<BackgroundImageOverlay />
|
||||
<CoverImageWrapper>
|
||||
{detailQuery?.data?.imageUrl ? (
|
||||
<StyledImage
|
||||
alt="cover"
|
||||
height={225}
|
||||
src={detailQuery?.data.imageUrl}
|
||||
width={225}
|
||||
/>
|
||||
) : (
|
||||
<Center
|
||||
sx={{
|
||||
background: 'var(--placeholder-bg)',
|
||||
borderRadius: 'var(--card-default-radius)',
|
||||
height: `${80}px`,
|
||||
width: `${80}px`,
|
||||
}}
|
||||
>
|
||||
<RiAlbumFill
|
||||
color="var(--placeholder-fg)"
|
||||
size={35}
|
||||
/>
|
||||
</Center>
|
||||
)}
|
||||
</CoverImageWrapper>
|
||||
<MetadataWrapper>
|
||||
<Group>
|
||||
<Text
|
||||
$link
|
||||
component={Link}
|
||||
fw="600"
|
||||
sx={{ textTransform: 'uppercase' }}
|
||||
to={AppRoute.LIBRARY_ALBUMS}
|
||||
>
|
||||
Album
|
||||
</Text>
|
||||
{detailQuery?.data?.releaseYear && (
|
||||
<>
|
||||
<Text>•</Text>
|
||||
<Text>{detailQuery?.data?.releaseYear}</Text>
|
||||
</>
|
||||
)}
|
||||
</Group>
|
||||
<TextTitle
|
||||
fw="900"
|
||||
lh="1"
|
||||
mb="0.12em"
|
||||
mt=".08em"
|
||||
sx={{ fontSize: titleSize }}
|
||||
const updateRatingMutation = useUpdateRating();
|
||||
|
||||
const handleUpdateRating = (rating: number) => {
|
||||
if (!detailQuery?.data) return;
|
||||
|
||||
updateRatingMutation.mutate({
|
||||
_serverId: detailQuery?.data.serverId,
|
||||
query: {
|
||||
item: [detailQuery.data],
|
||||
rating,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleClearRating = () => {
|
||||
if (!detailQuery?.data || !detailQuery?.data.userRating) return;
|
||||
|
||||
updateRatingMutation.mutate({
|
||||
_serverId: detailQuery.data.serverId,
|
||||
query: {
|
||||
item: [detailQuery.data],
|
||||
rating: 0,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const showRating = detailQuery?.data?.serverType === ServerType.NAVIDROME;
|
||||
|
||||
return (
|
||||
<Stack ref={cq.ref}>
|
||||
<LibraryHeader
|
||||
ref={ref}
|
||||
background={background}
|
||||
imageUrl={detailQuery?.data?.imageUrl}
|
||||
item={{ route: AppRoute.LIBRARY_ALBUMS, type: LibraryItem.ALBUM }}
|
||||
title={detailQuery?.data?.name || ''}
|
||||
>
|
||||
{detailQuery?.data?.name}
|
||||
</TextTitle>
|
||||
<Group
|
||||
spacing="xs"
|
||||
sx={{
|
||||
WebkitBoxOrient: 'vertical',
|
||||
WebkitLineClamp: 2,
|
||||
display: '-webkit-box',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
{detailQuery?.data?.albumArtists.map((artist, index) => (
|
||||
<Fragment key={`artist-${artist.id}`}>
|
||||
{index > 0 && (
|
||||
<Text
|
||||
$noSelect
|
||||
sx={{
|
||||
display: 'inline-block',
|
||||
padding: '0 0.5rem',
|
||||
}}
|
||||
>
|
||||
•
|
||||
</Text>
|
||||
<Stack spacing="sm">
|
||||
<Group spacing="sm">
|
||||
{metadataItems.map((item, index) => (
|
||||
<Fragment key={`item-${item.id}-${index}`}>
|
||||
{index > 0 && <Text $noSelect>•</Text>}
|
||||
<Text $secondary={item.secondary}>{item.value}</Text>
|
||||
</Fragment>
|
||||
))}
|
||||
{showRating && (
|
||||
<>
|
||||
<Text $noSelect>•</Text>
|
||||
<Rating
|
||||
readOnly={detailQuery?.isFetching || updateRatingMutation.isLoading}
|
||||
value={detailQuery?.data?.userRating || 0}
|
||||
onChange={handleUpdateRating}
|
||||
onClick={handleClearRating}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<Text
|
||||
$link
|
||||
component={Link}
|
||||
fw="600"
|
||||
to={generatePath(AppRoute.LIBRARY_ALBUMARTISTS_DETAIL, {
|
||||
albumArtistId: artist.id,
|
||||
})}
|
||||
>
|
||||
{artist.name}
|
||||
</Text>
|
||||
</Fragment>
|
||||
))}
|
||||
</Group>
|
||||
</MetadataWrapper>
|
||||
</HeaderContainer>
|
||||
);
|
||||
};
|
||||
</Group>
|
||||
<Group
|
||||
spacing="sm"
|
||||
sx={{
|
||||
WebkitBoxOrient: 'vertical',
|
||||
WebkitLineClamp: 2,
|
||||
display: '-webkit-box',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
{detailQuery?.data?.albumArtists.map((artist, index) => (
|
||||
<Fragment key={`artist-${artist.id}`}>
|
||||
{index > 0 && (
|
||||
<Text
|
||||
sx={{
|
||||
display: 'inline-block',
|
||||
padding: '0 0.5rem',
|
||||
}}
|
||||
>
|
||||
•
|
||||
</Text>
|
||||
)}
|
||||
<Text
|
||||
$link
|
||||
component={Link}
|
||||
to={generatePath(AppRoute.LIBRARY_ALBUM_ARTISTS_DETAIL, {
|
||||
albumArtistId: artist.id,
|
||||
})}
|
||||
weight={600}
|
||||
>
|
||||
{artist.name}
|
||||
</Text>
|
||||
</Fragment>
|
||||
))}
|
||||
</Group>
|
||||
</Stack>
|
||||
</LibraryHeader>
|
||||
</Stack>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -8,16 +8,14 @@ import {
|
||||
VirtualTable,
|
||||
} from '/@/renderer/components';
|
||||
import { AppRoute } from '/@/renderer/router/routes';
|
||||
import { ListDisplayType, CardRow, LibraryItem } from '/@/renderer/types';
|
||||
import { ListDisplayType, CardRow } from '/@/renderer/types';
|
||||
import AutoSizer from 'react-virtualized-auto-sizer';
|
||||
import { MutableRefObject, useCallback, useMemo } from 'react';
|
||||
import { MutableRefObject, useCallback, useMemo, useState } from 'react';
|
||||
import { ListOnScrollProps } from 'react-window';
|
||||
import { api } from '/@/renderer/api';
|
||||
import { controller } from '/@/renderer/api/controller';
|
||||
import { queryKeys } from '/@/renderer/api/query-keys';
|
||||
import { Album, AlbumListSort } from '/@/renderer/api/types';
|
||||
import { useAlbumList } from '/@/renderer/features/albums/queries/album-list-query';
|
||||
import { useHandlePlayQueueAdd } from '/@/renderer/features/player/hooks/use-handle-playqueue-add';
|
||||
import { Album, AlbumListQuery, AlbumListSort, LibraryItem } from '/@/renderer/api/types';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import {
|
||||
useCurrentServer,
|
||||
@@ -26,11 +24,12 @@ import {
|
||||
useAlbumTablePagination,
|
||||
useSetAlbumTable,
|
||||
useSetAlbumTablePagination,
|
||||
useAlbumListItemData,
|
||||
AlbumListFilter,
|
||||
} from '/@/renderer/store';
|
||||
import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/lib/agGridReact';
|
||||
import {
|
||||
BodyScrollEvent,
|
||||
CellContextMenuEvent,
|
||||
ColDef,
|
||||
GridReadyEvent,
|
||||
IDatasource,
|
||||
@@ -39,23 +38,34 @@ import {
|
||||
} from '@ag-grid-community/core';
|
||||
import { AnimatePresence } from 'framer-motion';
|
||||
import debounce from 'lodash/debounce';
|
||||
import { openContextMenu } from '/@/renderer/features/context-menu';
|
||||
import { useHandleTableContextMenu } from '/@/renderer/features/context-menu';
|
||||
import { ALBUM_CONTEXT_MENU_ITEMS } from '/@/renderer/features/context-menu/context-menu-items';
|
||||
import sortBy from 'lodash/sortBy';
|
||||
import { generatePath, useNavigate } from 'react-router';
|
||||
import { usePlayQueueAdd } from '/@/renderer/features/player';
|
||||
import { useCreateFavorite, useDeleteFavorite } from '/@/renderer/features/shared';
|
||||
|
||||
interface AlbumListContentProps {
|
||||
customFilters?: Partial<AlbumListFilter>;
|
||||
gridRef: MutableRefObject<VirtualInfiniteGridRef | null>;
|
||||
itemCount?: number;
|
||||
tableRef: MutableRefObject<AgGridReactType | null>;
|
||||
}
|
||||
|
||||
export const AlbumListContent = ({ gridRef, tableRef }: AlbumListContentProps) => {
|
||||
export const AlbumListContent = ({
|
||||
customFilters,
|
||||
itemCount,
|
||||
gridRef,
|
||||
tableRef,
|
||||
}: AlbumListContentProps) => {
|
||||
const queryClient = useQueryClient();
|
||||
const navigate = useNavigate();
|
||||
const server = useCurrentServer();
|
||||
const page = useAlbumListStore();
|
||||
const setPage = useSetAlbumStore();
|
||||
const handlePlayQueueAdd = useHandlePlayQueueAdd();
|
||||
const handlePlayQueueAdd = usePlayQueueAdd();
|
||||
|
||||
const { itemData, setItemData } = useAlbumListItemData();
|
||||
const [localItemData, setLocalItemData] = useState<any[]>([]);
|
||||
|
||||
const pagination = useAlbumTablePagination();
|
||||
const setPagination = useSetAlbumTablePagination();
|
||||
@@ -63,25 +73,11 @@ export const AlbumListContent = ({ gridRef, tableRef }: AlbumListContentProps) =
|
||||
|
||||
const isPaginationEnabled = page.display === ListDisplayType.TABLE_PAGINATED;
|
||||
|
||||
const checkAlbumList = useAlbumList({
|
||||
limit: 1,
|
||||
startIndex: 0,
|
||||
...page.filter,
|
||||
});
|
||||
|
||||
const columnDefs: ColDef[] = useMemo(
|
||||
() => getColumnDefs(page.table.columns),
|
||||
[page.table.columns],
|
||||
);
|
||||
|
||||
const defaultColumnDefs: ColDef = useMemo(() => {
|
||||
return {
|
||||
lockPinned: true,
|
||||
lockVisible: true,
|
||||
resizable: true,
|
||||
};
|
||||
}, []);
|
||||
|
||||
const onTableReady = useCallback(
|
||||
(params: GridReadyEvent) => {
|
||||
const dataSource: IDatasource = {
|
||||
@@ -89,42 +85,59 @@ export const AlbumListContent = ({ gridRef, tableRef }: AlbumListContentProps) =
|
||||
const limit = params.endRow - params.startRow;
|
||||
const startIndex = params.startRow;
|
||||
|
||||
const queryKey = queryKeys.albums.list(server?.id || '', {
|
||||
const query: AlbumListQuery = {
|
||||
limit,
|
||||
startIndex,
|
||||
...page.filter,
|
||||
});
|
||||
...customFilters,
|
||||
jfParams: {
|
||||
...page.filter.jfParams,
|
||||
...customFilters?.jfParams,
|
||||
},
|
||||
ndParams: {
|
||||
...page.filter.ndParams,
|
||||
...customFilters?.ndParams,
|
||||
},
|
||||
};
|
||||
|
||||
const albumsRes = await queryClient.fetchQuery(queryKey, async ({ signal }) =>
|
||||
api.controller.getAlbumList({
|
||||
query: {
|
||||
limit,
|
||||
startIndex,
|
||||
...page.filter,
|
||||
},
|
||||
server,
|
||||
signal,
|
||||
}),
|
||||
const queryKey = queryKeys.albums.list(server?.id || '', query);
|
||||
|
||||
const albumsRes = await queryClient.fetchQuery(
|
||||
queryKey,
|
||||
async ({ signal }) =>
|
||||
api.controller.getAlbumList({
|
||||
query,
|
||||
server,
|
||||
signal,
|
||||
}),
|
||||
{ cacheTime: 1000 * 60 * 1 },
|
||||
);
|
||||
|
||||
const albums = api.normalize.albumList(albumsRes, server);
|
||||
params.successCallback(albums?.items || [], albumsRes?.totalRecordCount || undefined);
|
||||
params.successCallback(albums?.items || [], albumsRes?.totalRecordCount || 0);
|
||||
},
|
||||
rowCount: undefined,
|
||||
};
|
||||
params.api.setDatasource(dataSource);
|
||||
// params.api.ensureIndexVisible(page.table.scrollOffset || 0, 'top');
|
||||
|
||||
if (!customFilters) {
|
||||
params.api.ensureIndexVisible(page.table.scrollOffset || 0, 'top');
|
||||
}
|
||||
},
|
||||
[page.filter, queryClient, server],
|
||||
[customFilters, page.filter, page.table.scrollOffset, queryClient, server],
|
||||
);
|
||||
|
||||
const onTablePaginationChanged = useCallback(
|
||||
(event: PaginationChangedEvent) => {
|
||||
if (!isPaginationEnabled || !event.api) return;
|
||||
|
||||
// Scroll to top of page on pagination change
|
||||
const currentPageStartIndex = pagination.currentPage * pagination.itemsPerPage;
|
||||
event.api?.ensureIndexVisible(currentPageStartIndex, 'top');
|
||||
try {
|
||||
// Scroll to top of page on pagination change
|
||||
const currentPageStartIndex = pagination.currentPage * pagination.itemsPerPage;
|
||||
event.api?.ensureIndexVisible(currentPageStartIndex, 'top');
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
}
|
||||
|
||||
setPagination({
|
||||
itemsPerPage: event.api.paginationGetPageSize(),
|
||||
@@ -135,12 +148,6 @@ export const AlbumListContent = ({ gridRef, tableRef }: AlbumListContentProps) =
|
||||
[isPaginationEnabled, pagination.currentPage, pagination.itemsPerPage, setPagination],
|
||||
);
|
||||
|
||||
const handleTableSizeChange = () => {
|
||||
if (page.table.autoFit) {
|
||||
tableRef?.current?.api.sizeColumnsToFit();
|
||||
}
|
||||
};
|
||||
|
||||
const handleTableColumnChange = useCallback(() => {
|
||||
const { columnApi } = tableRef?.current || {};
|
||||
const columnsOrder = columnApi?.getAllGridColumns();
|
||||
@@ -168,25 +175,33 @@ export const AlbumListContent = ({ gridRef, tableRef }: AlbumListContentProps) =
|
||||
const debouncedTableColumnChange = debounce(handleTableColumnChange, 200);
|
||||
|
||||
const handleTableScroll = (e: BodyScrollEvent) => {
|
||||
if (customFilters) return;
|
||||
const scrollOffset = Number((e.top / page.table.rowHeight).toFixed(0));
|
||||
setTable({ scrollOffset });
|
||||
};
|
||||
|
||||
const fetch = useCallback(
|
||||
async ({ skip, take }: { skip: number; take: number }) => {
|
||||
const queryKey = queryKeys.albums.list(server?.id || '', {
|
||||
const query: AlbumListQuery = {
|
||||
limit: take,
|
||||
startIndex: skip,
|
||||
...page.filter,
|
||||
});
|
||||
...customFilters,
|
||||
jfParams: {
|
||||
...page.filter.jfParams,
|
||||
...customFilters?.jfParams,
|
||||
},
|
||||
ndParams: {
|
||||
...page.filter.ndParams,
|
||||
...customFilters?.ndParams,
|
||||
},
|
||||
};
|
||||
|
||||
const queryKey = queryKeys.albums.list(server?.id || '', query);
|
||||
|
||||
const albums = await queryClient.fetchQuery(queryKey, async ({ signal }) =>
|
||||
controller.getAlbumList({
|
||||
query: {
|
||||
limit: take,
|
||||
startIndex: skip,
|
||||
...page.filter,
|
||||
},
|
||||
query,
|
||||
server,
|
||||
signal,
|
||||
}),
|
||||
@@ -194,11 +209,12 @@ export const AlbumListContent = ({ gridRef, tableRef }: AlbumListContentProps) =
|
||||
|
||||
return api.normalize.albumList(albums, server);
|
||||
},
|
||||
[page.filter, queryClient, server],
|
||||
[customFilters, page.filter, queryClient, server],
|
||||
);
|
||||
|
||||
const handleGridScroll = useCallback(
|
||||
(e: ListOnScrollProps) => {
|
||||
if (customFilters) return;
|
||||
setPage({
|
||||
list: {
|
||||
...page,
|
||||
@@ -209,7 +225,7 @@ export const AlbumListContent = ({ gridRef, tableRef }: AlbumListContentProps) =
|
||||
},
|
||||
});
|
||||
},
|
||||
[page, setPage],
|
||||
[customFilters, page, setPage],
|
||||
);
|
||||
|
||||
const cardRows = useMemo(() => {
|
||||
@@ -275,61 +291,71 @@ export const AlbumListContent = ({ gridRef, tableRef }: AlbumListContentProps) =
|
||||
return rows;
|
||||
}, [page.filter.sortBy]);
|
||||
|
||||
const handleContextMenu = (e: CellContextMenuEvent) => {
|
||||
if (!e.event) return;
|
||||
const clickEvent = e.event as MouseEvent;
|
||||
clickEvent.preventDefault();
|
||||
|
||||
const selectedNodes = e.api.getSelectedNodes();
|
||||
const selectedIds = selectedNodes.map((node) => node.data.id);
|
||||
let selectedRows = sortBy(selectedNodes, ['rowIndex']).map((node) => node.data);
|
||||
|
||||
if (!selectedIds.includes(e.data.id)) {
|
||||
e.api.deselectAll();
|
||||
e.node.setSelected(true);
|
||||
selectedRows = [e.data];
|
||||
}
|
||||
|
||||
openContextMenu({
|
||||
data: selectedRows,
|
||||
menuItems: ALBUM_CONTEXT_MENU_ITEMS,
|
||||
type: LibraryItem.ALBUM,
|
||||
xPos: clickEvent.clientX,
|
||||
yPos: clickEvent.clientY,
|
||||
});
|
||||
};
|
||||
const handleContextMenu = useHandleTableContextMenu(LibraryItem.ALBUM, ALBUM_CONTEXT_MENU_ITEMS);
|
||||
|
||||
const handleRowDoubleClick = (e: RowDoubleClickedEvent) => {
|
||||
navigate(generatePath(AppRoute.LIBRARY_ALBUMS_DETAIL, { albumId: e.data.id }));
|
||||
};
|
||||
|
||||
const createFavoriteMutation = useCreateFavorite();
|
||||
const deleteFavoriteMutation = useDeleteFavorite();
|
||||
|
||||
const handleFavorite = (options: {
|
||||
id: string[];
|
||||
isFavorite: boolean;
|
||||
itemType: LibraryItem;
|
||||
}) => {
|
||||
const { id, itemType, isFavorite } = options;
|
||||
if (isFavorite) {
|
||||
deleteFavoriteMutation.mutate({
|
||||
query: {
|
||||
id,
|
||||
type: itemType,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
createFavoriteMutation.mutate({
|
||||
query: {
|
||||
id,
|
||||
type: itemType,
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<VirtualGridAutoSizerContainer>
|
||||
{page.display === ListDisplayType.CARD || page.display === ListDisplayType.POSTER ? (
|
||||
<AutoSizer>
|
||||
{({ height, width }) => (
|
||||
<VirtualInfiniteGrid
|
||||
key={`album-list-${server?.id}-${page.display}`}
|
||||
ref={gridRef}
|
||||
cardRows={cardRows}
|
||||
display={page.display || ListDisplayType.CARD}
|
||||
fetchFn={fetch}
|
||||
handlePlayQueueAdd={handlePlayQueueAdd}
|
||||
height={height}
|
||||
initialScrollOffset={page?.grid.scrollOffset || 0}
|
||||
itemCount={checkAlbumList?.data?.totalRecordCount || 0}
|
||||
itemGap={20}
|
||||
itemSize={150 + page.grid?.size}
|
||||
itemType={LibraryItem.ALBUM}
|
||||
minimumBatchSize={40}
|
||||
route={{
|
||||
route: AppRoute.LIBRARY_ALBUMS_DETAIL,
|
||||
slugs: [{ idProperty: 'id', slugProperty: 'albumId' }],
|
||||
}}
|
||||
width={width}
|
||||
onScroll={handleGridScroll}
|
||||
/>
|
||||
<>
|
||||
<VirtualInfiniteGrid
|
||||
key={`album-list-${server?.id}-${page.display}`}
|
||||
ref={gridRef}
|
||||
cardRows={cardRows}
|
||||
display={page.display || ListDisplayType.CARD}
|
||||
fetchFn={fetch}
|
||||
handleFavorite={handleFavorite}
|
||||
handlePlayQueueAdd={handlePlayQueueAdd}
|
||||
height={height}
|
||||
initialScrollOffset={customFilters ? 0 : page?.grid.scrollOffset || 0}
|
||||
itemCount={itemCount || 0}
|
||||
itemData={customFilters ? localItemData : itemData}
|
||||
itemGap={20}
|
||||
itemSize={150 + page.grid?.size}
|
||||
itemType={LibraryItem.ALBUM}
|
||||
loading={itemCount === undefined || itemCount === null}
|
||||
minimumBatchSize={40}
|
||||
route={{
|
||||
route: AppRoute.LIBRARY_ALBUMS_DETAIL,
|
||||
slugs: [{ idProperty: 'id', slugProperty: 'albumId' }],
|
||||
}}
|
||||
setItemData={customFilters ? setLocalItemData : setItemData}
|
||||
width={width}
|
||||
onScroll={handleGridScroll}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</AutoSizer>
|
||||
) : (
|
||||
@@ -339,34 +365,23 @@ export const AlbumListContent = ({ gridRef, tableRef }: AlbumListContentProps) =
|
||||
key={`table-${page.display}-${page.table.rowHeight}-${server?.id}`}
|
||||
ref={tableRef}
|
||||
alwaysShowHorizontalScroll
|
||||
animateRows
|
||||
maintainColumnOrder
|
||||
suppressCopyRowsToClipboard
|
||||
suppressMoveWhenRowDragging
|
||||
suppressPaginationPanel
|
||||
suppressRowDrag
|
||||
suppressScrollOnNewData
|
||||
autoFitColumns={page.table.autoFit}
|
||||
blockLoadDebounceMillis={200}
|
||||
cacheBlockSize={500}
|
||||
cacheOverflowSize={1}
|
||||
columnDefs={columnDefs}
|
||||
defaultColDef={defaultColumnDefs}
|
||||
enableCellChangeFlash={false}
|
||||
getRowId={(data) => data.data.id}
|
||||
infiniteInitialRowCount={checkAlbumList.data?.totalRecordCount || 100}
|
||||
infiniteInitialRowCount={itemCount || 100}
|
||||
pagination={isPaginationEnabled}
|
||||
paginationAutoPageSize={isPaginationEnabled}
|
||||
paginationPageSize={page.table.pagination.itemsPerPage || 100}
|
||||
rowBuffer={20}
|
||||
rowHeight={page.table.rowHeight || 40}
|
||||
rowModelType="infinite"
|
||||
rowSelection="multiple"
|
||||
onBodyScrollEnd={handleTableScroll}
|
||||
onCellContextMenu={handleContextMenu}
|
||||
onColumnMoved={handleTableColumnChange}
|
||||
onColumnResized={debouncedTableColumnChange}
|
||||
onGridReady={onTableReady}
|
||||
onGridSizeChanged={handleTableSizeChange}
|
||||
onPaginationChanged={onTablePaginationChanged}
|
||||
onRowDoubleClicked={handleRowDoubleClick}
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,606 @@
|
||||
import { MutableRefObject, useCallback, MouseEvent, ChangeEvent, useMemo } from 'react';
|
||||
import { IDatasource } from '@ag-grid-community/core';
|
||||
import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/lib/agGridReact';
|
||||
import { Flex, Group, Stack } from '@mantine/core';
|
||||
import { openModal } from '@mantine/modals';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import {
|
||||
RiSortAsc,
|
||||
RiSortDesc,
|
||||
RiFolder2Line,
|
||||
RiMoreFill,
|
||||
RiAddBoxFill,
|
||||
RiPlayFill,
|
||||
RiAddCircleFill,
|
||||
RiRefreshLine,
|
||||
RiSettings3Fill,
|
||||
RiFilterFill,
|
||||
} from 'react-icons/ri';
|
||||
import { api } from '/@/renderer/api';
|
||||
import { queryKeys } from '/@/renderer/api/query-keys';
|
||||
import { AlbumListQuery, AlbumListSort, LibraryItem, SortOrder } from '/@/renderer/api/types';
|
||||
import {
|
||||
ALBUM_TABLE_COLUMNS,
|
||||
Button,
|
||||
DropdownMenu,
|
||||
MultiSelect,
|
||||
Slider,
|
||||
Switch,
|
||||
Text,
|
||||
VirtualInfiniteGridRef,
|
||||
} from '/@/renderer/components';
|
||||
import { JellyfinAlbumFilters } from '/@/renderer/features/albums/components/jellyfin-album-filters';
|
||||
import { NavidromeAlbumFilters } from '/@/renderer/features/albums/components/navidrome-album-filters';
|
||||
import { useContainerQuery } from '/@/renderer/hooks';
|
||||
import {
|
||||
AlbumListFilter,
|
||||
useAlbumListStore,
|
||||
useCurrentServer,
|
||||
useSetAlbumFilters,
|
||||
useSetAlbumStore,
|
||||
useSetAlbumTable,
|
||||
useSetAlbumTablePagination,
|
||||
} from '/@/renderer/store';
|
||||
import { ServerType, Play, ListDisplayType, TableColumn } from '/@/renderer/types';
|
||||
import { useMusicFolders } from '/@/renderer/features/shared';
|
||||
import { usePlayQueueAdd } from '/@/renderer/features/player';
|
||||
|
||||
const FILTERS = {
|
||||
jellyfin: [
|
||||
{ defaultOrder: SortOrder.ASC, name: 'Album Artist', value: AlbumListSort.ALBUM_ARTIST },
|
||||
{
|
||||
defaultOrder: SortOrder.DESC,
|
||||
name: 'Community Rating',
|
||||
value: AlbumListSort.COMMUNITY_RATING,
|
||||
},
|
||||
{ defaultOrder: SortOrder.DESC, name: 'Critic Rating', value: AlbumListSort.CRITIC_RATING },
|
||||
{ defaultOrder: SortOrder.ASC, name: 'Name', value: AlbumListSort.NAME },
|
||||
{ defaultOrder: SortOrder.ASC, name: 'Random', value: AlbumListSort.RANDOM },
|
||||
{ defaultOrder: SortOrder.DESC, name: 'Recently Added', value: AlbumListSort.RECENTLY_ADDED },
|
||||
{ defaultOrder: SortOrder.DESC, name: 'Release Date', value: AlbumListSort.RELEASE_DATE },
|
||||
],
|
||||
navidrome: [
|
||||
{ defaultOrder: SortOrder.ASC, name: 'Album Artist', value: AlbumListSort.ALBUM_ARTIST },
|
||||
{ defaultOrder: SortOrder.ASC, name: 'Artist', value: AlbumListSort.ARTIST },
|
||||
{ defaultOrder: SortOrder.DESC, name: 'Duration', value: AlbumListSort.DURATION },
|
||||
{ defaultOrder: SortOrder.DESC, name: 'Most Played', value: AlbumListSort.PLAY_COUNT },
|
||||
{ defaultOrder: SortOrder.ASC, name: 'Name', value: AlbumListSort.NAME },
|
||||
{ defaultOrder: SortOrder.ASC, name: 'Random', value: AlbumListSort.RANDOM },
|
||||
{ defaultOrder: SortOrder.DESC, name: 'Rating', value: AlbumListSort.RATING },
|
||||
{ defaultOrder: SortOrder.DESC, name: 'Recently Added', value: AlbumListSort.RECENTLY_ADDED },
|
||||
{ defaultOrder: SortOrder.DESC, name: 'Recently Played', value: AlbumListSort.RECENTLY_PLAYED },
|
||||
{ defaultOrder: SortOrder.DESC, name: 'Song Count', value: AlbumListSort.SONG_COUNT },
|
||||
{ defaultOrder: SortOrder.DESC, name: 'Favorited', value: AlbumListSort.FAVORITED },
|
||||
{ defaultOrder: SortOrder.DESC, name: 'Year', value: AlbumListSort.YEAR },
|
||||
],
|
||||
};
|
||||
|
||||
const ORDER = [
|
||||
{ name: 'Ascending', value: SortOrder.ASC },
|
||||
{ name: 'Descending', value: SortOrder.DESC },
|
||||
];
|
||||
|
||||
interface AlbumListHeaderFiltersProps {
|
||||
customFilters?: Partial<AlbumListFilter>;
|
||||
gridRef: MutableRefObject<VirtualInfiniteGridRef | null>;
|
||||
itemCount?: number;
|
||||
tableRef: MutableRefObject<AgGridReactType | null>;
|
||||
}
|
||||
|
||||
export const AlbumListHeaderFilters = ({
|
||||
customFilters,
|
||||
gridRef,
|
||||
tableRef,
|
||||
itemCount,
|
||||
}: AlbumListHeaderFiltersProps) => {
|
||||
const queryClient = useQueryClient();
|
||||
const server = useCurrentServer();
|
||||
|
||||
const setPage = useSetAlbumStore();
|
||||
const setFilter = useSetAlbumFilters();
|
||||
const page = useAlbumListStore();
|
||||
const filters = page.filter;
|
||||
const cq = useContainerQuery();
|
||||
|
||||
const musicFoldersQuery = useMusicFolders();
|
||||
|
||||
const setPagination = useSetAlbumTablePagination();
|
||||
const setTable = useSetAlbumTable();
|
||||
|
||||
const sortByLabel =
|
||||
(server?.type &&
|
||||
FILTERS[server.type as keyof typeof FILTERS].find((f) => f.value === filters.sortBy)?.name) ||
|
||||
'Unknown';
|
||||
|
||||
const sortOrderLabel = ORDER.find((o) => o.value === filters.sortOrder)?.name || 'Unknown';
|
||||
|
||||
const fetch = useCallback(
|
||||
async (skip: number, take: number, filters: AlbumListFilter) => {
|
||||
const query: AlbumListQuery = {
|
||||
limit: take,
|
||||
startIndex: skip,
|
||||
...filters,
|
||||
jfParams: {
|
||||
...filters.jfParams,
|
||||
...customFilters?.jfParams,
|
||||
},
|
||||
ndParams: {
|
||||
...filters.ndParams,
|
||||
...customFilters?.ndParams,
|
||||
},
|
||||
...customFilters,
|
||||
};
|
||||
|
||||
const queryKey = queryKeys.albums.list(server?.id || '', query);
|
||||
|
||||
const albums = await queryClient.fetchQuery(
|
||||
queryKey,
|
||||
async ({ signal }) =>
|
||||
api.controller.getAlbumList({
|
||||
query,
|
||||
server,
|
||||
signal,
|
||||
}),
|
||||
{ cacheTime: 1000 * 60 * 1 },
|
||||
);
|
||||
|
||||
return api.normalize.albumList(albums, server);
|
||||
},
|
||||
[customFilters, queryClient, server],
|
||||
);
|
||||
|
||||
const handleFilterChange = useCallback(
|
||||
async (filters: AlbumListFilter) => {
|
||||
if (
|
||||
page.display === ListDisplayType.TABLE ||
|
||||
page.display === ListDisplayType.TABLE_PAGINATED
|
||||
) {
|
||||
const dataSource: IDatasource = {
|
||||
getRows: async (params) => {
|
||||
const limit = params.endRow - params.startRow;
|
||||
const startIndex = params.startRow;
|
||||
|
||||
const query: AlbumListQuery = {
|
||||
limit,
|
||||
startIndex,
|
||||
...filters,
|
||||
...customFilters,
|
||||
jfParams: {
|
||||
...filters.jfParams,
|
||||
...customFilters?.jfParams,
|
||||
},
|
||||
ndParams: {
|
||||
...filters.ndParams,
|
||||
...customFilters?.ndParams,
|
||||
},
|
||||
};
|
||||
|
||||
const queryKey = queryKeys.albums.list(server?.id || '', query);
|
||||
|
||||
const albumsRes = await queryClient.fetchQuery(
|
||||
queryKey,
|
||||
async ({ signal }) =>
|
||||
api.controller.getAlbumList({
|
||||
query,
|
||||
server,
|
||||
signal,
|
||||
}),
|
||||
{ cacheTime: 1000 * 60 * 1 },
|
||||
);
|
||||
|
||||
const albums = api.normalize.albumList(albumsRes, server);
|
||||
params.successCallback(albums?.items || [], albumsRes?.totalRecordCount || 0);
|
||||
},
|
||||
rowCount: undefined,
|
||||
};
|
||||
tableRef.current?.api.setDatasource(dataSource);
|
||||
tableRef.current?.api.purgeInfiniteCache();
|
||||
tableRef.current?.api.ensureIndexVisible(0, 'top');
|
||||
|
||||
if (page.display === ListDisplayType.TABLE_PAGINATED) {
|
||||
setPagination({ currentPage: 0 });
|
||||
}
|
||||
} else {
|
||||
gridRef.current?.scrollTo(0);
|
||||
gridRef.current?.resetLoadMoreItemsCache();
|
||||
|
||||
// Refetching within the virtualized grid may be inconsistent due to it refetching
|
||||
// using an outdated set of filters. To avoid this, we fetch using the updated filters
|
||||
// and then set the grid's data here.
|
||||
const data = await fetch(0, 200, filters);
|
||||
|
||||
if (!data?.items) return;
|
||||
gridRef.current?.setItemData(data.items);
|
||||
}
|
||||
},
|
||||
[page.display, tableRef, customFilters, server, queryClient, setPagination, gridRef, fetch],
|
||||
);
|
||||
|
||||
const handleOpenFiltersModal = () => {
|
||||
openModal({
|
||||
children: (
|
||||
<>
|
||||
{server?.type === ServerType.NAVIDROME ? (
|
||||
<NavidromeAlbumFilters
|
||||
disableArtistFilter={!!customFilters}
|
||||
handleFilterChange={handleFilterChange}
|
||||
/>
|
||||
) : (
|
||||
<JellyfinAlbumFilters
|
||||
disableArtistFilter={!!customFilters}
|
||||
handleFilterChange={handleFilterChange}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
),
|
||||
title: 'Album Filters',
|
||||
});
|
||||
};
|
||||
|
||||
const handleRefresh = useCallback(() => {
|
||||
queryClient.invalidateQueries(queryKeys.albums.list(server?.id || ''));
|
||||
handleFilterChange(filters);
|
||||
}, [filters, handleFilterChange, queryClient, server?.id]);
|
||||
|
||||
const handleSetSortBy = useCallback(
|
||||
(e: MouseEvent<HTMLButtonElement>) => {
|
||||
if (!e.currentTarget?.value || !server?.type) return;
|
||||
|
||||
const sortOrder = FILTERS[server.type as keyof typeof FILTERS].find(
|
||||
(f) => f.value === e.currentTarget.value,
|
||||
)?.defaultOrder;
|
||||
|
||||
const updatedFilters = setFilter({
|
||||
sortBy: e.currentTarget.value as AlbumListSort,
|
||||
sortOrder: sortOrder || SortOrder.ASC,
|
||||
});
|
||||
|
||||
handleFilterChange(updatedFilters);
|
||||
},
|
||||
[handleFilterChange, server?.type, setFilter],
|
||||
);
|
||||
|
||||
const handleSetMusicFolder = useCallback(
|
||||
(e: MouseEvent<HTMLButtonElement>) => {
|
||||
if (!e.currentTarget?.value) return;
|
||||
|
||||
let updatedFilters = null;
|
||||
if (e.currentTarget.value === String(page.filter.musicFolderId)) {
|
||||
updatedFilters = setFilter({ musicFolderId: undefined });
|
||||
} else {
|
||||
updatedFilters = setFilter({ musicFolderId: e.currentTarget.value });
|
||||
}
|
||||
|
||||
handleFilterChange(updatedFilters);
|
||||
},
|
||||
[handleFilterChange, page.filter.musicFolderId, setFilter],
|
||||
);
|
||||
|
||||
const handleToggleSortOrder = useCallback(() => {
|
||||
const newSortOrder = filters.sortOrder === SortOrder.ASC ? SortOrder.DESC : SortOrder.ASC;
|
||||
const updatedFilters = setFilter({ sortOrder: newSortOrder });
|
||||
handleFilterChange(updatedFilters);
|
||||
}, [filters.sortOrder, handleFilterChange, setFilter]);
|
||||
|
||||
const handlePlayQueueAdd = usePlayQueueAdd();
|
||||
|
||||
const handlePlay = async (play: Play) => {
|
||||
if (!itemCount || itemCount === 0) return;
|
||||
|
||||
const query = {
|
||||
startIndex: 0,
|
||||
...filters,
|
||||
...customFilters,
|
||||
jfParams: {
|
||||
...filters.jfParams,
|
||||
...customFilters?.jfParams,
|
||||
},
|
||||
ndParams: {
|
||||
...filters.ndParams,
|
||||
...customFilters?.ndParams,
|
||||
},
|
||||
};
|
||||
const queryKey = queryKeys.albums.list(server?.id || '', query);
|
||||
|
||||
const albumListRes = await queryClient.fetchQuery({
|
||||
queryFn: ({ signal }) => api.controller.getAlbumList({ query, server, signal }),
|
||||
queryKey,
|
||||
});
|
||||
|
||||
const albumIds =
|
||||
api.normalize.albumList(albumListRes, server).items?.map((item) => item.id) || [];
|
||||
|
||||
handlePlayQueueAdd?.({
|
||||
byItemType: {
|
||||
id: albumIds,
|
||||
type: LibraryItem.ALBUM,
|
||||
},
|
||||
play,
|
||||
});
|
||||
};
|
||||
|
||||
const handleItemSize = (e: number) => {
|
||||
if (
|
||||
page.display === ListDisplayType.TABLE ||
|
||||
page.display === ListDisplayType.TABLE_PAGINATED
|
||||
) {
|
||||
setTable({ rowHeight: e });
|
||||
} else {
|
||||
setPage({ list: { ...page, grid: { ...page.grid, size: e } } });
|
||||
}
|
||||
};
|
||||
|
||||
const handleSetViewType = useCallback(
|
||||
(e: MouseEvent<HTMLButtonElement>) => {
|
||||
if (!e.currentTarget?.value) return;
|
||||
setPage({ list: { ...page, display: e.currentTarget.value as ListDisplayType } });
|
||||
},
|
||||
[page, setPage],
|
||||
);
|
||||
|
||||
const handleTableColumns = (values: TableColumn[]) => {
|
||||
const existingColumns = page.table.columns;
|
||||
|
||||
if (values.length === 0) {
|
||||
return setTable({
|
||||
columns: [],
|
||||
});
|
||||
}
|
||||
|
||||
// If adding a column
|
||||
if (values.length > existingColumns.length) {
|
||||
const newColumn = { column: values[values.length - 1], width: 100 };
|
||||
|
||||
setTable({ columns: [...existingColumns, newColumn] });
|
||||
} else {
|
||||
// If removing a column
|
||||
const removed = existingColumns.filter((column) => !values.includes(column.column));
|
||||
const newColumns = existingColumns.filter((column) => !removed.includes(column));
|
||||
|
||||
setTable({ columns: newColumns });
|
||||
}
|
||||
|
||||
return tableRef.current?.api.sizeColumnsToFit();
|
||||
};
|
||||
|
||||
const handleAutoFitColumns = (e: ChangeEvent<HTMLInputElement>) => {
|
||||
setTable({ autoFit: e.currentTarget.checked });
|
||||
|
||||
if (e.currentTarget.checked) {
|
||||
tableRef.current?.api.sizeColumnsToFit();
|
||||
}
|
||||
};
|
||||
|
||||
const isFilterApplied = useMemo(() => {
|
||||
const isNavidromeFilterApplied =
|
||||
server?.type === ServerType.NAVIDROME &&
|
||||
page.filter.ndParams &&
|
||||
Object.values(page.filter.ndParams).some((value) => value !== undefined);
|
||||
|
||||
const isJellyfinFilterApplied =
|
||||
server?.type === ServerType.JELLYFIN &&
|
||||
page.filter.jfParams &&
|
||||
Object.values(page.filter.jfParams).some((value) => value !== undefined);
|
||||
|
||||
return isNavidromeFilterApplied || isJellyfinFilterApplied;
|
||||
}, [page.filter.jfParams, page.filter.ndParams, server?.type]);
|
||||
|
||||
return (
|
||||
<Flex justify="space-between">
|
||||
<Group
|
||||
ref={cq.ref}
|
||||
spacing="sm"
|
||||
w="100%"
|
||||
>
|
||||
<DropdownMenu position="bottom-start">
|
||||
<DropdownMenu.Target>
|
||||
<Button
|
||||
compact
|
||||
fw={600}
|
||||
size="md"
|
||||
variant="subtle"
|
||||
>
|
||||
{sortByLabel}
|
||||
</Button>
|
||||
</DropdownMenu.Target>
|
||||
<DropdownMenu.Dropdown>
|
||||
{FILTERS[server?.type as keyof typeof FILTERS].map((filter) => (
|
||||
<DropdownMenu.Item
|
||||
key={`filter-${filter.name}`}
|
||||
$isActive={filter.value === filters.sortBy}
|
||||
value={filter.value}
|
||||
onClick={handleSetSortBy}
|
||||
>
|
||||
{filter.name}
|
||||
</DropdownMenu.Item>
|
||||
))}
|
||||
</DropdownMenu.Dropdown>
|
||||
</DropdownMenu>
|
||||
<Button
|
||||
compact
|
||||
fw={600}
|
||||
size="md"
|
||||
variant="subtle"
|
||||
onClick={handleToggleSortOrder}
|
||||
>
|
||||
{cq.isSm ? (
|
||||
sortOrderLabel
|
||||
) : (
|
||||
<>
|
||||
{filters.sortOrder === SortOrder.ASC ? (
|
||||
<RiSortAsc size={15} />
|
||||
) : (
|
||||
<RiSortDesc size={15} />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
{server?.type === ServerType.JELLYFIN && (
|
||||
<DropdownMenu position="bottom-start">
|
||||
<DropdownMenu.Target>
|
||||
<Button
|
||||
compact
|
||||
fw={600}
|
||||
size="md"
|
||||
variant="subtle"
|
||||
>
|
||||
{cq.isSm ? 'Folder' : <RiFolder2Line size="1.3rem" />}
|
||||
</Button>
|
||||
</DropdownMenu.Target>
|
||||
<DropdownMenu.Dropdown>
|
||||
{musicFoldersQuery.data?.map((folder) => (
|
||||
<DropdownMenu.Item
|
||||
key={`musicFolder-${folder.id}`}
|
||||
$isActive={filters.musicFolderId === folder.id}
|
||||
value={folder.id}
|
||||
onClick={handleSetMusicFolder}
|
||||
>
|
||||
{folder.name}
|
||||
</DropdownMenu.Item>
|
||||
))}
|
||||
</DropdownMenu.Dropdown>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
<DropdownMenu position="bottom-start">
|
||||
<DropdownMenu.Target>
|
||||
<Button
|
||||
compact
|
||||
size="md"
|
||||
variant="subtle"
|
||||
>
|
||||
<RiMoreFill size={15} />
|
||||
</Button>
|
||||
</DropdownMenu.Target>
|
||||
<DropdownMenu.Dropdown>
|
||||
<DropdownMenu.Item
|
||||
icon={<RiPlayFill />}
|
||||
onClick={() => handlePlay(Play.NOW)}
|
||||
>
|
||||
Play
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item
|
||||
icon={<RiAddBoxFill />}
|
||||
onClick={() => handlePlay(Play.LAST)}
|
||||
>
|
||||
Add to queue
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item
|
||||
icon={<RiAddCircleFill />}
|
||||
onClick={() => handlePlay(Play.NEXT)}
|
||||
>
|
||||
Add to queue next
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Divider />
|
||||
<DropdownMenu.Item
|
||||
icon={<RiRefreshLine />}
|
||||
onClick={handleRefresh}
|
||||
>
|
||||
Refresh
|
||||
</DropdownMenu.Item>
|
||||
</DropdownMenu.Dropdown>
|
||||
</DropdownMenu>
|
||||
</Group>
|
||||
<Group
|
||||
noWrap
|
||||
spacing="sm"
|
||||
>
|
||||
<Button
|
||||
compact
|
||||
size="md"
|
||||
sx={{ svg: { fill: isFilterApplied ? 'var(--primary-color) !important' : undefined } }}
|
||||
tooltip={{ label: 'Filters' }}
|
||||
variant="subtle"
|
||||
onClick={handleOpenFiltersModal}
|
||||
>
|
||||
<RiFilterFill size="1.3rem" />
|
||||
</Button>
|
||||
<DropdownMenu position="bottom-end">
|
||||
<DropdownMenu.Target>
|
||||
<Button
|
||||
compact
|
||||
size="md"
|
||||
tooltip={{ label: 'Configure' }}
|
||||
variant="subtle"
|
||||
>
|
||||
<RiSettings3Fill size="1.3rem" />
|
||||
</Button>
|
||||
</DropdownMenu.Target>
|
||||
<DropdownMenu.Dropdown>
|
||||
<DropdownMenu.Label>Display type</DropdownMenu.Label>
|
||||
<DropdownMenu.Item
|
||||
$isActive={page.display === ListDisplayType.CARD}
|
||||
value={ListDisplayType.CARD}
|
||||
onClick={handleSetViewType}
|
||||
>
|
||||
Card
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item
|
||||
$isActive={page.display === ListDisplayType.POSTER}
|
||||
value={ListDisplayType.POSTER}
|
||||
onClick={handleSetViewType}
|
||||
>
|
||||
Poster
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item
|
||||
$isActive={page.display === ListDisplayType.TABLE}
|
||||
value={ListDisplayType.TABLE}
|
||||
onClick={handleSetViewType}
|
||||
>
|
||||
Table
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item
|
||||
$isActive={page.display === ListDisplayType.TABLE_PAGINATED}
|
||||
value={ListDisplayType.TABLE_PAGINATED}
|
||||
onClick={handleSetViewType}
|
||||
>
|
||||
Table (paginated)
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Divider />
|
||||
<DropdownMenu.Label>Item size</DropdownMenu.Label>
|
||||
<DropdownMenu.Item closeMenuOnClick={false}>
|
||||
<Slider
|
||||
defaultValue={
|
||||
page.display === ListDisplayType.CARD || page.display === ListDisplayType.POSTER
|
||||
? page.grid.size
|
||||
: page.table.rowHeight
|
||||
}
|
||||
label={null}
|
||||
max={100}
|
||||
min={25}
|
||||
onChangeEnd={handleItemSize}
|
||||
/>
|
||||
</DropdownMenu.Item>
|
||||
{(page.display === ListDisplayType.TABLE ||
|
||||
page.display === ListDisplayType.TABLE_PAGINATED) && (
|
||||
<>
|
||||
<DropdownMenu.Label>Table Columns</DropdownMenu.Label>
|
||||
<DropdownMenu.Item
|
||||
closeMenuOnClick={false}
|
||||
component="div"
|
||||
sx={{ cursor: 'default' }}
|
||||
>
|
||||
<Stack>
|
||||
<MultiSelect
|
||||
clearable
|
||||
data={ALBUM_TABLE_COLUMNS}
|
||||
defaultValue={page.table?.columns.map((column) => column.column)}
|
||||
width={300}
|
||||
onChange={handleTableColumns}
|
||||
/>
|
||||
<Group position="apart">
|
||||
<Text>Auto Fit Columns</Text>
|
||||
<Switch
|
||||
defaultChecked={page.table.autoFit}
|
||||
onChange={handleAutoFitColumns}
|
||||
/>
|
||||
</Group>
|
||||
</Stack>
|
||||
</DropdownMenu.Item>
|
||||
</>
|
||||
)}
|
||||
</DropdownMenu.Dropdown>
|
||||
</DropdownMenu>
|
||||
</Group>
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
@@ -1,153 +1,92 @@
|
||||
import type { ChangeEvent, MouseEvent, MutableRefObject } from 'react';
|
||||
import type { ChangeEvent, MutableRefObject } from 'react';
|
||||
import { useCallback } from 'react';
|
||||
import { IDatasource } from '@ag-grid-community/core';
|
||||
import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/lib/agGridReact';
|
||||
import { Flex, Group, Stack } from '@mantine/core';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import debounce from 'lodash/debounce';
|
||||
import {
|
||||
RiArrowDownSLine,
|
||||
RiFilter3Line,
|
||||
RiFolder2Line,
|
||||
RiMoreFill,
|
||||
RiSortAsc,
|
||||
RiSortDesc,
|
||||
} from 'react-icons/ri';
|
||||
import styled from 'styled-components';
|
||||
import { api } from '/@/renderer/api';
|
||||
import { controller } from '/@/renderer/api/controller';
|
||||
import { queryKeys } from '/@/renderer/api/query-keys';
|
||||
import { AlbumListSort, ServerType, SortOrder } from '/@/renderer/api/types';
|
||||
import { AlbumListQuery, LibraryItem } from '/@/renderer/api/types';
|
||||
import {
|
||||
ALBUM_TABLE_COLUMNS,
|
||||
Button,
|
||||
DropdownMenu,
|
||||
MultiSelect,
|
||||
PageHeader,
|
||||
Popover,
|
||||
Paper,
|
||||
SearchInput,
|
||||
Slider,
|
||||
Switch,
|
||||
Text,
|
||||
TextTitle,
|
||||
SpinnerIcon,
|
||||
VirtualInfiniteGridRef,
|
||||
} from '/@/renderer/components';
|
||||
import { JellyfinAlbumFilters } from '/@/renderer/features/albums/components/jellyfin-album-filters';
|
||||
import { NavidromeAlbumFilters } from '/@/renderer/features/albums/components/navidrome-album-filters';
|
||||
import { useMusicFolders } from '/@/renderer/features/shared';
|
||||
import { LibraryHeaderBar } from '/@/renderer/features/shared';
|
||||
import { useContainerQuery } from '/@/renderer/hooks';
|
||||
import {
|
||||
AlbumListFilter,
|
||||
useAlbumListStore,
|
||||
useCurrentServer,
|
||||
useSetAlbumFilters,
|
||||
useSetAlbumStore,
|
||||
useSetAlbumTable,
|
||||
useSetAlbumTablePagination,
|
||||
} from '/@/renderer/store';
|
||||
import { ListDisplayType, TableColumn } from '/@/renderer/types';
|
||||
|
||||
const FILTERS = {
|
||||
jellyfin: [
|
||||
{ defaultOrder: SortOrder.ASC, name: 'Album Artist', value: AlbumListSort.ALBUM_ARTIST },
|
||||
{
|
||||
defaultOrder: SortOrder.DESC,
|
||||
name: 'Community Rating',
|
||||
value: AlbumListSort.COMMUNITY_RATING,
|
||||
},
|
||||
{ defaultOrder: SortOrder.DESC, name: 'Critic Rating', value: AlbumListSort.CRITIC_RATING },
|
||||
{ defaultOrder: SortOrder.ASC, name: 'Name', value: AlbumListSort.NAME },
|
||||
{ defaultOrder: SortOrder.ASC, name: 'Random', value: AlbumListSort.RANDOM },
|
||||
{ defaultOrder: SortOrder.DESC, name: 'Recently Added', value: AlbumListSort.RECENTLY_ADDED },
|
||||
{ defaultOrder: SortOrder.DESC, name: 'Release Date', value: AlbumListSort.RELEASE_DATE },
|
||||
],
|
||||
navidrome: [
|
||||
{ defaultOrder: SortOrder.ASC, name: 'Album Artist', value: AlbumListSort.ALBUM_ARTIST },
|
||||
{ defaultOrder: SortOrder.ASC, name: 'Artist', value: AlbumListSort.ARTIST },
|
||||
{ defaultOrder: SortOrder.DESC, name: 'Duration', value: AlbumListSort.DURATION },
|
||||
{ defaultOrder: SortOrder.DESC, name: 'Most Played', value: AlbumListSort.PLAY_COUNT },
|
||||
{ defaultOrder: SortOrder.ASC, name: 'Name', value: AlbumListSort.NAME },
|
||||
{ defaultOrder: SortOrder.ASC, name: 'Random', value: AlbumListSort.RANDOM },
|
||||
{ defaultOrder: SortOrder.DESC, name: 'Rating', value: AlbumListSort.RATING },
|
||||
{ defaultOrder: SortOrder.DESC, name: 'Recently Added', value: AlbumListSort.RECENTLY_ADDED },
|
||||
{ defaultOrder: SortOrder.DESC, name: 'Recently Played', value: AlbumListSort.RECENTLY_PLAYED },
|
||||
{ defaultOrder: SortOrder.DESC, name: 'Song Count', value: AlbumListSort.SONG_COUNT },
|
||||
{ defaultOrder: SortOrder.DESC, name: 'Favorited', value: AlbumListSort.FAVORITED },
|
||||
{ defaultOrder: SortOrder.DESC, name: 'Year', value: AlbumListSort.YEAR },
|
||||
],
|
||||
};
|
||||
|
||||
const ORDER = [
|
||||
{ name: 'Ascending', value: SortOrder.ASC },
|
||||
{ name: 'Descending', value: SortOrder.DESC },
|
||||
];
|
||||
|
||||
const HeaderItems = styled.div`
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
`;
|
||||
import { ListDisplayType, Play } from '/@/renderer/types';
|
||||
import { AlbumListHeaderFilters } from '/@/renderer/features/albums/components/album-list-header-filters';
|
||||
import { usePlayQueueAdd } from '/@/renderer/features/player';
|
||||
import { usePlayButtonBehavior } from '/@/renderer/store/settings.store';
|
||||
|
||||
interface AlbumListHeaderProps {
|
||||
customFilters?: Partial<AlbumListFilter>;
|
||||
gridRef: MutableRefObject<VirtualInfiniteGridRef | null>;
|
||||
itemCount?: number;
|
||||
tableRef: MutableRefObject<AgGridReactType | null>;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
export const AlbumListHeader = ({ gridRef, tableRef }: AlbumListHeaderProps) => {
|
||||
export const AlbumListHeader = ({
|
||||
itemCount,
|
||||
gridRef,
|
||||
tableRef,
|
||||
title,
|
||||
customFilters,
|
||||
}: AlbumListHeaderProps) => {
|
||||
const queryClient = useQueryClient();
|
||||
const server = useCurrentServer();
|
||||
const setPage = useSetAlbumStore();
|
||||
const setFilter = useSetAlbumFilters();
|
||||
const page = useAlbumListStore();
|
||||
const filters = page.filter;
|
||||
const cq = useContainerQuery();
|
||||
|
||||
const musicFoldersQuery = useMusicFolders();
|
||||
|
||||
const setPagination = useSetAlbumTablePagination();
|
||||
const setTable = useSetAlbumTable();
|
||||
|
||||
const sortByLabel =
|
||||
(server?.type &&
|
||||
FILTERS[server.type as keyof typeof FILTERS].find((f) => f.value === filters.sortBy)?.name) ||
|
||||
'Unknown';
|
||||
|
||||
const sortOrderLabel = ORDER.find((o) => o.value === filters.sortOrder)?.name || 'Unknown';
|
||||
|
||||
const handleItemSize = (e: number) => {
|
||||
if (
|
||||
page.display === ListDisplayType.TABLE ||
|
||||
page.display === ListDisplayType.TABLE_PAGINATED
|
||||
) {
|
||||
setTable({ rowHeight: e });
|
||||
} else {
|
||||
setPage({ list: { ...page, grid: { ...page.grid, size: e } } });
|
||||
}
|
||||
};
|
||||
|
||||
const fetch = useCallback(
|
||||
async (skip: number, take: number, filters: AlbumListFilter) => {
|
||||
const queryKey = queryKeys.albums.list(server?.id || '', {
|
||||
const query: AlbumListQuery = {
|
||||
limit: take,
|
||||
startIndex: skip,
|
||||
...filters,
|
||||
});
|
||||
jfParams: {
|
||||
...filters.jfParams,
|
||||
...customFilters?.jfParams,
|
||||
},
|
||||
ndParams: {
|
||||
...filters.ndParams,
|
||||
...customFilters?.ndParams,
|
||||
},
|
||||
...customFilters,
|
||||
};
|
||||
|
||||
const albums = await queryClient.fetchQuery(queryKey, async ({ signal }) =>
|
||||
controller.getAlbumList({
|
||||
query: {
|
||||
limit: take,
|
||||
startIndex: skip,
|
||||
...filters,
|
||||
},
|
||||
server,
|
||||
signal,
|
||||
}),
|
||||
const queryKey = queryKeys.albums.list(server?.id || '', query);
|
||||
|
||||
const albums = await queryClient.fetchQuery(
|
||||
queryKey,
|
||||
async ({ signal }) =>
|
||||
controller.getAlbumList({
|
||||
query,
|
||||
server,
|
||||
signal,
|
||||
}),
|
||||
{ cacheTime: 1000 * 60 * 1 },
|
||||
);
|
||||
|
||||
return api.normalize.albumList(albums, server);
|
||||
},
|
||||
[queryClient, server],
|
||||
[customFilters, queryClient, server],
|
||||
);
|
||||
|
||||
const handleFilterChange = useCallback(
|
||||
@@ -161,26 +100,36 @@ export const AlbumListHeader = ({ gridRef, tableRef }: AlbumListHeaderProps) =>
|
||||
const limit = params.endRow - params.startRow;
|
||||
const startIndex = params.startRow;
|
||||
|
||||
const queryKey = queryKeys.albums.list(server?.id || '', {
|
||||
const query: AlbumListQuery = {
|
||||
limit,
|
||||
startIndex,
|
||||
...filters,
|
||||
});
|
||||
...customFilters,
|
||||
jfParams: {
|
||||
...filters.jfParams,
|
||||
...customFilters?.jfParams,
|
||||
},
|
||||
ndParams: {
|
||||
...filters.ndParams,
|
||||
...customFilters?.ndParams,
|
||||
},
|
||||
};
|
||||
|
||||
const albumsRes = await queryClient.fetchQuery(queryKey, async ({ signal }) =>
|
||||
api.controller.getAlbumList({
|
||||
query: {
|
||||
limit,
|
||||
startIndex,
|
||||
...filters,
|
||||
},
|
||||
server,
|
||||
signal,
|
||||
}),
|
||||
const queryKey = queryKeys.albums.list(server?.id || '', query);
|
||||
|
||||
const albumsRes = await queryClient.fetchQuery(
|
||||
queryKey,
|
||||
async ({ signal }) =>
|
||||
api.controller.getAlbumList({
|
||||
query,
|
||||
server,
|
||||
signal,
|
||||
}),
|
||||
{ cacheTime: 1000 * 60 * 1 },
|
||||
);
|
||||
|
||||
const albums = api.normalize.albumList(albumsRes, server);
|
||||
params.successCallback(albums?.items || [], albumsRes?.totalRecordCount || undefined);
|
||||
params.successCallback(albums?.items || [], albumsRes?.totalRecordCount || 0);
|
||||
},
|
||||
rowCount: undefined,
|
||||
};
|
||||
@@ -204,55 +153,7 @@ export const AlbumListHeader = ({ gridRef, tableRef }: AlbumListHeaderProps) =>
|
||||
gridRef.current?.setItemData(data.items);
|
||||
}
|
||||
},
|
||||
[page.display, tableRef, setPagination, server, queryClient, gridRef, fetch],
|
||||
);
|
||||
|
||||
const handleSetSortBy = useCallback(
|
||||
(e: MouseEvent<HTMLButtonElement>) => {
|
||||
if (!e.currentTarget?.value || !server?.type) return;
|
||||
|
||||
const sortOrder = FILTERS[server.type as keyof typeof FILTERS].find(
|
||||
(f) => f.value === e.currentTarget.value,
|
||||
)?.defaultOrder;
|
||||
|
||||
const updatedFilters = setFilter({
|
||||
sortBy: e.currentTarget.value as AlbumListSort,
|
||||
sortOrder: sortOrder || SortOrder.ASC,
|
||||
});
|
||||
|
||||
handleFilterChange(updatedFilters);
|
||||
},
|
||||
[handleFilterChange, server?.type, setFilter],
|
||||
);
|
||||
|
||||
const handleSetMusicFolder = useCallback(
|
||||
(e: MouseEvent<HTMLButtonElement>) => {
|
||||
if (!e.currentTarget?.value) return;
|
||||
|
||||
let updatedFilters = null;
|
||||
if (e.currentTarget.value === String(page.filter.musicFolderId)) {
|
||||
updatedFilters = setFilter({ musicFolderId: undefined });
|
||||
} else {
|
||||
updatedFilters = setFilter({ musicFolderId: e.currentTarget.value });
|
||||
}
|
||||
|
||||
handleFilterChange(updatedFilters);
|
||||
},
|
||||
[handleFilterChange, page.filter.musicFolderId, setFilter],
|
||||
);
|
||||
|
||||
const handleToggleSortOrder = useCallback(() => {
|
||||
const newSortOrder = filters.sortOrder === SortOrder.ASC ? SortOrder.DESC : SortOrder.ASC;
|
||||
const updatedFilters = setFilter({ sortOrder: newSortOrder });
|
||||
handleFilterChange(updatedFilters);
|
||||
}, [filters.sortOrder, handleFilterChange, setFilter]);
|
||||
|
||||
const handleSetViewType = useCallback(
|
||||
(e: MouseEvent<HTMLButtonElement>) => {
|
||||
if (!e.currentTarget?.value) return;
|
||||
setPage({ list: { ...page, display: e.currentTarget.value as ListDisplayType } });
|
||||
},
|
||||
[page, setPage],
|
||||
[page.display, tableRef, customFilters, server, queryClient, setPagination, gridRef, fetch],
|
||||
);
|
||||
|
||||
const handleSearch = debounce((e: ChangeEvent<HTMLInputElement>) => {
|
||||
@@ -262,248 +163,83 @@ export const AlbumListHeader = ({ gridRef, tableRef }: AlbumListHeaderProps) =>
|
||||
if (previousSearchTerm !== searchTerm) handleFilterChange(updatedFilters);
|
||||
}, 500);
|
||||
|
||||
const handleTableColumns = (values: TableColumn[]) => {
|
||||
const existingColumns = page.table.columns;
|
||||
const handlePlayQueueAdd = usePlayQueueAdd();
|
||||
const playButtonBehavior = usePlayButtonBehavior();
|
||||
|
||||
if (values.length === 0) {
|
||||
return setTable({
|
||||
columns: [],
|
||||
});
|
||||
}
|
||||
const handlePlay = async (play: Play) => {
|
||||
if (!itemCount || itemCount === 0) return;
|
||||
|
||||
// If adding a column
|
||||
if (values.length > existingColumns.length) {
|
||||
const newColumn = { column: values[values.length - 1], width: 100 };
|
||||
const query = {
|
||||
startIndex: 0,
|
||||
...filters,
|
||||
...customFilters,
|
||||
jfParams: {
|
||||
...filters.jfParams,
|
||||
...customFilters?.jfParams,
|
||||
},
|
||||
ndParams: {
|
||||
...filters.ndParams,
|
||||
...customFilters?.ndParams,
|
||||
},
|
||||
};
|
||||
const queryKey = queryKeys.albums.list(server?.id || '', query);
|
||||
|
||||
setTable({ columns: [...existingColumns, newColumn] });
|
||||
} else {
|
||||
// If removing a column
|
||||
const removed = existingColumns.filter((column) => !values.includes(column.column));
|
||||
const newColumns = existingColumns.filter((column) => !removed.includes(column));
|
||||
const albumListRes = await queryClient.fetchQuery({
|
||||
queryFn: ({ signal }) => api.controller.getAlbumList({ query, server, signal }),
|
||||
queryKey,
|
||||
});
|
||||
|
||||
setTable({ columns: newColumns });
|
||||
}
|
||||
const albumIds =
|
||||
api.normalize.albumList(albumListRes, server).items?.map((item) => item.id) || [];
|
||||
|
||||
return tableRef.current?.api.sizeColumnsToFit();
|
||||
};
|
||||
|
||||
const handleAutoFitColumns = (e: ChangeEvent<HTMLInputElement>) => {
|
||||
setTable({ autoFit: e.currentTarget.checked });
|
||||
|
||||
if (e.currentTarget.checked) {
|
||||
tableRef.current?.api.sizeColumnsToFit();
|
||||
}
|
||||
handlePlayQueueAdd?.({
|
||||
byItemType: {
|
||||
id: albumIds,
|
||||
type: LibraryItem.ALBUM,
|
||||
},
|
||||
play,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<PageHeader>
|
||||
<HeaderItems ref={cq.ref}>
|
||||
<Stack
|
||||
ref={cq.ref}
|
||||
spacing={0}
|
||||
>
|
||||
<PageHeader backgroundColor="var(--titlebar-bg)">
|
||||
<Flex
|
||||
align="center"
|
||||
gap="md"
|
||||
justify="center"
|
||||
justify="space-between"
|
||||
w="100%"
|
||||
>
|
||||
<DropdownMenu position="bottom-start">
|
||||
<DropdownMenu.Target>
|
||||
<Button
|
||||
compact
|
||||
px={0}
|
||||
rightIcon={<RiArrowDownSLine size={15} />}
|
||||
size="xl"
|
||||
variant="subtle"
|
||||
>
|
||||
<TextTitle
|
||||
fw="bold"
|
||||
order={3}
|
||||
>
|
||||
Albums
|
||||
</TextTitle>
|
||||
</Button>
|
||||
</DropdownMenu.Target>
|
||||
<DropdownMenu.Dropdown>
|
||||
<DropdownMenu.Label>Display type</DropdownMenu.Label>
|
||||
<DropdownMenu.Item
|
||||
$isActive={page.display === ListDisplayType.CARD}
|
||||
value={ListDisplayType.CARD}
|
||||
onClick={handleSetViewType}
|
||||
>
|
||||
Card
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item
|
||||
$isActive={page.display === ListDisplayType.POSTER}
|
||||
value={ListDisplayType.POSTER}
|
||||
onClick={handleSetViewType}
|
||||
>
|
||||
Poster
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item
|
||||
$isActive={page.display === ListDisplayType.TABLE}
|
||||
value={ListDisplayType.TABLE}
|
||||
onClick={handleSetViewType}
|
||||
>
|
||||
Table
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item
|
||||
$isActive={page.display === ListDisplayType.TABLE_PAGINATED}
|
||||
value={ListDisplayType.TABLE_PAGINATED}
|
||||
onClick={handleSetViewType}
|
||||
>
|
||||
Table (paginated)
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Divider />
|
||||
<DropdownMenu.Label>Item size</DropdownMenu.Label>
|
||||
<DropdownMenu.Item closeMenuOnClick={false}>
|
||||
<Slider
|
||||
defaultValue={
|
||||
page.display === ListDisplayType.CARD || page.display === ListDisplayType.POSTER
|
||||
? page.grid.size
|
||||
: page.table.rowHeight
|
||||
}
|
||||
label={null}
|
||||
max={100}
|
||||
min={25}
|
||||
onChangeEnd={handleItemSize}
|
||||
/>
|
||||
</DropdownMenu.Item>
|
||||
{(page.display === ListDisplayType.TABLE ||
|
||||
page.display === ListDisplayType.TABLE_PAGINATED) && (
|
||||
<>
|
||||
<DropdownMenu.Label>Table Columns</DropdownMenu.Label>
|
||||
<DropdownMenu.Item
|
||||
closeMenuOnClick={false}
|
||||
component="div"
|
||||
sx={{ cursor: 'default' }}
|
||||
>
|
||||
<Stack>
|
||||
<MultiSelect
|
||||
clearable
|
||||
data={ALBUM_TABLE_COLUMNS}
|
||||
defaultValue={page.table?.columns.map((column) => column.column)}
|
||||
width={300}
|
||||
onChange={handleTableColumns}
|
||||
/>
|
||||
<Group position="apart">
|
||||
<Text>Auto Fit Columns</Text>
|
||||
<Switch
|
||||
defaultChecked={page.table.autoFit}
|
||||
onChange={handleAutoFitColumns}
|
||||
/>
|
||||
</Group>
|
||||
</Stack>
|
||||
</DropdownMenu.Item>
|
||||
</>
|
||||
)}
|
||||
</DropdownMenu.Dropdown>
|
||||
</DropdownMenu>
|
||||
<DropdownMenu position="bottom-start">
|
||||
<DropdownMenu.Target>
|
||||
<Button
|
||||
compact
|
||||
fw="600"
|
||||
variant="subtle"
|
||||
>
|
||||
{sortByLabel}
|
||||
</Button>
|
||||
</DropdownMenu.Target>
|
||||
<DropdownMenu.Dropdown>
|
||||
{FILTERS[server?.type as keyof typeof FILTERS].map((filter) => (
|
||||
<DropdownMenu.Item
|
||||
key={`filter-${filter.name}`}
|
||||
$isActive={filter.value === filters.sortBy}
|
||||
value={filter.value}
|
||||
onClick={handleSetSortBy}
|
||||
>
|
||||
{filter.name}
|
||||
</DropdownMenu.Item>
|
||||
))}
|
||||
</DropdownMenu.Dropdown>
|
||||
</DropdownMenu>
|
||||
<Button
|
||||
compact
|
||||
fw="600"
|
||||
variant="subtle"
|
||||
onClick={handleToggleSortOrder}
|
||||
>
|
||||
{cq.isMd ? (
|
||||
sortOrderLabel
|
||||
) : (
|
||||
<>
|
||||
{filters.sortOrder === SortOrder.ASC ? (
|
||||
<RiSortAsc size={15} />
|
||||
) : (
|
||||
<RiSortDesc size={15} />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
{server?.type === ServerType.JELLYFIN && (
|
||||
<DropdownMenu position="bottom-start">
|
||||
<DropdownMenu.Target>
|
||||
<Button
|
||||
compact
|
||||
fw="600"
|
||||
variant="subtle"
|
||||
>
|
||||
{cq.isMd ? 'Folder' : <RiFolder2Line size={15} />}
|
||||
</Button>
|
||||
</DropdownMenu.Target>
|
||||
<DropdownMenu.Dropdown>
|
||||
{musicFoldersQuery.data?.map((folder) => (
|
||||
<DropdownMenu.Item
|
||||
key={`musicFolder-${folder.id}`}
|
||||
$isActive={filters.musicFolderId === folder.id}
|
||||
value={folder.id}
|
||||
onClick={handleSetMusicFolder}
|
||||
>
|
||||
{folder.name}
|
||||
</DropdownMenu.Item>
|
||||
))}
|
||||
</DropdownMenu.Dropdown>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
<Popover position="bottom-start">
|
||||
<Popover.Target>
|
||||
<Button
|
||||
compact
|
||||
fw="600"
|
||||
variant="subtle"
|
||||
>
|
||||
{cq.isMd ? 'Filters' : <RiFilter3Line size={15} />}
|
||||
</Button>
|
||||
</Popover.Target>
|
||||
<Popover.Dropdown>
|
||||
{server?.type === ServerType.NAVIDROME ? (
|
||||
<NavidromeAlbumFilters handleFilterChange={handleFilterChange} />
|
||||
) : (
|
||||
<JellyfinAlbumFilters handleFilterChange={handleFilterChange} />
|
||||
)}
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
<DropdownMenu position="bottom-start">
|
||||
<DropdownMenu.Target>
|
||||
<Button
|
||||
compact
|
||||
variant="subtle"
|
||||
>
|
||||
<RiMoreFill size={15} />
|
||||
</Button>
|
||||
</DropdownMenu.Target>
|
||||
<DropdownMenu.Dropdown>
|
||||
<DropdownMenu.Item disabled>Play</DropdownMenu.Item>
|
||||
<DropdownMenu.Item disabled>Add to queue (next)</DropdownMenu.Item>
|
||||
<DropdownMenu.Item disabled>Add to queue (last)</DropdownMenu.Item>
|
||||
<DropdownMenu.Item disabled>Add to playlist</DropdownMenu.Item>
|
||||
</DropdownMenu.Dropdown>
|
||||
</DropdownMenu>
|
||||
<LibraryHeaderBar>
|
||||
<LibraryHeaderBar.PlayButton onClick={() => handlePlay(playButtonBehavior)} />
|
||||
<LibraryHeaderBar.Title>{title || 'Albums'}</LibraryHeaderBar.Title>
|
||||
<Paper
|
||||
fw="600"
|
||||
px="1rem"
|
||||
py="0.3rem"
|
||||
radius="sm"
|
||||
>
|
||||
{itemCount === null || itemCount === undefined ? <SpinnerIcon /> : itemCount}
|
||||
</Paper>
|
||||
</LibraryHeaderBar>
|
||||
<Group>
|
||||
<SearchInput
|
||||
defaultValue={page.filter.searchTerm}
|
||||
openedWidth={cq.isMd ? 250 : cq.isSm ? 200 : 150}
|
||||
onChange={handleSearch}
|
||||
/>
|
||||
</Group>
|
||||
</Flex>
|
||||
<Flex gap="md">
|
||||
<SearchInput
|
||||
defaultValue={page.filter.searchTerm}
|
||||
openedWidth={cq.isLg ? 300 : cq.isMd ? 250 : cq.isSm ? 150 : 75}
|
||||
onChange={handleSearch}
|
||||
/>
|
||||
</Flex>
|
||||
</HeaderItems>
|
||||
</PageHeader>
|
||||
</PageHeader>
|
||||
<Paper p="1rem">
|
||||
<AlbumListHeaderFilters
|
||||
customFilters={customFilters}
|
||||
gridRef={gridRef}
|
||||
itemCount={itemCount}
|
||||
tableRef={tableRef}
|
||||
/>
|
||||
</Paper>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,15 +1,21 @@
|
||||
import { ChangeEvent, useMemo } from 'react';
|
||||
import { ChangeEvent, useMemo, useState } from 'react';
|
||||
import { Divider, Group, Stack } from '@mantine/core';
|
||||
import { MultiSelect, NumberInput, Switch, Text } from '/@/renderer/components';
|
||||
import { MultiSelect, NumberInput, SpinnerIcon, Switch, Text } from '/@/renderer/components';
|
||||
import { AlbumListFilter, useAlbumListStore, useSetAlbumFilters } from '/@/renderer/store';
|
||||
import debounce from 'lodash/debounce';
|
||||
import { useGenreList } from '/@/renderer/features/genres';
|
||||
import { AlbumArtistListSort, SortOrder } from '/@/renderer/api/types';
|
||||
import { useAlbumArtistList } from '/@/renderer/features/artists/queries/album-artist-list-query';
|
||||
|
||||
interface JellyfinAlbumFiltersProps {
|
||||
disableArtistFilter?: boolean;
|
||||
handleFilterChange: (filters: AlbumListFilter) => void;
|
||||
}
|
||||
|
||||
export const JellyfinAlbumFilters = ({ handleFilterChange }: JellyfinAlbumFiltersProps) => {
|
||||
export const JellyfinAlbumFilters = ({
|
||||
disableArtistFilter,
|
||||
handleFilterChange,
|
||||
}: JellyfinAlbumFiltersProps) => {
|
||||
const { filter } = useAlbumListStore();
|
||||
const setFilters = useSetAlbumFilters();
|
||||
|
||||
@@ -41,30 +47,30 @@ export const JellyfinAlbumFilters = ({ handleFilterChange }: JellyfinAlbumFilter
|
||||
},
|
||||
];
|
||||
|
||||
const handleMinYearFilter = debounce((e: number | undefined) => {
|
||||
if (e && (e < 1700 || e > 2300)) return;
|
||||
const handleMinYearFilter = debounce((e: number | string) => {
|
||||
if (typeof e === 'number' && (e < 1700 || e > 2300)) return;
|
||||
const updatedFilters = setFilters({
|
||||
jfParams: {
|
||||
...filter.jfParams,
|
||||
minYear: e,
|
||||
minYear: e === '' ? undefined : (e as number),
|
||||
},
|
||||
});
|
||||
handleFilterChange(updatedFilters);
|
||||
}, 500);
|
||||
|
||||
const handleMaxYearFilter = debounce((e: number | undefined) => {
|
||||
if (e && (e < 1700 || e > 2300)) return;
|
||||
const handleMaxYearFilter = debounce((e: number | string) => {
|
||||
if (typeof e === 'number' && (e < 1700 || e > 2300)) return;
|
||||
const updatedFilters = setFilters({
|
||||
jfParams: {
|
||||
...filter.jfParams,
|
||||
maxYear: e,
|
||||
maxYear: e === '' ? undefined : (e as number),
|
||||
},
|
||||
});
|
||||
handleFilterChange(updatedFilters);
|
||||
}, 500);
|
||||
|
||||
const handleGenresFilter = debounce((e: string[] | undefined) => {
|
||||
const genreFilterString = e?.join(',');
|
||||
const genreFilterString = e?.length ? e.join(',') : undefined;
|
||||
const updatedFilters = setFilters({
|
||||
jfParams: {
|
||||
...filter.jfParams,
|
||||
@@ -74,46 +80,42 @@ export const JellyfinAlbumFilters = ({ handleFilterChange }: JellyfinAlbumFilter
|
||||
handleFilterChange(updatedFilters);
|
||||
}, 250);
|
||||
|
||||
const [albumArtistSearchTerm, setAlbumArtistSearchTerm] = useState<string>('');
|
||||
|
||||
const albumArtistListQuery = useAlbumArtistList(
|
||||
{
|
||||
sortBy: AlbumArtistListSort.NAME,
|
||||
sortOrder: SortOrder.ASC,
|
||||
startIndex: 0,
|
||||
},
|
||||
{
|
||||
cacheTime: 1000 * 60 * 2,
|
||||
staleTime: 1000 * 60 * 1,
|
||||
},
|
||||
);
|
||||
|
||||
const selectableAlbumArtists = useMemo(() => {
|
||||
if (!albumArtistListQuery?.data?.items) return [];
|
||||
|
||||
return albumArtistListQuery?.data?.items?.map((artist) => ({
|
||||
label: artist.name,
|
||||
value: artist.id,
|
||||
}));
|
||||
}, [albumArtistListQuery?.data?.items]);
|
||||
|
||||
const handleAlbumArtistFilter = (e: string[] | null) => {
|
||||
const albumArtistFilterString = e?.length ? e.join(',') : undefined;
|
||||
const updatedFilters = setFilters({
|
||||
jfParams: {
|
||||
...filter.jfParams,
|
||||
albumArtistIds: albumArtistFilterString,
|
||||
},
|
||||
});
|
||||
handleFilterChange(updatedFilters);
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack p="0.8rem">
|
||||
<Group position="apart">
|
||||
<Text>Year range</Text>
|
||||
<Group>
|
||||
<NumberInput
|
||||
required
|
||||
hideControls={false}
|
||||
max={2300}
|
||||
min={1700}
|
||||
value={filter.jfParams?.minYear}
|
||||
width={80}
|
||||
onChange={handleMinYearFilter}
|
||||
/>
|
||||
<NumberInput
|
||||
hideControls={false}
|
||||
max={2300}
|
||||
min={1700}
|
||||
value={filter.jfParams?.maxYear}
|
||||
width={80}
|
||||
onChange={handleMaxYearFilter}
|
||||
/>
|
||||
</Group>
|
||||
</Group>
|
||||
<Divider my="0.5rem" />
|
||||
<Group
|
||||
position="apart"
|
||||
spacing={20}
|
||||
>
|
||||
<Text>Genres</Text>
|
||||
<MultiSelect
|
||||
clearable
|
||||
searchable
|
||||
data={genreList}
|
||||
defaultValue={selectedGenres}
|
||||
width={250}
|
||||
onChange={handleGenresFilter}
|
||||
/>
|
||||
</Group>
|
||||
<Divider my="0.5rem" />
|
||||
{toggleFilters.map((filter) => (
|
||||
<Group
|
||||
key={`nd-filter-${filter.label}`}
|
||||
@@ -127,14 +129,54 @@ export const JellyfinAlbumFilters = ({ handleFilterChange }: JellyfinAlbumFilter
|
||||
/>
|
||||
</Group>
|
||||
))}
|
||||
{/* <Divider my="0.5rem" />
|
||||
<Stack>
|
||||
<Text>Tags</Text>
|
||||
<MultiSelect
|
||||
disabled
|
||||
data={[]}
|
||||
<Divider my="0.5rem" />
|
||||
<Group grow>
|
||||
<NumberInput
|
||||
defaultValue={filter.jfParams?.minYear}
|
||||
hideControls={false}
|
||||
label="From year"
|
||||
max={2300}
|
||||
min={1700}
|
||||
required={!!filter.jfParams?.maxYear}
|
||||
onChange={(e) => handleMinYearFilter(e)}
|
||||
/>
|
||||
</Stack> */}
|
||||
<NumberInput
|
||||
defaultValue={filter.jfParams?.maxYear}
|
||||
hideControls={false}
|
||||
label="To year"
|
||||
max={2300}
|
||||
min={1700}
|
||||
required={!!filter.jfParams?.minYear}
|
||||
onChange={(e) => handleMaxYearFilter(e)}
|
||||
/>
|
||||
</Group>
|
||||
<Group grow>
|
||||
<MultiSelect
|
||||
clearable
|
||||
searchable
|
||||
data={genreList}
|
||||
defaultValue={selectedGenres}
|
||||
label="Genres"
|
||||
onChange={handleGenresFilter}
|
||||
/>
|
||||
</Group>
|
||||
|
||||
<Group grow>
|
||||
<MultiSelect
|
||||
clearable
|
||||
searchable
|
||||
data={selectableAlbumArtists}
|
||||
defaultValue={filter.jfParams?.albumArtistIds?.split(',')}
|
||||
disabled={disableArtistFilter}
|
||||
label="Artist"
|
||||
limit={300}
|
||||
placeholder="Type to search for an artist"
|
||||
rightSection={albumArtistListQuery.isFetching ? <SpinnerIcon /> : undefined}
|
||||
searchValue={albumArtistSearchTerm}
|
||||
onChange={handleAlbumArtistFilter}
|
||||
onSearchChange={setAlbumArtistSearchTerm}
|
||||
/>
|
||||
</Group>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,15 +1,21 @@
|
||||
import { ChangeEvent, useMemo } from 'react';
|
||||
import { ChangeEvent, useMemo, useState } from 'react';
|
||||
import { Divider, Group, Stack } from '@mantine/core';
|
||||
import { NumberInput, Switch, Text, Select } from '/@/renderer/components';
|
||||
import { NumberInput, Switch, Text, Select, SpinnerIcon } from '/@/renderer/components';
|
||||
import { AlbumListFilter, useAlbumListStore, useSetAlbumFilters } from '/@/renderer/store';
|
||||
import debounce from 'lodash/debounce';
|
||||
import { useGenreList } from '/@/renderer/features/genres';
|
||||
import { useAlbumArtistList } from '/@/renderer/features/artists/queries/album-artist-list-query';
|
||||
import { AlbumArtistListSort, SortOrder } from '/@/renderer/api/types';
|
||||
|
||||
interface NavidromeAlbumFiltersProps {
|
||||
disableArtistFilter?: boolean;
|
||||
handleFilterChange: (filters: AlbumListFilter) => void;
|
||||
}
|
||||
|
||||
export const NavidromeAlbumFilters = ({ handleFilterChange }: NavidromeAlbumFiltersProps) => {
|
||||
export const NavidromeAlbumFilters = ({
|
||||
handleFilterChange,
|
||||
disableArtistFilter,
|
||||
}: NavidromeAlbumFiltersProps) => {
|
||||
const { filter } = useAlbumListStore();
|
||||
const setFilters = useSetAlbumFilters();
|
||||
|
||||
@@ -79,45 +85,52 @@ export const NavidromeAlbumFilters = ({ handleFilterChange }: NavidromeAlbumFilt
|
||||
},
|
||||
];
|
||||
|
||||
const handleYearFilter = debounce((e: number | undefined) => {
|
||||
const handleYearFilter = debounce((e: number | string) => {
|
||||
const updatedFilters = setFilters({
|
||||
ndParams: {
|
||||
...filter.ndParams,
|
||||
year: e,
|
||||
year: e === '' ? undefined : (e as number),
|
||||
},
|
||||
});
|
||||
handleFilterChange(updatedFilters);
|
||||
}, 500);
|
||||
|
||||
const [albumArtistSearchTerm, setAlbumArtistSearchTerm] = useState<string>('');
|
||||
|
||||
const albumArtistListQuery = useAlbumArtistList(
|
||||
{
|
||||
// searchTerm: debouncedSearchTerm,
|
||||
sortBy: AlbumArtistListSort.NAME,
|
||||
sortOrder: SortOrder.ASC,
|
||||
startIndex: 0,
|
||||
},
|
||||
{
|
||||
cacheTime: 1000 * 60 * 2,
|
||||
staleTime: 1000 * 60 * 1,
|
||||
},
|
||||
);
|
||||
|
||||
const selectableAlbumArtists = useMemo(() => {
|
||||
if (!albumArtistListQuery?.data?.items) return [];
|
||||
|
||||
return albumArtistListQuery?.data?.items?.map((artist) => ({
|
||||
label: artist.name,
|
||||
value: artist.id,
|
||||
}));
|
||||
}, [albumArtistListQuery?.data?.items]);
|
||||
|
||||
const handleAlbumArtistFilter = (e: string | null) => {
|
||||
const updatedFilters = setFilters({
|
||||
ndParams: {
|
||||
...filter.ndParams,
|
||||
artist_id: e || undefined,
|
||||
},
|
||||
});
|
||||
handleFilterChange(updatedFilters);
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack p="0.8rem">
|
||||
<Group position="apart">
|
||||
<Text>Year</Text>
|
||||
<NumberInput
|
||||
hideControls={false}
|
||||
max={5000}
|
||||
min={0}
|
||||
value={filter.ndParams?.year}
|
||||
width={80}
|
||||
onChange={handleYearFilter}
|
||||
/>
|
||||
</Group>
|
||||
<Divider my="0.5rem" />
|
||||
<Group
|
||||
position="apart"
|
||||
spacing={20}
|
||||
>
|
||||
<Text>Genre</Text>
|
||||
<Select
|
||||
clearable
|
||||
searchable
|
||||
data={genreList}
|
||||
defaultValue={filter.ndParams?.genre_id}
|
||||
width={150}
|
||||
onChange={handleGenresFilter}
|
||||
/>
|
||||
</Group>
|
||||
<Divider my="0.5rem" />
|
||||
{toggleFilters.map((filter) => (
|
||||
<Group
|
||||
key={`nd-filter-${filter.label}`}
|
||||
@@ -130,6 +143,41 @@ export const NavidromeAlbumFilters = ({ handleFilterChange }: NavidromeAlbumFilt
|
||||
/>
|
||||
</Group>
|
||||
))}
|
||||
<Divider my="0.5rem" />
|
||||
<Group grow>
|
||||
<NumberInput
|
||||
defaultValue={filter.ndParams?.year}
|
||||
hideControls={false}
|
||||
label="Year"
|
||||
max={5000}
|
||||
min={0}
|
||||
onChange={(e) => handleYearFilter(e)}
|
||||
/>
|
||||
<Select
|
||||
clearable
|
||||
searchable
|
||||
data={genreList}
|
||||
defaultValue={filter.ndParams?.genre_id}
|
||||
label="Genre"
|
||||
onChange={handleGenresFilter}
|
||||
/>
|
||||
</Group>
|
||||
<Group grow>
|
||||
<Select
|
||||
clearable
|
||||
searchable
|
||||
data={selectableAlbumArtists}
|
||||
defaultValue={filter.ndParams?.artist_id}
|
||||
disabled={disableArtistFilter}
|
||||
label="Artist"
|
||||
limit={300}
|
||||
placeholder="Type to search for an artist"
|
||||
rightSection={albumArtistListQuery.isFetching ? <SpinnerIcon /> : undefined}
|
||||
searchValue={albumArtistSearchTerm}
|
||||
onChange={handleAlbumArtistFilter}
|
||||
onSearchChange={setAlbumArtistSearchTerm}
|
||||
/>
|
||||
</Group>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { PageHeader, ScrollArea } from '/@/renderer/components';
|
||||
import { AnimatedPage } from '/@/renderer/features/shared';
|
||||
import { NativeScrollArea } from '/@/renderer/components';
|
||||
import { AnimatedPage, LibraryHeaderBar, PlayButton } from '/@/renderer/features/shared';
|
||||
import { useRef } from 'react';
|
||||
import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/lib/agGridReact';
|
||||
import { useAlbumDetail } from '/@/renderer/features/albums/queries/album-detail-query';
|
||||
@@ -7,32 +7,54 @@ import { useParams } from 'react-router';
|
||||
import { useFastAverageColor } from '/@/renderer/hooks';
|
||||
import { AlbumDetailContent } from '/@/renderer/features/albums/components/album-detail-content';
|
||||
import { AlbumDetailHeader } from '/@/renderer/features/albums/components/album-detail-header';
|
||||
import { usePlayQueueAdd } from '/@/renderer/features/player';
|
||||
import { usePlayButtonBehavior } from '/@/renderer/store/settings.store';
|
||||
import { LibraryItem } from '/@/renderer/api/types';
|
||||
|
||||
const AlbumDetailRoute = () => {
|
||||
const tableRef = useRef<AgGridReactType | null>(null);
|
||||
const scrollAreaRef = useRef<HTMLDivElement>(null);
|
||||
const headerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const { albumId } = useParams() as { albumId: string };
|
||||
const detailQuery = useAlbumDetail({ id: albumId });
|
||||
const background = useFastAverageColor(detailQuery.data?.imageUrl);
|
||||
const background = useFastAverageColor(detailQuery.data?.imageUrl, !detailQuery.isLoading);
|
||||
const handlePlayQueueAdd = usePlayQueueAdd();
|
||||
const playButtonBehavior = usePlayButtonBehavior();
|
||||
|
||||
const handlePlay = () => {
|
||||
handlePlayQueueAdd?.({
|
||||
byItemType: {
|
||||
id: [albumId],
|
||||
type: LibraryItem.ALBUM,
|
||||
},
|
||||
play: playButtonBehavior,
|
||||
});
|
||||
};
|
||||
|
||||
if (!background) return null;
|
||||
|
||||
return (
|
||||
<AnimatedPage>
|
||||
<PageHeader
|
||||
useOpacity
|
||||
position="absolute"
|
||||
/>
|
||||
|
||||
<ScrollArea
|
||||
h="100%"
|
||||
offsetScrollbars={false}
|
||||
styles={{
|
||||
scrollbar: {
|
||||
marginTop: '35px',
|
||||
},
|
||||
<AnimatedPage key={`album-detail-${albumId}`}>
|
||||
<NativeScrollArea
|
||||
ref={scrollAreaRef}
|
||||
pageHeaderProps={{
|
||||
backgroundColor: background,
|
||||
children: (
|
||||
<LibraryHeaderBar>
|
||||
<PlayButton onClick={handlePlay} />
|
||||
<LibraryHeaderBar.Title>{detailQuery?.data?.name}</LibraryHeaderBar.Title>
|
||||
</LibraryHeaderBar>
|
||||
),
|
||||
target: headerRef,
|
||||
}}
|
||||
>
|
||||
<AlbumDetailHeader background={background} />
|
||||
<AlbumDetailHeader
|
||||
ref={headerRef}
|
||||
background={background}
|
||||
/>
|
||||
<AlbumDetailContent tableRef={tableRef} />
|
||||
</ScrollArea>
|
||||
</NativeScrollArea>
|
||||
</AnimatedPage>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -4,20 +4,76 @@ import { AlbumListHeader } from '/@/renderer/features/albums/components/album-li
|
||||
import { AlbumListContent } from '/@/renderer/features/albums/components/album-list-content';
|
||||
import { useRef } from 'react';
|
||||
import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/lib/agGridReact';
|
||||
import { useAlbumList } from '/@/renderer/features/albums/queries/album-list-query';
|
||||
import { useAlbumListFilters, useCurrentServer } from '/@/renderer/store';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { AlbumListQuery, ServerType } from '/@/renderer/api/types';
|
||||
|
||||
const AlbumListRoute = () => {
|
||||
const gridRef = useRef<VirtualInfiniteGridRef | null>(null);
|
||||
const tableRef = useRef<AgGridReactType | null>(null);
|
||||
const filters = useAlbumListFilters();
|
||||
const server = useCurrentServer();
|
||||
|
||||
const [searchParams] = useSearchParams();
|
||||
|
||||
const customFilters: Partial<AlbumListQuery> | undefined = searchParams.get('artistId')
|
||||
? {
|
||||
jfParams:
|
||||
server?.type === ServerType.JELLYFIN
|
||||
? {
|
||||
artistIds: searchParams.get('artistId') as string,
|
||||
}
|
||||
: undefined,
|
||||
ndParams:
|
||||
server?.type === ServerType.NAVIDROME
|
||||
? {
|
||||
artist_id: searchParams.get('artistId') as string,
|
||||
}
|
||||
: undefined,
|
||||
}
|
||||
: undefined;
|
||||
|
||||
const itemCountCheck = useAlbumList(
|
||||
{
|
||||
limit: 1,
|
||||
startIndex: 0,
|
||||
...filters,
|
||||
...customFilters,
|
||||
jfParams: {
|
||||
...filters.jfParams,
|
||||
...customFilters?.jfParams,
|
||||
},
|
||||
ndParams: {
|
||||
...filters.ndParams,
|
||||
...customFilters?.ndParams,
|
||||
},
|
||||
},
|
||||
{
|
||||
cacheTime: 1000 * 60 * 60 * 2,
|
||||
staleTime: 1000 * 60 * 60 * 2,
|
||||
},
|
||||
);
|
||||
|
||||
const itemCount =
|
||||
itemCountCheck.data?.totalRecordCount === null
|
||||
? undefined
|
||||
: itemCountCheck.data?.totalRecordCount;
|
||||
|
||||
return (
|
||||
<AnimatedPage>
|
||||
<VirtualGridContainer>
|
||||
<AlbumListHeader
|
||||
customFilters={customFilters}
|
||||
gridRef={gridRef}
|
||||
itemCount={itemCount}
|
||||
tableRef={tableRef}
|
||||
title={searchParams.get('artistName') || undefined}
|
||||
/>
|
||||
<AlbumListContent
|
||||
customFilters={customFilters}
|
||||
gridRef={gridRef}
|
||||
itemCount={itemCount}
|
||||
tableRef={tableRef}
|
||||
/>
|
||||
</VirtualGridContainer>
|
||||
|
||||
@@ -0,0 +1,447 @@
|
||||
import { useMemo } from 'react';
|
||||
import {
|
||||
Button,
|
||||
getColumnDefs,
|
||||
GridCarousel,
|
||||
Text,
|
||||
TextTitle,
|
||||
VirtualTable,
|
||||
} from '/@/renderer/components';
|
||||
import { ColDef, RowDoubleClickedEvent } from '@ag-grid-community/core';
|
||||
import { Box, Group, Stack } from '@mantine/core';
|
||||
import { RiHeartFill, RiHeartLine, RiMoreFill } from 'react-icons/ri';
|
||||
import { generatePath, useParams } from 'react-router';
|
||||
import { useCurrentServer } from '/@/renderer/store';
|
||||
import { createSearchParams, Link } from 'react-router-dom';
|
||||
import styled from 'styled-components';
|
||||
import { AppRoute } from '/@/renderer/router/routes';
|
||||
import { useContainerQuery } from '/@/renderer/hooks';
|
||||
import { usePlayButtonBehavior } from '/@/renderer/store/settings.store';
|
||||
import {
|
||||
useHandleGeneralContextMenu,
|
||||
useHandleTableContextMenu,
|
||||
} from '/@/renderer/features/context-menu';
|
||||
import { Play, TableColumn } from '/@/renderer/types';
|
||||
import {
|
||||
ARTIST_CONTEXT_MENU_ITEMS,
|
||||
SONG_CONTEXT_MENU_ITEMS,
|
||||
} from '/@/renderer/features/context-menu/context-menu-items';
|
||||
import { PlayButton, useCreateFavorite, useDeleteFavorite } from '/@/renderer/features/shared';
|
||||
import { useAlbumList } from '/@/renderer/features/albums/queries/album-list-query';
|
||||
import {
|
||||
AlbumListSort,
|
||||
LibraryItem,
|
||||
QueueSong,
|
||||
ServerType,
|
||||
SortOrder,
|
||||
} from '/@/renderer/api/types';
|
||||
import { usePlayQueueAdd } from '/@/renderer/features/player';
|
||||
import { useAlbumArtistDetail } from '/@/renderer/features/artists/queries/album-artist-detail-query';
|
||||
import { useTopSongsList } from '/@/renderer/features/artists/queries/top-songs-list-query';
|
||||
|
||||
const ContentContainer = styled.div`
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3rem;
|
||||
padding: 1rem 2rem 5rem;
|
||||
overflow: hidden;
|
||||
|
||||
.ag-theme-alpine-dark {
|
||||
--ag-header-background-color: rgba(0, 0, 0, 0%) !important;
|
||||
}
|
||||
|
||||
.ag-header {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
`;
|
||||
|
||||
export const AlbumArtistDetailContent = () => {
|
||||
const { albumArtistId } = useParams() as { albumArtistId: string };
|
||||
const cq = useContainerQuery();
|
||||
const handlePlayQueueAdd = usePlayQueueAdd();
|
||||
const server = useCurrentServer();
|
||||
const itemsPerPage = cq.isXl ? 9 : cq.isLg ? 7 : cq.isMd ? 5 : cq.isSm ? 4 : 3;
|
||||
|
||||
const detailQuery = useAlbumArtistDetail({ id: albumArtistId });
|
||||
|
||||
const artistDiscographyLink = `${generatePath(AppRoute.LIBRARY_ALBUM_ARTISTS_DETAIL_DISCOGRAPHY, {
|
||||
albumArtistId,
|
||||
})}?${createSearchParams({
|
||||
artistId: albumArtistId,
|
||||
artistName: detailQuery?.data?.name || '',
|
||||
})}`;
|
||||
|
||||
const artistSongsLink = `${generatePath(AppRoute.LIBRARY_ALBUM_ARTISTS_DETAIL_SONGS, {
|
||||
albumArtistId,
|
||||
})}?${createSearchParams({
|
||||
artistId: albumArtistId,
|
||||
artistName: detailQuery?.data?.name || '',
|
||||
})}`;
|
||||
|
||||
const recentAlbumsQuery = useAlbumList({
|
||||
jfParams: server?.type === ServerType.JELLYFIN ? { artistIds: albumArtistId } : undefined,
|
||||
limit: itemsPerPage,
|
||||
ndParams:
|
||||
server?.type === ServerType.NAVIDROME
|
||||
? { artist_id: albumArtistId, compilation: false }
|
||||
: undefined,
|
||||
sortBy: AlbumListSort.RELEASE_DATE,
|
||||
sortOrder: SortOrder.DESC,
|
||||
startIndex: 0,
|
||||
});
|
||||
|
||||
const compilationAlbumsQuery = useAlbumList({
|
||||
jfParams:
|
||||
server?.type === ServerType.JELLYFIN ? { contributingArtistIds: albumArtistId } : undefined,
|
||||
limit: itemsPerPage,
|
||||
ndParams:
|
||||
server?.type === ServerType.NAVIDROME
|
||||
? { artist_id: albumArtistId, compilation: true }
|
||||
: undefined,
|
||||
sortBy: AlbumListSort.RELEASE_DATE,
|
||||
sortOrder: SortOrder.DESC,
|
||||
startIndex: 0,
|
||||
});
|
||||
|
||||
const topSongsQuery = useTopSongsList(
|
||||
{ artist: detailQuery?.data?.name || '' },
|
||||
{ enabled: server?.type !== ServerType.JELLYFIN && !!detailQuery?.data?.name },
|
||||
);
|
||||
|
||||
const topSongsColumnDefs: ColDef[] = useMemo(
|
||||
() =>
|
||||
getColumnDefs([
|
||||
{ column: TableColumn.ROW_INDEX, width: 0 },
|
||||
{ column: TableColumn.TITLE_COMBINED, width: 0 },
|
||||
{ column: TableColumn.DURATION, width: 0 },
|
||||
{ column: TableColumn.ALBUM, width: 0 },
|
||||
{ column: TableColumn.YEAR, width: 0 },
|
||||
{ column: TableColumn.PLAY_COUNT, width: 0 },
|
||||
{ column: TableColumn.USER_FAVORITE, width: 0 },
|
||||
]),
|
||||
[],
|
||||
);
|
||||
|
||||
const cardRows = {
|
||||
album: [
|
||||
{
|
||||
property: 'name',
|
||||
route: {
|
||||
route: AppRoute.LIBRARY_ALBUMS_DETAIL,
|
||||
slugs: [{ idProperty: 'id', slugProperty: 'albumId' }],
|
||||
},
|
||||
},
|
||||
{
|
||||
arrayProperty: 'name',
|
||||
property: 'albumArtists',
|
||||
route: {
|
||||
route: AppRoute.LIBRARY_ALBUM_ARTISTS_DETAIL,
|
||||
slugs: [{ idProperty: 'id', slugProperty: 'albumArtistId' }],
|
||||
},
|
||||
},
|
||||
],
|
||||
albumArtist: [
|
||||
{
|
||||
property: 'name',
|
||||
route: {
|
||||
route: AppRoute.LIBRARY_ALBUM_ARTISTS_DETAIL,
|
||||
slugs: [{ idProperty: 'id', slugProperty: 'albumArtistId' }],
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const carousels = [
|
||||
{
|
||||
data: recentAlbumsQuery?.data?.items,
|
||||
isHidden: !recentAlbumsQuery?.data?.items?.length,
|
||||
itemType: LibraryItem.ALBUM,
|
||||
loading: recentAlbumsQuery?.isLoading || recentAlbumsQuery.isFetching,
|
||||
pagination: {
|
||||
itemsPerPage,
|
||||
},
|
||||
title: (
|
||||
<>
|
||||
<TextTitle
|
||||
order={2}
|
||||
weight={700}
|
||||
>
|
||||
Recent releases
|
||||
</TextTitle>
|
||||
<Button
|
||||
compact
|
||||
uppercase
|
||||
component={Link}
|
||||
to={artistDiscographyLink}
|
||||
variant="subtle"
|
||||
>
|
||||
View discography
|
||||
</Button>
|
||||
</>
|
||||
),
|
||||
uniqueId: 'recentReleases',
|
||||
},
|
||||
{
|
||||
data: compilationAlbumsQuery?.data?.items,
|
||||
isHidden: !compilationAlbumsQuery?.data?.items?.length,
|
||||
itemType: LibraryItem.ALBUM,
|
||||
loading: compilationAlbumsQuery?.isLoading || compilationAlbumsQuery.isFetching,
|
||||
pagination: {
|
||||
itemsPerPage,
|
||||
},
|
||||
title: (
|
||||
<TextTitle
|
||||
order={2}
|
||||
weight={700}
|
||||
>
|
||||
Appears on
|
||||
</TextTitle>
|
||||
),
|
||||
uniqueId: 'compilationAlbums',
|
||||
},
|
||||
{
|
||||
data: detailQuery?.data?.similarArtists?.slice(0, itemsPerPage),
|
||||
isHidden: !detailQuery?.data?.similarArtists,
|
||||
itemType: LibraryItem.ALBUM_ARTIST,
|
||||
loading: detailQuery?.isLoading || detailQuery.isFetching,
|
||||
pagination: {
|
||||
itemsPerPage,
|
||||
},
|
||||
title: (
|
||||
<TextTitle
|
||||
order={2}
|
||||
weight={700}
|
||||
>
|
||||
Related artists
|
||||
</TextTitle>
|
||||
),
|
||||
uniqueId: 'similarArtists',
|
||||
},
|
||||
];
|
||||
|
||||
const playButtonBehavior = usePlayButtonBehavior();
|
||||
|
||||
const handlePlay = async (playType?: Play) => {
|
||||
handlePlayQueueAdd?.({
|
||||
byItemType: {
|
||||
id: [albumArtistId],
|
||||
type: LibraryItem.ALBUM_ARTIST,
|
||||
},
|
||||
play: playType || playButtonBehavior,
|
||||
});
|
||||
};
|
||||
|
||||
const handleContextMenu = useHandleTableContextMenu(LibraryItem.SONG, SONG_CONTEXT_MENU_ITEMS);
|
||||
|
||||
const handleRowDoubleClick = (e: RowDoubleClickedEvent<QueueSong>) => {
|
||||
if (!e.data) return;
|
||||
handlePlayQueueAdd?.({
|
||||
byData: [e.data],
|
||||
play: playButtonBehavior,
|
||||
});
|
||||
};
|
||||
|
||||
const createFavoriteMutation = useCreateFavorite();
|
||||
const deleteFavoriteMutation = useDeleteFavorite();
|
||||
|
||||
const handleFavorite = () => {
|
||||
if (!detailQuery?.data) return;
|
||||
|
||||
if (detailQuery.data.userFavorite) {
|
||||
deleteFavoriteMutation.mutate({
|
||||
query: {
|
||||
id: [detailQuery.data.id],
|
||||
type: LibraryItem.ALBUM_ARTIST,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
createFavoriteMutation.mutate({
|
||||
query: {
|
||||
id: [detailQuery.data.id],
|
||||
type: LibraryItem.ALBUM_ARTIST,
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleGeneralContextMenu = useHandleGeneralContextMenu(
|
||||
LibraryItem.ALBUM_ARTIST,
|
||||
ARTIST_CONTEXT_MENU_ITEMS,
|
||||
);
|
||||
|
||||
const topSongs = topSongsQuery?.data?.items?.slice(0, 10);
|
||||
|
||||
const showBiography =
|
||||
detailQuery?.data?.biography !== undefined && detailQuery?.data?.biography !== null;
|
||||
const showTopSongs = server?.type !== ServerType.JELLYFIN && topSongsQuery?.data?.items?.length;
|
||||
const showGenres = detailQuery?.data?.genres ? detailQuery?.data?.genres.length !== 0 : false;
|
||||
|
||||
const isLoading =
|
||||
detailQuery?.isLoading || (server?.type === ServerType.NAVIDROME && topSongsQuery?.isLoading);
|
||||
|
||||
if (isLoading) return <ContentContainer ref={cq.ref} />;
|
||||
|
||||
return (
|
||||
<ContentContainer ref={cq.ref}>
|
||||
<Box component="section">
|
||||
<Group spacing="md">
|
||||
<PlayButton onClick={() => handlePlay(playButtonBehavior)} />
|
||||
<Group spacing="xs">
|
||||
<Button
|
||||
compact
|
||||
loading={createFavoriteMutation.isLoading || deleteFavoriteMutation.isLoading}
|
||||
variant="subtle"
|
||||
onClick={handleFavorite}
|
||||
>
|
||||
{detailQuery?.data?.userFavorite ? (
|
||||
<RiHeartFill
|
||||
color="red"
|
||||
size={20}
|
||||
/>
|
||||
) : (
|
||||
<RiHeartLine size={20} />
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
compact
|
||||
variant="subtle"
|
||||
onClick={(e) => {
|
||||
if (!detailQuery?.data) return;
|
||||
handleGeneralContextMenu(e, [detailQuery.data!]);
|
||||
}}
|
||||
>
|
||||
<RiMoreFill size={20} />
|
||||
</Button>
|
||||
<Button
|
||||
compact
|
||||
uppercase
|
||||
component={Link}
|
||||
to={artistDiscographyLink}
|
||||
variant="subtle"
|
||||
>
|
||||
View discography
|
||||
</Button>
|
||||
<Button
|
||||
compact
|
||||
uppercase
|
||||
component={Link}
|
||||
to={artistSongsLink}
|
||||
variant="subtle"
|
||||
>
|
||||
View all songs
|
||||
</Button>
|
||||
</Group>
|
||||
</Group>
|
||||
</Box>
|
||||
{showGenres && (
|
||||
<Box component="section">
|
||||
<Group>
|
||||
{detailQuery?.data?.genres?.map((genre) => (
|
||||
<Button
|
||||
key={`genre-${genre.id}`}
|
||||
compact
|
||||
component={Link}
|
||||
radius="md"
|
||||
size="sm"
|
||||
to={generatePath(`${AppRoute.LIBRARY_ALBUM_ARTISTS}?genre=${genre.id}`, {
|
||||
albumArtistId,
|
||||
})}
|
||||
variant="default"
|
||||
>
|
||||
{genre.name}
|
||||
</Button>
|
||||
))}
|
||||
</Group>
|
||||
</Box>
|
||||
)}
|
||||
{showBiography ? (
|
||||
<Box
|
||||
component="section"
|
||||
maw="1280px"
|
||||
>
|
||||
<TextTitle
|
||||
order={2}
|
||||
weight={700}
|
||||
>
|
||||
About {detailQuery?.data?.name}
|
||||
</TextTitle>
|
||||
<Text
|
||||
$secondary
|
||||
component="p"
|
||||
dangerouslySetInnerHTML={{ __html: detailQuery?.data?.biography || '' }}
|
||||
sx={{ textAlign: 'justify' }}
|
||||
/>
|
||||
</Box>
|
||||
) : null}
|
||||
{showTopSongs && (
|
||||
<Box component="section">
|
||||
<Group
|
||||
noWrap
|
||||
position="apart"
|
||||
>
|
||||
<Group
|
||||
noWrap
|
||||
align="flex-end"
|
||||
>
|
||||
<TextTitle
|
||||
order={2}
|
||||
weight={700}
|
||||
>
|
||||
Top Songs
|
||||
</TextTitle>
|
||||
<Button
|
||||
compact
|
||||
uppercase
|
||||
component={Link}
|
||||
to={generatePath(AppRoute.LIBRARY_ALBUM_ARTISTS_DETAIL_TOP_SONGS, {
|
||||
albumArtistId,
|
||||
})}
|
||||
variant="subtle"
|
||||
>
|
||||
View all
|
||||
</Button>
|
||||
</Group>
|
||||
</Group>
|
||||
<VirtualTable
|
||||
autoFitColumns
|
||||
autoHeight
|
||||
deselectOnClickOutside
|
||||
suppressCellFocus
|
||||
suppressHorizontalScroll
|
||||
suppressLoadingOverlay
|
||||
suppressRowDrag
|
||||
columnDefs={topSongsColumnDefs}
|
||||
enableCellChangeFlash={false}
|
||||
getRowId={(data) => data.data.uniqueId}
|
||||
rowData={topSongs}
|
||||
rowHeight={60}
|
||||
rowSelection="multiple"
|
||||
onCellContextMenu={handleContextMenu}
|
||||
onRowDoubleClicked={handleRowDoubleClick}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
<Box component="section">
|
||||
<Stack spacing="xl">
|
||||
{carousels
|
||||
.filter((c) => !c.isHidden)
|
||||
.map((carousel) => (
|
||||
<GridCarousel
|
||||
key={`carousel-${carousel.uniqueId}`}
|
||||
cardRows={cardRows[carousel.itemType as keyof typeof cardRows]}
|
||||
containerWidth={cq.width}
|
||||
data={carousel.data}
|
||||
itemType={carousel.itemType}
|
||||
loading={carousel.loading}
|
||||
pagination={carousel.pagination}
|
||||
uniqueId={carousel.uniqueId}
|
||||
>
|
||||
<GridCarousel.Title>{carousel.title}</GridCarousel.Title>
|
||||
</GridCarousel>
|
||||
))}
|
||||
</Stack>
|
||||
</Box>
|
||||
</ContentContainer>
|
||||
);
|
||||
};
|
||||
+198
@@ -0,0 +1,198 @@
|
||||
// import { useMemo, useRef } from 'react';
|
||||
// import { ColDef } from '@ag-grid-community/core';
|
||||
// import { Box, Group, Stack } from '@mantine/core';
|
||||
// import { useVirtualizer, Virtualizer } from '@tanstack/react-virtual';
|
||||
// import { useParams } from 'react-router';
|
||||
// import { AlbumListSort, SortOrder, SongListSort, Song } from '/@/renderer/api/types';
|
||||
// import {
|
||||
// getColumnDefs,
|
||||
// VirtualTable,
|
||||
// Text,
|
||||
// TextTitle,
|
||||
// NativeScrollArea,
|
||||
// } from '/@/renderer/components';
|
||||
// import { useAlbumList } from '/@/renderer/features/albums';
|
||||
// import { PlayButton } from '/@/renderer/features/shared';
|
||||
// import { useSongList } from '/@/renderer/features/songs';
|
||||
// import { useSongListStore } from '/@/renderer/store';
|
||||
// import { usePlayQueueAdd } from '/@/renderer/features/player';
|
||||
// import { Play } from '/@/renderer/types';
|
||||
|
||||
// const RowVirtualizer = ({
|
||||
// rows,
|
||||
// columnDefs,
|
||||
// handlePlay,
|
||||
// rowVirtualizer,
|
||||
// }: {
|
||||
// columnDefs: ColDef[];
|
||||
// handlePlay: (play: Play, data: any[]) => void;
|
||||
// rowVirtualizer: Virtualizer<any, Element>;
|
||||
// rows: any[];
|
||||
// }) => {
|
||||
// const items = rowVirtualizer.getVirtualItems();
|
||||
|
||||
// return (
|
||||
// <div
|
||||
// style={{
|
||||
// height: `${rowVirtualizer.getTotalSize()}px`,
|
||||
// position: 'relative',
|
||||
// width: '100%',
|
||||
// }}
|
||||
// >
|
||||
// {items.map((virtualRow) => (
|
||||
// <div
|
||||
// key={rows?.[virtualRow.index].id}
|
||||
// style={{
|
||||
// height: `${(rows?.[virtualRow.index].songs?.length || 0) * 60 + 300}px`,
|
||||
// left: 0,
|
||||
// position: 'absolute',
|
||||
// top: 0,
|
||||
// transform: `translateY(${virtualRow.start}px)`,
|
||||
// width: '100%',
|
||||
// }}
|
||||
// >
|
||||
// <Stack
|
||||
// p="2rem"
|
||||
// spacing="lg"
|
||||
// >
|
||||
// <Group noWrap>
|
||||
// <img
|
||||
// alt={`${rows?.[virtualRow.index]?.name}-cover`}
|
||||
// height={150}
|
||||
// src={rows?.[virtualRow.index]?.imageUrl}
|
||||
// width={150}
|
||||
// />
|
||||
// <Stack>
|
||||
// <TextTitle
|
||||
// order={1}
|
||||
// weight={700}
|
||||
// >
|
||||
// {rows?.[virtualRow.index]?.name}
|
||||
// </TextTitle>
|
||||
// <Text $secondary>{rows?.[virtualRow.index]?.releaseYear}</Text>
|
||||
// <PlayButton
|
||||
// h="35px"
|
||||
// w="35px"
|
||||
// onClick={() => handlePlay?.(Play.NOW, rows?.[virtualRow.index]?.songs)}
|
||||
// />
|
||||
// </Stack>
|
||||
// </Group>
|
||||
// <Box sx={{ height: `${(rows?.[virtualRow.index].songs?.length || 0) * 60 + 60}px` }}>
|
||||
// <VirtualTable
|
||||
// autoFitColumns
|
||||
// suppressCellFocus
|
||||
// suppressHorizontalScroll
|
||||
// suppressLoadingOverlay
|
||||
// suppressRowDrag
|
||||
// transparentHeader
|
||||
// columnDefs={columnDefs}
|
||||
// getRowId={(data) => data.data.id}
|
||||
// rowData={rows?.[virtualRow.index]?.songs}
|
||||
// rowHeight={60}
|
||||
// rowModelType="clientSide"
|
||||
// rowSelection="multiple"
|
||||
// />
|
||||
// </Box>
|
||||
// </Stack>
|
||||
// </div>
|
||||
// ))}
|
||||
// </div>
|
||||
// );
|
||||
// };
|
||||
|
||||
// export const AlbumArtistDiscographyDetailList = () => {
|
||||
// const { albumArtistId } = useParams() as { albumArtistId: string };
|
||||
// // const albumArtistQuery = useAlbumArtistDetail({ id: albumArtistId });
|
||||
|
||||
// const albumsQuery = useAlbumList({
|
||||
// jfParams: { artistIds: albumArtistId },
|
||||
// ndParams: { artist_id: albumArtistId },
|
||||
// sortBy: AlbumListSort.YEAR,
|
||||
// sortOrder: SortOrder.DESC,
|
||||
// startIndex: 0,
|
||||
// });
|
||||
|
||||
// const songsQuery = useSongList(
|
||||
// {
|
||||
// albumIds: albumsQuery.data?.items?.map((album) => album.id),
|
||||
// sortBy: SongListSort.ALBUM,
|
||||
// sortOrder: SortOrder.ASC,
|
||||
// startIndex: 0,
|
||||
// },
|
||||
// {
|
||||
// enabled: !albumsQuery.isLoading,
|
||||
// },
|
||||
// );
|
||||
|
||||
// const songsByAlbum = useMemo(() => {
|
||||
// if (songsQuery.isLoading || albumsQuery.isLoading) return null;
|
||||
|
||||
// const songsByAlbumMap = songsQuery?.data?.items?.reduce((acc, song) => {
|
||||
// if (!acc[song.albumId as keyof typeof acc]) {
|
||||
// acc[song.albumId as keyof typeof acc] = [];
|
||||
// }
|
||||
|
||||
// acc[song.albumId as keyof typeof acc].push(song);
|
||||
|
||||
// return acc;
|
||||
// }, {} as Record<string, Song[]>);
|
||||
|
||||
// const albumDetailWithSongs = albumsQuery?.data?.items?.map((album) => {
|
||||
// return {
|
||||
// ...album,
|
||||
// songs: songsByAlbumMap?.[album.id],
|
||||
// };
|
||||
// });
|
||||
|
||||
// return albumDetailWithSongs;
|
||||
// }, [
|
||||
// albumsQuery?.data?.items,
|
||||
// albumsQuery?.isLoading,
|
||||
// songsQuery?.data?.items,
|
||||
// songsQuery?.isLoading,
|
||||
// ]);
|
||||
|
||||
// const page = useSongListStore();
|
||||
|
||||
// const columnDefs: ColDef[] = useMemo(
|
||||
// () =>
|
||||
// getColumnDefs(page.table.columns).filter((c) => c.colId !== 'album' && c.colId !== 'artist'),
|
||||
// [page.table.columns],
|
||||
// );
|
||||
|
||||
// const handlePlayQueueAdd = usePlayQueueAdd();
|
||||
|
||||
// const parentRef = useRef<any>();
|
||||
|
||||
// const rowVirtualizer = useVirtualizer({
|
||||
// count: songsByAlbum?.length || 0,
|
||||
// estimateSize: (i) => (songsByAlbum?.[i].songs?.length || 0) * 60 + 300,
|
||||
// getScrollElement: () => parentRef.current,
|
||||
// overscan: 3,
|
||||
// });
|
||||
|
||||
// const handlePlay = (play: Play, data: any[]) => {
|
||||
// handlePlayQueueAdd?.({
|
||||
// byData: data,
|
||||
// play,
|
||||
// });
|
||||
// };
|
||||
|
||||
// if (albumsQuery.isLoading || songsQuery.isLoading) return null;
|
||||
|
||||
// return (
|
||||
// <NativeScrollArea
|
||||
// ref={parentRef}
|
||||
// scrollBarOffset="0"
|
||||
// >
|
||||
// {songsByAlbum && (
|
||||
// <RowVirtualizer
|
||||
// columnDefs={columnDefs}
|
||||
// handlePlay={handlePlay}
|
||||
// rowVirtualizer={rowVirtualizer}
|
||||
// rows={songsByAlbum}
|
||||
// />
|
||||
// )}
|
||||
// </NativeScrollArea>
|
||||
// );
|
||||
// };
|
||||
@@ -0,0 +1,473 @@
|
||||
import type { IDatasource } from '@ag-grid-community/core';
|
||||
import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/lib/agGridReact';
|
||||
import { Flex, Group, Stack } from '@mantine/core';
|
||||
import { ChangeEvent, MouseEvent, MutableRefObject, useCallback } from 'react';
|
||||
import {
|
||||
RiArrowDownSLine,
|
||||
RiFilter3Line,
|
||||
RiFolder2Line,
|
||||
RiMoreFill,
|
||||
RiSortAsc,
|
||||
RiSortDesc,
|
||||
} from 'react-icons/ri';
|
||||
import { api } from '/@/renderer/api';
|
||||
import { queryKeys } from '/@/renderer/api/query-keys';
|
||||
import {
|
||||
LibraryItem,
|
||||
ServerType,
|
||||
SongListQuery,
|
||||
SongListSort,
|
||||
SortOrder,
|
||||
} from '/@/renderer/api/types';
|
||||
import {
|
||||
Button,
|
||||
DropdownMenu,
|
||||
PageHeader,
|
||||
Slider,
|
||||
TextTitle,
|
||||
Switch,
|
||||
MultiSelect,
|
||||
Text,
|
||||
SONG_TABLE_COLUMNS,
|
||||
Badge,
|
||||
SpinnerIcon,
|
||||
} from '/@/renderer/components';
|
||||
import { usePlayQueueAdd } from '/@/renderer/features/player';
|
||||
import { useMusicFolders } from '/@/renderer/features/shared';
|
||||
import { JellyfinSongFilters } from '/@/renderer/features/songs/components/jellyfin-song-filters';
|
||||
import { NavidromeSongFilters } from '/@/renderer/features/songs/components/navidrome-song-filters';
|
||||
import { useContainerQuery } from '/@/renderer/hooks';
|
||||
import { queryClient } from '/@/renderer/lib/react-query';
|
||||
import {
|
||||
SongListFilter,
|
||||
useCurrentServer,
|
||||
useSetSongFilters,
|
||||
useSetSongStore,
|
||||
useSetSongTable,
|
||||
useSetSongTablePagination,
|
||||
useSongListStore,
|
||||
} from '/@/renderer/store';
|
||||
import { ListDisplayType, Play, TableColumn } from '/@/renderer/types';
|
||||
|
||||
const FILTERS = {
|
||||
jellyfin: [
|
||||
{ defaultOrder: SortOrder.ASC, name: 'Album', value: SongListSort.ALBUM },
|
||||
{ defaultOrder: SortOrder.ASC, name: 'Album Artist', value: SongListSort.ALBUM_ARTIST },
|
||||
{ defaultOrder: SortOrder.ASC, name: 'Artist', value: SongListSort.ARTIST },
|
||||
{ defaultOrder: SortOrder.ASC, name: 'Duration', value: SongListSort.DURATION },
|
||||
{ defaultOrder: SortOrder.ASC, name: 'Most Played', value: SongListSort.PLAY_COUNT },
|
||||
{ defaultOrder: SortOrder.ASC, name: 'Name', value: SongListSort.NAME },
|
||||
{ defaultOrder: SortOrder.ASC, name: 'Random', value: SongListSort.RANDOM },
|
||||
{ defaultOrder: SortOrder.ASC, name: 'Recently Added', value: SongListSort.RECENTLY_ADDED },
|
||||
{ defaultOrder: SortOrder.ASC, name: 'Recently Played', value: SongListSort.RECENTLY_PLAYED },
|
||||
{ defaultOrder: SortOrder.ASC, name: 'Release Date', value: SongListSort.RELEASE_DATE },
|
||||
],
|
||||
navidrome: [
|
||||
{ defaultOrder: SortOrder.ASC, name: 'Album', value: SongListSort.ALBUM },
|
||||
{ defaultOrder: SortOrder.ASC, name: 'Album Artist', value: SongListSort.ALBUM_ARTIST },
|
||||
{ defaultOrder: SortOrder.ASC, name: 'Artist', value: SongListSort.ARTIST },
|
||||
{ defaultOrder: SortOrder.DESC, name: 'BPM', value: SongListSort.BPM },
|
||||
{ defaultOrder: SortOrder.ASC, name: 'Channels', value: SongListSort.CHANNELS },
|
||||
{ defaultOrder: SortOrder.ASC, name: 'Comment', value: SongListSort.COMMENT },
|
||||
{ defaultOrder: SortOrder.DESC, name: 'Duration', value: SongListSort.DURATION },
|
||||
{ defaultOrder: SortOrder.DESC, name: 'Favorited', value: SongListSort.FAVORITED },
|
||||
{ defaultOrder: SortOrder.ASC, name: 'Genre', value: SongListSort.GENRE },
|
||||
{ defaultOrder: SortOrder.ASC, name: 'Name', value: SongListSort.NAME },
|
||||
{ defaultOrder: SortOrder.DESC, name: 'Play Count', value: SongListSort.PLAY_COUNT },
|
||||
{ defaultOrder: SortOrder.DESC, name: 'Rating', value: SongListSort.RATING },
|
||||
{ defaultOrder: SortOrder.DESC, name: 'Recently Added', value: SongListSort.RECENTLY_ADDED },
|
||||
{ defaultOrder: SortOrder.DESC, name: 'Recently Played', value: SongListSort.RECENTLY_PLAYED },
|
||||
{ defaultOrder: SortOrder.DESC, name: 'Year', value: SongListSort.YEAR },
|
||||
],
|
||||
};
|
||||
|
||||
const ORDER = [
|
||||
{ name: 'Ascending', value: SortOrder.ASC },
|
||||
{ name: 'Descending', value: SortOrder.DESC },
|
||||
];
|
||||
|
||||
interface SongListHeaderProps {
|
||||
itemCount?: number;
|
||||
tableRef: MutableRefObject<AgGridReactType | null>;
|
||||
}
|
||||
|
||||
export const AlbumArtistDiscographyHeader = ({ itemCount, tableRef }: SongListHeaderProps) => {
|
||||
const server = useCurrentServer();
|
||||
const page = useSongListStore();
|
||||
const setPage = useSetSongStore();
|
||||
const setFilter = useSetSongFilters();
|
||||
const setTable = useSetSongTable();
|
||||
const setPagination = useSetSongTablePagination();
|
||||
const handlePlayQueueAdd = usePlayQueueAdd();
|
||||
const cq = useContainerQuery();
|
||||
|
||||
const musicFoldersQuery = useMusicFolders();
|
||||
|
||||
const sortByLabel =
|
||||
(server?.type &&
|
||||
(FILTERS[server.type as keyof typeof FILTERS] as { name: string; value: string }[]).find(
|
||||
(f) => f.value === page.filter.sortBy,
|
||||
)?.name) ||
|
||||
'Unknown';
|
||||
|
||||
const sortOrderLabel = ORDER.find((s) => s.value === page.filter.sortOrder)?.name;
|
||||
|
||||
const handleFilterChange = useCallback(
|
||||
async (filters?: SongListFilter) => {
|
||||
const dataSource: IDatasource = {
|
||||
getRows: async (params) => {
|
||||
const limit = params.endRow - params.startRow;
|
||||
const startIndex = params.startRow;
|
||||
|
||||
const pageFilters = filters || page.filter;
|
||||
|
||||
const queryKey = queryKeys.songs.list(server?.id || '', {
|
||||
limit,
|
||||
startIndex,
|
||||
...pageFilters,
|
||||
});
|
||||
|
||||
const songsRes = await queryClient.fetchQuery(
|
||||
queryKey,
|
||||
async ({ signal }) =>
|
||||
api.controller.getSongList({
|
||||
query: {
|
||||
limit,
|
||||
startIndex,
|
||||
...pageFilters,
|
||||
},
|
||||
server,
|
||||
signal,
|
||||
}),
|
||||
{ cacheTime: 1000 * 60 * 1 },
|
||||
);
|
||||
|
||||
const songs = api.normalize.songList(songsRes, server);
|
||||
params.successCallback(songs?.items || [], songsRes?.totalRecordCount || 0);
|
||||
},
|
||||
rowCount: undefined,
|
||||
};
|
||||
tableRef.current?.api.setDatasource(dataSource);
|
||||
tableRef.current?.api.purgeInfiniteCache();
|
||||
tableRef.current?.api.ensureIndexVisible(0, 'top');
|
||||
setPagination({ currentPage: 0 });
|
||||
},
|
||||
[page.filter, server, setPagination, tableRef],
|
||||
);
|
||||
|
||||
const handleSetSortBy = useCallback(
|
||||
(e: MouseEvent<HTMLButtonElement>) => {
|
||||
if (!e.currentTarget?.value || !server?.type) return;
|
||||
|
||||
const sortOrder = FILTERS[server.type as keyof typeof FILTERS].find(
|
||||
(f) => f.value === e.currentTarget.value,
|
||||
)?.defaultOrder;
|
||||
|
||||
const updatedFilters = setFilter({
|
||||
sortBy: e.currentTarget.value as SongListSort,
|
||||
sortOrder: sortOrder || SortOrder.ASC,
|
||||
});
|
||||
|
||||
handleFilterChange(updatedFilters);
|
||||
},
|
||||
[handleFilterChange, server?.type, setFilter],
|
||||
);
|
||||
|
||||
const handleSetMusicFolder = useCallback(
|
||||
(e: MouseEvent<HTMLButtonElement>) => {
|
||||
if (!e.currentTarget?.value) return;
|
||||
|
||||
let updatedFilters = null;
|
||||
if (e.currentTarget.value === String(page.filter.musicFolderId)) {
|
||||
updatedFilters = setFilter({ musicFolderId: undefined });
|
||||
} else {
|
||||
updatedFilters = setFilter({ musicFolderId: e.currentTarget.value });
|
||||
}
|
||||
|
||||
handleFilterChange(updatedFilters);
|
||||
},
|
||||
[handleFilterChange, page.filter.musicFolderId, setFilter],
|
||||
);
|
||||
|
||||
const handleToggleSortOrder = useCallback(() => {
|
||||
const newSortOrder = page.filter.sortOrder === SortOrder.ASC ? SortOrder.DESC : SortOrder.ASC;
|
||||
const updatedFilters = setFilter({ sortOrder: newSortOrder });
|
||||
handleFilterChange(updatedFilters);
|
||||
}, [page.filter.sortOrder, handleFilterChange, setFilter]);
|
||||
|
||||
const handleSetViewType = useCallback(
|
||||
(e: MouseEvent<HTMLButtonElement>) => {
|
||||
if (!e.currentTarget?.value) return;
|
||||
const display = e.currentTarget.value as ListDisplayType;
|
||||
setPage({ list: { ...page, display: e.currentTarget.value as ListDisplayType } });
|
||||
|
||||
if (display === ListDisplayType.TABLE) {
|
||||
tableRef.current?.api.paginationSetPageSize(tableRef.current.props.infiniteInitialRowCount);
|
||||
setPagination({ currentPage: 0 });
|
||||
} else if (display === ListDisplayType.TABLE_PAGINATED) {
|
||||
setPagination({ currentPage: 0 });
|
||||
}
|
||||
},
|
||||
[page, setPage, setPagination, tableRef],
|
||||
);
|
||||
|
||||
// const handleSearch = debounce((e: ChangeEvent<HTMLInputElement>) => {
|
||||
// const previousSearchTerm = page.filter.searchTerm;
|
||||
// const searchTerm = e.target.value === '' ? undefined : e.target.value;
|
||||
// const updatedFilters = setFilter({ searchTerm });
|
||||
// if (previousSearchTerm !== searchTerm) handleFilterChange(updatedFilters);
|
||||
// }, 500);
|
||||
|
||||
const handleTableColumns = (values: TableColumn[]) => {
|
||||
const existingColumns = page.table.columns;
|
||||
|
||||
if (values.length === 0) {
|
||||
return setTable({
|
||||
columns: [],
|
||||
});
|
||||
}
|
||||
|
||||
// If adding a column
|
||||
if (values.length > existingColumns.length) {
|
||||
const newColumn = { column: values[values.length - 1], width: 100 };
|
||||
|
||||
return setTable({ columns: [...existingColumns, newColumn] });
|
||||
}
|
||||
|
||||
// If removing a column
|
||||
const removed = existingColumns.filter((column) => !values.includes(column.column));
|
||||
const newColumns = existingColumns.filter((column) => !removed.includes(column));
|
||||
|
||||
return setTable({ columns: newColumns });
|
||||
};
|
||||
|
||||
const handleAutoFitColumns = (e: ChangeEvent<HTMLInputElement>) => {
|
||||
setTable({ autoFit: e.currentTarget.checked });
|
||||
|
||||
if (e.currentTarget.checked) {
|
||||
tableRef.current?.api.sizeColumnsToFit();
|
||||
}
|
||||
};
|
||||
|
||||
const handleRowHeight = (e: number) => {
|
||||
setTable({ rowHeight: e });
|
||||
};
|
||||
|
||||
const handleRefresh = () => {
|
||||
queryClient.invalidateQueries(queryKeys.songs.list(server?.id || ''));
|
||||
handleFilterChange(page.filter);
|
||||
};
|
||||
|
||||
const handlePlay = async (play: Play) => {
|
||||
if (!itemCount || itemCount === 0) return;
|
||||
const query: SongListQuery = { startIndex: 0, ...page.filter };
|
||||
|
||||
handlePlayQueueAdd?.({
|
||||
byItemType: {
|
||||
id: query,
|
||||
type: LibraryItem.SONG,
|
||||
},
|
||||
play,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<PageHeader p="1rem">
|
||||
<Flex
|
||||
ref={cq.ref}
|
||||
direction="row"
|
||||
justify="space-between"
|
||||
>
|
||||
<Flex
|
||||
align="center"
|
||||
gap="md"
|
||||
justify="center"
|
||||
>
|
||||
<DropdownMenu position="bottom-start">
|
||||
<DropdownMenu.Target>
|
||||
<Button
|
||||
compact
|
||||
rightIcon={<RiArrowDownSLine size={15} />}
|
||||
size="xl"
|
||||
sx={{ paddingLeft: 0, paddingRight: 0 }}
|
||||
variant="subtle"
|
||||
>
|
||||
<Group noWrap>
|
||||
<TextTitle
|
||||
order={2}
|
||||
weight={700}
|
||||
>
|
||||
Tracks
|
||||
</TextTitle>
|
||||
<Badge
|
||||
radius="xl"
|
||||
size="lg"
|
||||
>
|
||||
{itemCount === null || itemCount === undefined ? <SpinnerIcon /> : itemCount}
|
||||
</Badge>
|
||||
</Group>
|
||||
</Button>
|
||||
</DropdownMenu.Target>
|
||||
<DropdownMenu.Dropdown>
|
||||
<DropdownMenu.Label>Display type</DropdownMenu.Label>
|
||||
<DropdownMenu.Item
|
||||
$isActive={page.display === ListDisplayType.TABLE}
|
||||
value={ListDisplayType.TABLE}
|
||||
onClick={handleSetViewType}
|
||||
>
|
||||
Table
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item
|
||||
$isActive={page.display === ListDisplayType.TABLE_PAGINATED}
|
||||
value={ListDisplayType.TABLE_PAGINATED}
|
||||
onClick={handleSetViewType}
|
||||
>
|
||||
Table (paginated)
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Divider />
|
||||
<DropdownMenu.Label>Item Size</DropdownMenu.Label>
|
||||
<DropdownMenu.Item closeMenuOnClick={false}>
|
||||
<Slider
|
||||
defaultValue={page.table.rowHeight || 0}
|
||||
label={null}
|
||||
max={100}
|
||||
min={25}
|
||||
onChangeEnd={handleRowHeight}
|
||||
/>
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Label>Table Columns</DropdownMenu.Label>
|
||||
<DropdownMenu.Item
|
||||
closeMenuOnClick={false}
|
||||
component="div"
|
||||
sx={{ cursor: 'default' }}
|
||||
>
|
||||
<Stack>
|
||||
<MultiSelect
|
||||
clearable
|
||||
data={SONG_TABLE_COLUMNS}
|
||||
defaultValue={page.table?.columns.map((column) => column.column)}
|
||||
width={300}
|
||||
onChange={handleTableColumns}
|
||||
/>
|
||||
<Group position="apart">
|
||||
<Text>Auto Fit Columns</Text>
|
||||
<Switch
|
||||
defaultChecked={page.table.autoFit}
|
||||
onChange={handleAutoFitColumns}
|
||||
/>
|
||||
</Group>
|
||||
</Stack>
|
||||
</DropdownMenu.Item>
|
||||
</DropdownMenu.Dropdown>
|
||||
</DropdownMenu>
|
||||
<DropdownMenu position="bottom-start">
|
||||
<DropdownMenu.Target>
|
||||
<Button
|
||||
compact
|
||||
fw={600}
|
||||
variant="subtle"
|
||||
>
|
||||
{sortByLabel}
|
||||
</Button>
|
||||
</DropdownMenu.Target>
|
||||
<DropdownMenu.Dropdown>
|
||||
{FILTERS[server?.type as keyof typeof FILTERS].map((filter) => (
|
||||
<DropdownMenu.Item
|
||||
key={`filter-${filter.name}`}
|
||||
$isActive={filter.value === page.filter.sortBy}
|
||||
value={filter.value}
|
||||
onClick={handleSetSortBy}
|
||||
>
|
||||
{filter.name}
|
||||
</DropdownMenu.Item>
|
||||
))}
|
||||
</DropdownMenu.Dropdown>
|
||||
</DropdownMenu>
|
||||
<Button
|
||||
compact
|
||||
fw={600}
|
||||
variant="subtle"
|
||||
onClick={handleToggleSortOrder}
|
||||
>
|
||||
{cq.isMd ? (
|
||||
sortOrderLabel
|
||||
) : (
|
||||
<>
|
||||
{page.filter.sortOrder === SortOrder.ASC ? (
|
||||
<RiSortAsc size={15} />
|
||||
) : (
|
||||
<RiSortDesc size={15} />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
{server?.type === ServerType.JELLYFIN && (
|
||||
<DropdownMenu position="bottom-start">
|
||||
<DropdownMenu.Target>
|
||||
<Button
|
||||
compact
|
||||
fw={600}
|
||||
variant="subtle"
|
||||
>
|
||||
{cq.isMd ? 'Folder' : <RiFolder2Line size={15} />}
|
||||
</Button>
|
||||
</DropdownMenu.Target>
|
||||
<DropdownMenu.Dropdown>
|
||||
{musicFoldersQuery.data?.map((folder) => (
|
||||
<DropdownMenu.Item
|
||||
key={`musicFolder-${folder.id}`}
|
||||
$isActive={page.filter.musicFolderId === folder.id}
|
||||
value={folder.id}
|
||||
onClick={handleSetMusicFolder}
|
||||
>
|
||||
{folder.name}
|
||||
</DropdownMenu.Item>
|
||||
))}
|
||||
</DropdownMenu.Dropdown>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
<DropdownMenu position="bottom-start">
|
||||
<DropdownMenu.Target>
|
||||
<Button
|
||||
compact
|
||||
fw="600"
|
||||
variant="subtle"
|
||||
>
|
||||
{cq.isMd ? 'Filters' : <RiFilter3Line size={15} />}
|
||||
</Button>
|
||||
</DropdownMenu.Target>
|
||||
<DropdownMenu.Dropdown>
|
||||
{server?.type === ServerType.NAVIDROME ? (
|
||||
<NavidromeSongFilters handleFilterChange={handleFilterChange} />
|
||||
) : (
|
||||
<JellyfinSongFilters handleFilterChange={handleFilterChange} />
|
||||
)}
|
||||
</DropdownMenu.Dropdown>
|
||||
</DropdownMenu>
|
||||
<DropdownMenu position="bottom-start">
|
||||
<DropdownMenu.Target>
|
||||
<Button
|
||||
compact
|
||||
fw="600"
|
||||
variant="subtle"
|
||||
>
|
||||
<RiMoreFill size={15} />
|
||||
</Button>
|
||||
</DropdownMenu.Target>
|
||||
<DropdownMenu.Dropdown>
|
||||
<DropdownMenu.Item onClick={() => handlePlay(Play.NOW)}>Play</DropdownMenu.Item>
|
||||
<DropdownMenu.Item onClick={() => handlePlay(Play.LAST)}>
|
||||
Add to queue
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item onClick={() => handlePlay(Play.NEXT)}>
|
||||
Add to queue next
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Divider />
|
||||
<DropdownMenu.Item onClick={handleRefresh}>Refresh</DropdownMenu.Item>
|
||||
</DropdownMenu.Dropdown>
|
||||
</DropdownMenu>
|
||||
</Flex>
|
||||
</Flex>
|
||||
</PageHeader>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,115 @@
|
||||
import { Group, Rating, Stack } from '@mantine/core';
|
||||
import { forwardRef, Fragment, Ref, MouseEvent } from 'react';
|
||||
import { useParams } from 'react-router';
|
||||
import { LibraryItem, ServerType } from '/@/renderer/api/types';
|
||||
import { Text } from '/@/renderer/components';
|
||||
import { useAlbumArtistDetail } from '/@/renderer/features/artists/queries/album-artist-detail-query';
|
||||
import { LibraryHeader, useUpdateRating } from '/@/renderer/features/shared';
|
||||
import { useContainerQuery } from '/@/renderer/hooks';
|
||||
import { AppRoute } from '/@/renderer/router/routes';
|
||||
import { formatDurationString } from '/@/renderer/utils';
|
||||
|
||||
interface AlbumArtistDetailHeaderProps {
|
||||
background: string;
|
||||
}
|
||||
|
||||
export const AlbumArtistDetailHeader = forwardRef(
|
||||
({ background }: AlbumArtistDetailHeaderProps, ref: Ref<HTMLDivElement>) => {
|
||||
const { albumArtistId } = useParams() as { albumArtistId: string };
|
||||
const detailQuery = useAlbumArtistDetail({ id: albumArtistId });
|
||||
const cq = useContainerQuery();
|
||||
|
||||
const metadataItems = [
|
||||
{
|
||||
id: 'albumCount',
|
||||
secondary: false,
|
||||
value: detailQuery?.data?.albumCount && `${detailQuery?.data?.albumCount} albums`,
|
||||
},
|
||||
{
|
||||
id: 'songCount',
|
||||
secondary: false,
|
||||
value: detailQuery?.data?.songCount && `${detailQuery?.data?.songCount} songs`,
|
||||
},
|
||||
{
|
||||
id: 'duration',
|
||||
secondary: true,
|
||||
value: detailQuery?.data?.duration && formatDurationString(detailQuery.data.duration),
|
||||
},
|
||||
];
|
||||
|
||||
const updateRatingMutation = useUpdateRating();
|
||||
|
||||
const handleUpdateRating = (rating: number) => {
|
||||
if (!detailQuery?.data) return;
|
||||
|
||||
updateRatingMutation.mutate({
|
||||
_serverId: detailQuery?.data.serverId,
|
||||
query: {
|
||||
item: [detailQuery.data],
|
||||
rating,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleClearRating = (_e: MouseEvent<HTMLDivElement>, rating?: number) => {
|
||||
if (!detailQuery?.data || !detailQuery?.data.userRating) return;
|
||||
|
||||
const isSameRatingAsPrevious = rating === detailQuery.data.userRating;
|
||||
if (!isSameRatingAsPrevious) return;
|
||||
|
||||
updateRatingMutation.mutate({
|
||||
_serverId: detailQuery.data.serverId,
|
||||
query: {
|
||||
item: [detailQuery.data],
|
||||
rating: 0,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const showRating = detailQuery?.data?.serverType === ServerType.NAVIDROME;
|
||||
|
||||
return (
|
||||
<Stack ref={cq.ref}>
|
||||
<LibraryHeader
|
||||
ref={ref}
|
||||
background={background}
|
||||
imageUrl={detailQuery?.data?.imageUrl}
|
||||
item={{ route: AppRoute.LIBRARY_ALBUM_ARTISTS, type: LibraryItem.ALBUM_ARTIST }}
|
||||
title={detailQuery?.data?.name || ''}
|
||||
>
|
||||
<Stack>
|
||||
<Group>
|
||||
{metadataItems
|
||||
.filter((i) => i.value)
|
||||
.map((item, index) => (
|
||||
<Fragment key={`item-${item.id}-${index}`}>
|
||||
{index > 0 && <Text $noSelect>•</Text>}
|
||||
<Text $secondary={item.secondary}>{item.value}</Text>
|
||||
</Fragment>
|
||||
))}
|
||||
{showRating && (
|
||||
<>
|
||||
<Text $noSelect>•</Text>
|
||||
<Rating
|
||||
readOnly={detailQuery?.isFetching || updateRatingMutation.isLoading}
|
||||
value={detailQuery?.data?.userRating || 0}
|
||||
onChange={handleUpdateRating}
|
||||
onClick={handleClearRating}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</Group>
|
||||
<Group
|
||||
sx={{
|
||||
WebkitBoxOrient: 'vertical',
|
||||
WebkitLineClamp: 2,
|
||||
display: '-webkit-box',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
</LibraryHeader>
|
||||
</Stack>
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,213 @@
|
||||
import { MutableRefObject, useCallback, useMemo } from 'react';
|
||||
import type {
|
||||
ColDef,
|
||||
GridReadyEvent,
|
||||
IDatasource,
|
||||
PaginationChangedEvent,
|
||||
RowDoubleClickedEvent,
|
||||
} from '@ag-grid-community/core';
|
||||
import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/lib/agGridReact';
|
||||
import { useSetState } from '@mantine/hooks';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { api } from '/@/renderer/api';
|
||||
import { queryKeys } from '/@/renderer/api/query-keys';
|
||||
import {
|
||||
getColumnDefs,
|
||||
TablePagination,
|
||||
VirtualGridAutoSizerContainer,
|
||||
VirtualTable,
|
||||
} from '/@/renderer/components';
|
||||
import {
|
||||
SongListFilter,
|
||||
useCurrentServer,
|
||||
useSetSongTable,
|
||||
useSongListStore,
|
||||
} from '/@/renderer/store';
|
||||
import { ListDisplayType, TablePagination as TablePaginationType } from '/@/renderer/types';
|
||||
import { AnimatePresence } from 'framer-motion';
|
||||
import debounce from 'lodash/debounce';
|
||||
import { useHandleTableContextMenu } from '/@/renderer/features/context-menu';
|
||||
import { SONG_CONTEXT_MENU_ITEMS } from '/@/renderer/features/context-menu/context-menu-items';
|
||||
import { usePlayButtonBehavior } from '/@/renderer/store/settings.store';
|
||||
import { LibraryItem, QueueSong } from '/@/renderer/api/types';
|
||||
import { usePlayQueueAdd } from '/@/renderer/features/player';
|
||||
|
||||
interface AlbumArtistSongListContentProps {
|
||||
filter: SongListFilter;
|
||||
itemCount?: number;
|
||||
tableRef: MutableRefObject<AgGridReactType | null>;
|
||||
}
|
||||
|
||||
export const AlbumArtistDetailSongListContent = ({
|
||||
itemCount,
|
||||
filter,
|
||||
tableRef,
|
||||
}: AlbumArtistSongListContentProps) => {
|
||||
const queryClient = useQueryClient();
|
||||
const server = useCurrentServer();
|
||||
const page = useSongListStore();
|
||||
|
||||
const [pagination, setPagination] = useSetState<TablePaginationType>({
|
||||
currentPage: 0,
|
||||
itemsPerPage: 100,
|
||||
totalItems: itemCount || 0,
|
||||
totalPages: 0,
|
||||
});
|
||||
|
||||
const setTable = useSetSongTable();
|
||||
const handlePlayQueueAdd = usePlayQueueAdd();
|
||||
const playButtonBehavior = usePlayButtonBehavior();
|
||||
|
||||
const isPaginationEnabled = page.display === ListDisplayType.TABLE_PAGINATED;
|
||||
|
||||
const columnDefs: ColDef[] = useMemo(
|
||||
() => getColumnDefs(page.table.columns),
|
||||
[page.table.columns],
|
||||
);
|
||||
|
||||
const onGridReady = useCallback(
|
||||
(params: GridReadyEvent) => {
|
||||
const dataSource: IDatasource = {
|
||||
getRows: async (params) => {
|
||||
const limit = params.endRow - params.startRow;
|
||||
const startIndex = params.startRow;
|
||||
|
||||
const queryKey = queryKeys.songs.list(server?.id || '', {
|
||||
...filter,
|
||||
limit,
|
||||
startIndex,
|
||||
});
|
||||
|
||||
const songsRes = await queryClient.fetchQuery(
|
||||
queryKey,
|
||||
async ({ signal }) =>
|
||||
api.controller.getSongList({
|
||||
query: {
|
||||
...filter,
|
||||
limit,
|
||||
startIndex,
|
||||
},
|
||||
server,
|
||||
signal,
|
||||
}),
|
||||
{ cacheTime: 1000 * 60 * 1 },
|
||||
);
|
||||
|
||||
const songs = api.normalize.songList(songsRes, server);
|
||||
params.successCallback(songs?.items || [], songsRes?.totalRecordCount || 0);
|
||||
},
|
||||
rowCount: undefined,
|
||||
};
|
||||
params.api.setDatasource(dataSource);
|
||||
},
|
||||
[filter, queryClient, server],
|
||||
);
|
||||
|
||||
const onPaginationChanged = useCallback(
|
||||
(event: PaginationChangedEvent) => {
|
||||
if (!isPaginationEnabled || !event.api) return;
|
||||
|
||||
// Scroll to top of page on pagination change
|
||||
const currentPageStartIndex = pagination.currentPage * pagination.itemsPerPage;
|
||||
event.api?.ensureIndexVisible(currentPageStartIndex, 'top');
|
||||
|
||||
setPagination({
|
||||
itemsPerPage: event.api.paginationGetPageSize(),
|
||||
totalItems: event.api.paginationGetRowCount(),
|
||||
totalPages: event.api.paginationGetTotalPages() + 1,
|
||||
});
|
||||
},
|
||||
[isPaginationEnabled, pagination.currentPage, pagination.itemsPerPage, setPagination],
|
||||
);
|
||||
|
||||
const handleColumnChange = useCallback(() => {
|
||||
const { columnApi } = tableRef?.current || {};
|
||||
const columnsOrder = columnApi?.getAllGridColumns();
|
||||
|
||||
if (!columnsOrder) return;
|
||||
|
||||
const columnsInSettings = page.table.columns;
|
||||
const updatedColumns = [];
|
||||
for (const column of columnsOrder) {
|
||||
const columnInSettings = columnsInSettings.find((c) => c.column === column.getColDef().colId);
|
||||
|
||||
if (columnInSettings) {
|
||||
updatedColumns.push({
|
||||
...columnInSettings,
|
||||
...(!page.table.autoFit && {
|
||||
width: column.getActualWidth(),
|
||||
}),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
setTable({ columns: updatedColumns });
|
||||
}, [page.table.autoFit, page.table.columns, setTable, tableRef]);
|
||||
|
||||
const debouncedColumnChange = debounce(handleColumnChange, 200);
|
||||
|
||||
const handleContextMenu = useHandleTableContextMenu(LibraryItem.SONG, SONG_CONTEXT_MENU_ITEMS);
|
||||
|
||||
const handleRowDoubleClick = (e: RowDoubleClickedEvent<QueueSong>) => {
|
||||
if (!e.data) return;
|
||||
handlePlayQueueAdd?.({
|
||||
byData: [e.data],
|
||||
play: playButtonBehavior,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<VirtualGridAutoSizerContainer>
|
||||
<VirtualTable
|
||||
// https://github.com/ag-grid/ag-grid/issues/5284
|
||||
// Key is used to force remount of table when display, rowHeight, or server changes
|
||||
key={`table-${page.display}-${page.table.rowHeight}-${server?.id}`}
|
||||
ref={tableRef}
|
||||
alwaysShowHorizontalScroll
|
||||
animateRows
|
||||
maintainColumnOrder
|
||||
suppressCopyRowsToClipboard
|
||||
suppressMoveWhenRowDragging
|
||||
suppressPaginationPanel
|
||||
suppressRowDrag
|
||||
suppressScrollOnNewData
|
||||
autoFitColumns={page.table.autoFit}
|
||||
blockLoadDebounceMillis={200}
|
||||
cacheBlockSize={500}
|
||||
cacheOverflowSize={1}
|
||||
columnDefs={columnDefs}
|
||||
enableCellChangeFlash={false}
|
||||
getRowId={(data) => data.data.id}
|
||||
infiniteInitialRowCount={itemCount || 100}
|
||||
pagination={isPaginationEnabled}
|
||||
paginationAutoPageSize={isPaginationEnabled}
|
||||
paginationPageSize={page.table.pagination.itemsPerPage || 100}
|
||||
rowBuffer={20}
|
||||
rowHeight={page.table.rowHeight || 40}
|
||||
rowModelType="infinite"
|
||||
rowSelection="multiple"
|
||||
onCellContextMenu={handleContextMenu}
|
||||
onColumnMoved={handleColumnChange}
|
||||
onColumnResized={debouncedColumnChange}
|
||||
onGridReady={onGridReady}
|
||||
onPaginationChanged={onPaginationChanged}
|
||||
onRowDoubleClicked={handleRowDoubleClick}
|
||||
/>
|
||||
</VirtualGridAutoSizerContainer>
|
||||
<AnimatePresence
|
||||
presenceAffectsLayout
|
||||
initial={false}
|
||||
mode="wait"
|
||||
>
|
||||
{page.display === ListDisplayType.TABLE_PAGINATED && (
|
||||
<TablePagination
|
||||
pagination={pagination}
|
||||
setPagination={setPagination}
|
||||
tableRef={tableRef}
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,470 @@
|
||||
import type { IDatasource } from '@ag-grid-community/core';
|
||||
import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/lib/agGridReact';
|
||||
import { Flex, Group, Stack } from '@mantine/core';
|
||||
import debounce from 'lodash/debounce';
|
||||
import { ChangeEvent, MouseEvent, MutableRefObject, useCallback } from 'react';
|
||||
import { RiArrowDownSLine, RiFolder2Line, RiMoreFill, RiSortAsc, RiSortDesc } from 'react-icons/ri';
|
||||
import { api } from '/@/renderer/api';
|
||||
import { queryKeys } from '/@/renderer/api/query-keys';
|
||||
import {
|
||||
LibraryItem,
|
||||
ServerType,
|
||||
SongListQuery,
|
||||
SongListSort,
|
||||
SortOrder,
|
||||
} from '/@/renderer/api/types';
|
||||
import {
|
||||
Button,
|
||||
DropdownMenu,
|
||||
PageHeader,
|
||||
SearchInput,
|
||||
Slider,
|
||||
TextTitle,
|
||||
Switch,
|
||||
MultiSelect,
|
||||
Text,
|
||||
SONG_TABLE_COLUMNS,
|
||||
Badge,
|
||||
SpinnerIcon,
|
||||
} from '/@/renderer/components';
|
||||
import { usePlayQueueAdd } from '/@/renderer/features/player';
|
||||
import { useMusicFolders } from '/@/renderer/features/shared';
|
||||
import { useContainerQuery } from '/@/renderer/hooks';
|
||||
import { queryClient } from '/@/renderer/lib/react-query';
|
||||
import {
|
||||
SongListFilter,
|
||||
useCurrentServer,
|
||||
useSetSongStore,
|
||||
useSetSongTable,
|
||||
useSetSongTablePagination,
|
||||
useSongListStore,
|
||||
} from '/@/renderer/store';
|
||||
import { ListDisplayType, Play, TableColumn } from '/@/renderer/types';
|
||||
|
||||
const FILTERS = {
|
||||
jellyfin: [
|
||||
{ defaultOrder: SortOrder.ASC, name: 'Album', value: SongListSort.ALBUM },
|
||||
{ defaultOrder: SortOrder.ASC, name: 'Album Artist', value: SongListSort.ALBUM_ARTIST },
|
||||
{ defaultOrder: SortOrder.ASC, name: 'Artist', value: SongListSort.ARTIST },
|
||||
{ defaultOrder: SortOrder.ASC, name: 'Duration', value: SongListSort.DURATION },
|
||||
{ defaultOrder: SortOrder.ASC, name: 'Most Played', value: SongListSort.PLAY_COUNT },
|
||||
{ defaultOrder: SortOrder.ASC, name: 'Name', value: SongListSort.NAME },
|
||||
{ defaultOrder: SortOrder.ASC, name: 'Random', value: SongListSort.RANDOM },
|
||||
{ defaultOrder: SortOrder.ASC, name: 'Recently Added', value: SongListSort.RECENTLY_ADDED },
|
||||
{ defaultOrder: SortOrder.ASC, name: 'Recently Played', value: SongListSort.RECENTLY_PLAYED },
|
||||
{ defaultOrder: SortOrder.ASC, name: 'Release Date', value: SongListSort.RELEASE_DATE },
|
||||
],
|
||||
navidrome: [
|
||||
{ defaultOrder: SortOrder.ASC, name: 'Album', value: SongListSort.ALBUM },
|
||||
{ defaultOrder: SortOrder.ASC, name: 'Album Artist', value: SongListSort.ALBUM_ARTIST },
|
||||
{ defaultOrder: SortOrder.ASC, name: 'Artist', value: SongListSort.ARTIST },
|
||||
{ defaultOrder: SortOrder.DESC, name: 'BPM', value: SongListSort.BPM },
|
||||
{ defaultOrder: SortOrder.ASC, name: 'Channels', value: SongListSort.CHANNELS },
|
||||
{ defaultOrder: SortOrder.ASC, name: 'Comment', value: SongListSort.COMMENT },
|
||||
{ defaultOrder: SortOrder.DESC, name: 'Duration', value: SongListSort.DURATION },
|
||||
{ defaultOrder: SortOrder.DESC, name: 'Favorited', value: SongListSort.FAVORITED },
|
||||
{ defaultOrder: SortOrder.ASC, name: 'Genre', value: SongListSort.GENRE },
|
||||
{ defaultOrder: SortOrder.ASC, name: 'Name', value: SongListSort.NAME },
|
||||
{ defaultOrder: SortOrder.DESC, name: 'Play Count', value: SongListSort.PLAY_COUNT },
|
||||
{ defaultOrder: SortOrder.DESC, name: 'Rating', value: SongListSort.RATING },
|
||||
{ defaultOrder: SortOrder.DESC, name: 'Recently Added', value: SongListSort.RECENTLY_ADDED },
|
||||
{ defaultOrder: SortOrder.DESC, name: 'Recently Played', value: SongListSort.RECENTLY_PLAYED },
|
||||
{ defaultOrder: SortOrder.DESC, name: 'Year', value: SongListSort.YEAR },
|
||||
],
|
||||
};
|
||||
|
||||
const ORDER = [
|
||||
{ name: 'Ascending', value: SortOrder.ASC },
|
||||
{ name: 'Descending', value: SortOrder.DESC },
|
||||
];
|
||||
|
||||
interface AlbumArtistDetailSongListHeaderProps {
|
||||
filter: SongListFilter;
|
||||
itemCount?: number;
|
||||
setFilter: (filter: Partial<SongListFilter>) => void;
|
||||
tableRef: MutableRefObject<AgGridReactType | null>;
|
||||
title: string;
|
||||
}
|
||||
|
||||
export const AlbumArtistDetailSongListHeader = ({
|
||||
filter,
|
||||
setFilter,
|
||||
title,
|
||||
itemCount,
|
||||
tableRef,
|
||||
}: AlbumArtistDetailSongListHeaderProps) => {
|
||||
const server = useCurrentServer();
|
||||
const page = useSongListStore();
|
||||
const setPage = useSetSongStore();
|
||||
const setTable = useSetSongTable();
|
||||
const setPagination = useSetSongTablePagination();
|
||||
const handlePlayQueueAdd = usePlayQueueAdd();
|
||||
const cq = useContainerQuery();
|
||||
|
||||
const musicFoldersQuery = useMusicFolders();
|
||||
|
||||
const sortByLabel =
|
||||
(server?.type &&
|
||||
(FILTERS[server.type as keyof typeof FILTERS] as { name: string; value: string }[]).find(
|
||||
(f) => f.value === filter.sortBy,
|
||||
)?.name) ||
|
||||
'Unknown';
|
||||
|
||||
const sortOrderLabel = ORDER.find((s) => s.value === filter.sortOrder)?.name;
|
||||
|
||||
const handleFilterChange = useCallback(
|
||||
async (filters?: SongListFilter) => {
|
||||
const dataSource: IDatasource = {
|
||||
getRows: async (params) => {
|
||||
const limit = params.endRow - params.startRow;
|
||||
const startIndex = params.startRow;
|
||||
|
||||
const pageFilters = filters || filter;
|
||||
|
||||
const queryKey = queryKeys.songs.list(server?.id || '', {
|
||||
limit,
|
||||
startIndex,
|
||||
...pageFilters,
|
||||
});
|
||||
|
||||
const songsRes = await queryClient.fetchQuery(
|
||||
queryKey,
|
||||
async ({ signal }) =>
|
||||
api.controller.getSongList({
|
||||
query: {
|
||||
limit,
|
||||
startIndex,
|
||||
...pageFilters,
|
||||
},
|
||||
server,
|
||||
signal,
|
||||
}),
|
||||
{ cacheTime: 1000 * 60 * 1 },
|
||||
);
|
||||
|
||||
const songs = api.normalize.songList(songsRes, server);
|
||||
params.successCallback(songs?.items || [], songsRes?.totalRecordCount || 0);
|
||||
},
|
||||
rowCount: undefined,
|
||||
};
|
||||
tableRef.current?.api.setDatasource(dataSource);
|
||||
tableRef.current?.api.purgeInfiniteCache();
|
||||
tableRef.current?.api.ensureIndexVisible(0, 'top');
|
||||
setPagination({ currentPage: 0 });
|
||||
},
|
||||
[filter, server, setPagination, tableRef],
|
||||
);
|
||||
|
||||
const handleSetSortBy = useCallback(
|
||||
(e: MouseEvent<HTMLButtonElement>) => {
|
||||
if (!e.currentTarget?.value || !server?.type) return;
|
||||
|
||||
const sortOrder = FILTERS[server.type as keyof typeof FILTERS].find(
|
||||
(f) => f.value === e.currentTarget.value,
|
||||
)?.defaultOrder;
|
||||
|
||||
setFilter({
|
||||
sortBy: e.currentTarget.value as SongListSort,
|
||||
sortOrder: sortOrder || SortOrder.ASC,
|
||||
});
|
||||
|
||||
handleFilterChange({
|
||||
...filter,
|
||||
sortBy: e.currentTarget.value as SongListSort,
|
||||
sortOrder: sortOrder || SortOrder.ASC,
|
||||
});
|
||||
},
|
||||
[filter, handleFilterChange, server?.type, setFilter],
|
||||
);
|
||||
|
||||
const handleSetMusicFolder = useCallback(
|
||||
(e: MouseEvent<HTMLButtonElement>) => {
|
||||
if (!e.currentTarget?.value) return;
|
||||
|
||||
let updatedFilters = null;
|
||||
if (e.currentTarget.value === String(page.filter.musicFolderId)) {
|
||||
updatedFilters = { musicFolderId: undefined };
|
||||
setFilter(updatedFilters);
|
||||
} else {
|
||||
updatedFilters = { musicFolderId: e.currentTarget.value };
|
||||
setFilter(updatedFilters);
|
||||
}
|
||||
|
||||
handleFilterChange({ ...filter, ...updatedFilters });
|
||||
},
|
||||
[filter, handleFilterChange, page.filter.musicFolderId, setFilter],
|
||||
);
|
||||
|
||||
const handleToggleSortOrder = useCallback(() => {
|
||||
const newSortOrder = filter.sortOrder === SortOrder.ASC ? SortOrder.DESC : SortOrder.ASC;
|
||||
setFilter({ sortOrder: newSortOrder });
|
||||
handleFilterChange({ ...filter, sortOrder: newSortOrder });
|
||||
}, [filter, handleFilterChange, setFilter]);
|
||||
|
||||
const handleSetViewType = useCallback(
|
||||
(e: MouseEvent<HTMLButtonElement>) => {
|
||||
if (!e.currentTarget?.value) return;
|
||||
const display = e.currentTarget.value as ListDisplayType;
|
||||
setPage({ list: { ...page, display: e.currentTarget.value as ListDisplayType } });
|
||||
|
||||
if (display === ListDisplayType.TABLE) {
|
||||
tableRef.current?.api.paginationSetPageSize(tableRef.current.props.infiniteInitialRowCount);
|
||||
setPagination({ currentPage: 0 });
|
||||
} else if (display === ListDisplayType.TABLE_PAGINATED) {
|
||||
setPagination({ currentPage: 0 });
|
||||
}
|
||||
},
|
||||
[page, setPage, setPagination, tableRef],
|
||||
);
|
||||
|
||||
const handleSearch = debounce((e: ChangeEvent<HTMLInputElement>) => {
|
||||
const previousSearchTerm = filter.searchTerm;
|
||||
const searchTerm = e.target.value === '' ? undefined : e.target.value;
|
||||
setFilter({ searchTerm });
|
||||
if (previousSearchTerm !== searchTerm) handleFilterChange({ ...filter, searchTerm });
|
||||
}, 500);
|
||||
|
||||
const handleTableColumns = (values: TableColumn[]) => {
|
||||
const existingColumns = page.table.columns;
|
||||
|
||||
if (values.length === 0) {
|
||||
return setTable({
|
||||
columns: [],
|
||||
});
|
||||
}
|
||||
|
||||
// If adding a column
|
||||
if (values.length > existingColumns.length) {
|
||||
const newColumn = { column: values[values.length - 1], width: 100 };
|
||||
|
||||
return setTable({ columns: [...existingColumns, newColumn] });
|
||||
}
|
||||
|
||||
// If removing a column
|
||||
const removed = existingColumns.filter((column) => !values.includes(column.column));
|
||||
const newColumns = existingColumns.filter((column) => !removed.includes(column));
|
||||
|
||||
return setTable({ columns: newColumns });
|
||||
};
|
||||
|
||||
const handleAutoFitColumns = (e: ChangeEvent<HTMLInputElement>) => {
|
||||
setTable({ autoFit: e.currentTarget.checked });
|
||||
|
||||
if (e.currentTarget.checked) {
|
||||
tableRef.current?.api.sizeColumnsToFit();
|
||||
}
|
||||
};
|
||||
|
||||
const handleRowHeight = (e: number) => {
|
||||
setTable({ rowHeight: e });
|
||||
};
|
||||
|
||||
const handleRefresh = () => {
|
||||
queryClient.invalidateQueries(queryKeys.songs.list(server?.id || ''));
|
||||
handleFilterChange(filter);
|
||||
};
|
||||
|
||||
const handlePlay = async (play: Play) => {
|
||||
const query: SongListQuery = { startIndex: 0, ...filter };
|
||||
|
||||
handlePlayQueueAdd?.({
|
||||
byItemType: {
|
||||
id: query,
|
||||
type: LibraryItem.SONG,
|
||||
},
|
||||
play,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<PageHeader p="1rem">
|
||||
<Flex
|
||||
ref={cq.ref}
|
||||
direction="row"
|
||||
justify="space-between"
|
||||
>
|
||||
<Flex
|
||||
align="center"
|
||||
gap="md"
|
||||
justify="center"
|
||||
>
|
||||
<DropdownMenu position="bottom-start">
|
||||
<DropdownMenu.Target>
|
||||
<Button
|
||||
compact
|
||||
rightIcon={<RiArrowDownSLine size={15} />}
|
||||
size="xl"
|
||||
sx={{ paddingLeft: 0, paddingRight: 0 }}
|
||||
variant="subtle"
|
||||
>
|
||||
<Group noWrap>
|
||||
<TextTitle
|
||||
maw="20vw"
|
||||
order={2}
|
||||
overflow="hidden"
|
||||
weight={700}
|
||||
>
|
||||
{title}
|
||||
</TextTitle>
|
||||
<Badge
|
||||
radius="xl"
|
||||
size="lg"
|
||||
>
|
||||
{itemCount === null || itemCount === undefined ? <SpinnerIcon /> : itemCount}
|
||||
</Badge>
|
||||
</Group>
|
||||
</Button>
|
||||
</DropdownMenu.Target>
|
||||
<DropdownMenu.Dropdown>
|
||||
<DropdownMenu.Label>Display type</DropdownMenu.Label>
|
||||
<DropdownMenu.Item
|
||||
$isActive={page.display === ListDisplayType.TABLE}
|
||||
value={ListDisplayType.TABLE}
|
||||
onClick={handleSetViewType}
|
||||
>
|
||||
Table
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item
|
||||
$isActive={page.display === ListDisplayType.TABLE_PAGINATED}
|
||||
value={ListDisplayType.TABLE_PAGINATED}
|
||||
onClick={handleSetViewType}
|
||||
>
|
||||
Table (paginated)
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Divider />
|
||||
<DropdownMenu.Label>Item Size</DropdownMenu.Label>
|
||||
<DropdownMenu.Item closeMenuOnClick={false}>
|
||||
<Slider
|
||||
defaultValue={page.table.rowHeight || 0}
|
||||
label={null}
|
||||
max={100}
|
||||
min={25}
|
||||
onChangeEnd={handleRowHeight}
|
||||
/>
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Label>Table Columns</DropdownMenu.Label>
|
||||
<DropdownMenu.Item
|
||||
closeMenuOnClick={false}
|
||||
component="div"
|
||||
sx={{ cursor: 'default' }}
|
||||
>
|
||||
<Stack>
|
||||
<MultiSelect
|
||||
clearable
|
||||
data={SONG_TABLE_COLUMNS}
|
||||
defaultValue={page.table?.columns.map((column) => column.column)}
|
||||
width={300}
|
||||
onChange={handleTableColumns}
|
||||
/>
|
||||
<Group position="apart">
|
||||
<Text>Auto Fit Columns</Text>
|
||||
<Switch
|
||||
defaultChecked={page.table.autoFit}
|
||||
onChange={handleAutoFitColumns}
|
||||
/>
|
||||
</Group>
|
||||
</Stack>
|
||||
</DropdownMenu.Item>
|
||||
</DropdownMenu.Dropdown>
|
||||
</DropdownMenu>
|
||||
|
||||
<DropdownMenu position="bottom-start">
|
||||
<DropdownMenu.Target>
|
||||
<Button
|
||||
compact
|
||||
fw="600"
|
||||
variant="subtle"
|
||||
>
|
||||
{sortByLabel}
|
||||
</Button>
|
||||
</DropdownMenu.Target>
|
||||
<DropdownMenu.Dropdown>
|
||||
{FILTERS[server?.type as keyof typeof FILTERS].map((filter) => (
|
||||
<DropdownMenu.Item
|
||||
key={`filter-${filter.name}`}
|
||||
$isActive={filter.value === page.filter.sortBy}
|
||||
value={filter.value}
|
||||
onClick={handleSetSortBy}
|
||||
>
|
||||
{filter.name}
|
||||
</DropdownMenu.Item>
|
||||
))}
|
||||
</DropdownMenu.Dropdown>
|
||||
</DropdownMenu>
|
||||
<Button
|
||||
compact
|
||||
fw="600"
|
||||
variant="subtle"
|
||||
onClick={handleToggleSortOrder}
|
||||
>
|
||||
{cq.isMd ? (
|
||||
sortOrderLabel
|
||||
) : (
|
||||
<>
|
||||
{filter.sortOrder === SortOrder.ASC ? (
|
||||
<RiSortAsc size={15} />
|
||||
) : (
|
||||
<RiSortDesc size={15} />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
{server?.type === ServerType.JELLYFIN && (
|
||||
<DropdownMenu position="bottom-start">
|
||||
<DropdownMenu.Target>
|
||||
<Button
|
||||
compact
|
||||
fw="600"
|
||||
variant="subtle"
|
||||
>
|
||||
{cq.isMd ? 'Folder' : <RiFolder2Line size={15} />}
|
||||
</Button>
|
||||
</DropdownMenu.Target>
|
||||
<DropdownMenu.Dropdown>
|
||||
{musicFoldersQuery.data?.map((folder) => (
|
||||
<DropdownMenu.Item
|
||||
key={`musicFolder-${folder.id}`}
|
||||
$isActive={page.filter.musicFolderId === folder.id}
|
||||
value={folder.id}
|
||||
onClick={handleSetMusicFolder}
|
||||
>
|
||||
{folder.name}
|
||||
</DropdownMenu.Item>
|
||||
))}
|
||||
</DropdownMenu.Dropdown>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
<DropdownMenu position="bottom-start">
|
||||
<DropdownMenu.Target>
|
||||
<Button
|
||||
compact
|
||||
fw="600"
|
||||
variant="subtle"
|
||||
>
|
||||
<RiMoreFill size={15} />
|
||||
</Button>
|
||||
</DropdownMenu.Target>
|
||||
<DropdownMenu.Dropdown>
|
||||
<DropdownMenu.Item onClick={() => handlePlay(Play.NOW)}>Play</DropdownMenu.Item>
|
||||
<DropdownMenu.Item onClick={() => handlePlay(Play.LAST)}>
|
||||
Add to queue
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item onClick={() => handlePlay(Play.NEXT)}>
|
||||
Add to queue next
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Divider />
|
||||
<DropdownMenu.Item onClick={handleRefresh}>Refresh</DropdownMenu.Item>
|
||||
</DropdownMenu.Dropdown>
|
||||
</DropdownMenu>
|
||||
</Flex>
|
||||
<Flex gap="md">
|
||||
<SearchInput
|
||||
defaultValue={filter.searchTerm}
|
||||
openedWidth={cq.isLg ? 300 : cq.isMd ? 250 : cq.isSm ? 150 : 75}
|
||||
onChange={handleSearch}
|
||||
/>
|
||||
</Flex>
|
||||
</Flex>
|
||||
</PageHeader>
|
||||
);
|
||||
};
|
||||
+73
@@ -0,0 +1,73 @@
|
||||
import { MutableRefObject, useMemo } from 'react';
|
||||
import type { ColDef, RowDoubleClickedEvent } from '@ag-grid-community/core';
|
||||
import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/lib/agGridReact';
|
||||
import { getColumnDefs, VirtualGridAutoSizerContainer, VirtualTable } from '/@/renderer/components';
|
||||
import { useCurrentServer, useSongListStore } from '/@/renderer/store';
|
||||
import { useHandleTableContextMenu } from '/@/renderer/features/context-menu';
|
||||
import { SONG_CONTEXT_MENU_ITEMS } from '/@/renderer/features/context-menu/context-menu-items';
|
||||
import { usePlayButtonBehavior } from '/@/renderer/store/settings.store';
|
||||
import { LibraryItem, QueueSong } from '/@/renderer/api/types';
|
||||
import { usePlayQueueAdd } from '/@/renderer/features/player';
|
||||
|
||||
interface AlbumArtistSongListContentProps {
|
||||
data: QueueSong[];
|
||||
tableRef: MutableRefObject<AgGridReactType | null>;
|
||||
}
|
||||
|
||||
export const AlbumArtistDetailTopSongsListContent = ({
|
||||
tableRef,
|
||||
data,
|
||||
}: AlbumArtistSongListContentProps) => {
|
||||
const server = useCurrentServer();
|
||||
const page = useSongListStore();
|
||||
|
||||
const handlePlayQueueAdd = usePlayQueueAdd();
|
||||
const playButtonBehavior = usePlayButtonBehavior();
|
||||
|
||||
const columnDefs: ColDef[] = useMemo(
|
||||
() => getColumnDefs(page.table.columns),
|
||||
[page.table.columns],
|
||||
);
|
||||
|
||||
const handleContextMenu = useHandleTableContextMenu(LibraryItem.SONG, SONG_CONTEXT_MENU_ITEMS);
|
||||
|
||||
const handleRowDoubleClick = (e: RowDoubleClickedEvent<QueueSong>) => {
|
||||
if (!e.data) return;
|
||||
handlePlayQueueAdd?.({
|
||||
byData: [e.data],
|
||||
play: playButtonBehavior,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<VirtualGridAutoSizerContainer>
|
||||
<VirtualTable
|
||||
// https://github.com/ag-grid/ag-grid/issues/5284
|
||||
// Key is used to force remount of table when display, rowHeight, or server changes
|
||||
key={`table-${page.display}-${page.table.rowHeight}-${server?.id}`}
|
||||
ref={tableRef}
|
||||
alwaysShowHorizontalScroll
|
||||
animateRows
|
||||
maintainColumnOrder
|
||||
suppressCopyRowsToClipboard
|
||||
suppressMoveWhenRowDragging
|
||||
suppressPaginationPanel
|
||||
suppressRowDrag
|
||||
suppressScrollOnNewData
|
||||
autoFitColumns={page.table.autoFit}
|
||||
columnDefs={columnDefs}
|
||||
enableCellChangeFlash={false}
|
||||
getRowId={(data) => data.data.uniqueId}
|
||||
rowBuffer={20}
|
||||
rowData={data}
|
||||
rowHeight={page.table.rowHeight || 40}
|
||||
rowModelType="clientSide"
|
||||
rowSelection="multiple"
|
||||
onCellContextMenu={handleContextMenu}
|
||||
onRowDoubleClicked={handleRowDoubleClick}
|
||||
/>
|
||||
</VirtualGridAutoSizerContainer>
|
||||
</>
|
||||
);
|
||||
};
|
||||
+77
@@ -0,0 +1,77 @@
|
||||
import { RiAddBoxFill, RiAddCircleFill, RiMoreFill, RiPlayFill } from 'react-icons/ri';
|
||||
import { QueueSong } from '/@/renderer/api/types';
|
||||
import { Button, DropdownMenu, PageHeader, SpinnerIcon, Paper } from '/@/renderer/components';
|
||||
import { usePlayQueueAdd } from '/@/renderer/features/player';
|
||||
import { LibraryHeaderBar } from '/@/renderer/features/shared';
|
||||
import { usePlayButtonBehavior } from '/@/renderer/store/settings.store';
|
||||
import { Play } from '/@/renderer/types';
|
||||
|
||||
interface AlbumArtistDetailTopSongsListHeaderProps {
|
||||
data: QueueSong[];
|
||||
itemCount?: number;
|
||||
title: string;
|
||||
}
|
||||
|
||||
export const AlbumArtistDetailTopSongsListHeader = ({
|
||||
title,
|
||||
itemCount,
|
||||
data,
|
||||
}: AlbumArtistDetailTopSongsListHeaderProps) => {
|
||||
const handlePlayQueueAdd = usePlayQueueAdd();
|
||||
const playButtonBehavior = usePlayButtonBehavior();
|
||||
|
||||
const handlePlay = async (play: Play) => {
|
||||
handlePlayQueueAdd?.({
|
||||
byData: data,
|
||||
play,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<PageHeader p="1rem">
|
||||
<LibraryHeaderBar>
|
||||
<LibraryHeaderBar.PlayButton onClick={() => handlePlay(playButtonBehavior)} />
|
||||
<LibraryHeaderBar.Title>Top songs from {title}</LibraryHeaderBar.Title>
|
||||
<Paper
|
||||
fw="600"
|
||||
px="1rem"
|
||||
py="0.3rem"
|
||||
radius="sm"
|
||||
>
|
||||
{itemCount === null || itemCount === undefined ? <SpinnerIcon /> : itemCount}
|
||||
</Paper>
|
||||
<DropdownMenu position="bottom-start">
|
||||
<DropdownMenu.Target>
|
||||
<Button
|
||||
compact
|
||||
fw="600"
|
||||
variant="subtle"
|
||||
>
|
||||
<RiMoreFill size={15} />
|
||||
</Button>
|
||||
</DropdownMenu.Target>
|
||||
<DropdownMenu.Dropdown>
|
||||
<DropdownMenu.Item
|
||||
icon={<RiPlayFill />}
|
||||
onClick={() => handlePlay(Play.NOW)}
|
||||
>
|
||||
Play
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item
|
||||
icon={<RiAddBoxFill />}
|
||||
onClick={() => handlePlay(Play.LAST)}
|
||||
>
|
||||
Add to queue
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item
|
||||
icon={<RiAddCircleFill />}
|
||||
onClick={() => handlePlay(Play.NEXT)}
|
||||
>
|
||||
Add to queue next
|
||||
</DropdownMenu.Item>
|
||||
</DropdownMenu.Dropdown>
|
||||
</DropdownMenu>
|
||||
</LibraryHeaderBar>
|
||||
</PageHeader>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,344 @@
|
||||
import {
|
||||
ALBUMARTIST_CARD_ROWS,
|
||||
getColumnDefs,
|
||||
TablePagination,
|
||||
VirtualGridAutoSizerContainer,
|
||||
VirtualInfiniteGrid,
|
||||
VirtualInfiniteGridRef,
|
||||
VirtualTable,
|
||||
} from '/@/renderer/components';
|
||||
import { AppRoute } from '/@/renderer/router/routes';
|
||||
import { ListDisplayType, CardRow } from '/@/renderer/types';
|
||||
import AutoSizer from 'react-virtualized-auto-sizer';
|
||||
import { MutableRefObject, useCallback, useMemo } from 'react';
|
||||
import { ListOnScrollProps } from 'react-window';
|
||||
import { api } from '/@/renderer/api';
|
||||
import { queryKeys } from '/@/renderer/api/query-keys';
|
||||
import { AlbumArtist, AlbumArtistListSort, LibraryItem } from '/@/renderer/api/types';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import {
|
||||
useCurrentServer,
|
||||
useAlbumArtistListStore,
|
||||
useAlbumArtistTablePagination,
|
||||
useSetAlbumArtistStore,
|
||||
useSetAlbumArtistTable,
|
||||
useSetAlbumArtistTablePagination,
|
||||
useAlbumArtistListItemData,
|
||||
} from '/@/renderer/store';
|
||||
import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/lib/agGridReact';
|
||||
import {
|
||||
BodyScrollEvent,
|
||||
ColDef,
|
||||
GridReadyEvent,
|
||||
IDatasource,
|
||||
PaginationChangedEvent,
|
||||
RowDoubleClickedEvent,
|
||||
} from '@ag-grid-community/core';
|
||||
import { AnimatePresence } from 'framer-motion';
|
||||
import debounce from 'lodash/debounce';
|
||||
import { useHandleTableContextMenu } from '/@/renderer/features/context-menu';
|
||||
import { ALBUM_CONTEXT_MENU_ITEMS } from '/@/renderer/features/context-menu/context-menu-items';
|
||||
import { generatePath, useNavigate } from 'react-router';
|
||||
import { useAlbumArtistList } from '/@/renderer/features/artists/queries/album-artist-list-query';
|
||||
import { usePlayQueueAdd } from '/@/renderer/features/player';
|
||||
|
||||
interface AlbumArtistListContentProps {
|
||||
gridRef: MutableRefObject<VirtualInfiniteGridRef | null>;
|
||||
tableRef: MutableRefObject<AgGridReactType | null>;
|
||||
}
|
||||
|
||||
export const AlbumArtistListContent = ({ gridRef, tableRef }: AlbumArtistListContentProps) => {
|
||||
const queryClient = useQueryClient();
|
||||
const navigate = useNavigate();
|
||||
const server = useCurrentServer();
|
||||
const page = useAlbumArtistListStore();
|
||||
const setPage = useSetAlbumArtistStore();
|
||||
const handlePlayQueueAdd = usePlayQueueAdd();
|
||||
|
||||
const { itemData, setItemData } = useAlbumArtistListItemData();
|
||||
|
||||
const pagination = useAlbumArtistTablePagination();
|
||||
const setPagination = useSetAlbumArtistTablePagination();
|
||||
const setTable = useSetAlbumArtistTable();
|
||||
|
||||
const isPaginationEnabled = page.display === ListDisplayType.TABLE_PAGINATED;
|
||||
|
||||
const checkAlbumArtistList = useAlbumArtistList(
|
||||
{
|
||||
limit: 1,
|
||||
startIndex: 0,
|
||||
...page.filter,
|
||||
},
|
||||
{
|
||||
cacheTime: Infinity,
|
||||
staleTime: 60 * 1000 * 5,
|
||||
},
|
||||
);
|
||||
|
||||
const columnDefs: ColDef[] = useMemo(
|
||||
() => getColumnDefs(page.table.columns),
|
||||
[page.table.columns],
|
||||
);
|
||||
|
||||
const onTableReady = useCallback(
|
||||
(params: GridReadyEvent) => {
|
||||
const dataSource: IDatasource = {
|
||||
getRows: async (params) => {
|
||||
const limit = params.endRow - params.startRow;
|
||||
const startIndex = params.startRow;
|
||||
|
||||
const queryKey = queryKeys.albumArtists.list(server?.id || '', {
|
||||
limit,
|
||||
startIndex,
|
||||
...page.filter,
|
||||
});
|
||||
|
||||
const albumArtistsRes = await queryClient.fetchQuery(
|
||||
queryKey,
|
||||
async ({ signal }) =>
|
||||
api.controller.getAlbumArtistList({
|
||||
query: {
|
||||
limit,
|
||||
startIndex,
|
||||
...page.filter,
|
||||
},
|
||||
server,
|
||||
signal,
|
||||
}),
|
||||
{ cacheTime: 1000 * 60 * 1 },
|
||||
);
|
||||
|
||||
const albums = api.normalize.albumArtistList(albumArtistsRes, server);
|
||||
params.successCallback(albums?.items || [], albumArtistsRes?.totalRecordCount || 0);
|
||||
},
|
||||
rowCount: undefined,
|
||||
};
|
||||
|
||||
params.api.setDatasource(dataSource);
|
||||
params.api.ensureIndexVisible(page.table.scrollOffset || 0, 'top');
|
||||
},
|
||||
[page.filter, page.table.scrollOffset, queryClient, server],
|
||||
);
|
||||
|
||||
const onTablePaginationChanged = useCallback(
|
||||
(event: PaginationChangedEvent) => {
|
||||
if (!isPaginationEnabled || !event.api) return;
|
||||
|
||||
try {
|
||||
// Scroll to top of page on pagination change
|
||||
const currentPageStartIndex = pagination.currentPage * pagination.itemsPerPage;
|
||||
event.api?.ensureIndexVisible(currentPageStartIndex, 'top');
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
}
|
||||
|
||||
setPagination({
|
||||
itemsPerPage: event.api.paginationGetPageSize(),
|
||||
totalItems: event.api.paginationGetRowCount(),
|
||||
totalPages: event.api.paginationGetTotalPages() + 1,
|
||||
});
|
||||
},
|
||||
[isPaginationEnabled, pagination.currentPage, pagination.itemsPerPage, setPagination],
|
||||
);
|
||||
|
||||
const handleTableColumnChange = useCallback(() => {
|
||||
const { columnApi } = tableRef?.current || {};
|
||||
const columnsOrder = columnApi?.getAllGridColumns();
|
||||
|
||||
if (!columnsOrder) return;
|
||||
|
||||
const columnsInSettings = page.table.columns;
|
||||
const updatedColumns = [];
|
||||
for (const column of columnsOrder) {
|
||||
const columnInSettings = columnsInSettings.find((c) => c.column === column.getColDef().colId);
|
||||
|
||||
if (columnInSettings) {
|
||||
updatedColumns.push({
|
||||
...columnInSettings,
|
||||
...(!page.table.autoFit && {
|
||||
width: column.getColDef().width,
|
||||
}),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
setTable({ columns: updatedColumns });
|
||||
}, [page.table.autoFit, page.table.columns, setTable, tableRef]);
|
||||
|
||||
const debouncedTableColumnChange = debounce(handleTableColumnChange, 200);
|
||||
|
||||
const handleTableScroll = (e: BodyScrollEvent) => {
|
||||
const scrollOffset = Number((e.top / page.table.rowHeight).toFixed(0));
|
||||
setTable({ scrollOffset });
|
||||
};
|
||||
|
||||
const fetch = useCallback(
|
||||
async ({ skip: startIndex, take: limit }: { skip: number; take: number }) => {
|
||||
const queryKey = queryKeys.albumArtists.list(server?.id || '', {
|
||||
limit,
|
||||
startIndex,
|
||||
...page.filter,
|
||||
});
|
||||
|
||||
const albumArtistsRes = await queryClient.fetchQuery(
|
||||
queryKey,
|
||||
async ({ signal }) =>
|
||||
api.controller.getAlbumArtistList({
|
||||
query: {
|
||||
limit,
|
||||
startIndex,
|
||||
...page.filter,
|
||||
},
|
||||
server,
|
||||
signal,
|
||||
}),
|
||||
{ cacheTime: 1000 * 60 * 1 },
|
||||
);
|
||||
|
||||
return api.normalize.albumArtistList(albumArtistsRes, server);
|
||||
},
|
||||
[page.filter, queryClient, server],
|
||||
);
|
||||
|
||||
const handleGridScroll = useCallback(
|
||||
(e: ListOnScrollProps) => {
|
||||
setPage({
|
||||
list: {
|
||||
...page,
|
||||
grid: {
|
||||
...page.grid,
|
||||
scrollOffset: e.scrollOffset,
|
||||
},
|
||||
},
|
||||
});
|
||||
},
|
||||
[page, setPage],
|
||||
);
|
||||
|
||||
const handleGridSizeChange = () => {
|
||||
if (page.table.autoFit) {
|
||||
tableRef?.current?.api.sizeColumnsToFit();
|
||||
}
|
||||
};
|
||||
|
||||
const cardRows = useMemo(() => {
|
||||
const rows: CardRow<AlbumArtist>[] = [ALBUMARTIST_CARD_ROWS.name];
|
||||
|
||||
switch (page.filter.sortBy) {
|
||||
case AlbumArtistListSort.DURATION:
|
||||
rows.push(ALBUMARTIST_CARD_ROWS.duration);
|
||||
break;
|
||||
case AlbumArtistListSort.FAVORITED:
|
||||
break;
|
||||
case AlbumArtistListSort.NAME:
|
||||
break;
|
||||
case AlbumArtistListSort.ALBUM_COUNT:
|
||||
rows.push(ALBUMARTIST_CARD_ROWS.albumCount);
|
||||
break;
|
||||
case AlbumArtistListSort.PLAY_COUNT:
|
||||
rows.push(ALBUMARTIST_CARD_ROWS.playCount);
|
||||
break;
|
||||
case AlbumArtistListSort.RANDOM:
|
||||
break;
|
||||
case AlbumArtistListSort.RATING:
|
||||
rows.push(ALBUMARTIST_CARD_ROWS.rating);
|
||||
break;
|
||||
case AlbumArtistListSort.RECENTLY_ADDED:
|
||||
break;
|
||||
case AlbumArtistListSort.SONG_COUNT:
|
||||
rows.push(ALBUMARTIST_CARD_ROWS.songCount);
|
||||
break;
|
||||
case AlbumArtistListSort.RELEASE_DATE:
|
||||
break;
|
||||
}
|
||||
|
||||
return rows;
|
||||
}, [page.filter.sortBy]);
|
||||
|
||||
const handleContextMenu = useHandleTableContextMenu(
|
||||
LibraryItem.ALBUM_ARTIST,
|
||||
ALBUM_CONTEXT_MENU_ITEMS,
|
||||
);
|
||||
|
||||
const handleRowDoubleClick = (e: RowDoubleClickedEvent) => {
|
||||
navigate(generatePath(AppRoute.LIBRARY_ALBUM_ARTISTS_DETAIL, { albumArtistId: e.data.id }));
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<VirtualGridAutoSizerContainer>
|
||||
{page.display === ListDisplayType.CARD || page.display === ListDisplayType.POSTER ? (
|
||||
<AutoSizer>
|
||||
{({ height, width }) => (
|
||||
<VirtualInfiniteGrid
|
||||
key={`albumartist-list-${server?.id}-${page.display}`}
|
||||
ref={gridRef}
|
||||
cardRows={cardRows}
|
||||
display={page.display || ListDisplayType.CARD}
|
||||
fetchFn={fetch}
|
||||
handlePlayQueueAdd={handlePlayQueueAdd}
|
||||
height={height}
|
||||
initialScrollOffset={page?.grid.scrollOffset || 0}
|
||||
itemCount={checkAlbumArtistList?.data?.totalRecordCount || 0}
|
||||
itemData={itemData}
|
||||
itemGap={20}
|
||||
itemSize={150 + page.grid?.size}
|
||||
itemType={LibraryItem.ALBUM_ARTIST}
|
||||
loading={checkAlbumArtistList.isLoading}
|
||||
minimumBatchSize={40}
|
||||
route={{
|
||||
route: AppRoute.LIBRARY_ALBUM_ARTISTS_DETAIL,
|
||||
slugs: [{ idProperty: 'id', slugProperty: 'albumArtistId' }],
|
||||
}}
|
||||
setItemData={setItemData}
|
||||
width={width}
|
||||
onScroll={handleGridScroll}
|
||||
/>
|
||||
)}
|
||||
</AutoSizer>
|
||||
) : (
|
||||
<VirtualTable
|
||||
// https://github.com/ag-grid/ag-grid/issues/5284
|
||||
// Key is used to force remount of table when display, rowHeight, or server changes
|
||||
key={`table-${page.display}-${page.table.rowHeight}-${server?.id}`}
|
||||
ref={tableRef}
|
||||
alwaysShowHorizontalScroll
|
||||
suppressRowDrag
|
||||
autoFitColumns={page.table.autoFit}
|
||||
columnDefs={columnDefs}
|
||||
getRowId={(data) => data.data.id}
|
||||
infiniteInitialRowCount={checkAlbumArtistList.data?.totalRecordCount || 1}
|
||||
pagination={isPaginationEnabled}
|
||||
paginationAutoPageSize={isPaginationEnabled}
|
||||
paginationPageSize={page.table.pagination.itemsPerPage || 100}
|
||||
rowHeight={page.table.rowHeight || 40}
|
||||
rowModelType="infinite"
|
||||
onBodyScrollEnd={handleTableScroll}
|
||||
onCellContextMenu={handleContextMenu}
|
||||
onColumnMoved={handleTableColumnChange}
|
||||
onColumnResized={debouncedTableColumnChange}
|
||||
onGridReady={onTableReady}
|
||||
onGridSizeChanged={handleGridSizeChange}
|
||||
onPaginationChanged={onTablePaginationChanged}
|
||||
onRowDoubleClicked={handleRowDoubleClick}
|
||||
/>
|
||||
)}
|
||||
</VirtualGridAutoSizerContainer>
|
||||
{isPaginationEnabled && (
|
||||
<AnimatePresence
|
||||
presenceAffectsLayout
|
||||
initial={false}
|
||||
mode="wait"
|
||||
>
|
||||
{page.display === ListDisplayType.TABLE_PAGINATED && (
|
||||
<TablePagination
|
||||
pagination={pagination}
|
||||
setPagination={setPagination}
|
||||
tableRef={tableRef}
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,470 @@
|
||||
import { useCallback, ChangeEvent, MutableRefObject, MouseEvent } from 'react';
|
||||
import { IDatasource } from '@ag-grid-community/core';
|
||||
import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/lib/agGridReact';
|
||||
import { Group, Stack, Flex } from '@mantine/core';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import {
|
||||
RiSortAsc,
|
||||
RiSortDesc,
|
||||
RiFolder2Line,
|
||||
RiMoreFill,
|
||||
RiRefreshLine,
|
||||
RiSettings3Fill,
|
||||
} from 'react-icons/ri';
|
||||
import { api } from '/@/renderer/api';
|
||||
import { queryKeys } from '/@/renderer/api/query-keys';
|
||||
import { AlbumArtistListSort, SortOrder } from '/@/renderer/api/types';
|
||||
import {
|
||||
DropdownMenu,
|
||||
ALBUMARTIST_TABLE_COLUMNS,
|
||||
VirtualInfiniteGridRef,
|
||||
Text,
|
||||
Button,
|
||||
Slider,
|
||||
MultiSelect,
|
||||
Switch,
|
||||
} from '/@/renderer/components';
|
||||
import { useMusicFolders } from '/@/renderer/features/shared';
|
||||
import { useContainerQuery } from '/@/renderer/hooks';
|
||||
import {
|
||||
useCurrentServer,
|
||||
useSetAlbumArtistStore,
|
||||
useSetAlbumArtistFilters,
|
||||
useAlbumArtistListStore,
|
||||
useSetAlbumArtistTablePagination,
|
||||
useSetAlbumArtistTable,
|
||||
AlbumArtistListFilter,
|
||||
} from '/@/renderer/store';
|
||||
import { ListDisplayType, TableColumn, ServerType } from '/@/renderer/types';
|
||||
|
||||
const FILTERS = {
|
||||
jellyfin: [
|
||||
{ defaultOrder: SortOrder.ASC, name: 'Album', value: AlbumArtistListSort.ALBUM },
|
||||
{ defaultOrder: SortOrder.DESC, name: 'Duration', value: AlbumArtistListSort.DURATION },
|
||||
{ defaultOrder: SortOrder.ASC, name: 'Name', value: AlbumArtistListSort.NAME },
|
||||
{ defaultOrder: SortOrder.ASC, name: 'Random', value: AlbumArtistListSort.RANDOM },
|
||||
{
|
||||
defaultOrder: SortOrder.DESC,
|
||||
name: 'Recently Added',
|
||||
value: AlbumArtistListSort.RECENTLY_ADDED,
|
||||
},
|
||||
// { defaultOrder: SortOrder.DESC, name: 'Release Date', value: AlbumArtistListSort.RELEASE_DATE },
|
||||
],
|
||||
navidrome: [
|
||||
{ defaultOrder: SortOrder.DESC, name: 'Album Count', value: AlbumArtistListSort.ALBUM_COUNT },
|
||||
{ defaultOrder: SortOrder.DESC, name: 'Favorited', value: AlbumArtistListSort.FAVORITED },
|
||||
{ defaultOrder: SortOrder.DESC, name: 'Most Played', value: AlbumArtistListSort.PLAY_COUNT },
|
||||
{ defaultOrder: SortOrder.ASC, name: 'Name', value: AlbumArtistListSort.NAME },
|
||||
{ defaultOrder: SortOrder.DESC, name: 'Rating', value: AlbumArtistListSort.RATING },
|
||||
{ defaultOrder: SortOrder.DESC, name: 'Song Count', value: AlbumArtistListSort.SONG_COUNT },
|
||||
],
|
||||
};
|
||||
|
||||
const ORDER = [
|
||||
{ name: 'Ascending', value: SortOrder.ASC },
|
||||
{ name: 'Descending', value: SortOrder.DESC },
|
||||
];
|
||||
|
||||
interface AlbumArtistListHeaderFiltersProps {
|
||||
gridRef: MutableRefObject<VirtualInfiniteGridRef | null>;
|
||||
tableRef: MutableRefObject<AgGridReactType | null>;
|
||||
}
|
||||
|
||||
export const AlbumArtistListHeaderFilters = ({
|
||||
gridRef,
|
||||
tableRef,
|
||||
}: AlbumArtistListHeaderFiltersProps) => {
|
||||
const queryClient = useQueryClient();
|
||||
const server = useCurrentServer();
|
||||
const setPage = useSetAlbumArtistStore();
|
||||
const setFilter = useSetAlbumArtistFilters();
|
||||
const page = useAlbumArtistListStore();
|
||||
const filters = page.filter;
|
||||
const cq = useContainerQuery();
|
||||
|
||||
const musicFoldersQuery = useMusicFolders();
|
||||
|
||||
const setPagination = useSetAlbumArtistTablePagination();
|
||||
const setTable = useSetAlbumArtistTable();
|
||||
|
||||
const sortByLabel =
|
||||
(server?.type &&
|
||||
FILTERS[server.type as keyof typeof FILTERS].find((f) => f.value === filters.sortBy)?.name) ||
|
||||
'Unknown';
|
||||
|
||||
const sortOrderLabel = ORDER.find((o) => o.value === filters.sortOrder)?.name || 'Unknown';
|
||||
|
||||
const handleItemSize = (e: number) => {
|
||||
if (
|
||||
page.display === ListDisplayType.TABLE ||
|
||||
page.display === ListDisplayType.TABLE_PAGINATED
|
||||
) {
|
||||
setTable({ rowHeight: e });
|
||||
} else {
|
||||
setPage({ list: { ...page, grid: { ...page.grid, size: e } } });
|
||||
}
|
||||
};
|
||||
|
||||
const fetch = useCallback(
|
||||
async (startIndex: number, limit: number, filters: AlbumArtistListFilter) => {
|
||||
const queryKey = queryKeys.albumArtists.list(server?.id || '', {
|
||||
limit,
|
||||
startIndex,
|
||||
...filters,
|
||||
});
|
||||
|
||||
const albums = await queryClient.fetchQuery(
|
||||
queryKey,
|
||||
async ({ signal }) =>
|
||||
api.controller.getAlbumArtistList({
|
||||
query: {
|
||||
limit,
|
||||
startIndex,
|
||||
...filters,
|
||||
},
|
||||
server,
|
||||
signal,
|
||||
}),
|
||||
{ cacheTime: 1000 * 60 * 1 },
|
||||
);
|
||||
|
||||
return api.normalize.albumArtistList(albums, server);
|
||||
},
|
||||
[queryClient, server],
|
||||
);
|
||||
|
||||
const handleFilterChange = useCallback(
|
||||
async (filters: AlbumArtistListFilter) => {
|
||||
if (
|
||||
page.display === ListDisplayType.TABLE ||
|
||||
page.display === ListDisplayType.TABLE_PAGINATED
|
||||
) {
|
||||
const dataSource: IDatasource = {
|
||||
getRows: async (params) => {
|
||||
const limit = params.endRow - params.startRow;
|
||||
const startIndex = params.startRow;
|
||||
|
||||
const queryKey = queryKeys.albumArtists.list(server?.id || '', {
|
||||
limit,
|
||||
startIndex,
|
||||
...filters,
|
||||
});
|
||||
|
||||
const albumArtistsRes = await queryClient.fetchQuery(
|
||||
queryKey,
|
||||
async ({ signal }) =>
|
||||
api.controller.getAlbumArtistList({
|
||||
query: {
|
||||
limit,
|
||||
startIndex,
|
||||
...filters,
|
||||
},
|
||||
server,
|
||||
signal,
|
||||
}),
|
||||
{ cacheTime: 1000 * 60 * 1 },
|
||||
);
|
||||
|
||||
const albumArtists = api.normalize.albumArtistList(albumArtistsRes, server);
|
||||
params.successCallback(
|
||||
albumArtists?.items || [],
|
||||
albumArtistsRes?.totalRecordCount || 0,
|
||||
);
|
||||
},
|
||||
rowCount: undefined,
|
||||
};
|
||||
tableRef.current?.api.setDatasource(dataSource);
|
||||
tableRef.current?.api.purgeInfiniteCache();
|
||||
tableRef.current?.api.ensureIndexVisible(0, 'top');
|
||||
|
||||
if (page.display === ListDisplayType.TABLE_PAGINATED) {
|
||||
setPagination({ currentPage: 0 });
|
||||
}
|
||||
} else {
|
||||
gridRef.current?.scrollTo(0);
|
||||
gridRef.current?.resetLoadMoreItemsCache();
|
||||
|
||||
// Refetching within the virtualized grid may be inconsistent due to it refetching
|
||||
// using an outdated set of filters. To avoid this, we fetch using the updated filters
|
||||
// and then set the grid's data here.
|
||||
const data = await fetch(0, 200, filters);
|
||||
|
||||
if (!data?.items) return;
|
||||
gridRef.current?.setItemData(data.items);
|
||||
}
|
||||
},
|
||||
[page.display, tableRef, setPagination, server, queryClient, gridRef, fetch],
|
||||
);
|
||||
|
||||
const handleSetSortBy = useCallback(
|
||||
(e: MouseEvent<HTMLButtonElement>) => {
|
||||
if (!e.currentTarget?.value || !server?.type) return;
|
||||
|
||||
const sortOrder = FILTERS[server.type as keyof typeof FILTERS].find(
|
||||
(f) => f.value === e.currentTarget.value,
|
||||
)?.defaultOrder;
|
||||
|
||||
const updatedFilters = setFilter({
|
||||
sortBy: e.currentTarget.value as AlbumArtistListSort,
|
||||
sortOrder: sortOrder || SortOrder.ASC,
|
||||
});
|
||||
|
||||
handleFilterChange(updatedFilters);
|
||||
},
|
||||
[handleFilterChange, server?.type, setFilter],
|
||||
);
|
||||
|
||||
const handleSetMusicFolder = useCallback(
|
||||
(e: MouseEvent<HTMLButtonElement>) => {
|
||||
if (!e.currentTarget?.value) return;
|
||||
|
||||
let updatedFilters = null;
|
||||
if (e.currentTarget.value === String(page.filter.musicFolderId)) {
|
||||
updatedFilters = setFilter({ musicFolderId: undefined });
|
||||
} else {
|
||||
updatedFilters = setFilter({ musicFolderId: e.currentTarget.value });
|
||||
}
|
||||
|
||||
handleFilterChange(updatedFilters);
|
||||
},
|
||||
[handleFilterChange, page.filter.musicFolderId, setFilter],
|
||||
);
|
||||
|
||||
const handleToggleSortOrder = useCallback(() => {
|
||||
const newSortOrder = filters.sortOrder === SortOrder.ASC ? SortOrder.DESC : SortOrder.ASC;
|
||||
const updatedFilters = setFilter({ sortOrder: newSortOrder });
|
||||
handleFilterChange(updatedFilters);
|
||||
}, [filters.sortOrder, handleFilterChange, setFilter]);
|
||||
|
||||
const handleSetViewType = useCallback(
|
||||
(e: MouseEvent<HTMLButtonElement>) => {
|
||||
if (!e.currentTarget?.value) return;
|
||||
setPage({ list: { ...page, display: e.currentTarget.value as ListDisplayType } });
|
||||
},
|
||||
[page, setPage],
|
||||
);
|
||||
|
||||
const handleTableColumns = (values: TableColumn[]) => {
|
||||
const existingColumns = page.table.columns;
|
||||
|
||||
if (values.length === 0) {
|
||||
return setTable({
|
||||
columns: [],
|
||||
});
|
||||
}
|
||||
|
||||
// If adding a column
|
||||
if (values.length > existingColumns.length) {
|
||||
const newColumn = { column: values[values.length - 1], width: 100 };
|
||||
|
||||
setTable({ columns: [...existingColumns, newColumn] });
|
||||
} else {
|
||||
// If removing a column
|
||||
const removed = existingColumns.filter((column) => !values.includes(column.column));
|
||||
const newColumns = existingColumns.filter((column) => !removed.includes(column));
|
||||
|
||||
setTable({ columns: newColumns });
|
||||
}
|
||||
|
||||
return tableRef.current?.api.sizeColumnsToFit();
|
||||
};
|
||||
|
||||
const handleAutoFitColumns = (e: ChangeEvent<HTMLInputElement>) => {
|
||||
setTable({ autoFit: e.currentTarget.checked });
|
||||
|
||||
if (e.currentTarget.checked) {
|
||||
tableRef.current?.api.sizeColumnsToFit();
|
||||
}
|
||||
};
|
||||
|
||||
const handleRefresh = useCallback(() => {
|
||||
queryClient.invalidateQueries(queryKeys.albumArtists.list(server?.id || ''));
|
||||
handleFilterChange(filters);
|
||||
}, [filters, handleFilterChange, queryClient, server?.id]);
|
||||
|
||||
return (
|
||||
<Flex justify="space-between">
|
||||
<Group
|
||||
ref={cq.ref}
|
||||
spacing="sm"
|
||||
w="100%"
|
||||
>
|
||||
<DropdownMenu position="bottom-start">
|
||||
<DropdownMenu.Target>
|
||||
<Button
|
||||
compact
|
||||
fw="600"
|
||||
size="md"
|
||||
variant="subtle"
|
||||
>
|
||||
{sortByLabel}
|
||||
</Button>
|
||||
</DropdownMenu.Target>
|
||||
<DropdownMenu.Dropdown>
|
||||
{FILTERS[server?.type as keyof typeof FILTERS].map((filter) => (
|
||||
<DropdownMenu.Item
|
||||
key={`filter-${filter.name}`}
|
||||
$isActive={filter.value === filters.sortBy}
|
||||
value={filter.value}
|
||||
onClick={handleSetSortBy}
|
||||
>
|
||||
{filter.name}
|
||||
</DropdownMenu.Item>
|
||||
))}
|
||||
</DropdownMenu.Dropdown>
|
||||
</DropdownMenu>
|
||||
<Button
|
||||
compact
|
||||
fw="600"
|
||||
size="md"
|
||||
variant="subtle"
|
||||
onClick={handleToggleSortOrder}
|
||||
>
|
||||
{cq.isMd ? (
|
||||
sortOrderLabel
|
||||
) : (
|
||||
<>
|
||||
{filters.sortOrder === SortOrder.ASC ? (
|
||||
<RiSortAsc size={15} />
|
||||
) : (
|
||||
<RiSortDesc size={15} />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
{server?.type === ServerType.JELLYFIN && (
|
||||
<DropdownMenu position="bottom-start">
|
||||
<DropdownMenu.Target>
|
||||
<Button
|
||||
compact
|
||||
fw="600"
|
||||
size="md"
|
||||
variant="subtle"
|
||||
>
|
||||
{cq.isMd ? 'Folder' : <RiFolder2Line size={15} />}
|
||||
</Button>
|
||||
</DropdownMenu.Target>
|
||||
<DropdownMenu.Dropdown>
|
||||
{musicFoldersQuery.data?.map((folder) => (
|
||||
<DropdownMenu.Item
|
||||
key={`musicFolder-${folder.id}`}
|
||||
$isActive={filters.musicFolderId === folder.id}
|
||||
value={folder.id}
|
||||
onClick={handleSetMusicFolder}
|
||||
>
|
||||
{folder.name}
|
||||
</DropdownMenu.Item>
|
||||
))}
|
||||
</DropdownMenu.Dropdown>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
<DropdownMenu position="bottom-start">
|
||||
<DropdownMenu.Target>
|
||||
<Button
|
||||
compact
|
||||
size="md"
|
||||
variant="subtle"
|
||||
>
|
||||
<RiMoreFill size={15} />
|
||||
</Button>
|
||||
</DropdownMenu.Target>
|
||||
<DropdownMenu.Dropdown>
|
||||
<DropdownMenu.Item
|
||||
icon={<RiRefreshLine />}
|
||||
onClick={handleRefresh}
|
||||
>
|
||||
Refresh
|
||||
</DropdownMenu.Item>
|
||||
</DropdownMenu.Dropdown>
|
||||
</DropdownMenu>
|
||||
</Group>
|
||||
<Group>
|
||||
<DropdownMenu position="bottom-end">
|
||||
<DropdownMenu.Target>
|
||||
<Button
|
||||
compact
|
||||
size="md"
|
||||
variant="subtle"
|
||||
>
|
||||
<RiSettings3Fill size="1.3rem" />
|
||||
</Button>
|
||||
</DropdownMenu.Target>
|
||||
<DropdownMenu.Dropdown>
|
||||
<DropdownMenu.Label>Display type</DropdownMenu.Label>
|
||||
<DropdownMenu.Item
|
||||
$isActive={page.display === ListDisplayType.CARD}
|
||||
value={ListDisplayType.CARD}
|
||||
onClick={handleSetViewType}
|
||||
>
|
||||
Card
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item
|
||||
$isActive={page.display === ListDisplayType.POSTER}
|
||||
value={ListDisplayType.POSTER}
|
||||
onClick={handleSetViewType}
|
||||
>
|
||||
Poster
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item
|
||||
$isActive={page.display === ListDisplayType.TABLE}
|
||||
value={ListDisplayType.TABLE}
|
||||
onClick={handleSetViewType}
|
||||
>
|
||||
Table
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item
|
||||
$isActive={page.display === ListDisplayType.TABLE_PAGINATED}
|
||||
value={ListDisplayType.TABLE_PAGINATED}
|
||||
onClick={handleSetViewType}
|
||||
>
|
||||
Table (paginated)
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Divider />
|
||||
<DropdownMenu.Label>Item size</DropdownMenu.Label>
|
||||
<DropdownMenu.Item closeMenuOnClick={false}>
|
||||
<Slider
|
||||
defaultValue={
|
||||
page.display === ListDisplayType.CARD || page.display === ListDisplayType.POSTER
|
||||
? page.grid.size
|
||||
: page.table.rowHeight
|
||||
}
|
||||
label={null}
|
||||
max={100}
|
||||
min={25}
|
||||
onChangeEnd={handleItemSize}
|
||||
/>
|
||||
</DropdownMenu.Item>
|
||||
{(page.display === ListDisplayType.TABLE ||
|
||||
page.display === ListDisplayType.TABLE_PAGINATED) && (
|
||||
<>
|
||||
<DropdownMenu.Label>Table Columns</DropdownMenu.Label>
|
||||
<DropdownMenu.Item
|
||||
closeMenuOnClick={false}
|
||||
component="div"
|
||||
sx={{ cursor: 'default' }}
|
||||
>
|
||||
<Stack>
|
||||
<MultiSelect
|
||||
clearable
|
||||
data={ALBUMARTIST_TABLE_COLUMNS}
|
||||
defaultValue={page.table?.columns.map((column) => column.column)}
|
||||
width={300}
|
||||
onChange={handleTableColumns}
|
||||
/>
|
||||
<Group position="apart">
|
||||
<Text>Auto Fit Columns</Text>
|
||||
<Switch
|
||||
defaultChecked={page.table.autoFit}
|
||||
onChange={handleAutoFitColumns}
|
||||
/>
|
||||
</Group>
|
||||
</Stack>
|
||||
</DropdownMenu.Item>
|
||||
</>
|
||||
)}
|
||||
</DropdownMenu.Dropdown>
|
||||
</DropdownMenu>
|
||||
</Group>
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,184 @@
|
||||
import type { ChangeEvent, MutableRefObject } from 'react';
|
||||
import { useCallback } from 'react';
|
||||
import { IDatasource } from '@ag-grid-community/core';
|
||||
import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/lib/agGridReact';
|
||||
import { Flex, Group, Stack } from '@mantine/core';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import debounce from 'lodash/debounce';
|
||||
import { api } from '/@/renderer/api';
|
||||
import { queryKeys } from '/@/renderer/api/query-keys';
|
||||
import {
|
||||
PageHeader,
|
||||
Paper,
|
||||
SearchInput,
|
||||
SpinnerIcon,
|
||||
VirtualInfiniteGridRef,
|
||||
} from '/@/renderer/components';
|
||||
import { LibraryHeaderBar } from '/@/renderer/features/shared';
|
||||
import { useContainerQuery } from '/@/renderer/hooks';
|
||||
import {
|
||||
AlbumArtistListFilter,
|
||||
useAlbumArtistListStore,
|
||||
useCurrentServer,
|
||||
useSetAlbumArtistFilters,
|
||||
useSetAlbumArtistTablePagination,
|
||||
} from '/@/renderer/store';
|
||||
import { ListDisplayType } from '/@/renderer/types';
|
||||
import { AlbumArtistListHeaderFilters } from '/@/renderer/features/artists/components/album-artist-list-header-filters';
|
||||
|
||||
interface AlbumArtistListHeaderProps {
|
||||
gridRef: MutableRefObject<VirtualInfiniteGridRef | null>;
|
||||
itemCount?: number;
|
||||
tableRef: MutableRefObject<AgGridReactType | null>;
|
||||
}
|
||||
|
||||
export const AlbumArtistListHeader = ({
|
||||
itemCount,
|
||||
gridRef,
|
||||
tableRef,
|
||||
}: AlbumArtistListHeaderProps) => {
|
||||
const queryClient = useQueryClient();
|
||||
const server = useCurrentServer();
|
||||
const setFilter = useSetAlbumArtistFilters();
|
||||
const page = useAlbumArtistListStore();
|
||||
const cq = useContainerQuery();
|
||||
|
||||
const setPagination = useSetAlbumArtistTablePagination();
|
||||
|
||||
const fetch = useCallback(
|
||||
async (startIndex: number, limit: number, filters: AlbumArtistListFilter) => {
|
||||
const queryKey = queryKeys.albumArtists.list(server?.id || '', {
|
||||
limit,
|
||||
startIndex,
|
||||
...filters,
|
||||
});
|
||||
|
||||
const albums = await queryClient.fetchQuery(
|
||||
queryKey,
|
||||
async ({ signal }) =>
|
||||
api.controller.getAlbumArtistList({
|
||||
query: {
|
||||
limit,
|
||||
startIndex,
|
||||
...filters,
|
||||
},
|
||||
server,
|
||||
signal,
|
||||
}),
|
||||
{ cacheTime: 1000 * 60 * 1 },
|
||||
);
|
||||
|
||||
return api.normalize.albumArtistList(albums, server);
|
||||
},
|
||||
[queryClient, server],
|
||||
);
|
||||
|
||||
const handleFilterChange = useCallback(
|
||||
async (filters: AlbumArtistListFilter) => {
|
||||
if (
|
||||
page.display === ListDisplayType.TABLE ||
|
||||
page.display === ListDisplayType.TABLE_PAGINATED
|
||||
) {
|
||||
const dataSource: IDatasource = {
|
||||
getRows: async (params) => {
|
||||
const limit = params.endRow - params.startRow;
|
||||
const startIndex = params.startRow;
|
||||
|
||||
const queryKey = queryKeys.albumArtists.list(server?.id || '', {
|
||||
limit,
|
||||
startIndex,
|
||||
...filters,
|
||||
});
|
||||
|
||||
const albumArtistsRes = await queryClient.fetchQuery(
|
||||
queryKey,
|
||||
async ({ signal }) =>
|
||||
api.controller.getAlbumArtistList({
|
||||
query: {
|
||||
limit,
|
||||
startIndex,
|
||||
...filters,
|
||||
},
|
||||
server,
|
||||
signal,
|
||||
}),
|
||||
{ cacheTime: 1000 * 60 * 1 },
|
||||
);
|
||||
|
||||
const albumArtists = api.normalize.albumArtistList(albumArtistsRes, server);
|
||||
params.successCallback(
|
||||
albumArtists?.items || [],
|
||||
albumArtistsRes?.totalRecordCount || 0,
|
||||
);
|
||||
},
|
||||
rowCount: undefined,
|
||||
};
|
||||
tableRef.current?.api.setDatasource(dataSource);
|
||||
tableRef.current?.api.purgeInfiniteCache();
|
||||
tableRef.current?.api.ensureIndexVisible(0, 'top');
|
||||
|
||||
if (page.display === ListDisplayType.TABLE_PAGINATED) {
|
||||
setPagination({ currentPage: 0 });
|
||||
}
|
||||
} else {
|
||||
gridRef.current?.scrollTo(0);
|
||||
gridRef.current?.resetLoadMoreItemsCache();
|
||||
|
||||
// Refetching within the virtualized grid may be inconsistent due to it refetching
|
||||
// using an outdated set of filters. To avoid this, we fetch using the updated filters
|
||||
// and then set the grid's data here.
|
||||
const data = await fetch(0, 200, filters);
|
||||
|
||||
if (!data?.items) return;
|
||||
gridRef.current?.setItemData(data.items);
|
||||
}
|
||||
},
|
||||
[page.display, tableRef, setPagination, server, queryClient, gridRef, fetch],
|
||||
);
|
||||
|
||||
const handleSearch = debounce((e: ChangeEvent<HTMLInputElement>) => {
|
||||
const previousSearchTerm = page.filter.searchTerm;
|
||||
const searchTerm = e.target.value === '' ? undefined : e.target.value;
|
||||
const updatedFilters = setFilter({ searchTerm });
|
||||
if (previousSearchTerm !== searchTerm) handleFilterChange(updatedFilters);
|
||||
}, 500);
|
||||
|
||||
return (
|
||||
<Stack
|
||||
ref={cq.ref}
|
||||
spacing={0}
|
||||
>
|
||||
<PageHeader backgroundColor="var(--titlebar-bg)">
|
||||
<Flex
|
||||
justify="space-between"
|
||||
w="100%"
|
||||
>
|
||||
<LibraryHeaderBar>
|
||||
<LibraryHeaderBar.Title>Album Artists</LibraryHeaderBar.Title>
|
||||
<Paper
|
||||
fw="600"
|
||||
px="1rem"
|
||||
py="0.3rem"
|
||||
radius="sm"
|
||||
>
|
||||
{itemCount === null || itemCount === undefined ? <SpinnerIcon /> : itemCount}
|
||||
</Paper>
|
||||
</LibraryHeaderBar>
|
||||
<Group>
|
||||
<SearchInput
|
||||
defaultValue={page.filter.searchTerm}
|
||||
openedWidth={cq.isMd ? 250 : cq.isSm ? 200 : 150}
|
||||
onChange={handleSearch}
|
||||
/>
|
||||
</Group>
|
||||
</Flex>
|
||||
</PageHeader>
|
||||
<Paper p="1rem">
|
||||
<AlbumArtistListHeaderFilters
|
||||
gridRef={gridRef}
|
||||
tableRef={tableRef}
|
||||
/>
|
||||
</Paper>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user