mirror of
https://github.com/navidrome/navidrome.git
synced 2026-04-03 06:41:01 +00:00
feat(ui): add 'Show in Playlist' context menu (#4139)
* Update song playlist menu and endpoint * feat(ui): show submenu on click, not on hover Signed-off-by: Deluan <deluan@navidrome.org> * feat(ui): integrate dataProvider for fetching playlists in song context menu Signed-off-by: Deluan <deluan@navidrome.org> * feat(ui): update song context menu to use dataProvider for fetching playlists and inspecting songs Signed-off-by: Deluan <deluan@navidrome.org> * feat(ui): stop event propagation when closing playlist submenu Signed-off-by: Deluan <deluan@navidrome.org> * feat(ui): add 'show in playlist' option to options object Signed-off-by: Deluan <deluan@navidrome.org> --------- Signed-off-by: Deluan <deluan@navidrome.org>
This commit is contained in:
parent
6dd98e0bed
commit
ded8cf236e
@ -101,6 +101,7 @@ type PlaylistRepository interface {
|
||||
FindByPath(path string) (*Playlist, error)
|
||||
Delete(id string) error
|
||||
Tracks(playlistId string, refreshSmartPlaylist bool) PlaylistTrackRepository
|
||||
GetPlaylists(mediaFileId string) (Playlists, error)
|
||||
}
|
||||
|
||||
type PlaylistTrack struct {
|
||||
|
||||
@ -197,6 +197,25 @@ func (r *playlistRepository) GetAll(options ...model.QueryOptions) (model.Playli
|
||||
return playlists, err
|
||||
}
|
||||
|
||||
func (r *playlistRepository) GetPlaylists(mediaFileId string) (model.Playlists, error) {
|
||||
sel := r.selectPlaylist(model.QueryOptions{Sort: "name"}).
|
||||
Join("playlist_tracks on playlist.id = playlist_tracks.playlist_id").
|
||||
Where(And{Eq{"playlist_tracks.media_file_id": mediaFileId}, r.userFilter()})
|
||||
var res []dbPlaylist
|
||||
err := r.queryAll(sel, &res)
|
||||
if err != nil {
|
||||
if errors.Is(err, model.ErrNotFound) {
|
||||
return model.Playlists{}, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
playlists := make(model.Playlists, len(res))
|
||||
for i, p := range res {
|
||||
playlists[i] = p.Playlist
|
||||
}
|
||||
return playlists, nil
|
||||
}
|
||||
|
||||
func (r *playlistRepository) selectPlaylist(options ...model.QueryOptions) SelectBuilder {
|
||||
return r.newSelect(options...).Join("user on user.id = owner_id").
|
||||
Columns(r.tableName+".*", "user.user_name as owner_name")
|
||||
|
||||
@ -112,6 +112,21 @@ var _ = Describe("PlaylistRepository", func() {
|
||||
})
|
||||
})
|
||||
|
||||
Describe("GetPlaylists", func() {
|
||||
It("returns playlists for a track", func() {
|
||||
pls, err := repo.GetPlaylists(songRadioactivity.ID)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(pls).To(HaveLen(1))
|
||||
Expect(pls[0].ID).To(Equal(plsBest.ID))
|
||||
})
|
||||
|
||||
It("returns empty when none", func() {
|
||||
pls, err := repo.GetPlaylists("9999")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(pls).To(HaveLen(0))
|
||||
})
|
||||
})
|
||||
|
||||
Context("Smart Playlists", func() {
|
||||
var rules *criteria.Criteria
|
||||
BeforeEach(func() {
|
||||
|
||||
@ -59,6 +59,7 @@ func (n *Router) routes() http.Handler {
|
||||
|
||||
n.addPlaylistRoute(r)
|
||||
n.addPlaylistTrackRoute(r)
|
||||
n.addSongPlaylistsRoute(r)
|
||||
n.addMissingFilesRoute(r)
|
||||
n.addInspectRoute(r)
|
||||
n.addConfigRoute(r)
|
||||
@ -142,6 +143,15 @@ func (n *Router) addPlaylistTrackRoute(r chi.Router) {
|
||||
})
|
||||
}
|
||||
|
||||
func (n *Router) addSongPlaylistsRoute(r chi.Router) {
|
||||
r.Route("/song/{id}", func(r chi.Router) {
|
||||
r.Use(server.URLParamsMiddleware)
|
||||
r.Get("/playlists", func(w http.ResponseWriter, r *http.Request) {
|
||||
getSongPlaylists(n.ds)(w, r)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func (n *Router) addMissingFilesRoute(r chi.Router) {
|
||||
r.Route("/missing", func(r chi.Router) {
|
||||
n.RX(r, "/", newMissingRepository(n.ds), false)
|
||||
|
||||
@ -207,3 +207,21 @@ func reorderItem(ds model.DataStore) http.HandlerFunc {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func getSongPlaylists(ds model.DataStore) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
p := req.Params(r)
|
||||
trackId, _ := p.String(":id")
|
||||
playlists, err := ds.Playlist(r.Context()).GetPlaylists(trackId)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
data, err := json.Marshal(playlists)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
_, _ = w.Write(data)
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,7 +1,12 @@
|
||||
import React, { useState } from 'react'
|
||||
import PropTypes from 'prop-types'
|
||||
import { useDispatch } from 'react-redux'
|
||||
import { useNotify, usePermissions, useTranslate } from 'react-admin'
|
||||
import {
|
||||
useNotify,
|
||||
usePermissions,
|
||||
useTranslate,
|
||||
useDataProvider,
|
||||
} from 'react-admin'
|
||||
import { IconButton, Menu, MenuItem } from '@material-ui/core'
|
||||
import { makeStyles } from '@material-ui/core/styles'
|
||||
import MoreVertIcon from '@material-ui/icons/MoreVert'
|
||||
@ -20,7 +25,7 @@ import {
|
||||
import { LoveButton } from './LoveButton'
|
||||
import config from '../config'
|
||||
import { formatBytes } from '../utils'
|
||||
import { httpClient } from '../dataProvider'
|
||||
import { useRedirect } from 'react-admin'
|
||||
|
||||
const useStyles = makeStyles({
|
||||
noWrap: {
|
||||
@ -57,8 +62,13 @@ export const SongContextMenu = ({
|
||||
const dispatch = useDispatch()
|
||||
const translate = useTranslate()
|
||||
const notify = useNotify()
|
||||
const dataProvider = useDataProvider()
|
||||
const [anchorEl, setAnchorEl] = useState(null)
|
||||
const [playlistAnchorEl, setPlaylistAnchorEl] = useState(null)
|
||||
const [playlists, setPlaylists] = useState([])
|
||||
const [playlistsLoaded, setPlaylistsLoaded] = useState(false)
|
||||
const { permissions } = usePermissions()
|
||||
const redirect = useRedirect()
|
||||
|
||||
const options = {
|
||||
playNow: {
|
||||
@ -87,6 +97,15 @@ export const SongContextMenu = ({
|
||||
}),
|
||||
),
|
||||
},
|
||||
showInPlaylist: {
|
||||
enabled: true,
|
||||
label:
|
||||
translate('resources.song.actions.showInPlaylist') +
|
||||
(playlists.length > 0 ? ' ►' : ''),
|
||||
action: (record, e) => {
|
||||
setPlaylistAnchorEl(e.currentTarget)
|
||||
},
|
||||
},
|
||||
share: {
|
||||
enabled: config.enableSharing,
|
||||
label: translate('ra.action.share'),
|
||||
@ -113,8 +132,8 @@ export const SongContextMenu = ({
|
||||
if (permissions === 'admin' && !record.missing) {
|
||||
try {
|
||||
let id = record.mediaFileId ?? record.id
|
||||
const data = await httpClient(`/api/inspect?id=${id}`)
|
||||
fullRecord = { ...record, rawTags: data.json.rawTags }
|
||||
const data = await dataProvider.inspect(id)
|
||||
fullRecord = { ...record, rawTags: data.data.rawTags }
|
||||
} catch (error) {
|
||||
notify(
|
||||
translate('ra.notification.http_error') + ': ' + error.message,
|
||||
@ -134,6 +153,21 @@ export const SongContextMenu = ({
|
||||
|
||||
const handleClick = (e) => {
|
||||
setAnchorEl(e.currentTarget)
|
||||
if (!playlistsLoaded) {
|
||||
const id = record.mediaFileId || record.id
|
||||
dataProvider
|
||||
.getPlaylists(id)
|
||||
.then((res) => {
|
||||
setPlaylists(res.data)
|
||||
setPlaylistsLoaded(true)
|
||||
})
|
||||
.catch((error) => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('Failed to fetch playlists:', error)
|
||||
setPlaylists([])
|
||||
setPlaylistsLoaded(true)
|
||||
})
|
||||
}
|
||||
e.stopPropagation()
|
||||
}
|
||||
|
||||
@ -144,12 +178,39 @@ export const SongContextMenu = ({
|
||||
|
||||
const handleItemClick = (e) => {
|
||||
e.preventDefault()
|
||||
setAnchorEl(null)
|
||||
const key = e.target.getAttribute('value')
|
||||
options[key].action(record)
|
||||
const action = options[key].action
|
||||
|
||||
if (key === 'showInPlaylist') {
|
||||
// For showInPlaylist, we keep the main menu open and show submenu
|
||||
action(record, e)
|
||||
} else {
|
||||
// For other actions, close the main menu
|
||||
setAnchorEl(null)
|
||||
action(record)
|
||||
}
|
||||
e.stopPropagation()
|
||||
}
|
||||
|
||||
const handlePlaylistClose = (e) => {
|
||||
setPlaylistAnchorEl(null)
|
||||
if (e) {
|
||||
e.stopPropagation()
|
||||
}
|
||||
}
|
||||
|
||||
const handleMainMenuClose = (e) => {
|
||||
setAnchorEl(null)
|
||||
setPlaylistAnchorEl(null) // Close both menus
|
||||
e.stopPropagation()
|
||||
}
|
||||
|
||||
const handlePlaylistClick = (id, e) => {
|
||||
e.stopPropagation()
|
||||
redirect(`/playlist/${id}/show`)
|
||||
handlePlaylistClose()
|
||||
}
|
||||
|
||||
const open = Boolean(anchorEl)
|
||||
|
||||
if (!record) {
|
||||
@ -170,17 +231,41 @@ export const SongContextMenu = ({
|
||||
id={'menu' + record.id}
|
||||
anchorEl={anchorEl}
|
||||
open={open}
|
||||
onClose={handleClose}
|
||||
onClose={handleMainMenuClose}
|
||||
>
|
||||
{Object.keys(options).map(
|
||||
(key) =>
|
||||
options[key].enabled && (
|
||||
<MenuItem value={key} key={key} onClick={handleItemClick}>
|
||||
<MenuItem
|
||||
value={key}
|
||||
key={key}
|
||||
onClick={handleItemClick}
|
||||
disabled={key === 'showInPlaylist' && !playlists.length}
|
||||
>
|
||||
{options[key].label}
|
||||
</MenuItem>
|
||||
),
|
||||
)}
|
||||
</Menu>
|
||||
<Menu
|
||||
anchorEl={playlistAnchorEl}
|
||||
open={Boolean(playlistAnchorEl)}
|
||||
onClose={handlePlaylistClose}
|
||||
anchorOrigin={{
|
||||
vertical: 'top',
|
||||
horizontal: 'right',
|
||||
}}
|
||||
transformOrigin={{
|
||||
vertical: 'top',
|
||||
horizontal: 'left',
|
||||
}}
|
||||
>
|
||||
{playlists.map((p) => (
|
||||
<MenuItem key={p.id} onClick={(e) => handlePlaylistClick(p.id, e)}>
|
||||
{p.name}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Menu>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
82
ui/src/common/SongContextMenu.test.jsx
Normal file
82
ui/src/common/SongContextMenu.test.jsx
Normal file
@ -0,0 +1,82 @@
|
||||
import React from 'react'
|
||||
import { render, fireEvent, screen, waitFor } from '@testing-library/react'
|
||||
import { TestContext } from 'ra-test'
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { SongContextMenu } from './SongContextMenu'
|
||||
|
||||
vi.mock('../dataProvider', () => ({
|
||||
httpClient: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('react-redux', () => ({ useDispatch: () => vi.fn() }))
|
||||
|
||||
vi.mock('react-admin', async (importOriginal) => {
|
||||
const actual = await importOriginal()
|
||||
return {
|
||||
...actual,
|
||||
useRedirect: () => (url) => {
|
||||
window.location.hash = `#${url}`
|
||||
},
|
||||
useDataProvider: () => ({
|
||||
getPlaylists: vi.fn().mockResolvedValue({
|
||||
data: [{ id: 'pl1', name: 'Pl 1' }],
|
||||
}),
|
||||
inspect: vi.fn().mockResolvedValue({
|
||||
data: { rawTags: {} },
|
||||
}),
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
describe('SongContextMenu', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
window.location.hash = ''
|
||||
})
|
||||
|
||||
it('navigates to playlist when selected', async () => {
|
||||
render(
|
||||
<TestContext>
|
||||
<SongContextMenu record={{ id: 'song1', size: 1 }} resource="song" />
|
||||
</TestContext>,
|
||||
)
|
||||
fireEvent.click(screen.getAllByRole('button')[1])
|
||||
await waitFor(() =>
|
||||
screen.getByText(/resources\.song\.actions\.showInPlaylist/),
|
||||
)
|
||||
fireEvent.click(
|
||||
screen.getByText(/resources\.song\.actions\.showInPlaylist/),
|
||||
)
|
||||
await waitFor(() => screen.getByText('Pl 1'))
|
||||
fireEvent.click(screen.getByText('Pl 1'))
|
||||
expect(window.location.hash).toBe('#/playlist/pl1/show')
|
||||
})
|
||||
|
||||
it('stops event propagation when playlist submenu is closed', async () => {
|
||||
const mockOnClick = vi.fn()
|
||||
render(
|
||||
<TestContext>
|
||||
<div onClick={mockOnClick}>
|
||||
<SongContextMenu record={{ id: 'song1', size: 1 }} resource="song" />
|
||||
</div>
|
||||
</TestContext>,
|
||||
)
|
||||
|
||||
// Open main menu
|
||||
fireEvent.click(screen.getAllByRole('button')[1])
|
||||
await waitFor(() =>
|
||||
screen.getByText(/resources\.song\.actions\.showInPlaylist/),
|
||||
)
|
||||
|
||||
// Open playlist submenu
|
||||
fireEvent.click(
|
||||
screen.getByText(/resources\.song\.actions\.showInPlaylist/),
|
||||
)
|
||||
await waitFor(() => screen.getByText('Pl 1'))
|
||||
|
||||
// Click outside the playlist submenu (should close it without triggering parent click)
|
||||
fireEvent.click(document.body)
|
||||
|
||||
expect(mockOnClick).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@ -90,6 +90,16 @@ const wrapperDataProvider = {
|
||||
body: JSON.stringify(data),
|
||||
}).then(({ json }) => ({ data: json }))
|
||||
},
|
||||
getPlaylists: (songId) => {
|
||||
return httpClient(`${REST_URL}/song/${songId}/playlists`).then(
|
||||
({ json }) => ({ data: json }),
|
||||
)
|
||||
},
|
||||
inspect: (songId) => {
|
||||
return httpClient(`${REST_URL}/inspect?id=${songId}`).then(({ json }) => ({
|
||||
data: json,
|
||||
}))
|
||||
},
|
||||
}
|
||||
|
||||
export default wrapperDataProvider
|
||||
|
||||
@ -41,6 +41,7 @@
|
||||
"addToQueue": "Play Later",
|
||||
"playNow": "Play Now",
|
||||
"addToPlaylist": "Add to Playlist",
|
||||
"showInPlaylist": "Show in Playlist",
|
||||
"shuffleAll": "Shuffle All",
|
||||
"download": "Download",
|
||||
"playNext": "Play Next",
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user