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:
@@ -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>
|
||||
|
||||
@@ -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) {
|
||||
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);
|
||||
} else if (stack.length > 1) {
|
||||
setStack(s => s.slice(0, -1));
|
||||
}
|
||||
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 => (
|
||||
{!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>
|
||||
))}
|
||||
|
||||
{!albumDetail && !searching && !searchOpen && !loading && trackItems.map((track, i) => {
|
||||
const artist = track.artists?.map(a => a.name).join(', ') ?? '';
|
||||
{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={track.uri ?? i} className="browser-row">
|
||||
<span className="browser-type mono">{i + 1}</span>
|
||||
<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">{track.name}</span>
|
||||
<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([track.uri], 'Track added 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,19 +608,36 @@ function SpotifyBrowser({ onAdd }) {
|
||||
})}
|
||||
|
||||
{searchOpen && !searching && !query.trim() && (
|
||||
<div className="browser-msg">Search for albums and tracks on Spotify.</div>
|
||||
<div className="browser-msg">
|
||||
{searchUris ? 'Search for albums and tracks on Spotify.' : 'Enter a search term to find tracks.'}
|
||||
</div>
|
||||
)}
|
||||
{searching && !loading && searchAlbums.length === 0 && searchTracks.length === 0 && (
|
||||
{searching && !loading && searchTracks.length === 0 && searchAlbums.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.'}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user