fix reordering
This commit is contained in:
@@ -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
|
||||
router.post('/volume', async (req, res) => {
|
||||
try {
|
||||
|
||||
@@ -2,13 +2,13 @@ import express from 'express';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// Get current queue
|
||||
// Get current queue (full library in playback order)
|
||||
router.get('/', async (req, res) => {
|
||||
try {
|
||||
const response = await fetch(`${req.app.locals.botUrl}/state`);
|
||||
const state = await response.json();
|
||||
res.json({
|
||||
tracks: state.queue || [],
|
||||
tracks: state.fullQueue || [],
|
||||
currentTrack: state.currentTrack,
|
||||
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;
|
||||
|
||||
@@ -74,7 +74,8 @@ router.get('/', async (req, res) => {
|
||||
title: file.replace('.mp3', ''),
|
||||
artist: 'Unknown',
|
||||
album: '',
|
||||
duration: 0
|
||||
duration: 0,
|
||||
hasArt: false
|
||||
};
|
||||
|
||||
try {
|
||||
@@ -83,7 +84,8 @@ router.get('/', async (req, res) => {
|
||||
title: parsed.common.title || metadata.title,
|
||||
artist: parsed.common.artist || metadata.artist,
|
||||
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) {
|
||||
// Use defaults if metadata parsing fails
|
||||
@@ -94,7 +96,6 @@ router.get('/', async (req, res) => {
|
||||
filename: file,
|
||||
filepath: file,
|
||||
...metadata,
|
||||
hasArt: false,
|
||||
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
|
||||
router.post('/upload', upload.array('files', MAX_BATCH_SIZE), async (req, res) => {
|
||||
const uploaded = [];
|
||||
|
||||
179
bot/src/index.js
179
bot/src/index.js
@@ -32,6 +32,7 @@ let currentState = {
|
||||
position: 0,
|
||||
volume: 100,
|
||||
queue: [],
|
||||
history: [],
|
||||
shuffled: false
|
||||
};
|
||||
|
||||
@@ -39,6 +40,7 @@ let connection = null;
|
||||
let player = null;
|
||||
let startTime = 0;
|
||||
let currentResource = null;
|
||||
const MAX_HISTORY = 50; // Keep last 50 tracks in history
|
||||
|
||||
// Discord client
|
||||
const client = new Client({
|
||||
@@ -75,7 +77,22 @@ function initializeQueue() {
|
||||
}
|
||||
|
||||
// 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) {
|
||||
logger.info('Queue empty, reloading library');
|
||||
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
|
||||
async function connectToVoice() {
|
||||
try {
|
||||
@@ -177,10 +231,17 @@ app.get('/health', (req, res) => {
|
||||
|
||||
app.get('/state', (req, res) => {
|
||||
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({
|
||||
...currentState,
|
||||
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) => {
|
||||
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 });
|
||||
});
|
||||
|
||||
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) => {
|
||||
const { volume } = req.body;
|
||||
if (volume >= 0 && volume <= 100) {
|
||||
@@ -238,6 +347,70 @@ app.post('/queue/shuffle', (req, res) => {
|
||||
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) => {
|
||||
try {
|
||||
const guild = await client.guilds.fetch(DISCORD_GUILD_ID);
|
||||
|
||||
@@ -9,42 +9,43 @@ import useWebSocket from './hooks/useWebSocket';
|
||||
const API_URL = import.meta.env.VITE_API_URL || '/api';
|
||||
|
||||
function App() {
|
||||
const [tracks, setTracks] = useState([]);
|
||||
const [currentTrack, setCurrentTrack] = useState(null);
|
||||
const [playbackState, setPlaybackState] = useState('stopped');
|
||||
const [position, setPosition] = useState(0);
|
||||
const [volume, setVolume] = useState(100);
|
||||
const [tracks, setTracks] = useState([]);
|
||||
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) => {
|
||||
if (event === 'trackChange') {
|
||||
setCurrentTrack(data.track);
|
||||
loadQueue();
|
||||
} else if (event === 'playbackUpdate') {
|
||||
setPlaybackState(data.state);
|
||||
if (data.position !== undefined) setPosition(data.position);
|
||||
if (data.volume !== undefined) setVolume(data.volume);
|
||||
} else if (event === 'libraryUpdate') {
|
||||
loadTracks();
|
||||
} else if (event === 'libraryUpdate' || event === 'queueUpdate') {
|
||||
loadQueue();
|
||||
}
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
loadTracks();
|
||||
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 () => {
|
||||
try {
|
||||
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) => {
|
||||
try {
|
||||
await fetch(`${API_URL}/playback/volume`, {
|
||||
@@ -106,13 +115,13 @@ function App() {
|
||||
};
|
||||
|
||||
const handleUploadComplete = () => {
|
||||
loadTracks();
|
||||
loadQueue();
|
||||
};
|
||||
|
||||
const handleDeleteTrack = async (trackId) => {
|
||||
try {
|
||||
await fetch(`${API_URL}/tracks/${trackId}`, { method: 'DELETE' });
|
||||
setTracks(tracks.filter(t => t.id !== trackId));
|
||||
loadQueue();
|
||||
} catch (error) {
|
||||
console.error('Failed to delete track:', error);
|
||||
}
|
||||
@@ -143,6 +152,7 @@ function App() {
|
||||
onPlay={handlePlay}
|
||||
onPause={handlePause}
|
||||
onSkip={handleSkip}
|
||||
onPrevious={handlePrevious}
|
||||
onVolumeChange={handleVolumeChange}
|
||||
onShuffle={handleShuffle}
|
||||
/>
|
||||
@@ -158,6 +168,7 @@ function App() {
|
||||
currentTrack={currentTrack}
|
||||
loading={loading}
|
||||
onDelete={handleDeleteTrack}
|
||||
onReload={loadQueue}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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 (
|
||||
<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 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' ? (
|
||||
<button
|
||||
onClick={onPause}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
const API_URL = import.meta.env.VITE_API_URL || '/api';
|
||||
|
||||
function formatTime(seconds) {
|
||||
const mins = Math.floor(seconds / 60);
|
||||
const secs = Math.floor(seconds % 60);
|
||||
@@ -36,8 +38,21 @@ export default function NowPlaying({ track, state, position }) {
|
||||
return (
|
||||
<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="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 className="flex-1">
|
||||
@@ -47,7 +62,10 @@ export default function NowPlaying({ track, state, position }) {
|
||||
|
||||
<div className="mt-4 flex items-center gap-4">
|
||||
<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
|
||||
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%' }}
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
import { useState, useMemo, useEffect } from 'react';
|
||||
|
||||
const API_URL = import.meta.env.VITE_API_URL || '/api';
|
||||
|
||||
function formatDuration(seconds) {
|
||||
const mins = Math.floor(seconds / 60);
|
||||
const secs = Math.floor(seconds % 60);
|
||||
@@ -10,7 +14,65 @@ function formatFileSize(bytes) {
|
||||
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) {
|
||||
return (
|
||||
<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 (
|
||||
<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">
|
||||
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>
|
||||
|
||||
<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">
|
||||
{tracks.map((track) => (
|
||||
<div
|
||||
key={track.id}
|
||||
className={`p-3 rounded-lg transition-colors group ${
|
||||
currentTrack?.id === track.id
|
||||
? 'bg-purple-600/20 border border-purple-500/50'
|
||||
: 'bg-slate-700/30 hover:bg-slate-700/50'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-medium text-white truncate">{track.title}</div>
|
||||
<div className="text-sm text-slate-400 truncate">{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>}
|
||||
{filteredTracks.map((track, index) => {
|
||||
const isCurrentTrack = currentTrack && track.id === currentTrack.id && index === 0;
|
||||
const isFiltered = searchQuery.trim() !== '';
|
||||
|
||||
return (
|
||||
<div
|
||||
key={`${track.id}-${index}`}
|
||||
draggable={!isFiltered}
|
||||
onDragStart={(e) => !isFiltered && handleDragStart(e, index)}
|
||||
onDragOver={(e) => !isFiltered && handleDragOver(e, index)}
|
||||
onDragLeave={!isFiltered ? handleDragLeave : undefined}
|
||||
onDrop={(e) => !isFiltered && handleDrop(e, index)}
|
||||
onDragEnd={!isFiltered ? handleDragEnd : undefined}
|
||||
className={`p-3 rounded-lg transition-all group ${
|
||||
isCurrentTrack
|
||||
? '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>
|
||||
|
||||
<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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user