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:
Deluan Quintão 2025-05-30 21:26:35 -04:00 committed by GitHub
parent 6dd98e0bed
commit ded8cf236e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 249 additions and 8 deletions

View File

@ -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 {

View File

@ -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")

View File

@ -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() {

View File

@ -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)

View File

@ -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)
}
}

View File

@ -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>
)
}

View 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()
})
})

View File

@ -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

View File

@ -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",