update
Some checks failed
Pipeline: Test, Lint, Build / Get version info (push) Has been cancelled
Pipeline: Test, Lint, Build / Lint Go code (push) Has been cancelled
Pipeline: Test, Lint, Build / Test Go code (push) Has been cancelled
Pipeline: Test, Lint, Build / Test JS code (push) Has been cancelled
Pipeline: Test, Lint, Build / Lint i18n files (push) Has been cancelled
Pipeline: Test, Lint, Build / Check Docker configuration (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (darwin/amd64) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (darwin/arm64) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (linux/386) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (linux/amd64) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (linux/arm/v5) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (linux/arm/v6) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (linux/arm/v7) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (linux/arm64) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (windows/386) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (windows/amd64) (push) Has been cancelled
Pipeline: Test, Lint, Build / Push to GHCR (push) Has been cancelled
Pipeline: Test, Lint, Build / Push to Docker Hub (push) Has been cancelled
Pipeline: Test, Lint, Build / Cleanup digest artifacts (push) Has been cancelled
Pipeline: Test, Lint, Build / Build Windows installers (push) Has been cancelled
Pipeline: Test, Lint, Build / Package/Release (push) Has been cancelled
Pipeline: Test, Lint, Build / Upload Linux PKG (push) Has been cancelled
Close stale issues and PRs / stale (push) Has been cancelled
POEditor import / update-translations (push) Has been cancelled
7
ui/.eslintignore
Normal file
@@ -0,0 +1,7 @@
|
||||
node_modules/
|
||||
build/
|
||||
prettier.config.js
|
||||
.eslintrc
|
||||
vite.config.js
|
||||
public/3rdparty/workbox
|
||||
coverage/
|
||||
61
ui/.eslintrc
Normal file
@@ -0,0 +1,61 @@
|
||||
{
|
||||
"env": {
|
||||
"browser": true,
|
||||
"node": true,
|
||||
"es6": true
|
||||
},
|
||||
"extends": [
|
||||
"eslint:recommended",
|
||||
"plugin:react/recommended",
|
||||
"plugin:react-hooks/recommended",
|
||||
// "plugin:jsx-a11y/recommended",
|
||||
"eslint-config-prettier",
|
||||
"plugin:@typescript-eslint/recommended",
|
||||
],
|
||||
"parser": "@typescript-eslint/parser",
|
||||
"parserOptions": {
|
||||
"warnOnUnsupportedTypeScriptVersion": false
|
||||
},
|
||||
"settings": {
|
||||
"react": {
|
||||
"version": "detect"
|
||||
},
|
||||
"import/resolver": {
|
||||
"node": {
|
||||
"paths": [
|
||||
"src"
|
||||
],
|
||||
"extensions": [
|
||||
".js",
|
||||
".jsx",
|
||||
".ts",
|
||||
".tsx"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"plugins": ["react-refresh"],
|
||||
"rules": {
|
||||
"no-console": "error",
|
||||
// "no-unused-vars": "off",
|
||||
"@typescript-eslint/no-unused-vars": "off",
|
||||
"react-refresh/only-export-components": [
|
||||
"warn",
|
||||
{ "allowConstantExport": true }
|
||||
],
|
||||
"react/jsx-uses-react": "off",
|
||||
"react/react-in-jsx-scope": "off",
|
||||
"react/prop-types": "off",
|
||||
},
|
||||
// Fix Vitest
|
||||
"globals": {
|
||||
"describe": "readonly",
|
||||
"it": "readonly",
|
||||
"expect": "readonly",
|
||||
"vi": "readonly",
|
||||
"beforeAll": "readonly",
|
||||
"afterAll": "readonly",
|
||||
"beforeEach": "readonly",
|
||||
"afterEach": "readonly",
|
||||
}
|
||||
}
|
||||
7
ui/.gitignore
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
node_modules
|
||||
.eslintcache
|
||||
|
||||
build/*
|
||||
!build/.gitkeep
|
||||
/coverage/
|
||||
public/3rdparty/workbox
|
||||
17
ui/bin/update-workbox.sh
Executable file
@@ -0,0 +1,17 @@
|
||||
#!/usr/bin/env sh
|
||||
|
||||
set -e
|
||||
|
||||
export WORKBOX_DIR=public/3rdparty/workbox
|
||||
|
||||
rm -rf ${WORKBOX_DIR}
|
||||
workbox copyLibraries build/3rdparty/
|
||||
|
||||
mkdir -p ${WORKBOX_DIR}
|
||||
mv build/3rdparty/workbox-*/workbox-sw.js ${WORKBOX_DIR}
|
||||
mv build/3rdparty/workbox-*/workbox-core.prod.js ${WORKBOX_DIR}
|
||||
mv build/3rdparty/workbox-*/workbox-strategies.prod.js ${WORKBOX_DIR}
|
||||
mv build/3rdparty/workbox-*/workbox-routing.prod.js ${WORKBOX_DIR}
|
||||
mv build/3rdparty/workbox-*/workbox-navigation-preload.prod.js ${WORKBOX_DIR}
|
||||
mv build/3rdparty/workbox-*/workbox-precaching.prod.js ${WORKBOX_DIR}
|
||||
rm -rf build/3rdparty/workbox-*
|
||||
0
ui/build/.gitkeep
Normal file
14
ui/embed.go
Normal file
@@ -0,0 +1,14 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"io/fs"
|
||||
)
|
||||
|
||||
//go:embed build/*
|
||||
var filesystem embed.FS
|
||||
|
||||
func BuildAssets() fs.FS {
|
||||
build, _ := fs.Sub(filesystem, "build")
|
||||
return build
|
||||
}
|
||||
52
ui/index.html
Normal file
@@ -0,0 +1,52 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta
|
||||
name="description"
|
||||
content="Navidrome Music Server - {{.Version}}"
|
||||
/>
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png">
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png">
|
||||
<link rel="icon" type="image/png" sizes="192x192" href="/android-chrome-192x192.png">
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png">
|
||||
<link rel="mask-icon" href="/safari-pinned-tab.svg" color="#5b5fd5">
|
||||
<meta name="msapplication-TileColor" content="#da532c">
|
||||
<meta name="theme-color" content="#ffffff">
|
||||
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<!--
|
||||
manifest.webmanifest provides metadata used when your web app is installed on a
|
||||
user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/
|
||||
-->
|
||||
<link rel="manifest" href="./manifest.webmanifest" />
|
||||
<meta property="og:site_name" content="Navidrome">
|
||||
<meta property="og:url" content="{{ .ShareURL }}">
|
||||
<meta property="og:title" content="{{ .ShareDescription }}">
|
||||
<meta property="og:image" content="{{ .ShareImageURL }}">
|
||||
<meta property="og:image:width" content="300">
|
||||
<meta property="og:image:height" content="300">
|
||||
<title>Navidrome</title>
|
||||
<script>
|
||||
window.__APP_CONFIG__ = {{ .AppConfig }}
|
||||
</script>
|
||||
<script>
|
||||
window.__SHARE_INFO__ = {{ .ShareInfo }}
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||
<div id="root"></div>
|
||||
<!--
|
||||
This HTML file is a template.
|
||||
If you open it directly in the browser, you will see an empty page.
|
||||
|
||||
You can add webfonts, meta tags, or analytics to this file.
|
||||
The build step will place the bundled scripts into the <body> tag.
|
||||
|
||||
To begin the development, run `npm start` or `yarn start`.
|
||||
To create a production bundle, use `npm run build` or `yarn build`.
|
||||
-->
|
||||
</body>
|
||||
<script type="module" src="/src/index.jsx"></script>
|
||||
</html>
|
||||
11718
ui/package-lock.json
generated
Normal file
85
ui/package.json
Normal file
@@ -0,0 +1,85 @@
|
||||
{
|
||||
"name": "ui",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"start": "vite",
|
||||
"build": "vite build",
|
||||
"serve": "vite preview",
|
||||
"test:watch": "vitest",
|
||||
"test": "vitest --watch=false",
|
||||
"test:coverage": "vitest run --coverage --watch=false",
|
||||
"type-check": "tsc --noEmit",
|
||||
"lint": "eslint . --ext ts,tsx,js,jsx --report-unused-disable-directives --max-warnings 0",
|
||||
"prettier": "prettier --write ./src",
|
||||
"check-formatting": "prettier -c ./src",
|
||||
"postinstall": "bin/update-workbox.sh"
|
||||
},
|
||||
"dependencies": {
|
||||
"@material-ui/core": "^4.12.4",
|
||||
"@material-ui/icons": "^4.11.3",
|
||||
"@material-ui/lab": "^4.0.0-alpha.61",
|
||||
"@material-ui/styles": "^4.11.5",
|
||||
"blueimp-md5": "^2.19.0",
|
||||
"clsx": "^2.1.1",
|
||||
"connected-react-router": "^6.9.3",
|
||||
"deepmerge": "^4.3.1",
|
||||
"history": "^4.10.1",
|
||||
"inflection": "^3.0.2",
|
||||
"jwt-decode": "^4.0.0",
|
||||
"lodash.throttle": "^4.1.1",
|
||||
"navidrome-music-player": "4.25.1",
|
||||
"prop-types": "^15.8.1",
|
||||
"ra-data-json-server": "^3.19.12",
|
||||
"ra-i18n-polyglot": "^3.19.12",
|
||||
"react": "^17.0.2",
|
||||
"react-admin": "^3.19.12",
|
||||
"react-dnd": "^14.0.5",
|
||||
"react-dnd-html5-backend": "^14.1.0",
|
||||
"react-dom": "^17.0.2",
|
||||
"react-drag-listview": "^0.1.9",
|
||||
"react-ga": "^3.3.1",
|
||||
"react-hotkeys": "^2.0.0",
|
||||
"react-icons": "^5.5.0",
|
||||
"react-image-lightbox": "^5.1.4",
|
||||
"react-measure": "^2.5.2",
|
||||
"react-redux": "^7.2.9",
|
||||
"react-router-dom": "^5.3.4",
|
||||
"redux": "^4.2.1",
|
||||
"redux-saga": "^1.4.2",
|
||||
"uuid": "^13.0.0",
|
||||
"workbox-cli": "^7.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@testing-library/jest-dom": "^6.6.3",
|
||||
"@testing-library/react": "^12.1.5",
|
||||
"@testing-library/react-hooks": "^7.0.2",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/node": "^24.9.1",
|
||||
"@types/react": "^17.0.89",
|
||||
"@types/react-dom": "^17.0.26",
|
||||
"@typescript-eslint/eslint-plugin": "^6.21.0",
|
||||
"@typescript-eslint/parser": "^6.21.0",
|
||||
"@vitejs/plugin-react": "^5.1.0",
|
||||
"@vitest/coverage-v8": "^4.0.3",
|
||||
"eslint": "^8.57.1",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-plugin-jsx-a11y": "^6.10.2",
|
||||
"eslint-plugin-react": "^7.37.5",
|
||||
"eslint-plugin-react-hooks": "^5.2.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.24",
|
||||
"happy-dom": "^20.0.8",
|
||||
"jsdom": "^26.1.0",
|
||||
"prettier": "^3.6.2",
|
||||
"ra-test": "^3.19.12",
|
||||
"typescript": "^5.8.3",
|
||||
"vite": "^7.1.12",
|
||||
"vite-plugin-pwa": "^1.1.0",
|
||||
"vitest": "^4.0.3"
|
||||
},
|
||||
"overrides": {
|
||||
"vite": {
|
||||
"rollup": "npm:@rollup/wasm-node"
|
||||
}
|
||||
}
|
||||
}
|
||||
5
ui/prettier.config.js
Normal file
@@ -0,0 +1,5 @@
|
||||
export default {
|
||||
singleQuote: true,
|
||||
semi: false,
|
||||
arrowParens: "always",
|
||||
};
|
||||
0
ui/public/.gitkeep
Normal file
BIN
ui/public/android-chrome-192x192.png
Normal file
|
After Width: | Height: | Size: 9.6 KiB |
BIN
ui/public/android-chrome-512x512.png
Normal file
|
After Width: | Height: | Size: 30 KiB |
BIN
ui/public/apple-touch-icon-120x120.png
Normal file
|
After Width: | Height: | Size: 5.1 KiB |
BIN
ui/public/apple-touch-icon-152x152.png
Normal file
|
After Width: | Height: | Size: 6.6 KiB |
BIN
ui/public/apple-touch-icon-180x180.png
Normal file
|
After Width: | Height: | Size: 8.0 KiB |
BIN
ui/public/apple-touch-icon-60x60.png
Normal file
|
After Width: | Height: | Size: 2.5 KiB |
BIN
ui/public/apple-touch-icon-76x76.png
Normal file
|
After Width: | Height: | Size: 3.2 KiB |
BIN
ui/public/apple-touch-icon.png
Normal file
|
After Width: | Height: | Size: 8.0 KiB |
9
ui/public/browserconfig.xml
Normal file
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<browserconfig>
|
||||
<msapplication>
|
||||
<tile>
|
||||
<square150x150logo src="/mstile-150x150.png"/>
|
||||
<TileColor>#da532c</TileColor>
|
||||
</tile>
|
||||
</msapplication>
|
||||
</browserconfig>
|
||||
BIN
ui/public/favicon-16x16.png
Normal file
|
After Width: | Height: | Size: 1002 B |
BIN
ui/public/favicon-32x32.png
Normal file
|
After Width: | Height: | Size: 1.8 KiB |
BIN
ui/public/favicon.ico
Normal file
|
After Width: | Height: | Size: 15 KiB |
1
ui/public/internet-radio-icon.svg
Normal file
|
After Width: | Height: | Size: 8.8 KiB |
BIN
ui/public/mstile-144x144.png
Normal file
|
After Width: | Height: | Size: 6.8 KiB |
BIN
ui/public/mstile-150x150.png
Normal file
|
After Width: | Height: | Size: 7.2 KiB |
BIN
ui/public/mstile-310x150.png
Normal file
|
After Width: | Height: | Size: 8.1 KiB |
BIN
ui/public/mstile-310x310.png
Normal file
|
After Width: | Height: | Size: 16 KiB |
BIN
ui/public/mstile-70x70.png
Normal file
|
After Width: | Height: | Size: 4.7 KiB |
10
ui/public/offline.html
Normal file
@@ -0,0 +1,10 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head><title>Navidrome</title></head>
|
||||
<body style="margin:0">
|
||||
<p id="errorMessageDescription" style="text-align:center;font-size:21px;font-family:arial;margin-top:28px">
|
||||
It looks like we are having trouble connecting.
|
||||
<br/>
|
||||
Please check your internet connection and try again.</p>
|
||||
</body>
|
||||
</html>
|
||||
2
ui/public/robots.txt
Normal file
@@ -0,0 +1,2 @@
|
||||
User-agent: *
|
||||
Disallow: /
|
||||
60
ui/public/safari-pinned-tab.svg
Normal file
@@ -0,0 +1,60 @@
|
||||
<?xml version="1.0" standalone="no"?>
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 20010904//EN"
|
||||
"http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd">
|
||||
<svg version="1.0" xmlns="http://www.w3.org/2000/svg"
|
||||
width="700.000000pt" height="700.000000pt" viewBox="0 0 700.000000 700.000000"
|
||||
preserveAspectRatio="xMidYMid meet">
|
||||
<metadata>
|
||||
Created by potrace 1.11, written by Peter Selinger 2001-2013
|
||||
</metadata>
|
||||
<g transform="translate(0.000000,700.000000) scale(0.100000,-0.100000)"
|
||||
fill="#000000" stroke="none">
|
||||
<path d="M3225 6988 c-973 -78 -1852 -545 -2463 -1308 -833 -1041 -996 -2480
|
||||
-418 -3695 329 -691 917 -1285 1611 -1625 373 -183 748 -291 1190 -342 142
|
||||
-16 568 -16 710 0 628 73 1164 273 1659 620 162 114 334 262 491 423 417 428
|
||||
695 906 860 1479 91 317 129 599 129 960 0 332 -27 560 -100 855 -277 1108
|
||||
-1095 2023 -2164 2423 -473 176 -1004 251 -1505 210z m530 -289 c873 -75 1651
|
||||
-478 2209 -1145 396 -473 639 -1031 723 -1662 24 -181 24 -608 -1 -787 -83
|
||||
-604 -293 -1109 -662 -1585 -110 -142 -372 -407 -519 -525 -467 -373 -997
|
||||
-597 -1610 -681 -184 -25 -608 -25 -790 0 -521 72 -956 233 -1375 511 -185
|
||||
122 -326 239 -505 419 -511 513 -814 1133 -912 1863 -24 178 -24 608 0 785 81
|
||||
608 305 1136 681 1613 102 129 372 399 503 502 503 399 1085 631 1728 692 131
|
||||
12 391 12 530 0z"/>
|
||||
<path d="M3255 6279 c-624 -54 -1227 -328 -1682 -763 -398 -380 -683 -887
|
||||
-798 -1419 -16 -73 -16 -81 0 -122 31 -83 122 -117 201 -77 51 26 67 58 104
|
||||
209 84 342 252 679 477 958 101 126 309 328 435 423 406 305 867 473 1383 503
|
||||
195 11 217 19 253 91 18 35 22 55 16 83 -8 42 -44 89 -85 110 -31 17 -139 18
|
||||
-304 4z"/>
|
||||
<path d="M3320 5639 c-526 -45 -1008 -277 -1376 -661 -362 -379 -576 -891
|
||||
-587 -1401 -2 -137 3 -154 66 -201 37 -27 117 -27 154 0 59 44 65 62 74 212
|
||||
17 292 75 508 205 762 93 180 177 294 339 455 158 158 273 245 445 334 256
|
||||
133 478 193 772 210 114 7 144 12 166 28 48 36 66 69 66 123 0 53 -18 87 -66
|
||||
123 -32 24 -113 29 -258 16z"/>
|
||||
<path d="M3363 4840 c-313 -35 -592 -170 -813 -390 -190 -190 -314 -420 -371
|
||||
-693 -31 -143 -31 -371 0 -514 57 -273 179 -500 370 -692 255 -257 587 -394
|
||||
951 -394 491 0 922 252 1173 685 189 327 225 740 96 1105 -135 382 -440 687
|
||||
-822 822 -176 62 -413 91 -584 71z m265 -290 c235 -30 448 -134 618 -304 363
|
||||
-363 414 -920 123 -1347 -56 -83 -187 -214 -267 -268 -96 -65 -236 -128 -347
|
||||
-157 -87 -23 -119 -27 -255 -27 -136 0 -168 4 -255 27 -188 49 -356 145 -490
|
||||
280 -358 360 -414 901 -139 1324 218 335 617 521 1012 472z"/>
|
||||
<path d="M3405 3886 c-201 -49 -333 -251 -296 -452 31 -172 154 -294 331 -326
|
||||
257 -48 499 197 451 458 -23 124 -95 227 -198 281 -96 51 -189 63 -288 39z
|
||||
m174 -307 c26 -26 31 -38 31 -79 0 -41 -5 -53 -31 -79 -26 -26 -38 -31 -79
|
||||
-31 -41 0 -53 5 -79 31 -26 26 -31 38 -31 79 0 41 5 53 31 79 26 26 38 31 79
|
||||
31 41 0 53 -5 79 -31z"/>
|
||||
<path d="M792 3693 c-53 -26 -76 -68 -80 -145 -7 -116 49 -191 142 -191 53 0
|
||||
102 26 126 66 16 25 20 50 20 114 0 73 -3 85 -26 113 -51 59 -117 75 -182 43z"/>
|
||||
<path d="M5454 3639 c-18 -5 -46 -25 -62 -44 -34 -38 -38 -58 -47 -229 -20
|
||||
-409 -207 -829 -510 -1145 -331 -346 -759 -542 -1244 -570 -153 -9 -171 -15
|
||||
-215 -74 -27 -37 -27 -117 0 -154 46 -61 66 -68 187 -66 695 12 1351 382 1744
|
||||
985 140 214 244 473 298 743 31 155 50 398 36 452 -13 47 -55 90 -102 103 -39
|
||||
11 -43 11 -85 -1z"/>
|
||||
<path d="M6083 3631 c-64 -30 -83 -69 -83 -168 0 -73 3 -85 26 -113 38 -45 79
|
||||
-63 130 -57 87 9 134 71 134 177 0 134 -99 211 -207 161z"/>
|
||||
<path d="M6019 3101 c-47 -26 -63 -61 -103 -221 -101 -408 -313 -788 -611
|
||||
-1098 -320 -334 -692 -557 -1143 -686 -159 -46 -333 -74 -536 -87 -196 -12
|
||||
-218 -20 -254 -89 -17 -33 -21 -54 -17 -80 17 -88 75 -130 181 -130 197 0 510
|
||||
46 712 105 495 142 879 369 1233 725 371 374 631 849 744 1363 16 74 16 81 0
|
||||
122 -9 25 -30 54 -47 66 -42 31 -113 35 -159 10z"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.6 KiB |
172
ui/src/App.jsx
Normal file
@@ -0,0 +1,172 @@
|
||||
import ReactGA from 'react-ga'
|
||||
import { Provider } from 'react-redux'
|
||||
import { createHashHistory } from 'history'
|
||||
import { Admin as RAAdmin, Resource } from 'react-admin'
|
||||
import { HotKeys } from 'react-hotkeys'
|
||||
import dataProvider from './dataProvider'
|
||||
import authProvider from './authProvider'
|
||||
import { Layout, Login, Logout } from './layout'
|
||||
import transcoding from './transcoding'
|
||||
import player from './player'
|
||||
import user from './user'
|
||||
import song from './song'
|
||||
import album from './album'
|
||||
import artist from './artist'
|
||||
import playlist from './playlist'
|
||||
import radio from './radio'
|
||||
import share from './share'
|
||||
import library from './library'
|
||||
import { Player } from './audioplayer'
|
||||
import customRoutes from './routes'
|
||||
import {
|
||||
libraryReducer,
|
||||
themeReducer,
|
||||
addToPlaylistDialogReducer,
|
||||
expandInfoDialogReducer,
|
||||
listenBrainzTokenDialogReducer,
|
||||
saveQueueDialogReducer,
|
||||
playerReducer,
|
||||
albumViewReducer,
|
||||
activityReducer,
|
||||
settingsReducer,
|
||||
replayGainReducer,
|
||||
downloadMenuDialogReducer,
|
||||
shareDialogReducer,
|
||||
} from './reducers'
|
||||
import createAdminStore from './store/createAdminStore'
|
||||
import { i18nProvider } from './i18n'
|
||||
import config, { shareInfo } from './config'
|
||||
import { keyMap } from './hotkeys'
|
||||
import useChangeThemeColor from './useChangeThemeColor'
|
||||
import SharePlayer from './share/SharePlayer'
|
||||
import { HTML5Backend } from 'react-dnd-html5-backend'
|
||||
import { DndProvider } from 'react-dnd'
|
||||
import missing from './missing/index.js'
|
||||
|
||||
const history = createHashHistory()
|
||||
|
||||
if (config.gaTrackingId) {
|
||||
ReactGA.initialize(config.gaTrackingId)
|
||||
history.listen((location) => {
|
||||
ReactGA.pageview(location.pathname)
|
||||
})
|
||||
ReactGA.pageview(window.location.pathname)
|
||||
}
|
||||
|
||||
const adminStore = createAdminStore({
|
||||
authProvider,
|
||||
dataProvider,
|
||||
history,
|
||||
customReducers: {
|
||||
library: libraryReducer,
|
||||
player: playerReducer,
|
||||
albumView: albumViewReducer,
|
||||
theme: themeReducer,
|
||||
addToPlaylistDialog: addToPlaylistDialogReducer,
|
||||
downloadMenuDialog: downloadMenuDialogReducer,
|
||||
expandInfoDialog: expandInfoDialogReducer,
|
||||
listenBrainzTokenDialog: listenBrainzTokenDialogReducer,
|
||||
saveQueueDialog: saveQueueDialogReducer,
|
||||
shareDialog: shareDialogReducer,
|
||||
activity: activityReducer,
|
||||
settings: settingsReducer,
|
||||
replayGain: replayGainReducer,
|
||||
},
|
||||
})
|
||||
|
||||
const App = () => (
|
||||
<Provider store={adminStore}>
|
||||
<Admin />
|
||||
</Provider>
|
||||
)
|
||||
|
||||
const Admin = (props) => {
|
||||
useChangeThemeColor()
|
||||
/* eslint-disable react/jsx-key */
|
||||
return (
|
||||
<RAAdmin
|
||||
disableTelemetry
|
||||
dataProvider={dataProvider}
|
||||
authProvider={authProvider}
|
||||
i18nProvider={i18nProvider}
|
||||
customRoutes={customRoutes}
|
||||
history={history}
|
||||
layout={Layout}
|
||||
loginPage={Login}
|
||||
logoutButton={Logout}
|
||||
{...props}
|
||||
>
|
||||
{(permissions) => [
|
||||
<Resource name="album" {...album} options={{ subMenu: 'albumList' }} />,
|
||||
<Resource name="artist" {...artist} />,
|
||||
<Resource name="song" {...song} />,
|
||||
<Resource
|
||||
name="radio"
|
||||
{...(permissions === 'admin' ? radio.admin : radio.all)}
|
||||
/>,
|
||||
config.enableSharing && <Resource name="share" {...share} />,
|
||||
<Resource
|
||||
name="playlist"
|
||||
{...playlist}
|
||||
options={{ subMenu: 'playlist' }}
|
||||
/>,
|
||||
<Resource name="user" {...user} options={{ subMenu: 'settings' }} />,
|
||||
<Resource
|
||||
name="player"
|
||||
{...player}
|
||||
options={{ subMenu: 'settings' }}
|
||||
/>,
|
||||
permissions === 'admin' ? (
|
||||
<Resource
|
||||
name="transcoding"
|
||||
{...transcoding}
|
||||
options={{ subMenu: 'settings' }}
|
||||
/>
|
||||
) : (
|
||||
<Resource name="transcoding" />
|
||||
),
|
||||
permissions === 'admin' ? (
|
||||
<Resource
|
||||
name="library"
|
||||
{...library}
|
||||
options={{ subMenu: 'settings' }}
|
||||
/>
|
||||
) : null,
|
||||
permissions === 'admin' ? (
|
||||
<Resource
|
||||
name="missing"
|
||||
{...missing}
|
||||
options={{ subMenu: 'settings' }}
|
||||
/>
|
||||
) : null,
|
||||
|
||||
<Resource name="translation" />,
|
||||
<Resource name="genre" />,
|
||||
<Resource name="tag" />,
|
||||
<Resource name="playlistTrack" />,
|
||||
<Resource name="keepalive" />,
|
||||
<Resource name="insights" />,
|
||||
<Resource name="config" />,
|
||||
<Player />,
|
||||
]}
|
||||
</RAAdmin>
|
||||
)
|
||||
/* eslint-enable react/jsx-key */
|
||||
}
|
||||
|
||||
const AppWithHotkeys = () => {
|
||||
let language = localStorage.getItem('locale') || 'en'
|
||||
document.documentElement.lang = language
|
||||
if (config.enableSharing && shareInfo) {
|
||||
return <SharePlayer />
|
||||
}
|
||||
return (
|
||||
<HotKeys keyMap={keyMap}>
|
||||
<DndProvider backend={HTML5Backend}>
|
||||
<App />
|
||||
</DndProvider>
|
||||
</HotKeys>
|
||||
)
|
||||
}
|
||||
|
||||
export default AppWithHotkeys
|
||||
6
ui/src/actions/albumView.js
Normal file
@@ -0,0 +1,6 @@
|
||||
export const ALBUM_MODE_GRID = 'ALBUM_GRID_MODE'
|
||||
export const ALBUM_MODE_TABLE = 'ALBUM_TABLE_MODE'
|
||||
|
||||
export const albumViewGrid = () => ({ type: ALBUM_MODE_GRID })
|
||||
|
||||
export const albumViewTable = () => ({ type: ALBUM_MODE_TABLE })
|
||||
88
ui/src/actions/dialogs.js
Normal file
@@ -0,0 +1,88 @@
|
||||
export const ADD_TO_PLAYLIST_OPEN = 'ADD_TO_PLAYLIST_OPEN'
|
||||
export const ADD_TO_PLAYLIST_CLOSE = 'ADD_TO_PLAYLIST_CLOSE'
|
||||
export const DOWNLOAD_MENU_OPEN = 'DOWNLOAD_MENU_OPEN'
|
||||
export const DOWNLOAD_MENU_CLOSE = 'DOWNLOAD_MENU_CLOSE'
|
||||
export const DUPLICATE_SONG_WARNING_OPEN = 'DUPLICATE_SONG_WARNING_OPEN'
|
||||
export const DUPLICATE_SONG_WARNING_CLOSE = 'DUPLICATE_SONG_WARNING_CLOSE'
|
||||
export const EXTENDED_INFO_OPEN = 'EXTENDED_INFO_OPEN'
|
||||
export const EXTENDED_INFO_CLOSE = 'EXTENDED_INFO_CLOSE'
|
||||
export const LISTENBRAINZ_TOKEN_OPEN = 'LISTENBRAINZ_TOKEN_OPEN'
|
||||
export const LISTENBRAINZ_TOKEN_CLOSE = 'LISTENBRAINZ_TOKEN_CLOSE'
|
||||
export const SAVE_QUEUE_OPEN = 'SAVE_QUEUE_OPEN'
|
||||
export const SAVE_QUEUE_CLOSE = 'SAVE_QUEUE_CLOSE'
|
||||
export const DOWNLOAD_MENU_ALBUM = 'album'
|
||||
export const DOWNLOAD_MENU_ARTIST = 'artist'
|
||||
export const DOWNLOAD_MENU_PLAY = 'playlist'
|
||||
export const DOWNLOAD_MENU_SONG = 'song'
|
||||
export const SHARE_MENU_OPEN = 'SHARE_MENU_OPEN'
|
||||
export const SHARE_MENU_CLOSE = 'SHARE_MENU_CLOSE'
|
||||
|
||||
export const openShareMenu = (ids, resource, name, label) => ({
|
||||
type: SHARE_MENU_OPEN,
|
||||
ids,
|
||||
resource,
|
||||
name,
|
||||
label,
|
||||
})
|
||||
|
||||
export const closeShareMenu = () => ({
|
||||
type: SHARE_MENU_CLOSE,
|
||||
})
|
||||
|
||||
export const openAddToPlaylist = ({ selectedIds, onSuccess }) => ({
|
||||
type: ADD_TO_PLAYLIST_OPEN,
|
||||
selectedIds,
|
||||
onSuccess,
|
||||
})
|
||||
|
||||
export const closeAddToPlaylist = () => ({
|
||||
type: ADD_TO_PLAYLIST_CLOSE,
|
||||
})
|
||||
|
||||
export const openDownloadMenu = (record, recordType) => {
|
||||
return {
|
||||
type: DOWNLOAD_MENU_OPEN,
|
||||
recordType,
|
||||
record,
|
||||
}
|
||||
}
|
||||
|
||||
export const closeDownloadMenu = () => ({
|
||||
type: DOWNLOAD_MENU_CLOSE,
|
||||
})
|
||||
|
||||
export const openDuplicateSongWarning = (duplicateIds) => ({
|
||||
type: DUPLICATE_SONG_WARNING_OPEN,
|
||||
duplicateIds,
|
||||
})
|
||||
|
||||
export const closeDuplicateSongDialog = () => ({
|
||||
type: DUPLICATE_SONG_WARNING_CLOSE,
|
||||
})
|
||||
|
||||
export const openExtendedInfoDialog = (record) => {
|
||||
return {
|
||||
type: EXTENDED_INFO_OPEN,
|
||||
record,
|
||||
}
|
||||
}
|
||||
|
||||
export const closeExtendedInfoDialog = () => ({
|
||||
type: EXTENDED_INFO_CLOSE,
|
||||
})
|
||||
|
||||
export const openListenBrainzTokenDialog = () => ({
|
||||
type: LISTENBRAINZ_TOKEN_OPEN,
|
||||
})
|
||||
|
||||
export const closeListenBrainzTokenDialog = () => ({
|
||||
type: LISTENBRAINZ_TOKEN_CLOSE,
|
||||
})
|
||||
|
||||
export const openSaveQueueDialog = () => ({
|
||||
type: SAVE_QUEUE_OPEN,
|
||||
})
|
||||
|
||||
export const closeSaveQueueDialog = () => ({
|
||||
type: SAVE_QUEUE_CLOSE,
|
||||
})
|
||||
8
ui/src/actions/index.js
Normal file
@@ -0,0 +1,8 @@
|
||||
export * from './library'
|
||||
export * from './player'
|
||||
export * from './themes'
|
||||
export * from './albumView'
|
||||
export * from './dialogs'
|
||||
export * from './replayGain'
|
||||
export * from './serverEvents'
|
||||
export * from './settings'
|
||||
12
ui/src/actions/library.js
Normal file
@@ -0,0 +1,12 @@
|
||||
export const SET_SELECTED_LIBRARIES = 'SET_SELECTED_LIBRARIES'
|
||||
export const SET_USER_LIBRARIES = 'SET_USER_LIBRARIES'
|
||||
|
||||
export const setSelectedLibraries = (libraryIds) => ({
|
||||
type: SET_SELECTED_LIBRARIES,
|
||||
data: libraryIds,
|
||||
})
|
||||
|
||||
export const setUserLibraries = (libraries) => ({
|
||||
type: SET_USER_LIBRARIES,
|
||||
data: libraries,
|
||||
})
|
||||
104
ui/src/actions/player.js
Normal file
@@ -0,0 +1,104 @@
|
||||
export const PLAYER_ADD_TRACKS = 'PLAYER_ADD_TRACKS'
|
||||
export const PLAYER_PLAY_NEXT = 'PLAYER_PLAY_NEXT'
|
||||
export const PLAYER_SET_TRACK = 'PLAYER_SET_TRACK'
|
||||
export const PLAYER_SYNC_QUEUE = 'PLAYER_SYNC_QUEUE'
|
||||
export const PLAYER_CLEAR_QUEUE = 'PLAYER_CLEAR_QUEUE'
|
||||
export const PLAYER_PLAY_TRACKS = 'PLAYER_PLAY_TRACKS'
|
||||
export const PLAYER_CURRENT = 'PLAYER_CURRENT'
|
||||
export const PLAYER_SET_VOLUME = 'PLAYER_SET_VOLUME'
|
||||
export const PLAYER_SET_MODE = 'PLAYER_SET_MODE'
|
||||
|
||||
export const setTrack = (data) => ({
|
||||
type: PLAYER_SET_TRACK,
|
||||
data,
|
||||
})
|
||||
|
||||
export const filterSongs = (data, ids) => {
|
||||
const filteredData = Object.fromEntries(
|
||||
Object.entries(data).filter(([_, song]) => !song.missing),
|
||||
)
|
||||
return !ids
|
||||
? filteredData
|
||||
: ids.reduce((acc, id) => {
|
||||
if (filteredData[id]) {
|
||||
return { ...acc, [id]: filteredData[id] }
|
||||
}
|
||||
return acc
|
||||
}, {})
|
||||
}
|
||||
|
||||
export const addTracks = (data, ids) => {
|
||||
const songs = filterSongs(data, ids)
|
||||
return {
|
||||
type: PLAYER_ADD_TRACKS,
|
||||
data: songs,
|
||||
}
|
||||
}
|
||||
|
||||
export const playNext = (data, ids) => {
|
||||
const songs = filterSongs(data, ids)
|
||||
return {
|
||||
type: PLAYER_PLAY_NEXT,
|
||||
data: songs,
|
||||
}
|
||||
}
|
||||
|
||||
export const shuffle = (data) => {
|
||||
const ids = Object.keys(data)
|
||||
for (let i = ids.length - 1; i > 0; i--) {
|
||||
let j = Math.floor(Math.random() * (i + 1))
|
||||
;[ids[i], ids[j]] = [ids[j], ids[i]]
|
||||
}
|
||||
const shuffled = {}
|
||||
// The "_" is to force the object key to be a string, so it keeps the order when adding to object
|
||||
// or else the keys will always be in the same (numerically) order
|
||||
ids.forEach((id) => (shuffled['_' + id] = data[id]))
|
||||
return shuffled
|
||||
}
|
||||
|
||||
export const shuffleTracks = (data, ids) => {
|
||||
const songs = filterSongs(data, ids)
|
||||
const shuffled = shuffle(songs)
|
||||
const firstId = Object.keys(shuffled)[0]
|
||||
return {
|
||||
type: PLAYER_PLAY_TRACKS,
|
||||
id: firstId,
|
||||
data: shuffled,
|
||||
}
|
||||
}
|
||||
|
||||
export const playTracks = (data, ids, selectedId) => {
|
||||
const songs = filterSongs(data, ids)
|
||||
return {
|
||||
type: PLAYER_PLAY_TRACKS,
|
||||
id: selectedId || Object.keys(songs)[0],
|
||||
data: songs,
|
||||
}
|
||||
}
|
||||
|
||||
export const syncQueue = (audioInfo, audioLists) => ({
|
||||
type: PLAYER_SYNC_QUEUE,
|
||||
data: {
|
||||
audioInfo,
|
||||
audioLists,
|
||||
},
|
||||
})
|
||||
|
||||
export const clearQueue = () => ({
|
||||
type: PLAYER_CLEAR_QUEUE,
|
||||
})
|
||||
|
||||
export const currentPlaying = (audioInfo) => ({
|
||||
type: PLAYER_CURRENT,
|
||||
data: audioInfo,
|
||||
})
|
||||
|
||||
export const setVolume = (volume) => ({
|
||||
type: PLAYER_SET_VOLUME,
|
||||
data: { volume },
|
||||
})
|
||||
|
||||
export const setPlayMode = (mode) => ({
|
||||
type: PLAYER_SET_MODE,
|
||||
data: { mode },
|
||||
})
|
||||
12
ui/src/actions/replayGain.js
Normal file
@@ -0,0 +1,12 @@
|
||||
export const CHANGE_GAIN = 'CHANGE_GAIN'
|
||||
export const CHANGE_PREAMP = 'CHANGE_PREAMP'
|
||||
|
||||
export const changeGain = (gain) => ({
|
||||
type: CHANGE_GAIN,
|
||||
payload: gain,
|
||||
})
|
||||
|
||||
export const changePreamp = (preamp) => ({
|
||||
type: CHANGE_PREAMP,
|
||||
payload: preamp,
|
||||
})
|
||||
29
ui/src/actions/serverEvents.js
Normal file
@@ -0,0 +1,29 @@
|
||||
export const EVENT_SCAN_STATUS = 'scanStatus'
|
||||
export const EVENT_SERVER_START = 'serverStart'
|
||||
export const EVENT_REFRESH_RESOURCE = 'refreshResource'
|
||||
export const EVENT_NOW_PLAYING_COUNT = 'nowPlayingCount'
|
||||
export const EVENT_STREAM_RECONNECTED = 'streamReconnected'
|
||||
|
||||
export const processEvent = (type, data) => ({
|
||||
type,
|
||||
data: data,
|
||||
})
|
||||
export const scanStatusUpdate = (data) => ({
|
||||
type: EVENT_SCAN_STATUS,
|
||||
data: data,
|
||||
})
|
||||
|
||||
export const nowPlayingCountUpdate = (data) => ({
|
||||
type: EVENT_NOW_PLAYING_COUNT,
|
||||
data: data,
|
||||
})
|
||||
|
||||
export const serverDown = () => ({
|
||||
type: EVENT_SERVER_START,
|
||||
data: {},
|
||||
})
|
||||
|
||||
export const streamReconnected = () => ({
|
||||
type: EVENT_STREAM_RECONNECTED,
|
||||
data: {},
|
||||
})
|
||||
18
ui/src/actions/settings.js
Normal file
@@ -0,0 +1,18 @@
|
||||
export const SET_NOTIFICATIONS_STATE = 'SET_NOTIFICATIONS_STATE'
|
||||
export const SET_TOGGLEABLE_FIELDS = 'SET_TOGGLEABLE_FIELDS'
|
||||
export const SET_OMITTED_FIELDS = 'SET_OMITTED_FIELDS'
|
||||
|
||||
export const setNotificationsState = (enabled) => ({
|
||||
type: SET_NOTIFICATIONS_STATE,
|
||||
data: enabled,
|
||||
})
|
||||
|
||||
export const setToggleableFields = (obj) => ({
|
||||
type: SET_TOGGLEABLE_FIELDS,
|
||||
data: obj,
|
||||
})
|
||||
|
||||
export const setOmittedFields = (obj) => ({
|
||||
type: SET_OMITTED_FIELDS,
|
||||
data: obj,
|
||||
})
|
||||
6
ui/src/actions/themes.js
Normal file
@@ -0,0 +1,6 @@
|
||||
export const CHANGE_THEME = 'CHANGE_THEME'
|
||||
|
||||
export const changeTheme = (theme) => ({
|
||||
type: CHANGE_THEME,
|
||||
payload: theme,
|
||||
})
|
||||
158
ui/src/album/AlbumActions.jsx
Normal file
@@ -0,0 +1,158 @@
|
||||
import React from 'react'
|
||||
import PropTypes from 'prop-types'
|
||||
import { useDispatch } from 'react-redux'
|
||||
import {
|
||||
Button,
|
||||
sanitizeListRestProps,
|
||||
TopToolbar,
|
||||
useRecordContext,
|
||||
useTranslate,
|
||||
} from 'react-admin'
|
||||
import { useMediaQuery, makeStyles } from '@material-ui/core'
|
||||
import PlayArrowIcon from '@material-ui/icons/PlayArrow'
|
||||
import ShuffleIcon from '@material-ui/icons/Shuffle'
|
||||
import CloudDownloadOutlinedIcon from '@material-ui/icons/CloudDownloadOutlined'
|
||||
import { RiPlayListAddFill, RiPlayList2Fill } from 'react-icons/ri'
|
||||
import PlaylistAddIcon from '@material-ui/icons/PlaylistAdd'
|
||||
import ShareIcon from '@material-ui/icons/Share'
|
||||
import {
|
||||
playNext,
|
||||
addTracks,
|
||||
playTracks,
|
||||
shuffleTracks,
|
||||
openAddToPlaylist,
|
||||
openDownloadMenu,
|
||||
DOWNLOAD_MENU_ALBUM,
|
||||
openShareMenu,
|
||||
} from '../actions'
|
||||
import { formatBytes } from '../utils'
|
||||
import config from '../config'
|
||||
import { ToggleFieldsMenu } from '../common'
|
||||
|
||||
const useStyles = makeStyles({
|
||||
toolbar: { display: 'flex', justifyContent: 'space-between', width: '100%' },
|
||||
})
|
||||
|
||||
const AlbumButton = ({ children, ...rest }) => {
|
||||
const record = useRecordContext(rest) || {}
|
||||
return (
|
||||
<Button {...rest} disabled={record.missing}>
|
||||
{children}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
const AlbumActions = ({
|
||||
className,
|
||||
ids,
|
||||
data,
|
||||
record,
|
||||
permanentFilter,
|
||||
...rest
|
||||
}) => {
|
||||
const dispatch = useDispatch()
|
||||
const translate = useTranslate()
|
||||
const classes = useStyles()
|
||||
const isDesktop = useMediaQuery((theme) => theme.breakpoints.up('md'))
|
||||
const isNotSmall = useMediaQuery((theme) => theme.breakpoints.up('sm'))
|
||||
|
||||
const handlePlay = React.useCallback(() => {
|
||||
dispatch(playTracks(data, ids))
|
||||
}, [dispatch, data, ids])
|
||||
|
||||
const handlePlayNext = React.useCallback(() => {
|
||||
dispatch(playNext(data, ids))
|
||||
}, [dispatch, data, ids])
|
||||
|
||||
const handlePlayLater = React.useCallback(() => {
|
||||
dispatch(addTracks(data, ids))
|
||||
}, [dispatch, data, ids])
|
||||
|
||||
const handleShuffle = React.useCallback(() => {
|
||||
dispatch(shuffleTracks(data, ids))
|
||||
}, [dispatch, data, ids])
|
||||
|
||||
const handleAddToPlaylist = React.useCallback(() => {
|
||||
const selectedIds = ids.filter((id) => !data[id].missing)
|
||||
dispatch(openAddToPlaylist({ selectedIds }))
|
||||
}, [dispatch, data, ids])
|
||||
|
||||
const handleShare = React.useCallback(() => {
|
||||
dispatch(openShareMenu([record.id], 'album', record.name))
|
||||
}, [dispatch, record])
|
||||
|
||||
const handleDownload = React.useCallback(() => {
|
||||
dispatch(openDownloadMenu(record, DOWNLOAD_MENU_ALBUM))
|
||||
}, [dispatch, record])
|
||||
|
||||
return (
|
||||
<TopToolbar className={className} {...sanitizeListRestProps(rest)}>
|
||||
<div className={classes.toolbar}>
|
||||
<div>
|
||||
<AlbumButton
|
||||
onClick={handlePlay}
|
||||
label={translate('resources.album.actions.playAll')}
|
||||
>
|
||||
<PlayArrowIcon />
|
||||
</AlbumButton>
|
||||
<AlbumButton
|
||||
onClick={handleShuffle}
|
||||
label={translate('resources.album.actions.shuffle')}
|
||||
>
|
||||
<ShuffleIcon />
|
||||
</AlbumButton>
|
||||
<AlbumButton
|
||||
onClick={handlePlayNext}
|
||||
label={translate('resources.album.actions.playNext')}
|
||||
>
|
||||
<RiPlayList2Fill />
|
||||
</AlbumButton>
|
||||
<AlbumButton
|
||||
onClick={handlePlayLater}
|
||||
label={translate('resources.album.actions.addToQueue')}
|
||||
>
|
||||
<RiPlayListAddFill />
|
||||
</AlbumButton>
|
||||
<AlbumButton
|
||||
onClick={handleAddToPlaylist}
|
||||
label={translate('resources.album.actions.addToPlaylist')}
|
||||
>
|
||||
<PlaylistAddIcon />
|
||||
</AlbumButton>
|
||||
{config.enableSharing && (
|
||||
<AlbumButton
|
||||
onClick={handleShare}
|
||||
label={translate('ra.action.share')}
|
||||
>
|
||||
<ShareIcon />
|
||||
</AlbumButton>
|
||||
)}
|
||||
{config.enableDownloads && (
|
||||
<AlbumButton
|
||||
onClick={handleDownload}
|
||||
label={
|
||||
translate('ra.action.download') +
|
||||
(isDesktop ? ` (${formatBytes(record.size)})` : '')
|
||||
}
|
||||
>
|
||||
<CloudDownloadOutlinedIcon />
|
||||
</AlbumButton>
|
||||
)}
|
||||
</div>
|
||||
<div>{isNotSmall && <ToggleFieldsMenu resource="albumSong" />}</div>
|
||||
</div>
|
||||
</TopToolbar>
|
||||
)
|
||||
}
|
||||
|
||||
AlbumActions.propTypes = {
|
||||
record: PropTypes.object.isRequired,
|
||||
selectedIds: PropTypes.arrayOf(PropTypes.number),
|
||||
}
|
||||
|
||||
AlbumActions.defaultProps = {
|
||||
record: {},
|
||||
selectedIds: [],
|
||||
}
|
||||
|
||||
export default AlbumActions
|
||||
25
ui/src/album/AlbumDatesField.jsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import { useRecordContext } from 'react-admin'
|
||||
import { formatRange } from '../common/index.js'
|
||||
|
||||
const originalYearSymbol = '♫'
|
||||
const releaseYearSymbol = '○'
|
||||
|
||||
export const AlbumDatesField = ({ className, ...rest }) => {
|
||||
const record = useRecordContext(rest)
|
||||
const releaseDate = record.releaseDate
|
||||
const releaseYear = releaseDate?.toString().substring(0, 4)
|
||||
const yearRange =
|
||||
formatRange(record, 'originalYear') || record['maxYear']?.toString()
|
||||
|
||||
// Don't show anything if the year starts with "0"
|
||||
if (yearRange === '0' || releaseYear?.startsWith('0')) {
|
||||
return null
|
||||
}
|
||||
|
||||
let label = yearRange
|
||||
|
||||
if (releaseYear !== undefined && yearRange !== releaseYear) {
|
||||
label = `${originalYearSymbol} ${yearRange} · ${releaseYearSymbol} ${releaseYear}`
|
||||
}
|
||||
return <span className={className}>{label}</span>
|
||||
}
|
||||
112
ui/src/album/AlbumDatesField.test.jsx
Normal file
@@ -0,0 +1,112 @@
|
||||
import { describe, test, expect, vi } from 'vitest'
|
||||
import { render } from '@testing-library/react'
|
||||
import { RecordContextProvider } from 'react-admin'
|
||||
import { AlbumDatesField } from './AlbumDatesField'
|
||||
import { formatRange } from '../common/index.js'
|
||||
|
||||
// Mock the formatRange function
|
||||
vi.mock('../common/index.js', () => ({
|
||||
formatRange: vi.fn(),
|
||||
}))
|
||||
|
||||
describe('AlbumDatesField', () => {
|
||||
test('renders nothing when yearRange is "0"', () => {
|
||||
const record = {
|
||||
maxYear: '0',
|
||||
releaseDate: '2020-01-01',
|
||||
}
|
||||
|
||||
vi.mocked(formatRange).mockReturnValue('0')
|
||||
|
||||
const { container } = render(
|
||||
<RecordContextProvider value={record}>
|
||||
<AlbumDatesField />
|
||||
</RecordContextProvider>,
|
||||
)
|
||||
|
||||
expect(container.firstChild).toBeNull()
|
||||
})
|
||||
|
||||
test('renders nothing when releaseYear is "0"', () => {
|
||||
const record = {
|
||||
maxYear: '2020',
|
||||
releaseDate: '0-01-01',
|
||||
}
|
||||
|
||||
vi.mocked(formatRange).mockReturnValue('2020')
|
||||
|
||||
const { container } = render(
|
||||
<RecordContextProvider value={record}>
|
||||
<AlbumDatesField />
|
||||
</RecordContextProvider>,
|
||||
)
|
||||
|
||||
expect(container.firstChild).toBeNull()
|
||||
})
|
||||
|
||||
test('renders only yearRange when releaseYear is undefined', () => {
|
||||
const record = {
|
||||
maxYear: '2020',
|
||||
}
|
||||
|
||||
vi.mocked(formatRange).mockReturnValue('2020')
|
||||
|
||||
const { container } = render(
|
||||
<RecordContextProvider value={record}>
|
||||
<AlbumDatesField />
|
||||
</RecordContextProvider>,
|
||||
)
|
||||
|
||||
expect(container.textContent).toBe('2020')
|
||||
})
|
||||
|
||||
test('renders both years when they are different', () => {
|
||||
const record = {
|
||||
maxYear: '2018',
|
||||
releaseDate: '2020-01-01',
|
||||
}
|
||||
|
||||
vi.mocked(formatRange).mockReturnValue('2018')
|
||||
|
||||
const { container } = render(
|
||||
<RecordContextProvider value={record}>
|
||||
<AlbumDatesField />
|
||||
</RecordContextProvider>,
|
||||
)
|
||||
|
||||
expect(container.textContent).toBe('♫ 2018 · ○ 2020')
|
||||
})
|
||||
|
||||
test('renders only yearRange when both years are the same', () => {
|
||||
const record = {
|
||||
maxYear: '2020',
|
||||
releaseDate: '2020-01-01',
|
||||
}
|
||||
|
||||
vi.mocked(formatRange).mockReturnValue('2020')
|
||||
|
||||
const { container } = render(
|
||||
<RecordContextProvider value={record}>
|
||||
<AlbumDatesField />
|
||||
</RecordContextProvider>,
|
||||
)
|
||||
|
||||
expect(container.textContent).toBe('2020')
|
||||
})
|
||||
|
||||
test('applies className when provided', () => {
|
||||
const record = {
|
||||
maxYear: '2020',
|
||||
}
|
||||
|
||||
vi.mocked(formatRange).mockReturnValue('2020')
|
||||
|
||||
const { container } = render(
|
||||
<RecordContextProvider value={record}>
|
||||
<AlbumDatesField className="test-class" />
|
||||
</RecordContextProvider>,
|
||||
)
|
||||
|
||||
expect(container.firstChild).toHaveClass('test-class')
|
||||
})
|
||||
})
|
||||
392
ui/src/album/AlbumDetails.jsx
Normal file
@@ -0,0 +1,392 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardMedia,
|
||||
Collapse,
|
||||
makeStyles,
|
||||
Typography,
|
||||
useMediaQuery,
|
||||
withWidth,
|
||||
} from '@material-ui/core'
|
||||
import {
|
||||
ArrayField,
|
||||
ChipField,
|
||||
Link,
|
||||
SingleFieldList,
|
||||
useRecordContext,
|
||||
useTranslate,
|
||||
} from 'react-admin'
|
||||
import Lightbox from 'react-image-lightbox'
|
||||
import 'react-image-lightbox/style.css'
|
||||
import subsonic from '../subsonic'
|
||||
import {
|
||||
ArtistLinkField,
|
||||
CollapsibleComment,
|
||||
DurationField,
|
||||
formatRange,
|
||||
LoveButton,
|
||||
RatingField,
|
||||
SizeField,
|
||||
useAlbumsPerPage,
|
||||
} from '../common'
|
||||
import config from '../config'
|
||||
import { formatFullDate, intersperse } from '../utils'
|
||||
import AlbumExternalLinks from './AlbumExternalLinks'
|
||||
|
||||
const useStyles = makeStyles(
|
||||
(theme) => ({
|
||||
root: {
|
||||
[theme.breakpoints.down('xs')]: {
|
||||
padding: '0.7em',
|
||||
minWidth: '20em',
|
||||
},
|
||||
[theme.breakpoints.up('sm')]: {
|
||||
padding: '1em',
|
||||
minWidth: '32em',
|
||||
},
|
||||
},
|
||||
cardContents: {
|
||||
display: 'flex',
|
||||
},
|
||||
details: {
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
},
|
||||
content: {
|
||||
flex: '2 0 auto',
|
||||
},
|
||||
coverParent: {
|
||||
[theme.breakpoints.down('xs')]: {
|
||||
height: '8em',
|
||||
width: '8em',
|
||||
minWidth: '8em',
|
||||
},
|
||||
[theme.breakpoints.up('sm')]: {
|
||||
height: '10em',
|
||||
width: '10em',
|
||||
minWidth: '10em',
|
||||
},
|
||||
[theme.breakpoints.up('lg')]: {
|
||||
height: '15em',
|
||||
width: '15em',
|
||||
minWidth: '15em',
|
||||
},
|
||||
backgroundColor: 'transparent',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
cover: {
|
||||
objectFit: 'contain',
|
||||
cursor: 'pointer',
|
||||
display: 'block',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
backgroundColor: 'transparent',
|
||||
transition: 'opacity 0.3s ease-in-out',
|
||||
},
|
||||
coverLoading: {
|
||||
opacity: 0.5,
|
||||
},
|
||||
loveButton: {
|
||||
top: theme.spacing(-0.2),
|
||||
left: theme.spacing(0.5),
|
||||
},
|
||||
notes: {
|
||||
display: 'inline-block',
|
||||
marginTop: '1em',
|
||||
float: 'left',
|
||||
wordBreak: 'break-word',
|
||||
cursor: 'pointer',
|
||||
},
|
||||
recordName: {},
|
||||
recordArtist: {},
|
||||
recordMeta: {},
|
||||
genreList: {
|
||||
marginTop: theme.spacing(0.5),
|
||||
},
|
||||
externalLinks: {
|
||||
marginTop: theme.spacing(1.5),
|
||||
},
|
||||
}),
|
||||
{
|
||||
name: 'NDAlbumDetails',
|
||||
},
|
||||
)
|
||||
|
||||
const useGetHandleGenreClick = (width) => {
|
||||
const [perPage] = useAlbumsPerPage(width)
|
||||
|
||||
return (id) => {
|
||||
return `/album?filter={"genre_id":["${id}"]}&order=ASC&sort=name&perPage=${perPage}`
|
||||
}
|
||||
}
|
||||
|
||||
const GenreChipField = withWidth()(({ width, ...rest }) => {
|
||||
const record = useRecordContext(rest)
|
||||
const genreLink = useGetHandleGenreClick(width)
|
||||
|
||||
return (
|
||||
<Link to={genreLink(record.id)} onClick={(e) => e.stopPropagation()}>
|
||||
<ChipField
|
||||
source="name"
|
||||
// Workaround to force ChipField to be clickable
|
||||
onClick={() => {}}
|
||||
/>
|
||||
</Link>
|
||||
)
|
||||
})
|
||||
|
||||
const GenreList = () => {
|
||||
const classes = useStyles()
|
||||
return (
|
||||
<ArrayField className={classes.genreList} source={'genres'}>
|
||||
<SingleFieldList linkType={false}>
|
||||
<GenreChipField />
|
||||
</SingleFieldList>
|
||||
</ArrayField>
|
||||
)
|
||||
}
|
||||
|
||||
export const Details = (props) => {
|
||||
const isXsmall = useMediaQuery((theme) => theme.breakpoints.down('xs'))
|
||||
const translate = useTranslate()
|
||||
const record = useRecordContext(props)
|
||||
|
||||
// Create an array of detail elements
|
||||
let details = []
|
||||
const addDetail = (obj) => {
|
||||
const id = details.length
|
||||
details.push(<span key={`detail-${record.id}-${id}`}>{obj}</span>)
|
||||
}
|
||||
|
||||
// Calculate date related fields
|
||||
const yearRange = formatRange(record, 'year')
|
||||
const date = record.date ? formatFullDate(record.date) : yearRange
|
||||
|
||||
const originalDate = record.originalDate
|
||||
? formatFullDate(record.originalDate)
|
||||
: formatRange(record, 'originalYear')
|
||||
const releaseDate = record?.releaseDate && formatFullDate(record.releaseDate)
|
||||
|
||||
const dateToUse = originalDate || date
|
||||
const isOriginalDate = originalDate && dateToUse !== date
|
||||
const showDate = dateToUse && dateToUse !== releaseDate
|
||||
|
||||
// Get label for the main date display
|
||||
const getDateLabel = () => {
|
||||
if (isXsmall) return '♫'
|
||||
if (isOriginalDate) return translate('resources.album.fields.originalDate')
|
||||
return null
|
||||
}
|
||||
|
||||
// Get label for release date display
|
||||
const getReleaseDateLabel = () => {
|
||||
if (!isXsmall) return translate('resources.album.fields.releaseDate')
|
||||
if (showDate) return '○'
|
||||
return null
|
||||
}
|
||||
|
||||
// Display dates with appropriate labels
|
||||
if (showDate) {
|
||||
addDetail(<>{[getDateLabel(), dateToUse].filter(Boolean).join(' ')}</>)
|
||||
}
|
||||
|
||||
if (releaseDate) {
|
||||
addDetail(
|
||||
<>{[getReleaseDateLabel(), releaseDate].filter(Boolean).join(' ')}</>,
|
||||
)
|
||||
}
|
||||
addDetail(
|
||||
<>
|
||||
{record.songCount +
|
||||
' ' +
|
||||
translate('resources.song.name', {
|
||||
smart_count: record.songCount,
|
||||
})}
|
||||
</>,
|
||||
)
|
||||
!isXsmall && addDetail(<DurationField source={'duration'} />)
|
||||
!isXsmall && addDetail(<SizeField source="size" />)
|
||||
|
||||
// Return the details rendered with separators
|
||||
return <>{intersperse(details, ' · ')}</>
|
||||
}
|
||||
|
||||
const AlbumDetails = (props) => {
|
||||
const record = useRecordContext(props)
|
||||
const isXsmall = useMediaQuery((theme) => theme.breakpoints.down('xs'))
|
||||
const isDesktop = useMediaQuery((theme) => theme.breakpoints.up('lg'))
|
||||
const classes = useStyles()
|
||||
const [isLightboxOpen, setLightboxOpen] = useState(false)
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
const [albumInfo, setAlbumInfo] = useState()
|
||||
const [imageLoading, setImageLoading] = useState(false)
|
||||
const [imageError, setImageError] = useState(false)
|
||||
|
||||
let notes =
|
||||
albumInfo?.notes?.replace(new RegExp('<.*>', 'g'), '') || record.notes
|
||||
|
||||
if (notes !== undefined) {
|
||||
notes += '..'
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
subsonic
|
||||
.getAlbumInfo(record.id)
|
||||
.then((resp) => resp.json['subsonic-response'])
|
||||
.then((data) => {
|
||||
if (data.status === 'ok') {
|
||||
setAlbumInfo(data.albumInfo)
|
||||
}
|
||||
})
|
||||
.catch((e) => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('error on album page', e)
|
||||
})
|
||||
}, [record])
|
||||
|
||||
// Reset image state when album changes
|
||||
useEffect(() => {
|
||||
setImageLoading(true)
|
||||
setImageError(false)
|
||||
}, [record.id])
|
||||
|
||||
const imageUrl = subsonic.getCoverArtUrl(record, 300)
|
||||
const fullImageUrl = subsonic.getCoverArtUrl(record)
|
||||
|
||||
const handleImageLoad = useCallback(() => {
|
||||
setImageLoading(false)
|
||||
setImageError(false)
|
||||
}, [])
|
||||
|
||||
const handleImageError = useCallback(() => {
|
||||
setImageLoading(false)
|
||||
setImageError(true)
|
||||
}, [])
|
||||
|
||||
const handleOpenLightbox = useCallback(() => {
|
||||
if (!imageError) {
|
||||
setLightboxOpen(true)
|
||||
}
|
||||
}, [imageError])
|
||||
|
||||
const handleCloseLightbox = useCallback(() => setLightboxOpen(false), [])
|
||||
|
||||
return (
|
||||
<Card className={classes.root}>
|
||||
<div className={classes.cardContents}>
|
||||
<div className={classes.coverParent}>
|
||||
<CardMedia
|
||||
key={record.id}
|
||||
component={'img'}
|
||||
src={imageUrl}
|
||||
width="400"
|
||||
height="400"
|
||||
className={`${classes.cover} ${imageLoading ? classes.coverLoading : ''}`}
|
||||
onClick={handleOpenLightbox}
|
||||
onLoad={handleImageLoad}
|
||||
onError={handleImageError}
|
||||
title={record.name}
|
||||
style={{
|
||||
cursor: imageError ? 'default' : 'pointer',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className={classes.details}>
|
||||
<CardContent className={classes.content}>
|
||||
<Typography
|
||||
variant={isDesktop ? 'h5' : 'h6'}
|
||||
className={classes.recordName}
|
||||
>
|
||||
{record.name}
|
||||
<LoveButton
|
||||
className={classes.loveButton}
|
||||
record={record}
|
||||
resource={'album'}
|
||||
size={isDesktop ? 'default' : 'small'}
|
||||
aria-label="love"
|
||||
color="primary"
|
||||
/>
|
||||
</Typography>
|
||||
<Typography component={'h6'} className={classes.recordArtist}>
|
||||
{record?.tags?.['albumversion']}
|
||||
</Typography>
|
||||
<Typography component={'h6'} className={classes.recordArtist}>
|
||||
<ArtistLinkField record={record} />
|
||||
</Typography>
|
||||
<Typography component={'div'} className={classes.recordMeta}>
|
||||
<Details />
|
||||
</Typography>
|
||||
{config.enableStarRating && (
|
||||
<div>
|
||||
<RatingField
|
||||
record={record}
|
||||
resource={'album'}
|
||||
size={isDesktop ? 'medium' : 'small'}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{isDesktop ? (
|
||||
<GenreList />
|
||||
) : (
|
||||
<Typography component={'p'}>{record.genre}</Typography>
|
||||
)}
|
||||
{!isXsmall && (
|
||||
<Typography component={'div'} className={classes.recordMeta}>
|
||||
{config.enableExternalServices && (
|
||||
<AlbumExternalLinks className={classes.externalLinks} />
|
||||
)}
|
||||
</Typography>
|
||||
)}
|
||||
{isDesktop && (
|
||||
<Collapse
|
||||
collapsedHeight={'2.75em'}
|
||||
in={expanded}
|
||||
timeout={'auto'}
|
||||
className={classes.notes}
|
||||
>
|
||||
<Typography
|
||||
variant={'body1'}
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
>
|
||||
<span dangerouslySetInnerHTML={{ __html: notes }} />
|
||||
</Typography>
|
||||
</Collapse>
|
||||
)}
|
||||
{isDesktop && record['comment'] && (
|
||||
<CollapsibleComment record={record} />
|
||||
)}
|
||||
</CardContent>
|
||||
</div>
|
||||
</div>
|
||||
{!isDesktop && record['comment'] && (
|
||||
<CollapsibleComment record={record} />
|
||||
)}
|
||||
{!isDesktop && (
|
||||
<div className={classes.notes}>
|
||||
<Collapse collapsedHeight={'1.5em'} in={expanded} timeout={'auto'}>
|
||||
<Typography
|
||||
variant={'body1'}
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
>
|
||||
<span dangerouslySetInnerHTML={{ __html: notes }} />
|
||||
</Typography>
|
||||
</Collapse>
|
||||
</div>
|
||||
)}
|
||||
{isLightboxOpen && !imageError && (
|
||||
<Lightbox
|
||||
imagePadding={50}
|
||||
animationDuration={200}
|
||||
imageTitle={record.name}
|
||||
mainSrc={fullImageUrl}
|
||||
onCloseRequest={handleCloseLightbox}
|
||||
/>
|
||||
)}
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
export default AlbumDetails
|
||||
345
ui/src/album/AlbumDetails.test.jsx
Normal file
@@ -0,0 +1,345 @@
|
||||
// ui/src/album/__tests__/AlbumDetails.test.jsx
|
||||
import { describe, test, expect, beforeEach, afterEach } from 'vitest'
|
||||
import { render } from '@testing-library/react'
|
||||
import { RecordContextProvider } from 'react-admin'
|
||||
import { useMediaQuery } from '@material-ui/core'
|
||||
import { Details } from './AlbumDetails'
|
||||
|
||||
// Mock useMediaQuery
|
||||
vi.mock('@material-ui/core', async () => {
|
||||
const actual = await import('@material-ui/core')
|
||||
return {
|
||||
...actual,
|
||||
useMediaQuery: vi.fn(),
|
||||
}
|
||||
})
|
||||
|
||||
// Mock formatFullDate to return deterministic results
|
||||
vi.mock('../utils', async () => {
|
||||
const actual = await import('../utils')
|
||||
return {
|
||||
...actual,
|
||||
formatFullDate: (date) => {
|
||||
if (!date) return ''
|
||||
// Use en-CA locale for consistent test results
|
||||
return new Date(date).toLocaleDateString('en-CA', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
timeZone: 'UTC',
|
||||
})
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
describe('Details component', () => {
|
||||
describe('Desktop view', () => {
|
||||
beforeEach(() => {
|
||||
// Set desktop view (isXsmall = false)
|
||||
vi.mocked(useMediaQuery).mockReturnValue(false)
|
||||
})
|
||||
|
||||
test('renders correctly with just year range', () => {
|
||||
const record = {
|
||||
id: '123',
|
||||
name: 'Test Album',
|
||||
songCount: 12,
|
||||
duration: 3600,
|
||||
size: 102400,
|
||||
year: 2020,
|
||||
}
|
||||
|
||||
const { container } = render(
|
||||
<RecordContextProvider value={record}>
|
||||
<Details />
|
||||
</RecordContextProvider>,
|
||||
)
|
||||
|
||||
expect(container).toMatchSnapshot()
|
||||
})
|
||||
|
||||
test('renders correctly with date', () => {
|
||||
const record = {
|
||||
id: '123',
|
||||
name: 'Test Album',
|
||||
songCount: 12,
|
||||
duration: 3600,
|
||||
size: 102400,
|
||||
date: '2020-05-01',
|
||||
}
|
||||
|
||||
const { container } = render(
|
||||
<RecordContextProvider value={record}>
|
||||
<Details />
|
||||
</RecordContextProvider>,
|
||||
)
|
||||
|
||||
expect(container).toMatchSnapshot()
|
||||
})
|
||||
|
||||
test('renders correctly with originalDate', () => {
|
||||
const record = {
|
||||
id: '123',
|
||||
name: 'Test Album',
|
||||
songCount: 12,
|
||||
duration: 3600,
|
||||
size: 102400,
|
||||
originalDate: '2018-03-15',
|
||||
}
|
||||
|
||||
const { container } = render(
|
||||
<RecordContextProvider value={record}>
|
||||
<Details />
|
||||
</RecordContextProvider>,
|
||||
)
|
||||
|
||||
expect(container).toMatchSnapshot()
|
||||
})
|
||||
|
||||
test('renders correctly with date and originalDate', () => {
|
||||
const record = {
|
||||
id: '123',
|
||||
name: 'Test Album',
|
||||
songCount: 12,
|
||||
duration: 3600,
|
||||
size: 102400,
|
||||
date: '2020-05-01',
|
||||
originalDate: '2018-03-15',
|
||||
}
|
||||
|
||||
const { container } = render(
|
||||
<RecordContextProvider value={record}>
|
||||
<Details />
|
||||
</RecordContextProvider>,
|
||||
)
|
||||
|
||||
expect(container).toMatchSnapshot()
|
||||
})
|
||||
|
||||
test('renders correctly with releaseDate', () => {
|
||||
const record = {
|
||||
id: '123',
|
||||
name: 'Test Album',
|
||||
songCount: 12,
|
||||
duration: 3600,
|
||||
size: 102400,
|
||||
releaseDate: '2020-06-15',
|
||||
}
|
||||
|
||||
const { container } = render(
|
||||
<RecordContextProvider value={record}>
|
||||
<Details />
|
||||
</RecordContextProvider>,
|
||||
)
|
||||
|
||||
expect(container).toMatchSnapshot()
|
||||
})
|
||||
|
||||
test('renders correctly with all date fields', () => {
|
||||
const record = {
|
||||
id: '123',
|
||||
name: 'Test Album',
|
||||
songCount: 12,
|
||||
duration: 3600,
|
||||
size: 102400,
|
||||
date: '2020-05-01',
|
||||
originalDate: '2018-03-15',
|
||||
releaseDate: '2020-06-15',
|
||||
}
|
||||
|
||||
const { container } = render(
|
||||
<RecordContextProvider value={record}>
|
||||
<Details />
|
||||
</RecordContextProvider>,
|
||||
)
|
||||
|
||||
expect(container).toMatchSnapshot()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Mobile view', () => {
|
||||
beforeEach(() => {
|
||||
// Set mobile view (isXsmall = true)
|
||||
vi.mocked(useMediaQuery).mockReturnValue(true)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
test('renders correctly with just year range', () => {
|
||||
const record = {
|
||||
id: '123',
|
||||
name: 'Test Album',
|
||||
songCount: 12,
|
||||
duration: 3600,
|
||||
size: 102400,
|
||||
year: 2020,
|
||||
}
|
||||
|
||||
const { container } = render(
|
||||
<RecordContextProvider value={record}>
|
||||
<Details />
|
||||
</RecordContextProvider>,
|
||||
)
|
||||
|
||||
expect(container).toMatchSnapshot()
|
||||
})
|
||||
|
||||
test('renders correctly with date', () => {
|
||||
const record = {
|
||||
id: '123',
|
||||
name: 'Test Album',
|
||||
songCount: 12,
|
||||
duration: 3600,
|
||||
size: 102400,
|
||||
date: '2020-05-01',
|
||||
}
|
||||
|
||||
const { container } = render(
|
||||
<RecordContextProvider value={record}>
|
||||
<Details />
|
||||
</RecordContextProvider>,
|
||||
)
|
||||
|
||||
expect(container).toMatchSnapshot()
|
||||
})
|
||||
|
||||
test('renders correctly with originalDate', () => {
|
||||
const record = {
|
||||
id: '123',
|
||||
name: 'Test Album',
|
||||
songCount: 12,
|
||||
duration: 3600,
|
||||
size: 102400,
|
||||
originalDate: '2018-03-15',
|
||||
}
|
||||
|
||||
const { container } = render(
|
||||
<RecordContextProvider value={record}>
|
||||
<Details />
|
||||
</RecordContextProvider>,
|
||||
)
|
||||
|
||||
expect(container).toMatchSnapshot()
|
||||
})
|
||||
|
||||
test('renders correctly with date and originalDate', () => {
|
||||
const record = {
|
||||
id: '123',
|
||||
name: 'Test Album',
|
||||
songCount: 12,
|
||||
duration: 3600,
|
||||
size: 102400,
|
||||
date: '2020-05-01',
|
||||
originalDate: '2018-03-15',
|
||||
}
|
||||
|
||||
const { container } = render(
|
||||
<RecordContextProvider value={record}>
|
||||
<Details />
|
||||
</RecordContextProvider>,
|
||||
)
|
||||
|
||||
expect(container).toMatchSnapshot()
|
||||
})
|
||||
|
||||
test('renders correctly with releaseDate', () => {
|
||||
const record = {
|
||||
id: '123',
|
||||
name: 'Test Album',
|
||||
songCount: 12,
|
||||
duration: 3600,
|
||||
size: 102400,
|
||||
releaseDate: '2020-06-15',
|
||||
}
|
||||
|
||||
const { container } = render(
|
||||
<RecordContextProvider value={record}>
|
||||
<Details />
|
||||
</RecordContextProvider>,
|
||||
)
|
||||
|
||||
expect(container).toMatchSnapshot()
|
||||
})
|
||||
|
||||
test('renders correctly with all date fields', () => {
|
||||
const record = {
|
||||
id: '123',
|
||||
name: 'Test Album',
|
||||
songCount: 12,
|
||||
duration: 3600,
|
||||
size: 102400,
|
||||
date: '2020-05-01',
|
||||
originalDate: '2018-03-15',
|
||||
releaseDate: '2020-06-15',
|
||||
}
|
||||
|
||||
const { container } = render(
|
||||
<RecordContextProvider value={record}>
|
||||
<Details />
|
||||
</RecordContextProvider>,
|
||||
)
|
||||
|
||||
expect(container).toMatchSnapshot()
|
||||
})
|
||||
|
||||
test('renders correctly with no date fields', () => {
|
||||
const record = {
|
||||
id: '123',
|
||||
name: 'Test Album',
|
||||
songCount: 12,
|
||||
duration: 3600,
|
||||
size: 102400,
|
||||
}
|
||||
|
||||
const { container } = render(
|
||||
<RecordContextProvider value={record}>
|
||||
<Details />
|
||||
</RecordContextProvider>,
|
||||
)
|
||||
|
||||
expect(container).toMatchSnapshot()
|
||||
})
|
||||
|
||||
test('renders correctly with year range (start and end years)', () => {
|
||||
const record = {
|
||||
id: '123',
|
||||
name: 'Test Album',
|
||||
songCount: 12,
|
||||
duration: 3600,
|
||||
size: 102400,
|
||||
year: 2018,
|
||||
yearEnd: 2020,
|
||||
}
|
||||
|
||||
const { container } = render(
|
||||
<RecordContextProvider value={record}>
|
||||
<Details />
|
||||
</RecordContextProvider>,
|
||||
)
|
||||
|
||||
expect(container).toMatchSnapshot()
|
||||
})
|
||||
|
||||
test('renders correctly with originalYear range', () => {
|
||||
const record = {
|
||||
id: '123',
|
||||
name: 'Test Album',
|
||||
songCount: 12,
|
||||
duration: 3600,
|
||||
size: 102400,
|
||||
originalYear: 2015,
|
||||
originalYearEnd: 2016,
|
||||
}
|
||||
|
||||
const { container } = render(
|
||||
<RecordContextProvider value={record}>
|
||||
<Details />
|
||||
</RecordContextProvider>,
|
||||
)
|
||||
|
||||
expect(container).toMatchSnapshot()
|
||||
})
|
||||
})
|
||||
})
|
||||
52
ui/src/album/AlbumExternalLinks.jsx
Normal file
@@ -0,0 +1,52 @@
|
||||
import React from 'react'
|
||||
import { useRecordContext, useTranslate } from 'react-admin'
|
||||
import { IconButton, Tooltip, Link } from '@material-ui/core'
|
||||
import { ImLastfm2 } from 'react-icons/im'
|
||||
import MusicBrainz from '../icons/MusicBrainz'
|
||||
import { intersperse } from '../utils'
|
||||
import config from '../config'
|
||||
|
||||
const AlbumExternalLinks = (props) => {
|
||||
const { className } = props
|
||||
const translate = useTranslate()
|
||||
const record = useRecordContext(props)
|
||||
let links = []
|
||||
|
||||
const addLink = (url, title, icon) => {
|
||||
const translatedTitle = translate(title)
|
||||
const link = (
|
||||
<Link href={url} target="_blank" rel="noopener noreferrer">
|
||||
<Tooltip title={translatedTitle}>
|
||||
<IconButton size={'small'} aria-label={translatedTitle}>
|
||||
{icon}
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Link>
|
||||
)
|
||||
const id = links.length
|
||||
links.push(<span key={`link-${record.id}-${id}`}>{link}</span>)
|
||||
}
|
||||
|
||||
if (config.lastFMEnabled) {
|
||||
addLink(
|
||||
`https://last.fm/music/${
|
||||
encodeURIComponent(record.albumArtist) +
|
||||
'/' +
|
||||
encodeURIComponent(record.name)
|
||||
}`,
|
||||
'message.openIn.lastfm',
|
||||
<ImLastfm2 className="lastfm-icon" />,
|
||||
)
|
||||
}
|
||||
|
||||
record.mbzAlbumId &&
|
||||
addLink(
|
||||
`https://musicbrainz.org/release/${record.mbzAlbumId}`,
|
||||
'message.openIn.musicbrainz',
|
||||
<MusicBrainz className="musicbrainz-icon" />,
|
||||
)
|
||||
|
||||
return <div className={className}>{intersperse(links, ' ')}</div>
|
||||
}
|
||||
|
||||
export default AlbumExternalLinks
|
||||
252
ui/src/album/AlbumGridView.jsx
Normal file
@@ -0,0 +1,252 @@
|
||||
import React from 'react'
|
||||
import {
|
||||
GridList,
|
||||
GridListTile,
|
||||
Typography,
|
||||
GridListTileBar,
|
||||
useMediaQuery,
|
||||
} from '@material-ui/core'
|
||||
import { makeStyles } from '@material-ui/core/styles'
|
||||
import withWidth from '@material-ui/core/withWidth'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { linkToRecord, useListContext, Loading } from 'react-admin'
|
||||
import { withContentRect } from 'react-measure'
|
||||
import { useDrag } from 'react-dnd'
|
||||
import subsonic from '../subsonic'
|
||||
import { AlbumContextMenu, PlayButton, ArtistLinkField } from '../common'
|
||||
import { DraggableTypes } from '../consts'
|
||||
import clsx from 'clsx'
|
||||
import { AlbumDatesField } from './AlbumDatesField.jsx'
|
||||
|
||||
const useStyles = makeStyles(
|
||||
(theme) => ({
|
||||
root: {
|
||||
margin: '20px',
|
||||
display: 'grid',
|
||||
},
|
||||
tileBar: {
|
||||
transition: 'all 150ms ease-out',
|
||||
opacity: 0,
|
||||
textAlign: 'left',
|
||||
marginBottom: '3px',
|
||||
background:
|
||||
'linear-gradient(to top, rgba(0,0,0,0.7) 0%,rgba(0,0,0,0.4) 70%,rgba(0,0,0,0) 100%)',
|
||||
},
|
||||
tileBarMobile: {
|
||||
textAlign: 'left',
|
||||
marginBottom: '3px',
|
||||
background:
|
||||
'linear-gradient(to top, rgba(0,0,0,0.7) 0%,rgba(0,0,0,0.4) 70%,rgba(0,0,0,0) 100%)',
|
||||
},
|
||||
albumArtistName: {
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
textAlign: 'left',
|
||||
fontSize: '1em',
|
||||
},
|
||||
albumName: {
|
||||
fontSize: '14px',
|
||||
color: theme.palette.type === 'dark' ? '#eee' : 'black',
|
||||
overflow: 'hidden',
|
||||
whiteSpace: 'nowrap',
|
||||
textOverflow: 'ellipsis',
|
||||
},
|
||||
missingAlbum: {
|
||||
opacity: 0.3,
|
||||
},
|
||||
albumVersion: {
|
||||
fontSize: '12px',
|
||||
color: theme.palette.type === 'dark' ? '#c5c5c5' : '#696969',
|
||||
overflow: 'hidden',
|
||||
whiteSpace: 'nowrap',
|
||||
textOverflow: 'ellipsis',
|
||||
},
|
||||
albumSubtitle: {
|
||||
fontSize: '12px',
|
||||
color: theme.palette.type === 'dark' ? '#c5c5c5' : '#696969',
|
||||
overflow: 'hidden',
|
||||
whiteSpace: 'nowrap',
|
||||
textOverflow: 'ellipsis',
|
||||
},
|
||||
link: {
|
||||
position: 'relative',
|
||||
display: 'block',
|
||||
textDecoration: 'none',
|
||||
'&:hover $tileBar': {
|
||||
opacity: 1,
|
||||
},
|
||||
},
|
||||
albumLink: {
|
||||
position: 'relative',
|
||||
display: 'block',
|
||||
textDecoration: 'none',
|
||||
},
|
||||
albumContainer: {},
|
||||
albumPlayButton: { color: 'white' },
|
||||
}),
|
||||
{ name: 'NDAlbumGridView' },
|
||||
)
|
||||
|
||||
const useCoverStyles = makeStyles({
|
||||
cover: {
|
||||
display: 'inline-block',
|
||||
width: '100%',
|
||||
objectFit: 'contain',
|
||||
height: (props) => props.height,
|
||||
transition: 'opacity 0.3s ease-in-out',
|
||||
},
|
||||
coverLoading: {
|
||||
opacity: 0.5,
|
||||
},
|
||||
})
|
||||
|
||||
const getColsForWidth = (width) => {
|
||||
if (width === 'xs') return 2
|
||||
if (width === 'sm') return 3
|
||||
if (width === 'md') return 4
|
||||
if (width === 'lg') return 6
|
||||
return 9
|
||||
}
|
||||
|
||||
const Cover = withContentRect('bounds')(({
|
||||
record,
|
||||
measureRef,
|
||||
contentRect,
|
||||
}) => {
|
||||
// Force height to be the same as the width determined by the GridList
|
||||
// noinspection JSSuspiciousNameCombination
|
||||
const classes = useCoverStyles({ height: contentRect.bounds.width })
|
||||
const [imageLoading, setImageLoading] = React.useState(true)
|
||||
const [imageError, setImageError] = React.useState(false)
|
||||
const [, dragAlbumRef] = useDrag(
|
||||
() => ({
|
||||
type: DraggableTypes.ALBUM,
|
||||
item: { albumIds: [record.id] },
|
||||
options: { dropEffect: 'copy' },
|
||||
}),
|
||||
[record],
|
||||
)
|
||||
|
||||
// Reset image state when record changes
|
||||
React.useEffect(() => {
|
||||
setImageLoading(true)
|
||||
setImageError(false)
|
||||
}, [record.id])
|
||||
|
||||
const handleImageLoad = React.useCallback(() => {
|
||||
setImageLoading(false)
|
||||
setImageError(false)
|
||||
}, [])
|
||||
|
||||
const handleImageError = React.useCallback(() => {
|
||||
setImageLoading(false)
|
||||
setImageError(true)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div ref={measureRef}>
|
||||
<div ref={dragAlbumRef}>
|
||||
<img
|
||||
key={record.id} // Force re-render when record changes
|
||||
src={subsonic.getCoverArtUrl(record, 300, true)}
|
||||
alt={record.name}
|
||||
className={`${classes.cover} ${imageLoading ? classes.coverLoading : ''}`}
|
||||
onLoad={handleImageLoad}
|
||||
onError={handleImageError}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
const AlbumGridTile = ({ showArtist, record, basePath, ...props }) => {
|
||||
const classes = useStyles()
|
||||
const isDesktop = useMediaQuery((theme) => theme.breakpoints.up('md'), {
|
||||
noSsr: true,
|
||||
})
|
||||
if (!record) {
|
||||
return null
|
||||
}
|
||||
const computedClasses = clsx(
|
||||
classes.albumContainer,
|
||||
record.missing && classes.missingAlbum,
|
||||
)
|
||||
return (
|
||||
<div className={computedClasses}>
|
||||
<Link
|
||||
className={classes.link}
|
||||
to={linkToRecord(basePath, record.id, 'show')}
|
||||
>
|
||||
<Cover record={record} />
|
||||
<GridListTileBar
|
||||
className={isDesktop ? classes.tileBar : classes.tileBarMobile}
|
||||
subtitle={
|
||||
!record.missing && (
|
||||
<PlayButton
|
||||
className={classes.albumPlayButton}
|
||||
record={record}
|
||||
size="small"
|
||||
/>
|
||||
)
|
||||
}
|
||||
actionIcon={<AlbumContextMenu record={record} color={'white'} />}
|
||||
/>
|
||||
</Link>
|
||||
<Link
|
||||
className={classes.albumLink}
|
||||
to={linkToRecord(basePath, record.id, 'show')}
|
||||
>
|
||||
<span>
|
||||
<Typography className={classes.albumName}>{record.name}</Typography>
|
||||
{record.tags && record.tags['albumversion'] && (
|
||||
<Typography className={classes.albumVersion}>
|
||||
{record.tags['albumversion']}
|
||||
</Typography>
|
||||
)}
|
||||
</span>
|
||||
</Link>
|
||||
{showArtist ? (
|
||||
<ArtistLinkField record={record} className={classes.albumSubtitle} />
|
||||
) : (
|
||||
<AlbumDatesField record={record} className={classes.albumSubtitle} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const LoadedAlbumGrid = ({ ids, data, basePath, width }) => {
|
||||
const classes = useStyles()
|
||||
const { filterValues } = useListContext()
|
||||
const isArtistView = !!(filterValues && filterValues.artist_id)
|
||||
return (
|
||||
<div className={classes.root}>
|
||||
<GridList
|
||||
component={'div'}
|
||||
cellHeight={'auto'}
|
||||
cols={getColsForWidth(width)}
|
||||
spacing={20}
|
||||
>
|
||||
{ids.map((id) => (
|
||||
<GridListTile className={classes.gridListTile} key={id}>
|
||||
<AlbumGridTile
|
||||
record={data[id]}
|
||||
basePath={basePath}
|
||||
showArtist={!isArtistView}
|
||||
/>
|
||||
</GridListTile>
|
||||
))}
|
||||
</GridList>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const AlbumGridView = ({ albumListType, loaded, loading, ...props }) => {
|
||||
const hide =
|
||||
(loading && albumListType === 'random') || !props.data || !props.ids
|
||||
return hide ? <Loading /> : <LoadedAlbumGrid {...props} />
|
||||
}
|
||||
|
||||
const AlbumGridViewWithWidth = withWidth()(AlbumGridView)
|
||||
|
||||
export default AlbumGridViewWithWidth
|
||||
148
ui/src/album/AlbumInfo.jsx
Normal file
@@ -0,0 +1,148 @@
|
||||
import Table from '@material-ui/core/Table'
|
||||
import TableBody from '@material-ui/core/TableBody'
|
||||
import { humanize, underscore } from 'inflection'
|
||||
import TableCell from '@material-ui/core/TableCell'
|
||||
import TableContainer from '@material-ui/core/TableContainer'
|
||||
import TableRow from '@material-ui/core/TableRow'
|
||||
import {
|
||||
ArrayField,
|
||||
BooleanField,
|
||||
ChipField,
|
||||
DateField,
|
||||
FunctionField,
|
||||
SingleFieldList,
|
||||
TextField,
|
||||
useRecordContext,
|
||||
useTranslate,
|
||||
} from 'react-admin'
|
||||
import { makeStyles } from '@material-ui/core/styles'
|
||||
import {
|
||||
ArtistLinkField,
|
||||
MultiLineTextField,
|
||||
ParticipantsInfo,
|
||||
RangeField,
|
||||
} from '../common'
|
||||
|
||||
const useStyles = makeStyles({
|
||||
tableCell: {
|
||||
width: '17.5%',
|
||||
},
|
||||
value: {
|
||||
whiteSpace: 'pre-line',
|
||||
},
|
||||
})
|
||||
|
||||
const AlbumInfo = (props) => {
|
||||
const classes = useStyles()
|
||||
const translate = useTranslate()
|
||||
const record = useRecordContext(props)
|
||||
const data = {
|
||||
album: <TextField source={'name'} />,
|
||||
libraryName: <TextField source="libraryName" />,
|
||||
albumArtist: (
|
||||
<ArtistLinkField source="albumArtist" record={record} limit={Infinity} />
|
||||
),
|
||||
genre: (
|
||||
<ArrayField source={'genres'}>
|
||||
<SingleFieldList linkType={false}>
|
||||
<ChipField source={'name'} />
|
||||
</SingleFieldList>
|
||||
</ArrayField>
|
||||
),
|
||||
date:
|
||||
record?.maxYear && record.maxYear === record.minYear ? (
|
||||
<TextField source={'date'} />
|
||||
) : (
|
||||
<RangeField source={'year'} />
|
||||
),
|
||||
originalDate:
|
||||
record?.maxOriginalYear &&
|
||||
record.maxOriginalYear === record.minOriginalYear ? (
|
||||
<TextField source={'originalDate'} />
|
||||
) : (
|
||||
<RangeField source={'originalYear'} />
|
||||
),
|
||||
releaseDate: <TextField source={'releaseDate'} />,
|
||||
recordLabel: (
|
||||
<FunctionField
|
||||
source={'recordLabel'}
|
||||
render={(record) => record.tags?.recordlabel?.join(', ')}
|
||||
/>
|
||||
),
|
||||
catalogNum: <TextField source={'catalogNum'} />,
|
||||
releaseType: (
|
||||
<FunctionField
|
||||
source={'releaseType'}
|
||||
render={(record) => record.tags?.releasetype?.join(', ')}
|
||||
/>
|
||||
),
|
||||
media: (
|
||||
<FunctionField
|
||||
source={'media'}
|
||||
render={(record) => record.tags?.media?.join(', ')}
|
||||
/>
|
||||
),
|
||||
grouping: (
|
||||
<FunctionField
|
||||
source={'grouping'}
|
||||
render={(record) => record.tags?.grouping?.join(', ')}
|
||||
/>
|
||||
),
|
||||
mood: (
|
||||
<FunctionField
|
||||
source={'mood'}
|
||||
render={(record) => record.tags?.mood?.join(', ')}
|
||||
/>
|
||||
),
|
||||
compilation: <BooleanField source={'compilation'} />,
|
||||
updatedAt: <DateField source={'updatedAt'} showTime />,
|
||||
comment: <MultiLineTextField source={'comment'} />,
|
||||
}
|
||||
|
||||
const optionalFields = ['comment', 'genre', 'catalogNum']
|
||||
optionalFields.forEach((field) => {
|
||||
!record[field] && delete data[field]
|
||||
})
|
||||
|
||||
const optionalTags = [
|
||||
'releaseType',
|
||||
'recordLabel',
|
||||
'grouping',
|
||||
'mood',
|
||||
'media',
|
||||
]
|
||||
optionalTags.forEach((field) => {
|
||||
!record?.tags?.[field.toLowerCase()] && delete data[field]
|
||||
})
|
||||
|
||||
return (
|
||||
<TableContainer>
|
||||
<Table aria-label="album details" size="small">
|
||||
<TableBody>
|
||||
{Object.keys(data).map((key) => {
|
||||
return (
|
||||
<TableRow key={`${record.id}-${key}`}>
|
||||
<TableCell
|
||||
component="th"
|
||||
scope="row"
|
||||
className={classes.tableCell}
|
||||
>
|
||||
{translate(`resources.album.fields.${key}`, {
|
||||
_: humanize(underscore(key)),
|
||||
})}
|
||||
:
|
||||
</TableCell>
|
||||
<TableCell align="left" className={classes.value}>
|
||||
{data[key]}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
})}
|
||||
<ParticipantsInfo record={record} classes={classes} />
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
)
|
||||
}
|
||||
|
||||
export default AlbumInfo
|
||||
253
ui/src/album/AlbumList.jsx
Normal file
@@ -0,0 +1,253 @@
|
||||
import { useSelector } from 'react-redux'
|
||||
import { Redirect, useLocation } from 'react-router-dom'
|
||||
import {
|
||||
AutocompleteArrayInput,
|
||||
AutocompleteInput,
|
||||
Filter,
|
||||
NullableBooleanInput,
|
||||
NumberInput,
|
||||
Pagination,
|
||||
ReferenceArrayInput,
|
||||
ReferenceInput,
|
||||
SearchInput,
|
||||
usePermissions,
|
||||
useRefresh,
|
||||
useTranslate,
|
||||
useVersion,
|
||||
} from 'react-admin'
|
||||
import FavoriteIcon from '@material-ui/icons/Favorite'
|
||||
import { withWidth } from '@material-ui/core'
|
||||
import {
|
||||
List,
|
||||
QuickFilter,
|
||||
Title,
|
||||
useAlbumsPerPage,
|
||||
useResourceRefresh,
|
||||
useSetToggleableFields,
|
||||
} from '../common'
|
||||
import AlbumListActions from './AlbumListActions'
|
||||
import AlbumTableView from './AlbumTableView'
|
||||
import AlbumGridView from './AlbumGridView'
|
||||
import albumLists, { defaultAlbumList } from './albumLists'
|
||||
import config from '../config'
|
||||
import AlbumInfo from './AlbumInfo'
|
||||
import ExpandInfoDialog from '../dialogs/ExpandInfoDialog'
|
||||
import { humanize } from 'inflection'
|
||||
import { makeStyles } from '@material-ui/core/styles'
|
||||
|
||||
const useStyles = makeStyles({
|
||||
chip: {
|
||||
margin: 0,
|
||||
height: '24px',
|
||||
},
|
||||
})
|
||||
|
||||
const formatReleaseType = (record) =>
|
||||
record?.tagValue ? humanize(record?.tagValue) : '-- None --'
|
||||
|
||||
const AlbumFilter = (props) => {
|
||||
const classes = useStyles()
|
||||
const translate = useTranslate()
|
||||
const { permissions } = usePermissions()
|
||||
const isAdmin = permissions === 'admin'
|
||||
return (
|
||||
<Filter {...props} variant={'outlined'}>
|
||||
<SearchInput id="search" source="name" alwaysOn />
|
||||
<ReferenceInput
|
||||
label={translate('resources.album.fields.artist')}
|
||||
source="artist_id"
|
||||
reference="artist"
|
||||
sort={{ field: 'name', order: 'ASC' }}
|
||||
filterToQuery={(searchText) => ({ name: [searchText] })}
|
||||
>
|
||||
<AutocompleteInput emptyText="-- None --" />
|
||||
</ReferenceInput>
|
||||
<ReferenceArrayInput
|
||||
label={translate('resources.album.fields.genre')}
|
||||
source="genre_id"
|
||||
reference="genre"
|
||||
perPage={0}
|
||||
sort={{ field: 'name', order: 'ASC' }}
|
||||
filterToQuery={(searchText) => ({ name: [searchText] })}
|
||||
>
|
||||
<AutocompleteArrayInput emptyText="-- None --" classes={classes} />
|
||||
</ReferenceArrayInput>
|
||||
<ReferenceInput
|
||||
label={translate('resources.album.fields.recordLabel')}
|
||||
source="recordlabel"
|
||||
reference="tag"
|
||||
perPage={0}
|
||||
sort={{ field: 'tagValue', order: 'ASC' }}
|
||||
filter={{ tag_name: 'recordlabel' }}
|
||||
filterToQuery={(searchText) => ({
|
||||
tag_value: [searchText],
|
||||
})}
|
||||
>
|
||||
<AutocompleteInput emptyText="-- None --" optionText="tagValue" />
|
||||
</ReferenceInput>
|
||||
<ReferenceArrayInput
|
||||
label={translate('resources.album.fields.grouping')}
|
||||
source="grouping"
|
||||
reference="tag"
|
||||
perPage={0}
|
||||
sort={{ field: 'tagValue', order: 'ASC' }}
|
||||
filter={{ tag_name: 'grouping' }}
|
||||
filterToQuery={(searchText) => ({
|
||||
tag_value: [searchText],
|
||||
})}
|
||||
>
|
||||
<AutocompleteArrayInput
|
||||
emptyText="-- None --"
|
||||
classes={classes}
|
||||
optionText="tagValue"
|
||||
/>
|
||||
</ReferenceArrayInput>
|
||||
<ReferenceArrayInput
|
||||
label={translate('resources.album.fields.mood')}
|
||||
source="mood"
|
||||
reference="tag"
|
||||
perPage={0}
|
||||
sort={{ field: 'tagValue', order: 'ASC' }}
|
||||
filter={{ tag_name: 'mood' }}
|
||||
filterToQuery={(searchText) => ({
|
||||
tag_value: [searchText],
|
||||
})}
|
||||
>
|
||||
<AutocompleteArrayInput
|
||||
emptyText="-- None --"
|
||||
classes={classes}
|
||||
optionText="tagValue"
|
||||
/>
|
||||
</ReferenceArrayInput>
|
||||
<ReferenceInput
|
||||
label={translate('resources.album.fields.media')}
|
||||
source="media"
|
||||
reference="tag"
|
||||
perPage={0}
|
||||
sort={{ field: 'tagValue', order: 'ASC' }}
|
||||
filter={{ tag_name: 'media' }}
|
||||
filterToQuery={(searchText) => ({
|
||||
tag_value: [searchText],
|
||||
})}
|
||||
>
|
||||
<AutocompleteInput emptyText="-- None --" optionText="tagValue" />
|
||||
</ReferenceInput>
|
||||
<ReferenceInput
|
||||
label={translate('resources.album.fields.releaseType')}
|
||||
source="releasetype"
|
||||
reference="tag"
|
||||
perPage={0}
|
||||
sort={{ field: 'tagValue', order: 'ASC' }}
|
||||
filter={{ tag_name: 'releasetype' }}
|
||||
filterToQuery={(searchText) => ({
|
||||
tag_value: [searchText],
|
||||
})}
|
||||
>
|
||||
<AutocompleteInput
|
||||
emptyText="-- None --"
|
||||
optionText={formatReleaseType}
|
||||
/>
|
||||
</ReferenceInput>
|
||||
<NullableBooleanInput source="compilation" />
|
||||
<NumberInput source="year" />
|
||||
{config.enableFavourites && (
|
||||
<QuickFilter
|
||||
source="starred"
|
||||
label={<FavoriteIcon fontSize={'small'} />}
|
||||
defaultValue={true}
|
||||
/>
|
||||
)}
|
||||
{isAdmin && <NullableBooleanInput source="missing" />}
|
||||
</Filter>
|
||||
)
|
||||
}
|
||||
|
||||
const AlbumListTitle = ({ albumListType }) => {
|
||||
const translate = useTranslate()
|
||||
let title = translate('resources.album.name', { smart_count: 2 })
|
||||
if (albumListType) {
|
||||
let listTitle = translate(`resources.album.lists.${albumListType}`, {
|
||||
smart_count: 2,
|
||||
})
|
||||
title = `${title} - ${listTitle}`
|
||||
}
|
||||
return <Title subTitle={title} args={{ smart_count: 2 }} />
|
||||
}
|
||||
|
||||
const randomStartingSeed = Math.random().toString()
|
||||
|
||||
const AlbumList = (props) => {
|
||||
const { width } = props
|
||||
const albumView = useSelector((state) => state.albumView)
|
||||
const [perPage, perPageOptions] = useAlbumsPerPage(width)
|
||||
const location = useLocation()
|
||||
const version = useVersion()
|
||||
const refresh = useRefresh()
|
||||
useResourceRefresh('album')
|
||||
|
||||
const seed = `${randomStartingSeed}-${version}`
|
||||
|
||||
const albumListType = location.pathname
|
||||
.replace(/^\/album/, '')
|
||||
.replace(/^\//, '')
|
||||
|
||||
// Workaround to force album columns to appear the first time.
|
||||
// See https://github.com/navidrome/navidrome/pull/923#issuecomment-833004842
|
||||
// TODO: Find a better solution
|
||||
useSetToggleableFields(
|
||||
'album',
|
||||
[
|
||||
'artist',
|
||||
'songCount',
|
||||
'playCount',
|
||||
'year',
|
||||
'mood',
|
||||
'duration',
|
||||
'rating',
|
||||
'size',
|
||||
'createdAt',
|
||||
],
|
||||
['createdAt', 'size'],
|
||||
)
|
||||
|
||||
// If it does not have filter/sort params (usually coming from Menu),
|
||||
// reload with correct filter/sort params
|
||||
if (!location.search) {
|
||||
const type =
|
||||
albumListType || localStorage.getItem('defaultView') || defaultAlbumList
|
||||
const listParams = albumLists[type]
|
||||
if (type === 'random') {
|
||||
refresh()
|
||||
}
|
||||
if (listParams) {
|
||||
return <Redirect to={`/album/${type}?${listParams.params}`} />
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<List
|
||||
{...props}
|
||||
exporter={false}
|
||||
bulkActionButtons={false}
|
||||
filter={{ seed }}
|
||||
actions={<AlbumListActions />}
|
||||
filters={<AlbumFilter />}
|
||||
perPage={perPage}
|
||||
pagination={<Pagination rowsPerPageOptions={perPageOptions} />}
|
||||
title={<AlbumListTitle albumListType={albumListType} />}
|
||||
>
|
||||
{albumView.grid ? (
|
||||
<AlbumGridView albumListType={albumListType} {...props} />
|
||||
) : (
|
||||
<AlbumTableView {...props} />
|
||||
)}
|
||||
</List>
|
||||
<ExpandInfoDialog content={<AlbumInfo />} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const AlbumListWithWidth = withWidth()(AlbumList)
|
||||
|
||||
export default AlbumListWithWidth
|
||||
120
ui/src/album/AlbumListActions.jsx
Normal file
@@ -0,0 +1,120 @@
|
||||
import React, { cloneElement } from 'react'
|
||||
import {
|
||||
Button,
|
||||
sanitizeListRestProps,
|
||||
TopToolbar,
|
||||
useTranslate,
|
||||
} from 'react-admin'
|
||||
import {
|
||||
ButtonGroup,
|
||||
useMediaQuery,
|
||||
Typography,
|
||||
makeStyles,
|
||||
} from '@material-ui/core'
|
||||
import ViewHeadlineIcon from '@material-ui/icons/ViewHeadline'
|
||||
import ViewModuleIcon from '@material-ui/icons/ViewModule'
|
||||
import { useDispatch, useSelector } from 'react-redux'
|
||||
import { albumViewGrid, albumViewTable } from '../actions'
|
||||
import { ToggleFieldsMenu } from '../common'
|
||||
|
||||
const useStyles = makeStyles({
|
||||
title: { margin: '1rem' },
|
||||
buttonGroup: { width: '100%', justifyContent: 'center' },
|
||||
leftButton: { paddingRight: '0.5rem' },
|
||||
rightButton: { paddingLeft: '0.5rem' },
|
||||
})
|
||||
|
||||
const AlbumViewToggler = React.forwardRef(
|
||||
({ showTitle = true, disableElevation, fullWidth }, ref) => {
|
||||
const dispatch = useDispatch()
|
||||
const albumView = useSelector((state) => state.albumView)
|
||||
const classes = useStyles()
|
||||
const translate = useTranslate()
|
||||
return (
|
||||
<div ref={ref}>
|
||||
{showTitle && (
|
||||
<Typography className={classes.title}>
|
||||
{translate('ra.toggleFieldsMenu.layout')}
|
||||
</Typography>
|
||||
)}
|
||||
<ButtonGroup
|
||||
variant="text"
|
||||
color="primary"
|
||||
aria-label="text primary button group"
|
||||
className={classes.buttonGroup}
|
||||
>
|
||||
<Button
|
||||
size="small"
|
||||
className={classes.leftButton}
|
||||
label={translate('ra.toggleFieldsMenu.grid')}
|
||||
color={albumView.grid ? 'primary' : 'secondary'}
|
||||
onClick={() => dispatch(albumViewGrid())}
|
||||
>
|
||||
<ViewModuleIcon fontSize="inherit" />
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
className={classes.rightButton}
|
||||
label={translate('ra.toggleFieldsMenu.table')}
|
||||
color={albumView.grid ? 'secondary' : 'primary'}
|
||||
onClick={() => dispatch(albumViewTable())}
|
||||
>
|
||||
<ViewHeadlineIcon fontSize="inherit" />
|
||||
</Button>
|
||||
</ButtonGroup>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
AlbumViewToggler.displayName = 'AlbumViewToggler'
|
||||
|
||||
const AlbumListActions = ({
|
||||
currentSort,
|
||||
className,
|
||||
resource,
|
||||
filters,
|
||||
displayedFilters,
|
||||
filterValues,
|
||||
permanentFilter,
|
||||
exporter,
|
||||
basePath,
|
||||
selectedIds,
|
||||
onUnselectItems,
|
||||
showFilter,
|
||||
maxResults,
|
||||
total,
|
||||
fullWidth,
|
||||
...rest
|
||||
}) => {
|
||||
const isNotSmall = useMediaQuery((theme) => theme.breakpoints.up('sm'))
|
||||
const albumView = useSelector((state) => state.albumView)
|
||||
return (
|
||||
<TopToolbar className={className} {...sanitizeListRestProps(rest)}>
|
||||
{filters &&
|
||||
cloneElement(filters, {
|
||||
resource,
|
||||
showFilter,
|
||||
displayedFilters,
|
||||
filterValues,
|
||||
context: 'button',
|
||||
})}
|
||||
{isNotSmall ? (
|
||||
<ToggleFieldsMenu
|
||||
resource="album"
|
||||
topbarComponent={AlbumViewToggler}
|
||||
hideColumns={albumView.grid}
|
||||
/>
|
||||
) : (
|
||||
<AlbumViewToggler showTitle={false} />
|
||||
)}
|
||||
</TopToolbar>
|
||||
)
|
||||
}
|
||||
|
||||
AlbumListActions.defaultProps = {
|
||||
selectedIds: [],
|
||||
onUnselectItems: () => null,
|
||||
}
|
||||
|
||||
export default AlbumListActions
|
||||
69
ui/src/album/AlbumShow.jsx
Normal file
@@ -0,0 +1,69 @@
|
||||
import React from 'react'
|
||||
import {
|
||||
ReferenceManyField,
|
||||
ShowContextProvider,
|
||||
useShowContext,
|
||||
useShowController,
|
||||
Title as RaTitle,
|
||||
} from 'react-admin'
|
||||
import { makeStyles } from '@material-ui/core/styles'
|
||||
import AlbumSongs from './AlbumSongs'
|
||||
import AlbumDetails from './AlbumDetails'
|
||||
import AlbumActions from './AlbumActions'
|
||||
import { useResourceRefresh, Title } from '../common'
|
||||
|
||||
const useStyles = makeStyles(
|
||||
(theme) => ({
|
||||
albumActions: {
|
||||
width: '100%',
|
||||
},
|
||||
}),
|
||||
{
|
||||
name: 'NDAlbumShow',
|
||||
},
|
||||
)
|
||||
|
||||
const AlbumShowLayout = (props) => {
|
||||
const { loading, ...context } = useShowContext(props)
|
||||
const { record } = context
|
||||
const classes = useStyles()
|
||||
useResourceRefresh('album', 'song')
|
||||
|
||||
return (
|
||||
<>
|
||||
{record && <RaTitle title={<Title subTitle={record.name} />} />}
|
||||
{record && <AlbumDetails {...context} />}
|
||||
{record && (
|
||||
<ReferenceManyField
|
||||
{...context}
|
||||
addLabel={false}
|
||||
reference="song"
|
||||
target="album_id"
|
||||
sort={{ field: 'album', order: 'ASC' }}
|
||||
perPage={0}
|
||||
pagination={null}
|
||||
>
|
||||
<AlbumSongs
|
||||
resource={'song'}
|
||||
exporter={false}
|
||||
album={record}
|
||||
actions={
|
||||
<AlbumActions className={classes.albumActions} record={record} />
|
||||
}
|
||||
/>
|
||||
</ReferenceManyField>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const AlbumShow = (props) => {
|
||||
const controllerProps = useShowController(props)
|
||||
return (
|
||||
<ShowContextProvider value={controllerProps}>
|
||||
<AlbumShowLayout {...props} {...controllerProps} />
|
||||
</ShowContextProvider>
|
||||
)
|
||||
}
|
||||
|
||||
export default AlbumShow
|
||||
219
ui/src/album/AlbumSongs.jsx
Normal file
@@ -0,0 +1,219 @@
|
||||
import React, { useMemo } from 'react'
|
||||
import {
|
||||
BulkActionsToolbar,
|
||||
FunctionField,
|
||||
ListToolbar,
|
||||
NumberField,
|
||||
TextField,
|
||||
useListContext,
|
||||
useVersion,
|
||||
} from 'react-admin'
|
||||
import clsx from 'clsx'
|
||||
import { useDispatch } from 'react-redux'
|
||||
import { Card, useMediaQuery } from '@material-ui/core'
|
||||
import { makeStyles } from '@material-ui/core/styles'
|
||||
import FavoriteBorderIcon from '@material-ui/icons/FavoriteBorder'
|
||||
import { playTracks } from '../actions'
|
||||
import {
|
||||
ArtistLinkField,
|
||||
DateField,
|
||||
DurationField,
|
||||
QualityInfo,
|
||||
RatingField,
|
||||
SizeField,
|
||||
SongBulkActions,
|
||||
SongContextMenu,
|
||||
SongDatagrid,
|
||||
SongInfo,
|
||||
SongTitleField,
|
||||
useResourceRefresh,
|
||||
useSelectedFields,
|
||||
} from '../common'
|
||||
import config from '../config'
|
||||
import ExpandInfoDialog from '../dialogs/ExpandInfoDialog'
|
||||
import { removeAlbumCommentsFromSongs } from './utils.js'
|
||||
|
||||
const useStyles = makeStyles(
|
||||
(theme) => ({
|
||||
root: {},
|
||||
main: {
|
||||
display: 'flex',
|
||||
},
|
||||
content: {
|
||||
marginTop: 0,
|
||||
transition: theme.transitions.create('margin-top'),
|
||||
position: 'relative',
|
||||
flex: '1 1 auto',
|
||||
[theme.breakpoints.down('xs')]: {
|
||||
boxShadow: 'none',
|
||||
},
|
||||
},
|
||||
bulkActionsDisplayed: {
|
||||
marginTop: -theme.spacing(8),
|
||||
transition: theme.transitions.create('margin-top'),
|
||||
},
|
||||
actions: {
|
||||
zIndex: 2,
|
||||
display: 'flex',
|
||||
justifyContent: 'flex-end',
|
||||
flexWrap: 'wrap',
|
||||
},
|
||||
noResults: { padding: 20 },
|
||||
columnIcon: {
|
||||
marginLeft: '3px',
|
||||
marginTop: '-2px',
|
||||
verticalAlign: 'text-top',
|
||||
},
|
||||
toolbar: {
|
||||
justifyContent: 'flex-start',
|
||||
},
|
||||
row: {
|
||||
'&:hover': {
|
||||
'& $contextMenu': {
|
||||
visibility: 'visible',
|
||||
},
|
||||
'& $ratingField': {
|
||||
visibility: 'visible',
|
||||
},
|
||||
},
|
||||
},
|
||||
contextMenu: {
|
||||
visibility: (props) => (props.isDesktop ? 'hidden' : 'visible'),
|
||||
},
|
||||
ratingField: {
|
||||
visibility: 'hidden',
|
||||
},
|
||||
}),
|
||||
{ name: 'RaList' },
|
||||
)
|
||||
|
||||
const AlbumSongs = (props) => {
|
||||
const { data, ids } = props
|
||||
const isDesktop = useMediaQuery((theme) => theme.breakpoints.up('md'))
|
||||
const classes = useStyles({ isDesktop })
|
||||
const dispatch = useDispatch()
|
||||
const version = useVersion()
|
||||
useResourceRefresh('song', 'album')
|
||||
|
||||
const toggleableFields = useMemo(() => {
|
||||
return {
|
||||
trackNumber: isDesktop && (
|
||||
<TextField source="trackNumber" label="#" sortable={false} />
|
||||
),
|
||||
title: (
|
||||
<SongTitleField
|
||||
source="title"
|
||||
sortable={false}
|
||||
showTrackNumbers={!isDesktop}
|
||||
/>
|
||||
),
|
||||
artist: isDesktop && <ArtistLinkField source="artist" sortable={false} />,
|
||||
duration: <DurationField source="duration" sortable={false} />,
|
||||
year: isDesktop && (
|
||||
<FunctionField
|
||||
source="year"
|
||||
render={(r) => r.year || ''}
|
||||
sortable={false}
|
||||
/>
|
||||
),
|
||||
playCount: isDesktop && (
|
||||
<NumberField source="playCount" sortable={false} />
|
||||
),
|
||||
playDate: <DateField source="playDate" sortable={false} showTime />,
|
||||
quality: isDesktop && <QualityInfo source="quality" sortable={false} />,
|
||||
size: isDesktop && <SizeField source="size" sortable={false} />,
|
||||
channels: isDesktop && <NumberField source="channels" sortable={false} />,
|
||||
bpm: isDesktop && <NumberField source="bpm" sortable={false} />,
|
||||
genre: <TextField source="genre" sortable={false} />,
|
||||
mood: isDesktop && (
|
||||
<FunctionField
|
||||
source="mood"
|
||||
render={(r) => r.tags?.mood?.[0] ?? ''}
|
||||
sortable={false}
|
||||
/>
|
||||
),
|
||||
rating: isDesktop && config.enableStarRating && (
|
||||
<RatingField
|
||||
resource={'song'}
|
||||
source="rating"
|
||||
sortable={false}
|
||||
className={classes.ratingField}
|
||||
/>
|
||||
),
|
||||
}
|
||||
}, [isDesktop, classes.ratingField])
|
||||
|
||||
const columns = useSelectedFields({
|
||||
resource: 'albumSong',
|
||||
columns: toggleableFields,
|
||||
omittedColumns: ['title'],
|
||||
defaultOff: [
|
||||
'channels',
|
||||
'bpm',
|
||||
'year',
|
||||
'playCount',
|
||||
'playDate',
|
||||
'size',
|
||||
'mood',
|
||||
'genre',
|
||||
],
|
||||
})
|
||||
|
||||
const bulkActionsLabel = isDesktop
|
||||
? 'ra.action.bulk_actions'
|
||||
: 'ra.action.bulk_actions_mobile'
|
||||
|
||||
return (
|
||||
<>
|
||||
<ListToolbar
|
||||
classes={{ toolbar: classes.toolbar }}
|
||||
actions={props.actions}
|
||||
{...props}
|
||||
/>
|
||||
<div className={classes.main}>
|
||||
<Card
|
||||
className={clsx(classes.content, {
|
||||
[classes.bulkActionsDisplayed]: props.selectedIds.length > 0,
|
||||
})}
|
||||
key={version}
|
||||
>
|
||||
<BulkActionsToolbar {...props} label={bulkActionsLabel}>
|
||||
<SongBulkActions />
|
||||
</BulkActionsToolbar>
|
||||
<SongDatagrid
|
||||
rowClick={(id) => dispatch(playTracks(data, ids, id))}
|
||||
{...props}
|
||||
hasBulkActions={true}
|
||||
showDiscSubtitles={true}
|
||||
contextAlwaysVisible={!isDesktop}
|
||||
classes={{ row: classes.row }}
|
||||
>
|
||||
{columns}
|
||||
<SongContextMenu
|
||||
source={'starred'}
|
||||
sortable={false}
|
||||
className={classes.contextMenu}
|
||||
label={
|
||||
config.enableFavourites && (
|
||||
<FavoriteBorderIcon
|
||||
fontSize={'small'}
|
||||
className={classes.columnIcon}
|
||||
/>
|
||||
)
|
||||
}
|
||||
/>
|
||||
</SongDatagrid>
|
||||
</Card>
|
||||
</div>
|
||||
<ExpandInfoDialog content={<SongInfo />} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const SanitizedAlbumSongs = (props) => {
|
||||
removeAlbumCommentsFromSongs(props)
|
||||
const { loaded, loading, total, ...rest } = useListContext(props)
|
||||
return <>{loaded && <AlbumSongs {...rest} actions={props.actions} />}</>
|
||||
}
|
||||
|
||||
export default SanitizedAlbumSongs
|
||||
190
ui/src/album/AlbumTableView.jsx
Normal file
@@ -0,0 +1,190 @@
|
||||
import React, { useMemo } from 'react'
|
||||
import {
|
||||
Datagrid,
|
||||
DatagridBody,
|
||||
DatagridRow,
|
||||
DateField,
|
||||
NumberField,
|
||||
TextField,
|
||||
FunctionField,
|
||||
} from 'react-admin'
|
||||
import { useMediaQuery } from '@material-ui/core'
|
||||
import FavoriteBorderIcon from '@material-ui/icons/FavoriteBorder'
|
||||
import { makeStyles } from '@material-ui/core/styles'
|
||||
import { useDrag } from 'react-dnd'
|
||||
import {
|
||||
ArtistLinkField,
|
||||
DurationField,
|
||||
RangeField,
|
||||
SimpleList,
|
||||
AlbumContextMenu,
|
||||
RatingField,
|
||||
useSelectedFields,
|
||||
SizeField,
|
||||
} from '../common'
|
||||
import config from '../config'
|
||||
import { DraggableTypes } from '../consts'
|
||||
import clsx from 'clsx'
|
||||
|
||||
const useStyles = makeStyles({
|
||||
columnIcon: {
|
||||
marginLeft: '3px',
|
||||
marginTop: '-2px',
|
||||
verticalAlign: 'text-top',
|
||||
},
|
||||
row: {
|
||||
'&:hover': {
|
||||
'& $contextMenu': {
|
||||
visibility: 'visible',
|
||||
},
|
||||
'& $ratingField': {
|
||||
visibility: 'visible',
|
||||
},
|
||||
},
|
||||
},
|
||||
missingRow: {
|
||||
opacity: 0.3,
|
||||
},
|
||||
tableCell: {
|
||||
width: '17.5%',
|
||||
},
|
||||
contextMenu: {
|
||||
visibility: 'hidden',
|
||||
},
|
||||
ratingField: {
|
||||
visibility: 'hidden',
|
||||
},
|
||||
})
|
||||
|
||||
const AlbumDatagridRow = (props) => {
|
||||
const { record, className } = props
|
||||
const classes = useStyles()
|
||||
const [, dragAlbumRef] = useDrag(
|
||||
() => ({
|
||||
type: DraggableTypes.ALBUM,
|
||||
item: { albumIds: [record?.id] },
|
||||
options: { dropEffect: 'copy' },
|
||||
}),
|
||||
[record],
|
||||
)
|
||||
const computedClasses = clsx(
|
||||
className,
|
||||
classes.row,
|
||||
record.missing && classes.missingRow,
|
||||
)
|
||||
return (
|
||||
<DatagridRow ref={dragAlbumRef} {...props} className={computedClasses} />
|
||||
)
|
||||
}
|
||||
|
||||
const AlbumDatagridBody = (props) => (
|
||||
<DatagridBody {...props} row={<AlbumDatagridRow />} />
|
||||
)
|
||||
|
||||
const AlbumDatagrid = (props) => (
|
||||
<Datagrid {...props} body={<AlbumDatagridBody />} />
|
||||
)
|
||||
|
||||
const AlbumTableView = ({
|
||||
hasShow,
|
||||
hasEdit,
|
||||
hasList,
|
||||
syncWithLocation,
|
||||
...rest
|
||||
}) => {
|
||||
const classes = useStyles()
|
||||
const isDesktop = useMediaQuery((theme) => theme.breakpoints.up('md'))
|
||||
const isXsmall = useMediaQuery((theme) => theme.breakpoints.down('xs'))
|
||||
|
||||
const toggleableFields = useMemo(() => {
|
||||
return {
|
||||
artist: <ArtistLinkField source="albumArtist" />,
|
||||
songCount: isDesktop && (
|
||||
<NumberField source="songCount" sortByOrder={'DESC'} />
|
||||
),
|
||||
playCount: isDesktop && (
|
||||
<NumberField source="playCount" sortByOrder={'DESC'} />
|
||||
),
|
||||
year: (
|
||||
<RangeField source={'year'} sortBy={'max_year'} sortByOrder={'DESC'} />
|
||||
),
|
||||
mood: isDesktop && (
|
||||
<FunctionField
|
||||
source="mood"
|
||||
render={(r) => r.tags?.mood?.[0] || ''}
|
||||
sortable={false}
|
||||
/>
|
||||
),
|
||||
duration: isDesktop && <DurationField source="duration" />,
|
||||
size: isDesktop && <SizeField source="size" />,
|
||||
rating: config.enableStarRating && (
|
||||
<RatingField
|
||||
source={'rating'}
|
||||
resource={'album'}
|
||||
sortByOrder={'DESC'}
|
||||
className={classes.ratingField}
|
||||
/>
|
||||
),
|
||||
createdAt: isDesktop && <DateField source="createdAt" showTime />,
|
||||
}
|
||||
}, [classes.ratingField, isDesktop])
|
||||
|
||||
const columns = useSelectedFields({
|
||||
resource: 'album',
|
||||
columns: toggleableFields,
|
||||
defaultOff: ['createdAt', 'size', 'mood'],
|
||||
})
|
||||
|
||||
return isXsmall ? (
|
||||
<SimpleList
|
||||
primaryText={(r) => r.name}
|
||||
secondaryText={(r) => (
|
||||
<>
|
||||
{r.albumArtist}
|
||||
{config.enableStarRating && (
|
||||
<>
|
||||
<br />
|
||||
<RatingField
|
||||
record={r}
|
||||
sortByOrder={'DESC'}
|
||||
source={'rating'}
|
||||
resource={'album'}
|
||||
size={'small'}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
tertiaryText={(r) => (
|
||||
<>
|
||||
<RangeField record={r} source={'year'} sortBy={'max_year'} />
|
||||
|
||||
</>
|
||||
)}
|
||||
linkType={'show'}
|
||||
rightIcon={(r) => <AlbumContextMenu record={r} />}
|
||||
{...rest}
|
||||
/>
|
||||
) : (
|
||||
<AlbumDatagrid rowClick={'show'} classes={{ row: classes.row }} {...rest}>
|
||||
<TextField source="name" />
|
||||
{columns}
|
||||
<AlbumContextMenu
|
||||
source={'starred_at'}
|
||||
sortByOrder={'DESC'}
|
||||
sortable={config.enableFavourites}
|
||||
className={classes.contextMenu}
|
||||
label={
|
||||
config.enableFavourites && (
|
||||
<FavoriteBorderIcon
|
||||
fontSize={'small'}
|
||||
className={classes.columnIcon}
|
||||
/>
|
||||
)
|
||||
}
|
||||
/>
|
||||
</AlbumDatagrid>
|
||||
)
|
||||
}
|
||||
|
||||
export default AlbumTableView
|
||||
253
ui/src/album/__snapshots__/AlbumDetails.test.jsx.snap
Normal file
@@ -0,0 +1,253 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`Details component > Desktop view > renders correctly with all date fields 1`] = `
|
||||
<div>
|
||||
<span>
|
||||
resources.album.fields.originalDate Mar 15, 2018
|
||||
</span>
|
||||
·
|
||||
<span>
|
||||
resources.album.fields.releaseDate Jun 15, 2020
|
||||
</span>
|
||||
·
|
||||
<span>
|
||||
12 resources.song.name
|
||||
</span>
|
||||
·
|
||||
<span>
|
||||
<span>
|
||||
01:00:00
|
||||
</span>
|
||||
</span>
|
||||
·
|
||||
<span>
|
||||
<span
|
||||
class="makeStyles-root-6"
|
||||
>
|
||||
100 KB
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`Details component > Desktop view > renders correctly with date 1`] = `
|
||||
<div>
|
||||
<span>
|
||||
May 1, 2020
|
||||
</span>
|
||||
·
|
||||
<span>
|
||||
12 resources.song.name
|
||||
</span>
|
||||
·
|
||||
<span>
|
||||
<span>
|
||||
01:00:00
|
||||
</span>
|
||||
</span>
|
||||
·
|
||||
<span>
|
||||
<span
|
||||
class="makeStyles-root-2"
|
||||
>
|
||||
100 KB
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`Details component > Desktop view > renders correctly with date and originalDate 1`] = `
|
||||
<div>
|
||||
<span>
|
||||
resources.album.fields.originalDate Mar 15, 2018
|
||||
</span>
|
||||
·
|
||||
<span>
|
||||
12 resources.song.name
|
||||
</span>
|
||||
·
|
||||
<span>
|
||||
<span>
|
||||
01:00:00
|
||||
</span>
|
||||
</span>
|
||||
·
|
||||
<span>
|
||||
<span
|
||||
class="makeStyles-root-4"
|
||||
>
|
||||
100 KB
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`Details component > Desktop view > renders correctly with just year range 1`] = `
|
||||
<div>
|
||||
<span>
|
||||
12 resources.song.name
|
||||
</span>
|
||||
·
|
||||
<span>
|
||||
<span>
|
||||
01:00:00
|
||||
</span>
|
||||
</span>
|
||||
·
|
||||
<span>
|
||||
<span
|
||||
class="makeStyles-root-1"
|
||||
>
|
||||
100 KB
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`Details component > Desktop view > renders correctly with originalDate 1`] = `
|
||||
<div>
|
||||
<span>
|
||||
resources.album.fields.originalDate Mar 15, 2018
|
||||
</span>
|
||||
·
|
||||
<span>
|
||||
12 resources.song.name
|
||||
</span>
|
||||
·
|
||||
<span>
|
||||
<span>
|
||||
01:00:00
|
||||
</span>
|
||||
</span>
|
||||
·
|
||||
<span>
|
||||
<span
|
||||
class="makeStyles-root-3"
|
||||
>
|
||||
100 KB
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`Details component > Desktop view > renders correctly with releaseDate 1`] = `
|
||||
<div>
|
||||
<span>
|
||||
resources.album.fields.releaseDate Jun 15, 2020
|
||||
</span>
|
||||
·
|
||||
<span>
|
||||
12 resources.song.name
|
||||
</span>
|
||||
·
|
||||
<span>
|
||||
<span>
|
||||
01:00:00
|
||||
</span>
|
||||
</span>
|
||||
·
|
||||
<span>
|
||||
<span
|
||||
class="makeStyles-root-5"
|
||||
>
|
||||
100 KB
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`Details component > Mobile view > renders correctly with all date fields 1`] = `
|
||||
<div>
|
||||
<span>
|
||||
♫ Mar 15, 2018
|
||||
</span>
|
||||
·
|
||||
<span>
|
||||
○ Jun 15, 2020
|
||||
</span>
|
||||
·
|
||||
<span>
|
||||
12 resources.song.name
|
||||
</span>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`Details component > Mobile view > renders correctly with date 1`] = `
|
||||
<div>
|
||||
<span>
|
||||
♫ May 1, 2020
|
||||
</span>
|
||||
·
|
||||
<span>
|
||||
12 resources.song.name
|
||||
</span>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`Details component > Mobile view > renders correctly with date and originalDate 1`] = `
|
||||
<div>
|
||||
<span>
|
||||
♫ Mar 15, 2018
|
||||
</span>
|
||||
·
|
||||
<span>
|
||||
12 resources.song.name
|
||||
</span>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`Details component > Mobile view > renders correctly with just year range 1`] = `
|
||||
<div>
|
||||
<span>
|
||||
12 resources.song.name
|
||||
</span>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`Details component > Mobile view > renders correctly with no date fields 1`] = `
|
||||
<div>
|
||||
<span>
|
||||
12 resources.song.name
|
||||
</span>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`Details component > Mobile view > renders correctly with originalDate 1`] = `
|
||||
<div>
|
||||
<span>
|
||||
♫ Mar 15, 2018
|
||||
</span>
|
||||
·
|
||||
<span>
|
||||
12 resources.song.name
|
||||
</span>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`Details component > Mobile view > renders correctly with originalYear range 1`] = `
|
||||
<div>
|
||||
<span>
|
||||
12 resources.song.name
|
||||
</span>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`Details component > Mobile view > renders correctly with releaseDate 1`] = `
|
||||
<div>
|
||||
<span>
|
||||
Jun 15, 2020
|
||||
</span>
|
||||
·
|
||||
<span>
|
||||
12 resources.song.name
|
||||
</span>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`Details component > Mobile view > renders correctly with year range (start and end years) 1`] = `
|
||||
<div>
|
||||
<span>
|
||||
12 resources.song.name
|
||||
</span>
|
||||
</div>
|
||||
`;
|
||||
83
ui/src/album/albumLists.jsx
Normal file
@@ -0,0 +1,83 @@
|
||||
import React from 'react'
|
||||
import ShuffleIcon from '@material-ui/icons/Shuffle'
|
||||
import LibraryAddIcon from '@material-ui/icons/LibraryAdd'
|
||||
import VideoLibraryIcon from '@material-ui/icons/VideoLibrary'
|
||||
import RepeatIcon from '@material-ui/icons/Repeat'
|
||||
import AlbumIcon from '@material-ui/icons/Album'
|
||||
import FavoriteIcon from '@material-ui/icons/Favorite'
|
||||
import FavoriteBorderIcon from '@material-ui/icons/FavoriteBorder'
|
||||
import StarIcon from '@material-ui/icons/Star'
|
||||
import StarBorderIcon from '@material-ui/icons/StarBorder'
|
||||
import AlbumOutlinedIcon from '@material-ui/icons/AlbumOutlined'
|
||||
import LibraryAddOutlinedIcon from '@material-ui/icons/LibraryAddOutlined'
|
||||
import VideoLibraryOutlinedIcon from '@material-ui/icons/VideoLibraryOutlined'
|
||||
import config from '../config'
|
||||
import DynamicMenuIcon from '../layout/DynamicMenuIcon'
|
||||
|
||||
const albumLists = {
|
||||
all: {
|
||||
icon: (
|
||||
<DynamicMenuIcon
|
||||
path={'album/all'}
|
||||
icon={AlbumOutlinedIcon}
|
||||
activeIcon={AlbumIcon}
|
||||
/>
|
||||
),
|
||||
params: 'sort=name&order=ASC&filter={}',
|
||||
},
|
||||
random: {
|
||||
icon: <ShuffleIcon />,
|
||||
params: 'sort=random&order=ASC&filter={}',
|
||||
},
|
||||
...(config.enableFavourites && {
|
||||
starred: {
|
||||
icon: (
|
||||
<DynamicMenuIcon
|
||||
path={'album/starred'}
|
||||
icon={FavoriteBorderIcon}
|
||||
activeIcon={FavoriteIcon}
|
||||
/>
|
||||
),
|
||||
params: 'sort=starred_at&order=DESC&filter={"starred":true}',
|
||||
},
|
||||
}),
|
||||
...(config.enableStarRating && {
|
||||
topRated: {
|
||||
icon: (
|
||||
<DynamicMenuIcon
|
||||
path={'album/topRated'}
|
||||
icon={StarBorderIcon}
|
||||
activeIcon={StarIcon}
|
||||
/>
|
||||
),
|
||||
params: 'sort=rating&order=DESC&filter={"has_rating":true}',
|
||||
},
|
||||
}),
|
||||
recentlyAdded: {
|
||||
icon: (
|
||||
<DynamicMenuIcon
|
||||
path={'album/recentlyAdded'}
|
||||
icon={LibraryAddOutlinedIcon}
|
||||
activeIcon={LibraryAddIcon}
|
||||
/>
|
||||
),
|
||||
params: 'sort=recently_added&order=DESC&filter={}',
|
||||
},
|
||||
recentlyPlayed: {
|
||||
icon: (
|
||||
<DynamicMenuIcon
|
||||
path={'album/recentlyPlayed'}
|
||||
icon={VideoLibraryOutlinedIcon}
|
||||
activeIcon={VideoLibraryIcon}
|
||||
/>
|
||||
),
|
||||
params: 'sort=play_date&order=DESC&filter={"recently_played":true}',
|
||||
},
|
||||
mostPlayed: {
|
||||
icon: <RepeatIcon />,
|
||||
params: 'sort=play_count&order=DESC&filter={"recently_played":true}',
|
||||
},
|
||||
}
|
||||
|
||||
export default albumLists
|
||||
export const defaultAlbumList = 'recentlyAdded'
|
||||
7
ui/src/album/index.jsx
Normal file
@@ -0,0 +1,7 @@
|
||||
import AlbumList from './AlbumList'
|
||||
import AlbumShow from './AlbumShow'
|
||||
|
||||
export default {
|
||||
list: AlbumList,
|
||||
show: AlbumShow,
|
||||
}
|
||||
7
ui/src/album/utils.js
Normal file
@@ -0,0 +1,7 @@
|
||||
export const removeAlbumCommentsFromSongs = ({ album, data }) => {
|
||||
if (album?.comment && data) {
|
||||
Object.values(data).forEach((song) => {
|
||||
song.comment = ''
|
||||
})
|
||||
}
|
||||
}
|
||||
24
ui/src/album/utils.test.js
Normal file
@@ -0,0 +1,24 @@
|
||||
import { removeAlbumCommentsFromSongs } from './utils.js'
|
||||
|
||||
describe('removeAlbumCommentsFromSongs', () => {
|
||||
const data = { 1: { comment: 'one' }, 2: { comment: 'two' } }
|
||||
it('does not remove song comments if album does not have comment', () => {
|
||||
const album = { comment: '' }
|
||||
removeAlbumCommentsFromSongs({ album, data })
|
||||
expect(data['1'].comment).toEqual('one')
|
||||
expect(data['2'].comment).toEqual('two')
|
||||
})
|
||||
|
||||
it('removes song comments if album has comment', () => {
|
||||
const album = { comment: 'test' }
|
||||
removeAlbumCommentsFromSongs({ album, data })
|
||||
expect(data['1'].comment).toEqual('')
|
||||
expect(data['2'].comment).toEqual('')
|
||||
})
|
||||
|
||||
it('does not crash if album and data arr not available', () => {
|
||||
expect(() => {
|
||||
removeAlbumCommentsFromSongs({})
|
||||
}).not.toThrow()
|
||||
})
|
||||
})
|
||||
148
ui/src/artist/ArtistActions.jsx
Normal file
@@ -0,0 +1,148 @@
|
||||
import React from 'react'
|
||||
import PropTypes from 'prop-types'
|
||||
import { useDispatch } from 'react-redux'
|
||||
import { useMediaQuery, CircularProgress } from '@material-ui/core'
|
||||
import { makeStyles } from '@material-ui/core/styles'
|
||||
import {
|
||||
Button,
|
||||
TopToolbar,
|
||||
sanitizeListRestProps,
|
||||
useDataProvider,
|
||||
useNotify,
|
||||
useTranslate,
|
||||
} from 'react-admin'
|
||||
import ShuffleIcon from '@material-ui/icons/Shuffle'
|
||||
import PlayArrowIcon from '@material-ui/icons/PlayArrow'
|
||||
import { IoIosRadio } from 'react-icons/io'
|
||||
import { playShuffle, playSimilar, playTopSongs } from './actions.js'
|
||||
|
||||
const useStyles = makeStyles((theme) => ({
|
||||
toolbar: {
|
||||
minHeight: 'auto',
|
||||
padding: '0 !important',
|
||||
background: 'transparent',
|
||||
boxShadow: 'none',
|
||||
'& .MuiToolbar-root': {
|
||||
minHeight: 'auto',
|
||||
padding: '0 !important',
|
||||
background: 'transparent',
|
||||
},
|
||||
},
|
||||
button: {
|
||||
[theme.breakpoints.down('xs')]: {
|
||||
minWidth: 'auto',
|
||||
padding: '8px 12px',
|
||||
fontSize: '0.75rem',
|
||||
'& .MuiButton-startIcon': {
|
||||
marginRight: '4px',
|
||||
},
|
||||
},
|
||||
},
|
||||
radioIcon: {
|
||||
[theme.breakpoints.down('xs')]: {
|
||||
fontSize: '1.5rem',
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
const LoadingButton = ({ loading, icon, ...rest }) => (
|
||||
<Button {...rest}>
|
||||
{loading ? <CircularProgress size={20} color="inherit" /> : icon}
|
||||
</Button>
|
||||
)
|
||||
|
||||
const ArtistActions = ({ className, record, ...rest }) => {
|
||||
const dispatch = useDispatch()
|
||||
const translate = useTranslate()
|
||||
const dataProvider = useDataProvider()
|
||||
const notify = useNotify()
|
||||
const classes = useStyles()
|
||||
const isMobile = useMediaQuery((theme) => theme.breakpoints.down('xs'))
|
||||
const [loadingAction, setLoadingAction] = React.useState(null)
|
||||
const isLoading = !!loadingAction
|
||||
|
||||
const handlePlay = React.useCallback(async () => {
|
||||
setLoadingAction('play')
|
||||
try {
|
||||
await playTopSongs(dispatch, notify, record.name)
|
||||
} catch (e) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('Error fetching top songs for artist:', e)
|
||||
notify('ra.page.error', 'warning')
|
||||
} finally {
|
||||
setLoadingAction(null)
|
||||
}
|
||||
}, [dispatch, notify, record])
|
||||
|
||||
const handleShuffle = React.useCallback(async () => {
|
||||
setLoadingAction('shuffle')
|
||||
try {
|
||||
await playShuffle(dataProvider, dispatch, record.id)
|
||||
} catch (e) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('Error fetching songs for shuffle:', e)
|
||||
notify('ra.page.error', 'warning')
|
||||
} finally {
|
||||
setLoadingAction(null)
|
||||
}
|
||||
}, [dataProvider, dispatch, record, notify])
|
||||
|
||||
const handleRadio = React.useCallback(async () => {
|
||||
setLoadingAction('radio')
|
||||
try {
|
||||
await playSimilar(dispatch, notify, record.id)
|
||||
} catch (e) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('Error starting radio for artist:', e)
|
||||
notify('ra.page.error', 'warning')
|
||||
} finally {
|
||||
setLoadingAction(null)
|
||||
}
|
||||
}, [dispatch, notify, record])
|
||||
|
||||
return (
|
||||
<TopToolbar
|
||||
className={`${className} ${classes.toolbar}`}
|
||||
{...sanitizeListRestProps(rest)}
|
||||
>
|
||||
<LoadingButton
|
||||
onClick={handlePlay}
|
||||
label={translate('resources.artist.actions.topSongs')}
|
||||
className={classes.button}
|
||||
size={isMobile ? 'small' : 'medium'}
|
||||
disabled={isLoading}
|
||||
loading={loadingAction === 'play'}
|
||||
icon={<PlayArrowIcon />}
|
||||
/>
|
||||
<LoadingButton
|
||||
onClick={handleShuffle}
|
||||
label={translate('resources.artist.actions.shuffle')}
|
||||
className={classes.button}
|
||||
size={isMobile ? 'small' : 'medium'}
|
||||
disabled={isLoading}
|
||||
loading={loadingAction === 'shuffle'}
|
||||
icon={<ShuffleIcon />}
|
||||
/>
|
||||
<LoadingButton
|
||||
onClick={handleRadio}
|
||||
label={translate('resources.artist.actions.radio')}
|
||||
className={classes.button}
|
||||
size={isMobile ? 'small' : 'medium'}
|
||||
disabled={isLoading}
|
||||
loading={loadingAction === 'radio'}
|
||||
icon={<IoIosRadio className={classes.radioIcon} />}
|
||||
/>
|
||||
</TopToolbar>
|
||||
)
|
||||
}
|
||||
|
||||
ArtistActions.propTypes = {
|
||||
className: PropTypes.string,
|
||||
record: PropTypes.object.isRequired,
|
||||
}
|
||||
|
||||
ArtistActions.defaultProps = {
|
||||
className: '',
|
||||
}
|
||||
|
||||
export default ArtistActions
|
||||
230
ui/src/artist/ArtistActions.test.jsx
Normal file
@@ -0,0 +1,230 @@
|
||||
import React from 'react'
|
||||
import { render, fireEvent, waitFor, screen } from '@testing-library/react'
|
||||
import { TestContext } from 'ra-test'
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import ArtistActions from './ArtistActions'
|
||||
import subsonic from '../subsonic'
|
||||
import { ThemeProvider, createTheme } from '@material-ui/core/styles'
|
||||
|
||||
const mockDispatch = vi.fn()
|
||||
vi.mock('react-redux', () => ({ useDispatch: () => mockDispatch }))
|
||||
|
||||
vi.mock('../subsonic', () => ({
|
||||
default: { getSimilarSongs2: vi.fn(), getTopSongs: vi.fn() },
|
||||
}))
|
||||
|
||||
const mockNotify = vi.fn()
|
||||
const mockGetList = vi.fn().mockResolvedValue({ data: [{ id: 's1' }] })
|
||||
|
||||
vi.mock('react-admin', async (importOriginal) => {
|
||||
const actual = await importOriginal()
|
||||
return {
|
||||
...actual,
|
||||
useNotify: () => mockNotify,
|
||||
useDataProvider: () => ({ getList: mockGetList }),
|
||||
useTranslate: () => (x) => x,
|
||||
}
|
||||
})
|
||||
|
||||
describe('ArtistActions', () => {
|
||||
const defaultRecord = { id: 'ar1', name: 'Artist' }
|
||||
|
||||
const renderArtistActions = (record = defaultRecord) => {
|
||||
const theme = createTheme()
|
||||
return render(
|
||||
<TestContext>
|
||||
<ThemeProvider theme={theme}>
|
||||
<ArtistActions record={record} />
|
||||
</ThemeProvider>
|
||||
</TestContext>,
|
||||
)
|
||||
}
|
||||
|
||||
const clickActionButton = (actionKey) => {
|
||||
fireEvent.click(screen.getByText(`resources.artist.actions.${actionKey}`))
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
// Mock console.error to suppress error logging in tests
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
|
||||
const songWithReplayGain = {
|
||||
id: 'rec1',
|
||||
replayGain: {
|
||||
albumGain: -5,
|
||||
albumPeak: 1,
|
||||
trackGain: -6,
|
||||
trackPeak: 0.8,
|
||||
},
|
||||
}
|
||||
|
||||
subsonic.getSimilarSongs2.mockResolvedValue({
|
||||
json: {
|
||||
'subsonic-response': {
|
||||
status: 'ok',
|
||||
similarSongs2: { song: [songWithReplayGain] },
|
||||
},
|
||||
},
|
||||
})
|
||||
subsonic.getTopSongs.mockResolvedValue({
|
||||
json: {
|
||||
'subsonic-response': {
|
||||
status: 'ok',
|
||||
topSongs: { song: [songWithReplayGain] },
|
||||
},
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
describe('Shuffle action', () => {
|
||||
it('shuffles songs when clicked', async () => {
|
||||
renderArtistActions()
|
||||
clickActionButton('shuffle')
|
||||
|
||||
await waitFor(() =>
|
||||
expect(mockGetList).toHaveBeenCalledWith('song', {
|
||||
pagination: { page: 1, perPage: 500 },
|
||||
sort: { field: 'random', order: 'ASC' },
|
||||
filter: { album_artist_id: 'ar1', missing: false },
|
||||
}),
|
||||
)
|
||||
expect(mockDispatch).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Radio action', () => {
|
||||
it('starts radio when clicked', async () => {
|
||||
renderArtistActions()
|
||||
clickActionButton('radio')
|
||||
|
||||
await waitFor(() =>
|
||||
expect(subsonic.getSimilarSongs2).toHaveBeenCalledWith('ar1', 100),
|
||||
)
|
||||
expect(mockDispatch).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('maps replaygain info', async () => {
|
||||
renderArtistActions()
|
||||
clickActionButton('radio')
|
||||
|
||||
await waitFor(() =>
|
||||
expect(subsonic.getSimilarSongs2).toHaveBeenCalledWith('ar1', 100),
|
||||
)
|
||||
const action = mockDispatch.mock.calls[0][0]
|
||||
expect(action.data.rec1).toMatchObject({
|
||||
rgAlbumGain: -5,
|
||||
rgAlbumPeak: 1,
|
||||
rgTrackGain: -6,
|
||||
rgTrackPeak: 0.8,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Play action', () => {
|
||||
it('plays top songs when clicked', async () => {
|
||||
renderArtistActions()
|
||||
clickActionButton('topSongs')
|
||||
|
||||
await waitFor(() =>
|
||||
expect(subsonic.getTopSongs).toHaveBeenCalledWith('Artist', 100),
|
||||
)
|
||||
expect(mockDispatch).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('maps replaygain info for top songs', async () => {
|
||||
renderArtistActions()
|
||||
clickActionButton('topSongs')
|
||||
|
||||
await waitFor(() =>
|
||||
expect(subsonic.getTopSongs).toHaveBeenCalledWith('Artist', 100),
|
||||
)
|
||||
const action = mockDispatch.mock.calls[0][0]
|
||||
expect(action.data.rec1).toMatchObject({
|
||||
rgAlbumGain: -5,
|
||||
rgAlbumPeak: 1,
|
||||
rgTrackGain: -6,
|
||||
rgTrackPeak: 0.8,
|
||||
})
|
||||
})
|
||||
|
||||
it('handles API rejection', async () => {
|
||||
subsonic.getTopSongs.mockRejectedValue(new Error('Network error'))
|
||||
|
||||
renderArtistActions()
|
||||
clickActionButton('topSongs')
|
||||
|
||||
await waitFor(() =>
|
||||
expect(subsonic.getTopSongs).toHaveBeenCalledWith('Artist', 100),
|
||||
)
|
||||
expect(mockNotify).toHaveBeenCalledWith('ra.page.error', 'warning')
|
||||
expect(mockDispatch).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('handles failed API response', async () => {
|
||||
subsonic.getTopSongs.mockResolvedValue({
|
||||
json: {
|
||||
'subsonic-response': {
|
||||
status: 'failed',
|
||||
error: { code: 40, message: 'Wrong username or password' },
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
renderArtistActions()
|
||||
clickActionButton('topSongs')
|
||||
|
||||
await waitFor(() =>
|
||||
expect(subsonic.getTopSongs).toHaveBeenCalledWith('Artist', 100),
|
||||
)
|
||||
expect(mockNotify).toHaveBeenCalledWith('ra.page.error', 'warning')
|
||||
expect(mockDispatch).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('handles empty song list', async () => {
|
||||
subsonic.getTopSongs.mockResolvedValue({
|
||||
json: {
|
||||
'subsonic-response': {
|
||||
status: 'ok',
|
||||
topSongs: { song: [] },
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
renderArtistActions()
|
||||
clickActionButton('topSongs')
|
||||
|
||||
await waitFor(() =>
|
||||
expect(subsonic.getTopSongs).toHaveBeenCalledWith('Artist', 100),
|
||||
)
|
||||
expect(mockNotify).toHaveBeenCalledWith(
|
||||
'message.noTopSongsFound',
|
||||
'warning',
|
||||
)
|
||||
expect(mockDispatch).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('handles missing topSongs property', async () => {
|
||||
subsonic.getTopSongs.mockResolvedValue({
|
||||
json: {
|
||||
'subsonic-response': {
|
||||
status: 'ok',
|
||||
// topSongs property is missing
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
renderArtistActions()
|
||||
clickActionButton('topSongs')
|
||||
|
||||
await waitFor(() =>
|
||||
expect(subsonic.getTopSongs).toHaveBeenCalledWith('Artist', 100),
|
||||
)
|
||||
expect(mockNotify).toHaveBeenCalledWith(
|
||||
'message.noTopSongsFound',
|
||||
'warning',
|
||||
)
|
||||
expect(mockDispatch).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
66
ui/src/artist/ArtistExternalLink.jsx
Normal file
@@ -0,0 +1,66 @@
|
||||
import React from 'react'
|
||||
import { useTranslate } from 'react-admin'
|
||||
import { IconButton, Tooltip, Link } from '@material-ui/core'
|
||||
|
||||
import { ImLastfm2 } from 'react-icons/im'
|
||||
import MusicBrainz from '../icons/MusicBrainz'
|
||||
import { intersperse } from '../utils'
|
||||
import config from '../config'
|
||||
import { makeStyles } from '@material-ui/core/styles'
|
||||
|
||||
const useStyles = makeStyles({
|
||||
linkBar: {
|
||||
minHeight: '1.875em',
|
||||
},
|
||||
})
|
||||
|
||||
const ArtistExternalLinks = ({ artistInfo, record }) => {
|
||||
const classes = useStyles()
|
||||
const translate = useTranslate()
|
||||
let linkButtons = []
|
||||
const lastFMlink = artistInfo?.biography?.match(
|
||||
/<a\s+(?:[^>]*?\s+)?href=(["'])(.*?)\1/,
|
||||
)
|
||||
|
||||
const addLink = (url, title, icon) => {
|
||||
const translatedTitle = translate(title)
|
||||
const link = (
|
||||
<Link href={url} target="_blank" rel="noopener noreferrer">
|
||||
<Tooltip title={translatedTitle}>
|
||||
<IconButton size={'small'} aria-label={translatedTitle}>
|
||||
{icon}
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Link>
|
||||
)
|
||||
const id = linkButtons.length
|
||||
linkButtons.push(<span key={`link-${record.id}-${id}`}>{link}</span>)
|
||||
}
|
||||
|
||||
if (config.lastFMEnabled) {
|
||||
if (lastFMlink) {
|
||||
addLink(
|
||||
lastFMlink[2],
|
||||
'message.openIn.lastfm',
|
||||
<ImLastfm2 className="lastfm-icon" />,
|
||||
)
|
||||
} else if (artistInfo?.lastFmUrl) {
|
||||
addLink(
|
||||
artistInfo?.lastFmUrl,
|
||||
'message.openIn.lastfm',
|
||||
<ImLastfm2 className="lastfm-icon" />,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
artistInfo?.musicBrainzId &&
|
||||
addLink(
|
||||
`https://musicbrainz.org/artist/${artistInfo.musicBrainzId}`,
|
||||
'message.openIn.musicbrainz',
|
||||
<MusicBrainz className="musicbrainz-icon" />,
|
||||
)
|
||||
|
||||
return <div className={classes.linkBar}>{intersperse(linkButtons, ' ')}</div>
|
||||
}
|
||||
|
||||
export default ArtistExternalLinks
|
||||
224
ui/src/artist/ArtistList.jsx
Normal file
@@ -0,0 +1,224 @@
|
||||
import { useMemo } from 'react'
|
||||
import { useHistory } from 'react-router-dom'
|
||||
import {
|
||||
Datagrid,
|
||||
DatagridBody,
|
||||
DatagridRow,
|
||||
Filter,
|
||||
FunctionField,
|
||||
NumberField,
|
||||
SearchInput,
|
||||
SelectInput,
|
||||
TextField,
|
||||
useTranslate,
|
||||
NullableBooleanInput,
|
||||
usePermissions,
|
||||
} from 'react-admin'
|
||||
import { useMediaQuery, withWidth } from '@material-ui/core'
|
||||
import FavoriteIcon from '@material-ui/icons/Favorite'
|
||||
import FavoriteBorderIcon from '@material-ui/icons/FavoriteBorder'
|
||||
import { makeStyles } from '@material-ui/core/styles'
|
||||
import { useDrag } from 'react-dnd'
|
||||
import clsx from 'clsx'
|
||||
import {
|
||||
ArtistContextMenu,
|
||||
List,
|
||||
QuickFilter,
|
||||
useGetHandleArtistClick,
|
||||
RatingField,
|
||||
useSelectedFields,
|
||||
useResourceRefresh,
|
||||
} from '../common'
|
||||
import config from '../config'
|
||||
import ArtistListActions from './ArtistListActions'
|
||||
import ArtistSimpleList from './ArtistSimpleList'
|
||||
import { DraggableTypes } from '../consts'
|
||||
import en from '../i18n/en.json'
|
||||
import { formatBytes } from '../utils/index.js'
|
||||
|
||||
const useStyles = makeStyles({
|
||||
contextHeader: {
|
||||
marginLeft: '3px',
|
||||
marginTop: '-2px',
|
||||
verticalAlign: 'text-top',
|
||||
},
|
||||
row: {
|
||||
'&:hover': {
|
||||
'& $contextMenu': {
|
||||
visibility: 'visible',
|
||||
},
|
||||
'& $ratingField': {
|
||||
visibility: 'visible',
|
||||
},
|
||||
},
|
||||
},
|
||||
missingRow: {
|
||||
opacity: 0.3,
|
||||
},
|
||||
contextMenu: {
|
||||
visibility: 'hidden',
|
||||
},
|
||||
ratingField: {
|
||||
visibility: 'hidden',
|
||||
},
|
||||
})
|
||||
|
||||
const ArtistFilter = (props) => {
|
||||
const translate = useTranslate()
|
||||
const { permissions } = usePermissions()
|
||||
const isAdmin = permissions === 'admin'
|
||||
const rolesObj = en?.resources?.artist?.roles
|
||||
const roles = Object.keys(rolesObj).reduce((acc, role) => {
|
||||
acc.push({
|
||||
id: role,
|
||||
name: translate(`resources.artist.roles.${role}`, {
|
||||
smart_count: 2,
|
||||
}),
|
||||
})
|
||||
return acc
|
||||
}, [])
|
||||
roles?.sort((a, b) => a.name.localeCompare(b.name))
|
||||
return (
|
||||
<Filter {...props} variant={'outlined'}>
|
||||
<SearchInput id="search" source="name" alwaysOn />
|
||||
<SelectInput source="role" choices={roles} alwaysOn />
|
||||
{config.enableFavourites && (
|
||||
<QuickFilter
|
||||
source="starred"
|
||||
label={<FavoriteIcon fontSize={'small'} />}
|
||||
defaultValue={true}
|
||||
/>
|
||||
)}
|
||||
{isAdmin && <NullableBooleanInput source="missing" />}
|
||||
</Filter>
|
||||
)
|
||||
}
|
||||
|
||||
const ArtistDatagridRow = (props) => {
|
||||
const { record } = props
|
||||
const [, dragArtistRef] = useDrag(
|
||||
() => ({
|
||||
type: DraggableTypes.ARTIST,
|
||||
item: { artistIds: [record?.id] },
|
||||
options: { dropEffect: 'copy' },
|
||||
}),
|
||||
[record],
|
||||
)
|
||||
const classes = useStyles()
|
||||
const computedClasses = clsx(
|
||||
props.className,
|
||||
classes.row,
|
||||
record?.missing && classes.missingRow,
|
||||
)
|
||||
return (
|
||||
<DatagridRow ref={dragArtistRef} {...props} className={computedClasses} />
|
||||
)
|
||||
}
|
||||
|
||||
const ArtistDatagridBody = (props) => (
|
||||
<DatagridBody {...props} row={<ArtistDatagridRow />} />
|
||||
)
|
||||
|
||||
const ArtistDatagrid = (props) => (
|
||||
<Datagrid {...props} body={<ArtistDatagridBody />} />
|
||||
)
|
||||
|
||||
const ArtistListView = ({ hasShow, hasEdit, hasList, width, ...rest }) => {
|
||||
const { filterValues } = rest
|
||||
const classes = useStyles()
|
||||
const handleArtistLink = useGetHandleArtistClick(width)
|
||||
const history = useHistory()
|
||||
const isXsmall = useMediaQuery((theme) => theme.breakpoints.down('xs'))
|
||||
useResourceRefresh('artist')
|
||||
|
||||
const role = filterValues?.role
|
||||
const getCounter = (record, counter) => {
|
||||
if (!record) return undefined
|
||||
return role ? record?.stats?.[role]?.[counter] : record?.[counter]
|
||||
}
|
||||
const getAlbumCount = (record) => getCounter(record, 'albumCount')
|
||||
const getSongCount = (record) => getCounter(record, 'songCount')
|
||||
const getSize = (record) => {
|
||||
const size = getCounter(record, 'size')
|
||||
return size ? formatBytes(size) : '0 MB'
|
||||
}
|
||||
|
||||
const toggleableFields = useMemo(
|
||||
() => ({
|
||||
playCount: <NumberField source="playCount" sortByOrder={'DESC'} />,
|
||||
rating: config.enableStarRating && (
|
||||
<RatingField
|
||||
source="rating"
|
||||
sortByOrder={'DESC'}
|
||||
resource={'artist'}
|
||||
className={classes.ratingField}
|
||||
/>
|
||||
),
|
||||
}),
|
||||
[classes.ratingField],
|
||||
)
|
||||
|
||||
const columns = useSelectedFields({
|
||||
resource: 'artist',
|
||||
columns: toggleableFields,
|
||||
})
|
||||
|
||||
return isXsmall ? (
|
||||
<ArtistSimpleList
|
||||
linkType={(id) => history.push(handleArtistLink(id))}
|
||||
{...rest}
|
||||
/>
|
||||
) : (
|
||||
<ArtistDatagrid rowClick={handleArtistLink} classes={{ row: classes.row }}>
|
||||
<TextField source="name" />
|
||||
<FunctionField
|
||||
source="albumCount"
|
||||
sortByOrder={'DESC'}
|
||||
render={getAlbumCount}
|
||||
/>
|
||||
<FunctionField
|
||||
source="songCount"
|
||||
sortByOrder={'DESC'}
|
||||
render={getSongCount}
|
||||
/>
|
||||
<FunctionField source="size" sortByOrder={'DESC'} render={getSize} />
|
||||
{columns}
|
||||
<ArtistContextMenu
|
||||
source={'starred_at'}
|
||||
sortByOrder={'DESC'}
|
||||
sortable={config.enableFavourites}
|
||||
className={classes.contextMenu}
|
||||
label={
|
||||
config.enableFavourites && (
|
||||
<FavoriteBorderIcon
|
||||
fontSize={'small'}
|
||||
className={classes.contextHeader}
|
||||
/>
|
||||
)
|
||||
}
|
||||
/>
|
||||
</ArtistDatagrid>
|
||||
)
|
||||
}
|
||||
|
||||
const ArtistList = (props) => {
|
||||
return (
|
||||
<>
|
||||
<List
|
||||
{...props}
|
||||
sort={{ field: 'name', order: 'ASC' }}
|
||||
exporter={false}
|
||||
bulkActionButtons={false}
|
||||
filters={<ArtistFilter />}
|
||||
filterDefaultValues={{ role: 'albumartist' }}
|
||||
actions={<ArtistListActions />}
|
||||
>
|
||||
<ArtistListView {...props} />
|
||||
</List>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const ArtistListWithWidth = withWidth()(ArtistList)
|
||||
|
||||
export default ArtistListWithWidth
|
||||
32
ui/src/artist/ArtistListActions.jsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import React, { cloneElement } from 'react'
|
||||
import { sanitizeListRestProps, TopToolbar } from 'react-admin'
|
||||
import { useMediaQuery } from '@material-ui/core'
|
||||
import { ToggleFieldsMenu } from '../common'
|
||||
|
||||
const ArtistListActions = ({
|
||||
className,
|
||||
filters,
|
||||
resource,
|
||||
showFilter,
|
||||
displayedFilters,
|
||||
filterValues,
|
||||
...rest
|
||||
}) => {
|
||||
const isNotSmall = useMediaQuery((theme) => theme.breakpoints.up('sm'))
|
||||
|
||||
return (
|
||||
<TopToolbar className={className} {...sanitizeListRestProps(rest)}>
|
||||
{filters &&
|
||||
cloneElement(filters, {
|
||||
resource,
|
||||
showFilter,
|
||||
displayedFilters,
|
||||
filterValues,
|
||||
context: 'button',
|
||||
})}
|
||||
{isNotSmall && <ToggleFieldsMenu resource="artist" />}
|
||||
</TopToolbar>
|
||||
)
|
||||
}
|
||||
|
||||
export default ArtistListActions
|
||||
148
ui/src/artist/ArtistShow.jsx
Normal file
@@ -0,0 +1,148 @@
|
||||
import React, { useState, createElement, useEffect } from 'react'
|
||||
import { useMediaQuery, withWidth } from '@material-ui/core'
|
||||
import {
|
||||
useShowController,
|
||||
ShowContextProvider,
|
||||
useRecordContext,
|
||||
useShowContext,
|
||||
ReferenceManyField,
|
||||
Pagination,
|
||||
Title as RaTitle,
|
||||
} from 'react-admin'
|
||||
import subsonic from '../subsonic'
|
||||
import AlbumGridView from '../album/AlbumGridView'
|
||||
import MobileArtistDetails from './MobileArtistDetails'
|
||||
import DesktopArtistDetails from './DesktopArtistDetails'
|
||||
import { useAlbumsPerPage, useResourceRefresh, Title } from '../common/index.js'
|
||||
import ArtistActions from './ArtistActions'
|
||||
import { makeStyles } from '@material-ui/core'
|
||||
|
||||
const useStyles = makeStyles(
|
||||
(theme) => ({
|
||||
actions: {
|
||||
width: '100%',
|
||||
justifyContent: 'flex-start',
|
||||
display: 'flex',
|
||||
paddingTop: '0.25em',
|
||||
paddingBottom: '0.25em',
|
||||
paddingLeft: '1em',
|
||||
paddingRight: '1em',
|
||||
flexWrap: 'wrap',
|
||||
overflowX: 'auto',
|
||||
[theme.breakpoints.down('xs')]: {
|
||||
paddingLeft: '0.5em',
|
||||
paddingRight: '0.5em',
|
||||
gap: '0.5em',
|
||||
justifyContent: 'space-around',
|
||||
},
|
||||
},
|
||||
actionsContainer: {
|
||||
paddingLeft: '.75rem',
|
||||
[theme.breakpoints.down('xs')]: {
|
||||
padding: '.5rem',
|
||||
},
|
||||
},
|
||||
}),
|
||||
{
|
||||
name: 'NDArtistShow',
|
||||
},
|
||||
)
|
||||
|
||||
const ArtistDetails = (props) => {
|
||||
const record = useRecordContext(props)
|
||||
const isDesktop = useMediaQuery((theme) => theme.breakpoints.up('sm'))
|
||||
const [artistInfo, setArtistInfo] = useState()
|
||||
|
||||
const biography =
|
||||
artistInfo?.biography?.replace(new RegExp('<.*>', 'g'), '') ||
|
||||
record.biography
|
||||
|
||||
useEffect(() => {
|
||||
subsonic
|
||||
.getArtistInfo(record.id)
|
||||
.then((resp) => resp.json['subsonic-response'])
|
||||
.then((data) => {
|
||||
if (data.status === 'ok') {
|
||||
setArtistInfo(data.artistInfo)
|
||||
}
|
||||
})
|
||||
.catch((e) => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('error on artist page', e)
|
||||
})
|
||||
}, [record.id])
|
||||
|
||||
const component = isDesktop ? DesktopArtistDetails : MobileArtistDetails
|
||||
return (
|
||||
<>
|
||||
{createElement(component, {
|
||||
artistInfo,
|
||||
record,
|
||||
biography,
|
||||
})}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const ArtistShowLayout = (props) => {
|
||||
const showContext = useShowContext(props)
|
||||
const record = useRecordContext()
|
||||
const { width } = props
|
||||
const [, perPageOptions] = useAlbumsPerPage(width)
|
||||
const classes = useStyles()
|
||||
useResourceRefresh('artist', 'album')
|
||||
|
||||
const maxPerPage = 90
|
||||
let perPage = 0
|
||||
let pagination = null
|
||||
|
||||
// Use the main credit count instead of total count, as this is a precise measure
|
||||
// of the number of albums where the artist is credited as an album artist OR
|
||||
// artist
|
||||
const count = record?.stats?.['maincredit']?.albumCount || 0
|
||||
|
||||
if (count > maxPerPage) {
|
||||
perPage = Math.trunc(maxPerPage / perPageOptions[0]) * perPageOptions[0]
|
||||
const rowsPerPageOptions = [1, 2, 3].map((option) =>
|
||||
Math.trunc(option * (perPage / 3)),
|
||||
)
|
||||
pagination = <Pagination rowsPerPageOptions={rowsPerPageOptions} />
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{record && <RaTitle title={<Title subTitle={record.name} />} />}
|
||||
{record && <ArtistDetails />}
|
||||
{record && (
|
||||
<div className={classes.actionsContainer}>
|
||||
<ArtistActions record={record} className={classes.actions} />
|
||||
</div>
|
||||
)}
|
||||
{record && (
|
||||
<ReferenceManyField
|
||||
{...showContext}
|
||||
addLabel={false}
|
||||
reference="album"
|
||||
target="artist_id"
|
||||
sort={{ field: 'max_year', order: 'ASC' }}
|
||||
filter={{ artist_id: record?.id }}
|
||||
perPage={perPage}
|
||||
pagination={pagination}
|
||||
>
|
||||
<AlbumGridView {...props} />
|
||||
</ReferenceManyField>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const ArtistShow = withWidth()((props) => {
|
||||
const controllerProps = useShowController(props)
|
||||
return (
|
||||
<ShowContextProvider value={controllerProps}>
|
||||
<ArtistShowLayout {...controllerProps} />
|
||||
</ShowContextProvider>
|
||||
)
|
||||
})
|
||||
|
||||
export default ArtistShow
|
||||
93
ui/src/artist/ArtistSimpleList.jsx
Normal file
@@ -0,0 +1,93 @@
|
||||
import React from 'react'
|
||||
import PropTypes from 'prop-types'
|
||||
import List from '@material-ui/core/List'
|
||||
import ListItem from '@material-ui/core/ListItem'
|
||||
import ListItemIcon from '@material-ui/core/ListItemIcon'
|
||||
import ListItemSecondaryAction from '@material-ui/core/ListItemSecondaryAction'
|
||||
import ListItemText from '@material-ui/core/ListItemText'
|
||||
import { makeStyles } from '@material-ui/core/styles'
|
||||
import { sanitizeListRestProps } from 'react-admin'
|
||||
import { ArtistContextMenu, RatingField } from '../common'
|
||||
import config from '../config'
|
||||
|
||||
const useStyles = makeStyles(
|
||||
{
|
||||
listItem: {
|
||||
padding: '10px',
|
||||
},
|
||||
title: {
|
||||
paddingRight: '10px',
|
||||
width: '80%',
|
||||
},
|
||||
rightIcon: {
|
||||
top: '26px',
|
||||
},
|
||||
},
|
||||
{ name: 'RaArtistSimpleList' },
|
||||
)
|
||||
|
||||
const ArtistSimpleList = ({
|
||||
linkType,
|
||||
className,
|
||||
classes: classesOverride,
|
||||
data,
|
||||
hasBulkActions,
|
||||
ids,
|
||||
loading,
|
||||
selectedIds,
|
||||
total,
|
||||
...rest
|
||||
}) => {
|
||||
const classes = useStyles({ classes: classesOverride })
|
||||
return (
|
||||
(loading || total > 0) && (
|
||||
<List className={className} {...sanitizeListRestProps(rest)}>
|
||||
{ids.map(
|
||||
(id) =>
|
||||
data[id] && (
|
||||
<span key={id} onClick={() => linkType(id)}>
|
||||
<ListItem className={classes.listItem} button={true}>
|
||||
<ListItemText
|
||||
primary={
|
||||
<>
|
||||
<div className={classes.title}>{data[id].name}</div>
|
||||
{config.enableStarRating && (
|
||||
<RatingField
|
||||
record={data[id]}
|
||||
source={'rating'}
|
||||
resource={'artist'}
|
||||
size={'small'}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
<ListItemSecondaryAction className={classes.rightIcon}>
|
||||
<ListItemIcon>
|
||||
<ArtistContextMenu record={data[id]} />
|
||||
</ListItemIcon>
|
||||
</ListItemSecondaryAction>
|
||||
</ListItem>
|
||||
</span>
|
||||
),
|
||||
)}
|
||||
</List>
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
ArtistSimpleList.propTypes = {
|
||||
className: PropTypes.string,
|
||||
classes: PropTypes.object,
|
||||
data: PropTypes.object,
|
||||
hasBulkActions: PropTypes.bool.isRequired,
|
||||
ids: PropTypes.array,
|
||||
selectedIds: PropTypes.arrayOf(PropTypes.any).isRequired,
|
||||
}
|
||||
|
||||
ArtistSimpleList.defaultProps = {
|
||||
hasBulkActions: false,
|
||||
selectedIds: [],
|
||||
}
|
||||
|
||||
export default ArtistSimpleList
|
||||
200
ui/src/artist/DesktopArtistDetails.jsx
Normal file
@@ -0,0 +1,200 @@
|
||||
import React, { useState } from 'react'
|
||||
import { Typography, Collapse } from '@material-ui/core'
|
||||
import { makeStyles } from '@material-ui/core'
|
||||
import Card from '@material-ui/core/Card'
|
||||
import CardContent from '@material-ui/core/CardContent'
|
||||
import CardMedia from '@material-ui/core/CardMedia'
|
||||
import ArtistExternalLinks from './ArtistExternalLink'
|
||||
import config from '../config'
|
||||
import { LoveButton, RatingField } from '../common'
|
||||
import Lightbox from 'react-image-lightbox'
|
||||
import ExpandInfoDialog from '../dialogs/ExpandInfoDialog'
|
||||
import AlbumInfo from '../album/AlbumInfo'
|
||||
import subsonic from '../subsonic'
|
||||
|
||||
const useStyles = makeStyles(
|
||||
(theme) => ({
|
||||
root: {
|
||||
display: 'flex',
|
||||
padding: '1em',
|
||||
},
|
||||
details: {
|
||||
display: 'flex',
|
||||
flex: '1',
|
||||
flexDirection: 'column',
|
||||
},
|
||||
biography: {
|
||||
display: 'inline-block',
|
||||
marginTop: '1em',
|
||||
float: 'left',
|
||||
wordBreak: 'break-word',
|
||||
cursor: 'pointer',
|
||||
minHeight: '4.5em',
|
||||
},
|
||||
content: {
|
||||
flex: '1 0 auto',
|
||||
},
|
||||
cover: {
|
||||
width: '12rem',
|
||||
height: '12rem',
|
||||
borderRadius: '6em',
|
||||
cursor: 'pointer',
|
||||
backgroundColor: 'transparent',
|
||||
transition: 'opacity 0.3s ease-in-out',
|
||||
objectFit: 'cover',
|
||||
},
|
||||
coverLoading: {
|
||||
opacity: 0.5,
|
||||
},
|
||||
artistImage: {
|
||||
maxHeight: '12rem',
|
||||
minHeight: '12rem',
|
||||
width: '12rem',
|
||||
minWidth: '12rem',
|
||||
backgroundColor: 'inherit',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
boxShadow: 'none',
|
||||
},
|
||||
artistDetail: {
|
||||
flex: '1',
|
||||
padding: '3%',
|
||||
display: 'flex',
|
||||
minHeight: '10rem',
|
||||
},
|
||||
button: {
|
||||
marginLeft: '0.9em',
|
||||
},
|
||||
loveButton: {
|
||||
top: theme.spacing(-0.2),
|
||||
left: theme.spacing(0.5),
|
||||
},
|
||||
rating: {
|
||||
marginTop: '5px',
|
||||
},
|
||||
artistName: {
|
||||
wordBreak: 'break-word',
|
||||
},
|
||||
}),
|
||||
{ name: 'NDDesktopArtistDetails' },
|
||||
)
|
||||
|
||||
const DesktopArtistDetails = ({ artistInfo, record, biography }) => {
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
const classes = useStyles()
|
||||
const title = record.name
|
||||
const [isLightboxOpen, setLightboxOpen] = React.useState(false)
|
||||
const [imageLoading, setImageLoading] = React.useState(false)
|
||||
const [imageError, setImageError] = React.useState(false)
|
||||
|
||||
// Reset image state when artist changes
|
||||
React.useEffect(() => {
|
||||
setImageLoading(true)
|
||||
setImageError(false)
|
||||
}, [record.id])
|
||||
|
||||
const handleImageLoad = React.useCallback(() => {
|
||||
setImageLoading(false)
|
||||
setImageError(false)
|
||||
}, [])
|
||||
|
||||
const handleImageError = React.useCallback(() => {
|
||||
setImageLoading(false)
|
||||
setImageError(true)
|
||||
}, [])
|
||||
|
||||
const handleOpenLightbox = React.useCallback(() => {
|
||||
if (!imageError) {
|
||||
setLightboxOpen(true)
|
||||
}
|
||||
}, [imageError])
|
||||
|
||||
const handleCloseLightbox = React.useCallback(
|
||||
() => setLightboxOpen(false),
|
||||
[],
|
||||
)
|
||||
|
||||
return (
|
||||
<div className={classes.root}>
|
||||
<Card className={classes.artistDetail}>
|
||||
<Card className={classes.artistImage}>
|
||||
{artistInfo && (
|
||||
<CardMedia
|
||||
key={record.id}
|
||||
component="img"
|
||||
src={subsonic.getCoverArtUrl(record, 300)}
|
||||
className={`${classes.cover} ${imageLoading ? classes.coverLoading : ''}`}
|
||||
onClick={handleOpenLightbox}
|
||||
onLoad={handleImageLoad}
|
||||
onError={handleImageError}
|
||||
title={title}
|
||||
style={{
|
||||
cursor: imageError ? 'default' : 'pointer',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Card>
|
||||
<div className={classes.details}>
|
||||
<CardContent className={classes.content}>
|
||||
<Typography
|
||||
component="h5"
|
||||
variant="h5"
|
||||
className={classes.artistName}
|
||||
>
|
||||
{title}
|
||||
<LoveButton
|
||||
className={classes.loveButton}
|
||||
record={record}
|
||||
resource={'artist'}
|
||||
size={'default'}
|
||||
aria-label="artist context menu"
|
||||
color="primary"
|
||||
/>
|
||||
</Typography>
|
||||
{config.enableStarRating && (
|
||||
<div>
|
||||
<RatingField
|
||||
record={record}
|
||||
resource={'artist'}
|
||||
size={'small'}
|
||||
className={classes.rating}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<Collapse
|
||||
collapsedHeight={'4.5em'}
|
||||
in={expanded}
|
||||
timeout={'auto'}
|
||||
className={classes.biography}
|
||||
>
|
||||
<Typography
|
||||
variant={'body1'}
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
>
|
||||
<span dangerouslySetInnerHTML={{ __html: biography }} />
|
||||
</Typography>
|
||||
</Collapse>
|
||||
</CardContent>
|
||||
<Typography component={'div'} className={classes.button}>
|
||||
{config.enableExternalServices && (
|
||||
<ArtistExternalLinks artistInfo={artistInfo} record={record} />
|
||||
)}
|
||||
</Typography>
|
||||
</div>
|
||||
{isLightboxOpen && !imageError && (
|
||||
<Lightbox
|
||||
imagePadding={50}
|
||||
animationDuration={200}
|
||||
imageTitle={record.name}
|
||||
mainSrc={subsonic.getCoverArtUrl(record)}
|
||||
onCloseRequest={handleCloseLightbox}
|
||||
/>
|
||||
)}
|
||||
</Card>
|
||||
<ExpandInfoDialog content={<AlbumInfo />} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default DesktopArtistDetails
|
||||
188
ui/src/artist/MobileArtistDetails.jsx
Normal file
@@ -0,0 +1,188 @@
|
||||
import React, { useState } from 'react'
|
||||
import { Typography, Collapse } from '@material-ui/core'
|
||||
import { makeStyles } from '@material-ui/core/styles'
|
||||
import Card from '@material-ui/core/Card'
|
||||
import CardMedia from '@material-ui/core/CardMedia'
|
||||
import config from '../config'
|
||||
import { LoveButton, RatingField } from '../common'
|
||||
import Lightbox from 'react-image-lightbox'
|
||||
import subsonic from '../subsonic'
|
||||
|
||||
const useStyles = makeStyles(
|
||||
(theme) => ({
|
||||
root: {
|
||||
display: 'flex',
|
||||
background: ({ img }) => `url(${img})`,
|
||||
},
|
||||
bgContainer: {
|
||||
display: 'flex',
|
||||
height: '15rem',
|
||||
width: '100vw',
|
||||
padding: 'unset',
|
||||
backdropFilter: 'blur(1px)',
|
||||
backgroundPosition: '50% 30%',
|
||||
background: `linear-gradient(to bottom, rgba(52 52 52 / 72%), rgba(21 21 21))`,
|
||||
},
|
||||
link: {
|
||||
margin: '1px',
|
||||
},
|
||||
details: {
|
||||
display: 'flex',
|
||||
alignItems: 'flex-start',
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'center',
|
||||
marginLeft: '0.5rem',
|
||||
},
|
||||
biography: {
|
||||
display: 'flex',
|
||||
marginLeft: '3%',
|
||||
marginRight: '3%',
|
||||
marginTop: '-2em',
|
||||
zIndex: '1',
|
||||
'& p': {
|
||||
whiteSpace: ({ expanded }) => (expanded ? 'unset' : 'nowrap'),
|
||||
overflow: 'hidden',
|
||||
width: '95vw',
|
||||
textOverflow: 'ellipsis',
|
||||
},
|
||||
},
|
||||
cover: {
|
||||
width: 151,
|
||||
boxShadow: '0px 0px 6px 0px #565656',
|
||||
borderRadius: '5px',
|
||||
backgroundColor: 'transparent',
|
||||
transition: 'opacity 0.3s ease-in-out',
|
||||
objectFit: 'cover',
|
||||
},
|
||||
coverLoading: {
|
||||
opacity: 0.5,
|
||||
},
|
||||
artistImage: {
|
||||
marginLeft: '1em',
|
||||
maxHeight: '7rem',
|
||||
backgroundColor: 'inherit',
|
||||
marginTop: '4rem',
|
||||
width: '7rem',
|
||||
minWidth: '7rem',
|
||||
display: 'flex',
|
||||
borderRadius: '5em',
|
||||
},
|
||||
loveButton: {
|
||||
top: theme.spacing(-0.2),
|
||||
left: theme.spacing(0.5),
|
||||
},
|
||||
rating: {
|
||||
marginTop: '5px',
|
||||
},
|
||||
artistName: {
|
||||
wordBreak: 'break-word',
|
||||
},
|
||||
}),
|
||||
{ name: 'NDMobileArtistDetails' },
|
||||
)
|
||||
|
||||
const MobileArtistDetails = ({ artistInfo, biography, record }) => {
|
||||
const img = subsonic.getCoverArtUrl(record)
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
const classes = useStyles({ img, expanded })
|
||||
const title = record.name
|
||||
const [isLightboxOpen, setLightboxOpen] = React.useState(false)
|
||||
const [imageLoading, setImageLoading] = React.useState(false)
|
||||
const [imageError, setImageError] = React.useState(false)
|
||||
|
||||
// Reset image state when artist changes
|
||||
React.useEffect(() => {
|
||||
setImageLoading(true)
|
||||
setImageError(false)
|
||||
}, [record.id])
|
||||
|
||||
const handleImageLoad = React.useCallback(() => {
|
||||
setImageLoading(false)
|
||||
setImageError(false)
|
||||
}, [])
|
||||
|
||||
const handleImageError = React.useCallback(() => {
|
||||
setImageLoading(false)
|
||||
setImageError(true)
|
||||
}, [])
|
||||
|
||||
const handleOpenLightbox = React.useCallback(() => {
|
||||
if (!imageError) {
|
||||
setLightboxOpen(true)
|
||||
}
|
||||
}, [imageError])
|
||||
|
||||
const handleCloseLightbox = React.useCallback(
|
||||
() => setLightboxOpen(false),
|
||||
[],
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={classes.root}>
|
||||
<div className={classes.bgContainer}>
|
||||
<Card className={classes.artistImage}>
|
||||
{artistInfo && (
|
||||
<CardMedia
|
||||
key={record.id}
|
||||
component="img"
|
||||
src={subsonic.getCoverArtUrl(record, 300)}
|
||||
className={`${classes.cover} ${imageLoading ? classes.coverLoading : ''}`}
|
||||
onClick={handleOpenLightbox}
|
||||
onLoad={handleImageLoad}
|
||||
onError={handleImageError}
|
||||
title={title}
|
||||
style={{
|
||||
cursor: imageError ? 'default' : 'pointer',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Card>
|
||||
<div className={classes.details}>
|
||||
<Typography
|
||||
component="h5"
|
||||
variant="h5"
|
||||
className={classes.artistName}
|
||||
>
|
||||
{title}
|
||||
<LoveButton
|
||||
className={classes.loveButton}
|
||||
record={record}
|
||||
resource={'artist'}
|
||||
size={'small'}
|
||||
aria-label="love"
|
||||
color="primary"
|
||||
/>
|
||||
</Typography>
|
||||
{config.enableStarRating && (
|
||||
<RatingField
|
||||
record={record}
|
||||
resource={'artist'}
|
||||
size={'small'}
|
||||
className={classes.rating}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className={classes.biography}>
|
||||
<Collapse collapsedHeight={'1.5em'} in={expanded} timeout={'auto'}>
|
||||
<Typography variant={'body1'} onClick={() => setExpanded(!expanded)}>
|
||||
<span dangerouslySetInnerHTML={{ __html: biography }} />
|
||||
</Typography>
|
||||
</Collapse>
|
||||
</div>
|
||||
{isLightboxOpen && !imageError && (
|
||||
<Lightbox
|
||||
imagePadding={50}
|
||||
animationDuration={200}
|
||||
imageTitle={record.name}
|
||||
mainSrc={img}
|
||||
onCloseRequest={handleCloseLightbox}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default MobileArtistDetails
|
||||
84
ui/src/artist/actions.js
Normal file
@@ -0,0 +1,84 @@
|
||||
import subsonic from '../subsonic/index.js'
|
||||
import { playTracks } from '../actions/index.js'
|
||||
|
||||
const mapReplayGain = (song) => {
|
||||
const { replayGain: rg } = song
|
||||
if (!rg) {
|
||||
return song
|
||||
}
|
||||
|
||||
return {
|
||||
...song,
|
||||
...(rg.albumGain !== undefined && { rgAlbumGain: rg.albumGain }),
|
||||
...(rg.albumPeak !== undefined && { rgAlbumPeak: rg.albumPeak }),
|
||||
...(rg.trackGain !== undefined && { rgTrackGain: rg.trackGain }),
|
||||
...(rg.trackPeak !== undefined && { rgTrackPeak: rg.trackPeak }),
|
||||
}
|
||||
}
|
||||
|
||||
const processSongsForPlayback = (songs) => {
|
||||
const songData = {}
|
||||
const ids = []
|
||||
songs.forEach((s) => {
|
||||
const song = mapReplayGain(s)
|
||||
songData[song.id] = song
|
||||
ids.push(song.id)
|
||||
})
|
||||
return { songData, ids }
|
||||
}
|
||||
|
||||
export const playTopSongs = async (dispatch, notify, artistName) => {
|
||||
const res = await subsonic.getTopSongs(artistName, 100)
|
||||
const data = res.json['subsonic-response']
|
||||
|
||||
if (data.status !== 'ok') {
|
||||
throw new Error(
|
||||
`Error fetching top songs: ${data.error?.message || 'Unknown error'} (Code: ${data.error?.code || 'unknown'})`,
|
||||
)
|
||||
}
|
||||
|
||||
const songs = data.topSongs?.song || []
|
||||
if (!songs.length) {
|
||||
notify('message.noTopSongsFound', 'warning')
|
||||
return
|
||||
}
|
||||
|
||||
const { songData, ids } = processSongsForPlayback(songs)
|
||||
dispatch(playTracks(songData, ids))
|
||||
}
|
||||
|
||||
export const playSimilar = async (dispatch, notify, id) => {
|
||||
const res = await subsonic.getSimilarSongs2(id, 100)
|
||||
const data = res.json['subsonic-response']
|
||||
|
||||
if (data.status !== 'ok') {
|
||||
throw new Error(
|
||||
`Error fetching similar songs: ${data.error?.message || 'Unknown error'} (Code: ${data.error?.code || 'unknown'})`,
|
||||
)
|
||||
}
|
||||
|
||||
const songs = data.similarSongs2?.song || []
|
||||
if (!songs.length) {
|
||||
notify('message.noSimilarSongsFound', 'warning')
|
||||
return
|
||||
}
|
||||
|
||||
const { songData, ids } = processSongsForPlayback(songs)
|
||||
dispatch(playTracks(songData, ids))
|
||||
}
|
||||
|
||||
export const playShuffle = async (dataProvider, dispatch, id) => {
|
||||
const res = await dataProvider.getList('song', {
|
||||
pagination: { page: 1, perPage: 500 },
|
||||
sort: { field: 'random', order: 'ASC' },
|
||||
filter: { album_artist_id: id, missing: false },
|
||||
})
|
||||
|
||||
const data = {}
|
||||
const ids = []
|
||||
res.data.forEach((s) => {
|
||||
data[s.id] = s
|
||||
ids.push(s.id)
|
||||
})
|
||||
dispatch(playTracks(data, ids))
|
||||
}
|
||||
18
ui/src/artist/index.jsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import React from 'react'
|
||||
import ArtistList from './ArtistList'
|
||||
import ArtistShow from './ArtistShow'
|
||||
import DynamicMenuIcon from '../layout/DynamicMenuIcon'
|
||||
import MicNoneOutlinedIcon from '@material-ui/icons/MicNoneOutlined'
|
||||
import MicIcon from '@material-ui/icons/Mic'
|
||||
|
||||
export default {
|
||||
list: ArtistList,
|
||||
show: ArtistShow,
|
||||
icon: (
|
||||
<DynamicMenuIcon
|
||||
path={'artist'}
|
||||
icon={MicNoneOutlinedIcon}
|
||||
activeIcon={MicIcon}
|
||||
/>
|
||||
),
|
||||
}
|
||||
82
ui/src/audioplayer/AudioTitle.jsx
Normal file
@@ -0,0 +1,82 @@
|
||||
import React from 'react'
|
||||
import { useMediaQuery } from '@material-ui/core'
|
||||
import { Link } from 'react-router-dom'
|
||||
import clsx from 'clsx'
|
||||
import { QualityInfo } from '../common'
|
||||
import useStyle from './styles'
|
||||
import { useDrag } from 'react-dnd'
|
||||
import { DraggableTypes } from '../consts'
|
||||
|
||||
const AudioTitle = React.memo(({ audioInfo, gainInfo, isMobile }) => {
|
||||
const classes = useStyle()
|
||||
const className = classes.audioTitle
|
||||
const isDesktop = useMediaQuery('(min-width:810px)')
|
||||
|
||||
const song = audioInfo.song
|
||||
const [, dragSongRef] = useDrag(
|
||||
() => ({
|
||||
type: DraggableTypes.SONG,
|
||||
item: { ids: [song?.id] },
|
||||
options: { dropEffect: 'copy' },
|
||||
}),
|
||||
[song],
|
||||
)
|
||||
|
||||
if (!song) {
|
||||
return ''
|
||||
}
|
||||
|
||||
const qi = {
|
||||
suffix: song.suffix,
|
||||
bitRate: song.bitRate,
|
||||
rgAlbumGain: song.rgAlbumGain,
|
||||
rgAlbumPeak: song.rgAlbumPeak,
|
||||
rgTrackGain: song.rgTrackGain,
|
||||
rgTrackPeak: song.rgTrackPeak,
|
||||
}
|
||||
|
||||
const subtitle = song.tags?.['subtitle']
|
||||
const title = song.title + (subtitle ? ` (${subtitle})` : '')
|
||||
|
||||
const linkTo = audioInfo.isRadio
|
||||
? `/radio/${audioInfo.trackId}/show`
|
||||
: song.playlistId
|
||||
? `/playlist/${song.playlistId}/show`
|
||||
: `/album/${song.albumId}/show`
|
||||
|
||||
return (
|
||||
<Link to={linkTo} className={className} ref={dragSongRef}>
|
||||
<span>
|
||||
<span className={clsx(classes.songTitle, 'songTitle')}>{title}</span>
|
||||
{isDesktop && (
|
||||
<QualityInfo
|
||||
record={qi}
|
||||
className={classes.qualityInfo}
|
||||
{...gainInfo}
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
{isMobile ? (
|
||||
<>
|
||||
<span className={classes.songInfo}>
|
||||
<span className={'songArtist'}>{song.artist}</span>
|
||||
</span>
|
||||
<span className={clsx(classes.songInfo, classes.songAlbum)}>
|
||||
<span className={'songAlbum'}>{song.album}</span>
|
||||
{song.year ? ` - ${song.year}` : ''}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<span className={classes.songInfo}>
|
||||
<span className={'songArtist'}>{song.artist}</span> -{' '}
|
||||
<span className={'songAlbum'}>{song.album}</span>
|
||||
{song.year ? ` - ${song.year}` : ''}
|
||||
</span>
|
||||
)}
|
||||
</Link>
|
||||
)
|
||||
})
|
||||
|
||||
AudioTitle.displayName = 'AudioTitle'
|
||||
|
||||
export default AudioTitle
|
||||
58
ui/src/audioplayer/AudioTitle.test.jsx
Normal file
@@ -0,0 +1,58 @@
|
||||
import React from 'react'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||
import AudioTitle from './AudioTitle'
|
||||
|
||||
vi.mock('@material-ui/core', async () => {
|
||||
const actual = await import('@material-ui/core')
|
||||
return {
|
||||
...actual,
|
||||
useMediaQuery: vi.fn(),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('react-router-dom', () => ({
|
||||
// eslint-disable-next-line react/display-name
|
||||
Link: React.forwardRef(({ to, children, ...props }, ref) => (
|
||||
<a href={to} ref={ref} {...props}>
|
||||
{children}
|
||||
</a>
|
||||
)),
|
||||
}))
|
||||
|
||||
vi.mock('react-dnd', () => ({
|
||||
useDrag: vi.fn(() => [null, () => {}]),
|
||||
}))
|
||||
|
||||
describe('<AudioTitle />', () => {
|
||||
const baseSong = {
|
||||
id: 'song-1',
|
||||
albumId: 'album-1',
|
||||
playlistId: 'playlist-1',
|
||||
title: 'Test Song',
|
||||
artist: 'Artist',
|
||||
album: 'Album',
|
||||
year: '2020',
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('links to playlist when playlistId is provided', () => {
|
||||
const audioInfo = { trackId: 'track-1', song: baseSong }
|
||||
render(<AudioTitle audioInfo={audioInfo} gainInfo={{}} isMobile={false} />)
|
||||
const link = screen.getByRole('link')
|
||||
expect(link.getAttribute('href')).toBe('/playlist/playlist-1/show')
|
||||
})
|
||||
|
||||
it('falls back to album link when no playlistId', () => {
|
||||
const audioInfo = {
|
||||
trackId: 'track-1',
|
||||
song: { ...baseSong, playlistId: undefined },
|
||||
}
|
||||
render(<AudioTitle audioInfo={audioInfo} gainInfo={{}} isMobile={false} />)
|
||||
const link = screen.getByRole('link')
|
||||
expect(link.getAttribute('href')).toBe('/album/album-1/show')
|
||||
})
|
||||
})
|
||||
318
ui/src/audioplayer/Player.jsx
Normal file
@@ -0,0 +1,318 @@
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { useDispatch, useSelector } from 'react-redux'
|
||||
import { useMediaQuery } from '@material-ui/core'
|
||||
import { ThemeProvider } from '@material-ui/core/styles'
|
||||
import {
|
||||
createMuiTheme,
|
||||
useAuthState,
|
||||
useDataProvider,
|
||||
useTranslate,
|
||||
} from 'react-admin'
|
||||
import ReactGA from 'react-ga'
|
||||
import { GlobalHotKeys } from 'react-hotkeys'
|
||||
import ReactJkMusicPlayer from 'navidrome-music-player'
|
||||
import 'navidrome-music-player/assets/index.css'
|
||||
import useCurrentTheme from '../themes/useCurrentTheme'
|
||||
import config from '../config'
|
||||
import useStyle from './styles'
|
||||
import AudioTitle from './AudioTitle'
|
||||
import {
|
||||
clearQueue,
|
||||
currentPlaying,
|
||||
setPlayMode,
|
||||
setVolume,
|
||||
syncQueue,
|
||||
} from '../actions'
|
||||
import PlayerToolbar from './PlayerToolbar'
|
||||
import { sendNotification } from '../utils'
|
||||
import subsonic from '../subsonic'
|
||||
import locale from './locale'
|
||||
import { keyMap } from '../hotkeys'
|
||||
import keyHandlers from './keyHandlers'
|
||||
import { calculateGain } from '../utils/calculateReplayGain'
|
||||
|
||||
const Player = () => {
|
||||
const theme = useCurrentTheme()
|
||||
const translate = useTranslate()
|
||||
const playerTheme = theme.player?.theme || 'dark'
|
||||
const dataProvider = useDataProvider()
|
||||
const playerState = useSelector((state) => state.player)
|
||||
const dispatch = useDispatch()
|
||||
const [startTime, setStartTime] = useState(null)
|
||||
const [scrobbled, setScrobbled] = useState(false)
|
||||
const [preloaded, setPreload] = useState(false)
|
||||
const [audioInstance, setAudioInstance] = useState(null)
|
||||
const isDesktop = useMediaQuery('(min-width:810px)')
|
||||
const isMobilePlayer =
|
||||
/Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(
|
||||
navigator.userAgent,
|
||||
)
|
||||
|
||||
const { authenticated } = useAuthState()
|
||||
const visible = authenticated && playerState.queue.length > 0
|
||||
const isRadio = playerState.current?.isRadio || false
|
||||
const classes = useStyle({
|
||||
isRadio,
|
||||
visible,
|
||||
enableCoverAnimation: config.enableCoverAnimation,
|
||||
})
|
||||
const showNotifications = useSelector(
|
||||
(state) => state.settings.notifications || false,
|
||||
)
|
||||
const gainInfo = useSelector((state) => state.replayGain)
|
||||
const [context, setContext] = useState(null)
|
||||
const [gainNode, setGainNode] = useState(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
context === null &&
|
||||
audioInstance &&
|
||||
config.enableReplayGain &&
|
||||
'AudioContext' in window &&
|
||||
(gainInfo.gainMode === 'album' || gainInfo.gainMode === 'track')
|
||||
) {
|
||||
const ctx = new AudioContext()
|
||||
// we need this to support radios in firefox
|
||||
audioInstance.crossOrigin = 'anonymous'
|
||||
const source = ctx.createMediaElementSource(audioInstance)
|
||||
const gain = ctx.createGain()
|
||||
|
||||
source.connect(gain)
|
||||
gain.connect(ctx.destination)
|
||||
|
||||
setContext(ctx)
|
||||
setGainNode(gain)
|
||||
}
|
||||
}, [audioInstance, context, gainInfo.gainMode])
|
||||
|
||||
useEffect(() => {
|
||||
if (gainNode) {
|
||||
const current = playerState.current || {}
|
||||
const song = current.song || {}
|
||||
|
||||
const numericGain = calculateGain(gainInfo, song)
|
||||
gainNode.gain.setValueAtTime(numericGain, context.currentTime)
|
||||
}
|
||||
}, [audioInstance, context, gainNode, playerState, gainInfo])
|
||||
|
||||
const defaultOptions = useMemo(
|
||||
() => ({
|
||||
theme: playerTheme,
|
||||
bounds: 'body',
|
||||
playMode: playerState.mode,
|
||||
mode: 'full',
|
||||
loadAudioErrorPlayNext: false,
|
||||
autoPlayInitLoadPlayList: true,
|
||||
clearPriorAudioLists: false,
|
||||
showDestroy: true,
|
||||
showDownload: false,
|
||||
showLyric: true,
|
||||
showReload: false,
|
||||
toggleMode: !isDesktop,
|
||||
glassBg: false,
|
||||
showThemeSwitch: false,
|
||||
showMediaSession: true,
|
||||
restartCurrentOnPrev: true,
|
||||
quietUpdate: true,
|
||||
defaultPosition: {
|
||||
top: 300,
|
||||
left: 120,
|
||||
},
|
||||
volumeFade: { fadeIn: 200, fadeOut: 200 },
|
||||
renderAudioTitle: (audioInfo, isMobile) => (
|
||||
<AudioTitle
|
||||
audioInfo={audioInfo}
|
||||
gainInfo={gainInfo}
|
||||
isMobile={isMobile}
|
||||
/>
|
||||
),
|
||||
locale: locale(translate),
|
||||
sortableOptions: { delay: 200, delayOnTouchOnly: true },
|
||||
}),
|
||||
[gainInfo, isDesktop, playerTheme, translate, playerState.mode],
|
||||
)
|
||||
|
||||
const options = useMemo(() => {
|
||||
const current = playerState.current || {}
|
||||
return {
|
||||
...defaultOptions,
|
||||
audioLists: playerState.queue.map((item) => item),
|
||||
playIndex: playerState.playIndex,
|
||||
autoPlay: playerState.clear || playerState.playIndex === 0,
|
||||
clearPriorAudioLists: playerState.clear,
|
||||
extendsContent: (
|
||||
<PlayerToolbar id={current.trackId} isRadio={current.isRadio} />
|
||||
),
|
||||
defaultVolume: isMobilePlayer ? 1 : playerState.volume,
|
||||
showMediaSession: !current.isRadio,
|
||||
}
|
||||
}, [playerState, defaultOptions, isMobilePlayer])
|
||||
|
||||
const onAudioListsChange = useCallback(
|
||||
(_, audioLists, audioInfo) => dispatch(syncQueue(audioInfo, audioLists)),
|
||||
[dispatch],
|
||||
)
|
||||
|
||||
const nextSong = useCallback(() => {
|
||||
const idx = playerState.queue.findIndex(
|
||||
(item) => item.uuid === playerState.current.uuid,
|
||||
)
|
||||
return idx !== null ? playerState.queue[idx + 1] : null
|
||||
}, [playerState])
|
||||
|
||||
const onAudioProgress = useCallback(
|
||||
(info) => {
|
||||
if (info.ended) {
|
||||
document.title = 'Navidrome'
|
||||
}
|
||||
|
||||
const progress = (info.currentTime / info.duration) * 100
|
||||
if (isNaN(info.duration) || (progress < 50 && info.currentTime < 240)) {
|
||||
return
|
||||
}
|
||||
|
||||
if (info.isRadio) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!preloaded) {
|
||||
const next = nextSong()
|
||||
if (next != null) {
|
||||
const audio = new Audio()
|
||||
audio.src = next.musicSrc
|
||||
}
|
||||
setPreload(true)
|
||||
return
|
||||
}
|
||||
|
||||
if (!scrobbled) {
|
||||
info.trackId && subsonic.scrobble(info.trackId, startTime)
|
||||
setScrobbled(true)
|
||||
}
|
||||
},
|
||||
[startTime, scrobbled, nextSong, preloaded],
|
||||
)
|
||||
|
||||
const onAudioVolumeChange = useCallback(
|
||||
// sqrt to compensate for the logarithmic volume
|
||||
(volume) => dispatch(setVolume(Math.sqrt(volume))),
|
||||
[dispatch],
|
||||
)
|
||||
|
||||
const onAudioPlay = useCallback(
|
||||
(info) => {
|
||||
// Do this to start the context; on chrome-based browsers, the context
|
||||
// will start paused since it is created prior to user interaction
|
||||
if (context && context.state !== 'running') {
|
||||
context.resume()
|
||||
}
|
||||
|
||||
dispatch(currentPlaying(info))
|
||||
if (startTime === null) {
|
||||
setStartTime(Date.now())
|
||||
}
|
||||
if (info.duration) {
|
||||
const song = info.song
|
||||
document.title = `${song.title} - ${song.artist} - Navidrome`
|
||||
if (!info.isRadio) {
|
||||
const pos = startTime === null ? null : Math.floor(info.currentTime)
|
||||
subsonic.nowPlaying(info.trackId, pos)
|
||||
}
|
||||
setPreload(false)
|
||||
if (config.gaTrackingId) {
|
||||
ReactGA.event({
|
||||
category: 'Player',
|
||||
action: 'Play song',
|
||||
label: `${song.title} - ${song.artist}`,
|
||||
})
|
||||
}
|
||||
if (showNotifications) {
|
||||
sendNotification(
|
||||
song.title,
|
||||
`${song.artist} - ${song.album}`,
|
||||
info.cover,
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
[context, dispatch, showNotifications, startTime],
|
||||
)
|
||||
|
||||
const onAudioPlayTrackChange = useCallback(() => {
|
||||
if (scrobbled) {
|
||||
setScrobbled(false)
|
||||
}
|
||||
if (startTime !== null) {
|
||||
setStartTime(null)
|
||||
}
|
||||
}, [scrobbled, startTime])
|
||||
|
||||
const onAudioPause = useCallback(
|
||||
(info) => dispatch(currentPlaying(info)),
|
||||
[dispatch],
|
||||
)
|
||||
|
||||
const onAudioEnded = useCallback(
|
||||
(currentPlayId, audioLists, info) => {
|
||||
setScrobbled(false)
|
||||
setStartTime(null)
|
||||
dispatch(currentPlaying(info))
|
||||
dataProvider
|
||||
.getOne('keepalive', { id: info.trackId })
|
||||
// eslint-disable-next-line no-console
|
||||
.catch((e) => console.log('Keepalive error:', e))
|
||||
},
|
||||
[dispatch, dataProvider],
|
||||
)
|
||||
|
||||
const onCoverClick = useCallback((mode, audioLists, audioInfo) => {
|
||||
if (mode === 'full' && audioInfo?.song?.albumId) {
|
||||
window.location.href = `#/album/${audioInfo.song.albumId}/show`
|
||||
}
|
||||
}, [])
|
||||
|
||||
const onBeforeDestroy = useCallback(() => {
|
||||
return new Promise((resolve, reject) => {
|
||||
dispatch(clearQueue())
|
||||
reject()
|
||||
})
|
||||
}, [dispatch])
|
||||
|
||||
if (!visible) {
|
||||
document.title = 'Navidrome'
|
||||
}
|
||||
|
||||
const handlers = useMemo(
|
||||
() => keyHandlers(audioInstance, playerState),
|
||||
[audioInstance, playerState],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (isMobilePlayer && audioInstance) {
|
||||
audioInstance.volume = 1
|
||||
}
|
||||
}, [isMobilePlayer, audioInstance])
|
||||
|
||||
return (
|
||||
<ThemeProvider theme={createMuiTheme(theme)}>
|
||||
<ReactJkMusicPlayer
|
||||
{...options}
|
||||
className={classes.player}
|
||||
onAudioListsChange={onAudioListsChange}
|
||||
onAudioVolumeChange={onAudioVolumeChange}
|
||||
onAudioProgress={onAudioProgress}
|
||||
onAudioPlay={onAudioPlay}
|
||||
onAudioPlayTrackChange={onAudioPlayTrackChange}
|
||||
onAudioPause={onAudioPause}
|
||||
onPlayModeChange={(mode) => dispatch(setPlayMode(mode))}
|
||||
onAudioEnded={onAudioEnded}
|
||||
onCoverClick={onCoverClick}
|
||||
onBeforeDestroy={onBeforeDestroy}
|
||||
getAudioInstance={setAudioInstance}
|
||||
/>
|
||||
<GlobalHotKeys handlers={handlers} keyMap={keyMap} allowChanges />
|
||||
</ThemeProvider>
|
||||
)
|
||||
}
|
||||
|
||||
export { Player }
|
||||
120
ui/src/audioplayer/PlayerToolbar.jsx
Normal file
@@ -0,0 +1,120 @@
|
||||
import React, { useCallback } from 'react'
|
||||
import { useDispatch } from 'react-redux'
|
||||
import { useGetOne } from 'react-admin'
|
||||
import { GlobalHotKeys } from 'react-hotkeys'
|
||||
import IconButton from '@material-ui/core/IconButton'
|
||||
import { useMediaQuery } from '@material-ui/core'
|
||||
import { RiSaveLine } from 'react-icons/ri'
|
||||
import { LoveButton, useToggleLove } from '../common'
|
||||
import { openSaveQueueDialog } from '../actions'
|
||||
import { keyMap } from '../hotkeys'
|
||||
import { makeStyles } from '@material-ui/core/styles'
|
||||
|
||||
const useStyles = makeStyles((theme) => ({
|
||||
toolbar: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
flexGrow: 1,
|
||||
justifyContent: 'flex-end',
|
||||
gap: '0.5rem',
|
||||
listStyle: 'none',
|
||||
padding: 0,
|
||||
margin: 0,
|
||||
},
|
||||
mobileListItem: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
listStyle: 'none',
|
||||
padding: theme.spacing(0.5),
|
||||
margin: 0,
|
||||
height: 24,
|
||||
},
|
||||
button: {
|
||||
width: '2.5rem',
|
||||
height: '2.5rem',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: 0,
|
||||
},
|
||||
mobileButton: {
|
||||
width: 24,
|
||||
height: 24,
|
||||
padding: 0,
|
||||
margin: 0,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontSize: '18px',
|
||||
},
|
||||
mobileIcon: {
|
||||
fontSize: '18px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
},
|
||||
}))
|
||||
|
||||
const PlayerToolbar = ({ id, isRadio }) => {
|
||||
const dispatch = useDispatch()
|
||||
const { data, loading } = useGetOne('song', id, { enabled: !!id && !isRadio })
|
||||
const [toggleLove, toggling] = useToggleLove('song', data)
|
||||
const isDesktop = useMediaQuery('(min-width:810px)')
|
||||
const classes = useStyles()
|
||||
|
||||
const handlers = {
|
||||
TOGGLE_LOVE: useCallback(() => toggleLove(), [toggleLove]),
|
||||
}
|
||||
|
||||
const handleSaveQueue = useCallback(
|
||||
(e) => {
|
||||
dispatch(openSaveQueueDialog())
|
||||
e.stopPropagation()
|
||||
},
|
||||
[dispatch],
|
||||
)
|
||||
|
||||
const buttonClass = isDesktop ? classes.button : classes.mobileButton
|
||||
const listItemClass = isDesktop ? classes.toolbar : classes.mobileListItem
|
||||
|
||||
const saveQueueButton = (
|
||||
<IconButton
|
||||
size={isDesktop ? 'small' : undefined}
|
||||
onClick={handleSaveQueue}
|
||||
disabled={isRadio}
|
||||
data-testid="save-queue-button"
|
||||
className={buttonClass}
|
||||
>
|
||||
<RiSaveLine className={!isDesktop ? classes.mobileIcon : undefined} />
|
||||
</IconButton>
|
||||
)
|
||||
|
||||
const loveButton = (
|
||||
<LoveButton
|
||||
record={data}
|
||||
resource={'song'}
|
||||
size={isDesktop ? undefined : 'inherit'}
|
||||
disabled={loading || toggling || !id || isRadio}
|
||||
className={buttonClass}
|
||||
/>
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
<GlobalHotKeys keyMap={keyMap} handlers={handlers} allowChanges />
|
||||
{isDesktop ? (
|
||||
<li className={`${listItemClass} item`}>
|
||||
{saveQueueButton}
|
||||
{loveButton}
|
||||
</li>
|
||||
) : (
|
||||
<>
|
||||
<li className={`${listItemClass} item`}>{saveQueueButton}</li>
|
||||
<li className={`${listItemClass} item`}>{loveButton}</li>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default PlayerToolbar
|
||||
166
ui/src/audioplayer/PlayerToolbar.test.jsx
Normal file
@@ -0,0 +1,166 @@
|
||||
import React from 'react'
|
||||
import { render, screen, fireEvent, cleanup } from '@testing-library/react'
|
||||
import { useMediaQuery } from '@material-ui/core'
|
||||
import { useGetOne } from 'react-admin'
|
||||
import { useDispatch } from 'react-redux'
|
||||
import { useToggleLove } from '../common'
|
||||
import { openSaveQueueDialog } from '../actions'
|
||||
import PlayerToolbar from './PlayerToolbar'
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock('@material-ui/core', async () => {
|
||||
const actual = await import('@material-ui/core')
|
||||
return {
|
||||
...actual,
|
||||
useMediaQuery: vi.fn(),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('react-admin', () => ({
|
||||
useGetOne: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('react-redux', () => ({
|
||||
useDispatch: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('../common', () => ({
|
||||
LoveButton: ({ className, disabled }) => (
|
||||
<button data-testid="love-button" className={className} disabled={disabled}>
|
||||
Love
|
||||
</button>
|
||||
),
|
||||
useToggleLove: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('../actions', () => ({
|
||||
openSaveQueueDialog: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('react-hotkeys', () => ({
|
||||
GlobalHotKeys: () => <div data-testid="global-hotkeys" />,
|
||||
}))
|
||||
|
||||
describe('<PlayerToolbar />', () => {
|
||||
const mockToggleLove = vi.fn()
|
||||
const mockDispatch = vi.fn()
|
||||
const mockSongData = { id: 'song-1', name: 'Test Song', starred: false }
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
useGetOne.mockReturnValue({ data: mockSongData, loading: false })
|
||||
useToggleLove.mockReturnValue([mockToggleLove, false])
|
||||
useDispatch.mockReturnValue(mockDispatch)
|
||||
openSaveQueueDialog.mockReturnValue({ type: 'OPEN_SAVE_QUEUE_DIALOG' })
|
||||
})
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
describe('Desktop layout', () => {
|
||||
beforeEach(() => {
|
||||
useMediaQuery.mockReturnValue(true) // isDesktop = true
|
||||
})
|
||||
|
||||
it('renders desktop toolbar with both buttons', () => {
|
||||
render(<PlayerToolbar id="song-1" />)
|
||||
|
||||
// Both buttons should be in a single list item
|
||||
const listItems = screen.getAllByRole('listitem')
|
||||
expect(listItems).toHaveLength(1)
|
||||
|
||||
// Verify both buttons are rendered
|
||||
expect(screen.getByTestId('save-queue-button')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('love-button')).toBeInTheDocument()
|
||||
|
||||
// Verify desktop classes are applied
|
||||
expect(listItems[0].className).toContain('toolbar')
|
||||
})
|
||||
|
||||
it('disables save queue button when isRadio is true', () => {
|
||||
render(<PlayerToolbar id="song-1" isRadio={true} />)
|
||||
|
||||
const saveQueueButton = screen.getByTestId('save-queue-button')
|
||||
expect(saveQueueButton).toBeDisabled()
|
||||
})
|
||||
|
||||
it('disables love button when conditions are met', () => {
|
||||
useGetOne.mockReturnValue({ data: mockSongData, loading: true })
|
||||
|
||||
render(<PlayerToolbar id="song-1" />)
|
||||
|
||||
const loveButton = screen.getByTestId('love-button')
|
||||
expect(loveButton).toBeDisabled()
|
||||
})
|
||||
|
||||
it('opens save queue dialog when save button is clicked', () => {
|
||||
render(<PlayerToolbar id="song-1" />)
|
||||
|
||||
const saveQueueButton = screen.getByTestId('save-queue-button')
|
||||
fireEvent.click(saveQueueButton)
|
||||
|
||||
expect(mockDispatch).toHaveBeenCalledWith({
|
||||
type: 'OPEN_SAVE_QUEUE_DIALOG',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Mobile layout', () => {
|
||||
beforeEach(() => {
|
||||
useMediaQuery.mockReturnValue(false) // isDesktop = false
|
||||
})
|
||||
|
||||
it('renders mobile toolbar with buttons in separate list items', () => {
|
||||
render(<PlayerToolbar id="song-1" />)
|
||||
|
||||
// Each button should be in its own list item
|
||||
const listItems = screen.getAllByRole('listitem')
|
||||
expect(listItems).toHaveLength(2)
|
||||
|
||||
// Verify both buttons are rendered
|
||||
expect(screen.getByTestId('save-queue-button')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('love-button')).toBeInTheDocument()
|
||||
|
||||
// Verify mobile classes are applied
|
||||
expect(listItems[0].className).toContain('mobileListItem')
|
||||
expect(listItems[1].className).toContain('mobileListItem')
|
||||
})
|
||||
|
||||
it('disables save queue button when isRadio is true', () => {
|
||||
render(<PlayerToolbar id="song-1" isRadio={true} />)
|
||||
|
||||
const saveQueueButton = screen.getByTestId('save-queue-button')
|
||||
expect(saveQueueButton).toBeDisabled()
|
||||
})
|
||||
|
||||
it('disables love button when conditions are met', () => {
|
||||
useGetOne.mockReturnValue({ data: mockSongData, loading: true })
|
||||
|
||||
render(<PlayerToolbar id="song-1" />)
|
||||
|
||||
const loveButton = screen.getByTestId('love-button')
|
||||
expect(loveButton).toBeDisabled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Common behavior', () => {
|
||||
it('renders global hotkeys in both layouts', () => {
|
||||
// Test desktop layout
|
||||
useMediaQuery.mockReturnValue(true)
|
||||
render(<PlayerToolbar id="song-1" />)
|
||||
expect(screen.getByTestId('global-hotkeys')).toBeInTheDocument()
|
||||
|
||||
// Cleanup and test mobile layout
|
||||
cleanup()
|
||||
useMediaQuery.mockReturnValue(false)
|
||||
render(<PlayerToolbar id="song-1" />)
|
||||
expect(screen.getByTestId('global-hotkeys')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('disables buttons when id is not provided', () => {
|
||||
render(<PlayerToolbar />)
|
||||
|
||||
const loveButton = screen.getByTestId('love-button')
|
||||
expect(loveButton).toBeDisabled()
|
||||
})
|
||||
})
|
||||
})
|
||||
1
ui/src/audioplayer/index.js
Normal file
@@ -0,0 +1 @@
|
||||
export * from './Player'
|
||||
37
ui/src/audioplayer/keyHandlers.jsx
Normal file
@@ -0,0 +1,37 @@
|
||||
const keyHandlers = (audioInstance, playerState) => {
|
||||
const nextSong = () => {
|
||||
const idx = playerState.queue.findIndex(
|
||||
(item) => item.uuid === playerState.current.uuid,
|
||||
)
|
||||
return idx !== null ? playerState.queue[idx + 1] : null
|
||||
}
|
||||
|
||||
const prevSong = () => {
|
||||
const idx = playerState.queue.findIndex(
|
||||
(item) => item.uuid === playerState.current.uuid,
|
||||
)
|
||||
return idx !== null ? playerState.queue[idx - 1] : null
|
||||
}
|
||||
|
||||
return {
|
||||
TOGGLE_PLAY: (e) => {
|
||||
e.preventDefault()
|
||||
audioInstance && audioInstance.togglePlay()
|
||||
},
|
||||
VOL_UP: () =>
|
||||
(audioInstance.volume = Math.min(1, audioInstance.volume + 0.1)),
|
||||
VOL_DOWN: () =>
|
||||
(audioInstance.volume = Math.max(0, audioInstance.volume - 0.1)),
|
||||
PREV_SONG: (e) => {
|
||||
if (!e.metaKey && prevSong()) audioInstance && audioInstance.playPrev()
|
||||
},
|
||||
CURRENT_SONG: () => {
|
||||
window.location.href = `#/album/${playerState.current?.song.albumId}/show`
|
||||
},
|
||||
NEXT_SONG: (e) => {
|
||||
if (!e.metaKey && nextSong()) audioInstance && audioInstance.playNext()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export default keyHandlers
|
||||
27
ui/src/audioplayer/locale.js
Normal file
@@ -0,0 +1,27 @@
|
||||
const locale = (translate) => ({
|
||||
playListsText: translate('player.playListsText'),
|
||||
openText: translate('player.openText'),
|
||||
closeText: translate('player.closeText'),
|
||||
notContentText: translate('player.notContentText'),
|
||||
clickToPlayText: translate('player.clickToPlayText'),
|
||||
clickToPauseText: translate('player.clickToPauseText'),
|
||||
nextTrackText: translate('player.nextTrackText'),
|
||||
previousTrackText: translate('player.previousTrackText'),
|
||||
reloadText: translate('player.reloadText'),
|
||||
volumeText: translate('player.volumeText'),
|
||||
toggleLyricText: translate('player.toggleLyricText'),
|
||||
toggleMiniModeText: translate('player.toggleMiniModeText'),
|
||||
destroyText: translate('player.destroyText'),
|
||||
downloadText: translate('player.downloadText'),
|
||||
removeAudioListsText: translate('player.removeAudioListsText'),
|
||||
clickToDeleteText: (name) => translate('player.clickToDeleteText', { name }),
|
||||
emptyLyricText: translate('player.emptyLyricText'),
|
||||
playModeText: {
|
||||
order: translate('player.playModeText.order'),
|
||||
orderLoop: translate('player.playModeText.orderLoop'),
|
||||
singleLoop: translate('player.playModeText.singleLoop'),
|
||||
shufflePlay: translate('player.playModeText.shufflePlay'),
|
||||
},
|
||||
})
|
||||
|
||||
export default locale
|
||||
93
ui/src/audioplayer/styles.js
Normal file
@@ -0,0 +1,93 @@
|
||||
import { makeStyles } from '@material-ui/core/styles'
|
||||
|
||||
const useStyle = makeStyles(
|
||||
(theme) => ({
|
||||
audioTitle: {
|
||||
textDecoration: 'none',
|
||||
color: theme.palette.primary.dark,
|
||||
},
|
||||
songTitle: {
|
||||
fontWeight: 'bold',
|
||||
'&:hover + $qualityInfo': {
|
||||
opacity: 1,
|
||||
},
|
||||
},
|
||||
songInfo: {
|
||||
display: 'block',
|
||||
marginTop: '2px',
|
||||
},
|
||||
songAlbum: {
|
||||
fontStyle: 'italic',
|
||||
fontSize: 'smaller',
|
||||
},
|
||||
qualityInfo: {
|
||||
marginTop: '-4px',
|
||||
opacity: 0,
|
||||
transition: 'all 500ms ease-out',
|
||||
},
|
||||
player: {
|
||||
display: (props) => (props.visible ? 'block' : 'none'),
|
||||
'@media screen and (max-width:810px)': {
|
||||
'& .sound-operation': {
|
||||
display: 'none',
|
||||
},
|
||||
},
|
||||
'@media (prefers-reduced-motion)': {
|
||||
'& .music-player-panel .panel-content div.img-rotate': {
|
||||
animation: 'none',
|
||||
},
|
||||
},
|
||||
'& .progress-bar-content': {
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
},
|
||||
'& .play-mode-title': {
|
||||
'pointer-events': 'none',
|
||||
},
|
||||
'& .music-player-panel .panel-content div.img-rotate': {
|
||||
// Customize desktop player when cover animation is disabled
|
||||
animationDuration: (props) => !props.enableCoverAnimation && '0s',
|
||||
borderRadius: (props) => !props.enableCoverAnimation && '0',
|
||||
// Fix cover display when image is not square
|
||||
backgroundSize: 'contain',
|
||||
backgroundPosition: 'center',
|
||||
},
|
||||
'& .react-jinke-music-player-mobile .react-jinke-music-player-mobile-cover':
|
||||
{
|
||||
// Customize mobile player when cover animation is disabled
|
||||
borderRadius: (props) => !props.enableCoverAnimation && '0',
|
||||
width: (props) => !props.enableCoverAnimation && '85%',
|
||||
maxWidth: (props) => !props.enableCoverAnimation && '600px',
|
||||
height: (props) => !props.enableCoverAnimation && 'auto',
|
||||
// Fix cover display when image is not square
|
||||
aspectRatio: '1/1',
|
||||
display: 'flex',
|
||||
},
|
||||
'& .react-jinke-music-player-mobile .react-jinke-music-player-mobile-cover img.cover':
|
||||
{
|
||||
animationDuration: (props) => !props.enableCoverAnimation && '0s',
|
||||
objectFit: 'contain', // Fix cover display when image is not square
|
||||
},
|
||||
// Hide old singer display
|
||||
'& .react-jinke-music-player-mobile .react-jinke-music-player-mobile-singer':
|
||||
{
|
||||
display: 'none',
|
||||
},
|
||||
// Hide extra whitespace from switch div
|
||||
'& .react-jinke-music-player-mobile .react-jinke-music-player-mobile-switch':
|
||||
{
|
||||
display: 'none',
|
||||
},
|
||||
'& .music-player-panel .panel-content .progress-bar-content section.audio-main':
|
||||
{
|
||||
display: (props) => (props.isRadio ? 'none' : 'inline-flex'),
|
||||
},
|
||||
'& .react-jinke-music-player-mobile-progress': {
|
||||
display: (props) => (props.isRadio ? 'none' : 'flex'),
|
||||
},
|
||||
},
|
||||
}),
|
||||
{ name: 'NDAudioPlayer' },
|
||||
)
|
||||
|
||||
export default useStyle
|
||||
111
ui/src/authProvider.js
Normal file
@@ -0,0 +1,111 @@
|
||||
import { jwtDecode } from 'jwt-decode'
|
||||
import { baseUrl } from './utils'
|
||||
import config from './config'
|
||||
import { removeHomeCache } from './utils/removeHomeCache'
|
||||
|
||||
// config sent from server may contain authentication info, for example when the user is authenticated
|
||||
// by a reverse proxy request header
|
||||
if (config.auth) {
|
||||
try {
|
||||
storeAuthenticationInfo(config.auth)
|
||||
} catch (e) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(e)
|
||||
}
|
||||
}
|
||||
|
||||
function storeAuthenticationInfo(authInfo) {
|
||||
authInfo.token && localStorage.setItem('token', authInfo.token)
|
||||
localStorage.setItem('userId', authInfo.id)
|
||||
localStorage.setItem('name', authInfo.name)
|
||||
localStorage.setItem('username', authInfo.username)
|
||||
authInfo.avatar && localStorage.setItem('avatar', authInfo.avatar)
|
||||
localStorage.setItem('role', authInfo.isAdmin ? 'admin' : 'regular')
|
||||
localStorage.setItem('subsonic-salt', authInfo.subsonicSalt)
|
||||
localStorage.setItem('subsonic-token', authInfo.subsonicToken)
|
||||
localStorage.setItem('is-authenticated', 'true')
|
||||
}
|
||||
|
||||
const authProvider = {
|
||||
login: ({ username, password }) => {
|
||||
let url = baseUrl('/auth/login')
|
||||
if (config.firstTime) {
|
||||
url = baseUrl('/auth/createAdmin')
|
||||
}
|
||||
const request = new Request(url, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ username, password }),
|
||||
headers: new Headers({ 'Content-Type': 'application/json' }),
|
||||
})
|
||||
return fetch(request)
|
||||
.then((response) => {
|
||||
if (response.status < 200 || response.status >= 300) {
|
||||
throw new Error(response.statusText)
|
||||
}
|
||||
return response.json()
|
||||
})
|
||||
.then((response) => {
|
||||
jwtDecode(response.token) // Validate token
|
||||
storeAuthenticationInfo(response)
|
||||
// Avoid "going to create admin" dialog after logout/login without a refresh
|
||||
config.firstTime = false
|
||||
removeHomeCache()
|
||||
return response
|
||||
})
|
||||
.catch((error) => {
|
||||
if (
|
||||
error.message === 'Failed to fetch' ||
|
||||
error.stack === 'TypeError: Failed to fetch'
|
||||
) {
|
||||
throw new Error('errors.network_error')
|
||||
}
|
||||
|
||||
throw new Error(error)
|
||||
})
|
||||
},
|
||||
|
||||
logout: () => {
|
||||
removeItems()
|
||||
return Promise.resolve()
|
||||
},
|
||||
|
||||
checkAuth: () =>
|
||||
localStorage.getItem('is-authenticated')
|
||||
? Promise.resolve()
|
||||
: Promise.reject(),
|
||||
|
||||
checkError: ({ status }) => {
|
||||
if (status === 401) {
|
||||
removeItems()
|
||||
return Promise.reject()
|
||||
}
|
||||
return Promise.resolve()
|
||||
},
|
||||
|
||||
getPermissions: () => {
|
||||
const role = localStorage.getItem('role')
|
||||
return role ? Promise.resolve(role) : Promise.reject()
|
||||
},
|
||||
|
||||
getIdentity: () => {
|
||||
return Promise.resolve({
|
||||
id: localStorage.getItem('username'),
|
||||
fullName: localStorage.getItem('name'),
|
||||
avatar: localStorage.getItem('avatar'),
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
const removeItems = () => {
|
||||
localStorage.removeItem('token')
|
||||
localStorage.removeItem('userId')
|
||||
localStorage.removeItem('name')
|
||||
localStorage.removeItem('username')
|
||||
localStorage.removeItem('avatar')
|
||||
localStorage.removeItem('role')
|
||||
localStorage.removeItem('subsonic-salt')
|
||||
localStorage.removeItem('subsonic-token')
|
||||
localStorage.removeItem('is-authenticated')
|
||||
}
|
||||
|
||||
export default authProvider
|
||||
38
ui/src/common/AddToPlaylistButton.jsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import React from 'react'
|
||||
import PropTypes from 'prop-types'
|
||||
import { useDispatch } from 'react-redux'
|
||||
import { Button, useTranslate, useUnselectAll } from 'react-admin'
|
||||
import PlaylistAddIcon from '@material-ui/icons/PlaylistAdd'
|
||||
import { openAddToPlaylist } from '../actions'
|
||||
|
||||
export const AddToPlaylistButton = ({ resource, selectedIds, className }) => {
|
||||
const translate = useTranslate()
|
||||
const dispatch = useDispatch()
|
||||
const unselectAll = useUnselectAll()
|
||||
|
||||
const handleClick = () => {
|
||||
dispatch(
|
||||
openAddToPlaylist({
|
||||
selectedIds,
|
||||
onSuccess: () => unselectAll(resource),
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
aria-controls="simple-menu"
|
||||
aria-haspopup="true"
|
||||
onClick={handleClick}
|
||||
className={className}
|
||||
label={translate('resources.song.actions.addToPlaylist')}
|
||||
>
|
||||
<PlaylistAddIcon />
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
AddToPlaylistButton.propTypes = {
|
||||
resource: PropTypes.string.isRequired,
|
||||
selectedIds: PropTypes.arrayOf(PropTypes.string).isRequired,
|
||||
}
|
||||
174
ui/src/common/ArtistLinkField.jsx
Normal file
@@ -0,0 +1,174 @@
|
||||
import React from 'react'
|
||||
import PropTypes from 'prop-types'
|
||||
import { Link } from 'react-admin'
|
||||
import { withWidth } from '@material-ui/core'
|
||||
import { useGetHandleArtistClick } from './useGetHandleArtistClick'
|
||||
import { intersperse } from '../utils/index.js'
|
||||
import { useDispatch } from 'react-redux'
|
||||
import { closeExtendedInfoDialog } from '../actions/dialogs.js'
|
||||
|
||||
const ALink = withWidth()((props) => {
|
||||
const { artist, width, ...rest } = props
|
||||
const artistLink = useGetHandleArtistClick(width)
|
||||
const dispatch = useDispatch()
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={artist.id}
|
||||
to={artistLink(artist.id)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
dispatch(closeExtendedInfoDialog())
|
||||
}}
|
||||
{...rest}
|
||||
>
|
||||
{artist.name}
|
||||
{artist.subroles?.length > 0 ? ` (${artist.subroles.join(', ')})` : ''}
|
||||
</Link>
|
||||
)
|
||||
})
|
||||
|
||||
const parseAndReplaceArtists = (
|
||||
displayAlbumArtist,
|
||||
albumArtists,
|
||||
className,
|
||||
) => {
|
||||
let result = []
|
||||
let lastIndex = 0
|
||||
|
||||
albumArtists?.forEach((artist) => {
|
||||
const index = displayAlbumArtist.indexOf(artist.name, lastIndex)
|
||||
if (index !== -1) {
|
||||
// Add text before the artist name
|
||||
if (index > lastIndex) {
|
||||
result.push(displayAlbumArtist.slice(lastIndex, index))
|
||||
}
|
||||
// Add the artist link
|
||||
result.push(
|
||||
<ALink artist={artist} className={className} key={artist.id} />,
|
||||
)
|
||||
lastIndex = index + artist.name.length
|
||||
}
|
||||
})
|
||||
|
||||
if (lastIndex === 0) {
|
||||
return []
|
||||
}
|
||||
|
||||
// Add any remaining text after the last artist name
|
||||
if (lastIndex < displayAlbumArtist.length) {
|
||||
result.push(displayAlbumArtist.slice(lastIndex))
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
export const ArtistLinkField = ({ record, className, limit, source }) => {
|
||||
const role = source.toLowerCase()
|
||||
|
||||
// Get artists array with fallback
|
||||
let artists = record?.participants?.[role] || []
|
||||
const remixers =
|
||||
role === 'artist' && record?.participants?.remixer
|
||||
? record.participants.remixer.slice(0, 2)
|
||||
: []
|
||||
|
||||
// Use parseAndReplaceArtists for artist and albumartist roles
|
||||
if ((role === 'artist' || role === 'albumartist') && record[source]) {
|
||||
const artistsLinks = parseAndReplaceArtists(
|
||||
record[source],
|
||||
artists,
|
||||
className,
|
||||
)
|
||||
|
||||
if (artistsLinks.length > 0) {
|
||||
// For artist role, append remixers if available, avoiding duplicates
|
||||
if (role === 'artist' && remixers.length > 0) {
|
||||
// Track which artists are already displayed to avoid duplicates
|
||||
const displayedArtistIds = new Set(
|
||||
artists.map((artist) => artist.id).filter(Boolean),
|
||||
)
|
||||
|
||||
// Only add remixers that aren't already in the artists list
|
||||
const uniqueRemixers = remixers.filter(
|
||||
(remixer) => remixer.id && !displayedArtistIds.has(remixer.id),
|
||||
)
|
||||
|
||||
if (uniqueRemixers.length > 0) {
|
||||
artistsLinks.push(' • ')
|
||||
uniqueRemixers.forEach((remixer, index) => {
|
||||
if (index > 0) artistsLinks.push(' • ')
|
||||
artistsLinks.push(
|
||||
<ALink
|
||||
artist={remixer}
|
||||
className={className}
|
||||
key={`remixer-${remixer.id}`}
|
||||
/>,
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return <div className={className}>{artistsLinks}</div>
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to regular handling
|
||||
if (artists.length === 0 && record[source]) {
|
||||
artists = [{ name: record[source], id: record[source + 'Id'] }]
|
||||
}
|
||||
|
||||
// For artist role, combine artists and remixers before deduplication
|
||||
const allArtists = role === 'artist' ? [...artists, ...remixers] : artists
|
||||
|
||||
// Dedupe artists and collect subroles
|
||||
const seen = new Map()
|
||||
const dedupedArtists = []
|
||||
let limitedShow = false
|
||||
|
||||
for (const artist of allArtists) {
|
||||
if (!artist?.id) continue
|
||||
|
||||
if (!seen.has(artist.id)) {
|
||||
if (dedupedArtists.length < limit) {
|
||||
seen.set(artist.id, dedupedArtists.length)
|
||||
dedupedArtists.push({
|
||||
...artist,
|
||||
subroles: artist.subRole ? [artist.subRole] : [],
|
||||
})
|
||||
} else {
|
||||
limitedShow = true
|
||||
}
|
||||
} else {
|
||||
const position = seen.get(artist.id)
|
||||
const existing = dedupedArtists[position]
|
||||
if (artist.subRole && !existing.subroles.includes(artist.subRole)) {
|
||||
existing.subroles.push(artist.subRole)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create artist links
|
||||
const artistsList = dedupedArtists.map((artist) => (
|
||||
<ALink artist={artist} className={className} key={artist.id} />
|
||||
))
|
||||
|
||||
if (limitedShow) {
|
||||
artistsList.push(<span key="more">...</span>)
|
||||
}
|
||||
|
||||
return <>{intersperse(artistsList, ' • ')}</>
|
||||
}
|
||||
|
||||
ArtistLinkField.propTypes = {
|
||||
limit: PropTypes.number,
|
||||
record: PropTypes.object,
|
||||
className: PropTypes.string,
|
||||
source: PropTypes.string,
|
||||
}
|
||||
|
||||
ArtistLinkField.defaultProps = {
|
||||
addLabel: true,
|
||||
limit: 3,
|
||||
source: 'albumArtist',
|
||||
}
|
||||
238
ui/src/common/ArtistLinkField.test.jsx
Normal file
@@ -0,0 +1,238 @@
|
||||
import React from 'react'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||
import { ArtistLinkField } from './ArtistLinkField'
|
||||
import { intersperse } from '../utils/index.js'
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock('react-redux', () => ({
|
||||
useDispatch: vi.fn(() => vi.fn()),
|
||||
}))
|
||||
|
||||
vi.mock('./useGetHandleArtistClick', () => ({
|
||||
useGetHandleArtistClick: vi.fn(() => (id) => `/artist/${id}`),
|
||||
}))
|
||||
|
||||
vi.mock('../utils/index.js', () => ({
|
||||
intersperse: vi.fn((arr) => arr),
|
||||
}))
|
||||
|
||||
vi.mock('@material-ui/core', () => ({
|
||||
withWidth: () => (Component) => {
|
||||
const WithWidthComponent = (props) => <Component {...props} width="md" />
|
||||
WithWidthComponent.displayName = `WithWidth(${Component.displayName || Component.name || 'Component'})`
|
||||
return WithWidthComponent
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('react-admin', () => ({
|
||||
Link: ({ children, to, ...props }) => (
|
||||
<a href={to} {...props}>
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
}))
|
||||
|
||||
describe('ArtistLinkField', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('when rendering artists', () => {
|
||||
it('renders artists from participants when available', () => {
|
||||
const record = {
|
||||
participants: {
|
||||
artist: [
|
||||
{ id: '1', name: 'Artist 1' },
|
||||
{ id: '2', name: 'Artist 2' },
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
render(<ArtistLinkField record={record} source="artist" />)
|
||||
|
||||
expect(screen.getByText('Artist 1')).toBeInTheDocument()
|
||||
expect(screen.getByText('Artist 2')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('falls back to record[source] when participants not available', () => {
|
||||
const record = {
|
||||
artist: 'Fallback Artist',
|
||||
artistId: '123',
|
||||
}
|
||||
|
||||
render(<ArtistLinkField record={record} source="artist" />)
|
||||
|
||||
expect(screen.getByText('Fallback Artist')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('handles empty artists array', () => {
|
||||
const record = {
|
||||
participants: {
|
||||
artist: [],
|
||||
},
|
||||
}
|
||||
|
||||
render(<ArtistLinkField record={record} source="artist" />)
|
||||
|
||||
expect(intersperse).toHaveBeenCalledWith([], ' • ')
|
||||
})
|
||||
})
|
||||
|
||||
describe('when handling remixers', () => {
|
||||
it('adds remixers when showing artist role', () => {
|
||||
const record = {
|
||||
participants: {
|
||||
artist: [{ id: '1', name: 'Artist 1' }],
|
||||
remixer: [{ id: '2', name: 'Remixer 1' }],
|
||||
},
|
||||
}
|
||||
|
||||
render(<ArtistLinkField record={record} source="artist" />)
|
||||
|
||||
expect(screen.getByText('Artist 1')).toBeInTheDocument()
|
||||
expect(screen.getByText('Remixer 1')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('limits remixers to maximum of 2', () => {
|
||||
const record = {
|
||||
participants: {
|
||||
artist: [{ id: '1', name: 'Artist 1' }],
|
||||
remixer: [
|
||||
{ id: '2', name: 'Remixer 1' },
|
||||
{ id: '3', name: 'Remixer 2' },
|
||||
{ id: '4', name: 'Remixer 3' },
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
render(<ArtistLinkField record={record} source="artist" />)
|
||||
|
||||
expect(screen.getByText('Artist 1')).toBeInTheDocument()
|
||||
expect(screen.getByText('Remixer 1')).toBeInTheDocument()
|
||||
expect(screen.getByText('Remixer 2')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Remixer 3')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('deduplicates artists and remixers', () => {
|
||||
const record = {
|
||||
participants: {
|
||||
artist: [{ id: '1', name: 'Duplicate Person' }],
|
||||
remixer: [{ id: '1', name: 'Duplicate Person' }],
|
||||
},
|
||||
}
|
||||
|
||||
render(<ArtistLinkField record={record} source="artist" />)
|
||||
|
||||
const links = screen.getAllByRole('link')
|
||||
expect(links).toHaveLength(1)
|
||||
expect(links[0]).toHaveTextContent('Duplicate Person')
|
||||
})
|
||||
})
|
||||
|
||||
describe('when using parseAndReplaceArtists', () => {
|
||||
it('uses parseAndReplaceArtists when role is albumartist', () => {
|
||||
const record = {
|
||||
albumArtist: 'Group Artist',
|
||||
participants: {
|
||||
albumartist: [{ id: '1', name: 'Group Artist' }],
|
||||
},
|
||||
}
|
||||
|
||||
render(<ArtistLinkField record={record} source="albumArtist" />)
|
||||
|
||||
expect(screen.getByText('Group Artist')).toBeInTheDocument()
|
||||
expect(screen.getByRole('link')).toHaveAttribute('href', '/artist/1')
|
||||
})
|
||||
|
||||
it('uses parseAndReplaceArtists when role is artist', () => {
|
||||
const record = {
|
||||
artist: 'Main Artist',
|
||||
participants: {
|
||||
artist: [{ id: '1', name: 'Main Artist' }],
|
||||
},
|
||||
}
|
||||
|
||||
render(<ArtistLinkField record={record} source="artist" />)
|
||||
|
||||
expect(screen.getByText('Main Artist')).toBeInTheDocument()
|
||||
expect(screen.getByRole('link')).toHaveAttribute('href', '/artist/1')
|
||||
})
|
||||
|
||||
it('adds remixers after parseAndReplaceArtists for artist role', () => {
|
||||
const record = {
|
||||
artist: 'Main Artist',
|
||||
participants: {
|
||||
artist: [{ id: '1', name: 'Main Artist' }],
|
||||
remixer: [{ id: '2', name: 'Remixer 1' }],
|
||||
},
|
||||
}
|
||||
|
||||
render(<ArtistLinkField record={record} source="artist" />)
|
||||
|
||||
const links = screen.getAllByRole('link')
|
||||
expect(links).toHaveLength(2)
|
||||
expect(links[0]).toHaveAttribute('href', '/artist/1')
|
||||
expect(links[1]).toHaveAttribute('href', '/artist/2')
|
||||
})
|
||||
})
|
||||
|
||||
describe('when handling artist deduplication', () => {
|
||||
it('deduplicates artists with the same id', () => {
|
||||
const record = {
|
||||
participants: {
|
||||
artist: [
|
||||
{ id: '1', name: 'Duplicate Artist' },
|
||||
{ id: '1', name: 'Duplicate Artist', subRole: 'Vocals' },
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
render(<ArtistLinkField record={record} source="artist" />)
|
||||
|
||||
const links = screen.getAllByRole('link')
|
||||
expect(links).toHaveLength(1)
|
||||
expect(links[0]).toHaveTextContent('Duplicate Artist (Vocals)')
|
||||
})
|
||||
|
||||
it('aggregates subroles for the same artist', () => {
|
||||
const record = {
|
||||
participants: {
|
||||
artist: [
|
||||
{ id: '1', name: 'Multi-Role Artist', subRole: 'Vocals' },
|
||||
{ id: '1', name: 'Multi-Role Artist', subRole: 'Guitar' },
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
render(<ArtistLinkField record={record} source="artist" />)
|
||||
|
||||
expect(
|
||||
screen.getByText('Multi-Role Artist (Vocals, Guitar)'),
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('when limiting displayed artists', () => {
|
||||
it('limits the number of artists displayed', () => {
|
||||
const record = {
|
||||
participants: {
|
||||
artist: [
|
||||
{ id: '1', name: 'Artist 1' },
|
||||
{ id: '2', name: 'Artist 2' },
|
||||
{ id: '3', name: 'Artist 3' },
|
||||
{ id: '4', name: 'Artist 4' },
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
render(<ArtistLinkField record={record} source="artist" limit={3} />)
|
||||
|
||||
expect(screen.getByText('Artist 1')).toBeInTheDocument()
|
||||
expect(screen.getByText('Artist 2')).toBeInTheDocument()
|
||||
expect(screen.getByText('Artist 3')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Artist 4')).not.toBeInTheDocument()
|
||||
expect(screen.getByText('...')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
61
ui/src/common/BatchPlayButton.jsx
Normal file
@@ -0,0 +1,61 @@
|
||||
import React from 'react'
|
||||
import PropTypes from 'prop-types'
|
||||
import {
|
||||
Button,
|
||||
useDataProvider,
|
||||
useTranslate,
|
||||
useUnselectAll,
|
||||
useNotify,
|
||||
} from 'react-admin'
|
||||
import { useDispatch } from 'react-redux'
|
||||
|
||||
export const BatchPlayButton = ({
|
||||
resource,
|
||||
selectedIds,
|
||||
action,
|
||||
label,
|
||||
icon,
|
||||
className,
|
||||
}) => {
|
||||
const dispatch = useDispatch()
|
||||
const translate = useTranslate()
|
||||
const dataProvider = useDataProvider()
|
||||
const unselectAll = useUnselectAll()
|
||||
const notify = useNotify()
|
||||
|
||||
const addToQueue = () => {
|
||||
dataProvider
|
||||
.getMany(resource, { ids: selectedIds })
|
||||
.then((response) => {
|
||||
// Add tracks to a map for easy lookup by ID, needed for the next step
|
||||
const tracks = response.data.reduce(
|
||||
(acc, cur) => ({ ...acc, [cur.id]: cur }),
|
||||
{},
|
||||
)
|
||||
// Add the tracks to the queue in the selection order
|
||||
dispatch(action(tracks, selectedIds))
|
||||
})
|
||||
.catch(() => {
|
||||
notify('ra.page.error', 'warning')
|
||||
})
|
||||
unselectAll(resource)
|
||||
}
|
||||
|
||||
const caption = translate(label)
|
||||
return (
|
||||
<Button
|
||||
aria-label={caption}
|
||||
onClick={addToQueue}
|
||||
label={caption}
|
||||
className={className}
|
||||
>
|
||||
{icon}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
BatchPlayButton.propTypes = {
|
||||
action: PropTypes.func.isRequired,
|
||||
label: PropTypes.string.isRequired,
|
||||
icon: PropTypes.object.isRequired,
|
||||
}
|
||||
40
ui/src/common/BatchShareButton.jsx
Normal file
@@ -0,0 +1,40 @@
|
||||
import React from 'react'
|
||||
import { Button, useTranslate, useUnselectAll } from 'react-admin'
|
||||
import { useDispatch } from 'react-redux'
|
||||
import { openShareMenu } from '../actions'
|
||||
import ShareIcon from '@material-ui/icons/Share'
|
||||
|
||||
export const BatchShareButton = ({ resource, selectedIds, className }) => {
|
||||
const dispatch = useDispatch()
|
||||
const translate = useTranslate()
|
||||
const unselectAll = useUnselectAll()
|
||||
|
||||
const share = () => {
|
||||
dispatch(
|
||||
openShareMenu(
|
||||
selectedIds,
|
||||
resource,
|
||||
translate('ra.action.bulk_actions', {
|
||||
_: 'ra.action.bulk_actions',
|
||||
smart_count: selectedIds.length,
|
||||
}),
|
||||
'message.shareBatchDialogTitle',
|
||||
),
|
||||
)
|
||||
unselectAll(resource)
|
||||
}
|
||||
|
||||
const caption = translate('ra.action.share')
|
||||
return (
|
||||
<Button
|
||||
aria-label={caption}
|
||||
onClick={share}
|
||||
label={caption}
|
||||
className={className}
|
||||
>
|
||||
<ShareIcon />
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
BatchShareButton.propTypes = {}
|
||||
18
ui/src/common/BitrateField.jsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import React from 'react'
|
||||
import PropTypes from 'prop-types'
|
||||
import { useRecordContext } from 'react-admin'
|
||||
|
||||
export const BitrateField = ({ source, ...rest }) => {
|
||||
const record = useRecordContext(rest)
|
||||
return <span>{`${record[source]} kbps`}</span>
|
||||
}
|
||||
|
||||
BitrateField.propTypes = {
|
||||
label: PropTypes.string,
|
||||
record: PropTypes.object,
|
||||
source: PropTypes.string.isRequired,
|
||||
}
|
||||
|
||||
BitrateField.defaultProps = {
|
||||
addLabel: true,
|
||||
}
|
||||
64
ui/src/common/CollapsibleComment.jsx
Normal file
@@ -0,0 +1,64 @@
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
import { Typography, Collapse } from '@material-ui/core'
|
||||
import { makeStyles } from '@material-ui/core/styles'
|
||||
import AnchorMe from './Linkify'
|
||||
import clsx from 'clsx'
|
||||
|
||||
const useStyles = makeStyles(
|
||||
(theme) => ({
|
||||
commentBlock: {
|
||||
display: 'inline-block',
|
||||
marginTop: '1em',
|
||||
float: 'left',
|
||||
wordBreak: 'break-word',
|
||||
},
|
||||
pointerCursor: {
|
||||
cursor: 'pointer',
|
||||
},
|
||||
}),
|
||||
{
|
||||
name: 'NDCollapsibleComment',
|
||||
},
|
||||
)
|
||||
|
||||
export const CollapsibleComment = ({ record }) => {
|
||||
const classes = useStyles()
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
|
||||
const lines = useMemo(
|
||||
() => record.comment?.split('\n') || [],
|
||||
[record.comment],
|
||||
)
|
||||
const formatted = useMemo(() => {
|
||||
return lines.map((line, idx) => (
|
||||
<span key={record.id + '-comment-' + idx}>
|
||||
<AnchorMe text={line} />
|
||||
<br />
|
||||
</span>
|
||||
))
|
||||
}, [lines, record.id])
|
||||
|
||||
const handleExpandClick = useCallback(() => {
|
||||
setExpanded(!expanded)
|
||||
}, [expanded, setExpanded])
|
||||
|
||||
if (lines.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<Collapse
|
||||
collapsedHeight={'2em'}
|
||||
in={expanded}
|
||||
timeout={'auto'}
|
||||
className={clsx(
|
||||
classes.commentBlock,
|
||||
lines.length > 1 && classes.pointerCursor,
|
||||
)}
|
||||
>
|
||||
<Typography variant={'h6'} onClick={handleExpandClick}>
|
||||
{formatted}
|
||||
</Typography>
|
||||
</Collapse>
|
||||
)
|
||||
}
|
||||
279
ui/src/common/ContextMenus.jsx
Normal file
@@ -0,0 +1,279 @@
|
||||
import React, { useState } from 'react'
|
||||
import PropTypes from 'prop-types'
|
||||
import { useDispatch } from 'react-redux'
|
||||
import IconButton from '@material-ui/core/IconButton'
|
||||
import Menu from '@material-ui/core/Menu'
|
||||
import MenuItem from '@material-ui/core/MenuItem'
|
||||
import MoreVertIcon from '@material-ui/icons/MoreVert'
|
||||
import { MdQuestionMark } from 'react-icons/md'
|
||||
import { makeStyles } from '@material-ui/core/styles'
|
||||
import { useDataProvider, useNotify, useTranslate } from 'react-admin'
|
||||
import clsx from 'clsx'
|
||||
import {
|
||||
playNext,
|
||||
addTracks,
|
||||
playTracks,
|
||||
shuffleTracks,
|
||||
openAddToPlaylist,
|
||||
openDownloadMenu,
|
||||
openExtendedInfoDialog,
|
||||
DOWNLOAD_MENU_ALBUM,
|
||||
DOWNLOAD_MENU_ARTIST,
|
||||
openShareMenu,
|
||||
} from '../actions'
|
||||
import { LoveButton } from './LoveButton'
|
||||
import config from '../config'
|
||||
import { formatBytes } from '../utils'
|
||||
|
||||
const useStyles = makeStyles({
|
||||
noWrap: {
|
||||
whiteSpace: 'nowrap',
|
||||
},
|
||||
menu: {
|
||||
color: (props) => props.color,
|
||||
},
|
||||
})
|
||||
|
||||
const MoreButton = ({ record, onClick, info, ...rest }) => {
|
||||
const handleClick = record.missing
|
||||
? (e) => {
|
||||
e.preventDefault()
|
||||
info.action(record)
|
||||
e.stopPropagation()
|
||||
}
|
||||
: onClick
|
||||
return (
|
||||
<IconButton onClick={handleClick} size={'small'} {...rest}>
|
||||
{record?.missing ? (
|
||||
<MdQuestionMark fontSize={'large'} />
|
||||
) : (
|
||||
<MoreVertIcon fontSize={'small'} />
|
||||
)}
|
||||
</IconButton>
|
||||
)
|
||||
}
|
||||
|
||||
const ContextMenu = ({
|
||||
resource,
|
||||
showLove,
|
||||
record,
|
||||
color,
|
||||
className,
|
||||
songQueryParams,
|
||||
hideShare,
|
||||
hideInfo,
|
||||
}) => {
|
||||
const classes = useStyles({ color })
|
||||
const dataProvider = useDataProvider()
|
||||
const dispatch = useDispatch()
|
||||
const translate = useTranslate()
|
||||
const notify = useNotify()
|
||||
const [anchorEl, setAnchorEl] = useState(null)
|
||||
|
||||
const options = {
|
||||
play: {
|
||||
enabled: true,
|
||||
needData: true,
|
||||
label: translate('resources.album.actions.playAll'),
|
||||
action: (data, ids) => dispatch(playTracks(data, ids)),
|
||||
},
|
||||
playNext: {
|
||||
enabled: true,
|
||||
needData: true,
|
||||
label: translate('resources.album.actions.playNext'),
|
||||
action: (data, ids) => dispatch(playNext(data, ids)),
|
||||
},
|
||||
addToQueue: {
|
||||
enabled: true,
|
||||
needData: true,
|
||||
label: translate('resources.album.actions.addToQueue'),
|
||||
action: (data, ids) => dispatch(addTracks(data, ids)),
|
||||
},
|
||||
shuffle: {
|
||||
enabled: true,
|
||||
needData: true,
|
||||
label: translate('resources.album.actions.shuffle'),
|
||||
action: (data, ids) => dispatch(shuffleTracks(data, ids)),
|
||||
},
|
||||
addToPlaylist: {
|
||||
enabled: true,
|
||||
needData: true,
|
||||
label: translate('resources.album.actions.addToPlaylist'),
|
||||
action: (data, ids) => dispatch(openAddToPlaylist({ selectedIds: ids })),
|
||||
},
|
||||
...(!hideShare && {
|
||||
share: {
|
||||
enabled: config.enableSharing,
|
||||
needData: false,
|
||||
label: translate('ra.action.share'),
|
||||
action: (record) =>
|
||||
dispatch(openShareMenu([record.id], resource, record.name)),
|
||||
},
|
||||
}),
|
||||
download: {
|
||||
enabled: config.enableDownloads && record.size,
|
||||
needData: false,
|
||||
label: `${translate('ra.action.download')} (${formatBytes(record.size)})`,
|
||||
action: () => {
|
||||
dispatch(
|
||||
openDownloadMenu(
|
||||
record,
|
||||
record.duration !== undefined
|
||||
? DOWNLOAD_MENU_ALBUM
|
||||
: DOWNLOAD_MENU_ARTIST,
|
||||
),
|
||||
)
|
||||
},
|
||||
},
|
||||
...(!hideInfo && {
|
||||
info: {
|
||||
enabled: true,
|
||||
needData: true,
|
||||
label: translate('resources.album.actions.info'),
|
||||
action: () => dispatch(openExtendedInfoDialog(record)),
|
||||
},
|
||||
}),
|
||||
}
|
||||
|
||||
const handleClick = (e) => {
|
||||
e.preventDefault()
|
||||
setAnchorEl(e.currentTarget)
|
||||
e.stopPropagation()
|
||||
}
|
||||
|
||||
const handleOnClose = (e) => {
|
||||
e.preventDefault()
|
||||
setAnchorEl(null)
|
||||
e.stopPropagation()
|
||||
}
|
||||
|
||||
let extractSongsData = function (response) {
|
||||
const data = response.data.reduce(
|
||||
(acc, cur) => ({ ...acc, [cur.id]: cur }),
|
||||
{},
|
||||
)
|
||||
const ids = response.data.map((r) => r.id)
|
||||
return { data, ids }
|
||||
}
|
||||
|
||||
const handleItemClick = (e) => {
|
||||
setAnchorEl(null)
|
||||
const key = e.target.getAttribute('value')
|
||||
if (options[key].needData) {
|
||||
dataProvider
|
||||
.getList('song', songQueryParams)
|
||||
.then((response) => {
|
||||
let { data, ids } = extractSongsData(response)
|
||||
options[key].action(data, ids)
|
||||
})
|
||||
.catch(() => {
|
||||
notify('ra.page.error', 'warning')
|
||||
})
|
||||
} else {
|
||||
options[key].action(record)
|
||||
}
|
||||
|
||||
e.stopPropagation()
|
||||
}
|
||||
|
||||
const open = Boolean(anchorEl)
|
||||
|
||||
if (!record) {
|
||||
return null
|
||||
}
|
||||
|
||||
const present = !record.missing
|
||||
|
||||
return (
|
||||
<span className={clsx(classes.noWrap, className)}>
|
||||
<LoveButton
|
||||
record={record}
|
||||
resource={resource}
|
||||
visible={config.enableFavourites && showLove && present}
|
||||
color={color}
|
||||
/>
|
||||
<MoreButton
|
||||
record={record}
|
||||
onClick={handleClick}
|
||||
info={options.info}
|
||||
aria-label="more"
|
||||
aria-controls="context-menu"
|
||||
aria-haspopup="true"
|
||||
className={classes.menu}
|
||||
/>
|
||||
<Menu
|
||||
id="context-menu"
|
||||
anchorEl={anchorEl}
|
||||
keepMounted
|
||||
open={open}
|
||||
onClose={handleOnClose}
|
||||
>
|
||||
{Object.keys(options).map(
|
||||
(key) =>
|
||||
options[key].enabled && (
|
||||
<MenuItem value={key} key={key} onClick={handleItemClick}>
|
||||
{options[key].label}
|
||||
</MenuItem>
|
||||
),
|
||||
)}
|
||||
</Menu>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export const AlbumContextMenu = (props) =>
|
||||
props.record ? (
|
||||
<ContextMenu
|
||||
{...props}
|
||||
resource={'album'}
|
||||
songQueryParams={{
|
||||
pagination: { page: 1, perPage: -1 },
|
||||
sort: { field: 'album', order: 'ASC' },
|
||||
filter: {
|
||||
album_id: props.record.id,
|
||||
disc_number: props.discNumber,
|
||||
missing: false,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
) : null
|
||||
|
||||
AlbumContextMenu.propTypes = {
|
||||
record: PropTypes.object,
|
||||
discNumber: PropTypes.number,
|
||||
color: PropTypes.string,
|
||||
showLove: PropTypes.bool,
|
||||
}
|
||||
|
||||
AlbumContextMenu.defaultProps = {
|
||||
showLove: true,
|
||||
addLabel: true,
|
||||
}
|
||||
|
||||
export const ArtistContextMenu = (props) =>
|
||||
props.record ? (
|
||||
<ContextMenu
|
||||
{...props}
|
||||
hideInfo={true}
|
||||
resource={'artist'}
|
||||
songQueryParams={{
|
||||
pagination: { page: 1, perPage: 200 },
|
||||
sort: {
|
||||
field: 'album',
|
||||
order: 'ASC',
|
||||
},
|
||||
filter: { album_artist_id: props.record.id, missing: false },
|
||||
}}
|
||||
/>
|
||||
) : null
|
||||
|
||||
ArtistContextMenu.propTypes = {
|
||||
record: PropTypes.object,
|
||||
color: PropTypes.string,
|
||||
showLove: PropTypes.bool,
|
||||
}
|
||||
|
||||
ArtistContextMenu.defaultProps = {
|
||||
showLove: true,
|
||||
addLabel: true,
|
||||
}
|
||||
14
ui/src/common/DateField.jsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import React from 'react'
|
||||
import { isDateSet } from '../utils/validations'
|
||||
import { DateField as RADateField } from 'react-admin'
|
||||
|
||||
export const DateField = (props) => {
|
||||
const { record, source } = props
|
||||
const value = record?.[source]
|
||||
if (!isDateSet(value)) return null
|
||||
return <RADateField {...props} />
|
||||
}
|
||||
|
||||
DateField.defaultProps = {
|
||||
addLabel: true,
|
||||
}
|
||||
8
ui/src/common/DocLink.jsx
Normal file
@@ -0,0 +1,8 @@
|
||||
import React from 'react'
|
||||
import { docsUrl } from '../utils'
|
||||
|
||||
export const DocLink = ({ path, children }) => (
|
||||
<a href={docsUrl(path)} target={'_blank'} rel="noopener noreferrer">
|
||||
{children}
|
||||
</a>
|
||||
)
|
||||
25
ui/src/common/DurationField.jsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import React from 'react'
|
||||
import PropTypes from 'prop-types'
|
||||
import { formatDuration } from '../utils'
|
||||
import { useRecordContext } from 'react-admin'
|
||||
|
||||
export const DurationField = ({ source, ...rest }) => {
|
||||
const record = useRecordContext(rest)
|
||||
try {
|
||||
return <span>{formatDuration(record[source])}</span>
|
||||
} catch (e) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('Error in DurationField! Record:', record)
|
||||
return <span>00:00</span>
|
||||
}
|
||||
}
|
||||
|
||||
DurationField.propTypes = {
|
||||
label: PropTypes.string,
|
||||
record: PropTypes.object,
|
||||
source: PropTypes.string.isRequired,
|
||||
}
|
||||
|
||||
DurationField.defaultProps = {
|
||||
addLabel: true,
|
||||
}
|
||||
221
ui/src/common/LibrarySelector.jsx
Normal file
@@ -0,0 +1,221 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react'
|
||||
import { useDispatch, useSelector } from 'react-redux'
|
||||
import { useDataProvider, useTranslate, useRefresh } from 'react-admin'
|
||||
import {
|
||||
Box,
|
||||
Chip,
|
||||
ClickAwayListener,
|
||||
FormControl,
|
||||
FormGroup,
|
||||
FormControlLabel,
|
||||
Checkbox,
|
||||
Typography,
|
||||
Paper,
|
||||
Popper,
|
||||
makeStyles,
|
||||
} from '@material-ui/core'
|
||||
import { ExpandMore, ExpandLess, LibraryMusic } from '@material-ui/icons'
|
||||
import { setSelectedLibraries, setUserLibraries } from '../actions'
|
||||
import { useRefreshOnEvents } from './useRefreshOnEvents'
|
||||
|
||||
const useStyles = makeStyles((theme) => ({
|
||||
root: {
|
||||
marginTop: theme.spacing(3),
|
||||
marginBottom: theme.spacing(3),
|
||||
paddingLeft: theme.spacing(2),
|
||||
paddingRight: theme.spacing(2),
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
chip: {
|
||||
borderRadius: theme.spacing(1),
|
||||
height: theme.spacing(4.8),
|
||||
fontSize: '1rem',
|
||||
fontWeight: 'normal',
|
||||
minWidth: '210px',
|
||||
justifyContent: 'flex-start',
|
||||
paddingLeft: theme.spacing(1),
|
||||
paddingRight: theme.spacing(1),
|
||||
marginTop: theme.spacing(0.1),
|
||||
'& .MuiChip-label': {
|
||||
paddingLeft: theme.spacing(2),
|
||||
paddingRight: theme.spacing(1),
|
||||
},
|
||||
'& .MuiChip-icon': {
|
||||
fontSize: '1.2rem',
|
||||
marginLeft: theme.spacing(0.5),
|
||||
},
|
||||
},
|
||||
popper: {
|
||||
zIndex: 1300,
|
||||
},
|
||||
paper: {
|
||||
padding: theme.spacing(2),
|
||||
marginTop: theme.spacing(1),
|
||||
minWidth: 300,
|
||||
maxWidth: 400,
|
||||
},
|
||||
headerContainer: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
marginBottom: 0,
|
||||
},
|
||||
masterCheckbox: {
|
||||
padding: '7px',
|
||||
marginLeft: '-9px',
|
||||
marginRight: 0,
|
||||
},
|
||||
}))
|
||||
|
||||
const LibrarySelector = () => {
|
||||
const classes = useStyles()
|
||||
const dispatch = useDispatch()
|
||||
const dataProvider = useDataProvider()
|
||||
const translate = useTranslate()
|
||||
const refresh = useRefresh()
|
||||
const [anchorEl, setAnchorEl] = useState(null)
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
const { userLibraries, selectedLibraries } = useSelector(
|
||||
(state) => state.library,
|
||||
)
|
||||
|
||||
// Load user's libraries when component mounts
|
||||
const loadUserLibraries = useCallback(async () => {
|
||||
const userId = localStorage.getItem('userId')
|
||||
if (userId) {
|
||||
try {
|
||||
const { data } = await dataProvider.getOne('user', { id: userId })
|
||||
const libraries = data.libraries || []
|
||||
dispatch(setUserLibraries(libraries))
|
||||
} catch (error) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(
|
||||
'Could not load user libraries (this may be expected for non-admin users):',
|
||||
error,
|
||||
)
|
||||
}
|
||||
}
|
||||
}, [dataProvider, dispatch])
|
||||
|
||||
// Initial load
|
||||
useEffect(() => {
|
||||
loadUserLibraries()
|
||||
}, [loadUserLibraries])
|
||||
|
||||
// Reload user libraries when library changes occur
|
||||
useRefreshOnEvents({
|
||||
events: ['library', 'user'],
|
||||
onRefresh: loadUserLibraries,
|
||||
})
|
||||
|
||||
// Don't render if user has no libraries or only has one library
|
||||
if (!userLibraries.length || userLibraries.length === 1) {
|
||||
return null
|
||||
}
|
||||
|
||||
const handleToggle = (event) => {
|
||||
setAnchorEl(event.currentTarget)
|
||||
const wasOpen = open
|
||||
setOpen(!open)
|
||||
// Refresh data when closing the dropdown
|
||||
if (wasOpen) {
|
||||
refresh()
|
||||
}
|
||||
}
|
||||
|
||||
const handleClose = () => {
|
||||
setOpen(false)
|
||||
refresh()
|
||||
}
|
||||
|
||||
const handleLibraryToggle = (libraryId) => {
|
||||
const newSelection = selectedLibraries.includes(libraryId)
|
||||
? selectedLibraries.filter((id) => id !== libraryId)
|
||||
: [...selectedLibraries, libraryId]
|
||||
|
||||
dispatch(setSelectedLibraries(newSelection))
|
||||
}
|
||||
|
||||
const handleMasterCheckboxChange = () => {
|
||||
if (isAllSelected) {
|
||||
dispatch(setSelectedLibraries([]))
|
||||
} else {
|
||||
const allIds = userLibraries.map((lib) => lib.id)
|
||||
dispatch(setSelectedLibraries(allIds))
|
||||
}
|
||||
}
|
||||
|
||||
const selectedCount = selectedLibraries.length
|
||||
const totalCount = userLibraries.length
|
||||
const isAllSelected = selectedCount === totalCount
|
||||
const isNoneSelected = selectedCount === 0
|
||||
const isIndeterminate = selectedCount > 0 && selectedCount < totalCount
|
||||
|
||||
const displayText = isNoneSelected
|
||||
? translate('menu.librarySelector.none') + ` (0 of ${totalCount})`
|
||||
: isAllSelected
|
||||
? translate('menu.librarySelector.allLibraries', { count: totalCount })
|
||||
: translate('menu.librarySelector.multipleLibraries', {
|
||||
selected: selectedCount,
|
||||
total: totalCount,
|
||||
})
|
||||
|
||||
return (
|
||||
<Box className={classes.root}>
|
||||
<Chip
|
||||
icon={<LibraryMusic />}
|
||||
label={displayText}
|
||||
onClick={handleToggle}
|
||||
onDelete={open ? handleToggle : undefined}
|
||||
deleteIcon={open ? <ExpandLess /> : <ExpandMore />}
|
||||
variant="outlined"
|
||||
className={classes.chip}
|
||||
/>
|
||||
|
||||
<Popper
|
||||
open={open}
|
||||
anchorEl={anchorEl}
|
||||
placement="bottom-start"
|
||||
className={classes.popper}
|
||||
>
|
||||
<ClickAwayListener onClickAway={handleClose}>
|
||||
<Paper className={classes.paper}>
|
||||
<Box className={classes.headerContainer}>
|
||||
<Checkbox
|
||||
checked={isAllSelected}
|
||||
indeterminate={isIndeterminate}
|
||||
onChange={handleMasterCheckboxChange}
|
||||
size="small"
|
||||
className={classes.masterCheckbox}
|
||||
/>
|
||||
<Typography>
|
||||
{translate('menu.librarySelector.selectLibraries')}:
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<FormControl component="fieldset" variant="standard" fullWidth>
|
||||
<FormGroup>
|
||||
{userLibraries.map((library) => (
|
||||
<FormControlLabel
|
||||
key={library.id}
|
||||
control={
|
||||
<Checkbox
|
||||
checked={selectedLibraries.includes(library.id)}
|
||||
onChange={() => handleLibraryToggle(library.id)}
|
||||
size="small"
|
||||
/>
|
||||
}
|
||||
label={library.name}
|
||||
/>
|
||||
))}
|
||||
</FormGroup>
|
||||
</FormControl>
|
||||
</Paper>
|
||||
</ClickAwayListener>
|
||||
</Popper>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
export default LibrarySelector
|
||||
517
ui/src/common/LibrarySelector.test.jsx
Normal file
@@ -0,0 +1,517 @@
|
||||
import React from 'react'
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||
import LibrarySelector from './LibrarySelector'
|
||||
|
||||
// Mock dependencies
|
||||
const mockDispatch = vi.fn()
|
||||
const mockDataProvider = {
|
||||
getOne: vi.fn(),
|
||||
}
|
||||
const mockIdentity = { username: 'testuser' }
|
||||
const mockRefresh = vi.fn()
|
||||
const mockTranslate = vi.fn((key, options = {}) => {
|
||||
const translations = {
|
||||
'menu.librarySelector.allLibraries': `All Libraries (${options.count || 0})`,
|
||||
'menu.librarySelector.multipleLibraries': `${options.selected || 0} of ${options.total || 0} Libraries`,
|
||||
'menu.librarySelector.none': 'None',
|
||||
'menu.librarySelector.selectLibraries': 'Select Libraries',
|
||||
}
|
||||
return translations[key] || key
|
||||
})
|
||||
|
||||
vi.mock('react-redux', () => ({
|
||||
useDispatch: () => mockDispatch,
|
||||
useSelector: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('react-admin', () => ({
|
||||
useDataProvider: () => mockDataProvider,
|
||||
useGetIdentity: () => ({ identity: mockIdentity }),
|
||||
useTranslate: () => mockTranslate,
|
||||
useRefresh: () => mockRefresh,
|
||||
}))
|
||||
|
||||
// Mock Material-UI components
|
||||
vi.mock('@material-ui/core', () => ({
|
||||
Box: ({ children, className, ...props }) => (
|
||||
<div className={className} {...props}>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
Chip: ({ label, onClick, onDelete, deleteIcon, icon, ...props }) => (
|
||||
<button onClick={onClick} {...props}>
|
||||
{icon}
|
||||
{label}
|
||||
{deleteIcon && <span onClick={onDelete}>{deleteIcon}</span>}
|
||||
</button>
|
||||
),
|
||||
ClickAwayListener: ({ children, onClickAway }) => (
|
||||
<div data-testid="click-away-listener" onMouseDown={onClickAway}>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
Collapse: ({ children, in: inProp }) =>
|
||||
inProp ? <div>{children}</div> : null,
|
||||
FormControl: ({ children }) => <div>{children}</div>,
|
||||
FormGroup: ({ children }) => <div>{children}</div>,
|
||||
FormControlLabel: ({ control, label }) => (
|
||||
<label>
|
||||
{control}
|
||||
{label}
|
||||
</label>
|
||||
),
|
||||
Checkbox: ({
|
||||
checked,
|
||||
indeterminate,
|
||||
onChange,
|
||||
size,
|
||||
className,
|
||||
...props
|
||||
}) => (
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
ref={(el) => {
|
||||
if (el) el.indeterminate = indeterminate
|
||||
}}
|
||||
onChange={onChange}
|
||||
className={className}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
Typography: ({ children, variant, ...props }) => (
|
||||
<span {...props}>{children}</span>
|
||||
),
|
||||
Paper: ({ children, className }) => (
|
||||
<div className={className}>{children}</div>
|
||||
),
|
||||
Popper: ({ open, children, anchorEl, placement, className }) =>
|
||||
open ? (
|
||||
<div className={className} data-testid="popper">
|
||||
{children}
|
||||
</div>
|
||||
) : null,
|
||||
makeStyles: (styles) => () => {
|
||||
if (typeof styles === 'function') {
|
||||
return styles({
|
||||
spacing: (value) => `${value * 8}px`,
|
||||
palette: { divider: '#ccc' },
|
||||
shape: { borderRadius: 4 },
|
||||
})
|
||||
}
|
||||
return styles
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@material-ui/icons', () => ({
|
||||
ExpandMore: () => <span data-testid="expand-more">▼</span>,
|
||||
ExpandLess: () => <span data-testid="expand-less">▲</span>,
|
||||
LibraryMusic: () => <span data-testid="library-music">🎵</span>,
|
||||
}))
|
||||
|
||||
// Mock actions
|
||||
vi.mock('../actions', () => ({
|
||||
setSelectedLibraries: (libraries) => ({
|
||||
type: 'SET_SELECTED_LIBRARIES',
|
||||
data: libraries,
|
||||
}),
|
||||
setUserLibraries: (libraries) => ({
|
||||
type: 'SET_USER_LIBRARIES',
|
||||
data: libraries,
|
||||
}),
|
||||
}))
|
||||
|
||||
describe('LibrarySelector', () => {
|
||||
const mockLibraries = [
|
||||
{ id: '1', name: 'Music Library', path: '/music' },
|
||||
{ id: '2', name: 'Podcasts', path: '/podcasts' },
|
||||
{ id: '3', name: 'Audiobooks', path: '/audiobooks' },
|
||||
]
|
||||
|
||||
const defaultState = {
|
||||
userLibraries: mockLibraries,
|
||||
selectedLibraries: ['1'],
|
||||
}
|
||||
|
||||
let mockUseSelector
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks()
|
||||
const { useSelector } = await import('react-redux')
|
||||
mockUseSelector = vi.mocked(useSelector)
|
||||
mockDataProvider.getOne.mockResolvedValue({
|
||||
data: { libraries: mockLibraries },
|
||||
})
|
||||
// Setup localStorage mock
|
||||
Object.defineProperty(window, 'localStorage', {
|
||||
value: {
|
||||
getItem: vi.fn(() => null), // Default to null to prevent API calls
|
||||
setItem: vi.fn(),
|
||||
},
|
||||
writable: true,
|
||||
})
|
||||
})
|
||||
|
||||
const renderLibrarySelector = (selectorState = defaultState) => {
|
||||
mockUseSelector.mockImplementation((selector) =>
|
||||
selector({ library: selectorState }),
|
||||
)
|
||||
|
||||
return render(<LibrarySelector />)
|
||||
}
|
||||
|
||||
describe('when user has no libraries', () => {
|
||||
it('should not render anything', () => {
|
||||
const { container } = renderLibrarySelector({
|
||||
userLibraries: [],
|
||||
selectedLibraries: [],
|
||||
})
|
||||
expect(container.firstChild).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('when user has only one library', () => {
|
||||
it('should not render anything', () => {
|
||||
const singleLibrary = [mockLibraries[0]]
|
||||
const { container } = renderLibrarySelector({
|
||||
userLibraries: singleLibrary,
|
||||
selectedLibraries: ['1'],
|
||||
})
|
||||
expect(container.firstChild).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('when user has multiple libraries', () => {
|
||||
it('should render the chip with correct label when one library is selected', () => {
|
||||
renderLibrarySelector()
|
||||
|
||||
expect(screen.getByRole('button')).toBeInTheDocument()
|
||||
expect(screen.getByText('1 of 3 Libraries')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('library-music')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('expand-more')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render the chip with "All Libraries" when all libraries are selected', () => {
|
||||
renderLibrarySelector({
|
||||
userLibraries: mockLibraries,
|
||||
selectedLibraries: ['1', '2', '3'],
|
||||
})
|
||||
|
||||
expect(screen.getByText('All Libraries (3)')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render the chip with "None" when no libraries are selected', () => {
|
||||
renderLibrarySelector({
|
||||
userLibraries: mockLibraries,
|
||||
selectedLibraries: [],
|
||||
})
|
||||
|
||||
expect(screen.getByText('None (0 of 3)')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show expand less icon when dropdown is open', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderLibrarySelector()
|
||||
|
||||
const chipButton = screen.getByRole('button')
|
||||
await user.click(chipButton)
|
||||
|
||||
expect(screen.getByTestId('expand-less')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should open dropdown when chip is clicked', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderLibrarySelector()
|
||||
|
||||
const chipButton = screen.getByRole('button')
|
||||
await user.click(chipButton)
|
||||
|
||||
expect(screen.getByTestId('popper')).toBeInTheDocument()
|
||||
expect(screen.getByText('Select Libraries:')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should display all library names in dropdown', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderLibrarySelector()
|
||||
|
||||
const chipButton = screen.getByRole('button')
|
||||
await user.click(chipButton)
|
||||
|
||||
expect(screen.getByText('Music Library')).toBeInTheDocument()
|
||||
expect(screen.getByText('Podcasts')).toBeInTheDocument()
|
||||
expect(screen.getByText('Audiobooks')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not display library paths', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderLibrarySelector()
|
||||
|
||||
const chipButton = screen.getByRole('button')
|
||||
await user.click(chipButton)
|
||||
|
||||
expect(screen.queryByText('/music')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('/podcasts')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('/audiobooks')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
describe('master checkbox', () => {
|
||||
it('should be checked when all libraries are selected', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderLibrarySelector({
|
||||
userLibraries: mockLibraries,
|
||||
selectedLibraries: ['1', '2', '3'],
|
||||
})
|
||||
|
||||
const chipButton = screen.getByRole('button')
|
||||
await user.click(chipButton)
|
||||
|
||||
const checkboxes = screen.getAllByRole('checkbox')
|
||||
const masterCheckbox = checkboxes[0] // First checkbox is the master checkbox
|
||||
expect(masterCheckbox.checked).toBe(true)
|
||||
expect(masterCheckbox.indeterminate).toBe(false)
|
||||
})
|
||||
|
||||
it('should be unchecked when no libraries are selected', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderLibrarySelector({
|
||||
userLibraries: mockLibraries,
|
||||
selectedLibraries: [],
|
||||
})
|
||||
|
||||
const chipButton = screen.getByRole('button')
|
||||
await user.click(chipButton)
|
||||
|
||||
const checkboxes = screen.getAllByRole('checkbox')
|
||||
const masterCheckbox = checkboxes[0]
|
||||
expect(masterCheckbox.checked).toBe(false)
|
||||
expect(masterCheckbox.indeterminate).toBe(false)
|
||||
})
|
||||
|
||||
it('should be indeterminate when some libraries are selected', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderLibrarySelector({
|
||||
userLibraries: mockLibraries,
|
||||
selectedLibraries: ['1', '2'],
|
||||
})
|
||||
|
||||
const chipButton = screen.getByRole('button')
|
||||
await user.click(chipButton)
|
||||
|
||||
const checkboxes = screen.getAllByRole('checkbox')
|
||||
const masterCheckbox = checkboxes[0]
|
||||
expect(masterCheckbox.checked).toBe(false)
|
||||
expect(masterCheckbox.indeterminate).toBe(true)
|
||||
})
|
||||
|
||||
it('should select all libraries when clicked and none are selected', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderLibrarySelector({
|
||||
userLibraries: mockLibraries,
|
||||
selectedLibraries: [],
|
||||
})
|
||||
|
||||
// Clear the dispatch mock after initial mount (it sets user libraries)
|
||||
mockDispatch.mockClear()
|
||||
|
||||
const chipButton = screen.getByRole('button')
|
||||
await user.click(chipButton)
|
||||
|
||||
const checkboxes = screen.getAllByRole('checkbox')
|
||||
const masterCheckbox = checkboxes[0]
|
||||
|
||||
// Use fireEvent.click to trigger the onChange event
|
||||
fireEvent.click(masterCheckbox)
|
||||
|
||||
expect(mockDispatch).toHaveBeenCalledWith({
|
||||
type: 'SET_SELECTED_LIBRARIES',
|
||||
data: ['1', '2', '3'],
|
||||
})
|
||||
})
|
||||
|
||||
it('should deselect all libraries when clicked and all are selected', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderLibrarySelector({
|
||||
userLibraries: mockLibraries,
|
||||
selectedLibraries: ['1', '2', '3'],
|
||||
})
|
||||
|
||||
// Clear the dispatch mock after initial mount (it sets user libraries)
|
||||
mockDispatch.mockClear()
|
||||
|
||||
const chipButton = screen.getByRole('button')
|
||||
await user.click(chipButton)
|
||||
|
||||
const checkboxes = screen.getAllByRole('checkbox')
|
||||
const masterCheckbox = checkboxes[0]
|
||||
|
||||
fireEvent.click(masterCheckbox)
|
||||
|
||||
expect(mockDispatch).toHaveBeenCalledWith({
|
||||
type: 'SET_SELECTED_LIBRARIES',
|
||||
data: [],
|
||||
})
|
||||
})
|
||||
|
||||
it('should select all libraries when clicked and some are selected', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderLibrarySelector({
|
||||
userLibraries: mockLibraries,
|
||||
selectedLibraries: ['1'],
|
||||
})
|
||||
|
||||
// Clear the dispatch mock after initial mount (it sets user libraries)
|
||||
mockDispatch.mockClear()
|
||||
|
||||
const chipButton = screen.getByRole('button')
|
||||
await user.click(chipButton)
|
||||
|
||||
const checkboxes = screen.getAllByRole('checkbox')
|
||||
const masterCheckbox = checkboxes[0]
|
||||
|
||||
fireEvent.click(masterCheckbox)
|
||||
|
||||
expect(mockDispatch).toHaveBeenCalledWith({
|
||||
type: 'SET_SELECTED_LIBRARIES',
|
||||
data: ['1', '2', '3'],
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('individual library checkboxes', () => {
|
||||
it('should show correct checked state for each library', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderLibrarySelector({
|
||||
userLibraries: mockLibraries,
|
||||
selectedLibraries: ['1', '3'],
|
||||
})
|
||||
|
||||
const chipButton = screen.getByRole('button')
|
||||
await user.click(chipButton)
|
||||
|
||||
const checkboxes = screen.getAllByRole('checkbox')
|
||||
// Skip master checkbox (index 0)
|
||||
expect(checkboxes[1].checked).toBe(true) // Music Library
|
||||
expect(checkboxes[2].checked).toBe(false) // Podcasts
|
||||
expect(checkboxes[3].checked).toBe(true) // Audiobooks
|
||||
})
|
||||
|
||||
it('should toggle library selection when individual checkbox is clicked', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderLibrarySelector()
|
||||
|
||||
// Clear the dispatch mock after initial mount (it sets user libraries)
|
||||
mockDispatch.mockClear()
|
||||
|
||||
const chipButton = screen.getByRole('button')
|
||||
await user.click(chipButton)
|
||||
|
||||
const checkboxes = screen.getAllByRole('checkbox')
|
||||
const podcastsCheckbox = checkboxes[2] // Podcasts checkbox
|
||||
|
||||
fireEvent.click(podcastsCheckbox)
|
||||
|
||||
expect(mockDispatch).toHaveBeenCalledWith({
|
||||
type: 'SET_SELECTED_LIBRARIES',
|
||||
data: ['1', '2'],
|
||||
})
|
||||
})
|
||||
|
||||
it('should remove library from selection when clicking checked library', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderLibrarySelector({
|
||||
userLibraries: mockLibraries,
|
||||
selectedLibraries: ['1', '2'],
|
||||
})
|
||||
|
||||
// Clear the dispatch mock after initial mount (it sets user libraries)
|
||||
mockDispatch.mockClear()
|
||||
|
||||
const chipButton = screen.getByRole('button')
|
||||
await user.click(chipButton)
|
||||
|
||||
const checkboxes = screen.getAllByRole('checkbox')
|
||||
const musicCheckbox = checkboxes[1] // Music Library checkbox
|
||||
|
||||
fireEvent.click(musicCheckbox)
|
||||
|
||||
expect(mockDispatch).toHaveBeenCalledWith({
|
||||
type: 'SET_SELECTED_LIBRARIES',
|
||||
data: ['2'],
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('should close dropdown when clicking away', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderLibrarySelector()
|
||||
|
||||
// Open dropdown
|
||||
const chipButton = screen.getByRole('button')
|
||||
await user.click(chipButton)
|
||||
|
||||
expect(screen.getByTestId('popper')).toBeInTheDocument()
|
||||
|
||||
// Click away
|
||||
const clickAwayListener = screen.getByTestId('click-away-listener')
|
||||
fireEvent.mouseDown(clickAwayListener)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByTestId('popper')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Should trigger refresh when closing
|
||||
expect(mockRefresh).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should load user libraries on mount', async () => {
|
||||
// Override localStorage mock to return a userId for this test
|
||||
window.localStorage.getItem.mockReturnValue('user123')
|
||||
|
||||
mockDataProvider.getOne.mockResolvedValue({
|
||||
data: { libraries: mockLibraries },
|
||||
})
|
||||
|
||||
renderLibrarySelector({ userLibraries: [], selectedLibraries: [] })
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockDataProvider.getOne).toHaveBeenCalledWith('user', {
|
||||
id: 'user123',
|
||||
})
|
||||
})
|
||||
|
||||
expect(mockDispatch).toHaveBeenCalledWith({
|
||||
type: 'SET_USER_LIBRARIES',
|
||||
data: mockLibraries,
|
||||
})
|
||||
})
|
||||
|
||||
it('should handle API error gracefully', async () => {
|
||||
// Override localStorage mock to return a userId for this test
|
||||
window.localStorage.getItem.mockReturnValue('user123')
|
||||
|
||||
const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
mockDataProvider.getOne.mockRejectedValue(new Error('API Error'))
|
||||
|
||||
renderLibrarySelector({ userLibraries: [], selectedLibraries: [] })
|
||||
|
||||
await waitFor(() => {
|
||||
expect(consoleSpy).toHaveBeenCalledWith(
|
||||
'Could not load user libraries (this may be expected for non-admin users):',
|
||||
expect.any(Error),
|
||||
)
|
||||
})
|
||||
|
||||
consoleSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('should not load libraries when userId is not available', () => {
|
||||
window.localStorage.getItem.mockReturnValue(null)
|
||||
|
||||
renderLibrarySelector({ userLibraries: [], selectedLibraries: [] })
|
||||
|
||||
expect(mockDataProvider.getOne).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
76
ui/src/common/Linkify.jsx
Normal file
@@ -0,0 +1,76 @@
|
||||
import React, { useCallback, useMemo } from 'react'
|
||||
import { Link } from '@material-ui/core'
|
||||
import { makeStyles } from '@material-ui/core/styles'
|
||||
import PropTypes from 'prop-types'
|
||||
|
||||
const useStyles = makeStyles(
|
||||
(theme) => ({
|
||||
link: {
|
||||
textDecoration: 'none',
|
||||
color: theme.palette.primary.main,
|
||||
},
|
||||
}),
|
||||
{ name: 'RaLink' },
|
||||
)
|
||||
|
||||
const Linkify = ({ text, ...rest }) => {
|
||||
const classes = useStyles()
|
||||
const linkify = useCallback((text) => {
|
||||
const urlRegex =
|
||||
/(\b(https?|ftp|file):\/\/[-A-Z0-9+&@#/%?=~_|!:,.;]*[-A-Z0-9+&@#/%=~_|])/gi
|
||||
return [...text.matchAll(urlRegex)]
|
||||
}, [])
|
||||
|
||||
const parse = useCallback(() => {
|
||||
const matches = linkify(text)
|
||||
if (matches.length === 0) return text
|
||||
|
||||
const elements = []
|
||||
let lastIndex = 0
|
||||
matches.forEach((match, index) => {
|
||||
// Push text located before matched string
|
||||
if (match.index > lastIndex) {
|
||||
elements.push(text.substring(lastIndex, match.index))
|
||||
}
|
||||
|
||||
const href = match[0]
|
||||
// Push Link component
|
||||
elements.push(
|
||||
<Link
|
||||
{...rest}
|
||||
target="_blank"
|
||||
className={classes.link}
|
||||
rel="noopener noreferrer"
|
||||
key={index}
|
||||
href={href}
|
||||
>
|
||||
{href}
|
||||
</Link>,
|
||||
)
|
||||
|
||||
lastIndex = match.index + href.length
|
||||
})
|
||||
|
||||
// Push remaining text
|
||||
if (text.length > lastIndex) {
|
||||
elements.push(
|
||||
<span
|
||||
key={'last-span-key'}
|
||||
dangerouslySetInnerHTML={{ __html: text.substring(lastIndex) }}
|
||||
/>,
|
||||
)
|
||||
}
|
||||
|
||||
return elements.length === 1 ? elements[0] : elements
|
||||
}, [linkify, text, rest, classes.link])
|
||||
|
||||
const parsedText = useMemo(() => parse(), [parse])
|
||||
|
||||
return <>{parsedText}</>
|
||||
}
|
||||
|
||||
Linkify.propTypes = {
|
||||
text: PropTypes.string,
|
||||
}
|
||||
|
||||
export default React.memo(Linkify)
|
||||
33
ui/src/common/Linkify.test.jsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import React from 'react'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import Linkify from './Linkify'
|
||||
|
||||
const URL = 'http://www.example.com'
|
||||
|
||||
const expectLink = (url) => {
|
||||
const linkEl = screen.getByRole('link')
|
||||
expect(linkEl).not.toBeNull()
|
||||
expect(linkEl?.href).toBe(url)
|
||||
}
|
||||
|
||||
describe('<Linkify />', () => {
|
||||
it('should render link', () => {
|
||||
render(<Linkify text={URL} />)
|
||||
expectLink(`${URL}/`)
|
||||
expect(screen.getByText(URL)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render link and text', () => {
|
||||
render(<Linkify text={`foo ${URL} bar`} />)
|
||||
expectLink(`${URL}/`)
|
||||
expect(screen.getByText(/foo/i)).toBeInTheDocument()
|
||||
expect(screen.getByText(URL)).toBeInTheDocument()
|
||||
expect(screen.getByText(/bar/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render only text', () => {
|
||||
render(<Linkify text={'foo bar'} />)
|
||||
expect(screen.queryAllByRole('link')).toHaveLength(0)
|
||||
expect(screen.getByText(/foo bar/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
21
ui/src/common/List.jsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import React from 'react'
|
||||
import { List as RAList } from 'react-admin'
|
||||
import { Pagination } from './Pagination'
|
||||
import { Title } from './index'
|
||||
|
||||
export const List = (props) => {
|
||||
const { resource } = props
|
||||
return (
|
||||
<RAList
|
||||
title={
|
||||
<Title
|
||||
subTitle={`resources.${resource}.name`}
|
||||
args={{ smart_count: 2 }}
|
||||
/>
|
||||
}
|
||||
perPage={15}
|
||||
pagination={<Pagination />}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
85
ui/src/common/LoveButton.jsx
Normal file
@@ -0,0 +1,85 @@
|
||||
import React, { useCallback } from 'react'
|
||||
import PropTypes from 'prop-types'
|
||||
import FavoriteIcon from '@material-ui/icons/Favorite'
|
||||
import FavoriteBorderIcon from '@material-ui/icons/FavoriteBorder'
|
||||
import IconButton from '@material-ui/core/IconButton'
|
||||
import { makeStyles } from '@material-ui/core/styles'
|
||||
import { useToggleLove } from './useToggleLove'
|
||||
import { useRecordContext } from 'react-admin'
|
||||
import config from '../config'
|
||||
import { isDateSet } from '../utils/validations'
|
||||
|
||||
const useStyles = makeStyles({
|
||||
love: {
|
||||
color: (props) => props.color,
|
||||
visibility: (props) =>
|
||||
props.visible === false ? 'hidden' : props.loved ? 'visible' : 'inherit',
|
||||
},
|
||||
})
|
||||
|
||||
export const LoveButton = ({
|
||||
resource,
|
||||
color,
|
||||
visible,
|
||||
size,
|
||||
component: Button,
|
||||
addLabel,
|
||||
disabled,
|
||||
...rest
|
||||
}) => {
|
||||
const record = useRecordContext(rest) || {}
|
||||
const classes = useStyles({ color, visible, loved: record.starred })
|
||||
const [toggleLove, loading] = useToggleLove(resource, record)
|
||||
|
||||
const handleToggleLove = useCallback(
|
||||
(e) => {
|
||||
e.preventDefault()
|
||||
toggleLove()
|
||||
e.stopPropagation()
|
||||
},
|
||||
[toggleLove],
|
||||
)
|
||||
|
||||
if (!config.enableFavourites) {
|
||||
return <></>
|
||||
}
|
||||
return (
|
||||
<Button
|
||||
onClick={handleToggleLove}
|
||||
size={'small'}
|
||||
disabled={disabled || loading || record.missing}
|
||||
className={classes.love}
|
||||
title={
|
||||
isDateSet(record.starredAt)
|
||||
? new Date(record.starredAt).toLocaleString()
|
||||
: undefined
|
||||
}
|
||||
{...rest}
|
||||
>
|
||||
{record.starred ? (
|
||||
<FavoriteIcon fontSize={size} />
|
||||
) : (
|
||||
<FavoriteBorderIcon fontSize={size} />
|
||||
)}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
LoveButton.propTypes = {
|
||||
resource: PropTypes.string.isRequired,
|
||||
record: PropTypes.object,
|
||||
visible: PropTypes.bool,
|
||||
color: PropTypes.string,
|
||||
size: PropTypes.string,
|
||||
component: PropTypes.object,
|
||||
disabled: PropTypes.bool,
|
||||
}
|
||||
|
||||
LoveButton.defaultProps = {
|
||||
addLabel: true,
|
||||
visible: true,
|
||||
size: 'small',
|
||||
color: 'inherit',
|
||||
component: IconButton,
|
||||
disabled: false,
|
||||
}
|
||||
54
ui/src/common/MultiLineTextField.jsx
Normal file
@@ -0,0 +1,54 @@
|
||||
import React, { memo } from 'react'
|
||||
import Typography from '@material-ui/core/Typography'
|
||||
import sanitizeFieldRestProps from './sanitizeFieldRestProps'
|
||||
import md5 from 'blueimp-md5'
|
||||
import { useRecordContext } from 'react-admin'
|
||||
|
||||
export const MultiLineTextField = memo(
|
||||
({
|
||||
className,
|
||||
emptyText,
|
||||
source,
|
||||
firstLine,
|
||||
maxLines,
|
||||
addLabel,
|
||||
...rest
|
||||
}) => {
|
||||
const record = useRecordContext(rest)
|
||||
const value = record && record[source]
|
||||
let lines = value ? value.split('\n') : []
|
||||
if (maxLines || firstLine) {
|
||||
lines = lines.slice(firstLine, maxLines)
|
||||
}
|
||||
|
||||
return (
|
||||
<Typography
|
||||
className={className}
|
||||
variant="body2"
|
||||
component="span"
|
||||
{...sanitizeFieldRestProps(rest)}
|
||||
>
|
||||
{lines.length === 0 && emptyText
|
||||
? emptyText
|
||||
: lines.map((line, idx) =>
|
||||
line === '' ? (
|
||||
<br key={md5(line + idx)} />
|
||||
) : (
|
||||
<div
|
||||
data-testid={`${source}.${idx}`}
|
||||
key={md5(line + idx)}
|
||||
dangerouslySetInnerHTML={{ __html: line }}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
</Typography>
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
MultiLineTextField.displayName = 'MultiLineTextField'
|
||||
|
||||
MultiLineTextField.defaultProps = {
|
||||
addLabel: true,
|
||||
firstLine: 0,
|
||||
}
|
||||
28
ui/src/common/MultiLineTextField.test.jsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import * as React from 'react'
|
||||
import { render, cleanup, screen } from '@testing-library/react'
|
||||
import { MultiLineTextField } from './MultiLineTextField'
|
||||
|
||||
describe('<MultiLineTextField />', () => {
|
||||
afterEach(cleanup)
|
||||
|
||||
it('should render each line in a separated div', () => {
|
||||
const record = { comment: 'line1\nline2' }
|
||||
render(<MultiLineTextField record={record} source={'comment'} />)
|
||||
expect(screen.queryByTestId('comment.0').textContent).toBe('line1')
|
||||
expect(screen.queryByTestId('comment.1').textContent).toBe('line2')
|
||||
})
|
||||
|
||||
it.each([null, undefined])(
|
||||
'should render the emptyText when value is %s',
|
||||
(body) => {
|
||||
render(
|
||||
<MultiLineTextField
|
||||
record={{ id: 123, body }}
|
||||
emptyText="NA"
|
||||
source="body"
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByText('NA')).toBeInTheDocument()
|
||||
},
|
||||
)
|
||||
})
|
||||