Refactor cover art handling and improve accessibility

- Extract cover art fetching logic into a dedicated useCoverArt hook
- Replace duplicate cover art fetching code in MopidyPanel with the new hook
- Improve accessibility in App.jsx by using proper ARIA attributes (aria-selected, role="tab")
- Add Flask dependency to requirements.txt for now-playing service
- Add Content-Length limit to bandcamp-api to prevent DoS attacks
- Remove MopidyPanel.jsx.bak file

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-17 14:40:44 +02:00
parent 4d53efc9e4
commit 8393271ee3
8 changed files with 253 additions and 848 deletions
+3 -2
View File
@@ -24,13 +24,14 @@ export default function App() {
<span className="app-title-main">AUDIO</span>
<span className="app-title-sub">CONTROL</span>
</div>
<nav className="tab-nav">
<nav className="tab-nav" role="tablist">
{TABS.map(t => (
<button
key={t.id}
className={`tab-btn ${tab === t.id ? 'tab-btn--active' : ''}`}
onClick={() => setTab(t.id)}
aria-pressed={tab === t.id}
role="tab"
aria-selected={tab === t.id}
>
{t.label}
</button>
+191 -407
View File
@@ -1,15 +1,9 @@
import { Fragment, useRef, useState, useEffect } from 'react';
import { Fragment, useCallback, useRef, useState, useEffect } from 'react';
import { useMopidy } from '../hooks/useMopidy';
import { useCoverArt, imageProxyUri } from '../hooks/useCoverArt';
import { mopidy, formatTime } from '../api/mopidy';
import { nowPlayingApi } from '../api/nowPlaying';
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}`;
}
const VISUALIZER_BARS = [28, 56, 38, 72, 46, 84, 31, 63, 51, 76, 42, 68, 34, 58, 87, 45, 70, 36, 62, 49, 79, 40, 66, 53];
function MopidyCavaOverlay({ bars }) {
@@ -18,7 +12,7 @@ function MopidyCavaOverlay({ bars }) {
<div className={`cava-overlay cava-overlay--cover ${bars?.length ? 'cava-overlay--live' : ''}`} aria-hidden="true">
{values.map((height, index) => (
<span
key={`mop-${height}-${index}`}
key={index}
style={{ '--bar-height': `${height}%`, '--bar-delay': `${index * -0.055}s` }}
/>
))}
@@ -41,28 +35,7 @@ function IconButton({ className = '', title, children, onClick, disabled }) {
}
function TrackInfo({ track, bars }) {
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]);
const coverUri = useCoverArt(track?.track?.uri, track?.track?.album?.uri);
if (!track) return (
<div className="now-playing now-playing--empty">
@@ -117,28 +90,7 @@ function PlaylistBrowser({ playlists, onLoad }) {
}
function TrackThumb({ track, className = '' }) {
const [coverUri, setCoverUri] = useState(null);
useEffect(() => {
if (!track?.uri) {
setCoverUri(null);
return undefined;
}
let cancelled = false;
mopidy.getImages([track.uri, track.album?.uri].filter(Boolean))
.then(images => {
if (cancelled) return;
const image = images?.[track.uri]?.[0] ?? images?.[track.album?.uri]?.[0];
setCoverUri(imageProxyUri(image?.uri));
})
.catch(() => {
if (!cancelled) setCoverUri(null);
});
return () => { cancelled = true; };
}, [track?.uri, track?.album?.uri]);
const coverUri = useCoverArt(track?.uri, track?.album?.uri);
return (
<span className={`track-thumb ${className}`}>
{coverUri ? <img src={coverUri} alt="" loading="lazy" /> : 'ALB'}
@@ -172,12 +124,6 @@ function Queue({ tracklist, currentTrack, onPlay, onRemove }) {
}
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 trackDisc(track) {
return Number(track?.disc_no ?? track?.disc ?? 0) || 0;
@@ -186,9 +132,7 @@ function trackDisc(track) {
function leadingTrackNumber(value) {
if (!value) return 0;
let decoded = String(value);
try {
decoded = decodeURIComponent(decoded);
} catch {}
try { decoded = decodeURIComponent(decoded); } catch {}
const fileName = decoded.split('/').pop() ?? decoded;
const match = fileName.match(/^\D*(?:disc\s*\d+\D*)?(\d{1,3})(?:\D|$)/i);
return match ? Number(match[1]) || 0 : 0;
@@ -203,16 +147,10 @@ function trackNumber(track) {
function sortAlbumTracks(tracks) {
return tracks.map((track, index) => ({ track, index })).sort((a, b) => {
const trackA = a.track;
const trackB = b.track;
const da = trackDisc(trackA);
const db = trackDisc(trackB);
const da = trackDisc(a.track), db = trackDisc(b.track);
if (da !== db) return da - db;
const ta = trackNumber(trackA);
const tb = trackNumber(trackB);
const ta = trackNumber(a.track), tb = trackNumber(b.track);
if (ta !== tb) return ta - tb;
return a.index - b.index;
}).map(({ track }) => track);
}
@@ -228,7 +166,7 @@ function discGroups(tracks) {
}
async function resolveTrackRefs(trackRefs) {
const resolved = await Promise.all(trackRefs.map(async track => {
return Promise.all(trackRefs.map(async track => {
try {
const lookedUp = await mopidy.lookup(track.uri);
return lookedUp?.[0] ?? track;
@@ -236,23 +174,21 @@ async function resolveTrackRefs(trackRefs) {
return track;
}
}));
return resolved;
}
function coverUriFor(imageMap, uri) {
const image = imageMap[uri]?.[0];
return imageProxyUri(image?.uri);
return imageProxyUri(imageMap[uri]?.[0]?.uri);
}
function CoverGrid({ albums, covers, onOpen }) {
return (
<div className="cover-grid">
{albums.map(album => {
const coverUri = coverUriFor(covers, album.uri);
const uri = coverUriFor(covers, album.uri);
return (
<button key={album.uri} className="cover-card" onClick={() => onOpen(album)}>
<span className="cover-art">
{coverUri ? <img src={coverUri} alt="" loading="lazy" /> : 'ALB'}
{uri ? <img src={uri} alt="" loading="lazy" /> : 'ALB'}
</span>
<span className="cover-title">{album.name}</span>
</button>
@@ -276,10 +212,9 @@ function AlbumDetail({ album, onBack, onAdd }) {
.then(async items => {
if (cancelled) return;
const trackRefs = (items ?? []).filter(i => i.type === 'track');
const resolvedTracks = await resolveTrackRefs(trackRefs);
const resolved = await resolveTrackRefs(trackRefs);
if (cancelled) return;
const sortedTracks = sortAlbumTracks(resolvedTracks);
setTracks(sortedTracks);
setTracks(sortAlbumTracks(resolved));
})
.catch(() => { if (!cancelled) setTracks([]); })
.finally(() => { if (!cancelled) setLoading(false); });
@@ -362,9 +297,7 @@ function AlbumDetail({ album, onBack, onAdd }) {
className="browser-add-btn"
title="Add track to queue"
onClick={e => { e.stopPropagation(); onAdd([track.uri], 'Track added to queue'); }}
>
+
</IconButton>
>+</IconButton>
</div>
))}
</Fragment>
@@ -374,252 +307,27 @@ function AlbumDetail({ album, onBack, onAdd }) {
);
}
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 [resultCovers, setResultCovers] = useState({});
// Local library browse modes — defined outside component for stable reference
const LOCAL_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 [albumDetail, setAlbumDetail] = useState(null); // { name, uri } or null
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]);
useEffect(() => {
if (!searching || !results.length) {
setResultCovers({});
return undefined;
}
let cancelled = false;
const uris = results.flatMap(track => [track.uri, track.album?.uri].filter(Boolean));
mopidy.getImages([...new Set(uris)])
.then(r => { if (!cancelled) setResultCovers(r ?? {}); })
.catch(() => { if (!cancelled) setResultCovers({}); });
return () => { cancelled = true; };
}, [results, searching]);
const navigate = (item) => {
if (item.type === 'album' || (mode.id === 'album' && item.type === 'directory')) {
setAlbumDetail({ name: item.name, uri: item.uri });
} else {
setAlbumDetail(null);
setStack(s => [...s, { name: item.name, uri: item.uri }]);
}
};
const back = () => {
if (albumDetail) {
setAlbumDetail(null);
} else {
setStack(s => s.slice(0, -1));
}
};
const setBrowseMode = (nextMode) => {
setMode(nextMode);
setStack([{ name: nextMode.crumb, uri: nextMode.uri }]);
setAlbumDetail(null);
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 { setSearching(false); setResults([]); }
finally { setLoading(false); }
};
const clearSearch = () => { setSearching(false); setSearchOpen(false); setQuery(''); setResults([]); setResultCovers({}); setAlbumDetail(null); };
const toggleSearch = () => {
if (searchOpen || searching) clearSearch();
else setSearchOpen(true);
};
const browserTracks = sortAlbumTracks(items?.filter(i => i.type === 'track') ?? []);
const trackUris = browserTracks.map(i => i.uri);
const albumItems = items?.filter(i => i.type === 'album' || (mode.id === 'album' && i.type === 'directory')) ?? [];
const showCoverGrid = !searchOpen && !searching && !loading && mode.id === 'album' && albumItems.length > 0;
const breadcrumbItems = stack.length ? stack : [{ name: 'Root', uri: null }];
return (
<div className="browser">
<div className="browser-mode-row">
{BROWSE_MODES.map(option => (
<button
key={option.id}
className={`browser-mode-btn ${mode.id === option.id && !searching ? 'browser-mode-btn--active' : ''}`}
onClick={() => setBrowseMode(option)}
>
{option.label}
</button>
))}
<button
className={`browser-mode-btn browser-mode-btn--search ${searchOpen || searching ? 'browser-mode-btn--active' : ''}`}
onClick={toggleSearch}
>
Search
</button>
</div>
{(searchOpen || searching) && (
<div className="browser-search-row">
<input
className="browser-input"
type="search"
placeholder="Search library…"
value={query}
onChange={e => setQuery(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') doSearch(); if (e.key === 'Escape') clearSearch(); }}
/>
{searching
? <button className="browser-icon-btn" onClick={clearSearch}></button>
: <button className="browser-icon-btn" onClick={doSearch}></button>
}
</div>
)}
{!searchOpen && !searching && (
<div className="browser-nav">
<IconButton className="browser-back-btn" title="Back" onClick={back} disabled={!albumDetail && stack.length <= 1}></IconButton>
<div className="browser-crumbs mono" title={breadcrumbItems.map(item => item.name).join(' / ')}>
{breadcrumbItems.map((item, index) => (
<span key={`${item.uri ?? 'root'}-${index}`} className="browser-crumb">
{index > 0 && <span className="browser-crumb-sep">/</span>}
{item.name}
</span>
))}
{albumDetail && (
<span className="browser-crumb">
<span className="browser-crumb-sep">/</span>
{albumDetail.name}
</span>
)}
</div>
{trackUris.length > 0 && (
<IconButton className="browser-add-all-btn" title={`Add ${trackUris.length} tracks to queue`} onClick={() => onAdd(trackUris, `${trackUris.length} tracks added to queue`)}>
+ {trackUris.length}
</IconButton>
)}
</div>
)}
<div className="browser-list">
{albumDetail && (
<AlbumDetail
album={albumDetail}
onBack={back}
onAdd={onAdd}
/>
)}
{!albumDetail && loading && <div className="browser-msg">Loading {mode.label.toLowerCase()}</div>}
{!albumDetail && showCoverGrid && (
<CoverGrid albums={albumItems} covers={covers} onOpen={navigate} />
)}
{!albumDetail && !showCoverGrid && !searchOpen && !searching && !loading && items?.map(item => {
const isTrack = item.type === 'track';
return (
<div
key={item.uri}
className={`browser-row ${isTrack ? '' : 'browser-row--dir'}`}
onClick={() => !isTrack && navigate(item)}
>
<span className="browser-type mono">{TYPE_LABEL[item.type] ?? '···'}</span>
<span className="browser-name">{item.name}</span>
{isTrack
? <IconButton className="browser-add-btn" title="Add track to queue" onClick={e => { e.stopPropagation(); onAdd([item.uri], 'Track added to queue'); }}>+</IconButton>
: <span className="browser-arrow"></span>
}
</div>
);
})}
{!albumDetail && !searchOpen && !searching && !loading && items?.length === 0 && <div className="browser-msg">No {mode.label.toLowerCase()} found here.</div>}
{!albumDetail && searching && !loading && results.map((track, i) => (
<div key={`${track.uri}-${i}`} className="browser-row browser-row--search">
<span className="search-thumb">
{coverUriFor(resultCovers, track.uri) || coverUriFor(resultCovers, track.album?.uri)
? <img src={coverUriFor(resultCovers, track.uri) || coverUriFor(resultCovers, track.album?.uri)} alt="" loading="lazy" />
: 'TRK'
}
</span>
<div className="browser-name-col">
<span className="browser-name">{track.name}</span>
{track.artists?.length > 0 && (
<span className="browser-sub">{track.artists.map(a => a.name).join(', ')}</span>
)}
{track.album?.name && <span className="browser-sub">{track.album.name}</span>}
</div>
<IconButton className="browser-add-btn" title="Add track to queue" onClick={() => onAdd([track.uri], 'Track added to queue')}>+</IconButton>
</div>
))}
{searchOpen && !searching && !loading && !query.trim() && <div className="browser-msg">Enter a search term to find tracks.</div>}
{searching && !loading && results.length === 0 && <div className="browser-msg">No tracks matched {query}.</div>}
</div>
</div>
);
}
function SourceBrowser({ onAdd }) {
const [source, setSource] = useState('local');
return (
<div>
<div className="browser-mode-row">
<button
className={`browser-mode-btn ${source === 'local' ? 'browser-mode-btn--active' : ''}`}
onClick={() => setSource('local')}
>
Local Library
</button>
<button
className={`browser-mode-btn ${source === 'spotify' ? 'browser-mode-btn--active' : ''}`}
onClick={() => setSource('spotify')}
>
Spotify
</button>
</div>
{source === 'local' ? <MediaBrowser onAdd={onAdd} /> : <SpotifyBrowser onAdd={onAdd} />}
</div>
);
}
function SpotifyBrowser({ onAdd }) {
const [stack, setStack] = useState([{ name: 'Spotify', uri: 'spotify:directory' }]);
// Unified browser: handles both local library (with mode switching) and Spotify (fixed root).
// Props:
// modes LOCAL_MODES array for local, null for Spotify-style single root
// defaultRootUri starting URI when modes is null (e.g. 'spotify:directory')
// searchUris uris[] scoping for mopidy.search (undefined = search all)
// showAlbumSearch show album cover grid in search results (Spotify only)
// onAdd async (uris[], message) callback
function BrowserPanel({ modes, defaultRootUri, searchUris, showAlbumSearch, onAdd }) {
const [mode, setMode] = useState(modes ? modes[0] : null);
const [stack, setStack] = useState(() => {
const uri = modes ? modes[0].uri : defaultRootUri;
const name = modes ? modes[0].crumb : 'Spotify';
return [{ name, uri }];
});
const [items, setItems] = useState(null);
const [covers, setCovers] = useState({});
const [loading, setLoading] = useState(false);
@@ -627,13 +335,20 @@ function SpotifyBrowser({ onAdd }) {
const [searchOpen, setSearchOpen] = useState(false);
const [query, setQuery] = useState('');
const [searching, setSearching] = useState(false);
const [searchAlbums, setSearchAlbums] = useState([]);
const [searchAlbumCovers, setSearchAlbumCovers] = useState({});
const [searchTracks, setSearchTracks] = useState([]);
const [searchAlbums, setSearchAlbums] = useState([]);
const [searchTrackCovers, setSearchTrackCovers] = useState({});
const [searchAlbumCovers, setSearchAlbumCovers] = useState({});
const currentUri = stack[stack.length - 1].uri;
// Which items show in the cover grid and open as album detail
const isCoverable = useCallback((item) => {
if (!modes) return item.type === 'album' || item.type === 'playlist';
return item.type === 'album' || (mode?.id === 'album' && item.type === 'directory');
}, [modes, mode]);
// Browse
useEffect(() => {
if (searching || searchOpen) return;
let cancelled = false;
@@ -647,17 +362,19 @@ function SpotifyBrowser({ onAdd }) {
return () => { cancelled = true; };
}, [currentUri, searching, searchOpen]);
// Cover art for browseable items
useEffect(() => {
if (searching || searchOpen || !items?.length) return;
const coverable = items.filter(i => i.type === 'album' || i.type === 'playlist');
const coverable = items.filter(isCoverable);
if (!coverable.length) return;
let cancelled = false;
mopidy.getImages(coverable.map(i => i.uri))
.then(r => { if (!cancelled) setCovers(r ?? {}); })
.catch(() => { if (!cancelled) setCovers({}); });
return () => { cancelled = true; };
}, [items, searching, searchOpen]);
}, [items, searching, searchOpen, isCoverable]);
// Cover art for search album results
useEffect(() => {
if (!searching || !searchAlbums.length) { setSearchAlbumCovers({}); return undefined; }
let cancelled = false;
@@ -667,6 +384,7 @@ function SpotifyBrowser({ onAdd }) {
return () => { cancelled = true; };
}, [searchAlbums, searching]);
// Cover art for search track results
useEffect(() => {
if (!searching || !searchTracks.length) { setSearchTrackCovers({}); return undefined; }
let cancelled = false;
@@ -677,21 +395,25 @@ function SpotifyBrowser({ onAdd }) {
return () => { cancelled = true; };
}, [searchTracks, searching]);
const navigate = (item) => {
if (item.type === 'album' || item.type === 'playlist') {
const navigate = useCallback((item) => {
if (isCoverable(item)) {
setAlbumDetail({ name: item.name, uri: item.uri });
} else {
setAlbumDetail(null);
setStack(s => [...s, { name: item.name, uri: item.uri }]);
}
};
}, [isCoverable]);
const back = () => {
if (albumDetail) {
setAlbumDetail(null);
} else if (stack.length > 1) {
setStack(s => s.slice(0, -1));
}
if (albumDetail) setAlbumDetail(null);
else if (stack.length > 1) setStack(s => s.slice(0, -1));
};
const setBrowseMode = (nextMode) => {
setMode(nextMode);
setStack([{ name: nextMode.crumb, uri: nextMode.uri }]);
setAlbumDetail(null);
clearSearch();
};
const doSearch = async () => {
@@ -700,13 +422,13 @@ function SpotifyBrowser({ onAdd }) {
setLoading(true);
setAlbumDetail(null);
try {
const res = await mopidy.search(query, ['spotify:']);
setSearchAlbums((res ?? []).flatMap(r => r.albums ?? []).slice(0, 48));
const res = await mopidy.search(query, searchUris);
setSearchTracks((res ?? []).flatMap(r => r.tracks ?? []).slice(0, 80));
if (showAlbumSearch) setSearchAlbums((res ?? []).flatMap(r => r.albums ?? []).slice(0, 48));
} catch {
setSearching(false);
setSearchAlbums([]);
setSearchTracks([]);
setSearchAlbums([]);
} finally {
setLoading(false);
}
@@ -716,19 +438,33 @@ function SpotifyBrowser({ onAdd }) {
setSearching(false);
setSearchOpen(false);
setQuery('');
setSearchAlbums([]);
setSearchTracks([]);
setSearchAlbums([]);
setAlbumDetail(null);
};
const coverables = items?.filter(i => i.type === 'album' || i.type === 'playlist') ?? [];
const dirs = items?.filter(i => i.type === 'directory') ?? [];
const coverableItems = items?.filter(isCoverable) ?? [];
const dirItems = items?.filter(i => i.type === 'directory' && !isCoverable(i)) ?? [];
const otherItems = items?.filter(i => i.type !== 'directory' && i.type !== 'track' && !isCoverable(i)) ?? [];
const trackItems = sortAlbumTracks(items?.filter(i => i.type === 'track') ?? []);
const showCoverGrid = !searching && !searchOpen && !loading && coverables.length > 0;
const trackUris = trackItems.map(i => i.uri);
// For local: cover grid only in album mode. For Spotify: whenever coverables exist.
const showCoverGrid = !searching && !searchOpen && !loading && coverableItems.length > 0 &&
(!modes || mode?.id === 'album');
const breadcrumbs = stack.length ? stack : [{ name: 'Root', uri: null }];
return (
<div className="browser">
<div className="browser-mode-row">
{modes?.map(option => (
<button
key={option.id}
className={`browser-mode-btn ${mode?.id === option.id && !searching ? 'browser-mode-btn--active' : ''}`}
onClick={() => setBrowseMode(option)}
>
{option.label}
</button>
))}
<button
className={`browser-mode-btn browser-mode-btn--search ${searchOpen || searching ? 'browser-mode-btn--active' : ''}`}
onClick={() => { if (searchOpen || searching) clearSearch(); else setSearchOpen(true); }}
@@ -742,9 +478,9 @@ function SpotifyBrowser({ onAdd }) {
<input
className="browser-input"
type="search"
placeholder="Search Spotify…"
placeholder={searchUris ? 'Search Spotify…' : 'Search library…'}
value={query}
autoFocus
autoFocus={!modes}
onChange={e => setQuery(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') doSearch(); if (e.key === 'Escape') clearSearch(); }}
/>
@@ -763,9 +499,9 @@ function SpotifyBrowser({ onAdd }) {
onClick={back}
disabled={!albumDetail && stack.length <= 1}
></IconButton>
<div className="browser-crumbs mono">
{stack.map((item, i) => (
<span key={`${item.uri}-${i}`} className="browser-crumb">
<div className="browser-crumbs mono" title={breadcrumbs.map(i => i.name).join(' / ')}>
{breadcrumbs.map((item, i) => (
<span key={`${item.uri ?? 'root'}-${i}`} className="browser-crumb">
{i > 0 && <span className="browser-crumb-sep">/</span>}
{item.name}
</span>
@@ -777,60 +513,85 @@ function SpotifyBrowser({ onAdd }) {
</span>
)}
</div>
{!albumDetail && trackUris.length > 0 && (
<IconButton
className="browser-add-all-btn"
title={`Add ${trackUris.length} tracks to queue`}
onClick={() => onAdd(trackUris, `${trackUris.length} tracks added to queue`)}
>+ {trackUris.length}</IconButton>
)}
</div>
)}
<div className="browser-list">
{albumDetail && (
<AlbumDetail album={albumDetail} onBack={back} onAdd={onAdd} />
)}
{albumDetail && <AlbumDetail album={albumDetail} onBack={back} onAdd={onAdd} />}
{!albumDetail && loading && <div className="browser-msg">Loading</div>}
{!albumDetail && loading && (
<div className="browser-msg">Loading{mode ? ` ${mode.label.toLowerCase()}` : ''}</div>
)}
{!albumDetail && showCoverGrid && (
<CoverGrid albums={coverables} covers={covers} onOpen={navigate} />
<CoverGrid albums={coverableItems} covers={covers} onOpen={navigate} />
)}
{!albumDetail && !showCoverGrid && !searching && !searchOpen && !loading && dirs.map(item => (
<div key={item.uri} className="browser-row browser-row--dir" onClick={() => navigate(item)}>
<span className="browser-type mono">DIR</span>
<span className="browser-name">{item.name}</span>
<span className="browser-arrow"></span>
</div>
))}
{!albumDetail && !searching && !searchOpen && !loading && trackItems.map((track, i) => {
const artist = track.artists?.map(a => a.name).join(', ') ?? '';
return (
<div key={track.uri ?? i} className="browser-row">
<span className="browser-type mono">{i + 1}</span>
<div className="browser-name-col">
<span className="browser-name">{track.name}</span>
{artist && <span className="browser-sub">{artist}</span>}
{!albumDetail && !showCoverGrid && !searching && !searchOpen && !loading && (
<>
{dirItems.map(item => (
<div key={item.uri} className="browser-row browser-row--dir" onClick={() => navigate(item)}>
<span className="browser-type mono">DIR</span>
<span className="browser-name">{item.name}</span>
<span className="browser-arrow"></span>
</div>
<IconButton
className="browser-add-btn"
title="Add to queue"
onClick={e => { e.stopPropagation(); onAdd([track.uri], 'Track added to queue'); }}
>+</IconButton>
</div>
);
})}
))}
{otherItems.map(item => (
<div key={item.uri} className="browser-row browser-row--dir" onClick={() => navigate(item)}>
<span className="browser-type mono">{TYPE_LABEL[item.type] ?? '···'}</span>
<span className="browser-name">{item.name}</span>
<span className="browser-arrow"></span>
</div>
))}
{trackItems.map((item, i) => {
const artist = item.artists?.map(a => a.name).join(', ') ?? '';
return (
<div key={item.uri ?? i} className="browser-row">
<span className="browser-type mono">{TYPE_LABEL[item.type] ?? (i + 1)}</span>
<div className="browser-name-col">
<span className="browser-name">{item.name}</span>
{artist && <span className="browser-sub">{artist}</span>}
</div>
<IconButton
className="browser-add-btn"
title="Add to queue"
onClick={e => { e.stopPropagation(); onAdd([item.uri], 'Track added to queue'); }}
>+</IconButton>
</div>
);
})}
</>
)}
{!albumDetail && !loading && !searching && !searchOpen && items?.length === 0 && (
<div className="browser-msg">
{currentUri === 'spotify:directory'
? 'Spotify not configured — add credentials in Settings → Spotify, then restart Mopidy.'
: `No ${mode?.label.toLowerCase() ?? 'items'} found here.`}
</div>
)}
{!albumDetail && searching && !loading && searchAlbums.length > 0 && (
<>
<div className="section-label" style={{ display: 'block', padding: '10px 0 6px' }}>Albums</div>
<div className="search-section-label">Albums</div>
<CoverGrid albums={searchAlbums} covers={searchAlbumCovers} onOpen={navigate} />
</>
)}
{!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 (
<div key={`${track.uri}-${i}`} className="browser-row browser-row--search">
<span className="search-thumb">
{coverUri ? <img src={coverUri} alt="" loading="lazy" /> : 'TRK'}
{uri ? <img src={uri} alt="" loading="lazy" /> : 'TRK'}
</span>
<div className="browser-name-col">
<span className="browser-name">{track.name}</span>
@@ -847,23 +608,40 @@ function SpotifyBrowser({ onAdd }) {
})}
{searchOpen && !searching && !query.trim() && (
<div className="browser-msg">Search for albums and tracks on Spotify.</div>
)}
{searching && !loading && searchAlbums.length === 0 && searchTracks.length === 0 && (
<div className="browser-msg">No results for "{query}".</div>
)}
{!albumDetail && !loading && !searching && !searchOpen && items?.length === 0 && (
<div className="browser-msg">
{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.'}
</div>
)}
{searching && !loading && searchTracks.length === 0 && searchAlbums.length === 0 && (
<div className="browser-msg">No results for "{query}".</div>
)}
</div>
</div>
);
}
function SourceBrowser({ onAdd }) {
const [source, setSource] = useState('local');
return (
<div>
<div className="browser-mode-row">
<button
className={`browser-mode-btn ${source === 'local' ? 'browser-mode-btn--active' : ''}`}
onClick={() => setSource('local')}
>Local Library</button>
<button
className={`browser-mode-btn ${source === 'spotify' ? 'browser-mode-btn--active' : ''}`}
onClick={() => setSource('spotify')}
>Spotify</button>
</div>
{source === 'local'
? <BrowserPanel modes={LOCAL_MODES} onAdd={onAdd} />
: <BrowserPanel defaultRootUri="spotify:directory" searchUris={['spotify:']} showAlbumSearch onAdd={onAdd} />
}
</div>
);
}
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 (
<div className="panel">
{notice && <div className="action-toast" role="status">{notice}</div>}
{/* Connection status */}
{!m.connected && (
<div className="panel-error">
<span className="dot dot-red" /> Mopidy unreachable
</div>
)}
{/* Now playing */}
<section className="panel-section">
<div className="section-header">
<span className="section-label">NOW PLAYING</span>
@@ -937,8 +716,17 @@ export function MopidyPanel() {
</div>
<TrackInfo track={m.currentTrack} bars={visualizerBars} />
{/* Progress bar */}
<div className="progress-bar" onClick={handleSeek}>
<div
className="progress-bar"
role="slider"
tabIndex={0}
aria-label="Playback position"
aria-valuemin={0}
aria-valuemax={m.duration}
aria-valuenow={m.position}
onClick={handleSeek}
onKeyDown={handleSeekKey}
>
<div className="progress-fill" style={{ width: `${progressPct}%` }} />
</div>
<div className="progress-times">
@@ -947,7 +735,6 @@ export function MopidyPanel() {
</div>
</section>
{/* Transport */}
<section className="panel-section">
<div className="transport-panel">
<div className="transport-main">
@@ -1018,16 +805,13 @@ export function MopidyPanel() {
</div>
)}
{/* Playlists */}
<section className="panel-section">
<PlaylistBrowser playlists={m.playlists} onLoad={m.loadPlaylist} />
</section>
{/* Media browser */}
<section className="panel-section">
<SourceBrowser onAdd={addTracksWithFeedback} />
</section>
</div>
);
}
@@ -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 (
<div className="now-playing now-playing--empty">
<div className="now-cover now-cover--empty">ALB</div>
<div className="track-title">Nothing playing</div>
</div>
);
const t = track.track;
const artist = t.artists?.map(a => a.name).join(', ') ?? '';
const album = t.album?.name ?? '';
{/* CAVA Visualization */}
<div className="cava-visualization"></div>
return (
<div className="now-playing">
<div className="now-cover">
{coverUri ? <img src={coverUri} alt="" /> : 'ALB'}
</div>
<div className="now-text">
<div className="track-title">{t.name}</div>
<div className="track-sub">{[artist, album].filter(Boolean).join(' · ')}</div>
</div>
</div>
);
}
function ToggleBtn({ label, active, onClick }) {
return (
<button className={`toggle-btn ${active ? 'toggle-btn--on' : ''}`} onClick={onClick}>
{label}
</button>
);
}
function PlaylistBrowser({ playlists, onLoad }) {
const [open, setOpen] = useState(false);
if (!playlists.length) return null;
return (
<div className="playlist-browser">
<button className="section-toggle" onClick={() => setOpen(o => !o)}>
PLAYLISTS {open ? '▲' : '▼'}
</button>
{open && (
<div className="playlist-list">
{playlists.map(pl => (
<button key={pl.uri} className="playlist-item" onClick={() => onLoad(pl.uri)}>
{pl.name}
</button>
))}
</div>
)}
</div>
);
}
function Queue({ tracklist, currentTrack, onPlay, onRemove }) {
if (!tracklist.length) return null;
return (
<div className="queue-list">
{tracklist.map(item => {
const isCurrent = item.tlid === currentTrack?.tlid;
const t = item.track;
const artist = t.artists?.map(a => a.name).join(', ') ?? '';
return (
<div key={item.tlid} className={`queue-item ${isCurrent ? 'queue-item--active' : ''}`}>
<button className="queue-play" onClick={() => onPlay(item.tlid)}>▶</button>
<div className="queue-info">
<span className="queue-title">{t.name}</span>
{artist && <span className="queue-artist">{artist}</span>}
</div>
<span className="queue-dur mono">{formatTime(t.length)}</span>
<button className="queue-remove" onClick={() => onRemove(item.tlid)}>✕</button>
</div>
);
})}
</div>
);
}
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 (
<div className="cover-grid">
{albums.map(album => {
const coverUri = coverUriFor(covers, album.uri);
return (
<button key={album.uri} className="cover-card" onClick={() => onOpen(album)}>
<span className="cover-art">
{coverUri ? <img src={coverUri} alt="" loading="lazy" /> : 'ALB'}
</span>
<span className="cover-title">{album.name}</span>
</button>
);
})}
</div>
);
}
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 (
<div className="browser">
<div className="browser-mode-row">
{BROWSE_MODES.map(option => (
<button
key={option.id}
className={`browser-mode-btn ${mode.id === option.id && !searching ? 'browser-mode-btn--active' : ''}`}
onClick={() => setBrowseMode(option)}
>
{option.label}
</button>
))}
<button
className={`browser-mode-btn browser-mode-btn--search ${searchOpen || searching ? 'browser-mode-btn--active' : ''}`}
onClick={toggleSearch}
>
Search
</button>
</div>
{(searchOpen || searching) && (
<div className="browser-search-row">
<input
className="browser-input"
type="search"
placeholder="Search library…"
value={query}
onChange={e => setQuery(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') doSearch(); if (e.key === 'Escape') clearSearch(); }}
/>
{searching
? <button className="browser-icon-btn" onClick={clearSearch}>✕</button>
: <button className="browser-icon-btn" onClick={doSearch}>⌕</button>
}
</div>
)}
{!searching && (
<div className="browser-nav">
<button className="browser-back-btn" onClick={back} disabled={!stack.length}>←</button>
<span className="browser-crumb mono">
{stack.length === 0 ? 'root' : stack[stack.length - 1].name}
</span>
{trackUris.length > 0 && (
<button className="browser-add-all-btn" onClick={() => onAdd(trackUris)}>
+ {trackUris.length}
</button>
)}
</div>
)}
<div className="browser-list">
{loading && <div className="browser-msg">Loading…</div>}
{showCoverGrid && (
<CoverGrid albums={albumItems} covers={covers} onOpen={navigate} />
)}
{!showCoverGrid && !searching && !loading && items?.map(item => {
const isTrack = item.type === 'track';
return (
<div
key={item.uri}
className={`browser-row ${isTrack ? '' : 'browser-row--dir'}`}
onClick={() => !isTrack && navigate(item)}
>
<span className="browser-type mono">{TYPE_LABEL[item.type] ?? '···'}</span>
<span className="browser-name">{item.name}</span>
{isTrack
? <button className="browser-add-btn" onClick={e => { e.stopPropagation(); onAdd([item.uri]); }}>+</button>
: <span className="browser-arrow"></span>
}
</div>
);
})}
{!searching && !loading && items?.length === 0 && <div className="browser-msg">Empty</div>}
{searching && !loading && results.map((track, i) => (
<div key={`${track.uri}-${i}`} className="browser-row">
<span className="browser-type mono">TRK</span>
<div className="browser-name-col">
<span className="browser-name">{track.name}</span>
{track.artists?.length > 0 && (
<span className="browser-sub">{track.artists.map(a => a.name).join(', ')}</span>
)}
</div>
<button className="browser-add-btn" onClick={() => onAdd([track.uri])}>+</button>
</div>
))}
{searching && !loading && results.length === 0 && <div className="browser-msg">No results</div>}
</div>
</div>
);
}
function BrowserSection({ onAdd }) {
const [open, setOpen] = useState(true);
return (
<div>
<button className="section-toggle" onClick={() => setOpen(o => !o)}>
BROWSE LIBRARY {open ? '▲' : '▼'}
</button>
{open && <MediaBrowser onAdd={onAdd} />}
</div>
);
}
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 (
<div className="panel">
{/* Connection status */}
{!m.connected && (
<div className="panel-error">
<span className="dot dot-red" /> Mopidy unreachable
</div>
)}
{/* Now playing */}
<section className="panel-section">
<div className="section-header">
<span className="section-label">NOW PLAYING</span>
{m.connected && <span className={`dot ${isPlaying ? 'dot-green' : 'dot-dim'}`} />}
</div>
<TrackInfo track={m.currentTrack} />
{/* Progress bar */}
<div className="progress-bar" onClick={handleSeek}>
<div className="progress-fill" style={{ width: `${progressPct}%` }} />
</div>
<div className="progress-times">
<span className="mono">{formatTime(m.position)}</span>
<span className="mono">{formatTime(m.duration)}</span>
</div>
</section>
{/* Transport */}
<section className="panel-section">
<div className="transport-panel">
<div className="transport-main">
<div className="transport">
<button className="transport-btn" onClick={m.previous} title="Previous">⏮</button>
<button
className={`transport-btn transport-btn--play ${isPlaying ? 'transport-btn--active' : ''}`}
onClick={isPlaying ? m.pause : m.resume}
title={isPlaying ? 'Pause' : 'Play'}
>
{isPlaying ? '⏸' : '▶'}
</button>
<button className="transport-btn" onClick={m.next} title="Next">⏭</button>
<button className="transport-btn transport-btn--stop" onClick={m.stop} title="Stop">⏹</button>
<button
className={`transport-btn transport-btn--queue ${queueOpen ? 'transport-btn--active' : ''}`}
onClick={() => setQueueOpen(open => !open)}
title="Queue"
>
Q {m.tracklist.length}
</button>
</div>
<div className="transport-volume">
<span className="transport-volume-label mono">{m.volume ?? '--'}</span>
<div className="vol-slider vol-slider--sm">
<div className="vol-fill" style={{ width: `${m.volume ?? 0}%` }} />
<input
type="range" min={0} max={100} step={1}
value={m.volume ?? 0}
onChange={e => m.setVolume(parseInt(e.target.value))}
/>
</div>
</div>
</div>
<div className="transport-toggles">
<ToggleBtn label="RPT" active={m.repeat} onClick={() => m.setRepeat(!m.repeat)} />
<ToggleBtn label="RND" active={m.random} onClick={() => m.setRandom(!m.random)} />
<ToggleBtn label="1×" active={m.single} onClick={() => m.setSingle(!m.single)} />
<ToggleBtn label={m.mute ? '🔇' : '🔊'} active={m.mute} onClick={() => m.setMute(!m.mute)} />
</div>
{queueOpen && (
<div className="queue-popover">
<div className="queue-heading">
<span className="section-label">QUEUE</span>
{m.tracklist.length > 0 && (
<button className="danger-btn danger-btn--inline" onClick={m.clearTracklist}>Clear</button>
)}
</div>
<Queue
tracklist={m.tracklist}
currentTrack={m.currentTrack}
onPlay={m.play}
onRemove={m.removeTrack}
/>
{m.tracklist.length === 0 && <div className="browser-msg">Queue empty</div>}
</div>
)}
</div>
</section>
{/* Playlists */}
<section className="panel-section">
<PlaylistBrowser playlists={m.playlists} onLoad={m.loadPlaylist} />
</section>
{/* Media browser */}
<section className="panel-section">
<BrowserSection onAdd={m.addTracks} />
</section>
</div>
);
}
+36
View File
@@ -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;
}
+9 -3
View File
@@ -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 = {
+9 -5
View File
@@ -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 })
+1 -1
View File
@@ -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:
+4 -1
View File
@@ -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