fix reordering

This commit is contained in:
2025-12-12 22:26:14 -08:00
parent 6ff150cfef
commit 706f277fd3
8 changed files with 473 additions and 58 deletions

View File

@@ -102,6 +102,40 @@ router.post('/skip', async (req, res) => {
} }
}); });
// Go to previous track
router.post('/previous', async (req, res) => {
try {
const response = await fetch(`${req.app.locals.botUrl}/previous`, { method: 'POST' });
const result = await response.json();
// Get updated state immediately and broadcast
try {
const stateResponse = await fetch(`${req.app.locals.botUrl}/state`);
const state = await stateResponse.json();
if (state.currentTrack) {
req.app.locals.broadcast('trackChange', {
track: state.currentTrack,
queueLength: state.queueLength
});
}
req.app.locals.broadcast('playbackUpdate', {
state: state.state,
position: state.position,
volume: state.volume
});
} catch (broadcastError) {
// Polling will catch it
}
res.json(result);
} catch (error) {
req.app.locals.logger.error(`Error going to previous track: ${error.message}`);
res.status(500).json({ error: 'Failed to go to previous track' });
}
});
// Set volume // Set volume
router.post('/volume', async (req, res) => { router.post('/volume', async (req, res) => {
try { try {

View File

@@ -2,13 +2,13 @@ import express from 'express';
const router = express.Router(); const router = express.Router();
// Get current queue // Get current queue (full library in playback order)
router.get('/', async (req, res) => { router.get('/', async (req, res) => {
try { try {
const response = await fetch(`${req.app.locals.botUrl}/state`); const response = await fetch(`${req.app.locals.botUrl}/state`);
const state = await response.json(); const state = await response.json();
res.json({ res.json({
tracks: state.queue || [], tracks: state.fullQueue || [],
currentTrack: state.currentTrack, currentTrack: state.currentTrack,
shuffled: state.shuffled || false shuffled: state.shuffled || false
}); });
@@ -33,4 +33,41 @@ router.post('/shuffle', async (req, res) => {
} }
}); });
// Reorder queue
router.post('/reorder', async (req, res) => {
try {
const { from, to } = req.body;
const response = await fetch(`${req.app.locals.botUrl}/queue/reorder`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ from, to })
});
const result = await response.json();
req.app.locals.broadcast('queueUpdate', { queue: result.queue });
res.json(result);
} catch (error) {
req.app.locals.logger.error(`Error reordering queue: ${error.message}`);
res.status(500).json({ error: 'Failed to reorder queue' });
}
});
// Add track to queue
router.post('/add', async (req, res) => {
try {
const { trackId } = req.body;
const response = await fetch(`${req.app.locals.botUrl}/queue/add`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ trackId })
});
const result = await response.json();
req.app.locals.broadcast('queueUpdate', { queue: result.queue });
res.json(result);
} catch (error) {
req.app.locals.logger.error(`Error adding to queue: ${error.message}`);
res.status(500).json({ error: 'Failed to add to queue' });
}
});
export default router; export default router;

View File

@@ -74,7 +74,8 @@ router.get('/', async (req, res) => {
title: file.replace('.mp3', ''), title: file.replace('.mp3', ''),
artist: 'Unknown', artist: 'Unknown',
album: '', album: '',
duration: 0 duration: 0,
hasArt: false
}; };
try { try {
@@ -83,7 +84,8 @@ router.get('/', async (req, res) => {
title: parsed.common.title || metadata.title, title: parsed.common.title || metadata.title,
artist: parsed.common.artist || metadata.artist, artist: parsed.common.artist || metadata.artist,
album: parsed.common.album || '', album: parsed.common.album || '',
duration: Math.floor(parsed.format.duration || 0) duration: Math.floor(parsed.format.duration || 0),
hasArt: parsed.common.picture && parsed.common.picture.length > 0
}; };
} catch (error) { } catch (error) {
// Use defaults if metadata parsing fails // Use defaults if metadata parsing fails
@@ -94,7 +96,6 @@ router.get('/', async (req, res) => {
filename: file, filename: file,
filepath: file, filepath: file,
...metadata, ...metadata,
hasArt: false,
size: stats.size size: stats.size
}); });
} }
@@ -106,6 +107,31 @@ router.get('/', async (req, res) => {
} }
}); });
// Get album art for a track
router.get('/:id/art', async (req, res) => {
try {
const filepath = path.join(MUSIC_DIRECTORY, req.params.id);
if (!fs.existsSync(filepath)) {
return res.status(404).json({ error: 'Track not found' });
}
const parsed = await parseFile(filepath);
if (!parsed.common.picture || parsed.common.picture.length === 0) {
return res.status(404).json({ error: 'No album art found' });
}
const picture = parsed.common.picture[0];
res.set('Content-Type', picture.format || 'image/jpeg');
res.set('Cache-Control', 'public, max-age=86400'); // Cache for 24 hours
res.send(picture.data);
} catch (error) {
req.app.locals.logger.error(`Error getting album art: ${error.message}`);
res.status(500).json({ error: 'Failed to get album art' });
}
});
// Upload tracks // Upload tracks
router.post('/upload', upload.array('files', MAX_BATCH_SIZE), async (req, res) => { router.post('/upload', upload.array('files', MAX_BATCH_SIZE), async (req, res) => {
const uploaded = []; const uploaded = [];

View File

@@ -32,6 +32,7 @@ let currentState = {
position: 0, position: 0,
volume: 100, volume: 100,
queue: [], queue: [],
history: [],
shuffled: false shuffled: false
}; };
@@ -39,6 +40,7 @@ let connection = null;
let player = null; let player = null;
let startTime = 0; let startTime = 0;
let currentResource = null; let currentResource = null;
const MAX_HISTORY = 50; // Keep last 50 tracks in history
// Discord client // Discord client
const client = new Client({ const client = new Client({
@@ -75,7 +77,22 @@ function initializeQueue() {
} }
// Play next track in queue // Play next track in queue
function playNext() { function playNext(skipToNext = false) {
// When skipping manually, add current track to history
// When track finishes naturally, move current to end of queue (continuous loop)
if (currentState.currentTrack) {
if (skipToNext) {
// Manual skip - add to history for "previous" functionality
currentState.history.unshift(currentState.currentTrack);
if (currentState.history.length > MAX_HISTORY) {
currentState.history.pop();
}
} else {
// Natural finish - move to end of queue for continuous loop
currentState.queue.push(currentState.currentTrack);
}
}
if (currentState.queue.length === 0) { if (currentState.queue.length === 0) {
logger.info('Queue empty, reloading library'); logger.info('Queue empty, reloading library');
initializeQueue(); initializeQueue();
@@ -109,6 +126,43 @@ function playNext() {
} }
} }
// Play previous track from history
function playPrevious() {
if (currentState.history.length === 0) {
logger.info('No previous track in history');
return false;
}
// Put current track back at the front of the queue
if (currentState.currentTrack) {
currentState.queue.unshift(currentState.currentTrack);
}
// Get previous track from history
const track = currentState.history.shift();
currentState.currentTrack = track;
currentState.position = 0;
currentState.state = 'playing';
startTime = Date.now();
logger.info(`Playing previous track: ${track.title}`);
try {
currentResource = createAudioResource(track.filepath, {
inlineVolume: true
});
currentResource.volume.setVolume(currentState.volume / 100);
player.play(currentResource);
return true;
} catch (error) {
logger.error(`Error playing previous track: ${error.message}`);
currentResource = null;
// If previous track fails, continue to next
playNext();
return false;
}
}
// Connect to voice channel // Connect to voice channel
async function connectToVoice() { async function connectToVoice() {
try { try {
@@ -177,10 +231,17 @@ app.get('/health', (req, res) => {
app.get('/state', (req, res) => { app.get('/state', (req, res) => {
const elapsed = currentState.state === 'playing' ? Math.floor((Date.now() - startTime) / 1000) : 0; const elapsed = currentState.state === 'playing' ? Math.floor((Date.now() - startTime) / 1000) : 0;
// Queue includes current track + remaining tracks
const fullQueue = currentState.currentTrack
? [currentState.currentTrack, ...currentState.queue]
: [...currentState.queue];
res.json({ res.json({
...currentState, ...currentState,
position: currentState.position + elapsed, position: currentState.position + elapsed,
queueLength: currentState.queue.length queueLength: currentState.queue.length,
fullQueue: fullQueue
}); });
}); });
@@ -208,10 +269,58 @@ app.post('/pause', (req, res) => {
app.post('/skip', (req, res) => { app.post('/skip', (req, res) => {
logger.info('Skipping to next track'); logger.info('Skipping to next track');
playNext();
// Move current track to end of queue instead of history
if (currentState.currentTrack) {
currentState.queue.push(currentState.currentTrack);
}
// Get next track from queue
if (currentState.queue.length > 0) {
const track = currentState.queue.shift();
currentState.currentTrack = track;
currentState.position = 0;
currentState.state = 'playing';
startTime = Date.now();
try {
currentResource = createAudioResource(track.filepath, {
inlineVolume: true
});
currentResource.volume.setVolume(currentState.volume / 100);
player.play(currentResource);
} catch (error) {
logger.error(`Error playing track: ${error.message}`);
currentResource = null;
}
}
res.json({ success: true }); res.json({ success: true });
}); });
app.post('/previous', (req, res) => {
logger.info('Playing previous track');
const success = playPrevious();
res.json({ success, hasHistory: currentState.history.length > 0 });
});
app.post('/seek', (req, res) => {
const { position } = req.body;
if (typeof position !== 'number' || position < 0) {
return res.status(400).json({ error: 'Invalid position' });
}
// Note: Discord.js voice doesn't support seeking within a track
// This would require re-creating the audio resource from the seek position
// For now, we'll return an error indicating this limitation
logger.warn('Seek requested but not supported by Discord.js voice');
res.status(501).json({
error: 'Seek not supported',
message: 'Discord.js voice library does not support seeking within tracks'
});
});
app.post('/volume', (req, res) => { app.post('/volume', (req, res) => {
const { volume } = req.body; const { volume } = req.body;
if (volume >= 0 && volume <= 100) { if (volume >= 0 && volume <= 100) {
@@ -238,6 +347,70 @@ app.post('/queue/shuffle', (req, res) => {
res.json({ success: true, shuffled: currentState.shuffled }); res.json({ success: true, shuffled: currentState.shuffled });
}); });
app.post('/queue/reorder', (req, res) => {
const { from, to } = req.body;
// Full queue includes current track + remaining queue
const fullQueue = currentState.currentTrack
? [currentState.currentTrack, ...currentState.queue]
: [...currentState.queue];
if (typeof from !== 'number' || typeof to !== 'number' ||
from < 0 || to < 0 ||
from >= fullQueue.length || to >= fullQueue.length) {
return res.status(400).json({ error: 'Invalid indices' });
}
// Move the track
const [track] = fullQueue.splice(from, 1);
fullQueue.splice(to, 0, track);
// Update state: first track becomes current, rest becomes queue
if (fullQueue.length > 0) {
currentState.currentTrack = fullQueue[0];
currentState.queue = fullQueue.slice(1);
// If we moved the current track and it's playing, restart it
if (from === 0 && currentState.state === 'playing') {
currentState.position = 0;
startTime = Date.now();
try {
currentResource = createAudioResource(currentState.currentTrack.filepath, {
inlineVolume: true
});
currentResource.volume.setVolume(currentState.volume / 100);
player.play(currentResource);
} catch (error) {
logger.error(`Error replaying track: ${error.message}`);
}
}
}
logger.info(`Queue reordered: moved track from ${from} to ${to}`);
res.json({ success: true, queue: fullQueue });
});
app.post('/queue/add', (req, res) => {
const { trackId } = req.body;
if (!trackId) {
return res.status(400).json({ error: 'Track ID required' });
}
const library = loadMusicLibrary();
const track = library.find(t => t.id === trackId);
if (!track) {
return res.status(404).json({ error: 'Track not found' });
}
currentState.queue.push(track);
logger.info(`Added ${track.title} to queue`);
res.json({ success: true, queue: currentState.queue });
});
app.get('/channels', async (req, res) => { app.get('/channels', async (req, res) => {
try { try {
const guild = await client.guilds.fetch(DISCORD_GUILD_ID); const guild = await client.guilds.fetch(DISCORD_GUILD_ID);

View File

@@ -9,42 +9,43 @@ import useWebSocket from './hooks/useWebSocket';
const API_URL = import.meta.env.VITE_API_URL || '/api'; const API_URL = import.meta.env.VITE_API_URL || '/api';
function App() { function App() {
const [tracks, setTracks] = useState([]);
const [currentTrack, setCurrentTrack] = useState(null); const [currentTrack, setCurrentTrack] = useState(null);
const [playbackState, setPlaybackState] = useState('stopped'); const [playbackState, setPlaybackState] = useState('stopped');
const [position, setPosition] = useState(0); const [position, setPosition] = useState(0);
const [volume, setVolume] = useState(100); const [volume, setVolume] = useState(100);
const [tracks, setTracks] = useState([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const loadQueue = async () => {
try {
const response = await fetch(`${API_URL}/queue`);
const data = await response.json();
setTracks(data.tracks || []);
setLoading(false);
} catch (error) {
console.error('Failed to load queue:', error);
setLoading(false);
}
};
const { connected } = useWebSocket((event, data) => { const { connected } = useWebSocket((event, data) => {
if (event === 'trackChange') { if (event === 'trackChange') {
setCurrentTrack(data.track); setCurrentTrack(data.track);
loadQueue();
} else if (event === 'playbackUpdate') { } else if (event === 'playbackUpdate') {
setPlaybackState(data.state); setPlaybackState(data.state);
if (data.position !== undefined) setPosition(data.position); if (data.position !== undefined) setPosition(data.position);
if (data.volume !== undefined) setVolume(data.volume); if (data.volume !== undefined) setVolume(data.volume);
} else if (event === 'libraryUpdate') { } else if (event === 'libraryUpdate' || event === 'queueUpdate') {
loadTracks(); loadQueue();
} }
}); });
useEffect(() => { useEffect(() => {
loadTracks();
loadPlaybackState(); loadPlaybackState();
loadQueue();
}, []); }, []);
const loadTracks = async () => {
try {
const response = await fetch(`${API_URL}/tracks`);
const data = await response.json();
setTracks(data);
} catch (error) {
console.error('Failed to load tracks:', error);
} finally {
setLoading(false);
}
};
const loadPlaybackState = async () => { const loadPlaybackState = async () => {
try { try {
const response = await fetch(`${API_URL}/playback`); const response = await fetch(`${API_URL}/playback`);
@@ -84,6 +85,14 @@ function App() {
} }
}; };
const handlePrevious = async () => {
try {
await fetch(`${API_URL}/playback/previous`, { method: 'POST' });
} catch (error) {
console.error('Failed to go to previous:', error);
}
};
const handleVolumeChange = async (newVolume) => { const handleVolumeChange = async (newVolume) => {
try { try {
await fetch(`${API_URL}/playback/volume`, { await fetch(`${API_URL}/playback/volume`, {
@@ -106,13 +115,13 @@ function App() {
}; };
const handleUploadComplete = () => { const handleUploadComplete = () => {
loadTracks(); loadQueue();
}; };
const handleDeleteTrack = async (trackId) => { const handleDeleteTrack = async (trackId) => {
try { try {
await fetch(`${API_URL}/tracks/${trackId}`, { method: 'DELETE' }); await fetch(`${API_URL}/tracks/${trackId}`, { method: 'DELETE' });
setTracks(tracks.filter(t => t.id !== trackId)); loadQueue();
} catch (error) { } catch (error) {
console.error('Failed to delete track:', error); console.error('Failed to delete track:', error);
} }
@@ -143,6 +152,7 @@ function App() {
onPlay={handlePlay} onPlay={handlePlay}
onPause={handlePause} onPause={handlePause}
onSkip={handleSkip} onSkip={handleSkip}
onPrevious={handlePrevious}
onVolumeChange={handleVolumeChange} onVolumeChange={handleVolumeChange}
onShuffle={handleShuffle} onShuffle={handleShuffle}
/> />
@@ -158,6 +168,7 @@ function App() {
currentTrack={currentTrack} currentTrack={currentTrack}
loading={loading} loading={loading}
onDelete={handleDeleteTrack} onDelete={handleDeleteTrack}
onReload={loadQueue}
/> />
</div> </div>
</div> </div>

View File

@@ -1,8 +1,16 @@
export default function Controls({ state, volume, onPlay, onPause, onSkip, onVolumeChange, onShuffle }) { export default function Controls({ state, volume, onPlay, onPause, onSkip, onPrevious, onVolumeChange, onShuffle }) {
return ( return (
<div className="bg-slate-800/50 backdrop-blur-sm rounded-xl p-6 border border-slate-700"> <div className="bg-slate-800/50 backdrop-blur-sm rounded-xl p-6 border border-slate-700">
<div className="flex items-center justify-between gap-6"> <div className="flex items-center justify-between gap-6">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<button
onClick={onPrevious}
className="w-14 h-14 bg-slate-700 hover:bg-slate-600 rounded-full flex items-center justify-center text-white text-xl transition-colors"
title="Previous"
>
</button>
{state === 'playing' ? ( {state === 'playing' ? (
<button <button
onClick={onPause} onClick={onPause}

View File

@@ -1,5 +1,7 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
const API_URL = import.meta.env.VITE_API_URL || '/api';
function formatTime(seconds) { function formatTime(seconds) {
const mins = Math.floor(seconds / 60); const mins = Math.floor(seconds / 60);
const secs = Math.floor(seconds % 60); const secs = Math.floor(seconds % 60);
@@ -36,8 +38,21 @@ export default function NowPlaying({ track, state, position }) {
return ( return (
<div className="bg-slate-800/50 backdrop-blur-sm rounded-xl p-8 border border-slate-700"> <div className="bg-slate-800/50 backdrop-blur-sm rounded-xl p-8 border border-slate-700">
<div className="flex items-center gap-6"> <div className="flex items-center gap-6">
<div className="w-24 h-24 bg-gradient-to-br from-red-600 to-green-600 rounded-lg flex items-center justify-center text-4xl"> <div className="w-24 h-24 bg-gradient-to-br from-red-600 to-green-600 rounded-lg flex items-center justify-center text-4xl overflow-hidden flex-shrink-0">
🎵 {track.hasArt ? (
<img
src={`${API_URL}/tracks/${encodeURIComponent(track.id)}/art`}
alt={`${track.title} album art`}
className="w-full h-full object-cover"
onError={(e) => {
e.target.style.display = 'none';
e.target.nextSibling.style.display = 'flex';
}}
/>
) : null}
<div className={track.hasArt ? 'hidden' : 'flex items-center justify-center w-full h-full'}>
🎵
</div>
</div> </div>
<div className="flex-1"> <div className="flex-1">
@@ -47,7 +62,10 @@ export default function NowPlaying({ track, state, position }) {
<div className="mt-4 flex items-center gap-4"> <div className="mt-4 flex items-center gap-4">
<span className="text-sm text-slate-400">{formatTime(displayPosition)}</span> <span className="text-sm text-slate-400">{formatTime(displayPosition)}</span>
<div className="flex-1 h-2 bg-slate-700 rounded-full overflow-hidden"> <div
className="flex-1 h-2 bg-slate-700 rounded-full overflow-hidden cursor-not-allowed opacity-75"
title="Seek not supported (Discord.js limitation)"
>
<div <div
className="h-full bg-gradient-to-r from-red-600 to-green-600 transition-all" className="h-full bg-gradient-to-r from-red-600 to-green-600 transition-all"
style={{ width: track.duration > 0 ? `${(displayPosition / track.duration) * 100}%` : '0%' }} style={{ width: track.duration > 0 ? `${(displayPosition / track.duration) * 100}%` : '0%' }}

View File

@@ -1,3 +1,7 @@
import { useState, useMemo, useEffect } from 'react';
const API_URL = import.meta.env.VITE_API_URL || '/api';
function formatDuration(seconds) { function formatDuration(seconds) {
const mins = Math.floor(seconds / 60); const mins = Math.floor(seconds / 60);
const secs = Math.floor(seconds % 60); const secs = Math.floor(seconds % 60);
@@ -10,7 +14,65 @@ function formatFileSize(bytes) {
return (bytes / (1024 * 1024)).toFixed(1) + ' MB'; return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
} }
export default function TrackList({ tracks, currentTrack, loading, onDelete }) { export default function TrackList({ tracks, currentTrack, loading, onDelete, onReload }) {
const [searchQuery, setSearchQuery] = useState('');
const [draggedIndex, setDraggedIndex] = useState(null);
const [dropTargetIndex, setDropTargetIndex] = useState(null);
const handleReorder = async (from, to) => {
if (from === to) return;
try {
await fetch(`${API_URL}/queue/reorder`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ from, to })
});
if (onReload) onReload();
} catch (error) {
console.error('Failed to reorder queue:', error);
}
};
const handleDragStart = (e, index) => {
setDraggedIndex(index);
e.dataTransfer.effectAllowed = 'move';
};
const handleDragOver = (e, index) => {
e.preventDefault();
setDropTargetIndex(index);
};
const handleDragLeave = () => {
setDropTargetIndex(null);
};
const handleDrop = (e, toIndex) => {
e.preventDefault();
if (draggedIndex !== null && draggedIndex !== toIndex) {
handleReorder(draggedIndex, toIndex);
}
setDraggedIndex(null);
setDropTargetIndex(null);
};
const handleDragEnd = () => {
setDraggedIndex(null);
setDropTargetIndex(null);
};
const filteredTracks = useMemo(() => {
if (!searchQuery.trim()) return tracks;
const query = searchQuery.toLowerCase();
return tracks.filter(track =>
track.title.toLowerCase().includes(query) ||
track.artist.toLowerCase().includes(query) ||
track.album?.toLowerCase().includes(query) ||
track.filename.toLowerCase().includes(query)
);
}, [tracks, searchQuery]);
if (loading) { if (loading) {
return ( return (
<div className="bg-slate-800/50 backdrop-blur-sm rounded-xl p-6 border border-slate-700"> <div className="bg-slate-800/50 backdrop-blur-sm rounded-xl p-6 border border-slate-700">
@@ -35,39 +97,85 @@ export default function TrackList({ tracks, currentTrack, loading, onDelete }) {
return ( return (
<div className="bg-slate-800/50 backdrop-blur-sm rounded-xl p-6 border border-slate-700"> <div className="bg-slate-800/50 backdrop-blur-sm rounded-xl p-6 border border-slate-700">
<h3 className="text-xl font-bold text-white mb-4"> <h3 className="text-xl font-bold text-white mb-4">
Library <span className="text-slate-400 text-sm font-normal">({tracks.length} tracks)</span> Library <span className="text-slate-400 text-sm font-normal">({tracks.length} tracks in playback order)</span>
</h3> </h3>
<div className="mb-4">
<input
type="text"
placeholder="Search tracks, artists, albums..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full px-4 py-2 bg-slate-700/50 border border-slate-600 rounded-lg text-white placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-transparent"
/>
{searchQuery && (
<div className="mt-2 text-sm text-slate-400">
Found {filteredTracks.length} of {tracks.length} tracks
</div>
)}
</div>
<div className="space-y-2 max-h-[600px] overflow-y-auto pr-2"> <div className="space-y-2 max-h-[600px] overflow-y-auto pr-2">
{tracks.map((track) => ( {filteredTracks.map((track, index) => {
<div const isCurrentTrack = currentTrack && track.id === currentTrack.id && index === 0;
key={track.id} const isFiltered = searchQuery.trim() !== '';
className={`p-3 rounded-lg transition-colors group ${
currentTrack?.id === track.id return (
? 'bg-purple-600/20 border border-purple-500/50' <div
: 'bg-slate-700/30 hover:bg-slate-700/50' key={`${track.id}-${index}`}
}`} draggable={!isFiltered}
> onDragStart={(e) => !isFiltered && handleDragStart(e, index)}
<div className="flex items-start justify-between gap-2"> onDragOver={(e) => !isFiltered && handleDragOver(e, index)}
<div className="flex-1 min-w-0"> onDragLeave={!isFiltered ? handleDragLeave : undefined}
<div className="font-medium text-white truncate">{track.title}</div> onDrop={(e) => !isFiltered && handleDrop(e, index)}
<div className="text-sm text-slate-400 truncate">{track.artist}</div> onDragEnd={!isFiltered ? handleDragEnd : undefined}
<div className="flex gap-3 mt-1 text-xs text-slate-500"> className={`p-3 rounded-lg transition-all group ${
{track.duration > 0 && <span>{formatDuration(track.duration)}</span>} isCurrentTrack
{track.size && <span>{formatFileSize(track.size)}</span>} ? 'bg-green-600/20 border-2 border-green-500/50'
: draggedIndex === index
? 'opacity-50 scale-95'
: dropTargetIndex === index
? 'bg-purple-600/20 border-2 border-purple-500/50'
: 'bg-slate-700/30 hover:bg-slate-700/50'
} ${!isFiltered ? 'cursor-move' : ''}`}
>
<div className="flex items-start justify-between gap-2">
{!isFiltered && (
<div className={`text-sm font-mono w-6 flex-shrink-0 ${isCurrentTrack ? 'text-green-400 font-bold' : 'text-slate-500'}`}>
{isCurrentTrack ? '▶' : index + 1}
</div>
)}
<div className="flex-1 min-w-0">
<div className={`font-medium truncate ${isCurrentTrack ? 'text-green-300' : 'text-white'}`}>
{track.title}
</div>
<div className={`text-sm truncate ${isCurrentTrack ? 'text-green-400/80' : 'text-slate-400'}`}>
{track.artist}
</div>
<div className="flex gap-3 mt-1 text-xs text-slate-500">
{track.duration > 0 && <span>{formatDuration(track.duration)}</span>}
{track.size && <span>{formatFileSize(track.size)}</span>}
</div>
</div>
<div className="flex items-center gap-2 flex-shrink-0">
{!isFiltered && (
<div className="text-slate-500 text-xl cursor-grab active:cursor-grabbing">
</div>
)}
<button
onClick={() => onDelete(track.id)}
className="opacity-0 group-hover:opacity-100 text-red-400 hover:text-red-300 text-sm transition-opacity"
title="Delete track"
>
🗑
</button>
</div> </div>
</div> </div>
<button
onClick={() => onDelete(track.id)}
className="opacity-0 group-hover:opacity-100 text-red-400 hover:text-red-300 text-sm transition-opacity"
title="Delete track"
>
🗑
</button>
</div> </div>
</div> );
))} })}
</div> </div>
</div> </div>
); );