diff --git a/dashboard/package-lock.json b/dashboard/package-lock.json
index 029874c..ff68cd3 100644
--- a/dashboard/package-lock.json
+++ b/dashboard/package-lock.json
@@ -12,7 +12,8 @@
"maplibre-gl": "^5.21.1",
"react": "^18.3.1",
"react-dom": "^18.3.1",
- "react-leaflet": "^5.0.0"
+ "react-leaflet": "^5.0.0",
+ "spotify-web-api-js": "^1.5.2"
},
"devDependencies": {
"@vitejs/plugin-react": "^4.3.1",
@@ -1847,6 +1848,12 @@
"node": ">=0.10.0"
}
},
+ "node_modules/spotify-web-api-js": {
+ "version": "1.5.2",
+ "resolved": "https://registry.npmjs.org/spotify-web-api-js/-/spotify-web-api-js-1.5.2.tgz",
+ "integrity": "sha512-ie1gbg1wCabfobIkXTIBLUMyULS/hMCpF44Cdx2pAO0/+FrjhNSDjlDzcwCEDy+ZIo3Fscs+Gkg/GTeQ/ijo+Q==",
+ "license": "MIT"
+ },
"node_modules/supercluster": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/supercluster/-/supercluster-8.0.1.tgz",
diff --git a/dashboard/package.json b/dashboard/package.json
index 9e95153..7c237d6 100644
--- a/dashboard/package.json
+++ b/dashboard/package.json
@@ -13,7 +13,8 @@
"maplibre-gl": "^5.21.1",
"react": "^18.3.1",
"react-dom": "^18.3.1",
- "react-leaflet": "^5.0.0"
+ "react-leaflet": "^5.0.0",
+ "spotify-web-api-js": "^1.5.2"
},
"devDependencies": {
"@vitejs/plugin-react": "^4.3.1",
diff --git a/dashboard/src/App.jsx b/dashboard/src/App.jsx
index 6da2684..183e8aa 100644
--- a/dashboard/src/App.jsx
+++ b/dashboard/src/App.jsx
@@ -1,5 +1,6 @@
import { useState } from 'react'
import { ThemeProvider } from './contexts/ThemeContext.jsx'
+import { SpotifyProvider } from './contexts/SpotifyContext.jsx'
import TopBar from './components/layout/TopBar.jsx'
import TabNav from './components/layout/TabNav.jsx'
import Overview from './pages/Overview.jsx'
@@ -20,13 +21,15 @@ export default function App() {
return (
-
+
+
+
)
}
diff --git a/dashboard/src/components/audio/SpotifyAccountManager.jsx b/dashboard/src/components/audio/SpotifyAccountManager.jsx
new file mode 100644
index 0000000..898421c
--- /dev/null
+++ b/dashboard/src/components/audio/SpotifyAccountManager.jsx
@@ -0,0 +1,180 @@
+import { useState } from 'react'
+import { useSpotify } from '../../contexts/SpotifyContext.jsx'
+
+export default function SpotifyAccountManager() {
+ const { accounts, addAccount, removeAccount } = useSpotify()
+ const [isAdding, setIsAdding] = useState(false)
+ const [form, setForm] = useState({ displayName: '', email: '' })
+
+ const handleAdd = () => {
+ if (!form.email) return
+
+ addAccount({
+ id: `spotify-${Date.now()}`,
+ email: form.email,
+ displayName: form.displayName || form.email.split('@')[0],
+ addedAt: new Date().toISOString(),
+ })
+
+ setForm({ displayName: '', email: '' })
+ setIsAdding(false)
+ }
+
+ return (
+
+
+ 🎵 Spotify Accounts
+
+
+
+ {isAdding && (
+
+ )}
+
+
+ {accounts.length === 0 ? (
+
No Spotify accounts
+ ) : (
+ accounts.map(account => (
+
+
+
{account.displayName}
+
{account.email}
+
+
+
+ ))
+ )}
+
+
+ )
+}
+
+const styles = {
+ container: {
+ background: 'var(--glass-bg)',
+ backdropFilter: 'blur(var(--glass-blur))',
+ WebkitBackdropFilter: 'blur(var(--glass-blur))',
+ border: '1px solid rgba(255, 255, 255, 0.1)',
+ borderRadius: 'var(--radius-lg)',
+ padding: 14,
+ display: 'flex',
+ flexDirection: 'column',
+ gap: 10,
+ },
+ header: {
+ display: 'flex',
+ justifyContent: 'space-between',
+ alignItems: 'center',
+ paddingBottom: 8,
+ borderBottom: '1px solid rgba(255, 255, 255, 0.1)',
+ },
+ title: {
+ fontSize: 12,
+ fontWeight: 700,
+ color: 'var(--text)',
+ },
+ form: {
+ display: 'flex',
+ flexDirection: 'column',
+ gap: 8,
+ padding: 10,
+ background: 'rgba(29, 185, 84, 0.08)',
+ borderRadius: 'var(--radius)',
+ border: '1px solid rgba(29, 185, 84, 0.2)',
+ },
+ input: {
+ padding: '8px 10px',
+ background: 'rgba(255, 255, 255, 0.05)',
+ border: '1px solid rgba(255, 255, 255, 0.1)',
+ borderRadius: 'var(--radius)',
+ color: 'var(--text)',
+ fontSize: 12,
+ fontFamily: 'var(--font-ui)',
+ },
+ formButtons: {
+ display: 'flex',
+ gap: 8,
+ },
+ list: {
+ display: 'flex',
+ flexDirection: 'column',
+ gap: 6,
+ maxHeight: 300,
+ overflowY: 'auto',
+ },
+ empty: {
+ fontSize: 11,
+ color: 'var(--muted)',
+ textAlign: 'center',
+ padding: 12,
+ },
+ item: {
+ display: 'flex',
+ justifyContent: 'space-between',
+ alignItems: 'center',
+ padding: 8,
+ background: 'rgba(29, 185, 84, 0.08)',
+ border: '1px solid rgba(29, 185, 84, 0.15)',
+ borderRadius: 'var(--radius)',
+ },
+ itemInfo: {
+ flex: 1,
+ minWidth: 0,
+ },
+ itemName: {
+ fontSize: 12,
+ fontWeight: 600,
+ color: 'var(--text)',
+ },
+ itemEmail: {
+ fontSize: 10,
+ color: 'var(--muted)',
+ marginTop: 2,
+ },
+}
diff --git a/dashboard/src/components/audio/ZoneCard.jsx b/dashboard/src/components/audio/ZoneCard.jsx
index b05e9e6..a57e21f 100644
--- a/dashboard/src/components/audio/ZoneCard.jsx
+++ b/dashboard/src/components/audio/ZoneCard.jsx
@@ -1,5 +1,10 @@
+import { useSpotify } from '../../contexts/SpotifyContext.jsx'
+
export default function ZoneCard({ zone, onVolume, onMute, onSource, onGroup, groupedWith }) {
const { id, name, active, volume, muted, source } = zone
+ const { getAccountForZone, assignAccountToZone, removeAccountFromZone, accounts } = useSpotify()
+
+ const spotifyAccount = source === 'Spotify' ? getAccountForZone(id) : null
// Map source to emoji and connection info
const sourceInfo = {
@@ -9,6 +14,17 @@ export default function ZoneCard({ zone, onVolume, onMute, onSource, onGroup, gr
}
const info = sourceInfo[source] || { emoji: '📢', color: 'var(--muted)', label: source }
+ const handleSourceChange = (newSource) => {
+ onSource(id, newSource)
+
+ // If switching to Spotify, assign an account if not already assigned
+ if (newSource === 'Spotify' && !spotifyAccount && accounts.length > 0) {
+ assignAccountToZone(id, accounts[0].id)
+ } else if (newSource !== 'Spotify' && spotifyAccount) {
+ removeAccountFromZone(id)
+ }
+ }
+
return (
{info.emoji} {info.label}
+ {spotifyAccount && (
+
+ 👤 {spotifyAccount.displayName || spotifyAccount.email}
+
+ )}
@@ -45,13 +66,34 @@ export default function ZoneCard({ zone, onVolume, onMute, onSource, onGroup, gr
+
+ {source === 'Spotify' && accounts.length > 0 && (
+
+ )}
+
{onGroup && (
- {subTab === 'Zones' && }
- {subTab === 'Radio' && }
- {subTab === 'Library' && }
+ {subTab === 'Zones' && }
+ {subTab === 'Accounts' && }
+ {subTab === 'Radio' && }
+ {subTab === 'Library' && }
)
@@ -40,10 +42,10 @@ const styles = {
page: { padding: 16, display: 'flex', flexDirection: 'column', gap: 14, overflow: 'auto', flex: 1 },
subTabs: { display: 'flex', gap: 2, background: 'var(--surface)', borderRadius: 'var(--radius)', padding: 3, border: '1px solid var(--border)' },
subTab: {
- flex: 1, height: 36, fontSize: 13, fontWeight: 500,
- color: 'var(--muted)', borderRadius: 6,
- background: 'none', minHeight: 36,
+ flex: 1, height: 36, fontSize: 12, fontWeight: 600,
+ color: 'var(--muted)', borderRadius: 'var(--radius)',
+ background: 'none', minHeight: 36, transition: 'all 0.2s',
},
- subTabActive: { background: 'var(--surface2)', color: 'var(--text)' },
- content: { flex: 1 },
+ subTabActive: { background: 'var(--accent)', color: 'white' },
+ content: { flex: 1, overflowY: 'auto' },
}