From b25b58c3194da5eff3ea2c0ddcd615fd79459907 Mon Sep 17 00:00:00 2001 From: zapisanchez Date: Wed, 26 Aug 2026 23:35:11 +0200 Subject: [PATCH] feat(ui): mark smart playlists with an icon in the sidebar and list Nothing in the web UI told a smart playlist (.nsp) apart from one whose tracks were picked by hand. The sidebar now gives every playlist entry a left icon, sparkles for smart ones and the plain playlist glyph otherwise, and the playlist list flags smart ones next to their name. The REST API already returns the playlist's rules, so this is UI-only. Discussion: https://github.com/navidrome/navidrome/discussions/6040 Signed-off-by: zapisanchez --- ui/src/common/PlaylistTypeIcon.jsx | 44 +++++++++++++++++++++++++ ui/src/common/PlaylistTypeIcon.test.jsx | 42 +++++++++++++++++++++++ ui/src/common/index.js | 1 + ui/src/i18n/en.json | 3 +- ui/src/layout/PlaylistsSubMenu.jsx | 17 +++++++++- ui/src/layout/PlaylistsSubMenu.test.jsx | 18 +++++++++- ui/src/playlist/PlaylistList.jsx | 41 +++++++++++++++++++++-- ui/src/playlist/PlaylistList.test.jsx | 32 +++++++++++++++++- 8 files changed, 192 insertions(+), 6 deletions(-) create mode 100644 ui/src/common/PlaylistTypeIcon.jsx create mode 100644 ui/src/common/PlaylistTypeIcon.test.jsx diff --git a/ui/src/common/PlaylistTypeIcon.jsx b/ui/src/common/PlaylistTypeIcon.jsx new file mode 100644 index 000000000..dd3b2f5ee --- /dev/null +++ b/ui/src/common/PlaylistTypeIcon.jsx @@ -0,0 +1,44 @@ +import React from 'react' +import SvgIcon from '@material-ui/core/SvgIcon' +import PlaylistPlayIcon from '@material-ui/icons/PlaylistPlay' +import { MdOutlineAutoAwesome } from 'react-icons/md' +import { useTranslate } from 'react-admin' +import { isSmartPlaylist } from './playlistUtils' + +// Both icons name themselves: MenuItemLink clones its leftIcon with +// titleAccess=primaryText, which for a playlist entry is a node, not a string. +// Neither holds a ref (react-icons components don't accept one), so wrap them +// in an element of your own to hang a Tooltip off. + +export const SmartPlaylistIcon = (props) => { + const translate = useTranslate() + const label = translate('resources.playlist.message.smartPlaylist') + return ( + it builds + // from titleAccess, so pass its own `title` prop too. titleAccess still + // earns the icon its role="img". + title={label} + titleAccess={label} + /> + ) +} + +export const RegularPlaylistIcon = (props) => { + const translate = useTranslate() + return ( + + ) +} + +export const PlaylistTypeIcon = ({ record, ...props }) => + isSmartPlaylist(record) ? ( + + ) : ( + + ) diff --git a/ui/src/common/PlaylistTypeIcon.test.jsx b/ui/src/common/PlaylistTypeIcon.test.jsx new file mode 100644 index 000000000..d2cd17516 --- /dev/null +++ b/ui/src/common/PlaylistTypeIcon.test.jsx @@ -0,0 +1,42 @@ +import React from 'react' +import { render, screen } from '@testing-library/react' +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { PlaylistTypeIcon } from './PlaylistTypeIcon' + +vi.mock('react-admin', () => ({ + useTranslate: () => (key) => key, +})) + +describe('', () => { + beforeEach(() => { + localStorage.clear() + }) + + it('marks a playlist with rules as smart', () => { + render() + expect( + screen.getByTitle('resources.playlist.message.smartPlaylist'), + ).not.toBeNull() + }) + + it('uses the plain playlist icon when there are no rules', () => { + render() + expect(screen.getByTitle('resources.playlist.name')).not.toBeNull() + expect( + screen.queryByTitle('resources.playlist.message.smartPlaylist'), + ).toBeNull() + }) + + it('names the smart icon for screen readers', () => { + // react-icons discards the SvgIcon builds from titleAccess, so the + // icon has to pass react-icons' own `title` prop as well + const { container } = render( + <PlaylistTypeIcon record={{ id: 'pl-3', rules: { all: [] } }} />, + ) + const svg = container.querySelector('svg') + expect(svg.getAttribute('role')).toBe('img') + expect(svg.querySelector('title').textContent).toBe( + 'resources.playlist.message.smartPlaylist', + ) + }) +}) diff --git a/ui/src/common/index.js b/ui/src/common/index.js index 047f5b3cf..330bcfac3 100644 --- a/ui/src/common/index.js +++ b/ui/src/common/index.js @@ -50,3 +50,4 @@ export * from './ImageUploadOverlay' export * from './ArtworkAvatar' export * from './useImageLoadingState' export * from './useImageUrl' +export * from './PlaylistTypeIcon' diff --git a/ui/src/i18n/en.json b/ui/src/i18n/en.json index 8823a6749..e86ba0aca 100644 --- a/ui/src/i18n/en.json +++ b/ui/src/i18n/en.json @@ -232,7 +232,8 @@ "duplicate_song": "Add duplicated songs", "song_exist": "There are duplicates being added to the playlist. Would you like to add the duplicates or skip them?", "noPlaylistsFound": "No playlists found", - "noPlaylists": "No playlists available" + "noPlaylists": "No playlists available", + "smartPlaylist": "Smart playlist" } }, "radio": { diff --git a/ui/src/layout/PlaylistsSubMenu.jsx b/ui/src/layout/PlaylistsSubMenu.jsx index f332f6810..1195c16ae 100644 --- a/ui/src/layout/PlaylistsSubMenu.jsx +++ b/ui/src/layout/PlaylistsSubMenu.jsx @@ -10,18 +10,31 @@ import { import { useHistory } from 'react-router-dom' import QueueMusicIcon from '@material-ui/icons/QueueMusic' import { Typography } from '@material-ui/core' +import { makeStyles } from '@material-ui/core/styles' import QueueMusicOutlinedIcon from '@material-ui/icons/QueueMusicOutlined' import FavoriteIcon from '@material-ui/icons/Favorite' import FavoriteBorderIcon from '@material-ui/icons/FavoriteBorder' import { BiListUl } from 'react-icons/bi' import { useDrop } from 'react-dnd' import SubMenu from './SubMenu' -import { canChangeTracks, OverflowTooltip, useRefreshOnEvents } from '../common' +import { + canChangeTracks, + OverflowTooltip, + PlaylistTypeIcon, + useRefreshOnEvents, +} from '../common' import { DraggableTypes } from '../consts' import { setSidebarPlaylistsOnlyFavourites } from '../actions' import config from '../config' +const useStyles = makeStyles((theme) => ({ + // Narrower than MenuItemLink's default slot: these items are already indented + // by SubMenu, and the names need the horizontal room more than the icons do + icon: { minWidth: theme.spacing(4) }, +})) + const PlaylistMenuItemLink = ({ pls, sidebarIsOpen }) => { + const classes = useStyles() const dataProvider = useDataProvider() const notify = useNotify() @@ -50,6 +63,8 @@ const PlaylistMenuItemLink = ({ pls, sidebarIsOpen }) => { </Typography> </OverflowTooltip> } + leftIcon={<PlaylistTypeIcon record={pls} fontSize={'small'} />} + classes={{ icon: classes.icon }} sidebarIsOpen={sidebarIsOpen} dense={false} /> diff --git a/ui/src/layout/PlaylistsSubMenu.test.jsx b/ui/src/layout/PlaylistsSubMenu.test.jsx index 617f60a4e..95cbcb304 100644 --- a/ui/src/layout/PlaylistsSubMenu.test.jsx +++ b/ui/src/layout/PlaylistsSubMenu.test.jsx @@ -36,13 +36,19 @@ vi.mock('react-admin', async (importOriginal) => { useDataProvider: () => ({ addToPlaylist: vi.fn() }), useNotify: () => vi.fn(), useQueryWithStore: (query) => mockUseQueryWithStore(query), - MenuItemLink: ({ primaryText }) => <div>{primaryText}</div>, + MenuItemLink: ({ primaryText, leftIcon }) => ( + <div> + {leftIcon} + {primaryText} + </div> + ), } }) const playlists = { 'pl-1': { id: 'pl-1', name: 'Mine', ownerId: 'user-1' }, 'pl-2': { id: 'pl-2', name: 'Theirs', ownerId: 'user-2' }, + 'pl-3': { id: 'pl-3', name: 'Smart', ownerId: 'user-1', rules: { all: [] } }, } const SET_PLAYLIST_DATA = 'TEST/SET_PLAYLIST_DATA' @@ -155,6 +161,16 @@ describe('<PlaylistsSubMenu />', () => { expect(JSON.stringify(lastQuery().payload)).toBe(before) }) + it('marks smart playlists with their own icon', () => { + renderMenu() + // useTranslate is mocked to echo the key + const smart = screen.getAllByTitle( + 'resources.playlist.message.smartPlaylist', + ) + expect(smart.length).toBe(1) + expect(screen.getAllByTitle('resources.playlist.name').length).toBe(2) + }) + it('refetches when a playlist is starred locally (no SSE echo)', () => { const store = renderMenu( { sidebarPlaylistsOnlyFavourites: true }, diff --git a/ui/src/playlist/PlaylistList.jsx b/ui/src/playlist/PlaylistList.jsx index d2b17b108..6f136263b 100644 --- a/ui/src/playlist/PlaylistList.jsx +++ b/ui/src/playlist/PlaylistList.jsx @@ -15,16 +15,19 @@ import { useRecordContext, BulkDeleteButton, usePermissions, + useTranslate, } from 'react-admin' import Switch from '@material-ui/core/Switch' import { makeStyles } from '@material-ui/core/styles' -import { useMediaQuery } from '@material-ui/core' +import { Tooltip, useMediaQuery } from '@material-ui/core' import { ArtworkAvatar, DurationField, List, LoveButton, + SmartPlaylistIcon, Writable, + isSmartPlaylist, isWritable, useSelectedFields, useResourceRefresh, @@ -38,6 +41,13 @@ const useStyles = makeStyles((theme) => ({ button: { color: theme.palette.type === 'dark' ? 'white' : undefined, }, + smartIcon: { + display: 'inline-flex', + color: theme.palette.text.secondary, + marginRight: theme.spacing(0.75), + // Inline (not flex) so a name that wraps keeps the icon on its first line + verticalAlign: 'text-bottom', + }, })) const PlaylistFilter = (props) => { @@ -149,6 +159,33 @@ const PlaylistListBulkActions = (props) => { ) } +// Marks smart playlists (.nsp) in the name column, the only place in the list +// that tells them apart from playlists whose tracks the user picked by hand. +export const PlaylistNameField = ({ record }) => { + const classes = useStyles() + const translate = useTranslate() + if (!record) { + return null + } + return ( + <span> + {isSmartPlaylist(record) && ( + <Tooltip + title={translate('resources.playlist.message.smartPlaylist')} + placement={'top'} + > + {/* Tooltip needs a child that holds a ref; the icon does not */} + <span className={classes.smartIcon}> + <SmartPlaylistIcon fontSize={'small'} /> + </span> + </Tooltip> + )} + {record.name} + </span> + ) +} +PlaylistNameField.defaultProps = { source: 'name' } + // Datagrid reads `source`/`sortable`/`label` off this element for the column // header; only record/resource are forwarded so they never leak onto the button. export const PlaylistLove = ({ record, className }) => ( @@ -198,7 +235,7 @@ const PlaylistList = (props) => { > <Datagrid rowClick="show" isRowSelectable={(r) => isWritable(r?.ownerId)}> <ArtworkAvatar source="id" variant="square" /> - <TextField source="name" /> + <PlaylistNameField /> {columns} <Writable> <EditButton /> diff --git a/ui/src/playlist/PlaylistList.test.jsx b/ui/src/playlist/PlaylistList.test.jsx index 4fbc6d516..8e882804f 100644 --- a/ui/src/playlist/PlaylistList.test.jsx +++ b/ui/src/playlist/PlaylistList.test.jsx @@ -1,7 +1,7 @@ import React from 'react' import { render, screen } from '@testing-library/react' import { describe, it, expect, vi } from 'vitest' -import { PlaylistLove } from './PlaylistList' +import { PlaylistLove, PlaylistNameField } from './PlaylistList' vi.mock('../config', () => ({ default: { enableFavourites: true }, @@ -13,6 +13,9 @@ vi.mock('../common', () => ({ {record?.starred ? 'starred' : 'not-starred'} </button> ), + SmartPlaylistIcon: () => <span data-testid="smart-icon" />, + isSmartPlaylist: (pls) => !!pls.rules, + isWritable: () => true, })) describe('<PlaylistLove />', () => { @@ -32,3 +35,30 @@ describe('<PlaylistLove />', () => { }) }) }) + +describe('<PlaylistNameField />', () => { + it('flags a smart playlist next to its name', () => { + render( + <PlaylistNameField + record={{ id: 'pl-1', name: 'Top Rock', rules: { all: [] } }} + />, + ) + expect(screen.getByText('Top Rock')).not.toBeNull() + expect(screen.getByTestId('smart-icon')).not.toBeNull() + }) + + it('shows no flag for a hand-picked playlist', () => { + render(<PlaylistNameField record={{ id: 'pl-2', name: 'Road Trip' }} />) + expect(screen.getByText('Road Trip')).not.toBeNull() + expect(screen.queryByTestId('smart-icon')).toBeNull() + }) + + it('renders nothing without a record', () => { + const { container } = render(<PlaylistNameField />) + expect(container.innerHTML).toBe('') + }) + + it('exposes the source so the datagrid keeps a sortable Name column', () => { + expect(PlaylistNameField.defaultProps).toEqual({ source: 'name' }) + }) +})