diff --git a/web/src/components/ConnectionStatus.jsx b/web/src/components/ConnectionStatus.jsx
new file mode 100644
index 0000000..2a36ea8
--- /dev/null
+++ b/web/src/components/ConnectionStatus.jsx
@@ -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 (
+
+ );
+ }
+
+ if (!currentChannel) {
+ return (
+
+ );
+ }
+
+ return (
+
+
+
+
+ Connected to {currentChannel.name}
+
+
+
+
+ );
+}
diff --git a/web/src/components/Queue.jsx b/web/src/components/Queue.jsx
new file mode 100644
index 0000000..4aaf6ac
--- /dev/null
+++ b/web/src/components/Queue.jsx
@@ -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 (
+
+
+
+ Playback Queue ({queue.length} tracks)
+
+
+
+ {loading && (
+
Loading...
+ )}
+
+ {!loading && queue.length === 0 && (
+
+
No tracks in library
+
Upload some MP3 files to get started
+
+ )}
+
+ {!loading && queue.length > 0 && (
+
+ {queue.map((track, index) => {
+ const isCurrentTrack = currentTrack && track.id === currentTrack.id && index === 0;
+
+ return (
+
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'
+ }`}
+ >
+
+
+ {isCurrentTrack ? '▶' : index + 1}
+
+
+
+ {track.title}
+
+
+ {track.artist}
+
+
+ {track.duration > 0 && (
+
+ {formatDuration(track.duration)}
+
+ )}
+
+ ⋮⋮
+
+
+
+ );
+ })}
+
+ )}
+
+ );
+}