feat(soundtouch-web): add progress bar, shuffle/repeat, and bass controls

- NowPlaying: progress bar with live ticking (resets on position/state change)
- Controls: shuffle toggle (🔀), repeat cycle (🔁/🔂), active state styling
- Controls: bass slider (-9..+9), shown only when device reports bass support
- api.js: add bass() helper posting JSON body to /api/control/{id}/bass
- CSS: progress bar, progress time, bass-row styles

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Tobias Gesellchen
2026-05-08 21:22:57 +02:00
co-authored by Claude Sonnet 4.6
parent 6723515f54
commit 2edcc14342
4 changed files with 93 additions and 15 deletions
@@ -219,6 +219,26 @@ img { display: block; max-width: 100%; }
.volume-slider { flex: 1; accent-color: var(--accent); }
.volume-value { font-size: .8rem; color: var(--text-dim); min-width: 2.5ch; text-align: right; }
.bass-row { display: flex; align-items: center; gap: .75rem; margin-top: .5rem; }
.bass-label { font-size: .8rem; color: var(--text-dim); width: 2.5ch; }
/* ── Progress bar ────────────────────────────────────────────────────────── */
.progress-row { margin-top: .35rem; display: flex; align-items: center; gap: .5rem; }
.progress-bar {
flex: 1;
height: 3px;
background: var(--border);
border-radius: 2px;
overflow: hidden;
}
.progress-fill {
height: 100%;
background: var(--accent);
border-radius: 2px;
transition: width .9s linear;
}
.progress-time { font-size: .7rem; color: var(--text-dim); white-space: nowrap; flex-shrink: 0; }
/* ── Presets ─────────────────────────────────────────────────────────────── */
.presets-section, .sources-section { margin-top: 1.25rem; }
.section-title { font-size: .8rem; font-weight: 600; text-transform: uppercase; letter-spacing: .06em; color: var(--text-dim); margin-bottom: .6rem; }
+8 -1
View File
@@ -1,3 +1,5 @@
const JSON_HEADERS = { 'Content-Type': 'application/json' };
async function req(url, opts = {}) {
const r = await fetch(url, opts);
return r.json();
@@ -9,6 +11,11 @@ export const api = {
discover: () => req('/api/discover', { method: 'POST' }),
key: (id, key) => req(`/api/device-key/${id}/${key}`, { method: 'POST' }),
volume: (id, level) => req(`/api/device-volume/${id}/${level}`, { method: 'POST' }),
bass: (id, level) => req(`/api/control/${id}/bass`, {
method: 'POST',
headers: JSON_HEADERS,
body: JSON.stringify({ level }),
}),
power: (id) => req(`/api/device-power/${id}`, { method: 'POST' }),
tuneInBrowse: (path) => req(path ? `/api/tunein/navigate/${path}` : '/api/tunein/navigate'),
tuneInSearch: (q) => req(`/api/tunein/search?q=${encodeURIComponent(q)}`),
@@ -16,7 +23,7 @@ export const api = {
selectSource: (id, source, account) => req(`/api/control/${id}/source?name=${encodeURIComponent(source)}&account=${encodeURIComponent(account || '')}`),
tuneInPlay: (deviceId, item) => req(`/api/tunein/play/${deviceId}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
headers: JSON_HEADERS,
body: JSON.stringify(item),
}),
};
@@ -10,12 +10,16 @@ export function Controls({ deviceId, status }) {
const isPlaying = np?.PlayStatus === 'PLAY_STATE';
const actualVolume = status?.volume?.ActualVolume ?? 0;
const isMuted = status?.volume?.MuteEnabled ?? false;
const skipEnabled = np?.SkipEnabled != null;
const skipPrevEnabled = np?.SkipPreviousEnabled != null;
const shuffle = np?.ShuffleSetting ?? 'SHUFFLE_OFF';
const repeat = np?.RepeatSetting ?? 'REPEAT_OFF';
const actualBass = status?.bass?.TargetBass ?? 0;
const hasBass = status?.bass != null;
const [localVolume, setLocalVolume] = useState(actualVolume);
const [localBass, setLocalBass] = useState(actualBass);
useEffect(() => { setLocalVolume(actualVolume); }, [actualVolume]);
useEffect(() => { setLocalBass(actualBass); }, [actualBass]);
const send = (key) => api.key(deviceId, key);
@@ -25,6 +29,24 @@ export function Controls({ deviceId, status }) {
api.volume(deviceId, val);
}
function onBassChange(e) {
const val = parseInt(e.target.value, 10);
setLocalBass(val);
api.bass(deviceId, val);
}
function toggleShuffle() {
send(shuffle === 'SHUFFLE_ON' ? 'SHUFFLE_OFF' : 'SHUFFLE_ON');
}
function cycleRepeat() {
if (repeat === 'REPEAT_OFF') send('REPEAT_ALL');
else if (repeat === 'REPEAT_ALL') send('REPEAT_ONE');
else send('REPEAT_OFF');
}
const repeatIcon = repeat === 'REPEAT_ONE' ? '🔂' : '🔁';
return html`
<div class="controls">
<div class="transport">
@@ -36,19 +58,23 @@ export function Controls({ deviceId, status }) {
<button class="ctrl-btn ${isMuted ? 'active' : ''}" onClick=${() => send('MUTE')} title="Mute">
${isMuted ? '🔇' : '🔊'}
</button>
<button class="ctrl-btn ${shuffle === 'SHUFFLE_ON' ? 'active' : ''}" onClick=${toggleShuffle} title="Shuffle">🔀</button>
<button class="ctrl-btn ${repeat !== 'REPEAT_OFF' ? 'active' : ''}" onClick=${cycleRepeat} title="Repeat">${repeatIcon}</button>
</div>
<div class="volume-row">
<span class="volume-icon">🔈</span>
<input
type="range"
class="volume-slider"
min="0"
max="100"
value=${localVolume}
onInput=${onVolumeChange}
/>
<input type="range" class="volume-slider" min="0" max="100"
value=${localVolume} onInput=${onVolumeChange} />
<span class="volume-value">${localVolume}</span>
</div>
${hasBass && html`
<div class="bass-row">
<span class="bass-label">Bass</span>
<input type="range" class="volume-slider" min="-9" max="9"
value=${localBass} onInput=${onBassChange} />
<span class="volume-value">${localBass > 0 ? '+' : ''}${localBass}</span>
</div>
`}
</div>
`;
}
@@ -1,23 +1,40 @@
import { h } from 'preact';
import { useState, useEffect } from 'preact/hooks';
import htm from 'htm';
const html = htm.bind(h);
function fmt(secs) {
if (!secs || secs <= 0) return '0:00';
const m = Math.floor(secs / 60);
const s = secs % 60;
return `${m}:${s.toString().padStart(2, '0')}`;
}
export function NowPlaying({ nowPlaying }) {
const [position, setPosition] = useState(0);
useEffect(() => {
const pos = nowPlaying?.Time?.Position ?? 0;
setPosition(pos);
if (nowPlaying?.PlayStatus !== 'PLAY_STATE') return;
const id = setInterval(() => setPosition(p => p + 1), 1000);
return () => clearInterval(id);
}, [nowPlaying?.Time?.Position, nowPlaying?.PlayStatus]);
if (!nowPlaying || nowPlaying.Source === 'STANDBY') {
return html`<div class="now-playing standby">Standby</div>`;
}
const title = nowPlaying.Track || nowPlaying.StationName || nowPlaying.Source;
const artURL = nowPlaying.Art?.URL;
const isPlaying = nowPlaying.PlayStatus === 'PLAY_STATE';
const isBuffering = nowPlaying.PlayStatus === 'BUFFERING_STATE';
const total = nowPlaying.Time?.Total ?? 0;
const pct = total > 0 ? Math.min(100, (position / total) * 100) : 0;
return html`
<div class="now-playing">
${artURL && html`
<img class="album-art" src=${artURL} alt="" />
`}
${artURL && html`<img class="album-art" src=${artURL} alt="" />`}
<div class="track-info">
<div class="track-title">${title}</div>
${nowPlaying.Artist && html`<div class="track-artist">${nowPlaying.Artist}</div>`}
@@ -26,6 +43,14 @@ export function NowPlaying({ nowPlaying }) {
<span class="track-source">${nowPlaying.Source}</span>
${isBuffering && html`<span class="buffering-badge">Buffering…</span>`}
</div>
${total > 0 && html`
<div class="progress-row">
<div class="progress-bar">
<div class="progress-fill" style="width:${pct}%"></div>
</div>
<span class="progress-time">${fmt(position)} / ${fmt(total)}</span>
</div>
`}
</div>
</div>
`;