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:
@@ -12,7 +12,7 @@
|
|||||||
setAutoPin, setAutoDisconnect,
|
setAutoPin, setAutoDisconnect,
|
||||||
saveProfile, loadProfile, deleteProfile,
|
saveProfile, loadProfile, deleteProfile,
|
||||||
setNodeVolume, setNodeMute,
|
setNodeVolume, setNodeMute,
|
||||||
createNullSink, createLoopback,
|
createNullSink, createLoopback, loadModule,
|
||||||
} from '../lib/stores';
|
} from '../lib/stores';
|
||||||
import type { Node, Port, Link } from '../lib/types';
|
import type { Node, Port, Link } from '../lib/types';
|
||||||
|
|
||||||
@@ -423,8 +423,13 @@
|
|||||||
<!-- Virtual device dropdown -->
|
<!-- Virtual device dropdown -->
|
||||||
{#if showVirtualMenu}
|
{#if showVirtualMenu}
|
||||||
<div class="virt-menu">
|
<div class="virt-menu">
|
||||||
|
<div class="virt-header">Add Virtual Device</div>
|
||||||
<button onclick={async () => { showVirtualMenu = false; await createNullSink('pwweb-null-' + Date.now().toString(36)); }}>Null Sink (Virtual Output)</button>
|
<button onclick={async () => { showVirtualMenu = false; await createNullSink('pwweb-null-' + Date.now().toString(36)); }}>Null Sink (Virtual Output)</button>
|
||||||
<button onclick={async () => { showVirtualMenu = false; await createLoopback('pwweb-loop-' + Date.now().toString(36)); }}>Loopback Device</button>
|
<button onclick={async () => { showVirtualMenu = false; await createLoopback('pwweb-loop-' + Date.now().toString(36)); }}>Loopback Device</button>
|
||||||
|
<div class="virt-sep"></div>
|
||||||
|
<button onclick={async () => { showVirtualMenu = false; await loadModule('module-native-protocol-tcp', 'auth-anonymous=1 port=4713'); }}>TCP Network Server</button>
|
||||||
|
<button onclick={async () => { showVirtualMenu = false; await loadModule('module-tunnel-sink', 'server=tcp:127.0.0.1:4713'); }}>TCP Tunnel Sink</button>
|
||||||
|
<button onclick={async () => { showVirtualMenu = false; await loadModule('module-tunnel-source', 'server=tcp:127.0.0.1:4713'); }}>TCP Tunnel Source</button>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
@@ -693,7 +698,7 @@
|
|||||||
|
|
||||||
<style>
|
<style>
|
||||||
.wrap { width: 100%; height: 100vh; background: #14141e; position: relative; overflow: hidden; }
|
.wrap { width: 100%; height: 100vh; background: #14141e; position: relative; overflow: hidden; }
|
||||||
.canvas { width: 100%; height: 100%; display: block; cursor: default; }
|
.canvas { width: 100%; height: 100%; display: block; cursor: default; position: absolute; top: 0; left: 0; z-index: 1; }
|
||||||
|
|
||||||
.toolbar {
|
.toolbar {
|
||||||
position: absolute; top: 0; left: 0; right: 0; z-index: 10;
|
position: absolute; top: 0; left: 0; right: 0; z-index: 10;
|
||||||
@@ -822,6 +827,11 @@
|
|||||||
background: #2a2a3e; border: 1px solid #555;
|
background: #2a2a3e; border: 1px solid #555;
|
||||||
border-radius: 4px; box-shadow: 0 4px 12px rgba(0,0,0,0.6);
|
border-radius: 4px; box-shadow: 0 4px 12px rgba(0,0,0,0.6);
|
||||||
}
|
}
|
||||||
|
.virt-header {
|
||||||
|
padding: 4px 16px; font-size: 9px; color: #666; text-transform: uppercase;
|
||||||
|
letter-spacing: 1px; border-bottom: 1px solid #444;
|
||||||
|
}
|
||||||
|
.virt-sep { height: 1px; background: #444; margin: 2px 0; }
|
||||||
.virt-menu button {
|
.virt-menu button {
|
||||||
display: block; width: 100%; padding: 6px 16px;
|
display: block; width: 100%; padding: 6px 16px;
|
||||||
background: none; border: none; color: #ccc;
|
background: none; border: none; color: #ccc;
|
||||||
|
|||||||
@@ -392,7 +392,7 @@ export async function createNullSink(name: string): Promise<number | null> {
|
|||||||
body: JSON.stringify({ name }),
|
body: JSON.stringify({ name }),
|
||||||
});
|
});
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
return data.module_id || null;
|
return data.module_id > 0 ? data.module_id : null;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('[api] create-null-sink failed:', e);
|
console.error('[api] create-null-sink failed:', e);
|
||||||
return null;
|
return null;
|
||||||
@@ -407,13 +407,28 @@ export async function createLoopback(name: string): Promise<number | null> {
|
|||||||
body: JSON.stringify({ name }),
|
body: JSON.stringify({ name }),
|
||||||
});
|
});
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
return data.module_id || null;
|
return data.module_id > 0 ? data.module_id : null;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('[api] create-loopback failed:', e);
|
console.error('[api] create-loopback failed:', e);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function loadModule(module: string, args: string): Promise<number | null> {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/load-module', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ module, args }),
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
return data.module_id > 0 ? data.module_id : null;
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[api] load-module failed:', e);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function unloadModule(moduleId: number) {
|
export async function unloadModule(moduleId: number) {
|
||||||
try {
|
try {
|
||||||
await fetch('/api/unload-module', {
|
await fetch('/api/unload-module', {
|
||||||
|
|||||||
@@ -449,67 +449,102 @@ void WebServer::setupRoutes() {
|
|||||||
m_http.Options("/api/create-null-sink", cors_handler);
|
m_http.Options("/api/create-null-sink", cors_handler);
|
||||||
m_http.Options("/api/create-loopback", cors_handler);
|
m_http.Options("/api/create-loopback", cors_handler);
|
||||||
m_http.Options("/api/unload-module", 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"}
|
// 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) {
|
m_http.Post("/api/create-null-sink", [this, extractStr, loadModuleViaPactl](const httplib::Request &req, httplib::Response &res) {
|
||||||
// Parse name from JSON - simple extraction
|
std::string name = extractStr(req.body, "name");
|
||||||
std::string name = "pwweb-null";
|
if (name.empty()) name = "pwweb-null";
|
||||||
size_t pos = req.body.find("\"name\"");
|
|
||||||
if (pos != std::string::npos) {
|
std::string cmd = "pactl load-module module-null-sink sink_name=\"" + name +
|
||||||
size_t start = req.body.find('"', pos + 6);
|
"\" sink_properties=node.name=\"" + name + "\" 2>/dev/null";
|
||||||
if (start != std::string::npos) {
|
int id = loadModuleViaPactl(cmd);
|
||||||
start++;
|
|
||||||
size_t end = req.body.find('"', start);
|
if (id > 0) {
|
||||||
if (end != std::string::npos) {
|
sleep(1);
|
||||||
name = req.body.substr(start, end - start);
|
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];
|
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);
|
id > 0 ? "true" : "false", id);
|
||||||
res.set_content(buf, "application/json");
|
res.set_content(buf, "application/json");
|
||||||
res.set_header("Access-Control-Allow-Origin", "*");
|
res.set_header("Access-Control-Allow-Origin", "*");
|
||||||
});
|
});
|
||||||
|
|
||||||
// Create loopback: POST /api/create-loopback {"name":"My Loopback"}
|
// Create loopback: POST /api/create-loopback {"name":"My Loopback"}
|
||||||
m_http.Post("/api/create-loopback", [this](const httplib::Request &req, httplib::Response &res) {
|
m_http.Post("/api/create-loopback", [this, extractStr, loadModuleViaPactl](const httplib::Request &req, httplib::Response &res) {
|
||||||
std::string name = "pwweb-loopback";
|
std::string name = extractStr(req.body, "name");
|
||||||
size_t pos = req.body.find("\"name\"");
|
if (name.empty()) name = "pwweb-loopback";
|
||||||
if (pos != std::string::npos) {
|
|
||||||
size_t start = req.body.find('"', pos + 6);
|
std::string cmd = "pactl load-module module-loopback source=\"" + name +
|
||||||
if (start != std::string::npos) {
|
".monitor\" sink=\"" + name + "\" 2>/dev/null";
|
||||||
start++;
|
int id = loadModuleViaPactl(cmd);
|
||||||
size_t end = req.body.find('"', start);
|
|
||||||
if (end != std::string::npos) {
|
// If that fails, try without specific source/sink (generic loopback)
|
||||||
name = req.body.substr(start, end - start);
|
if (id <= 0) {
|
||||||
}
|
cmd = "pactl load-module module-loopback 2>/dev/null";
|
||||||
}
|
id = loadModuleViaPactl(cmd);
|
||||||
}
|
}
|
||||||
|
|
||||||
std::string args = "node.name=" + name +
|
if (id > 0) {
|
||||||
" capture.props=node.name=" + name + "-capture" +
|
sleep(1);
|
||||||
" playback.props=node.name=" + name + "-playback";
|
broadcastGraph();
|
||||||
|
}
|
||||||
uint32_t id = m_engine.loadModule(
|
|
||||||
"libpipewire-module-loopback",
|
|
||||||
args.c_str());
|
|
||||||
|
|
||||||
if (id > 0) broadcastGraph();
|
|
||||||
|
|
||||||
char buf[128];
|
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);
|
id > 0 ? "true" : "false", id);
|
||||||
res.set_content(buf, "application/json");
|
res.set_content(buf, "application/json");
|
||||||
res.set_header("Access-Control-Allow-Origin", "*");
|
res.set_header("Access-Control-Allow-Origin", "*");
|
||||||
|
|||||||
Reference in New Issue
Block a user