diff --git a/frontend/src/components/GraphCanvas.svelte b/frontend/src/components/GraphCanvas.svelte index 86387a3..c57728c 100644 --- a/frontend/src/components/GraphCanvas.svelte +++ b/frontend/src/components/GraphCanvas.svelte @@ -214,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; @@ -221,6 +237,29 @@ const pt = svgPoint(e); const target = e.target as HTMLElement; + // Volume slider handle - highest priority + if (target.classList.contains('vol-handle') || target.classList.contains('vol-bar') || target.classList.contains('vol-bg')) { + 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); @@ -253,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) { @@ -272,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')) { @@ -479,44 +527,30 @@ {/each} - setNodeMute(nd.id, !nd.mute)} + 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" /> {nd.mute ? 'M' : 'm'} + - - - - - { - e.stopPropagation(); - const svgRect = svgEl?.getBoundingClientRect(); - if (!svgRect) return; - const vbx = nd.x + 8; - const vbw = nd.width - 36; - const onMove = (ev: MouseEvent) => { - const ratio = (ev.clientX - svgRect.left - (vbx - viewBox.x) * svgRect.width / viewBox.w) / (vbw * svgRect.width / viewBox.w); - setNodeVolume(nd.id, Math.max(0, Math.min(1.5, ratio))); - }; - const onUp = () => { window.removeEventListener('mousemove', onMove); window.removeEventListener('mouseup', onUp); }; - window.addEventListener('mousemove', onMove); - window.addEventListener('mouseup', onUp); - }} - /> + + + + + - - {Math.round(nd.volume * 100)}% + + {Math.round(Math.max(0, Math.min(1, nd.volume)) * 100)}% @@ -758,4 +792,12 @@ .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-handle { cursor: ew-resize; } + .vol-handle:hover { filter: brightness(1.3); r: 6; } diff --git a/src/graph_engine.cpp b/src/graph_engine.cpp index 74f6cac..8cf1ff4 100644 --- a/src/graph_engine.cpp +++ b/src/graph_engine.cpp @@ -806,10 +806,12 @@ bool GraphEngine::setNodeVolume(uint32_t node_id, float volume) { // 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; - struct spa_pod *param = (struct spa_pod*)spa_pod_builder_add_object(&b, - SPA_TYPE_OBJECT_Props, SPA_PARAM_Props, - SPA_PROP_volume, SPA_POD_Float(volume)); + 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); @@ -834,10 +836,12 @@ bool GraphEngine::setNodeMute(uint32_t node_id, bool mute) { uint8_t buf[1024]; struct spa_pod_builder b = SPA_POD_BUILDER_INIT(buf, sizeof(buf)); + struct spa_pod_frame f; - struct spa_pod *param = (struct spa_pod*)spa_pod_builder_add_object(&b, - SPA_TYPE_OBJECT_Props, SPA_PARAM_Props, - SPA_PROP_mute, SPA_POD_Bool(mute)); + 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);