fix: volume slider and mute button race conditions

- Mute button click no longer triggers node drag (priority check in onMouseDown)
- Volume slider clamped to 0-100% in both display and API
- Added draggable circle handle on slider end
- Volume drag state tracked globally, not per-element
- Backend: fixed spa_pod_builder API usage (push_object/pop with frame)
- Volume calculated from SVG coordinates properly via svgPoint conversion
This commit is contained in:
joren
2026-03-29 23:29:40 +02:00
parent 65db5daa7c
commit 044c5e551c
2 changed files with 83 additions and 37 deletions

View File

@@ -214,6 +214,22 @@
return null; 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 // Mouse handlers
function onMouseDown(e: MouseEvent) { function onMouseDown(e: MouseEvent) {
contextMenu = null; contextMenu = null;
@@ -221,6 +237,29 @@
const pt = svgPoint(e); const pt = svgPoint(e);
const target = e.target as HTMLElement; 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')) { if (target.classList.contains('port-circle')) {
const portId = Number(target.dataset.portId); const portId = Number(target.dataset.portId);
const port = $portById.get(portId); const port = $portById.get(portId);
@@ -253,6 +292,11 @@
} }
function onMouseMove(e: MouseEvent) { function onMouseMove(e: MouseEvent) {
// Volume dragging takes priority
if (volumeDragging) {
applyVolumeAtMouse(e, volumeDragging.nodeId);
return;
}
if (!dragging && !connecting) return; if (!dragging && !connecting) return;
const pt = svgPoint(e); const pt = svgPoint(e);
if (connecting) { if (connecting) {
@@ -272,6 +316,10 @@
} }
function onMouseUp(e: MouseEvent) { function onMouseUp(e: MouseEvent) {
if (volumeDragging) {
volumeDragging = null;
return;
}
if (connecting) { if (connecting) {
const target = e.target as HTMLElement; const target = e.target as HTMLElement;
if (target.classList.contains('port-circle')) { if (target.classList.contains('port-circle')) {
@@ -479,44 +527,30 @@
{/each} {/each}
<!-- Volume slider + mute button at bottom of node --> <!-- Volume slider + mute button at bottom of node -->
<!-- Mute button -->
<rect <rect
x={nd.x + nd.width - 24} y={nd.y + nd.height - 17} width="16" height="12" rx="2" class="mute-btn"
fill={nd.mute ? '#a44' : '#2a2a3e'} stroke="#555" stroke-width="0.5" x={nd.x + nd.width - 24} y={nd.y + nd.height - 17}
style="cursor:pointer" width="16" height="12" rx="2"
onclick={() => setNodeMute(nd.id, !nd.mute)} fill={nd.mute ? '#a44' : '#2a2a3e'}
stroke="#555" stroke-width="0.5"
/> />
<text <text
class="mute-text"
x={nd.x + nd.width - 16} y={nd.y + nd.height - 8} x={nd.x + nd.width - 16} y={nd.y + nd.height - 8}
font-size="8" font-family="monospace" fill={nd.mute ? '#fff' : '#888'} font-size="8" font-family="monospace"
text-anchor="middle" style="pointer-events:none" fill={nd.mute ? '#fff' : '#888'}
text-anchor="middle"
>{nd.mute ? 'M' : 'm'}</text> >{nd.mute ? 'M' : 'm'}</text>
<!-- Volume bar background --> <!-- Volume bar background -->
<rect x={nd.x + 8} y={nd.y + nd.height - 12} width={nd.width - 36} height="3" rx="1.5" fill="#333" /> <rect class="vol-bg" x={nd.x + 8} y={nd.y + nd.height - 12} width={nd.width - 36} height="4" rx="2" fill="#333" />
<!-- Volume bar fill --> <!-- Volume bar fill (clamped to 1.0 for display) -->
<rect x={nd.x + 8} y={nd.y + nd.height - 12} width={(nd.width - 36) * Math.min(nd.volume, 1)} height="3" rx="1.5" fill={nd.mute ? '#666' : '#4a9'} /> <rect class="vol-bar" 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 slider hit area --> <!-- Volume handle circle -->
<rect <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="5" fill={nd.mute ? '#888' : '#6cb'} stroke="#fff" stroke-width="1" />
x={nd.x + 8} y={nd.y + nd.height - 16} width={nd.width - 36} height="11"
fill="transparent" style="cursor:pointer"
onmousedown={(e) => {
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);
}}
/>
<!-- Volume % label --> <!-- Volume % label -->
<text x={nd.x + 8} y={nd.y + nd.height - 14} font-size="7" font-family="monospace" fill="#666"> <text x={nd.x + 8} y={nd.y + nd.height - 15} font-size="7" font-family="monospace" fill="#888">
{Math.round(nd.volume * 100)}% {Math.round(Math.max(0, Math.min(1, nd.volume)) * 100)}%
</text> </text>
</g> </g>
@@ -758,4 +792,12 @@
.port-circle:hover { filter: brightness(1.5); } .port-circle:hover { filter: brightness(1.5); }
.edge-path { cursor: pointer; pointer-events: stroke; } .edge-path { cursor: pointer; pointer-events: stroke; }
.edge-path:hover { stroke-width: 4; } .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; }
</style> </style>

View File

@@ -806,10 +806,12 @@ bool GraphEngine::setNodeVolume(uint32_t node_id, float volume) {
// Build Props param with volume // Build Props param with volume
uint8_t buf[1024]; uint8_t buf[1024];
struct spa_pod_builder b = SPA_POD_BUILDER_INIT(buf, sizeof(buf)); 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_pod_builder_push_object(&b, &f, SPA_TYPE_OBJECT_Props, SPA_PARAM_Props);
SPA_TYPE_OBJECT_Props, SPA_PARAM_Props, spa_pod_builder_prop(&b, SPA_PROP_volume, 0);
SPA_PROP_volume, SPA_POD_Float(volume)); 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, pw_node_set_param((pw_node*)nobj->proxy,
SPA_PARAM_Props, 0, param); SPA_PARAM_Props, 0, param);
@@ -834,10 +836,12 @@ bool GraphEngine::setNodeMute(uint32_t node_id, bool mute) {
uint8_t buf[1024]; uint8_t buf[1024];
struct spa_pod_builder b = SPA_POD_BUILDER_INIT(buf, sizeof(buf)); 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_pod_builder_push_object(&b, &f, SPA_TYPE_OBJECT_Props, SPA_PARAM_Props);
SPA_TYPE_OBJECT_Props, SPA_PARAM_Props, spa_pod_builder_prop(&b, SPA_PROP_mute, 0);
SPA_PROP_mute, SPA_POD_Bool(mute)); 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, pw_node_set_param((pw_node*)nobj->proxy,
SPA_PARAM_Props, 0, param); SPA_PARAM_Props, 0, param);