redisign player, change now-playing logic
This commit is contained in:
+224
-25
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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', {}),
|
||||
};
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user