Spotify integration added

This commit is contained in:
2026-05-17 13:35:34 +02:00
parent 9a9275dae5
commit 4d53efc9e4
16 changed files with 1890 additions and 82 deletions
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -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>
+265 -7
View File
@@ -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>
);
}