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

24
frontend/.gitignore vendored Normal file
View File

@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

3
frontend/.vscode/extensions.json vendored Normal file
View File

@@ -0,0 +1,3 @@
{
"recommendations": ["svelte.svelte-vscode"]
}

47
frontend/README.md Normal file
View File

@@ -0,0 +1,47 @@
# Svelte + TS + Vite
This template should help get you started developing with Svelte and TypeScript in Vite.
## Recommended IDE Setup
[VS Code](https://code.visualstudio.com/) + [Svelte](https://marketplace.visualstudio.com/items?itemName=svelte.svelte-vscode).
## Need an official Svelte framework?
Check out [SvelteKit](https://github.com/sveltejs/kit#readme), which is also powered by Vite. Deploy anywhere with its serverless-first approach and adapt to various platforms, with out of the box support for TypeScript, SCSS, and Less, and easily-added support for mdsvex, GraphQL, PostCSS, Tailwind CSS, and more.
## Technical considerations
**Why use this over SvelteKit?**
- It brings its own routing solution which might not be preferable for some users.
- It is first and foremost a framework that just happens to use Vite under the hood, not a Vite app.
This template contains as little as possible to get started with Vite + TypeScript + Svelte, while taking into account the developer experience with regards to HMR and intellisense. It demonstrates capabilities on par with the other `create-vite` templates and is a good starting point for beginners dipping their toes into a Vite + Svelte project.
Should you later need the extended capabilities and extensibility provided by SvelteKit, the template has been structured similarly to SvelteKit so that it is easy to migrate.
**Why `global.d.ts` instead of `compilerOptions.types` inside `jsconfig.json` or `tsconfig.json`?**
Setting `compilerOptions.types` shuts out all other types not explicitly listed in the configuration. Using triple-slash references keeps the default TypeScript setting of accepting type information from the entire workspace, while also adding `svelte` and `vite/client` type information.
**Why include `.vscode/extensions.json`?**
Other templates indirectly recommend extensions via the README, but this file allows VS Code to prompt the user to install the recommended extension upon opening the project.
**Why enable `allowJs` in the TS template?**
While `allowJs: false` would indeed prevent the use of `.js` files in the project, it does not prevent the use of JavaScript syntax in `.svelte` files. In addition, it would force `checkJs: false`, bringing the worst of both worlds: not being able to guarantee the entire codebase is TypeScript, and also having worse typechecking for the existing JavaScript. In addition, there are valid use cases in which a mixed codebase may be relevant.
**Why is HMR not preserving my local component state?**
HMR state preservation comes with a number of gotchas! It has been disabled by default in both `svelte-hmr` and `@sveltejs/vite-plugin-svelte` due to its often surprising behavior. You can read the details [here](https://github.com/rixo/svelte-hmr#svelte-hmr).
If you have state that's important to retain within a component, consider creating an external store which would not be replaced by HMR.
```ts
// store.ts
// An extremely simple external store
import { writable } from 'svelte/store'
export default writable(0)
```

13
frontend/index.html Normal file
View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>pwweb - PipeWire Graph</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

1478
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

24
frontend/package.json Normal file
View File

@@ -0,0 +1,24 @@
{
"name": "frontend",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"check": "svelte-check --tsconfig ./tsconfig.app.json && tsc -p tsconfig.node.json"
},
"devDependencies": {
"@sveltejs/vite-plugin-svelte": "^7.0.0",
"@tsconfig/svelte": "^5.0.8",
"@types/node": "^24.12.0",
"svelte": "^5.53.12",
"svelte-check": "^4.4.5",
"typescript": "~5.9.3",
"vite": "^8.0.1"
},
"dependencies": {
"@xyflow/svelte": "^1.5.2"
}
}

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.3 KiB

24
frontend/public/icons.svg Normal file
View File

@@ -0,0 +1,24 @@
<svg xmlns="http://www.w3.org/2000/svg">
<symbol id="bluesky-icon" viewBox="0 0 16 17">
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
</symbol>
<symbol id="discord-icon" viewBox="0 0 20 19">
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
</symbol>
<symbol id="documentation-icon" viewBox="0 0 21 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
</symbol>
<symbol id="github-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
</symbol>
<symbol id="social-icon" viewBox="0 0 20 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
</symbol>
<symbol id="x-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
</symbol>
</svg>

After

Width:  |  Height:  |  Size: 4.9 KiB

14
frontend/src/App.svelte Normal file
View 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
View 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;
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

View 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

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.5 KiB

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>

View 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>

View 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>

View 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
View 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
View 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
View 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

View File

@@ -0,0 +1,2 @@
/** @type {import("@sveltejs/vite-plugin-svelte").SvelteConfig} */
export default {}

View File

@@ -0,0 +1,21 @@
{
"extends": "@tsconfig/svelte/tsconfig.json",
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2023",
"useDefineForClassFields": true,
"module": "ESNext",
"types": ["svelte", "vite/client"],
"noEmit": true,
/**
* Typecheck JS in `.svelte` and `.js` files by default.
* Disable checkJs if you'd like to use dynamic types in JS.
* Note that setting allowJs false does not prevent the use
* of JS in `.svelte` files.
*/
"allowJs": true,
"checkJs": true,
"moduleDetection": "force"
},
"include": ["src/**/*.ts", "src/**/*.js", "src/**/*.svelte"]
}

7
frontend/tsconfig.json Normal file
View File

@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

View File

@@ -0,0 +1,26 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2023",
"lib": ["ES2023"],
"module": "ESNext",
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
}

20
frontend/vite.config.ts Normal file
View File

@@ -0,0 +1,20 @@
import { defineConfig } from 'vite'
import { svelte } from '@sveltejs/vite-plugin-svelte'
// https://vite.dev/config/
export default defineConfig({
plugins: [svelte()],
server: {
port: 5173,
proxy: {
'/api': {
target: 'http://localhost:9876',
changeOrigin: true,
},
'/events': {
target: 'http://localhost:9876',
changeOrigin: true,
},
},
},
})