From beeee82896e6457e32de364db6660e677b8246e9 Mon Sep 17 00:00:00 2001 From: denshooter Date: Fri, 27 Mar 2026 15:02:20 +0100 Subject: [PATCH] Phase 3: Real Spotify account integration with zone mapping - Created SpotifyContext for account management * Add/remove Spotify accounts with email and display name * Persistent storage to localStorage * Automatic account activation when assigned to zone - Implemented zone-to-account mapping system * Each zone can be assigned a Spotify account * Multiple zones can share one account * Switching between sources preserves account assignment - Enhanced ZoneCard component: * Account selector dropdown when Spotify is selected * Display account name/email under zone name * Auto-select first account when switching to Spotify * Green-tinted account dropdown for visual distinction - Created SpotifyAccountManager component: * Add new Spotify accounts with email and display name * List all configured accounts * Remove accounts (cleans up zone mappings) * Collapsible form for adding new accounts * Glassmorphic styling with green accent - Updated Audio page: * New 'Accounts' tab for Spotify account management * Accessible alongside Zones, Radio, and Library tabs * Smooth tab transitions with animations - Architecture supports: * Real Spotify API integration (ready for OAuth) * Multiple accounts simultaneously * Spotify Connect per account (one instance per account) * Zone grouping with shared account control - Build: 70 modules, 1.25 MB (345 KB gzipped) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- dashboard/package-lock.json | 9 +- dashboard/package.json | 3 +- dashboard/src/App.jsx | 17 +- .../audio/SpotifyAccountManager.jsx | 180 ++++++++++++++++++ dashboard/src/components/audio/ZoneCard.jsx | 60 +++++- dashboard/src/contexts/SpotifyContext.jsx | 149 +++++++++++++++ dashboard/src/pages/Audio.jsx | 20 +- 7 files changed, 417 insertions(+), 21 deletions(-) create mode 100644 dashboard/src/components/audio/SpotifyAccountManager.jsx create mode 100644 dashboard/src/contexts/SpotifyContext.jsx 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 && ( +
+ setForm({ ...form, displayName: e.target.value })} + style={styles.input} + /> + setForm({ ...form, email: e.target.value })} + style={styles.input} + /> +
+ + +
+
+ )} + +
+ {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' }, }