Initial commit: pwweb - PipeWire WebUI

C++ backend with SSE streaming (reuses qpwgraph PipeWire callbacks).
Svelte frontend with custom SVG canvas for port-level connections.

Features:
- Live PipeWire graph via SSE
- Drag output->input port to connect
- Double-click or select+Delete to disconnect
- Node positions saved to localStorage
- Pan (drag bg) and zoom (scroll)
- Port type coloring (audio=green, midi=red, video=blue)
This commit is contained in:
joren
2026-03-29 22:40:07 +02:00
commit f8c57fbdd3
34 changed files with 24479 additions and 0 deletions

View File

@@ -0,0 +1,496 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import {
nodes, ports, links, connected,
portById,
initGraph, destroyGraph,
connectPorts, disconnectPorts,
} from '../lib/stores';
import type { Node, Port, Link } from '../lib/types';
// Viewport state
let viewBox = $state({ x: -100, y: -40, w: 1200, h: 700 });
let dragging = $state<{ type: string; startX: number; startY: number; origX: number; origY: number; nodeId?: string } | null>(null);
let connecting = $state<{ outputPortId: number; outputX: number; outputY: number; mouseX: number; mouseY: number; portType: string } | null>(null);
let hoveredPort = $state<number | null>(null);
let selectedEdge = $state<string | null>(null);
let svgEl: SVGElement | null = null;
// Node positions
const POS_KEY = 'pwweb_pos';
function loadPos(): Record<string, { x: number; y: number }> {
try { return JSON.parse(localStorage.getItem(POS_KEY) || '{}'); } catch { return {}; }
}
function savePos(pos: Record<string, { x: number; y: number }>) {
try { localStorage.setItem(POS_KEY, JSON.stringify(pos)); } catch {}
}
let nodePositions = $state(loadPos());
// Layout defaults
function getDefaultPositions(n: Node[]): Record<string, { x: number; y: number }> {
const out = n.filter(nd => nd.mode === 'output');
const inp = n.filter(nd => nd.mode === 'input');
const other = n.filter(nd => nd.mode !== 'output' && nd.mode !== 'input');
const result: Record<string, { x: number; y: number }> = {};
let i = 0;
for (const nd of out) {
if (!result[nd.id]) result[nd.id] = { x: 0, y: (i % 8) * 100 };
i++;
}
i = 0;
for (const nd of other) {
if (!result[nd.id]) result[nd.id] = { x: 320, y: (i % 8) * 100 };
i++;
}
i = 0;
for (const nd of inp) {
if (!result[nd.id]) result[nd.id] = { x: 640, y: (i % 8) * 100 };
i++;
}
return result;
}
// Compute layout
let graphNodes = $derived.by(() => {
const n = $nodes;
const p = $ports;
const portMap = new Map<number, Port>();
for (const port of p) portMap.set(port.id, port);
const defaults = getDefaultPositions(n);
const pos = { ...defaults, ...nodePositions };
return n.map(nd => {
const ndPorts = nd.port_ids.map(pid => portMap.get(pid)).filter(Boolean) as Port[];
const inPorts = ndPorts.filter(pp => pp.mode === 'input');
const outPorts = ndPorts.filter(pp => pp.mode === 'output');
const nodePos = pos[String(nd.id)] || { x: 0, y: 0 };
// Compute node dimensions
const maxPorts = Math.max(inPorts.length, outPorts.length, 1);
const headerH = 24;
const portH = 18;
const height = headerH + maxPorts * portH + 6;
const width = 200;
// Compute port positions
const portPositions = new Map<number, { x: number; y: number }>();
let yi = headerH;
for (const port of inPorts) {
portPositions.set(port.id, { x: nodePos.x, y: nodePos.y + yi + portH / 2 });
yi += portH;
}
let yo = headerH;
for (const port of outPorts) {
portPositions.set(port.id, { x: nodePos.x + width, y: nodePos.y + yo + portH / 2 });
yo += portH;
}
return {
...nd,
x: nodePos.x,
y: nodePos.y,
width,
height,
inPorts,
outPorts,
portPositions,
};
});
});
let graphLinks = $derived.by(() => {
const l = $links;
const p = $ports;
const portMap = new Map<number, Port>();
for (const port of p) portMap.set(port.id, port);
return l.map(link => {
const outPort = portMap.get(link.output_port_id);
const inPort = portMap.get(link.input_port_id);
return { ...link, outPort, inPort };
}).filter(l => l.outPort && l.inPort) as Array<Link & { outPort: Port; inPort: Port }>;
});
// Port positions lookup across all nodes
function getPortPos(portId: number): { x: number; y: number } | null {
for (const node of graphNodes) {
const pos = node.portPositions.get(portId);
if (pos) return pos;
}
return null;
}
function portColor(pt: string): string {
switch (pt) {
case 'audio': return '#4a9';
case 'midi': return '#a44';
case 'video': return '#49a';
default: return '#999';
}
}
function shortName(name: string): string {
const idx = name.lastIndexOf(':');
return idx >= 0 ? name.substring(idx + 1) : name;
}
// Bezier curve between two points
function bezierPath(x1: number, y1: number, x2: number, y2: number): string {
const dx = Math.abs(x2 - x1) * 0.5;
return `M ${x1} ${y1} C ${x1 + dx} ${y1}, ${x2 - dx} ${y2}, ${x2} ${y2}`;
}
// Mouse events
function svgPoint(e: MouseEvent): { x: number; y: number } {
if (!svgEl) return { x: e.clientX, y: e.clientY };
const pt = (svgEl as any).createSVGPoint();
pt.x = e.clientX;
pt.y = e.clientY;
const ctm = (svgEl as SVGGraphicsElement).getScreenCTM();
if (!ctm) return { x: e.clientX, y: e.clientY };
const svgP = pt.matrixTransform(ctm.inverse());
return { x: svgP.x, y: svgP.y };
}
function onMouseDown(e: MouseEvent) {
const pt = svgPoint(e);
const target = e.target as HTMLElement;
// Check if clicking on a port circle
if (target.classList.contains('port-circle')) {
const portId = Number(target.dataset.portId);
const port = $portById.get(portId);
if (port && port.mode === 'output') {
const pos = getPortPos(portId);
if (pos) {
connecting = {
outputPortId: portId,
outputX: pos.x,
outputY: pos.y,
mouseX: pt.x,
mouseY: pt.y,
portType: port.port_type,
};
}
}
return;
}
// Check if clicking on a node header (to drag)
if (target.classList.contains('node-header') || target.closest('.node-group')) {
const nodeGroup = target.closest('.node-group') as HTMLElement;
if (nodeGroup) {
const nodeId = nodeGroup.dataset.nodeId!;
const nd = graphNodes.find(n => String(n.id) === nodeId);
if (nd) {
dragging = {
type: 'node',
startX: pt.x,
startY: pt.y,
origX: nd.x,
origY: nd.y,
nodeId,
};
}
return;
}
}
// Check if clicking on an edge
if (target.classList.contains('edge-path')) {
selectedEdge = target.dataset.edgeId || null;
return;
}
// Click on background: pan
selectedEdge = null;
dragging = { type: 'pan', startX: e.clientX, startY: e.clientY, origX: viewBox.x, origY: viewBox.y };
}
function onMouseMove(e: MouseEvent) {
if (!dragging && !connecting) return;
const pt = svgPoint(e);
if (connecting) {
connecting.mouseX = pt.x;
connecting.mouseY = pt.y;
return;
}
if (!dragging) return;
if (dragging.type === 'node' && dragging.nodeId) {
const dx = pt.x - dragging.startX;
const dy = pt.y - dragging.startY;
nodePositions[dragging.nodeId] = {
x: dragging.origX + dx,
y: dragging.origY + dy,
};
nodePositions = nodePositions; // trigger reactivity
} else if (dragging.type === 'pan') {
const dx = (e.clientX - dragging.startX) * (viewBox.w / (svgEl?.clientWidth || 1));
const dy = (e.clientY - dragging.startY) * (viewBox.h / (svgEl?.clientHeight || 1));
viewBox.x = dragging.origX - dx;
viewBox.y = dragging.origY - dy;
}
}
function onMouseUp(e: MouseEvent) {
if (connecting) {
const target = e.target as HTMLElement;
if (target.classList.contains('port-circle')) {
const inputPortId = Number(target.dataset.portId);
const port = $portById.get(inputPortId);
if (port && port.mode === 'input' && port.port_type === connecting.portType) {
connectPorts(connecting.outputPortId, inputPortId);
}
}
connecting = null;
}
if (dragging?.type === 'node' && dragging.nodeId) {
savePos(nodePositions);
}
dragging = null;
}
function onWheel(e: WheelEvent) {
e.preventDefault();
const pt = svgPoint(e);
const factor = e.deltaY > 0 ? 1.1 : 0.9;
const nw = viewBox.w * factor;
const nh = viewBox.h * factor;
viewBox.x = pt.x - (pt.x - viewBox.x) * factor;
viewBox.y = pt.y - (pt.y - viewBox.y) * factor;
viewBox.w = nw;
viewBox.h = nh;
}
function onEdgeDblClick(e: MouseEvent) {
const target = e.target as HTMLElement;
if (target.classList.contains('edge-path')) {
const edgeId = target.dataset.edgeId;
const link = $links.find(l => String(l.id) === edgeId);
if (link) {
disconnectPorts(link.output_port_id, link.input_port_id);
}
}
}
function onKey(e: KeyboardEvent) {
if ((e.key === 'Delete' || e.key === 'Backspace') && selectedEdge) {
const link = $links.find(l => String(l.id) === selectedEdge);
if (link) {
disconnectPorts(link.output_port_id, link.input_port_id);
selectedEdge = null;
}
}
}
onMount(() => { initGraph(); });
onDestroy(() => { destroyGraph(); });
</script>
<svelte:window onkeydown={onKey} />
<div class="wrap">
<div class="bar">
<span class="dot" class:on={$connected}></span>
<span>{$connected ? 'Connected' : 'Disconnected'}</span>
<span class="st">{$nodes.length} nodes | {$ports.length} ports | {$links.length} links</span>
<span class="help">Drag port->port to connect. Dbl-click wire to disconnect. Scroll to zoom. Drag bg to pan.</span>
</div>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<svg
bind:this={svgEl}
viewBox="{viewBox.x} {viewBox.y} {viewBox.w} {viewBox.h}"
onmousedown={onMouseDown}
onmousemove={onMouseMove}
onmouseup={onMouseUp}
onwheel={onWheel}
ondblclick={onEdgeDblClick}
class="canvas"
>
<!-- Edges -->
{#each graphLinks as link (link.id)}
{@const outPos = getPortPos(link.output_port_id)}
{@const inPos = getPortPos(link.input_port_id)}
{#if outPos && inPos}
{@const color = portColor(link.outPort.port_type)}
<path
d={bezierPath(outPos.x, outPos.y, inPos.x, inPos.y)}
stroke={selectedEdge === String(link.id) ? '#fff' : color}
stroke-width={selectedEdge === String(link.id) ? 3 : 2}
fill="none"
class="edge-path"
data-edge-id={String(link.id)}
/>
{/if}
{/each}
<!-- Connecting line (while dragging) -->
{#if connecting}
<path
d={bezierPath(connecting.outputX, connecting.outputY, connecting.mouseX, connecting.mouseY)}
stroke={portColor(connecting.portType)}
stroke-width="2"
stroke-dasharray="6 3"
fill="none"
opacity="0.8"
/>
{/if}
<!-- Nodes -->
{#each graphNodes as nd (nd.id)}
{@const headerColor = nd.mode === 'output' ? '#2a3a2a' : nd.mode === 'input' ? '#3a2a2a' : '#2a2a3a'}
{@const borderColor = nd.mode === 'output' ? '#4a9' : nd.mode === 'input' ? '#a44' : '#555'}
<g class="node-group" data-node-id={String(nd.id)}>
<!-- Background -->
<rect
x={nd.x} y={nd.y}
width={nd.width} height={nd.height}
rx="5" ry="5"
fill="#1e1e2e"
stroke={borderColor}
stroke-width="1"
/>
<!-- Header -->
<rect
x={nd.x} y={nd.y}
width={nd.width} height="24"
rx="5" ry="5"
fill={headerColor}
class="node-header"
/>
<rect
x={nd.x} y={nd.y + 18}
width={nd.width} height="6"
fill={headerColor}
/>
<text
x={nd.x + 6} y={nd.y + 16}
font-size="11" font-family="monospace"
fill="#ddd" font-weight="bold"
>
{nd.name.length > 28 ? nd.name.substring(0, 25) + '...' : nd.name}
</text>
<!-- Input ports (left side) -->
{#each nd.inPorts as port, i (port.id)}
{@const py = nd.y + 24 + i * 18 + 9}
<circle
cx={nd.x} cy={py}
r="5"
fill={portColor(port.port_type)}
stroke="#fff"
stroke-width={hoveredPort === port.id ? 2 : 0.5}
class="port-circle"
data-port-id={String(port.id)}
onmouseenter={() => { hoveredPort = port.id; }}
onmouseleave={() => { hoveredPort = null; }}
/>
<text
x={nd.x + 10} y={py + 4}
font-size="10" font-family="monospace"
fill="#aaa"
>{shortName(port.name)}</text>
{/each}
<!-- Output ports (right side) -->
{#each nd.outPorts as port, i (port.id)}
{@const py = nd.y + 24 + i * 18 + 9}
<text
x={nd.x + nd.width - 10} y={py + 4}
font-size="10" font-family="monospace"
fill="#aaa"
text-anchor="end"
>{shortName(port.name)}</text>
<circle
cx={nd.x + nd.width} cy={py}
r="5"
fill={portColor(port.port_type)}
stroke="#fff"
stroke-width={hoveredPort === port.id ? 2 : 0.5}
class="port-circle"
data-port-id={String(port.id)}
onmouseenter={() => { hoveredPort = port.id; }}
onmouseleave={() => { hoveredPort = null; }}
/>
{/each}
</g>
{/each}
</svg>
</div>
<style>
.wrap {
width: 100%;
height: 100vh;
background: #1a1a2e;
position: relative;
overflow: hidden;
}
.canvas {
width: 100%;
height: 100%;
display: block;
cursor: default;
}
.bar {
position: absolute;
top: 0;
left: 0;
right: 0;
z-index: 10;
display: flex;
align-items: center;
gap: 8px;
padding: 6px 16px;
background: rgba(20, 20, 30, 0.95);
border-bottom: 1px solid #333;
font-size: 12px;
color: #aaa;
font-family: monospace;
}
.dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: #a44;
display: inline-block;
}
.dot.on { background: #4a9; }
.st { margin-left: auto; color: #666; }
.help { color: #555; font-size: 11px; margin-left: 16px; }
.port-circle {
cursor: crosshair;
}
.port-circle:hover {
filter: brightness(1.5);
}
.edge-path {
cursor: pointer;
pointer-events: stroke;
}
.edge-path:hover {
stroke-width: 4;
}
.node-header {
cursor: grab;
}
.node-group:hover rect {
filter: brightness(1.1);
}
</style>