feat: pactl-based module loading, more device types

Backend:
- Switched null-sink and loopback to use pactl (works on all systems)
- Generic POST /api/load-module {module, args} for any pactl module
- Fixed SVG toolbar click issue (z-index layering)

Frontend:
- + Add Device dropdown now includes:
  - Null Sink (virtual audio output)
  - Loopback Device (paired input+output)
  - TCP Network Server (module-native-protocol-tcp)
  - TCP Tunnel Sink
  - TCP Tunnel Source
- Dropdown header and section separators
- Fixed canvas z-index so toolbar is clickable
This commit is contained in:
joren
2026-03-29 23:55:19 +02:00
parent 2879469d13
commit 9dc685acda
3 changed files with 110 additions and 50 deletions

View File

@@ -449,67 +449,102 @@ void WebServer::setupRoutes() {
m_http.Options("/api/create-null-sink", cors_handler);
m_http.Options("/api/create-loopback", cors_handler);
m_http.Options("/api/unload-module", cors_handler);
m_http.Options("/api/load-module", cors_handler);
// Helper: extract a string value from simple JSON
auto extractStr = [](const std::string &body, const std::string &key) -> std::string {
size_t pos = body.find("\"" + key + "\"");
if (pos == std::string::npos) return "";
size_t start = body.find('"', pos + key.size() + 2);
if (start == std::string::npos) return "";
start++;
size_t end = body.find('"', start);
if (end == std::string::npos) return "";
return body.substr(start, end - start);
};
// Helper: run pactl load-module and return module ID
auto loadModuleViaPactl = [](const std::string &cmd) -> int {
FILE *fp = popen(cmd.c_str(), "r");
if (!fp) return -1;
char buf[32] = {};
fgets(buf, sizeof(buf), fp);
int status = pclose(fp);
if (status != 0) return -1;
return atoi(buf);
};
// Create null sink: POST /api/create-null-sink {"name":"My Sink"}
m_http.Post("/api/create-null-sink", [this](const httplib::Request &req, httplib::Response &res) {
// Parse name from JSON - simple extraction
std::string name = "pwweb-null";
size_t pos = req.body.find("\"name\"");
if (pos != std::string::npos) {
size_t start = req.body.find('"', pos + 6);
if (start != std::string::npos) {
start++;
size_t end = req.body.find('"', start);
if (end != std::string::npos) {
name = req.body.substr(start, end - start);
}
}
m_http.Post("/api/create-null-sink", [this, extractStr, loadModuleViaPactl](const httplib::Request &req, httplib::Response &res) {
std::string name = extractStr(req.body, "name");
if (name.empty()) name = "pwweb-null";
std::string cmd = "pactl load-module module-null-sink sink_name=\"" + name +
"\" sink_properties=node.name=\"" + name + "\" 2>/dev/null";
int id = loadModuleViaPactl(cmd);
if (id > 0) {
sleep(1);
broadcastGraph();
}
std::string args = "node.name=" + name +
" media.class=Audio/Sink " +
"audio.position=[FL FR]";
uint32_t id = m_engine.loadModule(
"libpipewire-module-null-sink",
args.c_str());
if (id > 0) broadcastGraph();
char buf[128];
snprintf(buf, sizeof(buf), "{\"ok\":%s,\"module_id\":%u}",
snprintf(buf, sizeof(buf), "{\"ok\":%s,\"module_id\":%d}",
id > 0 ? "true" : "false", id);
res.set_content(buf, "application/json");
res.set_header("Access-Control-Allow-Origin", "*");
});
// Create loopback: POST /api/create-loopback {"name":"My Loopback"}
m_http.Post("/api/create-loopback", [this](const httplib::Request &req, httplib::Response &res) {
std::string name = "pwweb-loopback";
size_t pos = req.body.find("\"name\"");
if (pos != std::string::npos) {
size_t start = req.body.find('"', pos + 6);
if (start != std::string::npos) {
start++;
size_t end = req.body.find('"', start);
if (end != std::string::npos) {
name = req.body.substr(start, end - start);
}
}
m_http.Post("/api/create-loopback", [this, extractStr, loadModuleViaPactl](const httplib::Request &req, httplib::Response &res) {
std::string name = extractStr(req.body, "name");
if (name.empty()) name = "pwweb-loopback";
std::string cmd = "pactl load-module module-loopback source=\"" + name +
".monitor\" sink=\"" + name + "\" 2>/dev/null";
int id = loadModuleViaPactl(cmd);
// If that fails, try without specific source/sink (generic loopback)
if (id <= 0) {
cmd = "pactl load-module module-loopback 2>/dev/null";
id = loadModuleViaPactl(cmd);
}
std::string args = "node.name=" + name +
" capture.props=node.name=" + name + "-capture" +
" playback.props=node.name=" + name + "-playback";
uint32_t id = m_engine.loadModule(
"libpipewire-module-loopback",
args.c_str());
if (id > 0) broadcastGraph();
if (id > 0) {
sleep(1);
broadcastGraph();
}
char buf[128];
snprintf(buf, sizeof(buf), "{\"ok\":%s,\"module_id\":%u}",
snprintf(buf, sizeof(buf), "{\"ok\":%s,\"module_id\":%d}",
id > 0 ? "true" : "false", id);
res.set_content(buf, "application/json");
res.set_header("Access-Control-Allow-Origin", "*");
});
// Generic module loading: POST /api/load-module {"module":"module-null-sink","args":"key=val ..."}
m_http.Post("/api/load-module", [this, extractStr, loadModuleViaPactl](const httplib::Request &req, httplib::Response &res) {
std::string module = extractStr(req.body, "module");
std::string args = extractStr(req.body, "args");
if (module.empty()) {
res.status = 400;
res.set_content("{\"error\":\"module name required\"}", "application/json");
res.set_header("Access-Control-Allow-Origin", "*");
return;
}
std::string cmd = "pactl load-module " + module;
if (!args.empty()) cmd += " " + args;
cmd += " 2>/dev/null";
int id = loadModuleViaPactl(cmd);
if (id > 0) {
sleep(1);
broadcastGraph();
}
char buf[128];
snprintf(buf, sizeof(buf), "{\"ok\":%s,\"module_id\":%d}",
id > 0 ? "true" : "false", id);
res.set_content(buf, "application/json");
res.set_header("Access-Control-Allow-Origin", "*");