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
|
// Set volume
|
||||||
router.post('/volume', async (req, res) => {
|
router.post('/volume', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 = [];
|
||||||
|
|||||||
179
bot/src/index.js
179
bot/src/index.js
@@ -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);
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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%' }}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user