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

@@ -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>

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 (
<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}

View File

@@ -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%' }}

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) {
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>
);