Move project from bordanlage/ to repo root

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-26 14:31:08 +01:00
parent 946c0a5377
commit 77123a0df5
56 changed files with 0 additions and 0 deletions

View 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' },
}

View 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 },
}

View 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 },
}

View 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,
},
}

View 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)' },
}

View 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,
}
}