diff --git a/audiocontrol/src/App.css b/audiocontrol/src/App.css index 9442cb9..62e0931 100644 --- a/audiocontrol/src/App.css +++ b/audiocontrol/src/App.css @@ -60,6 +60,15 @@ body { } .app-title { + display: flex; + align-items: center; + gap: 10px; +} +.app-logo { + color: var(--accent); + flex-shrink: 0; +} +.app-title-text { display: flex; flex-direction: column; line-height: 1; @@ -1296,7 +1305,8 @@ body { } .album-play-menu { - position: relative; + display: flex; + gap: 6px; } .album-play-btn { @@ -1322,39 +1332,40 @@ body { transform: none; } -.album-play-dropdown { - position: absolute; - top: 100%; - right: 0; - z-index: 10; - background: var(--overlay); +.album-add-btn { + display: inline-flex; + align-items: center; + gap: 4px; + background: none; border: 1px solid var(--border); border-radius: var(--radius); - box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4); - overflow: hidden; - margin-top: 4px; - min-width: 140px; -} -.album-play-dropdown button { - display: block; - width: 100%; - background: none; - border: none; - color: var(--text); + color: var(--text-muted); cursor: pointer; font-size: 12px; - padding: 8px 12px; - text-align: left; - transition: background 0.1s; + font-weight: 600; + padding: 4px 10px; + transition: color 0.15s, border-color 0.15s, transform 0.1s; } -.album-play-dropdown button:hover { - background: var(--surface-hi); +.album-add-btn:hover { + color: var(--accent); + border-color: var(--accent); + transform: scale(1.05); } -.album-play-dropdown button:disabled { +.album-add-btn:disabled { cursor: default; opacity: 0.55; + transform: none; } +.browser-play-btn { + font-size: 10px; + width: 26px; + height: 26px; + flex-shrink: 0; + color: var(--text-muted); +} +.browser-play-btn:hover { color: var(--accent); border-color: var(--accent); } + .album-info { display: flex; align-items: center; @@ -1743,7 +1754,7 @@ body { .browser-mode-btn, .album-back-btn, .album-play-btn, -.album-play-dropdown button { +.album-add-btn { -webkit-tap-highlight-color: transparent; } @@ -2112,3 +2123,191 @@ body { width: 100%; } } + +/* ── Two-column web layout ───────────────────────────────────────────────────── */ + +html, body, #root { + height: 100%; + overflow: hidden; +} + +.app { + display: flex; + flex-direction: column; + height: 100%; + width: 100%; + max-width: 100%; + min-height: 0; + padding: 0; + margin: 0 auto; +} + +.app-header { + padding-left: 24px; + padding-right: 24px; + flex-shrink: 0; +} + +.app-body { + display: flex; + flex: 1; + min-height: 0; + overflow: hidden; +} + +/* ── Player sidebar ── */ + +.player-sidebar { + width: 300px; + flex-shrink: 0; + display: flex; + flex-direction: column; + overflow-y: auto; + overflow-x: hidden; + border-right: 1px solid var(--border); + padding: 20px; +} + +.player-sidebar::-webkit-scrollbar { width: 4px; } +.player-sidebar::-webkit-scrollbar-track { background: transparent; } +.player-sidebar::-webkit-scrollbar-thumb { background: var(--border-hi); border-radius: 2px; } + +@media (min-width: 1100px) { .player-sidebar { width: 340px; } } +@media (min-width: 1400px) { .player-sidebar { width: 380px; } } + +/* ── Right content column ── */ + +.app-main { + flex: 1; + overflow-y: auto; + overflow-x: hidden; + padding: 16px 20px 48px; + min-width: 0; +} + +/* ── Sidebar player internals ── */ + +.sidebar-player { + display: flex; + flex-direction: column; + gap: 14px; + flex: 1; +} + +.sidebar-conn-error { + display: flex; + align-items: center; + gap: 8px; + font-size: 12px; + color: var(--text-muted); +} + +.sidebar-art { + width: 100%; + aspect-ratio: 1; + background: var(--overlay); + border: 1px solid var(--border-hi); + border-radius: calc(var(--radius) + 4px); + overflow: hidden; + position: relative; + box-shadow: var(--shadow); + flex-shrink: 0; +} + +.sidebar-art img { + width: 100%; + height: 100%; + object-fit: cover; + display: block; +} + +.sidebar-art--empty { + display: flex; + align-items: center; + justify-content: center; + color: var(--text-dim); + font-size: 40px; +} + +.sidebar-track { + min-width: 0; +} + +.sidebar-title { + font-size: 17px; + font-weight: 600; + line-height: 1.2; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: var(--text); +} + +.sidebar-sub { + font-size: 12px; + color: var(--text-muted); + margin-top: 3px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.sidebar-progress { + display: flex; + flex-direction: column; + gap: 6px; +} + +.sidebar-controls { + display: flex; + flex-direction: column; + gap: 10px; +} + +.sidebar-volume { + display: flex; + align-items: center; + gap: 8px; +} + +.sidebar-volume .vol-slider--sm { + flex: 1; +} + +/* ── Mobile: single column stack ── */ + +@media (max-width: 759px) { + html, body, #root { + height: auto; + overflow: visible; + } + + .app { + height: auto; + min-height: 100dvh; + } + + .app-body { + flex-direction: column; + overflow: visible; + min-height: 0; + } + + .player-sidebar { + width: 100%; + border-right: none; + border-bottom: 1px solid var(--border); + padding: 14px; + overflow: visible; + } + + .sidebar-art { + max-width: 260px; + margin: 0 auto; + } + + .app-main { + overflow-y: visible; + padding: 10px 14px 48px; + } +} diff --git a/audiocontrol/src/App.jsx b/audiocontrol/src/App.jsx index 375283a..f7b1ad5 100644 --- a/audiocontrol/src/App.jsx +++ b/audiocontrol/src/App.jsx @@ -1,28 +1,57 @@ import { useState } from 'react'; +import { MopidyProvider } from './contexts/MopidyContext'; +import { PlayerSidebar, LibraryPanel } from './components/MopidyPanel'; import { DSPPanel } from './components/DSPPanel'; -import { MopidyPanel } from './components/MopidyPanel'; import { SnapcastPanel } from './components/SnapcastPanel'; import { SettingsPanel } from './components/SettingsPanel'; import { useArtworkTheme } from './hooks/useArtworkTheme'; import './App.css'; const TABS = [ - { id: 'dsp', label: 'DSP' }, - { id: 'player', label: 'Player' }, - { id: 'network', label: 'Network' }, + { id: 'library', label: 'Library' }, + { id: 'dsp', label: 'DSP' }, + { id: 'network', label: 'Network' }, { id: 'settings', label: 'Settings' }, ]; -export default function App() { - const [tab, setTab] = useState('player'); +function SpeakerLogo() { + return ( + + ); +} + +function AppInner() { + const [tab, setTab] = useState('library'); useArtworkTheme(); return (
- AUDIO - CONTROL + +
+ GROOVE + AUDIO +
-
- {tab === 'dsp' && } - {tab === 'player' && } - {tab === 'network' && } - {tab === 'settings' && } -
+
+ +
+ {tab === 'library' && } + {tab === 'dsp' && } + {tab === 'network' && } + {tab === 'settings' && } +
+
); } + +export default function App() { + return ( + + + + ); +} diff --git a/audiocontrol/src/api/mopidy.js b/audiocontrol/src/api/mopidy.js index cd0a652..67e38a3 100644 --- a/audiocontrol/src/api/mopidy.js +++ b/audiocontrol/src/api/mopidy.js @@ -70,6 +70,7 @@ export const mopidy = { lookup: (uri) => rpc('core.library.lookup', { uri }), getImages: (uris) => rpc('core.library.get_images', { uris }), addTracks: (uris) => rpc('core.tracklist.add', { uris }), + addTracksAt: (uris, position) => rpc('core.tracklist.add', { uris, at_position: position }), localScan: () => rpc('core.library.refresh', {}), }; diff --git a/audiocontrol/src/components/MopidyPanel.jsx b/audiocontrol/src/components/MopidyPanel.jsx index ae6f812..313b759 100644 --- a/audiocontrol/src/components/MopidyPanel.jsx +++ b/audiocontrol/src/components/MopidyPanel.jsx @@ -1,5 +1,5 @@ import { Fragment, useCallback, useRef, useState, useEffect } from 'react'; -import { useMopidy } from '../hooks/useMopidy'; +import { useMopidyContext } from '../contexts/MopidyContext'; import { useCoverArt, imageProxyUri } from '../hooks/useCoverArt'; import { mopidy, formatTime } from '../api/mopidy'; import { nowPlayingApi } from '../api/nowPlaying'; @@ -34,32 +34,6 @@ function IconButton({ className = '', title, children, onClick, disabled }) { ); } -function TrackInfo({ track, bars }) { - const coverUri = useCoverArt(track?.track?.uri, track?.track?.album?.uri); - - if (!track) return ( -
-
ALB
-
Nothing playing
-
- ); - const t = track.track; - const artist = t.artists?.map(a => a.name).join(', ') ?? ''; - const album = t.album?.name ?? ''; - return ( -
-
- {coverUri ? : 'ALB'} - -
-
-
{t.name}
-
{[artist, album].filter(Boolean).join(' · ')}
-
-
- ); -} - function ToggleBtn({ label, title, active, onClick }) { return ( + - {playMenuOpen && ( -
- - -
- )} @@ -293,9 +266,14 @@ function AlbumDetail({ album, onBack, onAdd }) { {trackNumber(track) || (i + 1)} {track.name} {formatTime(track.length)} + { e.stopPropagation(); onPlay([track.uri]); }} + >▶ { e.stopPropagation(); onAdd([track.uri], 'Track added to queue'); }} >+ @@ -307,21 +285,13 @@ function AlbumDetail({ album, onBack, onAdd }) { ); } -// Local library browse modes — defined outside component for stable reference const LOCAL_MODES = [ { id: 'artist', label: 'Artists', uri: 'local:directory?type=artist', crumb: 'Artists' }, { id: 'album', label: 'Albums', uri: 'local:directory?type=album', crumb: 'Albums' }, { id: 'track', label: 'Tracks', uri: 'local:directory?type=track', crumb: 'Tracks' }, ]; -// Unified browser: handles both local library (with mode switching) and Spotify (fixed root). -// Props: -// modes – LOCAL_MODES array for local, null for Spotify-style single root -// defaultRootUri – starting URI when modes is null (e.g. 'spotify:directory') -// searchUris – uris[] scoping for mopidy.search (undefined = search all) -// showAlbumSearch – show album cover grid in search results (Spotify only) -// onAdd – async (uris[], message) callback -function BrowserPanel({ modes, defaultRootUri, searchUris, showAlbumSearch, onAdd }) { +function BrowserPanel({ modes, defaultRootUri, searchUris, showAlbumSearch, onAdd, onPlay }) { const [mode, setMode] = useState(modes ? modes[0] : null); const [stack, setStack] = useState(() => { const uri = modes ? modes[0].uri : defaultRootUri; @@ -342,13 +312,11 @@ function BrowserPanel({ modes, defaultRootUri, searchUris, showAlbumSearch, onAd const currentUri = stack[stack.length - 1].uri; - // Which items show in the cover grid and open as album detail const isCoverable = useCallback((item) => { if (!modes) return item.type === 'album' || item.type === 'playlist'; return item.type === 'album' || (mode?.id === 'album' && item.type === 'directory'); }, [modes, mode]); - // Browse useEffect(() => { if (searching || searchOpen) return; let cancelled = false; @@ -362,7 +330,6 @@ function BrowserPanel({ modes, defaultRootUri, searchUris, showAlbumSearch, onAd return () => { cancelled = true; }; }, [currentUri, searching, searchOpen]); - // Cover art for browseable items useEffect(() => { if (searching || searchOpen || !items?.length) return; const coverable = items.filter(isCoverable); @@ -374,7 +341,6 @@ function BrowserPanel({ modes, defaultRootUri, searchUris, showAlbumSearch, onAd return () => { cancelled = true; }; }, [items, searching, searchOpen, isCoverable]); - // Cover art for search album results useEffect(() => { if (!searching || !searchAlbums.length) { setSearchAlbumCovers({}); return undefined; } let cancelled = false; @@ -384,7 +350,6 @@ function BrowserPanel({ modes, defaultRootUri, searchUris, showAlbumSearch, onAd return () => { cancelled = true; }; }, [searchAlbums, searching]); - // Cover art for search track results useEffect(() => { if (!searching || !searchTracks.length) { setSearchTrackCovers({}); return undefined; } let cancelled = false; @@ -448,7 +413,6 @@ function BrowserPanel({ modes, defaultRootUri, searchUris, showAlbumSearch, onAd const otherItems = items?.filter(i => i.type !== 'directory' && i.type !== 'track' && !isCoverable(i)) ?? []; const trackItems = sortAlbumTracks(items?.filter(i => i.type === 'track') ?? []); const trackUris = trackItems.map(i => i.uri); - // For local: cover grid only in album mode. For Spotify: whenever coverables exist. const showCoverGrid = !searching && !searchOpen && !loading && coverableItems.length > 0 && (!modes || mode?.id === 'album'); const breadcrumbs = stack.length ? stack : [{ name: 'Root', uri: null }]; @@ -524,7 +488,7 @@ function BrowserPanel({ modes, defaultRootUri, searchUris, showAlbumSearch, onAd )}
- {albumDetail && } + {albumDetail && } {!albumDetail && loading && (
Loading{mode ? ` ${mode.label.toLowerCase()}` : ''}…
@@ -559,6 +523,11 @@ function BrowserPanel({ modes, defaultRootUri, searchUris, showAlbumSearch, onAd {item.name} {artist && {artist}}
+ { e.stopPropagation(); onPlay([item.uri]); }} + >▶ {artist}} {track.album?.name && {track.album.name}} + onPlay([track.uri])} + >▶ onAdd([track.uri], 'Track added to queue')} >+ @@ -620,7 +594,7 @@ function BrowserPanel({ modes, defaultRootUri, searchUris, showAlbumSearch, onAd ); } -function SourceBrowser({ onAdd }) { +function SourceBrowser({ onAdd, onPlay }) { const [source, setSource] = useState('local'); return (
@@ -635,43 +609,73 @@ function SourceBrowser({ onAdd }) { >Spotify
{source === 'local' - ? - : + ? + : } ); } -export function MopidyPanel() { - const m = useMopidy(); +// ── Player sidebar ──────────────────────────────────────────────────────────── + +const SIGNAL_THRESHOLD = 8; // avg CAVA bar value below this = noise only + +export function PlayerSidebar() { + const m = useMopidyContext(); const [queueOpen, setQueueOpen] = useState(false); const [visualizerBars, setVisualizerBars] = useState(null); - const [notice, setNotice] = useState(null); - const noticeTimer = useRef(null); + const [signalActive, setSignalActive] = useState(false); + const [nowPlayingMeta, setNowPlayingMeta] = useState(null); + + const t = m.currentTrack?.track; + const coverUri = useCoverArt(t?.uri, t?.album?.uri); + const isPlaying = m.playbackState === 'playing'; + const progressPct = m.duration > 0 ? (m.position / m.duration) * 100 : 0; + const artist = t?.artists?.map(a => a.name).join(', ') ?? ''; + const album = t?.album?.name ?? ''; useEffect(() => { let cancelled = false; - const loadVisualizer = () => { + const load = () => { nowPlayingApi.visualizer() - .then(payload => { - if (!cancelled && Array.isArray(payload.bars)) setVisualizerBars(payload.bars); + .then(p => { + if (cancelled) return; + if (Array.isArray(p.bars)) { + setVisualizerBars(p.bars); + const avg = p.bars.reduce((s, v) => s + v, 0) / p.bars.length; + setSignalActive(avg > SIGNAL_THRESHOLD); + } }) .catch(() => { if (!cancelled) setVisualizerBars(null); }); }; - loadVisualizer(); - const interval = setInterval(loadVisualizer, 300); - return () => { cancelled = true; clearInterval(interval); }; + load(); + const id = setInterval(load, 300); + return () => { cancelled = true; clearInterval(id); }; }, []); - useEffect(() => () => window.clearTimeout(noticeTimer.current), []); + useEffect(() => { + if (isPlaying) { setNowPlayingMeta(null); return undefined; } + let cancelled = false; + const load = () => { + nowPlayingApi.get() + .then(data => { if (!cancelled) setNowPlayingMeta(data?.metadata ?? null); }) + .catch(() => { if (!cancelled) setNowPlayingMeta(null); }); + }; + load(); + const id = setInterval(load, 5000); + return () => { cancelled = true; clearInterval(id); }; + }, [isPlaying]); - const isPlaying = m.playbackState === 'playing'; - const progressPct = m.duration > 0 ? (m.position / m.duration) * 100 : 0; + const showTurntable = !isPlaying && signalActive && Boolean(nowPlayingMeta?.artwork); + const displayCoverUri = showTurntable ? nowPlayingMeta.artwork : coverUri; + const displayTitle = showTurntable ? (nowPlayingMeta.title ?? 'Unknown') : (t?.name ?? 'Nothing playing'); + const displaySub = showTurntable + ? [nowPlayingMeta.artist, nowPlayingMeta.album].filter(Boolean).join(' · ') + : [artist, album].filter(Boolean).join(' · '); const handleSeek = (e) => { const rect = e.currentTarget.getBoundingClientRect(); - const x = (e.clientX - rect.left) / rect.width; - m.seek(Math.round(x * m.duration)); + m.seek(Math.round(((e.clientX - rect.left) / rect.width) * m.duration)); }; const handleSeekKey = (e) => { @@ -681,41 +685,27 @@ export function MopidyPanel() { if (e.key === 'ArrowLeft') { e.preventDefault(); m.seek(Math.max(m.position - step, 0)); } }; - const notify = (message) => { - setNotice(message); - window.clearTimeout(noticeTimer.current); - noticeTimer.current = window.setTimeout(() => setNotice(null), 1800); - }; - - const addTracksWithFeedback = async (uris, message = 'Added to queue') => { - if (!uris?.length) return; - notify('Adding…'); - try { - await m.addTracks(uris); - notify(message); - } catch (e) { - notify(e.message || 'Could not add tracks'); - throw e; - } - }; - return ( -
- {notice &&
{notice}
} - +
{!m.connected && ( -
+
Mopidy unreachable
)} -
-
- NOW PLAYING - {m.connected && } -
- +
+ {displayCoverUri ? : } + +
+
+
{displayTitle}
+ {displaySub && ( +
{displaySub}
+ )} +
+ +
{formatTime(m.position)} {formatTime(m.duration)}
-
+
-
-
-
-
- - - {isPlaying ? '⏸' : '▶'} - - - - setQueueOpen(open => !open)} - title="Queue" - > - ☰ {m.tracklist.length} - -
-
- {m.volume ?? '--'} -
-
- m.setVolume(parseInt(e.target.value))} - /> -
-
-
-
- m.setRepeat(!m.repeat)} /> - m.setRandom(!m.random)} /> - m.setSingle(!m.single)} /> - m.setMute(!m.mute)} /> +
+
+ + + {isPlaying ? '⏸' : '▶'} + + + + setQueueOpen(o => !o)} + title="Queue" + > + ☰ {m.tracklist.length} + +
+ +
+ {m.volume ?? '--'} +
+
+ m.setVolume(parseInt(e.target.value))} + />
-
+ +
+ m.setRepeat(!m.repeat)} /> + m.setRandom(!m.random)} /> + m.setSingle(!m.single)} /> + m.setMute(!m.mute)} /> +
+
{queueOpen && (
@@ -804,13 +792,62 @@ export function MopidyPanel() {
)} +
+ ); +} + +// ── Library panel (right column) ────────────────────────────────────────────── + +export function LibraryPanel() { + const m = useMopidyContext(); + const [notice, setNotice] = useState(null); + const noticeTimer = useRef(null); + + useEffect(() => () => window.clearTimeout(noticeTimer.current), []); + + const notify = (message) => { + setNotice(message); + window.clearTimeout(noticeTimer.current); + noticeTimer.current = window.setTimeout(() => setNotice(null), 1800); + }; + + const addTracksWithFeedback = async (uris, message = 'Added to queue') => { + if (!uris?.length) return; + notify('Adding…'); + try { + await m.addTracks(uris); + notify(message); + } catch (e) { + notify(e.message || 'Could not add tracks'); + throw e; + } + }; + + const playNowWithFeedback = async (uris) => { + if (!uris?.length) return; + try { + await m.playNow(uris); + } catch (e) { + notify(e.message || 'Could not play track'); + } + }; + + return ( +
+ {notice &&
{notice}
} + + {!m.connected && ( +
+ Mopidy unreachable +
+ )}
- +
); diff --git a/audiocontrol/src/contexts/MopidyContext.jsx b/audiocontrol/src/contexts/MopidyContext.jsx new file mode 100644 index 0000000..6607de9 --- /dev/null +++ b/audiocontrol/src/contexts/MopidyContext.jsx @@ -0,0 +1,13 @@ +import { createContext, useContext } from 'react'; +import { useMopidy } from '../hooks/useMopidy'; + +const MopidyContext = createContext(null); + +export function MopidyProvider({ children }) { + const m = useMopidy(); + return {children}; +} + +export function useMopidyContext() { + return useContext(MopidyContext); +} diff --git a/audiocontrol/src/hooks/useMopidy.js b/audiocontrol/src/hooks/useMopidy.js index f5f422e..6da3244 100644 --- a/audiocontrol/src/hooks/useMopidy.js +++ b/audiocontrol/src/hooks/useMopidy.js @@ -143,6 +143,12 @@ export function useMopidy() { clearTracklist: async () => { await mopidy.clearTracklist(); refresh(); }, removeTrack: async (tlid) => { await mopidy.removeTracks({ tlid: [tlid] }); refresh(); }, addTracks: async (uris) => { await mopidy.addTracks(uris); refresh(); }, + playNow: async (uris) => { + const added = await mopidy.addTracksAt(uris, 0); + const tlid = added?.[0]?.tlid; + if (tlid != null) await mopidy.play(tlid); + refresh(); + }, search: mopidy.search, };