This commit is contained in:
@@ -15,6 +15,7 @@ import VoiceOverlay from '@/components/VoiceOverlay'
|
|||||||
import VoiceController from '@/components/VoiceController'
|
import VoiceController from '@/components/VoiceController'
|
||||||
import TimerWidget from '@/components/TimerWidget'
|
import TimerWidget from '@/components/TimerWidget'
|
||||||
import TimerHomeWidget from '@/components/TimerHomeWidget'
|
import TimerHomeWidget from '@/components/TimerHomeWidget'
|
||||||
|
import SpotifyWidget from '@/components/SpotifyWidget'
|
||||||
|
|
||||||
type Tab = 'home' | 'devices' | 'calendar' | 'notes' | 'settings'
|
type Tab = 'home' | 'devices' | 'calendar' | 'notes' | 'settings'
|
||||||
|
|
||||||
@@ -712,6 +713,9 @@ function HomeTab({ weather, sensors }: { weather: WeatherData | null; sensors: S
|
|||||||
<TimerHomeWidget />
|
<TimerHomeWidget />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* ───── Spotify ───── */}
|
||||||
|
<SpotifyWidget />
|
||||||
|
|
||||||
{/* Weather day detail modal */}
|
{/* Weather day detail modal */}
|
||||||
{selectedDay && (
|
{selectedDay && (
|
||||||
<WeatherDayModal
|
<WeatherDayModal
|
||||||
|
|||||||
124
components/SpotifyWidget.tsx
Normal file
124
components/SpotifyWidget.tsx
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState, useEffect, useCallback } from 'react'
|
||||||
|
import { SkipBack, SkipForward, Play, Pause, Music } from 'lucide-react'
|
||||||
|
|
||||||
|
interface SpotifyState {
|
||||||
|
playing: boolean
|
||||||
|
track?: string
|
||||||
|
artist?: string
|
||||||
|
album?: string
|
||||||
|
volume?: number
|
||||||
|
device?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SpotifyWidget() {
|
||||||
|
const [state, setState] = useState<SpotifyState | null>(null)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [actionLoading, setActionLoading] = useState(false)
|
||||||
|
|
||||||
|
const fetchState = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const r = await fetch('/api/voice/tools/spotify', {
|
||||||
|
headers: {
|
||||||
|
'x-voice-internal': process.env.NEXT_PUBLIC_VOICE_API_KEY || '',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if (r.ok) {
|
||||||
|
const d = await r.json()
|
||||||
|
setState(d)
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
finally { setLoading(false) }
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchState()
|
||||||
|
const t = setInterval(fetchState, 15_000)
|
||||||
|
return () => clearInterval(t)
|
||||||
|
}, [fetchState])
|
||||||
|
|
||||||
|
const control = async (action: string) => {
|
||||||
|
setActionLoading(true)
|
||||||
|
try {
|
||||||
|
await fetch('/api/voice/tools/spotify', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'x-voice-internal': process.env.NEXT_PUBLIC_VOICE_API_KEY || '',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ action }),
|
||||||
|
})
|
||||||
|
setTimeout(fetchState, 500)
|
||||||
|
} catch {}
|
||||||
|
finally { setActionLoading(false) }
|
||||||
|
}
|
||||||
|
|
||||||
|
const btnStyle = (active?: boolean): React.CSSProperties => ({
|
||||||
|
width: 40, height: 40, borderRadius: 12,
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
background: active ? 'rgba(30,215,96,0.15)' : 'rgba(255,255,255,0.04)',
|
||||||
|
border: `1px solid ${active ? 'rgba(30,215,96,0.3)' : 'rgba(255,255,255,0.07)'}`,
|
||||||
|
color: active ? '#1ED760' : 'var(--text-secondary)',
|
||||||
|
transition: 'all 0.2s ease',
|
||||||
|
cursor: actionLoading ? 'not-allowed' : 'pointer',
|
||||||
|
opacity: actionLoading ? 0.6 : 1,
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="card" style={{ padding: '16px 20px', display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||||
|
{/* Header */}
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||||
|
<div style={{
|
||||||
|
width: 28, height: 28, borderRadius: 8,
|
||||||
|
background: 'rgba(30,215,96,0.12)',
|
||||||
|
border: '1px solid rgba(30,215,96,0.2)',
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
}}>
|
||||||
|
<Music size={14} color="#1ED760" />
|
||||||
|
</div>
|
||||||
|
<span style={{ fontSize: 11, fontWeight: 700, letterSpacing: '0.08em', textTransform: 'uppercase', color: 'var(--text-secondary)' }}>Spotify</span>
|
||||||
|
{state?.device && (
|
||||||
|
<span style={{ fontSize: 10, color: 'var(--text-tertiary)', marginLeft: 'auto' }}>
|
||||||
|
{state.device}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Track info */}
|
||||||
|
{loading ? (
|
||||||
|
<div style={{ fontSize: 13, color: 'var(--text-tertiary)', textAlign: 'center', padding: '8px 0' }}>Загрузка...</div>
|
||||||
|
) : !state?.track ? (
|
||||||
|
<div style={{ fontSize: 13, color: 'var(--text-tertiary)', textAlign: 'center', padding: '8px 0' }}>Ничего не играет</div>
|
||||||
|
) : (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||||
|
<div style={{
|
||||||
|
fontSize: 14, fontWeight: 700, color: 'var(--text-primary)',
|
||||||
|
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
|
||||||
|
}}>{state.track}</div>
|
||||||
|
<div style={{
|
||||||
|
fontSize: 12, color: 'var(--text-secondary)',
|
||||||
|
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
|
||||||
|
}}>{state.artist}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Controls */}
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 10 }}>
|
||||||
|
<button style={btnStyle()} onClick={() => control('previous')} disabled={actionLoading}>
|
||||||
|
<SkipBack size={16} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
style={{ ...btnStyle(state?.playing), width: 48, height: 48, borderRadius: 14 }}
|
||||||
|
onClick={() => control(state?.playing ? 'pause' : 'play')}
|
||||||
|
disabled={actionLoading}
|
||||||
|
>
|
||||||
|
{state?.playing ? <Pause size={18} /> : <Play size={18} />}
|
||||||
|
</button>
|
||||||
|
<button style={btnStyle()} onClick={() => control('next')} disabled={actionLoading}>
|
||||||
|
<SkipForward size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user