diff --git a/ui/package-lock.json b/ui/package-lock.json index 0e6183720..a9b83d76e 100644 --- a/ui/package-lock.json +++ b/ui/package-lock.json @@ -117,7 +117,6 @@ "node_modules/@babel/core": { "version": "7.28.6", "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/generator": "^7.28.6", @@ -1530,7 +1529,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=18" }, @@ -1552,7 +1550,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=18" } @@ -2296,7 +2293,6 @@ "node_modules/@jsonforms/core": { "version": "2.5.2", "license": "MIT", - "peer": true, "dependencies": { "@types/json-schema": "^7.0.3", "ajv": "^6.10.2", @@ -2340,7 +2336,6 @@ "node_modules/@jsonforms/react": { "version": "2.5.2", "license": "MIT", - "peer": true, "dependencies": { "lodash": "^4.17.15", "object-hash": "^2.0.0" @@ -2353,7 +2348,6 @@ "node_modules/@material-ui/core": { "version": "4.12.4", "license": "MIT", - "peer": true, "dependencies": { "@babel/runtime": "^7.4.4", "@material-ui/styles": "^4.11.5", @@ -2396,7 +2390,6 @@ "node_modules/@material-ui/icons": { "version": "4.11.3", "license": "MIT", - "peer": true, "dependencies": { "@babel/runtime": "^7.4.4" }, @@ -2794,7 +2787,9 @@ "license": "MIT" }, "node_modules/@rollup/pluginutils/node_modules/picomatch": { - "version": "4.0.3", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "license": "MIT", "engines": { "node": ">=12" @@ -3013,7 +3008,6 @@ "node_modules/@types/hoist-non-react-statics": { "version": "3.3.7", "license": "MIT", - "peer": true, "dependencies": { "hoist-non-react-statics": "^3.3.0" }, @@ -3054,7 +3048,6 @@ "version": "24.10.9", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~7.16.0" } @@ -3070,7 +3063,6 @@ "node_modules/@types/react": { "version": "17.0.90", "license": "MIT", - "peer": true, "dependencies": { "@types/prop-types": "*", "@types/scheduler": "^0.16", @@ -3206,7 +3198,6 @@ "version": "6.21.0", "dev": true, "license": "BSD-2-Clause", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "6.21.0", "@typescript-eslint/types": "6.21.0", @@ -3504,7 +3495,6 @@ "node_modules/acorn": { "version": "8.15.0", "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -4076,7 +4066,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -4390,7 +4379,6 @@ "node_modules/connected-react-router": { "version": "6.9.3", "license": "MIT", - "peer": true, "dependencies": { "lodash.isequalwith": "^4.4.0", "prop-types": "^15.7.2" @@ -5142,7 +5130,6 @@ "version": "8.57.1", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", @@ -5640,7 +5627,6 @@ "node_modules/final-form": { "version": "4.20.10", "license": "MIT", - "peer": true, "dependencies": { "@babel/runtime": "^7.10.0" }, @@ -5655,7 +5641,6 @@ "node_modules/final-form-arrays": { "version": "3.1.0", "license": "MIT", - "peer": true, "peerDependencies": { "final-form": "^4.20.8" } @@ -6004,7 +5989,6 @@ "version": "20.3.3", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/node": ">=20.0.0", "@types/whatwg-mimetype": "^3.0.2", @@ -6100,7 +6084,6 @@ "node_modules/history": { "version": "4.10.1", "license": "MIT", - "peer": true, "dependencies": { "@babel/runtime": "^7.1.2", "loose-envify": "^1.2.0", @@ -7433,7 +7416,6 @@ "node_modules/moment": { "version": "2.30.1", "license": "MIT", - "peer": true, "engines": { "node": "*" } @@ -7932,7 +7914,9 @@ "license": "ISC" }, "node_modules/picomatch": { - "version": "2.3.1", + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", "license": "MIT", "engines": { "node": ">=8.6" @@ -8039,7 +8023,6 @@ "node_modules/prop-types": { "version": "15.8.1", "license": "MIT", - "peer": true, "dependencies": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", @@ -8115,7 +8098,6 @@ "node_modules/ra-core": { "version": "3.19.12", "license": "MIT", - "peer": true, "dependencies": { "classnames": "~2.3.1", "date-fns": "^1.29.0", @@ -8469,7 +8451,6 @@ "node_modules/react": { "version": "17.0.2", "license": "MIT", - "peer": true, "dependencies": { "loose-envify": "^1.1.0", "object-assign": "^4.1.1" @@ -8543,7 +8524,6 @@ "node_modules/react-dom": { "version": "17.0.2", "license": "MIT", - "peer": true, "dependencies": { "loose-envify": "^1.1.0", "object-assign": "^4.1.1", @@ -8608,7 +8588,6 @@ "node_modules/react-final-form": { "version": "6.5.9", "license": "MIT", - "peer": true, "dependencies": { "@babel/runtime": "^7.15.4" }, @@ -8624,7 +8603,6 @@ "node_modules/react-final-form-arrays": { "version": "3.1.4", "license": "MIT", - "peer": true, "dependencies": { "@babel/runtime": "^7.19.4" }, @@ -8711,7 +8689,6 @@ "node_modules/react-redux": { "version": "7.2.9", "license": "MIT", - "peer": true, "dependencies": { "@babel/runtime": "^7.15.4", "@types/react-redux": "^7.1.20", @@ -8743,7 +8720,6 @@ "node_modules/react-router": { "version": "5.3.4", "license": "MIT", - "peer": true, "dependencies": { "@babel/runtime": "^7.12.13", "history": "^4.9.0", @@ -8762,7 +8738,6 @@ "node_modules/react-router-dom": { "version": "5.3.4", "license": "MIT", - "peer": true, "dependencies": { "@babel/runtime": "^7.12.13", "history": "^4.9.0", @@ -8916,7 +8891,6 @@ "node_modules/redux": { "version": "4.2.1", "license": "MIT", - "peer": true, "dependencies": { "@babel/runtime": "^7.9.2" } @@ -8924,7 +8898,6 @@ "node_modules/redux-saga": { "version": "1.4.2", "license": "MIT", - "peer": true, "dependencies": { "@redux-saga/core": "^1.4.2" } @@ -9133,7 +9106,6 @@ "version": "4.55.2", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "@types/estree": "1.0.8" }, @@ -9925,10 +9897,11 @@ } }, "node_modules/tinyglobby/node_modules/picomatch": { - "version": "4.0.3", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -10116,7 +10089,6 @@ "version": "5.9.3", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -10319,7 +10291,6 @@ "version": "7.3.1", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", @@ -10435,10 +10406,11 @@ } }, "node_modules/vite/node_modules/picomatch": { - "version": "4.0.3", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -10450,7 +10422,6 @@ "version": "4.0.17", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@vitest/expect": "4.0.17", "@vitest/mocker": "4.0.17", @@ -10524,7 +10495,9 @@ } }, "node_modules/vitest/node_modules/picomatch": { - "version": "4.0.3", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", "engines": { @@ -10904,7 +10877,6 @@ "node_modules/workbox-build/node_modules/ajv": { "version": "8.18.0", "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -10978,7 +10950,6 @@ "node_modules/workbox-build/node_modules/rollup": { "version": "2.79.2", "license": "MIT", - "peer": true, "bin": { "rollup": "dist/bin/rollup" }, diff --git a/ui/src/album/AlbumGridView.jsx b/ui/src/album/AlbumGridView.jsx index e90e7a77b..c8a161571 100644 --- a/ui/src/album/AlbumGridView.jsx +++ b/ui/src/album/AlbumGridView.jsx @@ -18,6 +18,7 @@ import { PlayButton, ArtistLinkField, OverflowTooltip, + useImageUrl, } from '../common' import { COVER_ART_SIZE, DraggableTypes } from '../consts' import clsx from 'clsx' @@ -105,7 +106,7 @@ const useCoverStyles = makeStyles({ transition: 'opacity 0.3s ease-in-out', }, coverLoading: { - opacity: 0.5, + opacity: 0, }, }) @@ -125,8 +126,6 @@ const Cover = withContentRect('bounds')(({ // 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, @@ -136,32 +135,16 @@ const Cover = withContentRect('bounds')(({ [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) - }, []) + const url = subsonic.getCoverArtUrl(record, COVER_ART_SIZE, true) + const { imgUrl, loading: imageLoading } = useImageUrl(url) return (
{record.name}
diff --git a/ui/src/album/AlbumList.jsx b/ui/src/album/AlbumList.jsx index d00d97701..28a24981c 100644 --- a/ui/src/album/AlbumList.jsx +++ b/ui/src/album/AlbumList.jsx @@ -175,12 +175,12 @@ const AlbumListTitle = ({ albumListType }) => { return } -const AlbumListPagination = (props) => { +const AlbumListPagination = ({ albumListType, ...rest }) => { const { loading } = useListContext() - if (loading) { + if (loading && albumListType === 'random') { return null } - return <Pagination {...props} /> + return <Pagination {...rest} /> } const randomStartingSeed = Math.random().toString() @@ -243,7 +243,12 @@ const AlbumList = (props) => { actions={<AlbumListActions />} filters={<AlbumFilter />} perPage={perPage} - pagination={<AlbumListPagination rowsPerPageOptions={perPageOptions} />} + pagination={ + <AlbumListPagination + rowsPerPageOptions={perPageOptions} + albumListType={albumListType} + /> + } title={<AlbumListTitle albumListType={albumListType} />} > {albumView.grid ? ( diff --git a/ui/src/common/CoverArtAvatar.jsx b/ui/src/common/CoverArtAvatar.jsx index 5642f2504..bc6dd8a3c 100644 --- a/ui/src/common/CoverArtAvatar.jsx +++ b/ui/src/common/CoverArtAvatar.jsx @@ -4,12 +4,16 @@ import { makeStyles } from '@material-ui/core/styles' import clsx from 'clsx' import { COVER_ART_SIZE } from '../consts' import subsonic from '../subsonic' +import { useImageUrl } from './useImageUrl' const useStyles = makeStyles({ avatar: { width: '55px', height: '55px', }, + avatarEmpty: { + backgroundColor: 'transparent', + }, square: { borderRadius: '4px', }, @@ -22,15 +26,26 @@ export const CoverArtAvatar = ({ const classes = useStyles() const recordContext = useRecordContext() const record = recordProp || recordContext - if (!record) return null const square = variant !== 'circular' + const url = record + ? subsonic.getCoverArtUrl(record, COVER_ART_SIZE, square) + : null + const { imgUrl } = useImageUrl(url) + if (!record) return null return ( <Avatar - src={subsonic.getCoverArtUrl(record, COVER_ART_SIZE, square)} + src={imgUrl || undefined} variant={variant} - className={clsx(classes.avatar, square && classes.square)} + className={clsx( + classes.avatar, + square && classes.square, + !imgUrl && classes.avatarEmpty, + )} alt={record.name} - /> + > + {/* Empty child prevents default person icon while loading */} + {!imgUrl && <span />} + </Avatar> ) } diff --git a/ui/src/common/index.js b/ui/src/common/index.js index a7d6a43c4..362a0ced3 100644 --- a/ui/src/common/index.js +++ b/ui/src/common/index.js @@ -46,3 +46,4 @@ export * from './useSearchRefocus' export * from './ImageUploadOverlay' export * from './CoverArtAvatar' export * from './useImageLoadingState' +export * from './useImageUrl' diff --git a/ui/src/common/useImageUrl.js b/ui/src/common/useImageUrl.js new file mode 100644 index 000000000..9bcc70d74 --- /dev/null +++ b/ui/src/common/useImageUrl.js @@ -0,0 +1,144 @@ +import { useEffect, useState, useRef } from 'react' + +// Persists across component mount/unmount cycles so that +// React Admin refreshes (which remount list items) don't re-fetch images. +const cache = new Map() +const MAX_CACHE_SIZE = 300 + +// Limit concurrent fetches to leave browser connections free for API requests. +// Browsers allow ~6 connections per origin on HTTP/1.1; reserving 2 for API +// calls prevents image fetches from blocking pagination/data requests. +const MAX_CONCURRENT = 4 +let activeFetches = 0 +const pendingQueue = [] + +const processQueue = () => { + while (pendingQueue.length > 0 && activeFetches < MAX_CONCURRENT) { + const next = pendingQueue.shift() + next() + } +} + +// Evicts oldest unused entries (Map iterates in insertion order). +const evictIfNeeded = () => { + if (cache.size <= MAX_CACHE_SIZE) return + for (const [key, entry] of cache) { + if (cache.size <= MAX_CACHE_SIZE) break + if (entry.refCount === 0) { + if (entry.blobUrl) URL.revokeObjectURL(entry.blobUrl) + cache.delete(key) + } + } +} + +/** + * Loads an image via fetch() with AbortController so that in-flight requests + * are canceled on unmount (e.g., during pagination). Uses a module-level cache + * so remounting returns the cached blob URL instantly. + */ +export const useImageUrl = (url) => { + const cached = url ? cache.get(url) : null + const [imgUrl, setImgUrl] = useState(cached?.blobUrl || null) + const [loading, setLoading] = useState(!!url && !cached) + const [error, setError] = useState(cached?.error || false) + const abortedRef = useRef(false) + + useEffect(() => { + abortedRef.current = false + + if (!url) { + setImgUrl(null) + setLoading(false) + setError(false) + return + } + + // Re-check: another component's effect may have populated the cache + // between this component's render and effect execution. + const entry = cache.get(url) + if (entry) { + entry.refCount++ + setImgUrl(entry.blobUrl) + setLoading(false) + setError(entry.error || false) + return () => { + entry.refCount-- + } + } + + const controller = new AbortController() + let queued = true + setImgUrl(null) + setLoading(true) + setError(false) + + const doFetch = () => { + queued = false + activeFetches++ + fetch(url, { signal: controller.signal }) + .then((res) => { + if (!res.ok) { + throw new Error(`HTTP ${res.status}`) + } + return res.blob() + }) + .then((blob) => { + activeFetches-- + processQueue() + // Guard against late resolution after abort + if (abortedRef.current) { + return + } + const objectUrl = URL.createObjectURL(blob) + // Handle concurrent fetches: if another component already cached + // this URL, use its entry and discard our blob. + const existing = cache.get(url) + if (existing && existing.blobUrl) { + existing.refCount++ + URL.revokeObjectURL(objectUrl) + setImgUrl(existing.blobUrl) + } else { + cache.set(url, { blobUrl: objectUrl, refCount: 1 }) + evictIfNeeded() + setImgUrl(objectUrl) + } + setLoading(false) + }) + .catch((err) => { + activeFetches-- + processQueue() + if (err.name === 'AbortError') { + return // Expected on unmount or URL change + } + // Cache the error so repeated mounts don't re-fetch broken URLs + cache.set(url, { blobUrl: null, error: true, refCount: 0 }) + setError(true) + setLoading(false) + }) + } + + if (activeFetches < MAX_CONCURRENT) { + queued = false + doFetch() + } else { + pendingQueue.push(doFetch) + } + + return () => { + abortedRef.current = true + if (queued) { + // Remove from queue if not yet started + const idx = pendingQueue.indexOf(doFetch) + if (idx !== -1) pendingQueue.splice(idx, 1) + } else { + controller.abort() + } + const entry = cache.get(url) + if (entry) { + entry.refCount-- + } + } + }, [url]) + + return { imgUrl, loading, error } +} diff --git a/ui/src/common/useImageUrl.test.js b/ui/src/common/useImageUrl.test.js new file mode 100644 index 000000000..976317105 --- /dev/null +++ b/ui/src/common/useImageUrl.test.js @@ -0,0 +1,234 @@ +import { renderHook, act } from '@testing-library/react-hooks' +import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest' + +// Helper to flush all pending promises +const flushPromises = () => new Promise((resolve) => setTimeout(resolve, 0)) + +// We need a fresh module for each test to reset the module-level cache +let useImageUrl + +describe('useImageUrl', () => { + let abortSpy + let OriginalAbortController + let originalCreateObjectURL + let originalRevokeObjectURL + let originalFetch + + beforeEach(async () => { + // Reset module to clear the cache + vi.resetModules() + const mod = await import('./useImageUrl') + useImageUrl = mod.useImageUrl + + abortSpy = vi.fn() + OriginalAbortController = global.AbortController + originalCreateObjectURL = global.URL.createObjectURL + originalRevokeObjectURL = global.URL.revokeObjectURL + originalFetch = global.fetch + + global.AbortController = function () { + this.signal = 'mock-signal' + this.abort = abortSpy + } + global.URL.createObjectURL = vi.fn(() => 'blob:mock-url') + global.URL.revokeObjectURL = vi.fn() + }) + + afterEach(() => { + global.AbortController = OriginalAbortController + global.URL.createObjectURL = originalCreateObjectURL + global.URL.revokeObjectURL = originalRevokeObjectURL + global.fetch = originalFetch + vi.restoreAllMocks() + }) + + it('should return null values when url is null', () => { + const { result } = renderHook(() => useImageUrl(null)) + + expect(result.current.loading).toBe(false) + expect(result.current.imgUrl).toBeNull() + expect(result.current.error).toBe(false) + }) + + it('should return loading state initially', () => { + global.fetch = vi.fn(() => new Promise(() => {})) + const { result } = renderHook(() => + useImageUrl('http://example.com/img.jpg'), + ) + + expect(result.current.loading).toBe(true) + expect(result.current.imgUrl).toBeNull() + expect(result.current.error).toBe(false) + }) + + it('should fetch image and return blob URL on success', async () => { + const mockBlob = new Blob(['image-data'], { type: 'image/png' }) + global.fetch = vi.fn(() => + Promise.resolve({ + ok: true, + blob: () => Promise.resolve(mockBlob), + }), + ) + + const { result } = renderHook(() => + useImageUrl('http://example.com/img.jpg'), + ) + + await act(async () => { + await flushPromises() + }) + + expect(result.current.loading).toBe(false) + expect(result.current.imgUrl).toBe('blob:mock-url') + expect(result.current.error).toBe(false) + expect(global.fetch).toHaveBeenCalledWith('http://example.com/img.jpg', { + signal: 'mock-signal', + }) + }) + + it('should set error on HTTP failure', async () => { + global.fetch = vi.fn(() => Promise.resolve({ ok: false, status: 404 })) + + const { result } = renderHook(() => + useImageUrl('http://example.com/missing.jpg'), + ) + + await act(async () => { + await flushPromises() + }) + + expect(result.current.loading).toBe(false) + expect(result.current.imgUrl).toBeNull() + expect(result.current.error).toBe(true) + }) + + it('should abort fetch on unmount', async () => { + global.fetch = vi.fn(() => new Promise(() => {})) + + const { unmount } = renderHook(() => + useImageUrl('http://example.com/img.jpg'), + ) + + await act(async () => { + await flushPromises() + }) + + unmount() + + expect(abortSpy).toHaveBeenCalled() + }) + + it('should abort previous fetch when URL changes', async () => { + const abortSpies = [] + global.AbortController = function () { + const spy = vi.fn() + abortSpies.push(spy) + this.signal = `signal-${abortSpies.length}` + this.abort = spy + } + + const mockBlob = new Blob(['data'], { type: 'image/png' }) + global.fetch = vi.fn(() => + Promise.resolve({ + ok: true, + blob: () => Promise.resolve(mockBlob), + }), + ) + + const { rerender } = renderHook(({ url }) => useImageUrl(url), { + initialProps: { url: 'http://example.com/img1.jpg' }, + }) + + await act(async () => { + await flushPromises() + }) + + // Change URL - should abort the first controller + rerender({ url: 'http://example.com/img2.jpg' }) + + expect(abortSpies[0]).toHaveBeenCalled() + }) + + it('should not set error on AbortError', async () => { + const abortError = new DOMException('Aborted', 'AbortError') + global.fetch = vi.fn(() => Promise.reject(abortError)) + + const { result } = renderHook(() => + useImageUrl('http://example.com/img.jpg'), + ) + + await act(async () => { + await flushPromises() + }) + + expect(result.current.error).toBe(false) + }) + + it('should use cached blob URL on remount without re-fetching', async () => { + const mockBlob = new Blob(['data'], { type: 'image/png' }) + global.fetch = vi.fn(() => + Promise.resolve({ + ok: true, + blob: () => Promise.resolve(mockBlob), + }), + ) + + // First mount — fetches and caches + const { unmount } = renderHook(() => + useImageUrl('http://example.com/img.jpg'), + ) + + await act(async () => { + await flushPromises() + }) + + expect(global.fetch).toHaveBeenCalledTimes(1) + + // Unmount (simulates React Admin refresh) + unmount() + + // Remount with same URL — should use cache + const { result: result2 } = renderHook(() => + useImageUrl('http://example.com/img.jpg'), + ) + + await act(async () => { + await flushPromises() + }) + + // Should NOT have fetched again + expect(global.fetch).toHaveBeenCalledTimes(1) + expect(result2.current.imgUrl).toBe('blob:mock-url') + expect(result2.current.loading).toBe(false) + }) + + it('should cache errors and not re-fetch broken URLs', async () => { + global.fetch = vi.fn(() => Promise.resolve({ ok: false, status: 404 })) + + // First mount — fetch fails and error is cached + const { unmount } = renderHook(() => + useImageUrl('http://example.com/broken.jpg'), + ) + + await act(async () => { + await flushPromises() + }) + + expect(global.fetch).toHaveBeenCalledTimes(1) + unmount() + + // Remount with same URL — should use cached error, not re-fetch + const { result: result2 } = renderHook(() => + useImageUrl('http://example.com/broken.jpg'), + ) + + await act(async () => { + await flushPromises() + }) + + expect(global.fetch).toHaveBeenCalledTimes(1) + expect(result2.current.error).toBe(true) + expect(result2.current.imgUrl).toBeNull() + expect(result2.current.loading).toBe(false) + }) +}) diff --git a/ui/src/radio/RadioList.jsx b/ui/src/radio/RadioList.jsx index 582fcaffc..945bac519 100644 --- a/ui/src/radio/RadioList.jsx +++ b/ui/src/radio/RadioList.jsx @@ -14,8 +14,12 @@ import { UrlField, useTranslate, } from 'react-admin' -import { List } from '../common' -import { ToggleFieldsMenu, useSelectedFields } from '../common' +import { + List, + useImageUrl, + ToggleFieldsMenu, + useSelectedFields, +} from '../common' import subsonic from '../subsonic' import { StreamField } from './StreamField' import { setTrack } from '../actions' @@ -78,10 +82,12 @@ const RadioListActions = ({ const avatarStyle = { width: 40, height: 40 } const CoverArtField = ({ record }) => { - if (!record) return null - const src = record.uploadedImage + const directUrl = record?.uploadedImage ? subsonic.getCoverArtUrl(record, 40, true) - : RADIO_PLACEHOLDER_IMAGE + : null + const { imgUrl } = useImageUrl(directUrl) + if (!record) return null + const src = imgUrl || RADIO_PLACEHOLDER_IMAGE return ( <Avatar src={src} variant="rounded" style={avatarStyle} alt={record.name} /> ) diff --git a/ui/src/themes/index.js b/ui/src/themes/index.js index 5c3e56f7d..e6cd4e0ff 100644 --- a/ui/src/themes/index.js +++ b/ui/src/themes/index.js @@ -11,6 +11,7 @@ import GruvboxDarkTheme from './gruvboxDark' import CatppuccinMacchiatoTheme from './catppuccinMacchiato' import DraculaTheme from './dracula' import NuclearTheme from './nuclear' +import NutballTheme from './nutball' import AmusicTheme from './amusic' import SquiddiesGlassTheme from './SquiddiesGlass' import NautilineTheme from './nautiline' @@ -37,6 +38,7 @@ export default { NautilineTheme, NordTheme, NuclearTheme, + NutballTheme, SpotifyTheme, SquiddiesGlassTheme, } diff --git a/ui/src/themes/nutball.css.js b/ui/src/themes/nutball.css.js new file mode 100644 index 000000000..aaf1c0e16 --- /dev/null +++ b/ui/src/themes/nutball.css.js @@ -0,0 +1,380 @@ +const stylesheet = ` +html { + scrollbar-width: none; +} +body { + -ms-overflow-style: none; + font-family: monospace; +} +body::-webkit-scrollbar, body::-webkit-scrollbar-button { + display: none; +} +.react-jinke-music-player-main .music-player-panel { + background-color: white!important; + box-shadow: none; + font-family: monospace; + color: black; + border-top: 1px solid black; +} +.react-jinke-music-player-main .music-player-panel .panel-content div.img-content { + animation: none; + box-shadow: none; + border-radius: 5px; +} +.react-jinke-music-player-main .music-player-panel .panel-content .progress-bar-content { + flex: 0 0 auto; + width: calc(50% - 150px); + margin-left: 10px; + padding: 0; +} +section.audio-main { + position: absolute; + width: calc(100% - 131px)!important; + bottom: 0; + margin-bottom: 10px; +} +span.audio-title { + margin-bottom: 20px; +} +span.audio-title .songTitle { + color: black!important; +} +.react-jinke-music-player-main .music-player-panel .panel-content .player-content { + flex: 1; + margin-bottom: 20px; + padding-left: 0; +} +div.player-content > span:first-child { + flex: 1!important; + justify-content: flex-start!important; +} +div.player-content > span:first-child svg { + width: 50px; +} +.react-jinke-music-player-main .music-player-panel .panel-content .player-content > .group { + flex: 0; +} +.play-sounds svg, .loop-btn svg, .audio-lists-btn svg, .destroy-btn { + margin-left: 0!important; +} +.play-sounds svg, .loop-btn svg, .audio-lists-btn svg, .destroy-btn svg { + width: 20px; +} +.react-jinke-music-player-main .music-player-panel .panel-content .player-content .audio-lists-btn { + padding: 0; +} +.react-jinke-music-player-main .music-player-panel .panel-content .player-content .audio-lists-btn .audio-lists-icon svg { + height: .75em; +} +.react-jinke-music-player-main .music-player-panel .panel-content .progress-bar-content .audio-main .current-time, .react-jinke-music-player-main .music-player-panel .panel-content .progress-bar-content .audio-main .duration { + flex-basis: 0; +} +.progress-bar > div:nth-child(2) > div:nth-child(4) { + transform: translateX(-50%) translateY(5%) !important; +} +.progress-load-bar { + display: none; +} +.sound-operation > div:nth-child(4) { + transform: translateX(-50%) translateY(5%) !important; +} +.react-jinke-music-player-main .music-player-panel .panel-content .player-content .play-sounds .sound-operation { + width: 60px; +} +.rc-slider { + border-radius: 0px; + border: 1px solid black; + padding: 3px 0!important; +} +.rc-slider .rc-slider-handle { + box-shadow: none!important; + border-radius: 0px; + background-color: black!important; + border: hidden!important; +} +.rc-slider[style*="left: 0%"] { + transform: translateX(0) !important; +} +.rc-slider .rc-slider-track { + display: none; +} +.react-jinke-music-player-main .rc-slider-rail, .react-jinke-music-player-main.light-theme .rc-slider-rail { + background-color: white!important; +} +.react-jinke-music-player-main .music-player-panel .panel-content .player-content .play-sounds .sounds-icon { + margin-right: 10px; +} +.lyric-btn { + display: none!important; +} +button[data-testid="save-queue-button"] { + display: none!important; +} +.react-jinke-music-player-main .music-player-panel .panel-content .player-content .audio-lists-btn { + box-shadow: 0 0 0 0; + margin: 0; + margin-left: -8px; + margin-right: -5px; +} +.audio-lists-btn:hover span, +.audio-lists-btn:hover svg { + color: #a8fe40!important; +} +.react-jinke-music-player-main.light-theme .audio-lists-btn { + background-color: white!important; +} +.react-jinke-music-player-main .music-player-panel .panel-content .player-content .audio-lists-btn .audio-lists-num { + color: grey; + margin-left: 5px; + font-size: .7rem; +} +.react-jinke-music-player-main .music-player-panel .panel-content .player-content .hide-panel { + margin-left: 2px; +} +.react-jinke-music-player-main .music-player-panel .panel-content .player-content .hide-panel svg { + stroke-width: 15px; + stroke: #fff; + height: .8em; +} +@media screen and (max-width: 810px) { + .react-jinke-music-player-main .music-player-panel .panel-content .player-content .play-sounds .sounds-icon { + margin-left: 5px; + margin-right: 0; + } + .react-jinke-music-player-main .music-player-panel .panel-content .player-content .loop-btn { + margin-left: 5px; + } + .play-sounds svg, .loop-btn svg, .audio-lists-btn svg, .destroy-btn { + margin-left: -3px!important; + } +} +.panel-content li { + flex-grow: 0; +} +.react-jinke-music-player .music-player-controller, +.react-jinke-music-player-main.light-theme .music-player-controller { + border-radius: 5px; + box-shadow: none; +} +.react-jinke-music-player .music-player-controller:hover, +.react-jinke-music-player .music-player-controller:has(+ .destroy-btn:hover) { + border: 1px solid black; +} +.react-jinke-music-player .music-player-controller .controller-title, +.react-jinke-music-player .music-player-controller .music-player-controller-setting { + display: none; +} +.react-jinke-music-player .music-player-controller.music-player-playing:before { + animation: none; + border: none; +} +@media screen and (max-width:767px) { + .react-jinke-music-player .music-player .destroy-btn { + right: 0; + } + .react-jinke-music-player-main .destroy-btn svg { + font-size: 10px; + } +} +.react-jinke-music-player-main svg { + transition: none; +} +.react-jinke-music-player-main svg, .react-jinke-music-player-main.light-theme svg { + color: black; +} +.react-jinke-music-player-main svg:active, .react-jinke-music-player-main svg:hover, +.react-jinke-music-player-main.light-theme svg:active, .react-jinke-music-player-main.light-theme svg:hover { + color: #a8fe40; +} +.react-jinke-music-player-main .play-mode-title { + font-family: monospace; + background-color: white; + color: black; +} +.react-jinke-music-player-mobile, +.react-jinke-music-player-main.light-theme .react-jinke-music-player-mobile { + font-family: monospace; + background-color: rgba(255, 255, 255, .9); + color: black!important; + justify-content: center; + padding: 50px; +} +.react-jinke-music-player-mobile:before { + content: " "; + display: block; + position: absolute; + margin-left: auto; + margin-right: auto; + left: 0; + right: 0; + text-align: center; + width: 90%; + height: 700px; + background-color: white; + border: 1px solid black; + z-index: -1; + border-radius: 4px; +} +.react-jinke-music-player-mobile-header { + align-items: start; + margin-bottom: 4rem; + justify-content: start; +} +.react-jinke-music-player-mobile-header-title { + text-align: left; + padding: 0; +} +.react-jinke-music-player-mobile-header-right { + color: black; +} +.react-jinke-music-player-mobile > .group { + flex: 0; +} +.react-jinke-music-player-mobile-cover, +.react-jinke-music-player-main.light-theme .react-jinke-music-player-mobile-cover { + border-radius: 5px; + box-shadow: none; + animation: none; + border: 1px solid black; + margin: 0 auto 4rem auto; + width: auto; + height: auto; +} +.react-jinke-music-player-mobile-cover .cover { + animation: none; +} +.react-jinke-music-player-mobile-progress .current-time { + /* margin-right: 17px; */ +} +.react-jinke-music-player-mobile-progress .current-time, .react-jinke-music-player-mobile-progress .duration { + color: black!important; +} +.react-jinke-music-player-mobile-progress .rc-slider { + height: 24px; +} +.react-jinke-music-player-mobile-progress .rc-slider-handle { + border: 2px solid black; + margin-top: -4px; + height: 24px; + width: 24px; +} +.react-jinke-music-player-mobile-toggle { + margin-bottom: 1rem; + padding: 2rem 0; +} +.react-jinke-music-player-mobile-operation .items .item svg { + color: black!important; + font-size: 2rem; + width: 2rem; +} +.react-jinke-music-player-mobile-operation .items .item svg:hover, +.react-jinke-music-player-mobile-operation .items .item button:hover svg { + color: #a8fe40!important; +} +.react-jinke-music-player-mobile-operation .items .item .MuiIconButton-root:hover { + background-color: rgba(0, 0, 0, 0.0); +} +.react-jinke-music-player-mobile-operation .MuiButtonBase-root.Mui-disabled { + cursor: pointer; + pointer-events: auto; +} +.react-jinke-music-player-mobile-operation .items li:nth-child(5) svg { + font-size: 1.4rem; +} +.react-jinke-music-player-mobile-operation .items li:nth-child(5) svg g path:nth-child(2) { + stroke-width: .4px; +} +.react-jinke-music-player-mobile-operation .items li:nth-child(2), +.react-jinke-music-player-mobile-operation .items li:nth-child(3) { + display: none; +} +.react-jinke-music-player-mobile-play-model-tip { + display: none; +} +.audio-lists-panel { + overflow-y: scroll; + scrollbar-width: none; + border-radius: .625rem; + bottom: 6.25rem; +} +.react-jinke-music-player-main.light-theme .audio-lists-panel { + font-family: monospace; + box-shadow: none; + border: 1px solid black; +} +.react-jinke-music-player-main.light-theme .audio-lists-panel-header { + text-shadow: none; + border-bottom: 1px solid black; +} +.audio-lists-panel-header-line { + width: 0; +} +.audio-lists-panel-header-close-btn:hover svg { + animation: none; +} +.audio-lists-panel-content .audio-item, +.react-jinke-music-player-main.light-theme .audio-item { + border-radius: 0px; + margin: 0; + border-bottom: none; + box-shadow: none; + transition: none; +} +.react-jinke-music-player-main.light-theme .audio-lists-panel .audio-lists-panel-content .audio-item:nth-child(2n+1) { + background-color: white!important; +} +.react-jinke-music-player-main.light-theme .audio-lists-panel .audio-lists-panel-content .audio-item:nth-child(2n+1):hover { + background-color: #fafafa!important; +} +.audio-lists-panel-content .audio-item .player-singer { + width: unset; + padding-right: 20px; +} +.audio-lists-panel-content .audio-item .player-delete:hover svg { + color: #a8fe40!important; + animation: none; +} +.react-jinke-music-player-main .audio-lists-panel-content .audio-item:active .group:not([class=".player-delete"]) svg, +.react-jinke-music-player-main .audio-lists-panel-content .audio-item:hover .group:not([class=".player-delete"]) svg, +.react-jinke-music-player-main.light-theme .audio-item:active svg, +.react-jinke-music-player-main.light-theme .audio-item:hover svg { + color: black; +} +.audio-lists-panel-content .audio-item .player-delete { + justify-content: center; + width: 25px; +} +.audio-lists-panel-content .audio-item .player-delete svg { + font-size: 20px; +} +.react-jinke-music-player-main.light-theme .audio-lists-panel .audio-item.playing, .react-jinke-music-player-main.light-theme .audio-lists-panel .audio-item.playing svg { + color: #a8fe40!important; +} +.audio-lists-panel-content .audio-item .player-name, +.audio-lists-panel-content .audio-item .player-singer, +.react-jinke-music-player-main.light-theme .audio-item.playing .player-singer, +.react-jinke-music-player-main.light-theme .audio-lists-panel .audio-item.playing, .react-jinke-music-player-main.light-theme .audio-lists-panel .audio-item.playing .player-delete svg { + color: black!important; +} +.audio-lists-panel-mobile { + height: 750px !important; + top: calc(100vh / 2 - 375px) !important; + width: 91% !important; + margin: 0 auto; +} +.audio-lists-panel-mobile .audio-lists-panel-content { + height: auto!important; +} +.audio-lists-panel-content { + scrollbar-width: none; +} +@keyframes fromOut { + 0% { + transform:scale(1) translateZ(0) + } + to { + transform:scale(1) translate3d(0,150%,0); + } +} +` +export default stylesheet diff --git a/ui/src/themes/nutball.js b/ui/src/themes/nutball.js new file mode 100644 index 000000000..f43250f5c --- /dev/null +++ b/ui/src/themes/nutball.js @@ -0,0 +1,684 @@ +import stylesheet from './nutball.css.js' + +export default { + themeName: 'Nutball', + palette: { + primary: { + main: '#80ea00', + light: '#fff', + }, + secondary: { + main: '#80ea00', + contrastText: '#fff', + }, + }, + typography: { + fontFamily: 'monospace', + h6: { + fontSize: '1rem', + }, + h4: { + fontSize: '1.2rem', + }, + h1: { + fontSize: '1.4rem', + }, + body: { + fontFamily: 'monospace', + }, + }, + overrides: { + MuiAppBar: { + root: { + borderBottom: '1px solid black', + }, + colorSecondary: { + color: 'black', + backgroundColor: 'white', + }, + }, + MuiPaper: { + elevation1: { + boxShadow: 'none', + }, + elevation4: { + boxShadow: 'none', + }, + elevation6: { + boxShadow: 'none', + }, + elevation8: { + boxShadow: 'none', + border: '1px solid black', + }, + elevation16: { + boxShadow: 'none', + borderRight: '1px solid grey!important', + }, + elevation24: { + boxShadow: 'none', + border: '1px solid black', + }, + }, + MuiButton: { + root: { + color: '#80ea00', + border: '1px solid rgba(0, 0, 0, 0.23)', + transition: 'none', + '&[aria-label="Grid"]': { + width: '50%', + marginLeft: '15px', + marginRight: '2px', + marginBottom: '10px', + '& .MuiButton-label': { + justifyContent: 'center', + }, + }, + '&[aria-label="Table"]': { + width: '50%', + marginRight: '15px', + marginLeft: '2px', + marginBottom: '10px', + '& .MuiButton-label': { + justifyContent: 'center', + }, + }, + }, + textPrimary: { + color: 'rgba(0,0,0,.57)', + '&:hover': { + borderColor: 'black', + backgroundColor: '#eaeaea', + }, + '&[aria-label="Grid"]': { + color: 'black', + borderColor: 'black!important', + }, + '&[aria-label="Table"]': { + color: 'black', + borderColor: 'black!important', + }, + }, + textSecondary: { + color: 'rgba(0,0,0,.57)', + '&:hover': { + borderColor: 'black', + backgroundColor: '#eaeaea', + }, + '&[aria-label="Grid"]': { + color: 'grey', + borderColor: 'grey!important', + }, + '&[aria-label="Table"]': { + color: 'grey', + borderColor: 'grey!important', + }, + }, + label: { + '& svg': { + display: 'none', + }, + '& span': { + paddingLeft: '0', + }, + }, + contained: { + boxShadow: 'none', + '&:hover': { + boxShadow: 'none', + }, + }, + }, + MuiButtonGroup: { + groupedTextHorizontal: { + justifyContent: 'flex-start', + margin: '0 .5rem', + '& button': { + width: '25%', + }, + }, + groupedTextPrimary: { + '&:not(:last-child)': { + border: 'none', + }, + }, + }, + MuiIconButton: { + root: { + '&[aria-label="Settings"]': { + padding: '12px!important', + marginRight: '-9px!important', + }, + }, + }, + MuiSwitch: { + thumb: { + color: '#eaeaea', + boxShadow: 'none', + borderRadius: '0', + }, + track: { + borderRadius: '0', + }, + switchBase: { + color: '#eaeaea', + }, + }, + MuiCheckbox: { + root: { + '& svg': { + width: '.8em', + }, + }, + }, + PrivateSwitchBase: { + root: { + padding: '8px 8px', + }, + }, + RaButton: { + button: { + marginRight: '10px', + lineHeight: 'normal', + }, + }, + MuiMenu: { + list: { + '& p': { + fontSize: '.85rem', + }, + '& p:first-of-type': { + margin: '6px 1rem', + }, + '& li:has(span.MuiCheckbox-root)': { + marginLeft: '-10px', + }, + '& span.MuiCheckbox-root .MuiSvgIcon-root': { + width: '.75em', + }, + }, + }, + MuiMenuItem: { + root: { + fontSize: '.85rem', + minHeight: 'inherit', + '&[aria-label="Clear value"]:before': { + display: 'block', + content: "'(any)'", + }, + }, + }, + MuiListItem: { + button: { + '& span.MuiCheckbox-root': { + padding: '0px 8px', + }, + }, + }, + MuiTooltip: { + tooltip: { + backgroundColor: 'rgb(117 117 117)', + }, + }, + MuiCircularProgress: { + root: { + color: '#80ea00!important', + }, + }, + MuiAvatar: { + img: { + borderRadius: '5px', + }, + }, + MuiFab: { + root: { + boxShadow: 'none', + }, + }, + MuiTableHead: { + root: { + boxShadow: 'none!important', + }, + }, + MuiTableCell: { + root: { + borderBottom: 'none', + }, + sizeSmall: { + '&:last-child': { + textAlign: 'right', + }, + '&:last-child:is(th)': { + paddingRight: '45px', + }, + }, + }, + MuiTablePagination: { + root: { + fontSize: '.6rem', + }, + caption: { + fontSize: '.6rem', + }, + menuItem: { + fontSize: '.6rem', + }, + }, + MuiTabs: { + root: { + marginBottom: '1rem', + }, + }, + MuiToolbar: { + gutters: { + '@media (min-width: 600px)': { + paddingLeft: '16px', + }, + }, + }, + RaListToolbar: { + toolbar: { + alignItems: 'start', + '& form:has(> div:nth-child(3))': { + paddingBottom: '.6rem', + }, + }, + actions: { + paddingRight: '0!important', + marginTop: '-8px', + textWrap: 'nowrap', + '@media (max-width: 599.95px)': { + marginTop: '3px', + }, + '& .MuiButton-text': { + height: '2.5rem', + padding: '7px 10px', + marginRight: '0', + }, + }, + }, + RaTopToolbar: { + root: { + '& div:first-of-type > div:first-of-type': { + display: 'flex', + flexWrap: 'wrap', + rowGap: '10px', + }, + '& div:first-of-type > div:first-of-type button': { + height: '2rem', + }, + '& div:first-of-type > div:first-of-type .MuiIconButton-root': { + padding: '0', + marginRight: '2rem', + }, + '& div:first-of-type > div:nth-of-type(2)': { + height: '2rem', + }, + }, + }, + RaToolbar: { + toolbar: { + backgroundColor: 'white', + }, + }, + RaFilterButton: { + root: { + textWrap: 'nowrap', + '& button': { + '@media (max-width: 599.95px)': { + padding: '12px', + }, + }, + "&[resource*='song']": { + marginLeft: '10px', + }, + }, + }, + RaDeleteWithUndoButton: { + deleteButton: { + color: 'rgba(0,0,0,.57)', + '&:hover': { + backgroundColor: 'rgba(0, 0, 0, 0.04)', + }, + }, + }, + RaAutocompleteSuggestionList: { + suggestionsContainer: { + borderRadius: '4px', + outline: '1px solid black', + backgroundColor: 'white', + }, + }, + RaEmpty: { + message: { + marginTop: '3rem', + }, + icon: { + display: 'none', + }, + }, + RaAutocompleteArrayInput: { + chipContainerOutlined: { + '&:empty': { + margin: '0', + }, + margin: '10px 0', + }, + chip: { + margin: '4px 4px 4px 0!important', + }, + inputInput: { + flexGrow: '0', + '& #genre_id': { + flexGrow: '0', + }, + }, + }, + RaLayout: { + content: { + width: '100%', + }, + }, + RaDatagrid: { + headerCell: { + fontWeight: 'bold', + }, + }, + NDAlbumShow: { + albumActions: { + padding: '0', + alignItems: 'center', + margin: '1rem 0', + }, + }, + MuiCardContent: { + root: { + fontFamily: 'monospace', + fontSize: '.8rem', + '& #now-playing-title': { + fontSize: '.8rem', + }, + '&:last-child': { + paddingBottom: '16px', + }, + '&[class*="makeStyles-usernameWrap-"]': { + paddingBottom: '16px', + }, + }, + }, + MuiDialogContent: { + root: { + '& .MuiTableCell-sizeSmall:last-child': { + textAlign: 'left', + }, + }, + }, + MuiGridList: { + root: { + '&:empty': { + display: 'none', + }, + backgroundColor: 'white', + borderRadius: '4px', + }, + }, + MuiGridListTile: { + root: { + '@media (max-width: 599.95px)': { + padding: '7px!important', + }, + }, + tile: { + '& img': { + borderRadius: '5px', + }, + }, + }, + NDAlbumGridView: { + root: { + '&:has(.MuiGridList-root:empty)': { + display: 'none', + }, + }, + albumContainer: { + border: '1px solid white', + borderRadius: '5px', + '& a:hover img': { + outline: '1px solid black', + }, + '& a:hover > div:nth-of-type(2)': { + border: 'none', + outline: '1px solid black', + }, + }, + albumLink: { + paddingRight: '6px', + }, + albumSubtitle: { + fontFamily: 'monospace', + }, + tileBar: { + transition: 'all 50ms ease-out', + }, + tileBarMobile: { + transition: 'all 50ms ease-out', + borderLeft: '1px solid black', + borderRight: '1px solid black', + borderBottom: '1px solid black', + }, + }, + MuiGridListTileBar: { + root: { + height: '30px!important', + background: 'white!important', + borderTop: '1px solid black', + borderBottom: '1px solid black', + borderRadius: '0 0 5px 5px', + }, + titleWrap: { + marginLeft: '0px', + }, + titlePositionBottom: { + bottom: '0', + }, + subtitle: { + '& button': { + color: 'black!important', + }, + }, + actionIcon: { + '& button': { + color: 'black!important', + }, + }, + }, + RaFilter: { + form: { + width: '100%', + '& div.filter-field:first-child': { + flex: '1 100%', + '& [class*="RaSearchInput-input-"]': { + width: '100%', + }, + }, + }, + }, + MuiInputAdornment: { + positionEnd: { + justifyContent: 'flex-end', + }, + }, + RaFilterFormInput: { + body: { + '& label': { + transform: 'translate(14px, -6px) scale(0.75)!important', + backgroundColor: '#fafafa', + padding: '0 5px', + }, + }, + hideButton: { + order: '1', + marginLeft: '2px', + top: '-7px', + padding: '8px', + }, + spacer: { + order: '2', + }, + }, + RaPaginationActions: { + actions: { + '& button': { + border: 'none', + fontSize: '.6rem', + }, + }, + }, + NDAlbumDetails: { + cover: { + borderRadius: '5px', + }, + content: { + padding: '0', + marginLeft: '1rem', + }, + externalLinks: { + marginTop: '5px', + }, + notes: { + display: 'none', + }, + root: { + '& p': { + fontSize: '.7rem', + backgroundColor: '#e0e0e0', + borderRadius: '10px', + width: 'fit-content', + padding: '2px 7px', + }, + }, + }, + NDPlaylistDetails: { + cover: { + borderRadius: '5px', + }, + }, + NDDesktopArtistDetails: { + cover: { + borderRadius: '0px', + }, + }, + NDMobileArtistDetails: { + bgContainer: { + background: + 'linear-gradient(to bottom, rgb(255 255 255 / 51%), rgb(250 250 250))!important', + }, + }, + NDArtistShow: { + actionsContainer: { + '& button': { + padding: '4px 5px', + fontSize: '0.8125rem', + height: '2rem', + }, + }, + }, + NDAudioPlayer: { + audioTitle: { + color: 'black', + }, + }, + NDLogin: { + main: { + background: 'white', + '& .MuiFormLabel-root': { + color: '#000', + }, + '& .MuiFormLabel-root.Mui-error': { + color: '#000', + }, + '& .MuiInput-underline:before': { + borderBottom: 'none', + }, + '& .MuiInput-underline:after': { + borderBottom: 'none', + }, + '& .MuiFormHelperText-root.Mui-error': { + color: '#000', + paddingLeft: '10px', + }, + '& .MuiInput-underline:hover:not(.Mui-disabled):before': { + borderBottom: 'none', + }, + }, + card: { + minWidth: 300, + marginTop: '6em', + backgroundColor: '#ffffffe6', + border: '1px solid black', + }, + avatar: { + marginTop: '1rem', + '& img': { + filter: 'invert(1)', + }, + }, + icon: {}, + input: { + '& .MuiInput-root': { + border: '1px solid black', + borderRadius: '4px', + padding: '10px', + }, + '& .MuiInputLabel-root': { + padding: '10px', + }, + '& .MuiInputLabel-shrink': { + transform: 'translate(0, -5.5px) scale(0.75)', + }, + }, + actions: { + marginTop: '2rem', + }, + button: { + boxShadow: 'none', + '&:hover': { + boxShadow: 'none', + backgroundColor: 'rgb(117, 177, 44)', + }, + }, + systemNameLink: { + fontFamily: 'monospace', + marginBottom: '1rem', + color: 'black', + '&:before': { + content: "'Welcome to '", + }, + '&:after': { + content: "' *~*!'", + }, + }, + }, + MuiCssBaseline: { + '@global': { + '*::-webkit-scrollbar': { + display: 'none', + }, + }, + }, + MuiBackdrop: { + root: { + backgroundColor: 'rgba(255, 255, 255, 0.5)', + }, + }, + RaLoading: { + message: { + fontFamily: 'monospace', + }, + }, + }, + player: { + theme: 'light', + stylesheet, + }, +}