diff --git a/frontend/src/components/GraphCanvas.svelte b/frontend/src/components/GraphCanvas.svelte index a6bfdf7..c09fbc2 100644 --- a/frontend/src/components/GraphCanvas.svelte +++ b/frontend/src/components/GraphCanvas.svelte @@ -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(); @@ -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 @@ {/if} {/each} + + + + {nd.mute ? 'M' : 'm'} + + + + + + + + + + + + {Math.round(Math.max(0, Math.min(1, nd.volume)) * 100)}% + + {/each} @@ -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); } diff --git a/frontend/src/lib/stores.ts b/frontend/src/lib/stores.ts index 73e2fbf..2d7f54e 100644 --- a/frontend/src/lib/stores.ts +++ b/frontend/src/lib/stores.ts @@ -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 }; diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index 454a3c9..c9d337a 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -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[]; } diff --git a/src/graph_engine.cpp b/src/graph_engine.cpp index 20b64f6..8cf1ff4 100644 --- a/src/graph_engine.cpp +++ b/src/graph_engine.cpp @@ -3,6 +3,10 @@ #include #include #include +#include +#include +#include +#include #include #include @@ -65,16 +69,30 @@ 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; + auto *nobj = static_cast(obj); + if (info && (info->change_mask & PW_NODE_CHANGE_MASK_PROPS)) { - auto *nobj = static_cast(obj); 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; } + + nobj->node.changed = true; + nobj->node.ready = true; } static const struct pw_node_events 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 diff --git a/src/graph_engine.h b/src/graph_engine.h index c5ae46e..30d009b 100644 --- a/src/graph_engine.h +++ b/src/graph_engine.h @@ -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; diff --git a/src/graph_types.h b/src/graph_types.h index 42fe24f..cc27d25 100644 --- a/src/graph_types.h +++ b/src/graph_types.h @@ -63,10 +63,15 @@ struct Node { bool ready; bool changed; + // Volume + float volume; // 0.0 - 1.0 (linear) + bool mute; + std::vector 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; diff --git a/src/web_server.cpp b/src/web_server.cpp index 63e2cd2..93dc924 100644 --- a/src/web_server.cpp +++ b/src/web_server.cpp @@ -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