Files
Tobias GesellchenandClaude Sonnet 4.6 e117b472b1 feat(soundtouch-web): add save-as-preset from Now Playing and preset tiles
Two complementary ways to save what's currently playing to a preset slot
without leaving the web UI:

★ Star button (Now Playing card)
  A semi-transparent star appears in the top-right corner of the Now
  Playing card whenever a device is selected and something is playing.
  Clicking it opens a slot picker (1–6); selecting a slot calls
  POST /api/control/{id}/storepreset?id={slot}.  The star turns gold
  when the current ContentItem is already mapped to at least one preset,
  matching the preset list by Source + Location.  An outside-click
  closes the picker without saving.

+ button (preset tiles)
  While content is playing each of the six preset tiles shows a small +
  button on hover.  Clicking it saves directly to that slot — no picker
  needed.  The button cycles through +  →  ✓  →  (reset) states with
  a 1.5 s success flash and shows ✗ briefly on error.

Backend (handler.go):
  New "storepreset" case in handleControlAction dispatches to
  handleStorePreset, which validates the ?id= query param (1-6) and
  calls device.Client.StoreCurrentAsPreset(presetID).

Frontend (api.js):
  storePreset(deviceId, slotId) helper added.

CSS (app.css):
  .preset-slot-wrap wrapper + .preset-save-btn styles for the + button,
  source-specific --slot-color custom properties for border accents,
  .now-playing-fav-wrap / .now-playing-fav-btn / .now-playing-fav-overlay
  for the star button and its popover (right-aligned, z-index: 50).
  position: relative added to .now-playing so the star can be absolutely
  positioned without being clipped by .track-info overflow: hidden.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 22:47:13 +02:00

112 lines
4.0 KiB
JavaScript

import { h } from 'preact';
import { useState } from 'preact/hooks';
import htm from 'htm';
import { api } from '../api.js';
const html = htm.bind(h);
const SOURCE_LABELS = {
TUNEIN: 'TuneIn', SPOTIFY: 'Spotify', AMAZON: 'Amazon',
PANDORA: 'Pandora', IHEARTRADIO: 'iHeart', DEEZER: 'Deezer',
LOCAL_INTERNET_RADIO: 'Internet Radio',
};
function sourceLabel(source) {
return SOURCE_LABELS[source] || source;
}
// PresetSlot renders a single preset button plus, when something presetable
// is currently playing (canSave=true), an overlaid save button that stores
// the current content to that slot. The save button lives *outside* the
// play button in the DOM (via the wrapper div) to avoid invalid nested
// <button> elements.
function PresetSlot({ preset, deviceId, active, canSave }) {
const [saveState, setSaveState] = useState(null); // null | 'saved' | 'error'
const item = preset?.ContentItem;
const isEmpty = !item;
const art = item?.ContainerArt;
const name = item?.ItemName || `Preset ${preset?.ID ?? ''}`;
function select() {
if (!isEmpty) api.control(deviceId, 'preset', preset.ID);
}
function save(e) {
e.stopPropagation();
api.storePreset(deviceId, preset.ID)
.then(res => {
setSaveState(res.success ? 'saved' : 'error');
setTimeout(() => setSaveState(null), 1500);
})
.catch(() => {
setSaveState('error');
setTimeout(() => setSaveState(null), 1500);
});
}
return html`
<div class="preset-slot-wrap">
<button
class="preset-slot ${isEmpty ? 'empty' : ''} ${active ? 'active' : ''}"
data-source=${item?.Source ?? ''}
onClick=${select}
disabled=${isEmpty}
title=${isEmpty ? 'Empty' : name}
>
${art
? html`<img class="preset-art" src=${art} alt="" />`
: html`<span class="preset-source-label">${isEmpty ? '—' : sourceLabel(item.Source)}</span>`
}
<span class="preset-name">${isEmpty ? 'Empty' : name}</span>
<span class="preset-num">${preset?.ID ?? ''}</span>
</button>
${canSave && html`
<button
class="preset-save-btn ${saveState ?? ''}"
onClick=${save}
title="Save current to preset ${preset.ID}"
aria-label="Save current to preset ${preset.ID}"
>
${saveState === 'saved' ? '✓' : saveState === 'error' ? '✗' : '+'}
</button>
`}
</div>
`;
}
export function Presets({ deviceId, status }) {
const presets = status?.presets?.Preset ?? [];
const currentSource = status?.nowPlaying?.Source;
const currentLocation = status?.nowPlaying?.ContentItem?.Location;
// Show save buttons whenever something that isn't STANDBY is playing.
// The server validates IsPresetable; if it fails the button shows ✗ briefly.
const canSave = !!(currentSource && currentSource !== 'STANDBY');
// Build a map for quick lookup, then render slots 1-6
const byId = Object.fromEntries(presets.map(p => [p.ID, p]));
const slots = [1, 2, 3, 4, 5, 6].map(id => byId[id] ?? { ID: id, ContentItem: null });
function isActive(preset) {
const item = preset.ContentItem;
return item && item.Source === currentSource && item.Location === currentLocation;
}
return html`
<div class="presets-section">
<h3 class="section-title">Presets</h3>
<div class="preset-grid">
${slots.map(preset => html`
<${PresetSlot}
key=${preset.ID}
preset=${preset}
deviceId=${deviceId}
active=${isActive(preset)}
canSave=${canSave}
/>
`)}
</div>
</div>
`;
}