diff --git a/pkg/service/soundtouchweb/static/css/app.css b/pkg/service/soundtouchweb/static/css/app.css index db8f554..7bf000a 100644 --- a/pkg/service/soundtouchweb/static/css/app.css +++ b/pkg/service/soundtouchweb/static/css/app.css @@ -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; } diff --git a/pkg/service/soundtouchweb/static/js/api.js b/pkg/service/soundtouchweb/static/js/api.js index 42fb05c..7caf130 100644 --- a/pkg/service/soundtouchweb/static/js/api.js +++ b/pkg/service/soundtouchweb/static/js/api.js @@ -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), }), }; \ No newline at end of file diff --git a/pkg/service/soundtouchweb/static/js/components/Controls.js b/pkg/service/soundtouchweb/static/js/components/Controls.js index 7541c9a..cacbd52 100644 --- a/pkg/service/soundtouchweb/static/js/components/Controls.js +++ b/pkg/service/soundtouchweb/static/js/components/Controls.js @@ -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`
@@ -36,19 +58,23 @@ export function Controls({ deviceId, status }) { + +
🔈 - + ${localVolume}
+ ${hasBass && html` +
+ Bass + + ${localBass > 0 ? '+' : ''}${localBass} +
+ `}
`; } \ No newline at end of file diff --git a/pkg/service/soundtouchweb/static/js/components/NowPlaying.js b/pkg/service/soundtouchweb/static/js/components/NowPlaying.js index 3af2239..7be021d 100644 --- a/pkg/service/soundtouchweb/static/js/components/NowPlaying.js +++ b/pkg/service/soundtouchweb/static/js/components/NowPlaying.js @@ -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`
Standby
`; } 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`
- ${artURL && html` - - `} + ${artURL && html``}
${title}
${nowPlaying.Artist && html`
${nowPlaying.Artist}
`} @@ -26,6 +43,14 @@ export function NowPlaying({ nowPlaying }) { ${nowPlaying.Source} ${isBuffering && html`Buffering…`}
+ ${total > 0 && html` +
+
+
+
+ ${fmt(position)} / ${fmt(total)} +
+ `}
`;