Merge feature/volume-management into master

This commit is contained in:
joren
2026-03-29 23:35:55 +02:00
7 changed files with 255 additions and 5 deletions

View File

@@ -11,6 +11,7 @@
setActivated, setExclusive,
setAutoPin, setAutoDisconnect,
saveProfile, loadProfile, deleteProfile,
setNodeVolume, setNodeMute,
} from '../lib/stores';
import type { Node, Port, Link } from '../lib/types';
@@ -166,7 +167,7 @@
const headerH = 22;
const portH = 16;
const maxPorts = Math.max(allInPorts.length, allOutPorts.length, 1);
const height = headerH + maxPorts * portH + 4;
const height = headerH + maxPorts * portH + 20; // extra 20 for volume slider
const width = 220;
const portPositions = new Map<number, { x: number; y: number }>();
@@ -213,6 +214,22 @@
return null;
}
// Volume drag state
let volumeDragging = $state<{ nodeId: number } | null>(null);
function applyVolumeAtMouse(e: MouseEvent, nodeId: number) {
const nd = graphNodes.find(n => n.id === nodeId);
if (!nd || !svgEl) return;
const volX = nd.x + 8;
const volW = nd.width - 36;
const svgRect = svgEl.getBoundingClientRect();
// Convert mouse X to SVG coordinate
const mouseSvgX = viewBox.x + (e.clientX - svgRect.left) * viewBox.w / svgRect.width;
const ratio = (mouseSvgX - volX) / volW;
const clamped = Math.max(0, Math.min(1, ratio));
setNodeVolume(nodeId, clamped);
}
// Mouse handlers
function onMouseDown(e: MouseEvent) {
contextMenu = null;
@@ -220,6 +237,29 @@
const pt = svgPoint(e);
const target = e.target as HTMLElement;
// Volume slider - high priority
if (target.classList.contains('vol-handle') || target.classList.contains('vol-hitarea')) {
const nodeEl = target.closest('.node-group');
if (nodeEl) {
const nodeId = Number((nodeEl as HTMLElement).dataset.nodeId);
volumeDragging = { nodeId };
// Immediately apply volume at click position
applyVolumeAtMouse(e, nodeId);
}
return;
}
// Mute button
if (target.classList.contains('mute-btn') || target.classList.contains('mute-text')) {
const nodeEl = target.closest('.node-group');
if (nodeEl) {
const nodeId = Number((nodeEl as HTMLElement).dataset.nodeId);
const nd = graphNodes.find(n => n.id === nodeId);
if (nd) setNodeMute(nodeId, !nd.mute);
}
return;
}
if (target.classList.contains('port-circle')) {
const portId = Number(target.dataset.portId);
const port = $portById.get(portId);
@@ -252,6 +292,11 @@
}
function onMouseMove(e: MouseEvent) {
// Volume dragging takes priority
if (volumeDragging) {
applyVolumeAtMouse(e, volumeDragging.nodeId);
return;
}
if (!dragging && !connecting) return;
const pt = svgPoint(e);
if (connecting) {
@@ -271,6 +316,10 @@
}
function onMouseUp(e: MouseEvent) {
if (volumeDragging) {
volumeDragging = null;
return;
}
if (connecting) {
const target = e.target as HTMLElement;
if (target.classList.contains('port-circle')) {
@@ -476,6 +525,36 @@
<circle cx={nd.x + nd.width} cy={py} r="3" fill="#333" />
{/if}
{/each}
<!-- Volume slider + mute button at bottom of node -->
<rect
class="mute-btn"
x={nd.x + nd.width - 24} y={nd.y + nd.height - 17}
width="16" height="12" rx="2"
fill={nd.mute ? '#a44' : '#2a2a3e'}
stroke="#555" stroke-width="0.5"
/>
<text
class="mute-text"
x={nd.x + nd.width - 16} y={nd.y + nd.height - 8}
font-size="8" font-family="monospace"
fill={nd.mute ? '#fff' : '#888'}
text-anchor="middle"
>{nd.mute ? 'M' : 'm'}</text>
<!-- Volume bar background (visual only) -->
<rect x={nd.x + 8} y={nd.y + nd.height - 12} width={nd.width - 36} height="4" rx="2" fill="#333" />
<!-- Volume bar fill (visual only, clamped to 1.0) -->
<rect x={nd.x + 8} y={nd.y + nd.height - 12} width={(nd.width - 36) * Math.max(0, Math.min(1, nd.volume))} height="4" rx="2" fill={nd.mute ? '#666' : '#4a9'} />
<!-- Volume click/drag hit area (tall transparent rect over the whole slider) -->
<rect class="vol-hitarea" x={nd.x + 8} y={nd.y + nd.height - 18} width={nd.width - 36} height="14" fill="transparent" />
<!-- Volume handle circle -->
<circle class="vol-handle" cx={nd.x + 8 + (nd.width - 36) * Math.max(0, Math.min(1, nd.volume))} cy={nd.y + nd.height - 10} r="3.5" fill={nd.mute ? '#888' : '#6cb'} stroke="#fff" stroke-width="1" />
<!-- Volume % label -->
<text x={nd.x + 8} y={nd.y + nd.height - 15} font-size="7" font-family="monospace" fill="#888">
{Math.round(Math.max(0, Math.min(1, nd.volume)) * 100)}%
</text>
</g>
{/each}
</svg>
@@ -715,4 +794,13 @@
.port-circle:hover { filter: brightness(1.5); }
.edge-path { cursor: pointer; pointer-events: stroke; }
.edge-path:hover { stroke-width: 4; }
.mute-btn { cursor: pointer; }
.mute-btn:hover { filter: brightness(1.3); }
.mute-text { pointer-events: none; }
.vol-bg { pointer-events: none; }
.vol-bar { pointer-events: none; }
.vol-hitarea { cursor: pointer; }
.vol-handle { cursor: ew-resize; pointer-events: none; }
.vol-handle:hover { filter: brightness(1.3); }
</style>

View File

@@ -358,4 +358,29 @@ export function deleteProfile(name: string) {
savePatchbayState();
}
// Volume control
export async function setNodeVolume(nodeId: number, volume: number) {
try {
await fetch('/api/volume', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ node_id: nodeId, volume }),
});
} catch (e) {
console.error('[api] volume failed:', e);
}
}
export async function setNodeMute(nodeId: number, mute: boolean) {
try {
await fetch('/api/mute', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ node_id: nodeId, mute }),
});
} catch (e) {
console.error('[api] mute failed:', e);
}
}
export { connectPorts, disconnectPorts };

View File

@@ -16,6 +16,8 @@ export interface Node {
media_name: string;
mode: 'input' | 'output' | 'duplex' | 'none';
node_type: string;
volume: number;
mute: boolean;
port_ids: number[];
}

View File

@@ -3,6 +3,10 @@
#include <pipewire/pipewire.h>
#include <spa/utils/result.h>
#include <spa/utils/list.h>
#include <spa/param/props.h>
#include <spa/pod/pod.h>
#include <spa/pod/builder.h>
#include <spa/pod/iter.h>
#include <cstring>
#include <algorithm>
@@ -65,17 +69,31 @@ static void on_node_info(void *data, const struct pw_node_info *info) {
info = pw_node_info_update((struct pw_node_info*)obj->info, info);
obj->info = (void*)info;
if (info && (info->change_mask & PW_NODE_CHANGE_MASK_PROPS)) {
auto *nobj = static_cast<GraphEngine::NodeObj*>(obj);
if (info && (info->change_mask & PW_NODE_CHANGE_MASK_PROPS)) {
if (info->props) {
const char *media_name = spa_dict_lookup(info->props, PW_KEY_MEDIA_NAME);
if (media_name && strlen(media_name) > 0)
nobj->node.media_name = media_name;
// Read volume from props
const char *vol_str = spa_dict_lookup(info->props, "volume");
if (vol_str) {
nobj->node.volume = pw_properties_parse_float(vol_str);
}
// Read mute from props
const char *mute_str = spa_dict_lookup(info->props, "mute");
if (mute_str) {
nobj->node.mute = pw_properties_parse_bool(mute_str);
}
}
}
nobj->node.changed = true;
nobj->node.ready = true;
}
}
static const struct pw_node_events node_events = {
.version = PW_VERSION_NODE_EVENTS,
@@ -770,4 +788,69 @@ bool GraphEngine::disconnectPorts(uint32_t output_port_id, uint32_t input_port_i
return found;
}
// ============================================================================
// Volume control
// ============================================================================
bool GraphEngine::setNodeVolume(uint32_t node_id, float volume) {
if (!m_pw.loop) return false;
pw_thread_loop_lock(m_pw.loop);
NodeObj *nobj = findNode(node_id);
if (!nobj || !nobj->proxy) {
pw_thread_loop_unlock(m_pw.loop);
return false;
}
// Build Props param with volume
uint8_t buf[1024];
struct spa_pod_builder b = SPA_POD_BUILDER_INIT(buf, sizeof(buf));
struct spa_pod_frame f;
spa_pod_builder_push_object(&b, &f, SPA_TYPE_OBJECT_Props, SPA_PARAM_Props);
spa_pod_builder_prop(&b, SPA_PROP_volume, 0);
spa_pod_builder_float(&b, volume);
struct spa_pod *param = (struct spa_pod*)spa_pod_builder_pop(&b, &f);
pw_node_set_param((pw_node*)nobj->proxy,
SPA_PARAM_Props, 0, param);
nobj->node.volume = volume;
nobj->node.changed = true;
pw_thread_loop_unlock(m_pw.loop);
return true;
}
bool GraphEngine::setNodeMute(uint32_t node_id, bool mute) {
if (!m_pw.loop) return false;
pw_thread_loop_lock(m_pw.loop);
NodeObj *nobj = findNode(node_id);
if (!nobj || !nobj->proxy) {
pw_thread_loop_unlock(m_pw.loop);
return false;
}
uint8_t buf[1024];
struct spa_pod_builder b = SPA_POD_BUILDER_INIT(buf, sizeof(buf));
struct spa_pod_frame f;
spa_pod_builder_push_object(&b, &f, SPA_TYPE_OBJECT_Props, SPA_PARAM_Props);
spa_pod_builder_prop(&b, SPA_PROP_mute, 0);
spa_pod_builder_bool(&b, mute);
struct spa_pod *param = (struct spa_pod*)spa_pod_builder_pop(&b, &f);
pw_node_set_param((pw_node*)nobj->proxy,
SPA_PARAM_Props, 0, param);
nobj->node.mute = mute;
nobj->node.changed = true;
pw_thread_loop_unlock(m_pw.loop);
return true;
}
// end of graph_engine.cpp

View File

@@ -44,6 +44,10 @@ public:
bool connectPorts(uint32_t output_port_id, uint32_t input_port_id);
bool disconnectPorts(uint32_t output_port_id, uint32_t input_port_id);
// Volume control
bool setNodeVolume(uint32_t node_id, float volume);
bool setNodeMute(uint32_t node_id, bool mute);
// PipeWire internal data (exposed for C callbacks)
struct PwData {
pw_thread_loop *loop;

View File

@@ -63,10 +63,15 @@ struct Node {
bool ready;
bool changed;
// Volume
float volume; // 0.0 - 1.0 (linear)
bool mute;
std::vector<uint32_t> port_ids; // child port IDs
Node() : id(0), mode(PortMode::None), node_type(NodeType::None),
mode2(PortMode::None), ready(false), changed(false) {}
mode2(PortMode::None), ready(false), changed(false),
volume(1.0f), mute(false) {}
};
struct Link;

View File

@@ -109,6 +109,8 @@ std::string WebServer::buildGraphJson() const {
<< ",\"media_name\":\"" << escapeJson(n.media_name) << "\""
<< ",\"mode\":\"" << portModeStr(n.mode) << "\""
<< ",\"node_type\":\"" << nodeTypeStr(n.node_type) << "\""
<< ",\"volume\":" << n.volume
<< ",\"mute\":" << (n.mute ? "true" : "false")
<< ",\"port_ids\":[";
bool first_p = true;
for (uint32_t pid : n.port_ids) {
@@ -403,6 +405,47 @@ void WebServer::setupRoutes() {
res.set_content(buf, "application/json");
res.set_header("Access-Control-Allow-Origin", "*");
});
// Volume: POST /api/volume {"node_id": N, "volume": 0.0-1.0}
m_http.Post("/api/volume", [this](const httplib::Request &req, httplib::Response &res) {
uint32_t node_id = 0;
float volume = 0;
if (sscanf(req.body.c_str(),
"{\"node_id\":%u,\"volume\":%f}", &node_id, &volume) == 2 ||
sscanf(req.body.c_str(),
"{\"node_id\":%u, \"volume\":%f}", &node_id, &volume) == 2)
{
if (volume < 0) volume = 0;
if (volume > 1.5) volume = 1.5;
bool ok = m_engine.setNodeVolume(node_id, volume);
if (ok) broadcastGraph();
res.set_content(ok ? "{\"ok\":true}" : "{\"ok\":false}", "application/json");
} else {
res.status = 400;
res.set_content("{\"error\":\"invalid json\"}", "application/json");
}
res.set_header("Access-Control-Allow-Origin", "*");
});
// Mute: POST /api/mute {"node_id": N, "mute": true/false}
m_http.Post("/api/mute", [this](const httplib::Request &req, httplib::Response &res) {
uint32_t node_id = 0;
if (sscanf(req.body.c_str(), "{\"node_id\":%u", &node_id) == 1 ||
sscanf(req.body.c_str(), "{\"node_id\": %u", &node_id) == 1)
{
bool mute = req.body.find("true") != std::string::npos;
bool ok = m_engine.setNodeMute(node_id, mute);
if (ok) broadcastGraph();
res.set_content(ok ? "{\"ok\":true}" : "{\"ok\":false}", "application/json");
} else {
res.status = 400;
res.set_content("{\"error\":\"invalid json\"}", "application/json");
}
res.set_header("Access-Control-Allow-Origin", "*");
});
m_http.Options("/api/volume", cors_handler);
m_http.Options("/api/mute", cors_handler);
}
// end of web_server.cpp