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 (