feat: built-in volume management
- Volume slider on every node (green bar, draggable)
- Mute toggle button (M/m) on every node
- Backend: read volume/mute from PipeWire node props
- Backend: POST /api/volume {node_id, volume} to set volume
- Backend: POST /api/mute {node_id, mute} to toggle mute
- Graph JSON includes volume and mute fields per node
- Slider supports drag-to-adjust with mouse
This commit is contained in:
@@ -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 }>();
|
||||
@@ -476,6 +477,48 @@
|
||||
<circle cx={nd.x + nd.width} cy={py} r="3" fill="#333" />
|
||||
{/if}
|
||||
{/each}
|
||||
|
||||
<!-- Volume slider + mute button at bottom of node -->
|
||||
<!-- Mute button -->
|
||||
<rect
|
||||
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"
|
||||
style="cursor:pointer"
|
||||
onclick={() => setNodeMute(nd.id, !nd.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" style="pointer-events:none"
|
||||
>{nd.mute ? 'M' : 'm'}</text>
|
||||
<!-- 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" />
|
||||
<!-- Volume bar fill -->
|
||||
<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'} />
|
||||
<!-- Volume slider hit area -->
|
||||
<rect
|
||||
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 -->
|
||||
<text x={nd.x + 8} y={nd.y + nd.height - 14} font-size="7" font-family="monospace" fill="#666">
|
||||
{Math.round(nd.volume * 100)}%
|
||||
</text>
|
||||
|
||||
</g>
|
||||
{/each}
|
||||
</svg>
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user