Spotify integration added
This commit is contained in:
-40
File diff suppressed because one or more lines are too long
+40
File diff suppressed because one or more lines are too long
Vendored
+1
-1
@@ -7,7 +7,7 @@
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Syne:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet" />
|
||||
<script type="module" crossorigin src="/assets/index-D7ESsBgL.js"></script>
|
||||
<script type="module" crossorigin src="/assets/index-vRxHNr4-.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-SAHpcFVS.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -595,14 +595,271 @@ function MediaBrowser({ onAdd }) {
|
||||
);
|
||||
}
|
||||
|
||||
function BrowserSection({ onAdd }) {
|
||||
const [open, setOpen] = useState(true);
|
||||
function SourceBrowser({ onAdd }) {
|
||||
const [source, setSource] = useState('local');
|
||||
return (
|
||||
<div>
|
||||
<button className="section-toggle" onClick={() => setOpen(o => !o)}>
|
||||
BROWSE LIBRARY {open ? '▲' : '▼'}
|
||||
</button>
|
||||
{open && <MediaBrowser onAdd={onAdd} />}
|
||||
<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' }]);
|
||||
const [items, setItems] = useState(null);
|
||||
const [covers, setCovers] = useState({});
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [albumDetail, setAlbumDetail] = useState(null);
|
||||
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 [searchTrackCovers, setSearchTrackCovers] = useState({});
|
||||
|
||||
const currentUri = stack[stack.length - 1].uri;
|
||||
|
||||
useEffect(() => {
|
||||
if (searching || searchOpen) 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, searchOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (searching || searchOpen || !items?.length) return;
|
||||
const coverable = items.filter(i => i.type === 'album' || i.type === 'playlist');
|
||||
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]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!searching || !searchAlbums.length) { setSearchAlbumCovers({}); return undefined; }
|
||||
let cancelled = false;
|
||||
mopidy.getImages(searchAlbums.map(a => a.uri))
|
||||
.then(r => { if (!cancelled) setSearchAlbumCovers(r ?? {}); })
|
||||
.catch(() => { if (!cancelled) setSearchAlbumCovers({}); });
|
||||
return () => { cancelled = true; };
|
||||
}, [searchAlbums, searching]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!searching || !searchTracks.length) { setSearchTrackCovers({}); return undefined; }
|
||||
let cancelled = false;
|
||||
const uris = [...new Set(searchTracks.flatMap(t => [t.uri, t.album?.uri].filter(Boolean)))];
|
||||
mopidy.getImages(uris)
|
||||
.then(r => { if (!cancelled) setSearchTrackCovers(r ?? {}); })
|
||||
.catch(() => { if (!cancelled) setSearchTrackCovers({}); });
|
||||
return () => { cancelled = true; };
|
||||
}, [searchTracks, searching]);
|
||||
|
||||
const navigate = (item) => {
|
||||
if (item.type === 'album' || item.type === 'playlist') {
|
||||
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 if (stack.length > 1) {
|
||||
setStack(s => s.slice(0, -1));
|
||||
}
|
||||
};
|
||||
|
||||
const doSearch = async () => {
|
||||
if (!query.trim()) { clearSearch(); return; }
|
||||
setSearching(true);
|
||||
setLoading(true);
|
||||
setAlbumDetail(null);
|
||||
try {
|
||||
const res = await mopidy.search(query, ['spotify:']);
|
||||
setSearchAlbums((res ?? []).flatMap(r => r.albums ?? []).slice(0, 48));
|
||||
setSearchTracks((res ?? []).flatMap(r => r.tracks ?? []).slice(0, 80));
|
||||
} catch {
|
||||
setSearching(false);
|
||||
setSearchAlbums([]);
|
||||
setSearchTracks([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const clearSearch = () => {
|
||||
setSearching(false);
|
||||
setSearchOpen(false);
|
||||
setQuery('');
|
||||
setSearchAlbums([]);
|
||||
setSearchTracks([]);
|
||||
setAlbumDetail(null);
|
||||
};
|
||||
|
||||
const coverables = items?.filter(i => i.type === 'album' || i.type === 'playlist') ?? [];
|
||||
const dirs = items?.filter(i => i.type === 'directory') ?? [];
|
||||
const trackItems = sortAlbumTracks(items?.filter(i => i.type === 'track') ?? []);
|
||||
const showCoverGrid = !searching && !searchOpen && !loading && coverables.length > 0;
|
||||
|
||||
return (
|
||||
<div className="browser">
|
||||
<div className="browser-mode-row">
|
||||
<button
|
||||
className={`browser-mode-btn browser-mode-btn--search ${searchOpen || searching ? 'browser-mode-btn--active' : ''}`}
|
||||
onClick={() => { if (searchOpen || searching) clearSearch(); else setSearchOpen(true); }}
|
||||
>
|
||||
Search
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{(searchOpen || searching) && (
|
||||
<div className="browser-search-row">
|
||||
<input
|
||||
className="browser-input"
|
||||
type="search"
|
||||
placeholder="Search Spotify…"
|
||||
value={query}
|
||||
autoFocus
|
||||
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">
|
||||
{stack.map((item, i) => (
|
||||
<span key={`${item.uri}-${i}`} className="browser-crumb">
|
||||
{i > 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>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="browser-list">
|
||||
{albumDetail && (
|
||||
<AlbumDetail album={albumDetail} onBack={back} onAdd={onAdd} />
|
||||
)}
|
||||
|
||||
{!albumDetail && loading && <div className="browser-msg">Loading…</div>}
|
||||
|
||||
{!albumDetail && showCoverGrid && (
|
||||
<CoverGrid albums={coverables} 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>}
|
||||
</div>
|
||||
<IconButton
|
||||
className="browser-add-btn"
|
||||
title="Add to queue"
|
||||
onClick={e => { e.stopPropagation(); onAdd([track.uri], 'Track added to queue'); }}
|
||||
>+</IconButton>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{!albumDetail && searching && !loading && searchAlbums.length > 0 && (
|
||||
<>
|
||||
<div className="section-label" style={{ display: 'block', padding: '10px 0 6px' }}>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);
|
||||
return (
|
||||
<div key={`${track.uri}-${i}`} className="browser-row browser-row--search">
|
||||
<span className="search-thumb">
|
||||
{coverUri ? <img src={coverUri} alt="" loading="lazy" /> : 'TRK'}
|
||||
</span>
|
||||
<div className="browser-name-col">
|
||||
<span className="browser-name">{track.name}</span>
|
||||
{artist && <span className="browser-sub">{artist}</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 && !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.'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -768,8 +1025,9 @@ export function MopidyPanel() {
|
||||
|
||||
{/* Media browser */}
|
||||
<section className="panel-section">
|
||||
<BrowserSection onAdd={addTracksWithFeedback} />
|
||||
<SourceBrowser onAdd={addTracksWithFeedback} />
|
||||
</section>
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -151,6 +151,213 @@ function ConnectionsSection() {
|
||||
);
|
||||
}
|
||||
|
||||
// ── Spotify credentials ───────────────────────────────────────────────────────
|
||||
|
||||
function SpotifySection() {
|
||||
const [clientId, setClientId] = useState('');
|
||||
const [clientSecret, setClientSecret] = useState('');
|
||||
const [secretSet, setSecretSet] = useState(false);
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
const [saveState, setSaveState] = useState('idle');
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/spotify/credentials')
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
setClientId(data.client_id || '');
|
||||
setSecretSet(!!data.client_secret_set);
|
||||
setLoaded(true);
|
||||
})
|
||||
.catch(() => setLoaded(true));
|
||||
}, []);
|
||||
|
||||
const save = async () => {
|
||||
setSaveState('saving');
|
||||
try {
|
||||
const body = { client_id: clientId };
|
||||
if (clientSecret) body.client_secret = clientSecret;
|
||||
const r = await fetch('/api/spotify/credentials', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!r.ok) throw new Error();
|
||||
setSaveState('ok');
|
||||
if (clientSecret) { setSecretSet(true); setClientSecret(''); }
|
||||
} catch {
|
||||
setSaveState('error');
|
||||
}
|
||||
setTimeout(() => setSaveState('idle'), 4000);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="settings-row-hint" style={{ marginBottom: 10 }}>
|
||||
Visit{' '}
|
||||
<a href="https://mopidy.com/ext/spotify/#authentication" target="_blank" rel="noreferrer"
|
||||
style={{ color: 'var(--accent)' }}>
|
||||
mopidy.com/ext/spotify/#authentication
|
||||
</a>
|
||||
, log in with your Spotify account, and paste the Client ID and Client Secret it gives you below.
|
||||
Requires Spotify Premium.
|
||||
</div>
|
||||
|
||||
<div className="settings-row">
|
||||
<div className="settings-row-left">
|
||||
<div className="settings-row-label">Client ID</div>
|
||||
</div>
|
||||
<input
|
||||
className="settings-input"
|
||||
value={clientId}
|
||||
onChange={e => setClientId(e.target.value)}
|
||||
spellCheck={false}
|
||||
placeholder="xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="settings-row">
|
||||
<div className="settings-row-left">
|
||||
<div className="settings-row-label">Client Secret</div>
|
||||
<div className="settings-row-hint">
|
||||
{loaded && !secretSet ? 'Not saved yet' : loaded ? 'Saved (leave blank to keep)' : '…'}
|
||||
</div>
|
||||
</div>
|
||||
<input
|
||||
className="settings-input"
|
||||
type="password"
|
||||
value={clientSecret}
|
||||
onChange={e => setClientSecret(e.target.value)}
|
||||
placeholder={secretSet ? '••••••••••••••••' : ''}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', marginTop: 12 }}>
|
||||
<button className="settings-action-btn" onClick={save} disabled={saveState === 'saving'}>
|
||||
{saveState === 'saving' ? 'Saving…' : 'Save'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{saveState === 'ok' && (
|
||||
<>
|
||||
<p className="settings-feedback settings-feedback--ok">✓ Credentials saved</p>
|
||||
<p className="settings-feedback settings-feedback--warn">↺ Restart Mopidy to apply: <code>docker compose restart mopidy</code></p>
|
||||
</>
|
||||
)}
|
||||
{saveState === 'error' && <p className="settings-feedback settings-feedback--error">✗ Failed to save credentials</p>}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Bandcamp credentials ──────────────────────────────────────────────────────
|
||||
|
||||
function BandcampSection() {
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [pwdSet, setPwdSet] = useState(false);
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
const [saveState, setSaveState] = useState('idle');
|
||||
const [testState, setTestState] = useState('idle');
|
||||
const [testMsg, setTestMsg] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/bandcamp/credentials')
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
setEmail(data.email || '');
|
||||
setPwdSet(!!data.password_set);
|
||||
setLoaded(true);
|
||||
})
|
||||
.catch(() => setLoaded(true));
|
||||
}, []);
|
||||
|
||||
const save = async () => {
|
||||
setSaveState('saving');
|
||||
try {
|
||||
const body = { email };
|
||||
if (password) body.password = password;
|
||||
const r = await fetch('/api/bandcamp/credentials', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!r.ok) throw new Error();
|
||||
setSaveState('ok');
|
||||
if (password) setPwdSet(true);
|
||||
setPassword('');
|
||||
} catch {
|
||||
setSaveState('error');
|
||||
}
|
||||
setTimeout(() => setSaveState('idle'), 4000);
|
||||
};
|
||||
|
||||
const testLogin = async () => {
|
||||
if (email || password) await save();
|
||||
setTestState('testing');
|
||||
setTestMsg('');
|
||||
try {
|
||||
const r = await fetch('/api/bandcamp/test-login', { method: 'POST' });
|
||||
const data = await r.json();
|
||||
setTestState(data.ok ? 'ok' : 'error');
|
||||
setTestMsg(data.ok
|
||||
? `Logged in${data.username ? ` as ${data.username}` : ''}`
|
||||
: (data.error || 'Login failed'));
|
||||
} catch {
|
||||
setTestState('error');
|
||||
setTestMsg('Could not reach bandcamp-api service');
|
||||
}
|
||||
setTimeout(() => setTestState('idle'), 8000);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="settings-row">
|
||||
<div className="settings-row-left">
|
||||
<div className="settings-row-label">Email</div>
|
||||
<div className="settings-row-hint">Bandcamp account email</div>
|
||||
</div>
|
||||
<input
|
||||
className="settings-input"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={e => setEmail(e.target.value)}
|
||||
spellCheck={false}
|
||||
placeholder="you@example.com"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="settings-row">
|
||||
<div className="settings-row-left">
|
||||
<div className="settings-row-label">Password</div>
|
||||
<div className="settings-row-hint">
|
||||
{loaded && !pwdSet ? 'Not saved yet' : loaded ? 'Saved (leave blank to keep)' : '…'}
|
||||
</div>
|
||||
</div>
|
||||
<input
|
||||
className="settings-input"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={e => setPassword(e.target.value)}
|
||||
placeholder={pwdSet ? '••••••••' : ''}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end', marginTop: 12 }}>
|
||||
<button className="settings-action-btn" onClick={save} disabled={saveState === 'saving'}>
|
||||
{saveState === 'saving' ? 'Saving…' : 'Save'}
|
||||
</button>
|
||||
<button className="settings-action-btn" onClick={testLogin} disabled={testState === 'testing'}>
|
||||
{testState === 'testing' ? 'Testing…' : 'Test Login'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{saveState === 'ok' && <p className="settings-feedback settings-feedback--ok">✓ Credentials saved</p>}
|
||||
{saveState === 'error' && <p className="settings-feedback settings-feedback--error">✗ Failed to save credentials</p>}
|
||||
{testState === 'ok' && <p className="settings-feedback settings-feedback--ok">✓ {testMsg}</p>}
|
||||
{testState === 'error' && <p className="settings-feedback settings-feedback--error">✗ {testMsg}</p>}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Main panel ────────────────────────────────────────────────────────────────
|
||||
export function SettingsPanel() {
|
||||
const { statuses, check } = useServiceHealth();
|
||||
@@ -200,6 +407,16 @@ export function SettingsPanel() {
|
||||
<ConnectionsSection />
|
||||
</div>
|
||||
|
||||
<div className="panel-section">
|
||||
<div className="section-label" style={{ marginBottom: 14 }}>SPOTIFY</div>
|
||||
<SpotifySection />
|
||||
</div>
|
||||
|
||||
<div className="panel-section">
|
||||
<div className="section-label" style={{ marginBottom: 14 }}>BANDCAMP</div>
|
||||
<BandcampSection />
|
||||
</div>
|
||||
|
||||
<div className="panel-section">
|
||||
<div className="section-label" style={{ marginBottom: 14 }}>MOPIDY</div>
|
||||
<div className="settings-row">
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
import { useSnapcast } from '../hooks/useSnapcast';
|
||||
|
||||
const SPOTIFY_STREAM_NAMES = ['spotify', 'librespot'];
|
||||
|
||||
function isSpotifyStream(stream) {
|
||||
const name = (stream?.id ?? stream?.uri?.query?.name ?? '').toLowerCase();
|
||||
return SPOTIFY_STREAM_NAMES.some(n => name.includes(n));
|
||||
}
|
||||
|
||||
export function SpotifyPanel() {
|
||||
const { streams, groups, clients, connected, error, setGroupStream } = useSnapcast();
|
||||
|
||||
const spotifyStream = streams.find(isSpotifyStream);
|
||||
const isPlaying = spotifyStream?.status === 'playing';
|
||||
|
||||
const spotifyGroups = groups.filter(g => g.stream_id === spotifyStream?.id);
|
||||
const otherGroups = groups.filter(g => g.stream_id !== spotifyStream?.id);
|
||||
|
||||
return (
|
||||
<div className="panel">
|
||||
<section className="panel-section">
|
||||
<div className="section-header">
|
||||
<span className="section-label">SPOTIFY</span>
|
||||
<span className="section-value">
|
||||
{connected
|
||||
? <span className={`dot ${spotifyStream ? (isPlaying ? 'dot-green' : 'dot-dim') : 'dot-red'}`} />
|
||||
: <span className="dot dot-red" />
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{error && <div className="panel-error">{error}</div>}
|
||||
|
||||
{connected && !spotifyStream && (
|
||||
<div className="panel-error">
|
||||
No Spotify stream found. Configure librespot as a Snapcast source.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{connected && spotifyStream && (
|
||||
<div className="section-header" style={{ marginTop: 8 }}>
|
||||
<span className="section-label">Stream</span>
|
||||
<span className="section-value mono">{spotifyStream.id} · {spotifyStream.status}</span>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{connected && spotifyStream && (
|
||||
<section className="panel-section">
|
||||
<div className="section-header">
|
||||
<span className="section-label">ROUTING</span>
|
||||
</div>
|
||||
|
||||
{groups.length === 0 && (
|
||||
<div className="browser-msg">No Snapcast groups found.</div>
|
||||
)}
|
||||
|
||||
{spotifyGroups.length > 0 && (
|
||||
<div style={{ marginBottom: 8 }}>
|
||||
<div className="section-label" style={{ fontSize: '10px', marginBottom: 6 }}>Playing Spotify</div>
|
||||
{spotifyGroups.map(group => (
|
||||
<div key={group.id} className="stream-row">
|
||||
<span className="dot dot-green" />
|
||||
<span className="stream-id mono">{group.name || group.id.slice(0, 8)}</span>
|
||||
<span className="stream-status">
|
||||
{group.clients?.filter(c => c.connected).length ?? 0} clients
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{otherGroups.length > 0 && (
|
||||
<div>
|
||||
<div className="section-label" style={{ fontSize: '10px', marginBottom: 6 }}>Other groups</div>
|
||||
{otherGroups.map(group => (
|
||||
<div key={group.id} className="stream-row">
|
||||
<span className="dot dot-dim" />
|
||||
<span className="stream-id mono">{group.name || group.id.slice(0, 8)}</span>
|
||||
<span className="stream-status mono">{group.stream_id}</span>
|
||||
<button
|
||||
className="recognize-btn"
|
||||
style={{ marginLeft: 'auto' }}
|
||||
onClick={() => setGroupStream(group.id, spotifyStream.id)}
|
||||
>
|
||||
Switch
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
|
||||
{connected && spotifyStream && clients.length === 0 && (
|
||||
<div className="panel-empty">No Snapcast clients connected</div>
|
||||
)}
|
||||
|
||||
{!connected && !error && (
|
||||
<div className="browser-msg">Connecting to Snapcast…</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user