fix reordering
This commit is contained in:
@@ -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