really add con/disconn
remove file
This commit is contained in:
82
web/src/components/ConnectionStatus.jsx
Normal file
82
web/src/components/ConnectionStatus.jsx
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
|
||||||
|
const API_URL = import.meta.env.VITE_API_URL || '/api';
|
||||||
|
|
||||||
|
export default function ConnectionStatus() {
|
||||||
|
const [channels, setChannels] = useState([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [disconnecting, setDisconnecting] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadChannels();
|
||||||
|
const interval = setInterval(loadChannels, 5000);
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const loadChannels = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const response = await fetch(`${API_URL}/channels`);
|
||||||
|
const data = await response.json();
|
||||||
|
setChannels(data.channels || []);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load channels:', error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDisconnect = async () => {
|
||||||
|
try {
|
||||||
|
setDisconnecting(true);
|
||||||
|
const response = await fetch(`${API_URL}/channels/disconnect`, {
|
||||||
|
method: 'POST'
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
loadChannels();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to disconnect:', error);
|
||||||
|
} finally {
|
||||||
|
setDisconnecting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const currentChannel = channels.find(c => c.current);
|
||||||
|
|
||||||
|
if (loading && !currentChannel) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2 px-4 py-2 bg-slate-700/50 rounded-lg">
|
||||||
|
<div className="text-slate-400 text-sm">Loading...</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!currentChannel) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2 px-4 py-2 bg-slate-700/50 rounded-lg">
|
||||||
|
<div className="w-2 h-2 bg-slate-500 rounded-full"></div>
|
||||||
|
<div className="text-slate-400 text-sm">Disconnected</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-3 px-4 py-2 bg-green-600/20 border border-green-500/50 rounded-lg">
|
||||||
|
<div className="flex items-center gap-2 flex-1">
|
||||||
|
<div className="w-2 h-2 bg-green-500 rounded-full animate-pulse"></div>
|
||||||
|
<div className="text-green-300 text-sm font-medium">
|
||||||
|
Connected to {currentChannel.name}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={handleDisconnect}
|
||||||
|
disabled={disconnecting}
|
||||||
|
className="px-3 py-1 bg-red-600 hover:bg-red-700 disabled:opacity-50 disabled:cursor-not-allowed text-white text-sm rounded transition-colors"
|
||||||
|
>
|
||||||
|
{disconnecting ? 'Disconnecting...' : 'Disconnect'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
151
web/src/components/Queue.jsx
Normal file
151
web/src/components/Queue.jsx
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
import { useState, 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);
|
||||||
|
return `${mins}:${secs.toString().padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Queue({ onQueueUpdate }) {
|
||||||
|
const [queue, setQueue] = useState([]);
|
||||||
|
const [currentTrack, setCurrentTrack] = useState(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [draggedIndex, setDraggedIndex] = useState(null);
|
||||||
|
const [dropTargetIndex, setDropTargetIndex] = useState(null);
|
||||||
|
|
||||||
|
const loadQueue = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const response = await fetch(`${API_URL}/queue`);
|
||||||
|
const data = await response.json();
|
||||||
|
setQueue(data.tracks || []);
|
||||||
|
setCurrentTrack(data.currentTrack);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load queue:', error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
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 })
|
||||||
|
});
|
||||||
|
loadQueue();
|
||||||
|
if (onQueueUpdate) onQueueUpdate();
|
||||||
|
} 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);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadQueue();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
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 mb-4">
|
||||||
|
<h3 className="text-xl font-bold text-white">
|
||||||
|
Playback Queue <span className="text-slate-400 text-sm font-normal">({queue.length} tracks)</span>
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading && (
|
||||||
|
<div className="text-center text-slate-400 py-8">Loading...</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loading && queue.length === 0 && (
|
||||||
|
<div className="text-center text-slate-400 py-8">
|
||||||
|
<p>No tracks in library</p>
|
||||||
|
<p className="text-sm mt-2">Upload some MP3 files to get started</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loading && queue.length > 0 && (
|
||||||
|
<div className="space-y-2 max-h-[400px] overflow-y-auto pr-2">
|
||||||
|
{queue.map((track, index) => {
|
||||||
|
const isCurrentTrack = currentTrack && track.id === currentTrack.id && index === 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={`${track.id}-${index}`}
|
||||||
|
draggable
|
||||||
|
onDragStart={(e) => handleDragStart(e, index)}
|
||||||
|
onDragOver={(e) => handleDragOver(e, index)}
|
||||||
|
onDragLeave={handleDragLeave}
|
||||||
|
onDrop={(e) => handleDrop(e, index)}
|
||||||
|
onDragEnd={handleDragEnd}
|
||||||
|
className={`p-3 rounded-lg transition-all cursor-move ${
|
||||||
|
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'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className={`text-sm font-mono w-6 ${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>
|
||||||
|
{track.duration > 0 && (
|
||||||
|
<div className="text-xs text-slate-500">
|
||||||
|
{formatDuration(track.duration)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="text-slate-500 text-xl cursor-grab active:cursor-grabbing">
|
||||||
|
⋮⋮
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user