import type { SubsonicSong } from '../api/subsonicTypes'; import { songToTrack } from '../utils/playback/songToTrack'; import React, { memo } from 'react'; import { useNavigateToAlbum } from '../hooks/useNavigateToAlbum'; import { useNavigateToArtist } from '../hooks/useNavigateToArtist'; import { Play, ListPlus } from 'lucide-react'; import { useTranslation } from 'react-i18next'; import { usePlayerStore } from '../store/playerStore'; import { enqueueAndPlay } from '../utils/playback/playSong'; import { useDragDrop } from '../contexts/DragDropContext'; import { useOrbitSongRowBehavior } from '../hooks/useOrbitSongRowBehavior'; import { formatTrackTime } from '../utils/format/formatDuration'; import { resolveTrackArtistRefs } from '../utils/playback/trackArtistRefs'; import { tooltipAttrs } from '@/ui/tooltipAttrs'; interface Props { song: SubsonicSong; showBpm?: boolean; } function SongRow({ song, showBpm }: Props) { const navigateToAlbum = useNavigateToAlbum(); const navigateToArtist = useNavigateToArtist(); const { t } = useTranslation(); const enqueue = usePlayerStore(s => s.enqueue); const openContextMenu = usePlayerStore(s => s.openContextMenu); const isCurrent = usePlayerStore(s => s.currentTrack?.id === song.id); const psyDrag = useDragDrop(); const { orbitActive, addTrackToOrbit } = useOrbitSongRowBehavior(); // In an orbit session both buttons collapse into the orbit-suggest / host-enqueue // path so we don't ship a queue replacement to every guest. const handlePlay = () => { if (orbitActive) { addTrackToOrbit(song.id); return; } enqueueAndPlay(song); }; const handleEnqueue = () => { if (orbitActive) { addTrackToOrbit(song.id); return; } enqueue([songToTrack(song)]); }; const artistRefs = resolveTrackArtistRefs(song); const bpmTooltip = song.localBpmSource === 'analysis' ? t('search.bpmSourceAnalysis') : song.localBpmSource === 'tag' ? t('search.bpmSourceTag') : undefined; return (
{ e.preventDefault(); openContextMenu(e.clientX, e.clientY, song, 'song'); }} onMouseDown={(e) => { if (e.button !== 0) return; const sx = e.clientX, sy = e.clientY; const track = songToTrack(song); const onMove = (me: MouseEvent) => { if (Math.abs(me.clientX - sx) > 5 || Math.abs(me.clientY - sy) > 5) { document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); psyDrag.startDrag( { data: JSON.stringify({ type: 'song', track }), label: song.title }, me.clientX, me.clientY, ); } }; const onUp = () => { document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); }; document.addEventListener('mousemove', onMove); document.addEventListener('mouseup', onUp); }} >
{song.title}
{artistRefs.map((a, i) => ( {i > 0 &&  · } { if (a.id) { e.stopPropagation(); navigateToArtist(a.id!); } }} >{a.name ?? song.artist} ))}
{song.albumId ? ( { e.stopPropagation(); navigateToAlbum(song.albumId!); }} title={song.album} >{song.album} ) : {song.album}}
{song.genre ?? '—'}
{showBpm && (
{song.bpm != null && song.bpm > 0 ? song.bpm : '—'}
)}
{formatTrackTime(song.duration, '–')}
); } /** Column header with the same grid as . Optional — pages can render it above the list. */ export function SongListHeader({ showBpm }: { showBpm?: boolean } = {}) { const { t } = useTranslation(); return (
{t('albumDetail.trackTitle')}
{t('albumDetail.trackArtist')}
{t('albumDetail.trackAlbum')}
{t('randomMix.trackGenre')}
{showBpm && (
{t('albumDetail.trackBpm')}
)}
{t('albumDetail.trackDuration')}
); } export default memo(SongRow);