Move project from bordanlage/ to repo root
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
53
dashboard/src/components/audio/LibraryBrowser.jsx
Normal file
53
dashboard/src/components/audio/LibraryBrowser.jsx
Normal file
@@ -0,0 +1,53 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { getApi } from '../../mock/index.js'
|
||||
|
||||
export default function LibraryBrowser() {
|
||||
const [tracks, setTracks] = useState([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const { mopidy } = getApi()
|
||||
|
||||
useEffect(() => {
|
||||
mopidy.call('tracklist.get_tracks')
|
||||
.then(t => { setTracks(t || []); setLoading(false) })
|
||||
.catch(() => setLoading(false))
|
||||
}, [])
|
||||
|
||||
async function playTrack(uri) {
|
||||
await mopidy.call('tracklist.clear')
|
||||
await mopidy.call('tracklist.add', { uris: [uri] })
|
||||
await mopidy.call('playback.play')
|
||||
}
|
||||
|
||||
if (loading) return <div style={{ color: 'var(--muted)', padding: 16 }}>Loading library…</div>
|
||||
if (!tracks.length) return <div style={{ color: 'var(--muted)', padding: 16 }}>No tracks found. Add music to ./music</div>
|
||||
|
||||
return (
|
||||
<div style={styles.list}>
|
||||
{tracks.map((track, i) => (
|
||||
<button key={track.uri || i} style={styles.row} onClick={() => playTrack(track.uri)}>
|
||||
<span style={styles.num}>{i + 1}</span>
|
||||
<div style={styles.meta}>
|
||||
<span style={styles.name}>{track.name}</span>
|
||||
<span style={styles.artist}>{track.artists?.map(a => a.name).join(', ')}</span>
|
||||
</div>
|
||||
<span style={styles.album}>{track.album?.name}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = {
|
||||
list: { display: 'flex', flexDirection: 'column', gap: 2, overflow: 'auto', maxHeight: 400 },
|
||||
row: {
|
||||
display: 'flex', alignItems: 'center', gap: 12,
|
||||
padding: '8px 12px', borderRadius: 'var(--radius)',
|
||||
background: 'none', border: '1px solid transparent',
|
||||
color: 'var(--text)', textAlign: 'left', minHeight: 48,
|
||||
},
|
||||
num: { fontFamily: 'var(--font-mono)', fontSize: 11, color: 'var(--muted)', minWidth: 20 },
|
||||
meta: { flex: 1, minWidth: 0, display: 'flex', flexDirection: 'column', gap: 2 },
|
||||
name: { fontSize: 13, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' },
|
||||
artist: { fontSize: 11, color: 'var(--muted)' },
|
||||
album: { fontSize: 11, color: 'var(--muted)', minWidth: 100, textAlign: 'right', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' },
|
||||
}
|
||||
90
dashboard/src/components/audio/NowPlaying.jsx
Normal file
90
dashboard/src/components/audio/NowPlaying.jsx
Normal file
@@ -0,0 +1,90 @@
|
||||
import { usePlayer } from '../../hooks/usePlayer.js'
|
||||
|
||||
function formatTime(ms) {
|
||||
if (!ms) return '0:00'
|
||||
const s = Math.floor(ms / 1000)
|
||||
return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
export default function NowPlaying({ compact = false }) {
|
||||
const { currentTrack, state, position, play, pause, next, previous, connected } = usePlayer()
|
||||
|
||||
if (!connected) {
|
||||
return (
|
||||
<div style={styles.container}>
|
||||
<span style={{ color: 'var(--muted)', fontSize: 13 }}>Audio not connected</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const progress = currentTrack?.duration
|
||||
? Math.min(100, (position / currentTrack.duration) * 100)
|
||||
: 0
|
||||
|
||||
return (
|
||||
<div style={{ ...styles.container, ...(compact ? styles.compact : {}) }}>
|
||||
{/* Cover placeholder */}
|
||||
<div style={styles.cover}>
|
||||
{currentTrack?.coverUrl
|
||||
? <img src={currentTrack.coverUrl} alt="cover" style={styles.coverImg} />
|
||||
: <span style={styles.coverIcon}>♫</span>}
|
||||
</div>
|
||||
|
||||
<div style={styles.info}>
|
||||
<div style={styles.title}>{currentTrack?.title || 'Nothing playing'}</div>
|
||||
<div style={styles.artist}>{currentTrack?.artist || ''}</div>
|
||||
{!compact && <div style={styles.album}>{currentTrack?.album || ''}</div>}
|
||||
|
||||
{/* Progress bar */}
|
||||
{currentTrack && (
|
||||
<div style={styles.progressRow}>
|
||||
<span style={styles.timeText}>{formatTime(position)}</span>
|
||||
<div style={styles.progressBg}>
|
||||
<div style={{ ...styles.progressFill, width: `${progress}%` }} />
|
||||
</div>
|
||||
<span style={styles.timeText}>{formatTime(currentTrack.duration)}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Controls */}
|
||||
<div style={styles.controls}>
|
||||
<button style={styles.btn} onClick={previous}>⏮</button>
|
||||
<button style={{ ...styles.btn, ...styles.playBtn }}
|
||||
onClick={state === 'playing' ? pause : play}>
|
||||
{state === 'playing' ? '⏸' : '▶'}
|
||||
</button>
|
||||
<button style={styles.btn} onClick={next}>⏭</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = {
|
||||
container: {
|
||||
display: 'flex', gap: 16, padding: 16,
|
||||
background: 'var(--surface)', borderRadius: 'var(--radius)',
|
||||
border: '1px solid var(--border)',
|
||||
alignItems: 'center',
|
||||
},
|
||||
compact: { padding: '10px 14px' },
|
||||
cover: {
|
||||
width: 64, height: 64, flexShrink: 0,
|
||||
background: 'var(--surface2)', borderRadius: 6,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
overflow: 'hidden',
|
||||
},
|
||||
coverImg: { width: '100%', height: '100%', objectFit: 'cover' },
|
||||
coverIcon: { fontSize: 28, color: 'var(--muted)' },
|
||||
info: { flex: 1, minWidth: 0, display: 'flex', flexDirection: 'column', gap: 4 },
|
||||
title: { fontWeight: 600, fontSize: 14, color: 'var(--text)', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' },
|
||||
artist: { fontSize: 12, color: 'var(--muted)' },
|
||||
album: { fontSize: 11, color: 'var(--muted)', opacity: 0.7 },
|
||||
progressRow: { display: 'flex', alignItems: 'center', gap: 8, marginTop: 4 },
|
||||
progressBg: { flex: 1, height: 3, background: 'var(--border)', borderRadius: 2, overflow: 'hidden' },
|
||||
progressFill: { height: '100%', background: 'var(--accent)', borderRadius: 2, transition: 'width 1s linear' },
|
||||
timeText: { fontSize: 10, color: 'var(--muted)', fontFamily: 'var(--font-mono)', minWidth: 30 },
|
||||
controls: { display: 'flex', gap: 4, marginTop: 4 },
|
||||
btn: { width: 36, height: 36, fontSize: 14, background: 'var(--surface2)', color: 'var(--text)', minWidth: 36 },
|
||||
playBtn: { background: 'var(--accent)', color: '#000', fontWeight: 700 },
|
||||
}
|
||||
61
dashboard/src/components/audio/RadioBrowser.jsx
Normal file
61
dashboard/src/components/audio/RadioBrowser.jsx
Normal file
@@ -0,0 +1,61 @@
|
||||
import { useState } from 'react'
|
||||
import { getApi } from '../../mock/index.js'
|
||||
|
||||
const BUILT_IN_STATIONS = [
|
||||
{ name: 'SWR3', uri: 'http://stream.swr3.de/swr3/mp3-128/stream.mp3' },
|
||||
{ name: 'NDR 1 Welle Nord', uri: 'http://ndr.de/ndr1welle-nord-128.mp3' },
|
||||
{ name: 'Deutschlandfunk', uri: 'https://st01.sslstream.dlf.de/dlf/01/128/mp3/stream.mp3' },
|
||||
{ name: 'KISS FM', uri: 'http://topstream.kissfm.de/kissfm' },
|
||||
]
|
||||
|
||||
export default function RadioBrowser() {
|
||||
const [playing, setPlaying] = useState(null)
|
||||
const { mopidy } = getApi()
|
||||
|
||||
async function playStation(uri, name) {
|
||||
try {
|
||||
await mopidy.call('tracklist.clear')
|
||||
await mopidy.call('tracklist.add', { uris: [uri] })
|
||||
await mopidy.call('playback.play')
|
||||
setPlaying(uri)
|
||||
} catch (e) {
|
||||
console.error('Radio play error:', e)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={styles.container}>
|
||||
<div style={styles.title}>Radio Stations</div>
|
||||
{BUILT_IN_STATIONS.map(s => (
|
||||
<button
|
||||
key={s.uri}
|
||||
style={{
|
||||
...styles.station,
|
||||
...(playing === s.uri ? styles.active : {}),
|
||||
}}
|
||||
onClick={() => playStation(s.uri, s.name)}
|
||||
>
|
||||
<span style={styles.dot}>◉</span>
|
||||
<span style={styles.stationName}>{s.name}</span>
|
||||
{playing === s.uri && <span style={styles.live}>LIVE</span>}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = {
|
||||
container: { display: 'flex', flexDirection: 'column', gap: 6 },
|
||||
title: { fontWeight: 600, fontSize: 12, color: 'var(--muted)', textTransform: 'uppercase', letterSpacing: '0.06em', marginBottom: 4 },
|
||||
station: {
|
||||
display: 'flex', alignItems: 'center', gap: 12,
|
||||
padding: '12px 16px', background: 'var(--surface)',
|
||||
border: '1px solid var(--border)', borderRadius: 'var(--radius)',
|
||||
color: 'var(--text)', textAlign: 'left', minHeight: 48,
|
||||
transition: 'border-color 0.15s',
|
||||
},
|
||||
active: { borderColor: 'var(--accent)', background: 'var(--surface2)' },
|
||||
dot: { color: 'var(--muted)', fontSize: 10 },
|
||||
stationName: { flex: 1, fontSize: 14 },
|
||||
live: { fontSize: 9, padding: '2px 5px', background: '#ef444422', color: 'var(--danger)', borderRadius: 3, fontWeight: 700 },
|
||||
}
|
||||
37
dashboard/src/components/audio/SourcePicker.jsx
Normal file
37
dashboard/src/components/audio/SourcePicker.jsx
Normal file
@@ -0,0 +1,37 @@
|
||||
const SOURCES = [
|
||||
{ id: 'Spotify', label: 'Spotify', color: 'var(--spotify)', icon: '🎵' },
|
||||
{ id: 'AirPlay', label: 'AirPlay', color: 'var(--airplay)', icon: '📡' },
|
||||
{ id: 'Mopidy', label: 'Mopidy', color: 'var(--accent)', icon: '📻' },
|
||||
]
|
||||
|
||||
export default function SourcePicker({ activeSource, onSelect }) {
|
||||
return (
|
||||
<div style={styles.row}>
|
||||
{SOURCES.map(s => (
|
||||
<button
|
||||
key={s.id}
|
||||
style={{
|
||||
...styles.btn,
|
||||
...(activeSource === s.id ? { background: s.color + '22', borderColor: s.color, color: s.color } : {}),
|
||||
}}
|
||||
onClick={() => onSelect(s.id)}
|
||||
>
|
||||
<span>{s.icon}</span>
|
||||
<span style={{ fontSize: 12 }}>{s.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = {
|
||||
row: { display: 'flex', gap: 8 },
|
||||
btn: {
|
||||
flex: 1, height: 52, flexDirection: 'column',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 4,
|
||||
background: 'var(--surface)', border: '1px solid var(--border)',
|
||||
borderRadius: 'var(--radius)', color: 'var(--muted)',
|
||||
transition: 'all 0.15s',
|
||||
minHeight: 52,
|
||||
},
|
||||
}
|
||||
53
dashboard/src/components/audio/ZoneCard.jsx
Normal file
53
dashboard/src/components/audio/ZoneCard.jsx
Normal file
@@ -0,0 +1,53 @@
|
||||
export default function ZoneCard({ zone, onVolume, onMute, onSource }) {
|
||||
const { id, name, active, volume, muted, source } = zone
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
...styles.card,
|
||||
opacity: active ? 1 : 0.45,
|
||||
borderColor: active && !muted ? 'var(--accent)' : 'var(--border)',
|
||||
}}>
|
||||
<div style={styles.header}>
|
||||
<span style={styles.name}>{name}</span>
|
||||
<div style={styles.badges}>
|
||||
<span style={{ ...styles.badge, background: active ? '#34d39922' : 'var(--border)', color: active ? 'var(--success)' : 'var(--muted)' }}>
|
||||
{active ? 'ON' : 'OFF'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={styles.source}>{source}</div>
|
||||
|
||||
<div style={styles.volumeRow}>
|
||||
<button style={styles.muteBtn} onClick={() => onMute(id, !muted)}>
|
||||
{muted ? '🔇' : '🔊'}
|
||||
</button>
|
||||
<input
|
||||
type="range" min={0} max={100} value={muted ? 0 : volume}
|
||||
onChange={e => onVolume(id, Number(e.target.value))}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<span style={styles.volVal}>{muted ? '–' : volume}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = {
|
||||
card: {
|
||||
padding: 14,
|
||||
background: 'var(--surface)',
|
||||
borderRadius: 'var(--radius)',
|
||||
border: '1px solid var(--border)',
|
||||
display: 'flex', flexDirection: 'column', gap: 10,
|
||||
transition: 'border-color 0.2s, opacity 0.2s',
|
||||
},
|
||||
header: { display: 'flex', justifyContent: 'space-between', alignItems: 'center' },
|
||||
name: { fontWeight: 600, fontSize: 14 },
|
||||
badges: { display: 'flex', gap: 4 },
|
||||
badge: { fontSize: 10, padding: '2px 6px', borderRadius: 4, fontWeight: 700 },
|
||||
source: { fontSize: 11, color: 'var(--muted)' },
|
||||
volumeRow: { display: 'flex', alignItems: 'center', gap: 10 },
|
||||
muteBtn: { fontSize: 18, minWidth: 44, minHeight: 44 },
|
||||
volVal: { fontFamily: 'var(--font-mono)', fontSize: 13, minWidth: 26, textAlign: 'right', color: 'var(--muted)' },
|
||||
}
|
||||
32
dashboard/src/components/audio/ZoneGrid.jsx
Normal file
32
dashboard/src/components/audio/ZoneGrid.jsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import { useZones } from '../../hooks/useZones.js'
|
||||
import ZoneCard from './ZoneCard.jsx'
|
||||
|
||||
export default function ZoneGrid() {
|
||||
const { zones, setVolume, setMuted, setSource } = useZones()
|
||||
|
||||
if (!zones.length) {
|
||||
return <div style={{ color: 'var(--muted)', padding: 24, textAlign: 'center' }}>Loading zones…</div>
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={styles.grid}>
|
||||
{zones.map(zone => (
|
||||
<ZoneCard
|
||||
key={zone.id}
|
||||
zone={zone}
|
||||
onVolume={setVolume}
|
||||
onMute={setMuted}
|
||||
onSource={setSource}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = {
|
||||
grid: {
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fill, minmax(220px, 1fr))',
|
||||
gap: 12,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user