feat: complete qpwgraph feature parity
Core Visual Routing: - Dashed lines for inactive connections (unpinned shown dimmer) - Color-coded streams (audio=green, midi=red, video=blue) - Pin indicator (yellow dot) on pinned connections Patchbay - Session Management: - Save & Restore: named profiles stored on server (~/.config/pwweb/patchbay.json) - Activated Mode: toggle to auto-restore connections when apps appear - Exclusive Mode: enforce profile-only links, disconnect everything else - Pin & Unpin: right-click wire -> Pin/Unpin, yellow dot on pinned wires - Auto-Pin: automatically pin all new manual connections - Auto-Disconnect: auto-sever pinned connections when patchbay deactivated - Rule Management: dialog showing all profile rules with type badges Graph Customization: - Node Hiding: add regex patterns to hide nodes (e.g. Dummy|Freewheel) - Node Merging: merge nodes sharing a common prefix into one block - All rules persisted to server with the patchbay state Dialogs: Hide Nodes, Merge Nodes, Profiles, Rules
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import { writable, derived, get } from 'svelte/store';
|
||||
import type { Node, Port, Link, GraphMessage } from './types';
|
||||
import { writable, derived } from 'svelte/store';
|
||||
import type { Node, Port, Link, GraphMessage, PatchbayState, PatchbayProfile, ConnectionRule } from './types';
|
||||
import { subscribe, connectPorts, disconnectPorts } from './ws';
|
||||
|
||||
// Raw graph stores
|
||||
@@ -8,21 +8,33 @@ export const ports = writable<Port[]>([]);
|
||||
export const links = writable<Link[]>([]);
|
||||
export const connected = writable(false);
|
||||
|
||||
// Port lookup map
|
||||
// Patchbay state
|
||||
export const patchbay = writable<PatchbayState>({
|
||||
profiles: {},
|
||||
active_profile: '',
|
||||
activated: false,
|
||||
exclusive: false,
|
||||
auto_pin: false,
|
||||
auto_disconnect: false,
|
||||
pinned_connections: [],
|
||||
hide_rules: [],
|
||||
merge_rules: [],
|
||||
});
|
||||
|
||||
// Port/node lookups
|
||||
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
|
||||
// SSE init
|
||||
let unsubscribe: (() => void) | null = null;
|
||||
|
||||
function applyGraph(graph: GraphMessage) {
|
||||
@@ -30,23 +42,61 @@ function applyGraph(graph: GraphMessage) {
|
||||
ports.set(graph.ports);
|
||||
links.set(graph.links);
|
||||
connected.set(true);
|
||||
|
||||
// Auto-restore if activated
|
||||
const pb = get_store_value(patchbay);
|
||||
if (pb.activated && pb.active_profile) {
|
||||
applyPatchbay(pb);
|
||||
}
|
||||
// Auto-pin new connections
|
||||
if (pb.auto_pin) {
|
||||
const current = get_store_value(links);
|
||||
const pinned = [...pb.pinned_connections];
|
||||
for (const link of current) {
|
||||
if (!pinned.includes(link.id)) {
|
||||
pinned.push(link.id);
|
||||
}
|
||||
}
|
||||
patchbay.update(pb => ({ ...pb, pinned_connections: pinned }));
|
||||
}
|
||||
// Exclusive mode: disconnect unpinned links not in profile
|
||||
if (pb.activated && pb.exclusive && pb.active_profile) {
|
||||
enforceExclusive(pb);
|
||||
}
|
||||
}
|
||||
|
||||
// Simple store value getter (for reading outside components)
|
||||
function get_store_value<T>(store: { subscribe: (fn: (val: T) => void) => () => void }): T {
|
||||
let val: T;
|
||||
const unsub = store.subscribe(v => { val = v; });
|
||||
unsub();
|
||||
return val!;
|
||||
}
|
||||
|
||||
export async function initGraph() {
|
||||
if (unsubscribe) return;
|
||||
|
||||
// 1. Fetch initial state via REST API (works immediately, no WS needed)
|
||||
// Load patchbay state from server
|
||||
try {
|
||||
const res = await fetch('/api/patchbay');
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
if (data && data.profiles) {
|
||||
patchbay.set(data as PatchbayState);
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
|
||||
// Fetch initial graph
|
||||
try {
|
||||
const res = await fetch('/api/graph');
|
||||
if (res.ok) {
|
||||
const graph: GraphMessage = await res.json();
|
||||
applyGraph(graph);
|
||||
applyGraph(await res.json());
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[graph] REST fetch failed:', e);
|
||||
}
|
||||
|
||||
// 2. Subscribe to WebSocket for live updates
|
||||
unsubscribe = subscribe((graph: GraphMessage) => {
|
||||
applyGraph(graph);
|
||||
});
|
||||
@@ -60,5 +110,252 @@ export function destroyGraph() {
|
||||
}
|
||||
}
|
||||
|
||||
// Re-export connection functions
|
||||
// Patchbay operations
|
||||
function linkKey(link: { output_port_id: number; input_port_id: number }): string {
|
||||
return `${link.output_port_id}:${link.input_port_id}`;
|
||||
}
|
||||
|
||||
function ruleKey(rule: ConnectionRule): string {
|
||||
return `${rule.output_node_name}:${rule.output_port_name}=>${rule.input_node_name}:${rule.input_port_name}`;
|
||||
}
|
||||
|
||||
export async function savePatchbayState() {
|
||||
const pb = get_store_value(patchbay);
|
||||
await fetch('/api/patchbay', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(pb),
|
||||
});
|
||||
}
|
||||
|
||||
async function applyPatchbay(pb: PatchbayState) {
|
||||
if (!pb.active_profile || !pb.profiles[pb.active_profile]) return;
|
||||
const profile = pb.profiles[pb.active_profile];
|
||||
const currentNodes = get_store_value(nodes);
|
||||
const currentPorts = get_store_value(ports);
|
||||
const currentLinks = get_store_value(links);
|
||||
const existingKeys = new Set(currentLinks.map(linkKey));
|
||||
|
||||
// Build name lookup for current graph
|
||||
const portByName = new Map<string, Port>();
|
||||
const nodeByIdMap = new Map<number, Node>();
|
||||
for (const n of currentNodes) nodeByIdMap.set(n.id, n);
|
||||
for (const p of currentPorts) {
|
||||
const node = nodeByIdMap.get(p.node_id);
|
||||
if (node) {
|
||||
const key = `${node.name}:${p.name}:${p.mode}:${p.port_type}`;
|
||||
portByName.set(key, p);
|
||||
}
|
||||
}
|
||||
|
||||
for (const rule of profile.connections) {
|
||||
const outKey = `${rule.output_node_name}:${rule.output_port_name}:output:${rule.output_port_type}`;
|
||||
const inKey = `${rule.input_node_name}:${rule.input_port_name}:input:${rule.input_port_type}`;
|
||||
const outPort = portByName.get(outKey);
|
||||
const inPort = portByName.get(inKey);
|
||||
if (outPort && inPort) {
|
||||
const lk = `${outPort.id}:${inPort.id}`;
|
||||
if (!existingKeys.has(lk)) {
|
||||
connectPorts(outPort.id, inPort.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function enforceExclusive(pb: PatchbayState) {
|
||||
if (!pb.active_profile || !pb.profiles[pb.active_profile]) return;
|
||||
const profile = pb.profiles[pb.active_profile];
|
||||
const currentNodes = get_store_value(nodes);
|
||||
const currentPorts = get_store_value(ports);
|
||||
const currentLinks = get_store_value(links);
|
||||
const nodeByIdMap = new Map<number, Node>();
|
||||
for (const n of currentNodes) nodeByIdMap.set(n.id, n);
|
||||
|
||||
// Build set of allowed connection keys from profile
|
||||
const allowed = new Set<string>();
|
||||
const portByName = new Map<string, Port>();
|
||||
for (const p of currentPorts) {
|
||||
const node = nodeByIdMap.get(p.node_id);
|
||||
if (node) {
|
||||
portByName.set(`${node.name}:${p.name}:${p.mode}:${p.port_type}`, p);
|
||||
}
|
||||
}
|
||||
for (const rule of profile.connections) {
|
||||
const outKey = `${rule.output_node_name}:${rule.output_port_name}:output:${rule.output_port_type}`;
|
||||
const inKey = `${rule.input_node_name}:${rule.input_port_name}:input:${rule.input_port_type}`;
|
||||
const outPort = portByName.get(outKey);
|
||||
const inPort = portByName.get(inKey);
|
||||
if (outPort && inPort) {
|
||||
allowed.add(`${outPort.id}:${inPort.id}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Also keep pinned connections
|
||||
for (const linkId of pb.pinned_connections) {
|
||||
const link = currentLinks.find(l => l.id === linkId);
|
||||
if (link) allowed.add(linkKey(link));
|
||||
}
|
||||
|
||||
// Disconnect everything else
|
||||
for (const link of currentLinks) {
|
||||
if (!allowed.has(linkKey(link))) {
|
||||
disconnectPorts(link.output_port_id, link.input_port_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function togglePin(linkId: number) {
|
||||
patchbay.update(pb => {
|
||||
const idx = pb.pinned_connections.indexOf(linkId);
|
||||
if (idx >= 0) {
|
||||
pb.pinned_connections.splice(idx, 1);
|
||||
} else {
|
||||
pb.pinned_connections.push(linkId);
|
||||
}
|
||||
return { ...pb };
|
||||
});
|
||||
savePatchbayState();
|
||||
}
|
||||
|
||||
export function addHideRule(pattern: string) {
|
||||
patchbay.update(pb => {
|
||||
if (!pb.hide_rules.includes(pattern)) {
|
||||
pb.hide_rules.push(pattern);
|
||||
}
|
||||
return { ...pb };
|
||||
});
|
||||
savePatchbayState();
|
||||
}
|
||||
|
||||
export function removeHideRule(pattern: string) {
|
||||
patchbay.update(pb => {
|
||||
return { ...pb, hide_rules: pb.hide_rules.filter(r => r !== pattern) };
|
||||
});
|
||||
savePatchbayState();
|
||||
}
|
||||
|
||||
export function addMergeRule(prefix: string) {
|
||||
patchbay.update(pb => {
|
||||
if (!pb.merge_rules.includes(prefix)) {
|
||||
pb.merge_rules.push(prefix);
|
||||
}
|
||||
return { ...pb };
|
||||
});
|
||||
savePatchbayState();
|
||||
}
|
||||
|
||||
export function removeMergeRule(prefix: string) {
|
||||
patchbay.update(pb => {
|
||||
return { ...pb, merge_rules: pb.merge_rules.filter(r => r !== prefix) };
|
||||
});
|
||||
savePatchbayState();
|
||||
}
|
||||
|
||||
export function setActivated(active: boolean) {
|
||||
patchbay.update(pb => {
|
||||
const newPb = { ...pb, activated: active };
|
||||
if (active && pb.active_profile) {
|
||||
setTimeout(() => applyPatchbay(newPb), 100);
|
||||
}
|
||||
if (!active && pb.exclusive) {
|
||||
// Auto-disconnect: remove all pinned connections when deactivated
|
||||
if (pb.auto_disconnect) {
|
||||
const currentLinks = get_store_value(links);
|
||||
for (const linkId of pb.pinned_connections) {
|
||||
const link = currentLinks.find(l => l.id === linkId);
|
||||
if (link) disconnectPorts(link.output_port_id, link.input_port_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
return newPb;
|
||||
});
|
||||
savePatchbayState();
|
||||
}
|
||||
|
||||
export function setExclusive(exclusive: boolean) {
|
||||
patchbay.update(pb => ({ ...pb, exclusive }));
|
||||
if (exclusive) {
|
||||
const pb = get_store_value(patchbay);
|
||||
if (pb.activated) enforceExclusive(pb);
|
||||
}
|
||||
savePatchbayState();
|
||||
}
|
||||
|
||||
export function setAutoPin(val: boolean) {
|
||||
patchbay.update(pb => ({ ...pb, auto_pin: val }));
|
||||
savePatchbayState();
|
||||
}
|
||||
|
||||
export function setAutoDisconnect(val: boolean) {
|
||||
patchbay.update(pb => ({ ...pb, auto_disconnect: val }));
|
||||
savePatchbayState();
|
||||
}
|
||||
|
||||
export async function saveProfile(name: string) {
|
||||
const currentLinks = get_store_value(links);
|
||||
const currentPorts = get_store_value(ports);
|
||||
const currentNodes = get_store_value(nodes);
|
||||
const pb = get_store_value(patchbay);
|
||||
|
||||
const portByIdMap = new Map<number, Port>();
|
||||
for (const p of currentPorts) portByIdMap.set(p.id, p);
|
||||
const nodeByIdMap = new Map<number, Node>();
|
||||
for (const n of currentNodes) nodeByIdMap.set(n.id, n);
|
||||
|
||||
const connections: ConnectionRule[] = [];
|
||||
for (const link of currentLinks) {
|
||||
const outPort = portByIdMap.get(link.output_port_id);
|
||||
const inPort = portByIdMap.get(link.input_port_id);
|
||||
if (!outPort || !inPort) continue;
|
||||
const outNode = nodeByIdMap.get(outPort.node_id);
|
||||
const inNode = nodeByIdMap.get(inPort.node_id);
|
||||
if (!outNode || !inNode) continue;
|
||||
connections.push({
|
||||
output_node_name: outNode.name,
|
||||
output_port_name: outPort.name,
|
||||
output_port_type: outPort.port_type,
|
||||
input_node_name: inNode.name,
|
||||
input_port_name: inPort.name,
|
||||
input_port_type: inPort.port_type,
|
||||
pinned: pb.pinned_connections.includes(link.id),
|
||||
});
|
||||
}
|
||||
|
||||
const profile: PatchbayProfile = {
|
||||
name,
|
||||
connections,
|
||||
hide_rules: [...pb.hide_rules],
|
||||
merge_rules: [...pb.merge_rules],
|
||||
};
|
||||
|
||||
patchbay.update(pb => ({
|
||||
...pb,
|
||||
profiles: { ...pb.profiles, [name]: profile },
|
||||
active_profile: name,
|
||||
}));
|
||||
savePatchbayState();
|
||||
}
|
||||
|
||||
export function loadProfile(name: string) {
|
||||
patchbay.update(pb => ({ ...pb, active_profile: name }));
|
||||
const pb = get_store_value(patchbay);
|
||||
if (pb.activated) {
|
||||
applyPatchbay(pb);
|
||||
}
|
||||
savePatchbayState();
|
||||
}
|
||||
|
||||
export function deleteProfile(name: string) {
|
||||
patchbay.update(pb => {
|
||||
const profiles = { ...pb.profiles };
|
||||
delete profiles[name];
|
||||
return {
|
||||
...pb,
|
||||
profiles,
|
||||
active_profile: pb.active_profile === name ? '' : pb.active_profile,
|
||||
};
|
||||
});
|
||||
savePatchbayState();
|
||||
}
|
||||
|
||||
export { connectPorts, disconnectPorts };
|
||||
|
||||
Reference in New Issue
Block a user