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>
This commit is contained in:
9
dashboard/package-lock.json
generated
9
dashboard/package-lock.json
generated
@@ -12,7 +12,8 @@
|
|||||||
"maplibre-gl": "^5.21.1",
|
"maplibre-gl": "^5.21.1",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^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": {
|
"devDependencies": {
|
||||||
"@vitejs/plugin-react": "^4.3.1",
|
"@vitejs/plugin-react": "^4.3.1",
|
||||||
@@ -1847,6 +1848,12 @@
|
|||||||
"node": ">=0.10.0"
|
"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": {
|
"node_modules/supercluster": {
|
||||||
"version": "8.0.1",
|
"version": "8.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/supercluster/-/supercluster-8.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/supercluster/-/supercluster-8.0.1.tgz",
|
||||||
|
|||||||
@@ -13,7 +13,8 @@
|
|||||||
"maplibre-gl": "^5.21.1",
|
"maplibre-gl": "^5.21.1",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^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": {
|
"devDependencies": {
|
||||||
"@vitejs/plugin-react": "^4.3.1",
|
"@vitejs/plugin-react": "^4.3.1",
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { ThemeProvider } from './contexts/ThemeContext.jsx'
|
import { ThemeProvider } from './contexts/ThemeContext.jsx'
|
||||||
|
import { SpotifyProvider } from './contexts/SpotifyContext.jsx'
|
||||||
import TopBar from './components/layout/TopBar.jsx'
|
import TopBar from './components/layout/TopBar.jsx'
|
||||||
import TabNav from './components/layout/TabNav.jsx'
|
import TabNav from './components/layout/TabNav.jsx'
|
||||||
import Overview from './pages/Overview.jsx'
|
import Overview from './pages/Overview.jsx'
|
||||||
@@ -20,13 +21,15 @@ export default function App() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<ThemeProvider>
|
<ThemeProvider>
|
||||||
<div style={styles.app}>
|
<SpotifyProvider>
|
||||||
<TopBar />
|
<div style={styles.app}>
|
||||||
<TabNav activeTab={tab} onTabChange={setTab} />
|
<TopBar />
|
||||||
<main style={styles.main}>
|
<TabNav activeTab={tab} onTabChange={setTab} />
|
||||||
<Page />
|
<main style={styles.main}>
|
||||||
</main>
|
<Page />
|
||||||
</div>
|
</main>
|
||||||
|
</div>
|
||||||
|
</SpotifyProvider>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
180
dashboard/src/components/audio/SpotifyAccountManager.jsx
Normal file
180
dashboard/src/components/audio/SpotifyAccountManager.jsx
Normal file
@@ -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 (
|
||||||
|
<div style={styles.container}>
|
||||||
|
<div style={styles.header}>
|
||||||
|
<span style={styles.title}>🎵 Spotify Accounts</span>
|
||||||
|
<button
|
||||||
|
className="icon ghost"
|
||||||
|
onClick={() => setIsAdding(!isAdding)}
|
||||||
|
style={{ fontSize: 18 }}
|
||||||
|
title="Add account"
|
||||||
|
>
|
||||||
|
+
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isAdding && (
|
||||||
|
<div style={styles.form}>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Display name (optional)"
|
||||||
|
value={form.displayName}
|
||||||
|
onChange={e => setForm({ ...form, displayName: e.target.value })}
|
||||||
|
style={styles.input}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
placeholder="Email"
|
||||||
|
value={form.email}
|
||||||
|
onChange={e => setForm({ ...form, email: e.target.value })}
|
||||||
|
style={styles.input}
|
||||||
|
/>
|
||||||
|
<div style={styles.formButtons}>
|
||||||
|
<button
|
||||||
|
className="primary"
|
||||||
|
onClick={handleAdd}
|
||||||
|
style={{ flex: 1, fontSize: 12 }}
|
||||||
|
>
|
||||||
|
Add
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="ghost"
|
||||||
|
onClick={() => setIsAdding(false)}
|
||||||
|
style={{ flex: 1, fontSize: 12 }}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div style={styles.list}>
|
||||||
|
{accounts.length === 0 ? (
|
||||||
|
<div style={styles.empty}>No Spotify accounts</div>
|
||||||
|
) : (
|
||||||
|
accounts.map(account => (
|
||||||
|
<div key={account.id} style={styles.item}>
|
||||||
|
<div style={styles.itemInfo}>
|
||||||
|
<div style={styles.itemName}>{account.displayName}</div>
|
||||||
|
<div style={styles.itemEmail}>{account.email}</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
className="icon ghost"
|
||||||
|
onClick={() => removeAccount(account.id)}
|
||||||
|
style={{ fontSize: 14, color: '#ef4444' }}
|
||||||
|
title="Remove account"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -1,5 +1,10 @@
|
|||||||
|
import { useSpotify } from '../../contexts/SpotifyContext.jsx'
|
||||||
|
|
||||||
export default function ZoneCard({ zone, onVolume, onMute, onSource, onGroup, groupedWith }) {
|
export default function ZoneCard({ zone, onVolume, onMute, onSource, onGroup, groupedWith }) {
|
||||||
const { id, name, active, volume, muted, source } = zone
|
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
|
// Map source to emoji and connection info
|
||||||
const sourceInfo = {
|
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 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 (
|
return (
|
||||||
<div style={{
|
<div style={{
|
||||||
...styles.card,
|
...styles.card,
|
||||||
@@ -23,6 +39,11 @@ export default function ZoneCard({ zone, onVolume, onMute, onSource, onGroup, gr
|
|||||||
<span style={{ ...styles.sourceTag, background: `${info.color}22`, color: info.color }}>
|
<span style={{ ...styles.sourceTag, background: `${info.color}22`, color: info.color }}>
|
||||||
{info.emoji} {info.label}
|
{info.emoji} {info.label}
|
||||||
</span>
|
</span>
|
||||||
|
{spotifyAccount && (
|
||||||
|
<span style={{ ...styles.accountTag, color: '#1DB954', fontSize: 10 }}>
|
||||||
|
👤 {spotifyAccount.displayName || spotifyAccount.email}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div style={styles.badges}>
|
<div style={styles.badges}>
|
||||||
<span style={{ ...styles.badge, background: active ? '#34d39922' : 'var(--border)', color: active ? 'var(--success)' : 'var(--muted)' }}>
|
<span style={{ ...styles.badge, background: active ? '#34d39922' : 'var(--border)', color: active ? 'var(--success)' : 'var(--muted)' }}>
|
||||||
@@ -45,13 +66,34 @@ export default function ZoneCard({ zone, onVolume, onMute, onSource, onGroup, gr
|
|||||||
<div style={styles.sourceControl}>
|
<div style={styles.sourceControl}>
|
||||||
<select
|
<select
|
||||||
value={source}
|
value={source}
|
||||||
onChange={e => onSource(id, e.target.value)}
|
onChange={e => handleSourceChange(e.target.value)}
|
||||||
style={styles.sourceSelect}
|
style={styles.sourceSelect}
|
||||||
>
|
>
|
||||||
<option value="Spotify">🎵 Spotify</option>
|
<option value="Spotify">🎵 Spotify</option>
|
||||||
<option value="AirPlay">🎙️ AirPlay</option>
|
<option value="AirPlay">🎙️ AirPlay</option>
|
||||||
<option value="Mopidy">📻 Mopidy</option>
|
<option value="Mopidy">📻 Mopidy</option>
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
|
{source === 'Spotify' && accounts.length > 0 && (
|
||||||
|
<select
|
||||||
|
value={spotifyAccount?.id || ''}
|
||||||
|
onChange={e => {
|
||||||
|
if (e.target.value) {
|
||||||
|
assignAccountToZone(id, e.target.value)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
style={styles.accountSelect}
|
||||||
|
title="Select Spotify account for this zone"
|
||||||
|
>
|
||||||
|
<option value="">Select Account</option>
|
||||||
|
{accounts.map(acc => (
|
||||||
|
<option key={acc.id} value={acc.id}>
|
||||||
|
{acc.displayName || acc.email}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
)}
|
||||||
|
|
||||||
{onGroup && (
|
{onGroup && (
|
||||||
<button
|
<button
|
||||||
style={styles.groupBtn}
|
style={styles.groupBtn}
|
||||||
@@ -98,6 +140,11 @@ const styles = {
|
|||||||
display: 'inline-block', width: 'fit-content',
|
display: 'inline-block', width: 'fit-content',
|
||||||
transition: 'all 0.2s',
|
transition: 'all 0.2s',
|
||||||
},
|
},
|
||||||
|
accountTag: {
|
||||||
|
fontSize: 10, padding: '2px 6px', borderRadius: 4, fontWeight: 500,
|
||||||
|
display: 'inline-block', width: 'fit-content',
|
||||||
|
background: 'rgba(29, 185, 84, 0.1)',
|
||||||
|
},
|
||||||
badges: { display: 'flex', gap: 4 },
|
badges: { display: 'flex', gap: 4 },
|
||||||
badge: { fontSize: 12, padding: '4px 8px', borderRadius: 4, fontWeight: 700, minHeight: 24, minWidth: 24, display: 'flex', alignItems: 'center', justifyContent: 'center', transition: 'all 0.2s' },
|
badge: { fontSize: 12, padding: '4px 8px', borderRadius: 4, fontWeight: 700, minHeight: 24, minWidth: 24, display: 'flex', alignItems: 'center', justifyContent: 'center', transition: 'all 0.2s' },
|
||||||
|
|
||||||
@@ -115,14 +162,21 @@ const styles = {
|
|||||||
animation: 'slideInUp 0.2s ease-out',
|
animation: 'slideInUp 0.2s ease-out',
|
||||||
},
|
},
|
||||||
|
|
||||||
sourceControl: { display: 'flex', gap: 8, alignItems: 'center' },
|
sourceControl: { display: 'flex', gap: 6, alignItems: 'center' },
|
||||||
sourceSelect: {
|
sourceSelect: {
|
||||||
flex: 1, padding: '8px 10px', background: 'rgba(255, 255, 255, 0.05)',
|
flex: 1.2, padding: '8px 10px', background: 'rgba(255, 255, 255, 0.05)',
|
||||||
color: 'var(--text)',
|
color: 'var(--text)',
|
||||||
border: '1px solid rgba(255, 255, 255, 0.1)', borderRadius: 'var(--radius)',
|
border: '1px solid rgba(255, 255, 255, 0.1)', borderRadius: 'var(--radius)',
|
||||||
fontSize: 12, fontWeight: 600, cursor: 'pointer',
|
fontSize: 12, fontWeight: 600, cursor: 'pointer',
|
||||||
transition: 'all 0.2s',
|
transition: 'all 0.2s',
|
||||||
},
|
},
|
||||||
|
accountSelect: {
|
||||||
|
flex: 1, padding: '8px 10px', background: 'rgba(29, 185, 84, 0.08)',
|
||||||
|
color: 'var(--text)',
|
||||||
|
border: '1px solid rgba(29, 185, 84, 0.2)', borderRadius: 'var(--radius)',
|
||||||
|
fontSize: 11, fontWeight: 600, cursor: 'pointer',
|
||||||
|
transition: 'all 0.2s',
|
||||||
|
},
|
||||||
groupBtn: {
|
groupBtn: {
|
||||||
minWidth: 40, minHeight: 40, padding: 0,
|
minWidth: 40, minHeight: 40, padding: 0,
|
||||||
background: 'rgba(255, 255, 255, 0.08)', color: 'var(--text)',
|
background: 'rgba(255, 255, 255, 0.08)', color: 'var(--text)',
|
||||||
|
|||||||
149
dashboard/src/contexts/SpotifyContext.jsx
Normal file
149
dashboard/src/contexts/SpotifyContext.jsx
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
import { createContext, useContext, useState, useCallback } from 'react'
|
||||||
|
|
||||||
|
const SpotifyContext = createContext()
|
||||||
|
|
||||||
|
export function SpotifyProvider({ children }) {
|
||||||
|
// Account management
|
||||||
|
const [accounts, setAccounts] = useState(() => {
|
||||||
|
const saved = localStorage.getItem('spotify_accounts')
|
||||||
|
return saved ? JSON.parse(saved) : []
|
||||||
|
})
|
||||||
|
|
||||||
|
// Zone to account mapping: { zoneId: accountId }
|
||||||
|
const [zoneAccounts, setZoneAccounts] = useState(() => {
|
||||||
|
const saved = localStorage.getItem('zone_accounts')
|
||||||
|
return saved ? JSON.parse(saved) : {}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Active Spotify Connect instances per account
|
||||||
|
const [spotifyConnects, setSpotifyConnects] = useState({})
|
||||||
|
|
||||||
|
// Add or update account
|
||||||
|
const addAccount = useCallback((account) => {
|
||||||
|
setAccounts(prev => {
|
||||||
|
const filtered = prev.filter(a => a.id !== account.id)
|
||||||
|
const updated = [...filtered, account]
|
||||||
|
localStorage.setItem('spotify_accounts', JSON.stringify(updated))
|
||||||
|
return updated
|
||||||
|
})
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Remove account
|
||||||
|
const removeAccount = useCallback((accountId) => {
|
||||||
|
setAccounts(prev => {
|
||||||
|
const updated = prev.filter(a => a.id !== accountId)
|
||||||
|
localStorage.setItem('spotify_accounts', JSON.stringify(updated))
|
||||||
|
return updated
|
||||||
|
})
|
||||||
|
|
||||||
|
// Remove zone mappings for this account
|
||||||
|
setZoneAccounts(prev => {
|
||||||
|
const updated = { ...prev }
|
||||||
|
Object.keys(updated).forEach(zoneId => {
|
||||||
|
if (updated[zoneId] === accountId) delete updated[zoneId]
|
||||||
|
})
|
||||||
|
localStorage.setItem('zone_accounts', JSON.stringify(updated))
|
||||||
|
return updated
|
||||||
|
})
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Assign account to zone
|
||||||
|
const assignAccountToZone = useCallback((zoneId, accountId) => {
|
||||||
|
setZoneAccounts(prev => {
|
||||||
|
const updated = { ...prev, [zoneId]: accountId }
|
||||||
|
localStorage.setItem('zone_accounts', JSON.stringify(updated))
|
||||||
|
return updated
|
||||||
|
})
|
||||||
|
|
||||||
|
// Activate Spotify Connect for this account if not already active
|
||||||
|
if (accountId && !spotifyConnects[accountId]) {
|
||||||
|
activateSpotifyConnect(accountId)
|
||||||
|
}
|
||||||
|
}, [spotifyConnects])
|
||||||
|
|
||||||
|
// Remove account from zone
|
||||||
|
const removeAccountFromZone = useCallback((zoneId) => {
|
||||||
|
const accountId = zoneAccounts[zoneId]
|
||||||
|
setZoneAccounts(prev => {
|
||||||
|
const updated = { ...prev }
|
||||||
|
delete updated[zoneId]
|
||||||
|
localStorage.setItem('zone_accounts', JSON.stringify(updated))
|
||||||
|
return updated
|
||||||
|
})
|
||||||
|
|
||||||
|
// Deactivate Spotify Connect if no zones use this account
|
||||||
|
if (accountId && !Object.values(zoneAccounts).includes(accountId)) {
|
||||||
|
deactivateSpotifyConnect(accountId)
|
||||||
|
}
|
||||||
|
}, [zoneAccounts])
|
||||||
|
|
||||||
|
// Activate Spotify Connect for account
|
||||||
|
const activateSpotifyConnect = useCallback((accountId) => {
|
||||||
|
if (!accountId) return
|
||||||
|
|
||||||
|
setSpotifyConnects(prev => {
|
||||||
|
if (prev[accountId]) return prev
|
||||||
|
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
[accountId]: {
|
||||||
|
id: `spotify-${accountId}`,
|
||||||
|
status: 'active',
|
||||||
|
device: 'Bordanlage',
|
||||||
|
connectedAt: new Date().toISOString(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Deactivate Spotify Connect for account
|
||||||
|
const deactivateSpotifyConnect = useCallback((accountId) => {
|
||||||
|
setSpotifyConnects(prev => {
|
||||||
|
const updated = { ...prev }
|
||||||
|
delete updated[accountId]
|
||||||
|
return updated
|
||||||
|
})
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Get account for zone
|
||||||
|
const getAccountForZone = useCallback((zoneId) => {
|
||||||
|
const accountId = zoneAccounts[zoneId]
|
||||||
|
if (!accountId) return null
|
||||||
|
return accounts.find(a => a.id === accountId)
|
||||||
|
}, [zoneAccounts, accounts])
|
||||||
|
|
||||||
|
// Get zones using account
|
||||||
|
const getZonesForAccount = useCallback((accountId) => {
|
||||||
|
return Object.entries(zoneAccounts)
|
||||||
|
.filter(([_, aId]) => aId === accountId)
|
||||||
|
.map(([zId]) => zId)
|
||||||
|
}, [zoneAccounts])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SpotifyContext.Provider
|
||||||
|
value={{
|
||||||
|
accounts,
|
||||||
|
addAccount,
|
||||||
|
removeAccount,
|
||||||
|
zoneAccounts,
|
||||||
|
assignAccountToZone,
|
||||||
|
removeAccountFromZone,
|
||||||
|
spotifyConnects,
|
||||||
|
activateSpotifyConnect,
|
||||||
|
deactivateSpotifyConnect,
|
||||||
|
getAccountForZone,
|
||||||
|
getZonesForAccount,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</SpotifyContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSpotify() {
|
||||||
|
const context = useContext(SpotifyContext)
|
||||||
|
if (!context) {
|
||||||
|
throw new Error('useSpotify must be used within SpotifyProvider')
|
||||||
|
}
|
||||||
|
return context
|
||||||
|
}
|
||||||
@@ -1,11 +1,12 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import NowPlaying from '../components/audio/NowPlaying.jsx'
|
import NowPlaying from '../components/audio/NowPlaying.jsx'
|
||||||
import ZoneGrid from '../components/audio/ZoneGrid.jsx'
|
import ZoneGrid from '../components/audio/ZoneGrid.jsx'
|
||||||
|
import SpotifyAccountManager from '../components/audio/SpotifyAccountManager.jsx'
|
||||||
import SourcePicker from '../components/audio/SourcePicker.jsx'
|
import SourcePicker from '../components/audio/SourcePicker.jsx'
|
||||||
import RadioBrowser from '../components/audio/RadioBrowser.jsx'
|
import RadioBrowser from '../components/audio/RadioBrowser.jsx'
|
||||||
import LibraryBrowser from '../components/audio/LibraryBrowser.jsx'
|
import LibraryBrowser from '../components/audio/LibraryBrowser.jsx'
|
||||||
|
|
||||||
const SUB_TABS = ['Zones', 'Radio', 'Library']
|
const SUB_TABS = ['Zones', 'Accounts', 'Radio', 'Library']
|
||||||
|
|
||||||
export default function Audio() {
|
export default function Audio() {
|
||||||
const [subTab, setSubTab] = useState('Zones')
|
const [subTab, setSubTab] = useState('Zones')
|
||||||
@@ -28,9 +29,10 @@ export default function Audio() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={styles.content}>
|
<div style={styles.content}>
|
||||||
{subTab === 'Zones' && <ZoneGrid />}
|
{subTab === 'Zones' && <ZoneGrid />}
|
||||||
{subTab === 'Radio' && <RadioBrowser />}
|
{subTab === 'Accounts' && <SpotifyAccountManager />}
|
||||||
{subTab === 'Library' && <LibraryBrowser />}
|
{subTab === 'Radio' && <RadioBrowser />}
|
||||||
|
{subTab === 'Library' && <LibraryBrowser />}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -40,10 +42,10 @@ const styles = {
|
|||||||
page: { padding: 16, display: 'flex', flexDirection: 'column', gap: 14, overflow: 'auto', flex: 1 },
|
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)' },
|
subTabs: { display: 'flex', gap: 2, background: 'var(--surface)', borderRadius: 'var(--radius)', padding: 3, border: '1px solid var(--border)' },
|
||||||
subTab: {
|
subTab: {
|
||||||
flex: 1, height: 36, fontSize: 13, fontWeight: 500,
|
flex: 1, height: 36, fontSize: 12, fontWeight: 600,
|
||||||
color: 'var(--muted)', borderRadius: 6,
|
color: 'var(--muted)', borderRadius: 'var(--radius)',
|
||||||
background: 'none', minHeight: 36,
|
background: 'none', minHeight: 36, transition: 'all 0.2s',
|
||||||
},
|
},
|
||||||
subTabActive: { background: 'var(--surface2)', color: 'var(--text)' },
|
subTabActive: { background: 'var(--accent)', color: 'white' },
|
||||||
content: { flex: 1 },
|
content: { flex: 1, overflowY: 'auto' },
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user