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:
14
frontend/src/App.svelte
Normal file
14
frontend/src/App.svelte
Normal file
@@ -0,0 +1,14 @@
|
||||
<script lang="ts">
|
||||
import GraphCanvas from './components/GraphCanvas.svelte';
|
||||
</script>
|
||||
|
||||
<GraphCanvas />
|
||||
|
||||
<style>
|
||||
:global(body) {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
background: #14141e;
|
||||
}
|
||||
</style>
|
||||
53
frontend/src/app.css
Normal file
53
frontend/src/app.css
Normal file
@@ -0,0 +1,53 @@
|
||||
*, *::before, *::after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html, body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
background: #1a1a2e;
|
||||
color: #ccc;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
/* Force xyflow dark theme */
|
||||
:root {
|
||||
--xy-background-color: #1a1a2e;
|
||||
--xy-node-background-color: #1e1e2e;
|
||||
--xy-node-border-color: #555;
|
||||
--xy-edge-stroke-color: #888;
|
||||
--xy-handle-background-color: #888;
|
||||
--xy-handle-border-color: #555;
|
||||
--xy-attribution-background-color: rgba(0,0,0,0.5);
|
||||
}
|
||||
|
||||
.svelte-flow {
|
||||
background: #1a1a2e !important;
|
||||
}
|
||||
|
||||
.svelte-flow__edge-path {
|
||||
stroke-linecap: round;
|
||||
}
|
||||
|
||||
.svelte-flow__controls button {
|
||||
background: #2a2a3e !important;
|
||||
color: #aaa !important;
|
||||
border: 1px solid #444 !important;
|
||||
fill: #aaa !important;
|
||||
}
|
||||
|
||||
.svelte-flow__controls button:hover {
|
||||
background: #3a3a4e !important;
|
||||
}
|
||||
|
||||
.svelte-flow__minimap {
|
||||
background: #1a1a2e !important;
|
||||
border: 1px solid #444 !important;
|
||||
}
|
||||
|
||||
.svelte-flow__background {
|
||||
background: #1a1a2e;
|
||||
}
|
||||
BIN
frontend/src/assets/hero.png
Normal file
BIN
frontend/src/assets/hero.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 44 KiB |
1
frontend/src/assets/svelte.svg
Normal file
1
frontend/src/assets/svelte.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="26.6" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 308"><path fill="#FF3E00" d="M239.682 40.707C211.113-.182 154.69-12.301 113.895 13.69L42.247 59.356a82.198 82.198 0 0 0-37.135 55.056a86.566 86.566 0 0 0 8.536 55.576a82.425 82.425 0 0 0-12.296 30.719a87.596 87.596 0 0 0 14.964 66.244c28.574 40.893 84.997 53.007 125.787 27.016l71.648-45.664a82.182 82.182 0 0 0 37.135-55.057a86.601 86.601 0 0 0-8.53-55.577a82.409 82.409 0 0 0 12.29-30.718a87.573 87.573 0 0 0-14.963-66.244"></path><path fill="#FFF" d="M106.889 270.841c-23.102 6.007-47.497-3.036-61.103-22.648a52.685 52.685 0 0 1-9.003-39.85a49.978 49.978 0 0 1 1.713-6.693l1.35-4.115l3.671 2.697a92.447 92.447 0 0 0 28.036 14.007l2.663.808l-.245 2.659a16.067 16.067 0 0 0 2.89 10.656a17.143 17.143 0 0 0 18.397 6.828a15.786 15.786 0 0 0 4.403-1.935l71.67-45.672a14.922 14.922 0 0 0 6.734-9.977a15.923 15.923 0 0 0-2.713-12.011a17.156 17.156 0 0 0-18.404-6.832a15.78 15.78 0 0 0-4.396 1.933l-27.35 17.434a52.298 52.298 0 0 1-14.553 6.391c-23.101 6.007-47.497-3.036-61.101-22.649a52.681 52.681 0 0 1-9.004-39.849a49.428 49.428 0 0 1 22.34-33.114l71.664-45.677a52.218 52.218 0 0 1 14.563-6.398c23.101-6.007 47.497 3.036 61.101 22.648a52.685 52.685 0 0 1 9.004 39.85a50.559 50.559 0 0 1-1.713 6.692l-1.35 4.116l-3.67-2.693a92.373 92.373 0 0 0-28.037-14.013l-2.664-.809l.246-2.658a16.099 16.099 0 0 0-2.89-10.656a17.143 17.143 0 0 0-18.398-6.828a15.786 15.786 0 0 0-4.402 1.935l-71.67 45.674a14.898 14.898 0 0 0-6.73 9.975a15.9 15.9 0 0 0 2.709 12.012a17.156 17.156 0 0 0 18.404 6.832a15.841 15.841 0 0 0 4.402-1.935l27.345-17.427a52.147 52.147 0 0 1 14.552-6.397c23.101-6.006 47.497 3.037 61.102 22.65a52.681 52.681 0 0 1 9.003 39.848a49.453 49.453 0 0 1-22.34 33.12l-71.664 45.673a52.218 52.218 0 0 1-14.563 6.398"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.9 KiB |
1
frontend/src/assets/vite.svg
Normal file
1
frontend/src/assets/vite.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 8.5 KiB |
496
frontend/src/components/GraphCanvas.svelte
Normal file
496
frontend/src/components/GraphCanvas.svelte
Normal 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>
|
||||
137
frontend/src/components/PortNode.svelte
Normal file
137
frontend/src/components/PortNode.svelte
Normal file
@@ -0,0 +1,137 @@
|
||||
<script lang="ts">
|
||||
import { Handle, Position } from '@xyflow/svelte';
|
||||
import type { Port } from '../lib/types';
|
||||
|
||||
let { data }: { data: {
|
||||
label: string;
|
||||
nodeType: string;
|
||||
mode: string;
|
||||
inputs: Port[];
|
||||
outputs: Port[];
|
||||
} } = $props();
|
||||
|
||||
function portColor(portType: string): string {
|
||||
switch (portType) {
|
||||
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;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="port-node" class:output-node={data.mode === 'output'} class:input-node={data.mode === 'input'}>
|
||||
<div class="node-header">
|
||||
<span class="node-type-label">{data.nodeType}</span>
|
||||
<span class="node-label" title={data.label}>{data.label}</span>
|
||||
</div>
|
||||
|
||||
<div class="node-body">
|
||||
{#if data.inputs.length > 0}
|
||||
<div class="ports-column">
|
||||
{#each data.inputs as port (port.id)}
|
||||
<div class="port" style="--pc: {portColor(port.port_type)}">
|
||||
<Handle
|
||||
type="target"
|
||||
position={Position.Left}
|
||||
id={String(port.id)}
|
||||
class="port-handle"
|
||||
style="background:{portColor(port.port_type)};border-color:{portColor(port.port_type)}"
|
||||
/>
|
||||
<span class="port-name">{shortName(port.name)}</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if data.outputs.length > 0}
|
||||
<div class="ports-column">
|
||||
{#each data.outputs as port (port.id)}
|
||||
<div class="port out" style="--pc: {portColor(port.port_type)}">
|
||||
<span class="port-name">{shortName(port.name)}</span>
|
||||
<Handle
|
||||
type="source"
|
||||
position={Position.Right}
|
||||
id={String(port.id)}
|
||||
class="port-handle"
|
||||
style="background:{portColor(port.port_type)};border-color:{portColor(port.port_type)}"
|
||||
/>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.port-node {
|
||||
background: #1e1e2e;
|
||||
border: 1px solid #555;
|
||||
border-radius: 6px;
|
||||
min-width: 150px;
|
||||
font-family: monospace;
|
||||
font-size: 12px;
|
||||
color: #ccc;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.5);
|
||||
}
|
||||
.output-node { border-color: #4a9; }
|
||||
.input-node { border-color: #a44; }
|
||||
|
||||
.node-header {
|
||||
background: #2a2a3e;
|
||||
padding: 5px 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
border-bottom: 1px solid #444;
|
||||
border-radius: 6px 6px 0 0;
|
||||
}
|
||||
.node-type-label {
|
||||
font-size: 9px;
|
||||
color: #777;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.node-label {
|
||||
font-weight: bold;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
color: #ddd;
|
||||
flex: 1;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.node-body { padding: 2px 0; }
|
||||
.ports-column { display: flex; flex-direction: column; }
|
||||
.ports-column + .ports-column { border-top: 1px solid #333; }
|
||||
|
||||
.port {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 3px 6px;
|
||||
position: relative;
|
||||
}
|
||||
.port.out { justify-content: flex-end; }
|
||||
|
||||
.port-name {
|
||||
font-size: 11px;
|
||||
color: #aaa;
|
||||
}
|
||||
|
||||
:global(.port-handle) {
|
||||
width: 8px !important;
|
||||
height: 8px !important;
|
||||
min-width: 8px !important;
|
||||
min-height: 8px !important;
|
||||
border: 1px solid #888 !important;
|
||||
border-radius: 50% !important;
|
||||
position: relative !important;
|
||||
top: 0 !important;
|
||||
transform: none !important;
|
||||
}
|
||||
</style>
|
||||
10
frontend/src/lib/Counter.svelte
Normal file
10
frontend/src/lib/Counter.svelte
Normal file
@@ -0,0 +1,10 @@
|
||||
<script lang="ts">
|
||||
let count: number = $state(0)
|
||||
const increment = () => {
|
||||
count += 1
|
||||
}
|
||||
</script>
|
||||
|
||||
<button class="counter" onclick={increment}>
|
||||
Count is {count}
|
||||
</button>
|
||||
64
frontend/src/lib/stores.ts
Normal file
64
frontend/src/lib/stores.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import { writable, derived, get } from 'svelte/store';
|
||||
import type { Node, Port, Link, GraphMessage } from './types';
|
||||
import { subscribe, connectPorts, disconnectPorts } from './ws';
|
||||
|
||||
// Raw graph stores
|
||||
export const nodes = writable<Node[]>([]);
|
||||
export const ports = writable<Port[]>([]);
|
||||
export const links = writable<Link[]>([]);
|
||||
export const connected = writable(false);
|
||||
|
||||
// Port lookup map
|
||||
export const portById = derived(ports, ($ports) => {
|
||||
const map = new Map<number, Port>();
|
||||
for (const p of $ports) map.set(p.id, p);
|
||||
return map;
|
||||
});
|
||||
|
||||
// Node lookup map
|
||||
export const nodeById = derived(nodes, ($nodes) => {
|
||||
const map = new Map<number, Node>();
|
||||
for (const n of $nodes) map.set(n.id, n);
|
||||
return map;
|
||||
});
|
||||
|
||||
// Initialize: fetch via REST immediately, then subscribe to WS for updates
|
||||
let unsubscribe: (() => void) | null = null;
|
||||
|
||||
function applyGraph(graph: GraphMessage) {
|
||||
nodes.set(graph.nodes);
|
||||
ports.set(graph.ports);
|
||||
links.set(graph.links);
|
||||
connected.set(true);
|
||||
}
|
||||
|
||||
export async function initGraph() {
|
||||
if (unsubscribe) return;
|
||||
|
||||
// 1. Fetch initial state via REST API (works immediately, no WS needed)
|
||||
try {
|
||||
const res = await fetch('/api/graph');
|
||||
if (res.ok) {
|
||||
const graph: GraphMessage = await res.json();
|
||||
applyGraph(graph);
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[graph] REST fetch failed:', e);
|
||||
}
|
||||
|
||||
// 2. Subscribe to WebSocket for live updates
|
||||
unsubscribe = subscribe((graph: GraphMessage) => {
|
||||
applyGraph(graph);
|
||||
});
|
||||
}
|
||||
|
||||
export function destroyGraph() {
|
||||
if (unsubscribe) {
|
||||
unsubscribe();
|
||||
unsubscribe = null;
|
||||
connected.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
// Re-export connection functions
|
||||
export { connectPorts, disconnectPorts };
|
||||
47
frontend/src/lib/types.ts
Normal file
47
frontend/src/lib/types.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
// Types matching the C++ backend JSON protocol
|
||||
|
||||
export interface Port {
|
||||
id: number;
|
||||
node_id: number;
|
||||
name: string;
|
||||
mode: 'input' | 'output' | 'duplex' | 'none';
|
||||
port_type: 'audio' | 'midi' | 'video' | 'other';
|
||||
flags: number;
|
||||
}
|
||||
|
||||
export interface Node {
|
||||
id: number;
|
||||
name: string;
|
||||
nick: string;
|
||||
media_name: string;
|
||||
mode: 'input' | 'output' | 'duplex' | 'none';
|
||||
node_type: string;
|
||||
port_ids: number[];
|
||||
}
|
||||
|
||||
export interface Link {
|
||||
id: number;
|
||||
output_port_id: number;
|
||||
input_port_id: number;
|
||||
}
|
||||
|
||||
export interface GraphMessage {
|
||||
type: 'graph';
|
||||
nodes: Node[];
|
||||
ports: Port[];
|
||||
links: Link[];
|
||||
}
|
||||
|
||||
export interface ConnectMessage {
|
||||
type: 'connect';
|
||||
output_port_id: number;
|
||||
input_port_id: number;
|
||||
}
|
||||
|
||||
export interface DisconnectMessage {
|
||||
type: 'disconnect';
|
||||
output_port_id: number;
|
||||
input_port_id: number;
|
||||
}
|
||||
|
||||
export type WsMessage = ConnectMessage | DisconnectMessage;
|
||||
83
frontend/src/lib/ws.ts
Normal file
83
frontend/src/lib/ws.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import type { GraphMessage } from './types';
|
||||
|
||||
type GraphListener = (graph: GraphMessage) => void;
|
||||
|
||||
let es: EventSource | null = null;
|
||||
let listeners: GraphListener[] = [];
|
||||
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
function connect() {
|
||||
if (es && es.readyState === EventSource.OPEN) return;
|
||||
|
||||
const url = '/events';
|
||||
console.log('[sse] connecting to', url);
|
||||
es = new EventSource(url);
|
||||
|
||||
es.onopen = () => {
|
||||
console.log('[sse] connected');
|
||||
};
|
||||
|
||||
es.onmessage = (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
if (data.type === 'graph') {
|
||||
for (const fn of listeners) {
|
||||
fn(data as GraphMessage);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[sse] parse error:', e);
|
||||
}
|
||||
};
|
||||
|
||||
es.onerror = () => {
|
||||
console.log('[sse] disconnected, reconnecting in 2s...');
|
||||
es?.close();
|
||||
es = null;
|
||||
if (!reconnectTimer && listeners.length > 0) {
|
||||
reconnectTimer = setTimeout(() => {
|
||||
reconnectTimer = null;
|
||||
connect();
|
||||
}, 2000);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function subscribe(fn: GraphListener): () => void {
|
||||
listeners.push(fn);
|
||||
connect();
|
||||
return () => {
|
||||
listeners = listeners.filter(l => l !== fn);
|
||||
if (listeners.length === 0 && es) {
|
||||
es.close();
|
||||
es = null;
|
||||
if (reconnectTimer) {
|
||||
clearTimeout(reconnectTimer);
|
||||
reconnectTimer = null;
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
async function postCommand(endpoint: string, outputPortId: number, inputPortId: number) {
|
||||
try {
|
||||
await fetch(endpoint, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
output_port_id: outputPortId,
|
||||
input_port_id: inputPortId,
|
||||
}),
|
||||
});
|
||||
} catch (e) {
|
||||
console.error('[api] POST', endpoint, 'failed:', e);
|
||||
}
|
||||
}
|
||||
|
||||
export function connectPorts(outputPortId: number, inputPortId: number) {
|
||||
postCommand('/api/connect', outputPortId, inputPortId);
|
||||
}
|
||||
|
||||
export function disconnectPorts(outputPortId: number, inputPortId: number) {
|
||||
postCommand('/api/disconnect', outputPortId, inputPortId);
|
||||
}
|
||||
9
frontend/src/main.ts
Normal file
9
frontend/src/main.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { mount } from 'svelte'
|
||||
import './app.css'
|
||||
import App from './App.svelte'
|
||||
|
||||
const app = mount(App, {
|
||||
target: document.getElementById('app')!,
|
||||
})
|
||||
|
||||
export default app
|
||||
Reference in New Issue
Block a user