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:
7
.gitignore
vendored
Normal file
7
.gitignore
vendored
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
build/
|
||||||
|
!build/.gitkeep
|
||||||
|
frontend/node_modules/
|
||||||
|
frontend/dist/
|
||||||
|
*.o
|
||||||
|
*.d
|
||||||
|
pwweb
|
||||||
43
CMakeLists.txt
Normal file
43
CMakeLists.txt
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
cmake_minimum_required(VERSION 3.16)
|
||||||
|
project(pwweb VERSION 0.1.0 LANGUAGES C CXX)
|
||||||
|
|
||||||
|
set(CMAKE_CXX_STANDARD 17)
|
||||||
|
set(CMAKE_CXX_STANDARD_REQUIRED ON)
|
||||||
|
|
||||||
|
find_package(PkgConfig REQUIRED)
|
||||||
|
pkg_check_modules(PW REQUIRED libpipewire-0.3)
|
||||||
|
pkg_check_modules(SPA REQUIRED libspa-0.2)
|
||||||
|
|
||||||
|
# ZLIB for cpp-httplib gzip compression
|
||||||
|
find_package(ZLIB QUIET)
|
||||||
|
|
||||||
|
add_executable(pwweb
|
||||||
|
src/main.cpp
|
||||||
|
src/graph_engine.cpp
|
||||||
|
src/web_server.cpp
|
||||||
|
)
|
||||||
|
|
||||||
|
target_include_directories(pwweb PRIVATE
|
||||||
|
src
|
||||||
|
src/third_party
|
||||||
|
${PW_INCLUDE_DIRS}
|
||||||
|
${SPA_INCLUDE_DIRS}
|
||||||
|
)
|
||||||
|
|
||||||
|
target_link_directories(pwweb PRIVATE
|
||||||
|
${PW_LIBRARY_DIRS}
|
||||||
|
${SPA_LIBRARY_DIRS}
|
||||||
|
)
|
||||||
|
|
||||||
|
target_link_libraries(pwweb PRIVATE
|
||||||
|
${PW_LIBRARIES}
|
||||||
|
${SPA_LIBRARIES}
|
||||||
|
pthread
|
||||||
|
)
|
||||||
|
|
||||||
|
if(ZLIB_FOUND)
|
||||||
|
target_link_libraries(pwweb PRIVATE ZLIB::ZLIB)
|
||||||
|
target_compile_definitions(pwweb PRIVATE CPPHTTPLIB_ZLIB_SUPPORT)
|
||||||
|
endif()
|
||||||
|
|
||||||
|
target_compile_options(pwweb PRIVATE ${PW_CFLAGS_OTHER} ${SPA_CFLAGS_OTHER})
|
||||||
24
frontend/.gitignore
vendored
Normal file
24
frontend/.gitignore
vendored
Normal 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
3
frontend/.vscode/extensions.json
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"recommendations": ["svelte.svelte-vscode"]
|
||||||
|
}
|
||||||
47
frontend/README.md
Normal file
47
frontend/README.md
Normal 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
13
frontend/index.html
Normal 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
1478
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
24
frontend/package.json
Normal file
24
frontend/package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
1
frontend/public/favicon.svg
Normal file
1
frontend/public/favicon.svg
Normal file
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
24
frontend/public/icons.svg
Normal 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
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
|
||||||
2
frontend/svelte.config.js
Normal file
2
frontend/svelte.config.js
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
/** @type {import("@sveltejs/vite-plugin-svelte").SvelteConfig} */
|
||||||
|
export default {}
|
||||||
21
frontend/tsconfig.app.json
Normal file
21
frontend/tsconfig.app.json
Normal 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
7
frontend/tsconfig.json
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{ "path": "./tsconfig.app.json" },
|
||||||
|
{ "path": "./tsconfig.node.json" }
|
||||||
|
]
|
||||||
|
}
|
||||||
26
frontend/tsconfig.node.json
Normal file
26
frontend/tsconfig.node.json
Normal 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
20
frontend/vite.config.ts
Normal 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,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
773
src/graph_engine.cpp
Normal file
773
src/graph_engine.cpp
Normal file
@@ -0,0 +1,773 @@
|
|||||||
|
#include "graph_engine.h"
|
||||||
|
|
||||||
|
#include <pipewire/pipewire.h>
|
||||||
|
#include <spa/utils/result.h>
|
||||||
|
#include <spa/utils/list.h>
|
||||||
|
|
||||||
|
#include <cstring>
|
||||||
|
#include <algorithm>
|
||||||
|
#include <chrono>
|
||||||
|
#include <thread>
|
||||||
|
|
||||||
|
// Default port type strings (must match qpwgraph)
|
||||||
|
#define DEFAULT_AUDIO_TYPE "32 bit float mono audio"
|
||||||
|
#define DEFAULT_MIDI_TYPE "8 bit raw midi"
|
||||||
|
#define DEFAULT_MIDI2_TYPE "32 bit raw UMP"
|
||||||
|
#define DEFAULT_VIDEO_TYPE "32 bit float RGBA video"
|
||||||
|
#define DEFAULT_OTHER_TYPE "PIPEWIRE_PORT_TYPE"
|
||||||
|
|
||||||
|
using namespace pwgraph;
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Pending/sync helpers (ported from qpwgraph_pipewire.cpp)
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
static void add_pending(GraphEngine::Object *obj, GraphEngine::PwData &pw) {
|
||||||
|
if (obj->pending_seq == 0)
|
||||||
|
spa_list_append(&pw.pending, &obj->pending_link);
|
||||||
|
obj->pending_seq = pw_core_sync(pw.core, 0, obj->pending_seq);
|
||||||
|
pw.pending_seq = obj->pending_seq;
|
||||||
|
}
|
||||||
|
|
||||||
|
static void remove_pending(GraphEngine::Object *obj) {
|
||||||
|
if (obj->pending_seq != 0) {
|
||||||
|
spa_list_remove(&obj->pending_link);
|
||||||
|
obj->pending_seq = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Proxy helpers
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
static void destroy_proxy(GraphEngine::Object *obj) {
|
||||||
|
if (obj->proxy) {
|
||||||
|
pw_proxy_destroy((pw_proxy*)obj->proxy);
|
||||||
|
obj->proxy = nullptr;
|
||||||
|
}
|
||||||
|
spa_hook_remove(&obj->object_listener);
|
||||||
|
spa_hook_remove(&obj->proxy_listener);
|
||||||
|
remove_pending(obj);
|
||||||
|
if (obj->info && obj->destroy_info) {
|
||||||
|
obj->destroy_info(obj->info);
|
||||||
|
obj->info = nullptr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Node events (ported from qpwgraph_pipewire.cpp lines 224-278)
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
static void on_node_info(void *data, const struct pw_node_info *info) {
|
||||||
|
auto *obj = static_cast<GraphEngine::Object*>(data);
|
||||||
|
if (!obj) return;
|
||||||
|
|
||||||
|
info = pw_node_info_update((struct pw_node_info*)obj->info, info);
|
||||||
|
obj->info = (void*)info;
|
||||||
|
|
||||||
|
if (info && (info->change_mask & PW_NODE_CHANGE_MASK_PROPS)) {
|
||||||
|
auto *nobj = static_cast<GraphEngine::NodeObj*>(obj);
|
||||||
|
if (info->props) {
|
||||||
|
const char *media_name = spa_dict_lookup(info->props, PW_KEY_MEDIA_NAME);
|
||||||
|
if (media_name && strlen(media_name) > 0)
|
||||||
|
nobj->node.media_name = media_name;
|
||||||
|
}
|
||||||
|
nobj->node.changed = true;
|
||||||
|
nobj->node.ready = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static const struct pw_node_events node_events = {
|
||||||
|
.version = PW_VERSION_NODE_EVENTS,
|
||||||
|
.info = on_node_info,
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Port events
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
static void on_port_info(void *data, const struct pw_port_info *info) {
|
||||||
|
auto *obj = static_cast<GraphEngine::Object*>(data);
|
||||||
|
if (!obj) return;
|
||||||
|
info = pw_port_info_update((struct pw_port_info*)obj->info, info);
|
||||||
|
obj->info = (void*)info;
|
||||||
|
}
|
||||||
|
|
||||||
|
static const struct pw_port_events port_events = {
|
||||||
|
.version = PW_VERSION_PORT_EVENTS,
|
||||||
|
.info = on_port_info,
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Link events
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
static void on_link_info(void *data, const struct pw_link_info *info) {
|
||||||
|
auto *obj = static_cast<GraphEngine::Object*>(data);
|
||||||
|
if (!obj) return;
|
||||||
|
info = pw_link_info_update((struct pw_link_info*)obj->info, info);
|
||||||
|
obj->info = (void*)info;
|
||||||
|
}
|
||||||
|
|
||||||
|
static const struct pw_link_events link_events = {
|
||||||
|
.version = PW_VERSION_LINK_EVENTS,
|
||||||
|
.info = on_link_info,
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Proxy events
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
static void on_proxy_removed(void *data) {
|
||||||
|
auto *obj = static_cast<GraphEngine::Object*>(data);
|
||||||
|
if (obj && obj->proxy) {
|
||||||
|
pw_proxy *proxy = (pw_proxy*)obj->proxy;
|
||||||
|
obj->proxy = nullptr;
|
||||||
|
pw_proxy_destroy(proxy);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static void on_proxy_destroy(void *data) {
|
||||||
|
auto *obj = static_cast<GraphEngine::Object*>(data);
|
||||||
|
if (obj)
|
||||||
|
destroy_proxy(obj);
|
||||||
|
}
|
||||||
|
|
||||||
|
static const struct pw_proxy_events proxy_events = {
|
||||||
|
.version = PW_VERSION_PROXY_EVENTS,
|
||||||
|
.destroy = on_proxy_destroy,
|
||||||
|
.removed = on_proxy_removed,
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Object proxy creation (adapted from qpwgraph_pipewire::Object::create_proxy)
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
static void create_proxy_for_object(GraphEngine::Object *obj, GraphEngine *engine) {
|
||||||
|
if (obj->proxy) return;
|
||||||
|
|
||||||
|
const char *proxy_type = nullptr;
|
||||||
|
uint32_t version = 0;
|
||||||
|
void (*destroy_info)(void*) = nullptr;
|
||||||
|
const void *events = nullptr;
|
||||||
|
|
||||||
|
switch (obj->type) {
|
||||||
|
case GraphEngine::Object::ObjNode:
|
||||||
|
proxy_type = PW_TYPE_INTERFACE_Node;
|
||||||
|
version = PW_VERSION_NODE;
|
||||||
|
destroy_info = (void(*)(void*))pw_node_info_free;
|
||||||
|
events = &node_events;
|
||||||
|
break;
|
||||||
|
case GraphEngine::Object::ObjPort:
|
||||||
|
proxy_type = PW_TYPE_INTERFACE_Port;
|
||||||
|
version = PW_VERSION_PORT;
|
||||||
|
destroy_info = (void(*)(void*))pw_port_info_free;
|
||||||
|
events = &port_events;
|
||||||
|
break;
|
||||||
|
case GraphEngine::Object::ObjLink:
|
||||||
|
proxy_type = PW_TYPE_INTERFACE_Link;
|
||||||
|
version = PW_VERSION_LINK;
|
||||||
|
destroy_info = (void(*)(void*))pw_link_info_free;
|
||||||
|
events = &link_events;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
auto &pw = engine->pwData();
|
||||||
|
pw_proxy *proxy = (pw_proxy*)pw_registry_bind(
|
||||||
|
pw.registry, obj->id, proxy_type, version, 0);
|
||||||
|
if (proxy) {
|
||||||
|
obj->proxy = proxy;
|
||||||
|
obj->destroy_info = destroy_info;
|
||||||
|
obj->pending_seq = 0;
|
||||||
|
pw_proxy_add_object_listener(proxy,
|
||||||
|
&obj->object_listener, events, obj);
|
||||||
|
pw_proxy_add_listener(proxy,
|
||||||
|
&obj->proxy_listener, &proxy_events, obj);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Core events (sync/error handling)
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
static void on_core_done(void *data, uint32_t id, int seq) {
|
||||||
|
auto *engine = static_cast<GraphEngine*>(data);
|
||||||
|
auto &pw = engine->pwData();
|
||||||
|
|
||||||
|
if (id == PW_ID_CORE) {
|
||||||
|
pw.last_seq = seq;
|
||||||
|
if (pw.pending_seq == seq)
|
||||||
|
pw_thread_loop_signal(pw.loop, false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static void on_core_error(void *data, uint32_t id, int seq, int res, const char *message) {
|
||||||
|
auto *engine = static_cast<GraphEngine*>(data);
|
||||||
|
auto &pw = engine->pwData();
|
||||||
|
|
||||||
|
if (id == PW_ID_CORE) {
|
||||||
|
pw.last_res = res;
|
||||||
|
if (res == -EPIPE)
|
||||||
|
pw.error = true;
|
||||||
|
}
|
||||||
|
pw_thread_loop_signal(pw.loop, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
static const struct pw_core_events core_events = {
|
||||||
|
.version = PW_VERSION_CORE_EVENTS,
|
||||||
|
.info = nullptr,
|
||||||
|
.done = on_core_done,
|
||||||
|
.error = on_core_error,
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Link proxy sync (for connect/disconnect operations)
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
static int link_proxy_sync(GraphEngine *engine) {
|
||||||
|
auto &pw = engine->pwData();
|
||||||
|
|
||||||
|
if (pw_thread_loop_in_thread(pw.loop))
|
||||||
|
return 0;
|
||||||
|
|
||||||
|
pw.pending_seq = pw_proxy_sync((pw_proxy*)pw.core, pw.pending_seq);
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
pw_thread_loop_wait(pw.loop);
|
||||||
|
if (pw.error)
|
||||||
|
return pw.last_res;
|
||||||
|
if (pw.pending_seq == pw.last_seq)
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
static void on_link_proxy_error(void *data, int seq, int res, const char *message) {
|
||||||
|
int *link_res = (int*)data;
|
||||||
|
*link_res = res;
|
||||||
|
}
|
||||||
|
|
||||||
|
static const struct pw_proxy_events link_proxy_events = {
|
||||||
|
.version = PW_VERSION_PROXY_EVENTS,
|
||||||
|
.error = on_link_proxy_error,
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Registry events (the main entry point — ported from qpwgraph lines 425-568)
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
static void on_registry_global(void *data,
|
||||||
|
uint32_t id, uint32_t permissions,
|
||||||
|
const char *type, uint32_t version,
|
||||||
|
const struct spa_dict *props)
|
||||||
|
{
|
||||||
|
if (!props) return;
|
||||||
|
|
||||||
|
auto *engine = static_cast<GraphEngine*>(data);
|
||||||
|
|
||||||
|
if (strcmp(type, PW_TYPE_INTERFACE_Node) == 0) {
|
||||||
|
// Parse node properties (ported from qpwgraph lines 444-489)
|
||||||
|
const char *str = spa_dict_lookup(props, PW_KEY_NODE_DESCRIPTION);
|
||||||
|
const char *nick = spa_dict_lookup(props, PW_KEY_NODE_NICK);
|
||||||
|
if (!str || strlen(str) < 1) str = nick;
|
||||||
|
if (!str || strlen(str) < 1) str = nick = spa_dict_lookup(props, PW_KEY_NODE_NAME);
|
||||||
|
if (!str || strlen(str) < 1) str = "node";
|
||||||
|
|
||||||
|
std::string node_name;
|
||||||
|
const char *app = spa_dict_lookup(props, PW_KEY_APP_NAME);
|
||||||
|
if (app && strlen(app) > 0 && strcmp(app, str) != 0) {
|
||||||
|
node_name += app;
|
||||||
|
node_name += '/';
|
||||||
|
}
|
||||||
|
node_name += str;
|
||||||
|
std::string node_nick = (nick ? nick : str);
|
||||||
|
|
||||||
|
PortMode node_mode = PortMode::None;
|
||||||
|
uint8_t node_types = 0;
|
||||||
|
|
||||||
|
str = spa_dict_lookup(props, PW_KEY_MEDIA_CLASS);
|
||||||
|
if (str) {
|
||||||
|
if (strstr(str, "Source") || strstr(str, "Output"))
|
||||||
|
node_mode = PortMode::Output;
|
||||||
|
else if (strstr(str, "Sink") || strstr(str, "Input"))
|
||||||
|
node_mode = PortMode::Input;
|
||||||
|
if (strstr(str, "Audio")) node_types |= (uint8_t)NodeType::Audio;
|
||||||
|
if (strstr(str, "Video")) node_types |= (uint8_t)NodeType::Video;
|
||||||
|
if (strstr(str, "Midi")) node_types |= (uint8_t)NodeType::Midi;
|
||||||
|
}
|
||||||
|
if (node_mode == PortMode::None) {
|
||||||
|
str = spa_dict_lookup(props, PW_KEY_MEDIA_CATEGORY);
|
||||||
|
if (str && strstr(str, "Duplex"))
|
||||||
|
node_mode = PortMode::Duplex;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create node object
|
||||||
|
auto *nobj = new GraphEngine::NodeObj(id);
|
||||||
|
nobj->node.id = id;
|
||||||
|
nobj->node.name = node_name;
|
||||||
|
nobj->node.nick = node_nick;
|
||||||
|
nobj->node.mode = node_mode;
|
||||||
|
nobj->node.node_type = NodeType(node_types);
|
||||||
|
nobj->node.mode2 = node_mode;
|
||||||
|
nobj->node.ready = false;
|
||||||
|
nobj->node.changed = false;
|
||||||
|
|
||||||
|
engine->addObject(id, nobj);
|
||||||
|
create_proxy_for_object(nobj, engine);
|
||||||
|
engine->notifyChanged();
|
||||||
|
}
|
||||||
|
else if (strcmp(type, PW_TYPE_INTERFACE_Port) == 0) {
|
||||||
|
// Parse port properties (ported from qpwgraph lines 492-535)
|
||||||
|
const char *str = spa_dict_lookup(props, PW_KEY_NODE_ID);
|
||||||
|
const uint32_t node_id = (str ? (uint32_t)atoi(str) : 0);
|
||||||
|
|
||||||
|
std::string port_name;
|
||||||
|
str = spa_dict_lookup(props, PW_KEY_PORT_ALIAS);
|
||||||
|
if (!str) str = spa_dict_lookup(props, PW_KEY_PORT_NAME);
|
||||||
|
if (!str) str = "port";
|
||||||
|
port_name = str;
|
||||||
|
|
||||||
|
auto *node_obj = engine->findNode(node_id);
|
||||||
|
|
||||||
|
uint32_t port_type = engine->otherPortType();
|
||||||
|
str = spa_dict_lookup(props, PW_KEY_FORMAT_DSP);
|
||||||
|
if (str) {
|
||||||
|
port_type = hashType(str);
|
||||||
|
} else if (node_obj && (node_obj->node.node_type & NodeType::Video) != NodeType::None) {
|
||||||
|
port_type = engine->videoPortType();
|
||||||
|
}
|
||||||
|
|
||||||
|
PortMode port_mode = PortMode::None;
|
||||||
|
str = spa_dict_lookup(props, PW_KEY_PORT_DIRECTION);
|
||||||
|
if (str) {
|
||||||
|
if (strcmp(str, "in") == 0) port_mode = PortMode::Input;
|
||||||
|
else if (strcmp(str, "out") == 0) port_mode = PortMode::Output;
|
||||||
|
}
|
||||||
|
|
||||||
|
uint8_t port_flags = 0;
|
||||||
|
if (node_obj && (node_obj->node.mode2 != PortMode::Duplex))
|
||||||
|
port_flags |= (uint8_t)PortFlags::Terminal;
|
||||||
|
str = spa_dict_lookup(props, PW_KEY_PORT_PHYSICAL);
|
||||||
|
if (str && pw_properties_parse_bool(str))
|
||||||
|
port_flags |= (uint8_t)PortFlags::Physical;
|
||||||
|
str = spa_dict_lookup(props, PW_KEY_PORT_TERMINAL);
|
||||||
|
if (str && pw_properties_parse_bool(str))
|
||||||
|
port_flags |= (uint8_t)PortFlags::Terminal;
|
||||||
|
str = spa_dict_lookup(props, PW_KEY_PORT_MONITOR);
|
||||||
|
if (str && pw_properties_parse_bool(str))
|
||||||
|
port_flags |= (uint8_t)PortFlags::Monitor;
|
||||||
|
str = spa_dict_lookup(props, PW_KEY_PORT_CONTROL);
|
||||||
|
if (str && pw_properties_parse_bool(str))
|
||||||
|
port_flags |= (uint8_t)PortFlags::Control;
|
||||||
|
|
||||||
|
if (!node_obj) return; // Node not ready yet
|
||||||
|
|
||||||
|
auto *pobj = new GraphEngine::PortObj(id);
|
||||||
|
pobj->port.id = id;
|
||||||
|
pobj->port.node_id = node_id;
|
||||||
|
pobj->port.name = port_name;
|
||||||
|
pobj->port.mode = port_mode;
|
||||||
|
pobj->port.port_type = port_type;
|
||||||
|
pobj->port.flags = PortFlags(port_flags);
|
||||||
|
|
||||||
|
// Update node's mode2 if port direction differs
|
||||||
|
if ((node_obj->node.mode2 & port_mode) == PortMode::None)
|
||||||
|
node_obj->node.mode2 = PortMode::Duplex;
|
||||||
|
|
||||||
|
node_obj->node.port_ids.push_back(id);
|
||||||
|
node_obj->node.changed = true;
|
||||||
|
|
||||||
|
engine->addObject(id, pobj);
|
||||||
|
create_proxy_for_object(pobj, engine);
|
||||||
|
engine->notifyChanged();
|
||||||
|
}
|
||||||
|
else if (strcmp(type, PW_TYPE_INTERFACE_Link) == 0) {
|
||||||
|
// Parse link properties (ported from qpwgraph lines 538-545)
|
||||||
|
const char *str = spa_dict_lookup(props, PW_KEY_LINK_OUTPUT_PORT);
|
||||||
|
const uint32_t port1_id = (str ? (uint32_t)pw_properties_parse_int(str) : 0);
|
||||||
|
str = spa_dict_lookup(props, PW_KEY_LINK_INPUT_PORT);
|
||||||
|
const uint32_t port2_id = (str ? (uint32_t)pw_properties_parse_int(str) : 0);
|
||||||
|
|
||||||
|
// Validate
|
||||||
|
auto *p1 = engine->findPort(port1_id);
|
||||||
|
auto *p2 = engine->findPort(port2_id);
|
||||||
|
if (!p1 || !p2) return;
|
||||||
|
if ((p1->port.mode & PortMode::Output) == PortMode::None) return;
|
||||||
|
if ((p2->port.mode & PortMode::Input) == PortMode::None) return;
|
||||||
|
|
||||||
|
auto *lobj = new GraphEngine::LinkObj(id);
|
||||||
|
lobj->link.id = id;
|
||||||
|
lobj->link.port1_id = port1_id;
|
||||||
|
lobj->link.port2_id = port2_id;
|
||||||
|
|
||||||
|
p1->port.link_ids.push_back(id);
|
||||||
|
|
||||||
|
engine->addObject(id, lobj);
|
||||||
|
create_proxy_for_object(lobj, engine);
|
||||||
|
engine->notifyChanged();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static void on_registry_global_remove(void *data, uint32_t id) {
|
||||||
|
auto *engine = static_cast<GraphEngine*>(data);
|
||||||
|
engine->removeObject(id);
|
||||||
|
engine->notifyChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
static const struct pw_registry_events registry_events = {
|
||||||
|
.version = PW_VERSION_REGISTRY_EVENTS,
|
||||||
|
.global = on_registry_global,
|
||||||
|
.global_remove = on_registry_global_remove,
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// GraphEngine::Object
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
GraphEngine::Object::Object(uint32_t id, Type type)
|
||||||
|
: id(id), type(type), proxy(nullptr), info(nullptr),
|
||||||
|
destroy_info(nullptr), pending_seq(0)
|
||||||
|
{
|
||||||
|
spa_zero(proxy_listener);
|
||||||
|
spa_zero(object_listener);
|
||||||
|
spa_list_init(&pending_link);
|
||||||
|
}
|
||||||
|
|
||||||
|
GraphEngine::Object::~Object() {
|
||||||
|
if (proxy) {
|
||||||
|
destroy_proxy(this);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// GraphEngine
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
GraphEngine::GraphEngine()
|
||||||
|
: m_on_change(nullptr), m_running(false)
|
||||||
|
{
|
||||||
|
m_audio_type = hashType(DEFAULT_AUDIO_TYPE);
|
||||||
|
m_midi_type = hashType(DEFAULT_MIDI_TYPE);
|
||||||
|
m_midi2_type = hashType(DEFAULT_MIDI2_TYPE);
|
||||||
|
m_video_type = hashType(DEFAULT_VIDEO_TYPE);
|
||||||
|
m_other_type = hashType(DEFAULT_OTHER_TYPE);
|
||||||
|
|
||||||
|
memset(&m_pw, 0, sizeof(m_pw));
|
||||||
|
spa_list_init(&m_pw.pending);
|
||||||
|
}
|
||||||
|
|
||||||
|
GraphEngine::~GraphEngine() {
|
||||||
|
close();
|
||||||
|
}
|
||||||
|
|
||||||
|
bool GraphEngine::open() {
|
||||||
|
std::lock_guard<std::mutex> lock(m_mutex);
|
||||||
|
|
||||||
|
pw_init(nullptr, nullptr);
|
||||||
|
|
||||||
|
memset(&m_pw, 0, sizeof(m_pw));
|
||||||
|
spa_list_init(&m_pw.pending);
|
||||||
|
m_pw.pending_seq = 0;
|
||||||
|
|
||||||
|
m_pw.loop = pw_thread_loop_new("pwweb_loop", nullptr);
|
||||||
|
if (!m_pw.loop) {
|
||||||
|
fprintf(stderr, "pw_thread_loop_new failed\n");
|
||||||
|
pw_deinit();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
pw_thread_loop_lock(m_pw.loop);
|
||||||
|
|
||||||
|
struct pw_loop *loop = pw_thread_loop_get_loop(m_pw.loop);
|
||||||
|
m_pw.context = pw_context_new(loop, nullptr, 0);
|
||||||
|
if (!m_pw.context) {
|
||||||
|
fprintf(stderr, "pw_context_new failed\n");
|
||||||
|
pw_thread_loop_unlock(m_pw.loop);
|
||||||
|
pw_thread_loop_destroy(m_pw.loop);
|
||||||
|
pw_deinit();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
m_pw.core = pw_context_connect(m_pw.context, nullptr, 0);
|
||||||
|
if (!m_pw.core) {
|
||||||
|
fprintf(stderr, "pw_context_connect failed\n");
|
||||||
|
pw_thread_loop_unlock(m_pw.loop);
|
||||||
|
pw_context_destroy(m_pw.context);
|
||||||
|
pw_thread_loop_destroy(m_pw.loop);
|
||||||
|
pw_deinit();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
pw_core_add_listener(m_pw.core,
|
||||||
|
&m_pw.core_listener, &core_events, this);
|
||||||
|
|
||||||
|
m_pw.registry = pw_core_get_registry(m_pw.core,
|
||||||
|
PW_VERSION_REGISTRY, 0);
|
||||||
|
|
||||||
|
pw_registry_add_listener(m_pw.registry,
|
||||||
|
&m_pw.registry_listener, ®istry_events, this);
|
||||||
|
|
||||||
|
m_pw.pending_seq = 0;
|
||||||
|
m_pw.last_seq = 0;
|
||||||
|
m_pw.last_res = 0;
|
||||||
|
m_pw.error = false;
|
||||||
|
|
||||||
|
m_running = true;
|
||||||
|
|
||||||
|
pw_thread_loop_start(m_pw.loop);
|
||||||
|
pw_thread_loop_unlock(m_pw.loop);
|
||||||
|
|
||||||
|
fprintf(stderr, "pwweb: PipeWire connected\n");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
void GraphEngine::close() {
|
||||||
|
if (!m_pw.loop) return;
|
||||||
|
|
||||||
|
m_running = false;
|
||||||
|
|
||||||
|
pw_thread_loop_lock(m_pw.loop);
|
||||||
|
|
||||||
|
clearObjects();
|
||||||
|
|
||||||
|
pw_thread_loop_unlock(m_pw.loop);
|
||||||
|
|
||||||
|
if (m_pw.loop)
|
||||||
|
pw_thread_loop_stop(m_pw.loop);
|
||||||
|
|
||||||
|
if (m_pw.registry) {
|
||||||
|
spa_hook_remove(&m_pw.registry_listener);
|
||||||
|
pw_proxy_destroy((pw_proxy*)m_pw.registry);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (m_pw.core) {
|
||||||
|
spa_hook_remove(&m_pw.core_listener);
|
||||||
|
pw_core_disconnect(m_pw.core);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (m_pw.context)
|
||||||
|
pw_context_destroy(m_pw.context);
|
||||||
|
|
||||||
|
if (m_pw.loop)
|
||||||
|
pw_thread_loop_destroy(m_pw.loop);
|
||||||
|
|
||||||
|
memset(&m_pw, 0, sizeof(m_pw));
|
||||||
|
|
||||||
|
pw_deinit();
|
||||||
|
|
||||||
|
fprintf(stderr, "pwweb: PipeWire disconnected\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
void GraphEngine::setOnChange(ChangeCallback cb) {
|
||||||
|
std::lock_guard<std::mutex> lock(m_mutex);
|
||||||
|
m_on_change = std::move(cb);
|
||||||
|
}
|
||||||
|
|
||||||
|
void GraphEngine::notifyChanged() {
|
||||||
|
// Called from PipeWire thread — invoke callback outside lock
|
||||||
|
ChangeCallback cb;
|
||||||
|
{
|
||||||
|
std::lock_guard<std::mutex> lock(m_mutex);
|
||||||
|
cb = m_on_change;
|
||||||
|
}
|
||||||
|
if (cb) cb();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Object management
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
void GraphEngine::addObject(uint32_t id, Object *obj) {
|
||||||
|
m_objects_by_id[id] = obj;
|
||||||
|
m_objects.push_back(obj);
|
||||||
|
}
|
||||||
|
|
||||||
|
GraphEngine::Object *GraphEngine::findObject(uint32_t id) const {
|
||||||
|
auto it = m_objects_by_id.find(id);
|
||||||
|
return (it != m_objects_by_id.end()) ? it->second : nullptr;
|
||||||
|
}
|
||||||
|
|
||||||
|
void GraphEngine::removeObject(uint32_t id) {
|
||||||
|
auto it = m_objects_by_id.find(id);
|
||||||
|
if (it == m_objects_by_id.end()) return;
|
||||||
|
|
||||||
|
Object *obj = it->second;
|
||||||
|
m_objects_by_id.erase(it);
|
||||||
|
|
||||||
|
auto vit = std::find(m_objects.begin(), m_objects.end(), obj);
|
||||||
|
if (vit != m_objects.end())
|
||||||
|
m_objects.erase(vit);
|
||||||
|
|
||||||
|
// If it's a port, remove from parent node's port list
|
||||||
|
if (obj->type == Object::ObjPort) {
|
||||||
|
auto *pobj = static_cast<PortObj*>(obj);
|
||||||
|
auto *nobj = findNode(pobj->port.node_id);
|
||||||
|
if (nobj) {
|
||||||
|
auto &pids = nobj->node.port_ids;
|
||||||
|
pids.erase(std::remove(pids.begin(), pids.end(), id), pids.end());
|
||||||
|
nobj->node.changed = true;
|
||||||
|
}
|
||||||
|
// Remove any links involving this port
|
||||||
|
auto link_ids_copy = pobj->port.link_ids;
|
||||||
|
for (uint32_t lid : link_ids_copy) {
|
||||||
|
removeObject(lid);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If it's a node, remove all its ports
|
||||||
|
if (obj->type == Object::ObjNode) {
|
||||||
|
auto *nobj = static_cast<NodeObj*>(obj);
|
||||||
|
auto port_ids_copy = nobj->node.port_ids;
|
||||||
|
for (uint32_t pid : port_ids_copy) {
|
||||||
|
removeObject(pid);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If it's a link, remove from output port's link list
|
||||||
|
if (obj->type == Object::ObjLink) {
|
||||||
|
auto *lobj = static_cast<LinkObj*>(obj);
|
||||||
|
auto *pobj = findPort(lobj->link.port1_id);
|
||||||
|
if (pobj) {
|
||||||
|
auto &lids = pobj->port.link_ids;
|
||||||
|
lids.erase(std::remove(lids.begin(), lids.end(), id), lids.end());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
delete obj;
|
||||||
|
}
|
||||||
|
|
||||||
|
void GraphEngine::clearObjects() {
|
||||||
|
for (auto *obj : m_objects)
|
||||||
|
delete obj;
|
||||||
|
m_objects.clear();
|
||||||
|
m_objects_by_id.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
GraphEngine::NodeObj *GraphEngine::findNode(uint32_t node_id) const {
|
||||||
|
Object *obj = findObject(node_id);
|
||||||
|
return (obj && obj->type == Object::ObjNode) ? static_cast<NodeObj*>(obj) : nullptr;
|
||||||
|
}
|
||||||
|
|
||||||
|
GraphEngine::PortObj *GraphEngine::findPort(uint32_t port_id) const {
|
||||||
|
Object *obj = findObject(port_id);
|
||||||
|
return (obj && obj->type == Object::ObjPort) ? static_cast<PortObj*>(obj) : nullptr;
|
||||||
|
}
|
||||||
|
|
||||||
|
GraphEngine::LinkObj *GraphEngine::findLink(uint32_t link_id) const {
|
||||||
|
Object *obj = findObject(link_id);
|
||||||
|
return (obj && obj->type == Object::ObjLink) ? static_cast<LinkObj*>(obj) : nullptr;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Snapshot (thread-safe graph state)
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
GraphEngine::Snapshot GraphEngine::snapshot() const {
|
||||||
|
std::lock_guard<std::mutex> lock(m_mutex);
|
||||||
|
|
||||||
|
Snapshot snap;
|
||||||
|
for (auto *obj : m_objects) {
|
||||||
|
switch (obj->type) {
|
||||||
|
case Object::ObjNode:
|
||||||
|
snap.nodes.push_back(static_cast<NodeObj*>(obj)->node);
|
||||||
|
break;
|
||||||
|
case Object::ObjPort:
|
||||||
|
snap.ports.push_back(static_cast<PortObj*>(obj)->port);
|
||||||
|
break;
|
||||||
|
case Object::ObjLink:
|
||||||
|
snap.links.push_back(static_cast<LinkObj*>(obj)->link);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return snap;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Connect/Disconnect (ported from qpwgraph_pipewire::connectPorts)
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
bool GraphEngine::connectPorts(uint32_t output_port_id, uint32_t input_port_id) {
|
||||||
|
if (!m_pw.loop) return false;
|
||||||
|
|
||||||
|
pw_thread_loop_lock(m_pw.loop);
|
||||||
|
|
||||||
|
PortObj *p1 = findPort(output_port_id);
|
||||||
|
PortObj *p2 = findPort(input_port_id);
|
||||||
|
|
||||||
|
if (!p1 || !p2 ||
|
||||||
|
(p1->port.mode & PortMode::Output) == PortMode::None ||
|
||||||
|
(p2->port.mode & PortMode::Input) == PortMode::None ||
|
||||||
|
p1->port.port_type != p2->port.port_type) {
|
||||||
|
pw_thread_loop_unlock(m_pw.loop);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create link via PipeWire (adapted from qpwgraph lines 858-891)
|
||||||
|
char val[4][16];
|
||||||
|
snprintf(val[0], sizeof(val[0]), "%u", p1->port.node_id);
|
||||||
|
snprintf(val[1], sizeof(val[1]), "%u", p1->id);
|
||||||
|
snprintf(val[2], sizeof(val[2]), "%u", p2->port.node_id);
|
||||||
|
snprintf(val[3], sizeof(val[3]), "%u", p2->id);
|
||||||
|
|
||||||
|
struct spa_dict props;
|
||||||
|
struct spa_dict_item items[6];
|
||||||
|
props = SPA_DICT_INIT(items, 0);
|
||||||
|
items[props.n_items++] = SPA_DICT_ITEM_INIT(PW_KEY_LINK_OUTPUT_NODE, val[0]);
|
||||||
|
items[props.n_items++] = SPA_DICT_ITEM_INIT(PW_KEY_LINK_OUTPUT_PORT, val[1]);
|
||||||
|
items[props.n_items++] = SPA_DICT_ITEM_INIT(PW_KEY_LINK_INPUT_NODE, val[2]);
|
||||||
|
items[props.n_items++] = SPA_DICT_ITEM_INIT(PW_KEY_LINK_INPUT_PORT, val[3]);
|
||||||
|
items[props.n_items++] = SPA_DICT_ITEM_INIT(PW_KEY_OBJECT_LINGER, "true");
|
||||||
|
const char *str = getenv("PIPEWIRE_LINK_PASSIVE");
|
||||||
|
if (str && pw_properties_parse_bool(str))
|
||||||
|
items[props.n_items++] = SPA_DICT_ITEM_INIT(PW_KEY_LINK_PASSIVE, "true");
|
||||||
|
|
||||||
|
pw_proxy *proxy = (pw_proxy*)pw_core_create_object(m_pw.core,
|
||||||
|
"link-factory", PW_TYPE_INTERFACE_Link, PW_VERSION_LINK, &props, 0);
|
||||||
|
|
||||||
|
if (proxy) {
|
||||||
|
int link_res = 0;
|
||||||
|
spa_hook listener;
|
||||||
|
spa_zero(listener);
|
||||||
|
pw_proxy_add_listener(proxy,
|
||||||
|
&listener, &link_proxy_events, &link_res);
|
||||||
|
link_proxy_sync(this);
|
||||||
|
spa_hook_remove(&listener);
|
||||||
|
pw_proxy_destroy(proxy);
|
||||||
|
}
|
||||||
|
|
||||||
|
pw_thread_loop_unlock(m_pw.loop);
|
||||||
|
return (proxy != nullptr);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool GraphEngine::disconnectPorts(uint32_t output_port_id, uint32_t input_port_id) {
|
||||||
|
if (!m_pw.loop) return false;
|
||||||
|
|
||||||
|
pw_thread_loop_lock(m_pw.loop);
|
||||||
|
|
||||||
|
PortObj *p1 = findPort(output_port_id);
|
||||||
|
PortObj *p2 = findPort(input_port_id);
|
||||||
|
|
||||||
|
if (!p1 || !p2) {
|
||||||
|
pw_thread_loop_unlock(m_pw.loop);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find and destroy matching link (adapted from qpwgraph lines 846-853)
|
||||||
|
bool found = false;
|
||||||
|
for (uint32_t lid : p1->port.link_ids) {
|
||||||
|
LinkObj *link = findLink(lid);
|
||||||
|
if (link && link->link.port1_id == output_port_id &&
|
||||||
|
link->link.port2_id == input_port_id) {
|
||||||
|
pw_registry_destroy(m_pw.registry, lid);
|
||||||
|
link_proxy_sync(this);
|
||||||
|
found = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pw_thread_loop_unlock(m_pw.loop);
|
||||||
|
return found;
|
||||||
|
}
|
||||||
|
|
||||||
|
// end of graph_engine.cpp
|
||||||
131
src/graph_engine.h
Normal file
131
src/graph_engine.h
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include "graph_types.h"
|
||||||
|
#include <functional>
|
||||||
|
#include <mutex>
|
||||||
|
#include <vector>
|
||||||
|
#include <unordered_map>
|
||||||
|
#include <thread>
|
||||||
|
#include <atomic>
|
||||||
|
|
||||||
|
#include <pipewire/utils.h> // pw_thread_loop, etc.
|
||||||
|
#include <spa/utils/list.h> // spa_list
|
||||||
|
#include <spa/utils/hook.h> // spa_hook
|
||||||
|
|
||||||
|
struct pw_thread_loop;
|
||||||
|
struct pw_context;
|
||||||
|
struct pw_core;
|
||||||
|
struct pw_registry;
|
||||||
|
|
||||||
|
namespace pwgraph {
|
||||||
|
|
||||||
|
class GraphEngine {
|
||||||
|
public:
|
||||||
|
using ChangeCallback = std::function<void()>;
|
||||||
|
|
||||||
|
GraphEngine();
|
||||||
|
~GraphEngine();
|
||||||
|
|
||||||
|
bool open();
|
||||||
|
void close();
|
||||||
|
|
||||||
|
// Set callback invoked when graph changes
|
||||||
|
void setOnChange(ChangeCallback cb);
|
||||||
|
|
||||||
|
// Thread-safe snapshot of the current graph state
|
||||||
|
struct Snapshot {
|
||||||
|
std::vector<Node> nodes;
|
||||||
|
std::vector<Port> ports;
|
||||||
|
std::vector<Link> links;
|
||||||
|
};
|
||||||
|
Snapshot snapshot() const;
|
||||||
|
|
||||||
|
// Connect/disconnect two ports (thread-safe, blocks until done)
|
||||||
|
bool connectPorts(uint32_t output_port_id, uint32_t input_port_id);
|
||||||
|
bool disconnectPorts(uint32_t output_port_id, uint32_t input_port_id);
|
||||||
|
|
||||||
|
// PipeWire internal data (exposed for C callbacks)
|
||||||
|
struct PwData {
|
||||||
|
pw_thread_loop *loop;
|
||||||
|
pw_context *context;
|
||||||
|
pw_core *core;
|
||||||
|
spa_hook core_listener;
|
||||||
|
pw_registry *registry;
|
||||||
|
spa_hook registry_listener;
|
||||||
|
int pending_seq;
|
||||||
|
spa_list pending; // doubly-linked list head for pending syncs
|
||||||
|
int last_seq;
|
||||||
|
int last_res;
|
||||||
|
bool error;
|
||||||
|
};
|
||||||
|
|
||||||
|
PwData& pwData() { return m_pw; }
|
||||||
|
|
||||||
|
// Object management (called from C callbacks)
|
||||||
|
struct Object {
|
||||||
|
enum Type { ObjNode, ObjPort, ObjLink };
|
||||||
|
uint32_t id;
|
||||||
|
Type type;
|
||||||
|
void *proxy;
|
||||||
|
void *info;
|
||||||
|
void (*destroy_info)(void*);
|
||||||
|
spa_hook proxy_listener;
|
||||||
|
spa_hook object_listener;
|
||||||
|
int pending_seq;
|
||||||
|
spa_list pending_link;
|
||||||
|
|
||||||
|
Object(uint32_t id, Type type);
|
||||||
|
virtual ~Object();
|
||||||
|
};
|
||||||
|
|
||||||
|
struct NodeObj : Object {
|
||||||
|
NodeObj(uint32_t id) : Object(id, ObjNode) {}
|
||||||
|
Node node;
|
||||||
|
};
|
||||||
|
|
||||||
|
struct PortObj : Object {
|
||||||
|
PortObj(uint32_t id) : Object(id, ObjPort) {}
|
||||||
|
Port port;
|
||||||
|
};
|
||||||
|
|
||||||
|
struct LinkObj : Object {
|
||||||
|
LinkObj(uint32_t id) : Object(id, ObjLink) {}
|
||||||
|
Link link;
|
||||||
|
};
|
||||||
|
|
||||||
|
void addObject(uint32_t id, Object *obj);
|
||||||
|
Object *findObject(uint32_t id) const;
|
||||||
|
void removeObject(uint32_t id);
|
||||||
|
void clearObjects();
|
||||||
|
|
||||||
|
NodeObj *findNode(uint32_t node_id) const;
|
||||||
|
PortObj *findPort(uint32_t port_id) const;
|
||||||
|
LinkObj *findLink(uint32_t link_id) const;
|
||||||
|
|
||||||
|
void notifyChanged();
|
||||||
|
|
||||||
|
private:
|
||||||
|
PwData m_pw;
|
||||||
|
|
||||||
|
mutable std::mutex m_mutex; // protects m_objects and graph data
|
||||||
|
std::unordered_map<uint32_t, Object*> m_objects_by_id;
|
||||||
|
std::vector<Object*> m_objects;
|
||||||
|
|
||||||
|
ChangeCallback m_on_change;
|
||||||
|
std::atomic<bool> m_running;
|
||||||
|
|
||||||
|
// Port type hashes
|
||||||
|
uint32_t m_audio_type;
|
||||||
|
uint32_t m_midi_type;
|
||||||
|
uint32_t m_midi2_type;
|
||||||
|
uint32_t m_video_type;
|
||||||
|
uint32_t m_other_type;
|
||||||
|
|
||||||
|
public:
|
||||||
|
uint32_t audioPortType() const { return m_audio_type; }
|
||||||
|
uint32_t midiPortType() const { return m_midi_type; }
|
||||||
|
uint32_t videoPortType() const { return m_video_type; }
|
||||||
|
uint32_t otherPortType() const { return m_other_type; }
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace pwgraph
|
||||||
105
src/graph_types.h
Normal file
105
src/graph_types.h
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <string>
|
||||||
|
#include <vector>
|
||||||
|
#include <unordered_map>
|
||||||
|
#include <cstdint>
|
||||||
|
|
||||||
|
namespace pwgraph {
|
||||||
|
|
||||||
|
enum class PortMode : uint8_t {
|
||||||
|
None = 0,
|
||||||
|
Input = 1,
|
||||||
|
Output = 2,
|
||||||
|
Duplex = Input | Output
|
||||||
|
};
|
||||||
|
|
||||||
|
enum class NodeType : uint8_t {
|
||||||
|
None = 0,
|
||||||
|
Audio = 1,
|
||||||
|
Video = 2,
|
||||||
|
Midi = 4
|
||||||
|
};
|
||||||
|
|
||||||
|
enum class PortFlags : uint8_t {
|
||||||
|
None = 0,
|
||||||
|
Physical = 1,
|
||||||
|
Terminal = 2,
|
||||||
|
Monitor = 4,
|
||||||
|
Control = 8
|
||||||
|
};
|
||||||
|
|
||||||
|
inline PortFlags operator|(PortFlags a, PortFlags b) {
|
||||||
|
return static_cast<PortFlags>(static_cast<uint8_t>(a) | static_cast<uint8_t>(b));
|
||||||
|
}
|
||||||
|
inline PortFlags operator&(PortFlags a, PortFlags b) {
|
||||||
|
return static_cast<PortFlags>(static_cast<uint8_t>(a) & static_cast<uint8_t>(b));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bitwise operators for PortMode
|
||||||
|
inline PortMode operator|(PortMode a, PortMode b) {
|
||||||
|
return static_cast<PortMode>(static_cast<uint8_t>(a) | static_cast<uint8_t>(b));
|
||||||
|
}
|
||||||
|
inline PortMode operator&(PortMode a, PortMode b) {
|
||||||
|
return static_cast<PortMode>(static_cast<uint8_t>(a) & static_cast<uint8_t>(b));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bitwise operators for NodeType
|
||||||
|
inline NodeType operator|(NodeType a, NodeType b) {
|
||||||
|
return static_cast<NodeType>(static_cast<uint8_t>(a) | static_cast<uint8_t>(b));
|
||||||
|
}
|
||||||
|
inline NodeType operator&(NodeType a, NodeType b) {
|
||||||
|
return static_cast<NodeType>(static_cast<uint8_t>(a) & static_cast<uint8_t>(b));
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Node {
|
||||||
|
uint32_t id;
|
||||||
|
std::string name;
|
||||||
|
std::string nick;
|
||||||
|
std::string media_name;
|
||||||
|
PortMode mode; // from media class (Source=Output, Sink=Input)
|
||||||
|
NodeType node_type;
|
||||||
|
PortMode mode2; // derived from actual port directions
|
||||||
|
bool ready;
|
||||||
|
bool changed;
|
||||||
|
|
||||||
|
std::vector<uint32_t> port_ids; // child port IDs
|
||||||
|
|
||||||
|
Node() : id(0), mode(PortMode::None), node_type(NodeType::None),
|
||||||
|
mode2(PortMode::None), ready(false), changed(false) {}
|
||||||
|
};
|
||||||
|
|
||||||
|
struct Link;
|
||||||
|
|
||||||
|
struct Port {
|
||||||
|
uint32_t id;
|
||||||
|
uint32_t node_id;
|
||||||
|
std::string name;
|
||||||
|
PortMode mode;
|
||||||
|
uint32_t port_type; // hashed type string
|
||||||
|
PortFlags flags;
|
||||||
|
|
||||||
|
std::vector<uint32_t> link_ids; // links where this is the output port
|
||||||
|
|
||||||
|
Port() : id(0), node_id(0), mode(PortMode::None),
|
||||||
|
port_type(0), flags(PortFlags::None) {}
|
||||||
|
};
|
||||||
|
|
||||||
|
struct Link {
|
||||||
|
uint32_t id;
|
||||||
|
uint32_t port1_id; // output port
|
||||||
|
uint32_t port2_id; // input port
|
||||||
|
|
||||||
|
Link() : id(0), port1_id(0), port2_id(0) {}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Simple hash function for port type strings (matches qpwgraph_item::itemType)
|
||||||
|
inline uint32_t hashType(const std::string& s) {
|
||||||
|
uint32_t h = 5381;
|
||||||
|
for (char c : s) {
|
||||||
|
h = ((h << 5) + h) + static_cast<uint32_t>(c);
|
||||||
|
}
|
||||||
|
return h;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace pwgraph
|
||||||
69
src/main.cpp
Normal file
69
src/main.cpp
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
#include "graph_engine.h"
|
||||||
|
#include "web_server.h"
|
||||||
|
|
||||||
|
#include <csignal>
|
||||||
|
#include <cstdio>
|
||||||
|
#include <cstdlib>
|
||||||
|
#include <unistd.h>
|
||||||
|
#include <atomic>
|
||||||
|
|
||||||
|
static std::atomic<bool> g_running(true);
|
||||||
|
|
||||||
|
static void signal_handler(int sig) {
|
||||||
|
g_running = false;
|
||||||
|
fprintf(stderr, "\npwweb: caught signal %d, shutting down...\n", sig);
|
||||||
|
}
|
||||||
|
|
||||||
|
int main(int argc, char *argv[]) {
|
||||||
|
int port = 9876;
|
||||||
|
|
||||||
|
// Parse optional port argument
|
||||||
|
if (argc > 1) {
|
||||||
|
port = atoi(argv[1]);
|
||||||
|
if (port <= 0 || port > 65535) {
|
||||||
|
fprintf(stderr, "usage: pwweb [port]\n");
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
signal(SIGINT, signal_handler);
|
||||||
|
signal(SIGTERM, signal_handler);
|
||||||
|
|
||||||
|
pwgraph::GraphEngine engine;
|
||||||
|
|
||||||
|
fprintf(stderr, "pwweb: connecting to PipeWire...\n");
|
||||||
|
if (!engine.open()) {
|
||||||
|
fprintf(stderr, "pwweb: failed to connect to PipeWire.\n");
|
||||||
|
fprintf(stderr, " Make sure XDG_RUNTIME_DIR is set: export XDG_RUNTIME_DIR=/run/user/$(id -u)\n");
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
pwgraph::WebServer server(engine, port);
|
||||||
|
|
||||||
|
// Wire change notifications to broadcast
|
||||||
|
engine.setOnChange([&server]() {
|
||||||
|
server.broadcastGraph();
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!server.start()) {
|
||||||
|
fprintf(stderr, "pwweb: failed to start web server on port %d\n", port);
|
||||||
|
engine.close();
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
fprintf(stderr, "pwweb: server running at http://localhost:%d\n", port);
|
||||||
|
fprintf(stderr, "pwweb: API at http://localhost:%d/api/graph\n", port);
|
||||||
|
fprintf(stderr, "pwweb: press Ctrl+C to stop\n");
|
||||||
|
|
||||||
|
// Main loop — just wait for shutdown signal
|
||||||
|
while (g_running) {
|
||||||
|
std::this_thread::sleep_for(std::chrono::milliseconds(200));
|
||||||
|
}
|
||||||
|
|
||||||
|
fprintf(stderr, "pwweb: shutting down...\n");
|
||||||
|
server.stop();
|
||||||
|
engine.close();
|
||||||
|
|
||||||
|
fprintf(stderr, "pwweb: goodbye.\n");
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
20393
src/third_party/httplib.h
vendored
Normal file
20393
src/third_party/httplib.h
vendored
Normal file
File diff suppressed because it is too large
Load Diff
314
src/web_server.cpp
Normal file
314
src/web_server.cpp
Normal file
@@ -0,0 +1,314 @@
|
|||||||
|
#include "web_server.h"
|
||||||
|
#include <sstream>
|
||||||
|
#include <iomanip>
|
||||||
|
#include <cstdio>
|
||||||
|
#include <algorithm>
|
||||||
|
#include <condition_variable>
|
||||||
|
|
||||||
|
using namespace pwgraph;
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// JSON serialization helpers
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
static std::string escapeJson(const std::string &s) {
|
||||||
|
std::ostringstream o;
|
||||||
|
for (char c : s) {
|
||||||
|
switch (c) {
|
||||||
|
case '"': o << "\\\""; break;
|
||||||
|
case '\\': o << "\\\\"; break;
|
||||||
|
case '\b': o << "\\b"; break;
|
||||||
|
case '\f': o << "\\f"; break;
|
||||||
|
case '\n': o << "\\n"; break;
|
||||||
|
case '\r': o << "\\r"; break;
|
||||||
|
case '\t': o << "\\t"; break;
|
||||||
|
default:
|
||||||
|
if (static_cast<unsigned char>(c) < 0x20) {
|
||||||
|
o << "\\u" << std::hex << std::setw(4) << std::setfill('0')
|
||||||
|
<< (int)(unsigned char)c;
|
||||||
|
} else {
|
||||||
|
o << c;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return o.str();
|
||||||
|
}
|
||||||
|
|
||||||
|
static const char *portModeStr(PortMode m) {
|
||||||
|
switch (m) {
|
||||||
|
case PortMode::Input: return "input";
|
||||||
|
case PortMode::Output: return "output";
|
||||||
|
case PortMode::Duplex: return "duplex";
|
||||||
|
default: return "none";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static std::string nodeTypeStr(NodeType t) {
|
||||||
|
std::string result;
|
||||||
|
if ((t & NodeType::Audio) != NodeType::None) {
|
||||||
|
if (!result.empty()) result += '+';
|
||||||
|
result += "audio";
|
||||||
|
}
|
||||||
|
if ((t & NodeType::Video) != NodeType::None) {
|
||||||
|
if (!result.empty()) result += '+';
|
||||||
|
result += "video";
|
||||||
|
}
|
||||||
|
if ((t & NodeType::Midi) != NodeType::None) {
|
||||||
|
if (!result.empty()) result += '+';
|
||||||
|
result += "midi";
|
||||||
|
}
|
||||||
|
return result.empty() ? "other" : result;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string WebServer::buildGraphJson() const {
|
||||||
|
auto snap = m_engine.snapshot();
|
||||||
|
|
||||||
|
std::ostringstream json;
|
||||||
|
json << "{\"type\":\"graph\",\"nodes\":[";
|
||||||
|
|
||||||
|
bool first_node = true;
|
||||||
|
for (auto &n : snap.nodes) {
|
||||||
|
if (!n.ready) continue;
|
||||||
|
if (!first_node) json << ",";
|
||||||
|
first_node = false;
|
||||||
|
|
||||||
|
json << "{\"id\":" << n.id
|
||||||
|
<< ",\"name\":\"" << escapeJson(n.name) << "\""
|
||||||
|
<< ",\"nick\":\"" << escapeJson(n.nick) << "\""
|
||||||
|
<< ",\"media_name\":\"" << escapeJson(n.media_name) << "\""
|
||||||
|
<< ",\"mode\":\"" << portModeStr(n.mode) << "\""
|
||||||
|
<< ",\"node_type\":\"" << nodeTypeStr(n.node_type) << "\""
|
||||||
|
<< ",\"port_ids\":[";
|
||||||
|
bool first_p = true;
|
||||||
|
for (uint32_t pid : n.port_ids) {
|
||||||
|
if (!first_p) json << ",";
|
||||||
|
first_p = false;
|
||||||
|
json << pid;
|
||||||
|
}
|
||||||
|
json << "]}";
|
||||||
|
}
|
||||||
|
|
||||||
|
json << "],\"ports\":[";
|
||||||
|
|
||||||
|
bool first_port = true;
|
||||||
|
for (auto &p : snap.ports) {
|
||||||
|
if (!first_port) json << ",";
|
||||||
|
first_port = false;
|
||||||
|
|
||||||
|
std::string port_type_name;
|
||||||
|
uint32_t pt = p.port_type;
|
||||||
|
if (pt == m_engine.audioPortType()) port_type_name = "audio";
|
||||||
|
else if (pt == m_engine.midiPortType()) port_type_name = "midi";
|
||||||
|
else if (pt == m_engine.videoPortType()) port_type_name = "video";
|
||||||
|
else port_type_name = "other";
|
||||||
|
|
||||||
|
json << "{\"id\":" << p.id
|
||||||
|
<< ",\"node_id\":" << p.node_id
|
||||||
|
<< ",\"name\":\"" << escapeJson(p.name) << "\""
|
||||||
|
<< ",\"mode\":\"" << portModeStr(p.mode) << "\""
|
||||||
|
<< ",\"port_type\":\"" << port_type_name << "\""
|
||||||
|
<< ",\"flags\":" << (int)(uint8_t)p.flags
|
||||||
|
<< "}";
|
||||||
|
}
|
||||||
|
|
||||||
|
json << "],\"links\":[";
|
||||||
|
|
||||||
|
bool first_link = true;
|
||||||
|
for (auto &l : snap.links) {
|
||||||
|
if (!first_link) json << ",";
|
||||||
|
first_link = false;
|
||||||
|
|
||||||
|
json << "{\"id\":" << l.id
|
||||||
|
<< ",\"output_port_id\":" << l.port1_id
|
||||||
|
<< ",\"input_port_id\":" << l.port2_id << "}";
|
||||||
|
}
|
||||||
|
|
||||||
|
json << "]}";
|
||||||
|
return json.str();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// WebServer
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
WebServer::WebServer(GraphEngine &engine, int port)
|
||||||
|
: m_engine(engine), m_port(port), m_running(false)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
WebServer::~WebServer() {
|
||||||
|
stop();
|
||||||
|
}
|
||||||
|
|
||||||
|
bool WebServer::start() {
|
||||||
|
setupRoutes();
|
||||||
|
|
||||||
|
m_running = true;
|
||||||
|
m_thread = std::thread([this]() {
|
||||||
|
fprintf(stderr, "pwweb: starting web server on port %d\n", m_port);
|
||||||
|
if (!m_http.listen("0.0.0.0", m_port)) {
|
||||||
|
fprintf(stderr, "pwweb: failed to bind to port %d\n", m_port);
|
||||||
|
m_running = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
std::this_thread::sleep_for(std::chrono::milliseconds(200));
|
||||||
|
return m_running;
|
||||||
|
}
|
||||||
|
|
||||||
|
void WebServer::stop() {
|
||||||
|
if (m_running) {
|
||||||
|
m_running = false;
|
||||||
|
// Close all SSE clients so their loops exit
|
||||||
|
{
|
||||||
|
std::lock_guard<std::mutex> lock(m_sse_mutex);
|
||||||
|
for (auto *sink : m_sse_clients)
|
||||||
|
sink->done();
|
||||||
|
m_sse_clients.clear();
|
||||||
|
}
|
||||||
|
m_http.stop();
|
||||||
|
if (m_thread.joinable())
|
||||||
|
m_thread.join();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void WebServer::broadcastGraph() {
|
||||||
|
if (!m_running) return;
|
||||||
|
std::string json = buildGraphJson();
|
||||||
|
|
||||||
|
std::lock_guard<std::mutex> lock(m_sse_mutex);
|
||||||
|
for (auto it = m_sse_clients.begin(); it != m_sse_clients.end(); ) {
|
||||||
|
auto *sink = *it;
|
||||||
|
std::string msg = "data: " + json + "\n\n";
|
||||||
|
if (sink->write(msg.c_str(), msg.size())) {
|
||||||
|
++it;
|
||||||
|
} else {
|
||||||
|
it = m_sse_clients.erase(it);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void WebServer::setupRoutes() {
|
||||||
|
// Serve frontend static files from ./frontend/dist
|
||||||
|
m_http.set_mount_point("/", "./frontend/dist");
|
||||||
|
|
||||||
|
// SSE endpoint: long-lived event stream
|
||||||
|
m_http.Get("/events", [this](const httplib::Request &req, httplib::Response &res) {
|
||||||
|
res.set_header("Content-Type", "text/event-stream");
|
||||||
|
res.set_header("Cache-Control", "no-cache");
|
||||||
|
res.set_header("Connection", "keep-alive");
|
||||||
|
res.set_header("Access-Control-Allow-Origin", "*");
|
||||||
|
|
||||||
|
res.set_content_provider(
|
||||||
|
"text/event-stream",
|
||||||
|
[this](size_t /*offset*/, httplib::DataSink &sink) {
|
||||||
|
// Register this SSE client
|
||||||
|
{
|
||||||
|
std::lock_guard<std::mutex> lock(m_sse_mutex);
|
||||||
|
m_sse_clients.insert(&sink);
|
||||||
|
}
|
||||||
|
fprintf(stderr, "pwweb: SSE client connected (total: %zu)\n",
|
||||||
|
m_sse_clients.size());
|
||||||
|
|
||||||
|
// Send initial graph state
|
||||||
|
{
|
||||||
|
std::string json = buildGraphJson();
|
||||||
|
std::string msg = "data: " + json + "\n\n";
|
||||||
|
sink.write(msg.c_str(), msg.size());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Block until connection closes or server stops
|
||||||
|
std::mutex mtx;
|
||||||
|
std::unique_lock<std::mutex> lock(mtx);
|
||||||
|
std::condition_variable cv;
|
||||||
|
auto sink_ptr = &sink;
|
||||||
|
|
||||||
|
// Poll: check if sink is still open, broadcast triggers via broadcastGraph()
|
||||||
|
while (m_running && sink.write("", 0)) {
|
||||||
|
// write with 0 bytes keeps connection alive; sleep between checks
|
||||||
|
// Actually, we need to block. Let's use a simpler approach:
|
||||||
|
// just block on a condition variable that gets notified
|
||||||
|
cv.wait_for(lock, std::chrono::seconds(30), [&] {
|
||||||
|
return !m_running;
|
||||||
|
});
|
||||||
|
// Send a SSE comment as keepalive
|
||||||
|
if (m_running) {
|
||||||
|
std::string keepalive = ": keepalive\n\n";
|
||||||
|
if (!sink.write(keepalive.c_str(), keepalive.size()))
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unregister
|
||||||
|
{
|
||||||
|
std::lock_guard<std::mutex> l(m_sse_mutex);
|
||||||
|
m_sse_clients.erase(sink_ptr);
|
||||||
|
}
|
||||||
|
fprintf(stderr, "pwweb: SSE client disconnected (remaining: %zu)\n",
|
||||||
|
m_sse_clients.size());
|
||||||
|
|
||||||
|
sink.done();
|
||||||
|
return false; // stop calling this callback
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// REST API: GET /api/graph
|
||||||
|
m_http.Get("/api/graph", [this](const httplib::Request &, httplib::Response &res) {
|
||||||
|
std::string json = buildGraphJson();
|
||||||
|
res.set_content(json, "application/json");
|
||||||
|
res.set_header("Access-Control-Allow-Origin", "*");
|
||||||
|
});
|
||||||
|
|
||||||
|
// REST API: POST /api/connect
|
||||||
|
m_http.Post("/api/connect", [this](const httplib::Request &req, httplib::Response &res) {
|
||||||
|
uint32_t out_id = 0, in_id = 0;
|
||||||
|
if (sscanf(req.body.c_str(),
|
||||||
|
"{\"output_port_id\":%u,\"input_port_id\":%u}", &out_id, &in_id) == 2 ||
|
||||||
|
sscanf(req.body.c_str(),
|
||||||
|
"{\"output_port_id\":%u, \"input_port_id\":%u}", &out_id, &in_id) == 2)
|
||||||
|
{
|
||||||
|
bool ok = m_engine.connectPorts(out_id, in_id);
|
||||||
|
if (ok) broadcastGraph();
|
||||||
|
res.set_content(
|
||||||
|
ok ? "{\"ok\":true}" : "{\"ok\":false,\"error\":\"connect failed\"}",
|
||||||
|
"application/json");
|
||||||
|
} else {
|
||||||
|
res.status = 400;
|
||||||
|
res.set_content("{\"error\":\"invalid json\"}", "application/json");
|
||||||
|
}
|
||||||
|
res.set_header("Access-Control-Allow-Origin", "*");
|
||||||
|
});
|
||||||
|
|
||||||
|
// REST API: POST /api/disconnect
|
||||||
|
m_http.Post("/api/disconnect", [this](const httplib::Request &req, httplib::Response &res) {
|
||||||
|
uint32_t out_id = 0, in_id = 0;
|
||||||
|
if (sscanf(req.body.c_str(),
|
||||||
|
"{\"output_port_id\":%u,\"input_port_id\":%u}", &out_id, &in_id) == 2 ||
|
||||||
|
sscanf(req.body.c_str(),
|
||||||
|
"{\"output_port_id\":%u, \"input_port_id\":%u}", &out_id, &in_id) == 2)
|
||||||
|
{
|
||||||
|
bool ok = m_engine.disconnectPorts(out_id, in_id);
|
||||||
|
if (ok) broadcastGraph();
|
||||||
|
res.set_content(
|
||||||
|
ok ? "{\"ok\":true}" : "{\"ok\":false,\"error\":\"disconnect failed\"}",
|
||||||
|
"application/json");
|
||||||
|
} else {
|
||||||
|
res.status = 400;
|
||||||
|
res.set_content("{\"error\":\"invalid json\"}", "application/json");
|
||||||
|
}
|
||||||
|
res.set_header("Access-Control-Allow-Origin", "*");
|
||||||
|
});
|
||||||
|
|
||||||
|
// CORS preflight
|
||||||
|
auto cors_handler = [](const httplib::Request &, httplib::Response &res) {
|
||||||
|
res.set_header("Access-Control-Allow-Origin", "*");
|
||||||
|
res.set_header("Access-Control-Allow-Methods", "POST, OPTIONS");
|
||||||
|
res.set_header("Access-Control-Allow-Headers", "Content-Type");
|
||||||
|
res.status = 204;
|
||||||
|
};
|
||||||
|
m_http.Options("/api/connect", cors_handler);
|
||||||
|
m_http.Options("/api/disconnect", cors_handler);
|
||||||
|
}
|
||||||
|
|
||||||
|
// end of web_server.cpp
|
||||||
39
src/web_server.h
Normal file
39
src/web_server.h
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include "graph_engine.h"
|
||||||
|
#include <httplib.h>
|
||||||
|
#include <thread>
|
||||||
|
#include <mutex>
|
||||||
|
#include <set>
|
||||||
|
#include <atomic>
|
||||||
|
#include <functional>
|
||||||
|
|
||||||
|
namespace pwgraph {
|
||||||
|
|
||||||
|
class WebServer {
|
||||||
|
public:
|
||||||
|
WebServer(GraphEngine &engine, int port = 9876);
|
||||||
|
~WebServer();
|
||||||
|
|
||||||
|
bool start();
|
||||||
|
void stop();
|
||||||
|
|
||||||
|
// Broadcast graph update to all connected SSE clients
|
||||||
|
void broadcastGraph();
|
||||||
|
|
||||||
|
private:
|
||||||
|
void setupRoutes();
|
||||||
|
std::string buildGraphJson() const;
|
||||||
|
|
||||||
|
GraphEngine &m_engine;
|
||||||
|
int m_port;
|
||||||
|
httplib::Server m_http;
|
||||||
|
std::thread m_thread;
|
||||||
|
std::atomic<bool> m_running;
|
||||||
|
|
||||||
|
// SSE clients
|
||||||
|
mutable std::mutex m_sse_mutex;
|
||||||
|
std::set<httplib::DataSink *> m_sse_clients;
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace pwgraph
|
||||||
Reference in New Issue
Block a user