redisign player, change now-playing logic
This commit is contained in:
+224
-25
@@ -60,6 +60,15 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.app-title {
|
.app-title {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
.app-logo {
|
||||||
|
color: var(--accent);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.app-title-text {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
@@ -1296,7 +1305,8 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.album-play-menu {
|
.album-play-menu {
|
||||||
position: relative;
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.album-play-btn {
|
.album-play-btn {
|
||||||
@@ -1322,39 +1332,40 @@ body {
|
|||||||
transform: none;
|
transform: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.album-play-dropdown {
|
.album-add-btn {
|
||||||
position: absolute;
|
display: inline-flex;
|
||||||
top: 100%;
|
align-items: center;
|
||||||
right: 0;
|
gap: 4px;
|
||||||
z-index: 10;
|
background: none;
|
||||||
background: var(--overlay);
|
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: var(--radius);
|
border-radius: var(--radius);
|
||||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
|
color: var(--text-muted);
|
||||||
overflow: hidden;
|
|
||||||
margin-top: 4px;
|
|
||||||
min-width: 140px;
|
|
||||||
}
|
|
||||||
.album-play-dropdown button {
|
|
||||||
display: block;
|
|
||||||
width: 100%;
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
color: var(--text);
|
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
padding: 8px 12px;
|
font-weight: 600;
|
||||||
text-align: left;
|
padding: 4px 10px;
|
||||||
transition: background 0.1s;
|
transition: color 0.15s, border-color 0.15s, transform 0.1s;
|
||||||
}
|
}
|
||||||
.album-play-dropdown button:hover {
|
.album-add-btn:hover {
|
||||||
background: var(--surface-hi);
|
color: var(--accent);
|
||||||
|
border-color: var(--accent);
|
||||||
|
transform: scale(1.05);
|
||||||
}
|
}
|
||||||
.album-play-dropdown button:disabled {
|
.album-add-btn:disabled {
|
||||||
cursor: default;
|
cursor: default;
|
||||||
opacity: 0.55;
|
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 {
|
.album-info {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -1743,7 +1754,7 @@ body {
|
|||||||
.browser-mode-btn,
|
.browser-mode-btn,
|
||||||
.album-back-btn,
|
.album-back-btn,
|
||||||
.album-play-btn,
|
.album-play-btn,
|
||||||
.album-play-dropdown button {
|
.album-add-btn {
|
||||||
-webkit-tap-highlight-color: transparent;
|
-webkit-tap-highlight-color: transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2112,3 +2123,191 @@ body {
|
|||||||
width: 100%;
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,28 +1,57 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
import { MopidyProvider } from './contexts/MopidyContext';
|
||||||
|
import { PlayerSidebar, LibraryPanel } from './components/MopidyPanel';
|
||||||
import { DSPPanel } from './components/DSPPanel';
|
import { DSPPanel } from './components/DSPPanel';
|
||||||
import { MopidyPanel } from './components/MopidyPanel';
|
|
||||||
import { SnapcastPanel } from './components/SnapcastPanel';
|
import { SnapcastPanel } from './components/SnapcastPanel';
|
||||||
import { SettingsPanel } from './components/SettingsPanel';
|
import { SettingsPanel } from './components/SettingsPanel';
|
||||||
import { useArtworkTheme } from './hooks/useArtworkTheme';
|
import { useArtworkTheme } from './hooks/useArtworkTheme';
|
||||||
import './App.css';
|
import './App.css';
|
||||||
|
|
||||||
const TABS = [
|
const TABS = [
|
||||||
|
{ id: 'library', label: 'Library' },
|
||||||
{ id: 'dsp', label: 'DSP' },
|
{ id: 'dsp', label: 'DSP' },
|
||||||
{ id: 'player', label: 'Player' },
|
|
||||||
{ id: 'network', label: 'Network' },
|
{ id: 'network', label: 'Network' },
|
||||||
{ id: 'settings', label: 'Settings' },
|
{ id: 'settings', label: 'Settings' },
|
||||||
];
|
];
|
||||||
|
|
||||||
export default function App() {
|
function SpeakerLogo() {
|
||||||
const [tab, setTab] = useState('player');
|
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();
|
useArtworkTheme();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="app">
|
<div className="app">
|
||||||
<header className="app-header">
|
<header className="app-header">
|
||||||
<div className="app-title">
|
<div className="app-title">
|
||||||
<span className="app-title-main">AUDIO</span>
|
<SpeakerLogo />
|
||||||
<span className="app-title-sub">CONTROL</span>
|
<div className="app-title-text">
|
||||||
|
<span className="app-title-main">GROOVE</span>
|
||||||
|
<span className="app-title-sub">AUDIO</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<nav className="tab-nav" role="tablist">
|
<nav className="tab-nav" role="tablist">
|
||||||
{TABS.map(t => (
|
{TABS.map(t => (
|
||||||
@@ -39,12 +68,25 @@ export default function App() {
|
|||||||
</nav>
|
</nav>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
<div className="app-body">
|
||||||
|
<aside className="player-sidebar">
|
||||||
|
<PlayerSidebar />
|
||||||
|
</aside>
|
||||||
<main className="app-main">
|
<main className="app-main">
|
||||||
|
{tab === 'library' && <LibraryPanel />}
|
||||||
{tab === 'dsp' && <DSPPanel />}
|
{tab === 'dsp' && <DSPPanel />}
|
||||||
{tab === 'player' && <MopidyPanel />}
|
|
||||||
{tab === 'network' && <SnapcastPanel />}
|
{tab === 'network' && <SnapcastPanel />}
|
||||||
{tab === 'settings' && <SettingsPanel />}
|
{tab === 'settings' && <SettingsPanel />}
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
return (
|
||||||
|
<MopidyProvider>
|
||||||
|
<AppInner />
|
||||||
|
</MopidyProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -70,6 +70,7 @@ export const mopidy = {
|
|||||||
lookup: (uri) => rpc('core.library.lookup', { uri }),
|
lookup: (uri) => rpc('core.library.lookup', { uri }),
|
||||||
getImages: (uris) => rpc('core.library.get_images', { uris }),
|
getImages: (uris) => rpc('core.library.get_images', { uris }),
|
||||||
addTracks: (uris) => rpc('core.tracklist.add', { uris }),
|
addTracks: (uris) => rpc('core.tracklist.add', { uris }),
|
||||||
|
addTracksAt: (uris, position) => rpc('core.tracklist.add', { uris, at_position: position }),
|
||||||
localScan: () => rpc('core.library.refresh', {}),
|
localScan: () => rpc('core.library.refresh', {}),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Fragment, useCallback, useRef, useState, useEffect } from 'react';
|
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 { useCoverArt, imageProxyUri } from '../hooks/useCoverArt';
|
||||||
import { mopidy, formatTime } from '../api/mopidy';
|
import { mopidy, formatTime } from '../api/mopidy';
|
||||||
import { nowPlayingApi } from '../api/nowPlaying';
|
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 }) {
|
function ToggleBtn({ label, title, active, onClick }) {
|
||||||
return (
|
return (
|
||||||
<button className={`toggle-btn ${active ? 'toggle-btn--on' : ''}`} onClick={onClick} title={title} aria-label={title}>
|
<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 [tracks, setTracks] = useState([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [playMenuOpen, setPlayMenuOpen] = useState(false);
|
|
||||||
const [coverUri, setCoverUri] = useState(null);
|
const [coverUri, setCoverUri] = useState(null);
|
||||||
const [busyAction, setBusyAction] = useState(null);
|
const [busyAction, setBusyAction] = useState(null);
|
||||||
|
|
||||||
@@ -237,11 +210,10 @@ function AlbumDetail({ album, onBack, onAdd }) {
|
|||||||
if (mode === 'replace') {
|
if (mode === 'replace') {
|
||||||
await mopidy.clearTracklist();
|
await mopidy.clearTracklist();
|
||||||
await onAdd(uris, 'Album replaced queue');
|
await onAdd(uris, 'Album replaced queue');
|
||||||
|
await mopidy.play();
|
||||||
} else {
|
} else {
|
||||||
await onAdd(uris, 'Album added to queue');
|
await onAdd(uris, 'Album added to queue');
|
||||||
}
|
}
|
||||||
setPlayMenuOpen(false);
|
|
||||||
await mopidy.play();
|
|
||||||
} finally {
|
} finally {
|
||||||
setBusyAction(null);
|
setBusyAction(null);
|
||||||
}
|
}
|
||||||
@@ -258,17 +230,18 @@ function AlbumDetail({ album, onBack, onAdd }) {
|
|||||||
<div className="album-play-menu">
|
<div className="album-play-menu">
|
||||||
<button
|
<button
|
||||||
className="album-play-btn"
|
className="album-play-btn"
|
||||||
onClick={() => setPlayMenuOpen(o => !o)}
|
onClick={() => handlePlayAll('replace')}
|
||||||
disabled={loading || !tracks.length || Boolean(busyAction)}
|
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>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -293,9 +266,14 @@ function AlbumDetail({ album, onBack, onAdd }) {
|
|||||||
<span className="track-num mono">{trackNumber(track) || (i + 1)}</span>
|
<span className="track-num mono">{trackNumber(track) || (i + 1)}</span>
|
||||||
<span className="track-name">{track.name}</span>
|
<span className="track-name">{track.name}</span>
|
||||||
<span className="track-duration mono">{formatTime(track.length)}</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
|
<IconButton
|
||||||
className="browser-add-btn"
|
className="browser-add-btn"
|
||||||
title="Add track to queue"
|
title="Add to queue"
|
||||||
onClick={e => { e.stopPropagation(); onAdd([track.uri], 'Track added to queue'); }}
|
onClick={e => { e.stopPropagation(); onAdd([track.uri], 'Track added to queue'); }}
|
||||||
>+</IconButton>
|
>+</IconButton>
|
||||||
</div>
|
</div>
|
||||||
@@ -307,21 +285,13 @@ function AlbumDetail({ album, onBack, onAdd }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Local library browse modes — defined outside component for stable reference
|
|
||||||
const LOCAL_MODES = [
|
const LOCAL_MODES = [
|
||||||
{ id: 'artist', label: 'Artists', uri: 'local:directory?type=artist', crumb: 'Artists' },
|
{ id: 'artist', label: 'Artists', uri: 'local:directory?type=artist', crumb: 'Artists' },
|
||||||
{ id: 'album', label: 'Albums', uri: 'local:directory?type=album', crumb: 'Albums' },
|
{ id: 'album', label: 'Albums', uri: 'local:directory?type=album', crumb: 'Albums' },
|
||||||
{ id: 'track', label: 'Tracks', uri: 'local:directory?type=track', crumb: 'Tracks' },
|
{ id: 'track', label: 'Tracks', uri: 'local:directory?type=track', crumb: 'Tracks' },
|
||||||
];
|
];
|
||||||
|
|
||||||
// Unified browser: handles both local library (with mode switching) and Spotify (fixed root).
|
function BrowserPanel({ modes, defaultRootUri, searchUris, showAlbumSearch, onAdd, onPlay }) {
|
||||||
// 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 }) {
|
|
||||||
const [mode, setMode] = useState(modes ? modes[0] : null);
|
const [mode, setMode] = useState(modes ? modes[0] : null);
|
||||||
const [stack, setStack] = useState(() => {
|
const [stack, setStack] = useState(() => {
|
||||||
const uri = modes ? modes[0].uri : defaultRootUri;
|
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;
|
const currentUri = stack[stack.length - 1].uri;
|
||||||
|
|
||||||
// Which items show in the cover grid and open as album detail
|
|
||||||
const isCoverable = useCallback((item) => {
|
const isCoverable = useCallback((item) => {
|
||||||
if (!modes) return item.type === 'album' || item.type === 'playlist';
|
if (!modes) return item.type === 'album' || item.type === 'playlist';
|
||||||
return item.type === 'album' || (mode?.id === 'album' && item.type === 'directory');
|
return item.type === 'album' || (mode?.id === 'album' && item.type === 'directory');
|
||||||
}, [modes, mode]);
|
}, [modes, mode]);
|
||||||
|
|
||||||
// Browse
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (searching || searchOpen) return;
|
if (searching || searchOpen) return;
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
@@ -362,7 +330,6 @@ function BrowserPanel({ modes, defaultRootUri, searchUris, showAlbumSearch, onAd
|
|||||||
return () => { cancelled = true; };
|
return () => { cancelled = true; };
|
||||||
}, [currentUri, searching, searchOpen]);
|
}, [currentUri, searching, searchOpen]);
|
||||||
|
|
||||||
// Cover art for browseable items
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (searching || searchOpen || !items?.length) return;
|
if (searching || searchOpen || !items?.length) return;
|
||||||
const coverable = items.filter(isCoverable);
|
const coverable = items.filter(isCoverable);
|
||||||
@@ -374,7 +341,6 @@ function BrowserPanel({ modes, defaultRootUri, searchUris, showAlbumSearch, onAd
|
|||||||
return () => { cancelled = true; };
|
return () => { cancelled = true; };
|
||||||
}, [items, searching, searchOpen, isCoverable]);
|
}, [items, searching, searchOpen, isCoverable]);
|
||||||
|
|
||||||
// Cover art for search album results
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!searching || !searchAlbums.length) { setSearchAlbumCovers({}); return undefined; }
|
if (!searching || !searchAlbums.length) { setSearchAlbumCovers({}); return undefined; }
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
@@ -384,7 +350,6 @@ function BrowserPanel({ modes, defaultRootUri, searchUris, showAlbumSearch, onAd
|
|||||||
return () => { cancelled = true; };
|
return () => { cancelled = true; };
|
||||||
}, [searchAlbums, searching]);
|
}, [searchAlbums, searching]);
|
||||||
|
|
||||||
// Cover art for search track results
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!searching || !searchTracks.length) { setSearchTrackCovers({}); return undefined; }
|
if (!searching || !searchTracks.length) { setSearchTrackCovers({}); return undefined; }
|
||||||
let cancelled = false;
|
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 otherItems = items?.filter(i => i.type !== 'directory' && i.type !== 'track' && !isCoverable(i)) ?? [];
|
||||||
const trackItems = sortAlbumTracks(items?.filter(i => i.type === 'track') ?? []);
|
const trackItems = sortAlbumTracks(items?.filter(i => i.type === 'track') ?? []);
|
||||||
const trackUris = trackItems.map(i => i.uri);
|
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 &&
|
const showCoverGrid = !searching && !searchOpen && !loading && coverableItems.length > 0 &&
|
||||||
(!modes || mode?.id === 'album');
|
(!modes || mode?.id === 'album');
|
||||||
const breadcrumbs = stack.length ? stack : [{ name: 'Root', uri: null }];
|
const breadcrumbs = stack.length ? stack : [{ name: 'Root', uri: null }];
|
||||||
@@ -524,7 +488,7 @@ function BrowserPanel({ modes, defaultRootUri, searchUris, showAlbumSearch, onAd
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="browser-list">
|
<div className="browser-list">
|
||||||
{albumDetail && <AlbumDetail album={albumDetail} onBack={back} onAdd={onAdd} />}
|
{albumDetail && <AlbumDetail album={albumDetail} onBack={back} onAdd={onAdd} onPlay={onPlay} />}
|
||||||
|
|
||||||
{!albumDetail && loading && (
|
{!albumDetail && loading && (
|
||||||
<div className="browser-msg">Loading{mode ? ` ${mode.label.toLowerCase()}` : ''}…</div>
|
<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>
|
<span className="browser-name">{item.name}</span>
|
||||||
{artist && <span className="browser-sub">{artist}</span>}
|
{artist && <span className="browser-sub">{artist}</span>}
|
||||||
</div>
|
</div>
|
||||||
|
<IconButton
|
||||||
|
className="browser-play-btn"
|
||||||
|
title="Play now"
|
||||||
|
onClick={e => { e.stopPropagation(); onPlay([item.uri]); }}
|
||||||
|
>▶</IconButton>
|
||||||
<IconButton
|
<IconButton
|
||||||
className="browser-add-btn"
|
className="browser-add-btn"
|
||||||
title="Add to queue"
|
title="Add to queue"
|
||||||
@@ -598,9 +567,14 @@ function BrowserPanel({ modes, defaultRootUri, searchUris, showAlbumSearch, onAd
|
|||||||
{artist && <span className="browser-sub">{artist}</span>}
|
{artist && <span className="browser-sub">{artist}</span>}
|
||||||
{track.album?.name && <span className="browser-sub">{track.album.name}</span>}
|
{track.album?.name && <span className="browser-sub">{track.album.name}</span>}
|
||||||
</div>
|
</div>
|
||||||
|
<IconButton
|
||||||
|
className="browser-play-btn"
|
||||||
|
title="Play now"
|
||||||
|
onClick={() => onPlay([track.uri])}
|
||||||
|
>▶</IconButton>
|
||||||
<IconButton
|
<IconButton
|
||||||
className="browser-add-btn"
|
className="browser-add-btn"
|
||||||
title="Add track to queue"
|
title="Add to queue"
|
||||||
onClick={() => onAdd([track.uri], 'Track added to queue')}
|
onClick={() => onAdd([track.uri], 'Track added to queue')}
|
||||||
>+</IconButton>
|
>+</IconButton>
|
||||||
</div>
|
</div>
|
||||||
@@ -620,7 +594,7 @@ function BrowserPanel({ modes, defaultRootUri, searchUris, showAlbumSearch, onAd
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function SourceBrowser({ onAdd }) {
|
function SourceBrowser({ onAdd, onPlay }) {
|
||||||
const [source, setSource] = useState('local');
|
const [source, setSource] = useState('local');
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
@@ -635,43 +609,73 @@ function SourceBrowser({ onAdd }) {
|
|||||||
>Spotify</button>
|
>Spotify</button>
|
||||||
</div>
|
</div>
|
||||||
{source === 'local'
|
{source === 'local'
|
||||||
? <BrowserPanel modes={LOCAL_MODES} onAdd={onAdd} />
|
? <BrowserPanel modes={LOCAL_MODES} onAdd={onAdd} onPlay={onPlay} />
|
||||||
: <BrowserPanel defaultRootUri="spotify:directory" searchUris={['spotify:']} showAlbumSearch onAdd={onAdd} />
|
: <BrowserPanel defaultRootUri="spotify:directory" searchUris={['spotify:']} showAlbumSearch onAdd={onAdd} onPlay={onPlay} />
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function MopidyPanel() {
|
// ── Player sidebar ────────────────────────────────────────────────────────────
|
||||||
const m = useMopidy();
|
|
||||||
|
const SIGNAL_THRESHOLD = 8; // avg CAVA bar value below this = noise only
|
||||||
|
|
||||||
|
export function PlayerSidebar() {
|
||||||
|
const m = useMopidyContext();
|
||||||
const [queueOpen, setQueueOpen] = useState(false);
|
const [queueOpen, setQueueOpen] = useState(false);
|
||||||
const [visualizerBars, setVisualizerBars] = useState(null);
|
const [visualizerBars, setVisualizerBars] = useState(null);
|
||||||
const [notice, setNotice] = useState(null);
|
const [signalActive, setSignalActive] = useState(false);
|
||||||
const noticeTimer = useRef(null);
|
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(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
const loadVisualizer = () => {
|
const load = () => {
|
||||||
nowPlayingApi.visualizer()
|
nowPlayingApi.visualizer()
|
||||||
.then(payload => {
|
.then(p => {
|
||||||
if (!cancelled && Array.isArray(payload.bars)) setVisualizerBars(payload.bars);
|
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); });
|
.catch(() => { if (!cancelled) setVisualizerBars(null); });
|
||||||
};
|
};
|
||||||
loadVisualizer();
|
load();
|
||||||
const interval = setInterval(loadVisualizer, 300);
|
const id = setInterval(load, 300);
|
||||||
return () => { cancelled = true; clearInterval(interval); };
|
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 showTurntable = !isPlaying && signalActive && Boolean(nowPlayingMeta?.artwork);
|
||||||
const progressPct = m.duration > 0 ? (m.position / m.duration) * 100 : 0;
|
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 handleSeek = (e) => {
|
||||||
const rect = e.currentTarget.getBoundingClientRect();
|
const rect = e.currentTarget.getBoundingClientRect();
|
||||||
const x = (e.clientX - rect.left) / rect.width;
|
m.seek(Math.round(((e.clientX - rect.left) / rect.width) * m.duration));
|
||||||
m.seek(Math.round(x * m.duration));
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSeekKey = (e) => {
|
const handleSeekKey = (e) => {
|
||||||
@@ -681,41 +685,27 @@ export function MopidyPanel() {
|
|||||||
if (e.key === 'ArrowLeft') { e.preventDefault(); m.seek(Math.max(m.position - step, 0)); }
|
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 (
|
return (
|
||||||
<div className="panel">
|
<div className="sidebar-player">
|
||||||
{notice && <div className="action-toast" role="status">{notice}</div>}
|
|
||||||
|
|
||||||
{!m.connected && (
|
{!m.connected && (
|
||||||
<div className="panel-error">
|
<div className="sidebar-conn-error">
|
||||||
<span className="dot dot-red" /> Mopidy unreachable
|
<span className="dot dot-red" /> Mopidy unreachable
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<section className="panel-section">
|
<div className={`sidebar-art${!displayCoverUri ? ' sidebar-art--empty' : ''}`}>
|
||||||
<div className="section-header">
|
{displayCoverUri ? <img src={displayCoverUri} alt="" /> : <span>♪</span>}
|
||||||
<span className="section-label">NOW PLAYING</span>
|
<MopidyCavaOverlay bars={visualizerBars} />
|
||||||
{m.connected && <span className={`dot ${isPlaying ? 'dot-green' : 'dot-dim'}`} />}
|
|
||||||
</div>
|
</div>
|
||||||
<TrackInfo track={m.currentTrack} bars={visualizerBars} />
|
|
||||||
|
|
||||||
|
<div className="sidebar-track">
|
||||||
|
<div className="sidebar-title">{displayTitle}</div>
|
||||||
|
{displaySub && (
|
||||||
|
<div className="sidebar-sub">{displaySub}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="sidebar-progress">
|
||||||
<div
|
<div
|
||||||
className="progress-bar"
|
className="progress-bar"
|
||||||
role="slider"
|
role="slider"
|
||||||
@@ -733,11 +723,9 @@ export function MopidyPanel() {
|
|||||||
<span className="mono">{formatTime(m.position)}</span>
|
<span className="mono">{formatTime(m.position)}</span>
|
||||||
<span className="mono">{formatTime(m.duration)}</span>
|
<span className="mono">{formatTime(m.duration)}</span>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</div>
|
||||||
|
|
||||||
<section className="panel-section">
|
<div className="sidebar-controls">
|
||||||
<div className="transport-panel">
|
|
||||||
<div className="transport-main">
|
|
||||||
<div className="transport">
|
<div className="transport">
|
||||||
<IconButton className="transport-btn" onClick={m.previous} title="Previous track">⏮</IconButton>
|
<IconButton className="transport-btn" onClick={m.previous} title="Previous track">⏮</IconButton>
|
||||||
<IconButton
|
<IconButton
|
||||||
@@ -751,13 +739,14 @@ export function MopidyPanel() {
|
|||||||
<IconButton className="transport-btn transport-btn--stop" onClick={m.stop} title="Stop">⏹</IconButton>
|
<IconButton className="transport-btn transport-btn--stop" onClick={m.stop} title="Stop">⏹</IconButton>
|
||||||
<IconButton
|
<IconButton
|
||||||
className={`transport-btn transport-btn--queue ${queueOpen ? 'transport-btn--active' : ''}`}
|
className={`transport-btn transport-btn--queue ${queueOpen ? 'transport-btn--active' : ''}`}
|
||||||
onClick={() => setQueueOpen(open => !open)}
|
onClick={() => setQueueOpen(o => !o)}
|
||||||
title="Queue"
|
title="Queue"
|
||||||
>
|
>
|
||||||
☰ <span className="queue-count">{m.tracklist.length}</span>
|
☰ <span className="queue-count">{m.tracklist.length}</span>
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</div>
|
</div>
|
||||||
<div className="transport-volume">
|
|
||||||
|
<div className="sidebar-volume">
|
||||||
<span className="transport-volume-label mono">{m.volume ?? '--'}</span>
|
<span className="transport-volume-label mono">{m.volume ?? '--'}</span>
|
||||||
<div className="vol-slider vol-slider--sm">
|
<div className="vol-slider vol-slider--sm">
|
||||||
<div className="vol-fill" style={{ width: `${m.volume ?? 0}%` }} />
|
<div className="vol-fill" style={{ width: `${m.volume ?? 0}%` }} />
|
||||||
@@ -768,7 +757,7 @@ export function MopidyPanel() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
<div className="transport-toggles">
|
<div className="transport-toggles">
|
||||||
<ToggleBtn label="∞" title="Repeat" active={m.repeat} onClick={() => m.setRepeat(!m.repeat)} />
|
<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="⤨" title="Random" active={m.random} onClick={() => m.setRandom(!m.random)} />
|
||||||
@@ -776,7 +765,6 @@ export function MopidyPanel() {
|
|||||||
<ToggleBtn label={m.mute ? '🔇' : '🔊'} title={m.mute ? 'Unmute' : 'Mute'} active={m.mute} onClick={() => m.setMute(!m.mute)} />
|
<ToggleBtn label={m.mute ? '🔇' : '🔊'} title={m.mute ? 'Unmute' : 'Mute'} active={m.mute} onClick={() => m.setMute(!m.mute)} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
|
||||||
|
|
||||||
{queueOpen && (
|
{queueOpen && (
|
||||||
<div className="queue-shell" role="dialog" aria-label="Queue">
|
<div className="queue-shell" role="dialog" aria-label="Queue">
|
||||||
@@ -804,13 +792,62 @@ export function MopidyPanel() {
|
|||||||
</aside>
|
</aside>
|
||||||
</div>
|
</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">
|
<section className="panel-section">
|
||||||
<PlaylistBrowser playlists={m.playlists} onLoad={m.loadPlaylist} />
|
<PlaylistBrowser playlists={m.playlists} onLoad={m.loadPlaylist} />
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="panel-section">
|
<section className="panel-section">
|
||||||
<SourceBrowser onAdd={addTracksWithFeedback} />
|
<SourceBrowser onAdd={addTracksWithFeedback} onPlay={playNowWithFeedback} />
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</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(); },
|
clearTracklist: async () => { await mopidy.clearTracklist(); refresh(); },
|
||||||
removeTrack: async (tlid) => { await mopidy.removeTracks({ tlid: [tlid] }); refresh(); },
|
removeTrack: async (tlid) => { await mopidy.removeTracks({ tlid: [tlid] }); refresh(); },
|
||||||
addTracks: async (uris) => { await mopidy.addTracks(uris); 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,
|
search: mopidy.search,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user