)}
- {!albumDetail && !showCoverGrid && !searching && !searchOpen && !loading && dirs.map(item => (
-
- ))}
-
- {!albumDetail && !searching && !searchOpen && !loading && trackItems.map((track, i) => {
- const artist = track.artists?.map(a => a.name).join(', ') ?? '';
- return (
-
-
{i + 1}
-
-
{track.name}
- {artist &&
{artist}}
+ {!albumDetail && !showCoverGrid && !searching && !searchOpen && !loading && (
+ <>
+ {dirItems.map(item => (
+
navigate(item)}>
+ DIR
+ {item.name}
+ ›
-
{ e.stopPropagation(); onAdd([track.uri], 'Track added to queue'); }}
- >+
-
- );
- })}
+ ))}
+ {otherItems.map(item => (
+
navigate(item)}>
+ {TYPE_LABEL[item.type] ?? '···'}
+ {item.name}
+ ›
+
+ ))}
+ {trackItems.map((item, i) => {
+ const artist = item.artists?.map(a => a.name).join(', ') ?? '';
+ return (
+
+
{TYPE_LABEL[item.type] ?? (i + 1)}
+
+ {item.name}
+ {artist && {artist}}
+
+
{ e.stopPropagation(); onAdd([item.uri], 'Track added to queue'); }}
+ >+
+
+ );
+ })}
+ >
+ )}
+
+ {!albumDetail && !loading && !searching && !searchOpen && items?.length === 0 && (
+
+ {currentUri === 'spotify:directory'
+ ? 'Spotify not configured — add credentials in Settings → Spotify, then restart Mopidy.'
+ : `No ${mode?.label.toLowerCase() ?? 'items'} found here.`}
+
+ )}
{!albumDetail && searching && !loading && searchAlbums.length > 0 && (
<>
-
Albums
+
Albums
>
)}
{!albumDetail && searching && !loading && searchTracks.map((track, i) => {
const artist = track.artists?.map(a => a.name).join(', ') ?? '';
- const coverUri = coverUriFor(searchTrackCovers, track.uri) || coverUriFor(searchTrackCovers, track.album?.uri);
+ const uri = coverUriFor(searchTrackCovers, track.uri) || coverUriFor(searchTrackCovers, track.album?.uri);
return (
- {coverUri ?
: 'TRK'}
+ {uri ?
: 'TRK'}
{track.name}
@@ -847,23 +608,40 @@ function SpotifyBrowser({ onAdd }) {
})}
{searchOpen && !searching && !query.trim() && (
-
Search for albums and tracks on Spotify.
- )}
- {searching && !loading && searchAlbums.length === 0 && searchTracks.length === 0 && (
-
No results for "{query}".
- )}
- {!albumDetail && !loading && !searching && !searchOpen && items?.length === 0 && (
- {currentUri === 'spotify:directory'
- ? 'Spotify not configured — add credentials in Settings → Spotify, then restart Mopidy.'
- : 'Nothing here.'}
+ {searchUris ? 'Search for albums and tracks on Spotify.' : 'Enter a search term to find tracks.'}
)}
+ {searching && !loading && searchTracks.length === 0 && searchAlbums.length === 0 && (
+
No results for "{query}".
+ )}
);
}
+function SourceBrowser({ onAdd }) {
+ const [source, setSource] = useState('local');
+ return (
+
+
+
+
+
+ {source === 'local'
+ ?
+ :
+ }
+
+ );
+}
+
export function MopidyPanel() {
const m = useMopidy();
const [queueOpen, setQueueOpen] = useState(false);
@@ -878,16 +656,11 @@ export function MopidyPanel() {
.then(payload => {
if (!cancelled && Array.isArray(payload.bars)) setVisualizerBars(payload.bars);
})
- .catch(() => {
- if (!cancelled) setVisualizerBars(null);
- });
+ .catch(() => { if (!cancelled) setVisualizerBars(null); });
};
loadVisualizer();
const interval = setInterval(loadVisualizer, 300);
- return () => {
- cancelled = true;
- clearInterval(interval);
- };
+ return () => { cancelled = true; clearInterval(interval); };
}, []);
useEffect(() => () => window.clearTimeout(noticeTimer.current), []);
@@ -901,6 +674,13 @@ export function MopidyPanel() {
m.seek(Math.round(x * m.duration));
};
+ const handleSeekKey = (e) => {
+ if (!m.duration) return;
+ const step = e.shiftKey ? 30000 : 5000;
+ if (e.key === 'ArrowRight') { e.preventDefault(); m.seek(Math.min(m.position + step, m.duration)); }
+ if (e.key === 'ArrowLeft') { e.preventDefault(); m.seek(Math.max(m.position - step, 0)); }
+ };
+
const notify = (message) => {
setNotice(message);
window.clearTimeout(noticeTimer.current);
@@ -922,14 +702,13 @@ export function MopidyPanel() {
return (
{notice &&
{notice}
}
- {/* Connection status */}
+
{!m.connected && (
Mopidy unreachable
)}
- {/* Now playing */}
NOW PLAYING
@@ -937,8 +716,17 @@ export function MopidyPanel() {
- {/* Progress bar */}
-
+
@@ -947,7 +735,6 @@ export function MopidyPanel() {
- {/* Transport */}
@@ -1018,16 +805,13 @@ export function MopidyPanel() {
)}
- {/* Playlists */}
- {/* Media browser */}
-
);
}
diff --git a/audiocontrol/src/components/MopidyPanel.jsx.bak b/audiocontrol/src/components/MopidyPanel.jsx.bak
deleted file mode 100644
index b35b1d4..0000000
--- a/audiocontrol/src/components/MopidyPanel.jsx.bak
+++ /dev/null
@@ -1,429 +0,0 @@
-import { useState, useEffect } from 'react';
-import { useMopidy } from '../hooks/useMopidy';
-import { mopidy, formatTime } from '../api/mopidy';
-
-function imageProxyUri(uri) {
- if (!uri) return null;
- if (uri.startsWith('http') || uri.startsWith('data:')) return uri;
- const path = uri.replace(/^\/+/, '').replace(/^mopidy\//, '');
- return `/api/mopidy-image/${path}`;
-}
-
-function TrackInfo({ track }) {
- const [coverUri, setCoverUri] = useState(null);
-
- useEffect(() => {
- const current = track?.track;
- if (!current?.uri) {
- setCoverUri(null);
- return undefined;
- }
-
- let cancelled = false;
- mopidy.getImages([current.uri, current.album?.uri].filter(Boolean))
- .then(images => {
- if (cancelled) return;
- const image = images?.[current.uri]?.[0] ?? images?.[current.album?.uri]?.[0];
- setCoverUri(imageProxyUri(image?.uri));
- })
- .catch(() => {
- if (!cancelled) setCoverUri(null);
- });
-
- return () => { cancelled = true; };
- }, [track?.track?.uri, track?.track?.album?.uri]);
-
- if (!track) return (
-
-
ALB
-
Nothing playing
-
- );
- const t = track.track;
- const artist = t.artists?.map(a => a.name).join(', ') ?? '';
- const album = t.album?.name ?? '';
- {/* CAVA Visualization */}
-
- return (
-
-
- {coverUri ?

: 'ALB'}
-
-
-
{t.name}
-
{[artist, album].filter(Boolean).join(' · ')}
-
-
- );
-}
-
-function ToggleBtn({ label, active, onClick }) {
- return (
-
- );
-}
-
-function PlaylistBrowser({ playlists, onLoad }) {
- const [open, setOpen] = useState(false);
- if (!playlists.length) return null;
- return (
-
-
- {open && (
-
- {playlists.map(pl => (
-
- ))}
-
- )}
-
- );
-}
-
-function Queue({ tracklist, currentTrack, onPlay, onRemove }) {
- if (!tracklist.length) return null;
- return (
-
- {tracklist.map(item => {
- const isCurrent = item.tlid === currentTrack?.tlid;
- const t = item.track;
- const artist = t.artists?.map(a => a.name).join(', ') ?? '';
- return (
-
-
-
- {t.name}
- {artist && {artist}}
-
-
{formatTime(t.length)}
-
-
- );
- })}
-
- );
-}
-
-const TYPE_LABEL = { directory: 'DIR', album: 'ALB', artist: 'ART', playlist: 'PLS', track: 'TRK' };
-const BROWSE_MODES = [
- { id: 'artist', label: 'Artists', uri: 'local:directory?type=artist', crumb: 'Artists' },
- { id: 'album', label: 'Albums', uri: 'local:directory?type=album', crumb: 'Albums' },
- { id: 'track', label: 'Tracks', uri: 'local:directory?type=track', crumb: 'Tracks' },
-];
-const DEFAULT_MODE = BROWSE_MODES[1];
-
-function coverUriFor(imageMap, uri) {
- const image = imageMap[uri]?.[0];
- return imageProxyUri(image?.uri);
-}
-
-function CoverGrid({ albums, covers, onOpen }) {
- return (
-
- {albums.map(album => {
- const coverUri = coverUriFor(covers, album.uri);
- return (
-
- );
- })}
-
- );
-}
-
-function MediaBrowser({ onAdd }) {
- const [mode, setMode] = useState(DEFAULT_MODE);
- const [stack, setStack] = useState([{ name: DEFAULT_MODE.crumb, uri: DEFAULT_MODE.uri }]); // [{ name, uri }]
- const [items, setItems] = useState(null);
- const [covers, setCovers] = useState({});
- const [loading, setLoading] = useState(false);
- const [query, setQuery] = useState('');
- const [searchOpen, setSearchOpen] = useState(false);
- const [searching, setSearching] = useState(false);
- const [results, setResults] = useState([]);
-
- const currentUri = stack.length ? stack[stack.length - 1].uri : null;
-
- useEffect(() => {
- if (searching) return;
- let cancelled = false;
- setLoading(true);
- setItems(null);
- setCovers({});
- mopidy.browse(currentUri)
- .then(r => { if (!cancelled) setItems(r ?? []); })
- .catch(() => { if (!cancelled) setItems([]); })
- .finally(() => { if (!cancelled) setLoading(false); });
- return () => { cancelled = true; };
- }, [currentUri, searching]);
-
- useEffect(() => {
- if (searching || !items?.length) return;
- const albums = items.filter(i => i.type === 'album' || (mode.id === 'album' && i.type === 'directory'));
- if (!albums.length) return;
- let cancelled = false;
- mopidy.getImages(albums.map(album => album.uri))
- .then(r => { if (!cancelled) setCovers(r ?? {}); })
- .catch(() => { if (!cancelled) setCovers({}); });
- return () => { cancelled = true; };
- }, [mode.id, items, searching]);
-
- const navigate = (item) => setStack(s => [...s, { name: item.name, uri: item.uri }]);
- const back = () => setStack(s => s.slice(0, -1));
- const setBrowseMode = (nextMode) => {
- setMode(nextMode);
- setStack([{ name: nextMode.crumb, uri: nextMode.uri }]);
- clearSearch();
- };
-
- const doSearch = async () => {
- if (!query.trim()) { setSearching(false); setSearchOpen(false); return; }
- setSearching(true);
- setLoading(true);
- try {
- const res = await mopidy.search(query);
- setResults((res ?? []).flatMap(r => r.tracks ?? []).slice(0, 100));
- } catch { setResults([]); }
- finally { setLoading(false); }
- };
-
- const clearSearch = () => { setSearching(false); setSearchOpen(false); setQuery(''); setResults([]); };
- const toggleSearch = () => {
- if (searchOpen || searching) clearSearch();
- else setSearchOpen(true);
- };
-
- const trackUris = items?.filter(i => i.type === 'track').map(i => i.uri) ?? [];
- const albumItems = items?.filter(i => i.type === 'album' || (mode.id === 'album' && i.type === 'directory')) ?? [];
- const showCoverGrid = !searching && !loading && mode.id === 'album' && albumItems.length > 0;
-
- return (
-
-
- {BROWSE_MODES.map(option => (
-
- ))}
-
-
-
- {(searchOpen || searching) && (
-
- setQuery(e.target.value)}
- onKeyDown={e => { if (e.key === 'Enter') doSearch(); if (e.key === 'Escape') clearSearch(); }}
- />
- {searching
- ?
- :
- }
-
- )}
-
- {!searching && (
-
-
-
- {stack.length === 0 ? 'root' : stack[stack.length - 1].name}
-
- {trackUris.length > 0 && (
-
- )}
-
- )}
-
-
- {loading &&
Loading…
}
-
- {showCoverGrid && (
-
- )}
-
- {!showCoverGrid && !searching && !loading && items?.map(item => {
- const isTrack = item.type === 'track';
- return (
-
!isTrack && navigate(item)}
- >
- {TYPE_LABEL[item.type] ?? '···'}
- {item.name}
- {isTrack
- ?
- : ›
- }
-
- );
- })}
- {!searching && !loading && items?.length === 0 &&
Empty
}
-
- {searching && !loading && results.map((track, i) => (
-
-
TRK
-
- {track.name}
- {track.artists?.length > 0 && (
- {track.artists.map(a => a.name).join(', ')}
- )}
-
-
-
- ))}
- {searching && !loading && results.length === 0 &&
No results
}
-
-
- );
-}
-
-function BrowserSection({ onAdd }) {
- const [open, setOpen] = useState(true);
- return (
-
-
- {open && }
-
- );
-}
-
-export function MopidyPanel() {
- const m = useMopidy();
- const [queueOpen, setQueueOpen] = useState(false);
-
- const isPlaying = m.playbackState === 'playing';
- const progressPct = m.duration > 0 ? (m.position / m.duration) * 100 : 0;
-
- const handleSeek = (e) => {
- const rect = e.currentTarget.getBoundingClientRect();
- const x = (e.clientX - rect.left) / rect.width;
- m.seek(Math.round(x * m.duration));
- };
-
- return (
-
- {/* Connection status */}
- {!m.connected && (
-
- Mopidy unreachable
-
- )}
-
- {/* Now playing */}
-
-
- NOW PLAYING
- {m.connected && }
-
-
-
- {/* Progress bar */}
-
-
- {formatTime(m.position)}
- {formatTime(m.duration)}
-
-
-
- {/* Transport */}
-
-
-
-
-
-
-
-
-
-
-
-
{m.volume ?? '--'}
-
-
-
m.setVolume(parseInt(e.target.value))}
- />
-
-
-
-
- m.setRepeat(!m.repeat)} />
- m.setRandom(!m.random)} />
- m.setSingle(!m.single)} />
- m.setMute(!m.mute)} />
-
- {queueOpen && (
-
-
- QUEUE
- {m.tracklist.length > 0 && (
-
- )}
-
-
- {m.tracklist.length === 0 &&
Queue empty
}
-
- )}
-
-
-
- {/* Playlists */}
-
-
- {/* Media browser */}
-
-
- );
-}
diff --git a/audiocontrol/src/hooks/useCoverArt.js b/audiocontrol/src/hooks/useCoverArt.js
new file mode 100644
index 0000000..6ef3435
--- /dev/null
+++ b/audiocontrol/src/hooks/useCoverArt.js
@@ -0,0 +1,36 @@
+import { useState, useEffect } from 'react';
+import { mopidy } from '../api/mopidy';
+
+export function imageProxyUri(uri) {
+ if (!uri) return null;
+ if (uri.startsWith('http') || uri.startsWith('data:')) return uri;
+ const path = uri.replace(/^\/+/, '').replace(/^mopidy\//, '');
+ return `/api/mopidy-image/${path}`;
+}
+
+export function useCoverArt(trackUri, albumUri) {
+ const [coverUri, setCoverUri] = useState(null);
+
+ useEffect(() => {
+ const uris = [trackUri, albumUri].filter(Boolean);
+ if (!uris.length) {
+ setCoverUri(null);
+ return undefined;
+ }
+
+ let cancelled = false;
+ mopidy.getImages(uris)
+ .then(images => {
+ if (cancelled) return;
+ const image = images?.[trackUri]?.[0] ?? images?.[albumUri]?.[0];
+ setCoverUri(imageProxyUri(image?.uri));
+ })
+ .catch(() => {
+ if (!cancelled) setCoverUri(null);
+ });
+
+ return () => { cancelled = true; };
+ }, [trackUri, albumUri]);
+
+ return coverUri;
+}
diff --git a/audiocontrol/src/hooks/useMopidy.js b/audiocontrol/src/hooks/useMopidy.js
index c33e0ef..f5f422e 100644
--- a/audiocontrol/src/hooks/useMopidy.js
+++ b/audiocontrol/src/hooks/useMopidy.js
@@ -23,6 +23,7 @@ export function useMopidy() {
const ws = useRef(null);
const positionTimer = useRef(null);
const positionRef = useRef(0);
+ const refreshTimerRef = useRef(null);
const tick = useCallback(() => {
positionRef.current += 1000;
@@ -81,6 +82,11 @@ export function useMopidy() {
}
}, [startPositionTimer, stopPositionTimer]);
+ const debouncedRefresh = useCallback(() => {
+ clearTimeout(refreshTimerRef.current);
+ refreshTimerRef.current = setTimeout(refresh, 80);
+ }, [refresh]);
+
// Mopidy WebSocket for real-time events
useEffect(() => {
let reconnectTimer;
@@ -105,8 +111,7 @@ export function useMopidy() {
try {
const msg = JSON.parse(evt.data);
if (!msg.event) return;
- // Refresh state on any event
- refresh();
+ debouncedRefresh();
} catch {}
};
};
@@ -114,10 +119,11 @@ export function useMopidy() {
connect();
return () => {
clearTimeout(reconnectTimer);
+ clearTimeout(refreshTimerRef.current);
clearInterval(positionTimer.current);
ws.current?.close();
};
- }, [refresh]);
+ }, [refresh, debouncedRefresh]);
// Controls
const controls = {
diff --git a/audiocontrol/src/hooks/useSnapcast.js b/audiocontrol/src/hooks/useSnapcast.js
index 936428b..235824c 100644
--- a/audiocontrol/src/hooks/useSnapcast.js
+++ b/audiocontrol/src/hooks/useSnapcast.js
@@ -55,11 +55,15 @@ export function useSnapcast() {
send('Client.SetVolume', { id: clientId, volume: { percent, muted: false } })
.then(refreshStatus), [send, refreshStatus]);
- const setClientMute = useCallback((clientId, muted) =>
- send('Client.SetVolume', {
- id: clientId,
- volume: { percent: clients.find(c => c.id === clientId)?.config?.volume?.percent ?? 100, muted }
- }).then(refreshStatus), [send, refreshStatus, clients]);
+ const setClientMute = useCallback(async (clientId, muted) => {
+ const fresh = await send('Server.GetStatus');
+ const freshClient = (fresh?.server ?? fresh)?.groups
+ ?.flatMap(g => g.clients)
+ ?.find(c => c.id === clientId);
+ const percent = freshClient?.config?.volume?.percent ?? 100;
+ await send('Client.SetVolume', { id: clientId, volume: { percent, muted } });
+ refreshStatus();
+ }, [send, refreshStatus]);
const setGroupStream = useCallback((groupId, streamId) =>
send('Group.SetStream', { id: groupId, stream_id: streamId })
diff --git a/bandcamp-api/app.py b/bandcamp-api/app.py
index 78f2620..04574ee 100644
--- a/bandcamp-api/app.py
+++ b/bandcamp-api/app.py
@@ -126,7 +126,7 @@ class _Handler(BaseHTTPRequestHandler):
self._json(404, {"error": "not found"})
def do_POST(self):
- length = int(self.headers.get("Content-Length", 0))
+ length = min(int(self.headers.get("Content-Length", 0)), 65536)
try:
body = json.loads(self.rfile.read(length) or b"{}")
except json.JSONDecodeError:
diff --git a/scripts/requirements.txt b/scripts/requirements.txt
index a71dcf5..55fa324 100644
--- a/scripts/requirements.txt
+++ b/scripts/requirements.txt
@@ -1,5 +1,8 @@
-# Optional dependencies for snapcast-now-playing.py
+# Dependencies for snapcast-now-playing.py
# Install with: pip3 install -r scripts/requirements.txt
+# HTTP framework for the now-playing service
+flask
+
# Significantly faster FFT for the visualizer (falls back to pure Python if absent)
numpy