feat(ui) add Save Queue to Playlist (#4110)

* ui: add save queue to playlist

* fix(ui): improve toolbar layout

Signed-off-by: Deluan <deluan@navidrome.org>

* fix(ui): add loading state to save queue dialog

Signed-off-by: Deluan <deluan@navidrome.org>

* fix(ui): refresh playlist after saving queue

Signed-off-by: Deluan <deluan@navidrome.org>

* fix lint

Signed-off-by: Deluan <deluan@navidrome.org>

* remove duplication in PlayerToolbar and add tests

Signed-off-by: Deluan <deluan@navidrome.org>

* fix(i18n): update save queue text for clarity in English and Portuguese

Signed-off-by: Deluan <deluan@navidrome.org>

---------

Signed-off-by: Deluan <deluan@navidrome.org>
This commit is contained in:
Deluan Quintão
2025-05-23 22:04:18 -04:00
committed by GitHub
parent 370f8ba293
commit 514aceb785
12 changed files with 510 additions and 11 deletions

View File

@@ -191,6 +191,7 @@
"selectPlaylist": "Selecione a playlist:",
"addNewPlaylist": "Criar \"%{name}\"",
"export": "Exportar",
"saveQueue": "Salvar fila em nova Playlist",
"makePublic": "Pública",
"makePrivate": "Pessoal"
},

View File

@@ -22,6 +22,7 @@ import {
addToPlaylistDialogReducer,
expandInfoDialogReducer,
listenBrainzTokenDialogReducer,
saveQueueDialogReducer,
playerReducer,
albumViewReducer,
activityReducer,
@@ -62,6 +63,7 @@ const adminStore = createAdminStore({
downloadMenuDialog: downloadMenuDialogReducer,
expandInfoDialog: expandInfoDialogReducer,
listenBrainzTokenDialog: listenBrainzTokenDialogReducer,
saveQueueDialog: saveQueueDialogReducer,
shareDialog: shareDialogReducer,
activity: activityReducer,
settings: settingsReducer,

View File

@@ -8,6 +8,8 @@ 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'
@@ -76,3 +78,11 @@ export const openListenBrainzTokenDialog = () => ({
export const closeListenBrainzTokenDialog = () => ({
type: LISTENBRAINZ_TOKEN_CLOSE,
})
export const openSaveQueueDialog = () => ({
type: SAVE_QUEUE_OPEN,
})
export const closeSaveQueueDialog = () => ({
type: SAVE_QUEUE_CLOSE,
})

View File

@@ -1,32 +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 Placeholder = () => <LoveButton disabled={true} resource={'song'} />
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 Toolbar = ({ id }) => {
const PlayerToolbar = ({ id, isRadio }) => {
const dispatch = useDispatch()
const { data, loading } = useGetOne('song', id)
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 />
<LoveButton
record={data}
resource={'song'}
disabled={loading || toggling}
/>
{isDesktop ? (
<li className={`${listItemClass} item`}>
{saveQueueButton}
{loveButton}
</li>
) : (
<>
<li className={`${listItemClass} item`}>{saveQueueButton}</li>
<li className={`${listItemClass} item`}>{loveButton}</li>
</>
)}
</>
)
}
const PlayerToolbar = ({ id, isRadio }) =>
id && !isRadio ? <Toolbar id={id} /> : <Placeholder />
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

@@ -25,7 +25,10 @@ export const CollapsibleComment = ({ record }) => {
const classes = useStyles()
const [expanded, setExpanded] = useState(false)
const lines = record.comment.split('\n')
const lines = useMemo(
() => record.comment?.split('\n') || [],
[record.comment],
)
const formatted = useMemo(() => {
return lines.map((line, idx) => (
<span key={record.id + '-comment-' + idx}>

View File

@@ -2,10 +2,12 @@ import { AddToPlaylistDialog } from './AddToPlaylistDialog'
import DownloadMenuDialog from './DownloadMenuDialog'
import { HelpDialog } from './HelpDialog'
import { ShareDialog } from './ShareDialog'
import { SaveQueueDialog } from './SaveQueueDialog'
export const Dialogs = (props) => (
<>
<AddToPlaylistDialog />
<SaveQueueDialog />
<DownloadMenuDialog />
<HelpDialog />
<ShareDialog />

View File

@@ -0,0 +1,117 @@
import React, { useState, useCallback } from 'react'
import { useDispatch, useSelector } from 'react-redux'
import {
useDataProvider,
useNotify,
useTranslate,
useRefresh,
} from 'react-admin'
import {
Button,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
TextField,
CircularProgress,
} from '@material-ui/core'
import { closeSaveQueueDialog } from '../actions'
import { useHistory } from 'react-router-dom'
export const SaveQueueDialog = () => {
const dispatch = useDispatch()
const { open } = useSelector((state) => state.saveQueueDialog)
const queue = useSelector((state) => state.player.queue)
const [name, setName] = useState('')
const dataProvider = useDataProvider()
const notify = useNotify()
const translate = useTranslate()
const history = useHistory()
const [isSaving, setIsSaving] = useState(false)
const refresh = useRefresh()
const handleClose = useCallback(
(e) => {
setName('')
dispatch(closeSaveQueueDialog())
e.stopPropagation()
},
[dispatch],
)
const handleSave = useCallback(() => {
setIsSaving(true)
const ids = queue.map((item) => item.trackId)
dataProvider
.create('playlist', { data: { name } })
.then((res) => {
const playlistId = res.data.id
if (ids.length) {
return dataProvider
.create('playlistTrack', {
data: { ids },
filter: { playlist_id: playlistId },
})
.then(() => res)
}
return res
})
.then((res) => {
notify('ra.notification.created', 'info', { smart_count: 1 })
dispatch(closeSaveQueueDialog())
refresh()
history.push(`/playlist/${res.data.id}/show`)
})
.catch(() => notify('ra.page.error', { type: 'warning' }))
.finally(() => setIsSaving(false))
}, [dataProvider, dispatch, notify, queue, name, history, refresh])
const handleKeyPress = useCallback(
(e) => {
if (e.key === 'Enter' && name.trim() !== '') {
handleSave()
}
},
[handleSave, name],
)
return (
<Dialog
open={open}
onClose={isSaving ? undefined : handleClose}
aria-labelledby="save-queue-dialog"
fullWidth={true}
maxWidth={'sm'}
>
<DialogTitle id="save-queue-dialog">
{translate('resources.playlist.actions.saveQueue', { _: 'Save Queue' })}
</DialogTitle>
<DialogContent>
<TextField
value={name}
onChange={(e) => setName(e.target.value)}
onKeyPress={handleKeyPress}
autoFocus
fullWidth
variant={'outlined'}
label={translate('resources.playlist.fields.name')}
disabled={isSaving}
/>
</DialogContent>
<DialogActions>
<Button onClick={handleClose} color="primary" disabled={isSaving}>
{translate('ra.action.cancel')}
</Button>
<Button
onClick={handleSave}
color="primary"
disabled={name.trim() === '' || isSaving}
data-testid="save-queue-save"
startIcon={isSaving ? <CircularProgress size={20} /> : null}
>
{translate('ra.action.save')}
</Button>
</DialogActions>
</Dialog>
)
}

View File

@@ -0,0 +1,91 @@
import * as React from 'react'
import { TestContext } from 'ra-test'
import { DataProviderContext } from 'react-admin'
import {
cleanup,
fireEvent,
render,
waitFor,
screen,
} from '@testing-library/react'
import { SaveQueueDialog } from './SaveQueueDialog'
import { describe, afterEach, it, expect, vi, beforeAll } from 'vitest'
const queue = [{ trackId: 'song-1' }, { trackId: 'song-2' }]
const createTestUtils = (mockDataProvider) =>
render(
<DataProviderContext.Provider value={mockDataProvider}>
<TestContext
initialState={{
saveQueueDialog: { open: true },
player: { queue },
admin: { ui: { optimistic: false } },
}}
>
<SaveQueueDialog />
</TestContext>
</DataProviderContext.Provider>,
)
// Mock useHistory to update window.location.hash on push
vi.mock('react-router-dom', async (importOriginal) => {
const actual = await importOriginal()
return {
...actual,
useHistory: () => ({
push: (url) => {
window.location.hash = `#${url}`
},
}),
}
})
beforeAll(() => {
// No need to patch pushState anymore
})
describe('SaveQueueDialog', () => {
afterEach(cleanup)
it('creates playlist and saves queue', async () => {
const mockDataProvider = {
create: vi
.fn()
.mockResolvedValueOnce({ data: { id: 'created-id' } })
.mockResolvedValueOnce({ data: { id: 'pt-id' } }),
}
createTestUtils(mockDataProvider)
fireEvent.change(screen.getByRole('textbox'), {
target: { value: 'my playlist' },
})
fireEvent.click(screen.getByTestId('save-queue-save'))
await waitFor(() => {
expect(mockDataProvider.create).toHaveBeenNthCalledWith(1, 'playlist', {
data: { name: 'my playlist' },
})
})
await waitFor(() => {
expect(mockDataProvider.create).toHaveBeenNthCalledWith(
2,
'playlistTrack',
{
data: { ids: ['song-1', 'song-2'] },
filter: { playlist_id: 'created-id' },
},
)
})
await waitFor(() => {
expect(window.location.hash).toBe('#/playlist/created-id/show')
})
})
it('disables save button when name is empty', () => {
const mockDataProvider = { create: vi.fn() }
createTestUtils(mockDataProvider)
expect(screen.getByTestId('save-queue-save')).toBeDisabled()
})
})

View File

@@ -1,4 +1,5 @@
export * from './AboutDialog'
export * from './SelectPlaylistInput'
export * from './ListenBrainzTokenDialog'
export * from './SaveQueueDialog'
export * from './Dialogs'

View File

@@ -192,6 +192,7 @@
"selectPlaylist": "Select a playlist:",
"addNewPlaylist": "Create \"%{name}\"",
"export": "Export",
"saveQueue": "Save Queue to Playlist",
"makePublic": "Make Public",
"makePrivate": "Make Private"
},

View File

@@ -13,6 +13,8 @@ import {
EXTENDED_INFO_CLOSE,
LISTENBRAINZ_TOKEN_OPEN,
LISTENBRAINZ_TOKEN_CLOSE,
SAVE_QUEUE_OPEN,
SAVE_QUEUE_CLOSE,
SHARE_MENU_OPEN,
SHARE_MENU_CLOSE,
} from '../actions'
@@ -169,3 +171,18 @@ export const listenBrainzTokenDialogReducer = (
return previousState
}
}
export const saveQueueDialogReducer = (
previousState = { open: false },
payload,
) => {
const { type } = payload
switch (type) {
case SAVE_QUEUE_OPEN:
return { ...previousState, open: true }
case SAVE_QUEUE_CLOSE:
return { ...previousState, open: false }
default:
return previousState
}
}