redisign player, change now-playing logic

This commit is contained in:
2026-05-18 11:00:39 +02:00
parent 560d95cfc0
commit ee6e7b806f
6 changed files with 482 additions and 184 deletions
+224 -25
View File
@@ -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;
}
}
+56 -14
View File
@@ -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 (
<svg className="app-logo" width="40" height="24" viewBox="0 0 40 24" fill="none" aria-hidden="true">
{/* Left cabinet */}
<rect x="0.75" y="0.75" width="15.5" height="22.5" rx="2.5" stroke="currentColor" strokeWidth="1.5"/>
{/* Left woofer */}
<circle cx="8.5" cy="15.5" r="5.5" stroke="currentColor" strokeWidth="1.5"/>
<circle cx="8.5" cy="15.5" r="2.5" stroke="currentColor" strokeWidth="1"/>
<circle cx="8.5" cy="15.5" r="0.9" fill="currentColor"/>
{/* Left tweeter */}
<circle cx="8.5" cy="6.5" r="2" stroke="currentColor" strokeWidth="1"/>
<circle cx="8.5" cy="6.5" r="0.7" fill="currentColor"/>
{/* Right cabinet */}
<rect x="23.75" y="0.75" width="15.5" height="22.5" rx="2.5" stroke="currentColor" strokeWidth="1.5"/>
{/* Right woofer */}
<circle cx="31.5" cy="15.5" r="5.5" stroke="currentColor" strokeWidth="1.5"/>
<circle cx="31.5" cy="15.5" r="2.5" stroke="currentColor" strokeWidth="1"/>
<circle cx="31.5" cy="15.5" r="0.9" fill="currentColor"/>
{/* Right tweeter */}
<circle cx="31.5" cy="6.5" r="2" stroke="currentColor" strokeWidth="1"/>
<circle cx="31.5" cy="6.5" r="0.7" fill="currentColor"/>
</svg>
);
}
function AppInner() {
const [tab, setTab] = useState('library');
useArtworkTheme();
return (
<div className="app">
<header className="app-header">
<div className="app-title">
<span className="app-title-main">AUDIO</span>
<span className="app-title-sub">CONTROL</span>
<SpeakerLogo />
<div className="app-title-text">
<span className="app-title-main">GROOVE</span>
<span className="app-title-sub">AUDIO</span>
</div>
</div>
<nav className="tab-nav" role="tablist">
{TABS.map(t => (
@@ -39,12 +68,25 @@ export default function App() {
</nav>
</header>
<main className="app-main">
{tab === 'dsp' && <DSPPanel />}
{tab === 'player' && <MopidyPanel />}
{tab === 'network' && <SnapcastPanel />}
{tab === 'settings' && <SettingsPanel />}
</main>
<div className="app-body">
<aside className="player-sidebar">
<PlayerSidebar />
</aside>
<main className="app-main">
{tab === 'library' && <LibraryPanel />}
{tab === 'dsp' && <DSPPanel />}
{tab === 'network' && <SnapcastPanel />}
{tab === 'settings' && <SettingsPanel />}
</main>
</div>
</div>
);
}
export default function App() {
return (
<MopidyProvider>
<AppInner />
</MopidyProvider>
);
}
+1
View File
@@ -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', {}),
};
+182 -145
View File
@@ -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 (
<div className="now-playing now-playing--empty">
<div className="now-cover now-cover--empty">ALB</div>
<div className="track-title">Nothing playing</div>
</div>
);
const t = track.track;
const artist = t.artists?.map(a => a.name).join(', ') ?? '';
const album = t.album?.name ?? '';
return (
<div className="now-playing">
<div className="now-cover">
{coverUri ? <img src={coverUri} alt="" /> : 'ALB'}
<MopidyCavaOverlay bars={bars} />
</div>
<div className="now-text">
<div className="track-title">{t.name}</div>
<div className="track-sub">{[artist, album].filter(Boolean).join(' · ')}</div>
</div>
</div>
);
}
function ToggleBtn({ label, title, active, onClick }) {
return (
<button className={`toggle-btn ${active ? 'toggle-btn--on' : ''}`} onClick={onClick} title={title} aria-label={title}>
@@ -198,10 +172,9 @@ function CoverGrid({ albums, covers, onOpen }) {
);
}
function AlbumDetail({ album, onBack, onAdd }) {
function AlbumDetail({ album, onBack, onAdd, onPlay }) {
const [tracks, setTracks] = useState([]);
const [loading, setLoading] = useState(true);
const [playMenuOpen, setPlayMenuOpen] = useState(false);
const [coverUri, setCoverUri] = useState(null);
const [busyAction, setBusyAction] = useState(null);
@@ -237,11 +210,10 @@ function AlbumDetail({ album, onBack, onAdd }) {
if (mode === 'replace') {
await mopidy.clearTracklist();
await onAdd(uris, 'Album replaced queue');
await mopidy.play();
} else {
await onAdd(uris, 'Album added to queue');
}
setPlayMenuOpen(false);
await mopidy.play();
} finally {
setBusyAction(null);
}
@@ -258,17 +230,18 @@ function AlbumDetail({ album, onBack, onAdd }) {
<div className="album-play-menu">
<button
className="album-play-btn"
onClick={() => setPlayMenuOpen(o => !o)}
onClick={() => handlePlayAll('replace')}
disabled={loading || !tracks.length || Boolean(busyAction)}
>
{busyAction ? 'Adding…' : '▶ Play Album'}
{busyAction === 'replace' ? 'Loading…' : '▶ Play Album'}
</button>
<button
className="album-add-btn"
onClick={() => handlePlayAll('add')}
disabled={loading || !tracks.length || Boolean(busyAction)}
>
{busyAction === 'add' ? 'Adding…' : '+ Queue'}
</button>
{playMenuOpen && (
<div className="album-play-dropdown">
<button onClick={() => handlePlayAll('add')} disabled={Boolean(busyAction)}>Add to Queue</button>
<button onClick={() => handlePlayAll('replace')} disabled={Boolean(busyAction)}>Replace Queue</button>
</div>
)}
</div>
</div>
@@ -293,9 +266,14 @@ function AlbumDetail({ album, onBack, onAdd }) {
<span className="track-num mono">{trackNumber(track) || (i + 1)}</span>
<span className="track-name">{track.name}</span>
<span className="track-duration mono">{formatTime(track.length)}</span>
<IconButton
className="browser-play-btn"
title="Play now"
onClick={e => { e.stopPropagation(); onPlay([track.uri]); }}
></IconButton>
<IconButton
className="browser-add-btn"
title="Add track to queue"
title="Add to queue"
onClick={e => { e.stopPropagation(); onAdd([track.uri], 'Track added to queue'); }}
>+</IconButton>
</div>
@@ -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
)}
<div className="browser-list">
{albumDetail && <AlbumDetail album={albumDetail} onBack={back} onAdd={onAdd} />}
{albumDetail && <AlbumDetail album={albumDetail} onBack={back} onAdd={onAdd} onPlay={onPlay} />}
{!albumDetail && loading && (
<div className="browser-msg">Loading{mode ? ` ${mode.label.toLowerCase()}` : ''}</div>
@@ -559,6 +523,11 @@ function BrowserPanel({ modes, defaultRootUri, searchUris, showAlbumSearch, onAd
<span className="browser-name">{item.name}</span>
{artist && <span className="browser-sub">{artist}</span>}
</div>
<IconButton
className="browser-play-btn"
title="Play now"
onClick={e => { e.stopPropagation(); onPlay([item.uri]); }}
></IconButton>
<IconButton
className="browser-add-btn"
title="Add to queue"
@@ -598,9 +567,14 @@ function BrowserPanel({ modes, defaultRootUri, searchUris, showAlbumSearch, onAd
{artist && <span className="browser-sub">{artist}</span>}
{track.album?.name && <span className="browser-sub">{track.album.name}</span>}
</div>
<IconButton
className="browser-play-btn"
title="Play now"
onClick={() => onPlay([track.uri])}
></IconButton>
<IconButton
className="browser-add-btn"
title="Add track to queue"
title="Add to queue"
onClick={() => onAdd([track.uri], 'Track added to queue')}
>+</IconButton>
</div>
@@ -620,7 +594,7 @@ function BrowserPanel({ modes, defaultRootUri, searchUris, showAlbumSearch, onAd
);
}
function SourceBrowser({ onAdd }) {
function SourceBrowser({ onAdd, onPlay }) {
const [source, setSource] = useState('local');
return (
<div>
@@ -635,43 +609,73 @@ function SourceBrowser({ onAdd }) {
>Spotify</button>
</div>
{source === 'local'
? <BrowserPanel modes={LOCAL_MODES} onAdd={onAdd} />
: <BrowserPanel defaultRootUri="spotify:directory" searchUris={['spotify:']} showAlbumSearch onAdd={onAdd} />
? <BrowserPanel modes={LOCAL_MODES} onAdd={onAdd} onPlay={onPlay} />
: <BrowserPanel defaultRootUri="spotify:directory" searchUris={['spotify:']} showAlbumSearch onAdd={onAdd} onPlay={onPlay} />
}
</div>
);
}
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 (
<div className="panel">
{notice && <div className="action-toast" role="status">{notice}</div>}
<div className="sidebar-player">
{!m.connected && (
<div className="panel-error">
<div className="sidebar-conn-error">
<span className="dot dot-red" /> Mopidy unreachable
</div>
)}
<section className="panel-section">
<div className="section-header">
<span className="section-label">NOW PLAYING</span>
{m.connected && <span className={`dot ${isPlaying ? 'dot-green' : 'dot-dim'}`} />}
</div>
<TrackInfo track={m.currentTrack} bars={visualizerBars} />
<div className={`sidebar-art${!displayCoverUri ? ' sidebar-art--empty' : ''}`}>
{displayCoverUri ? <img src={displayCoverUri} alt="" /> : <span></span>}
<MopidyCavaOverlay bars={visualizerBars} />
</div>
<div className="sidebar-track">
<div className="sidebar-title">{displayTitle}</div>
{displaySub && (
<div className="sidebar-sub">{displaySub}</div>
)}
</div>
<div className="sidebar-progress">
<div
className="progress-bar"
role="slider"
@@ -733,50 +723,48 @@ export function MopidyPanel() {
<span className="mono">{formatTime(m.position)}</span>
<span className="mono">{formatTime(m.duration)}</span>
</div>
</section>
</div>
<section className="panel-section">
<div className="transport-panel">
<div className="transport-main">
<div className="transport">
<IconButton className="transport-btn" onClick={m.previous} title="Previous track"></IconButton>
<IconButton
className={`transport-btn transport-btn--play ${isPlaying ? 'transport-btn--active' : ''}`}
onClick={isPlaying ? m.pause : m.resume}
title={isPlaying ? 'Pause' : 'Play'}
>
{isPlaying ? '⏸' : '▶'}
</IconButton>
<IconButton className="transport-btn" onClick={m.next} title="Next track"></IconButton>
<IconButton className="transport-btn transport-btn--stop" onClick={m.stop} title="Stop"></IconButton>
<IconButton
className={`transport-btn transport-btn--queue ${queueOpen ? 'transport-btn--active' : ''}`}
onClick={() => setQueueOpen(open => !open)}
title="Queue"
>
<span className="queue-count">{m.tracklist.length}</span>
</IconButton>
</div>
<div className="transport-volume">
<span className="transport-volume-label mono">{m.volume ?? '--'}</span>
<div className="vol-slider vol-slider--sm">
<div className="vol-fill" style={{ width: `${m.volume ?? 0}%` }} />
<input
type="range" min={0} max={100} step={1}
value={m.volume ?? 0}
onChange={e => m.setVolume(parseInt(e.target.value))}
/>
</div>
</div>
</div>
<div className="transport-toggles">
<ToggleBtn label="∞" title="Repeat" active={m.repeat} onClick={() => m.setRepeat(!m.repeat)} />
<ToggleBtn label="⤨" title="Random" active={m.random} onClick={() => m.setRandom(!m.random)} />
<ToggleBtn label="1" title="Single track" active={m.single} onClick={() => m.setSingle(!m.single)} />
<ToggleBtn label={m.mute ? '🔇' : '🔊'} title={m.mute ? 'Unmute' : 'Mute'} active={m.mute} onClick={() => m.setMute(!m.mute)} />
<div className="sidebar-controls">
<div className="transport">
<IconButton className="transport-btn" onClick={m.previous} title="Previous track"></IconButton>
<IconButton
className={`transport-btn transport-btn--play ${isPlaying ? 'transport-btn--active' : ''}`}
onClick={isPlaying ? m.pause : m.resume}
title={isPlaying ? 'Pause' : 'Play'}
>
{isPlaying ? '' : ''}
</IconButton>
<IconButton className="transport-btn" onClick={m.next} title="Next track"></IconButton>
<IconButton className="transport-btn transport-btn--stop" onClick={m.stop} title="Stop"></IconButton>
<IconButton
className={`transport-btn transport-btn--queue ${queueOpen ? 'transport-btn--active' : ''}`}
onClick={() => setQueueOpen(o => !o)}
title="Queue"
>
<span className="queue-count">{m.tracklist.length}</span>
</IconButton>
</div>
<div className="sidebar-volume">
<span className="transport-volume-label mono">{m.volume ?? '--'}</span>
<div className="vol-slider vol-slider--sm">
<div className="vol-fill" style={{ width: `${m.volume ?? 0}%` }} />
<input
type="range" min={0} max={100} step={1}
value={m.volume ?? 0}
onChange={e => m.setVolume(parseInt(e.target.value))}
/>
</div>
</div>
</section>
<div className="transport-toggles">
<ToggleBtn label="∞" title="Repeat" active={m.repeat} onClick={() => m.setRepeat(!m.repeat)} />
<ToggleBtn label="⤨" title="Random" active={m.random} onClick={() => m.setRandom(!m.random)} />
<ToggleBtn label="1" title="Single track" active={m.single} onClick={() => m.setSingle(!m.single)} />
<ToggleBtn label={m.mute ? '🔇' : '🔊'} title={m.mute ? 'Unmute' : 'Mute'} active={m.mute} onClick={() => m.setMute(!m.mute)} />
</div>
</div>
{queueOpen && (
<div className="queue-shell" role="dialog" aria-label="Queue">
@@ -804,13 +792,62 @@ export function MopidyPanel() {
</aside>
</div>
)}
</div>
);
}
// ── 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 (
<div className="panel">
{notice && <div className="action-toast" role="status">{notice}</div>}
{!m.connected && (
<div className="panel-error">
<span className="dot dot-red" /> Mopidy unreachable
</div>
)}
<section className="panel-section">
<PlaylistBrowser playlists={m.playlists} onLoad={m.loadPlaylist} />
</section>
<section className="panel-section">
<SourceBrowser onAdd={addTracksWithFeedback} />
<SourceBrowser onAdd={addTracksWithFeedback} onPlay={playNowWithFeedback} />
</section>
</div>
);
@@ -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 <MopidyContext.Provider value={m}>{children}</MopidyContext.Provider>;
}
export function useMopidyContext() {
return useContext(MopidyContext);
}
+6
View File
@@ -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,
};