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

This commit is contained in:
2025-12-08 16:16:23 +01:00
commit c251f174ed
1349 changed files with 194301 additions and 0 deletions

7
ui/.eslintignore Normal file
View File

@@ -0,0 +1,7 @@
node_modules/
build/
prettier.config.js
.eslintrc
vite.config.js
public/3rdparty/workbox
coverage/

61
ui/.eslintrc Normal file
View 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
View File

@@ -0,0 +1,7 @@
node_modules
.eslintcache
build/*
!build/.gitkeep
/coverage/
public/3rdparty/workbox

17
ui/bin/update-workbox.sh Executable file
View 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
View File

14
ui/embed.go Normal file
View 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
View 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

File diff suppressed because it is too large Load Diff

85
ui/package.json Normal file
View 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
View File

@@ -0,0 +1,5 @@
export default {
singleQuote: true,
semi: false,
arrowParens: "always",
};

0
ui/public/.gitkeep Normal file
View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.0 KiB

View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 1002 B

BIN
ui/public/favicon-32x32.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

BIN
ui/public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

BIN
ui/public/mstile-70x70.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

10
ui/public/offline.html Normal file
View 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
View File

@@ -0,0 +1,2 @@
User-agent: *
Disallow: /

View 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
View 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

View 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
View 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
View 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
View 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
View 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 },
})

View 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,
})

View 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: {},
})

View 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
View File

@@ -0,0 +1,6 @@
export const CHANGE_THEME = 'CHANGE_THEME'
export const changeTheme = (theme) => ({
type: CHANGE_THEME,
payload: theme,
})

View 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

View 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>
}

View 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')
})
})

View 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

View 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()
})
})
})

View 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

View 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
View 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
View 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

View 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

View 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
View 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

View 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'} />
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;
</>
)}
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

View 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>
`;

View 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
View 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
View File

@@ -0,0 +1,7 @@
export const removeAlbumCommentsFromSongs = ({ album, data }) => {
if (album?.comment && data) {
Object.values(data).forEach((song) => {
song.comment = ''
})
}
}

View 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()
})
})

View 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

View 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()
})
})
})

View 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

View 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

View 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

View 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

View 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

View 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

View 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
View 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
View 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}
/>
),
}

View 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

View 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')
})
})

View 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 }

View 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

View 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()
})
})
})

View File

@@ -0,0 +1 @@
export * from './Player'

View 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

View 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

View 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
View 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

View 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,
}

View 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',
}

View 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()
})
})
})

View 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,
}

View 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 = {}

View 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,
}

View 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>
)
}

View 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,
}

View 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,
}

View 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>
)

View 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,
}

View 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

View 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
View 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)

View 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
View 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}
/>
)
}

View 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,
}

View 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,
}

View 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()
},
)
})

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