diff --git a/resources/i18n/pt-br.json b/resources/i18n/pt-br.json
index 974a628f1..4b240bcff 100644
--- a/resources/i18n/pt-br.json
+++ b/resources/i18n/pt-br.json
@@ -191,6 +191,7 @@
"selectPlaylist": "Selecione a playlist:",
"addNewPlaylist": "Criar \"%{name}\"",
"export": "Exportar",
+ "saveQueue": "Salvar fila em nova Playlist",
"makePublic": "Pública",
"makePrivate": "Pessoal"
},
diff --git a/ui/src/App.jsx b/ui/src/App.jsx
index a3a34a5f3..1b89f7b8c 100644
--- a/ui/src/App.jsx
+++ b/ui/src/App.jsx
@@ -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,
diff --git a/ui/src/actions/dialogs.js b/ui/src/actions/dialogs.js
index 8fac6076a..dea4d6203 100644
--- a/ui/src/actions/dialogs.js
+++ b/ui/src/actions/dialogs.js
@@ -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,
+})
diff --git a/ui/src/audioplayer/PlayerToolbar.jsx b/ui/src/audioplayer/PlayerToolbar.jsx
index d3645f5b0..82f9e36f3 100644
--- a/ui/src/audioplayer/PlayerToolbar.jsx
+++ b/ui/src/audioplayer/PlayerToolbar.jsx
@@ -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 = () =>
+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 = (
+
+
+
+ )
+
+ const loveButton = (
+
+ )
+
return (
<>
-
+ {isDesktop ? (
+
+ {saveQueueButton}
+ {loveButton}
+
+ ) : (
+ <>
+ {saveQueueButton}
+ {loveButton}
+ >
+ )}
>
)
}
-const PlayerToolbar = ({ id, isRadio }) =>
- id && !isRadio ? :
-
export default PlayerToolbar
diff --git a/ui/src/audioplayer/PlayerToolbar.test.jsx b/ui/src/audioplayer/PlayerToolbar.test.jsx
new file mode 100644
index 000000000..d0368b0f0
--- /dev/null
+++ b/ui/src/audioplayer/PlayerToolbar.test.jsx
@@ -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 }) => (
+
+ ),
+ useToggleLove: vi.fn(),
+}))
+
+vi.mock('../actions', () => ({
+ openSaveQueueDialog: vi.fn(),
+}))
+
+vi.mock('react-hotkeys', () => ({
+ GlobalHotKeys: () => ,
+}))
+
+describe('', () => {
+ 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()
+
+ // 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()
+
+ 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()
+
+ const loveButton = screen.getByTestId('love-button')
+ expect(loveButton).toBeDisabled()
+ })
+
+ it('opens save queue dialog when save button is clicked', () => {
+ render()
+
+ 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()
+
+ // 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()
+
+ 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()
+
+ 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()
+ expect(screen.getByTestId('global-hotkeys')).toBeInTheDocument()
+
+ // Cleanup and test mobile layout
+ cleanup()
+ useMediaQuery.mockReturnValue(false)
+ render()
+ expect(screen.getByTestId('global-hotkeys')).toBeInTheDocument()
+ })
+
+ it('disables buttons when id is not provided', () => {
+ render()
+
+ const loveButton = screen.getByTestId('love-button')
+ expect(loveButton).toBeDisabled()
+ })
+ })
+})
diff --git a/ui/src/common/CollapsibleComment.jsx b/ui/src/common/CollapsibleComment.jsx
index 8660d0492..77750e7f4 100644
--- a/ui/src/common/CollapsibleComment.jsx
+++ b/ui/src/common/CollapsibleComment.jsx
@@ -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) => (
diff --git a/ui/src/dialogs/Dialogs.jsx b/ui/src/dialogs/Dialogs.jsx
index 74f5a094d..f37c55823 100644
--- a/ui/src/dialogs/Dialogs.jsx
+++ b/ui/src/dialogs/Dialogs.jsx
@@ -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) => (
<>
+
diff --git a/ui/src/dialogs/SaveQueueDialog.jsx b/ui/src/dialogs/SaveQueueDialog.jsx
new file mode 100644
index 000000000..69f07dab7
--- /dev/null
+++ b/ui/src/dialogs/SaveQueueDialog.jsx
@@ -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 (
+
+ )
+}
diff --git a/ui/src/dialogs/SaveQueueDialog.test.jsx b/ui/src/dialogs/SaveQueueDialog.test.jsx
new file mode 100644
index 000000000..c3a91385f
--- /dev/null
+++ b/ui/src/dialogs/SaveQueueDialog.test.jsx
@@ -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(
+
+
+
+
+ ,
+ )
+
+// 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()
+ })
+})
diff --git a/ui/src/dialogs/index.js b/ui/src/dialogs/index.js
index 7f71529f8..86586aef0 100644
--- a/ui/src/dialogs/index.js
+++ b/ui/src/dialogs/index.js
@@ -1,4 +1,5 @@
export * from './AboutDialog'
export * from './SelectPlaylistInput'
export * from './ListenBrainzTokenDialog'
+export * from './SaveQueueDialog'
export * from './Dialogs'
diff --git a/ui/src/i18n/en.json b/ui/src/i18n/en.json
index ab8712152..64b264e45 100644
--- a/ui/src/i18n/en.json
+++ b/ui/src/i18n/en.json
@@ -192,6 +192,7 @@
"selectPlaylist": "Select a playlist:",
"addNewPlaylist": "Create \"%{name}\"",
"export": "Export",
+ "saveQueue": "Save Queue to Playlist",
"makePublic": "Make Public",
"makePrivate": "Make Private"
},
diff --git a/ui/src/reducers/dialogReducer.js b/ui/src/reducers/dialogReducer.js
index 04f235c5f..e1a77f100 100644
--- a/ui/src/reducers/dialogReducer.js
+++ b/ui/src/reducers/dialogReducer.js
@@ -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
+ }
+}