935 lines
37 KiB
Svelte
935 lines
37 KiB
Svelte
<script lang="ts">
|
|
import { onMount, onDestroy } from 'svelte';
|
|
import {
|
|
nodes, ports, links, connected, patchbay,
|
|
portById,
|
|
initGraph, destroyGraph,
|
|
connectPorts, disconnectPorts,
|
|
togglePin,
|
|
addHideRule, removeHideRule,
|
|
addMergeRule, removeMergeRule,
|
|
setActivated, setExclusive,
|
|
setAutoPin, setAutoDisconnect,
|
|
saveProfile, loadProfile, deleteProfile,
|
|
setNodeVolume, setNodeMute,
|
|
createNullSink, createLoopback, loadModule,
|
|
} from '../lib/stores';
|
|
import type { Node, Port, Link } from '../lib/types';
|
|
|
|
// Viewport
|
|
let viewBox = $state({ x: -100, y: -40, w: 1200, h: 700 });
|
|
let svgEl: SVGElement | null = null;
|
|
|
|
// Interaction
|
|
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 contextMenu = $state<{ x: number; y: number; linkId: number; outputPortId: number; inputPortId: number; pinned: boolean } | null>(null);
|
|
let nodeContextMenu = $state<{ x: number; y: number; nodeId: number; nodeName: string } | null>(null);
|
|
|
|
// Filters
|
|
let showAudio = $state(true);
|
|
let showMidi = $state(true);
|
|
let showVideo = $state(true);
|
|
let showOther = $state(true);
|
|
|
|
// Dialogs
|
|
let showHideDialog = $state(false);
|
|
let showMergeDialog = $state(false);
|
|
let showProfileDialog = $state(false);
|
|
let showRuleDialog = $state(false);
|
|
let showVirtualMenu = $state(false);
|
|
let showNetworkDialog = $state<{ type: string } | null>(null);
|
|
let netHost = $state('127.0.0.1');
|
|
let netPort = $state('4713');
|
|
let newHideRule = $state('');
|
|
let newMergeRule = $state('');
|
|
let newProfileName = $state('');
|
|
|
|
// Positions
|
|
let nodePositions = $state<Record<string, { x: number; y: number }>>({});
|
|
const POS_KEY = 'pwweb_positions';
|
|
|
|
function loadPositions() {
|
|
try { nodePositions = JSON.parse(localStorage.getItem(POS_KEY) || '{}'); } catch { nodePositions = {}; }
|
|
fetch('/api/positions').then(r => r.json()).then(data => {
|
|
if (data && typeof data === 'object' && !data.error) {
|
|
nodePositions = { ...data, ...nodePositions };
|
|
}
|
|
}).catch(() => {});
|
|
}
|
|
|
|
function savePositions() {
|
|
try { localStorage.setItem(POS_KEY, JSON.stringify(nodePositions)); } catch {}
|
|
fetch('/api/positions', {
|
|
method: 'PUT', headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(nodePositions),
|
|
}).catch(() => {});
|
|
}
|
|
|
|
// Helpers
|
|
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;
|
|
}
|
|
|
|
function isPortVisible(pt: string): boolean {
|
|
switch (pt) {
|
|
case 'audio': return showAudio;
|
|
case 'midi': return showMidi;
|
|
case 'video': return showVideo;
|
|
default: return showOther;
|
|
}
|
|
}
|
|
|
|
function bezierPath(x1: number, y1: number, x2: number, y2: number): string {
|
|
const dx = Math.max(Math.abs(x2 - x1) * 0.5, 30);
|
|
return `M ${x1} ${y1} C ${x1 + dx} ${y1}, ${x2 - dx} ${y2}, ${x2} ${y2}`;
|
|
}
|
|
|
|
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 };
|
|
return pt.matrixTransform(ctm.inverse());
|
|
}
|
|
|
|
function isNodeHidden(nodeName: string): boolean {
|
|
for (const rule of $patchbay.hide_rules) {
|
|
try {
|
|
if (new RegExp(rule, 'i').test(nodeName)) return true;
|
|
} catch {
|
|
if (nodeName.toLowerCase().includes(rule.toLowerCase())) return true;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
function getMergedName(nodeName: string): string {
|
|
for (const prefix of $patchbay.merge_rules) {
|
|
if (nodeName.startsWith(prefix)) return prefix;
|
|
}
|
|
return nodeName;
|
|
}
|
|
|
|
// Build computed layout
|
|
let graphNodes = $derived.by(() => {
|
|
const n = $nodes;
|
|
const p = $ports;
|
|
const pb = $patchbay;
|
|
const portMap = new Map<number, Port>();
|
|
for (const port of p) portMap.set(port.id, port);
|
|
|
|
// Filter hidden nodes
|
|
let visible = n.filter(nd => !isNodeHidden(nd.name));
|
|
|
|
// Merge nodes by prefix
|
|
const merged = new Map<string, typeof visible[0]>();
|
|
for (const nd of visible) {
|
|
const mergedName = getMergedName(nd.name);
|
|
const existing = merged.get(mergedName);
|
|
if (existing) {
|
|
// Merge port IDs
|
|
existing.port_ids = [...new Set([...existing.port_ids, ...nd.port_ids])];
|
|
existing.name = mergedName;
|
|
} else {
|
|
merged.set(mergedName, { ...nd, name: mergedName });
|
|
}
|
|
}
|
|
visible = Array.from(merged.values());
|
|
|
|
const out = visible.filter(nd => nd.mode === 'output');
|
|
const inp = visible.filter(nd => nd.mode === 'input');
|
|
const other = visible.filter(nd => nd.mode !== 'output' && nd.mode !== 'input');
|
|
|
|
const defaults: Record<string, { x: number; y: number }> = {};
|
|
let i = 0;
|
|
for (const nd of out) { defaults[String(nd.id)] = { x: 0, y: (i % 10) * 90 }; i++; }
|
|
i = 0;
|
|
for (const nd of other) { defaults[String(nd.id)] = { x: 280, y: (i % 10) * 90 }; i++; }
|
|
i = 0;
|
|
for (const nd of inp) { defaults[String(nd.id)] = { x: 560, y: (i % 10) * 90 }; i++; }
|
|
|
|
const pos = { ...defaults, ...nodePositions };
|
|
|
|
return visible.map(nd => {
|
|
const ndPorts = nd.port_ids.map(pid => portMap.get(pid)).filter(Boolean) as Port[];
|
|
const allInPorts = ndPorts.filter(pp => pp.mode === 'input');
|
|
const allOutPorts = ndPorts.filter(pp => pp.mode === 'output');
|
|
|
|
const nodePos = pos[String(nd.id)] || { x: 0, y: 0 };
|
|
const headerH = 22;
|
|
const portH = 16;
|
|
const maxPorts = Math.max(allInPorts.length, allOutPorts.length, 1);
|
|
const height = headerH + maxPorts * portH + 20; // extra 20 for volume slider
|
|
const width = 220;
|
|
|
|
const portPositions = new Map<number, { x: number; y: number }>();
|
|
let yi = headerH;
|
|
for (const port of allInPorts) {
|
|
portPositions.set(port.id, { x: nodePos.x, y: nodePos.y + yi + portH / 2 });
|
|
yi += portH;
|
|
}
|
|
let yo = headerH;
|
|
for (const port of allOutPorts) {
|
|
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: allInPorts, outPorts: allOutPorts, portPositions };
|
|
});
|
|
});
|
|
|
|
let graphLinks = $derived.by(() => {
|
|
const l = $links;
|
|
const p = $ports;
|
|
const pb = $patchbay;
|
|
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);
|
|
if (!outPort || !inPort) return null;
|
|
// Check if both endpoint nodes are visible
|
|
const outNode = $nodes.find(n => n.id === outPort.node_id);
|
|
const inNode = $nodes.find(n => n.id === inPort.node_id);
|
|
if (outNode && isNodeHidden(outNode.name)) return null;
|
|
if (inNode && isNodeHidden(inNode.name)) return null;
|
|
const pinned = pb.pinned_connections.includes(link.id);
|
|
return { ...link, outPort, inPort, pinned };
|
|
}).filter(Boolean) as Array<Link & { outPort: Port; inPort: Port; pinned: boolean }>;
|
|
});
|
|
|
|
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;
|
|
}
|
|
|
|
// Volume drag state
|
|
let volumeDragging = $state<{ nodeId: number } | null>(null);
|
|
|
|
function applyVolumeAtMouse(e: MouseEvent, nodeId: number) {
|
|
const nd = graphNodes.find(n => n.id === nodeId);
|
|
if (!nd || !svgEl) return;
|
|
const volX = nd.x + 8;
|
|
const volW = nd.width - 36;
|
|
const svgRect = svgEl.getBoundingClientRect();
|
|
// Convert mouse X to SVG coordinate
|
|
const mouseSvgX = viewBox.x + (e.clientX - svgRect.left) * viewBox.w / svgRect.width;
|
|
const ratio = (mouseSvgX - volX) / volW;
|
|
const clamped = Math.max(0, Math.min(1, ratio));
|
|
setNodeVolume(nodeId, clamped);
|
|
}
|
|
|
|
// Mouse handlers
|
|
function onMouseDown(e: MouseEvent) {
|
|
// Ignore clicks outside the SVG (toolbar, dialogs, etc.)
|
|
if (svgEl && !svgEl.contains(e.target as Node)) return;
|
|
|
|
contextMenu = null;
|
|
nodeContextMenu = null;
|
|
showVirtualMenu = false;
|
|
if (e.button === 2) return;
|
|
const pt = svgPoint(e);
|
|
const target = e.target as HTMLElement;
|
|
|
|
// Volume slider - high priority
|
|
if (target.classList.contains('vol-handle') || target.classList.contains('vol-hitarea')) {
|
|
const nodeEl = target.closest('.node-group');
|
|
if (nodeEl) {
|
|
const nodeId = Number((nodeEl as HTMLElement).dataset.nodeId);
|
|
volumeDragging = { nodeId };
|
|
// Immediately apply volume at click position
|
|
applyVolumeAtMouse(e, nodeId);
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Mute button
|
|
if (target.classList.contains('mute-btn') || target.classList.contains('mute-text')) {
|
|
const nodeEl = target.closest('.node-group');
|
|
if (nodeEl) {
|
|
const nodeId = Number((nodeEl as HTMLElement).dataset.nodeId);
|
|
const nd = graphNodes.find(n => n.id === nodeId);
|
|
if (nd) setNodeMute(nodeId, !nd.mute);
|
|
}
|
|
return;
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
if (target.closest('.node-group')) {
|
|
const nodeGroup = target.closest('.node-group') as HTMLElement;
|
|
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;
|
|
}
|
|
|
|
if (target.classList.contains('edge-path')) {
|
|
selectedEdge = target.dataset.edgeId || null;
|
|
return;
|
|
}
|
|
|
|
selectedEdge = null;
|
|
dragging = { type: 'pan', startX: e.clientX, startY: e.clientY, origX: viewBox.x, origY: viewBox.y };
|
|
}
|
|
|
|
function onMouseMove(e: MouseEvent) {
|
|
// Volume dragging takes priority
|
|
if (volumeDragging) {
|
|
applyVolumeAtMouse(e, volumeDragging.nodeId);
|
|
return;
|
|
}
|
|
if (!dragging && !connecting) return;
|
|
const pt = svgPoint(e);
|
|
if (connecting) {
|
|
connecting = { ...connecting, mouseX: pt.x, 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 };
|
|
} 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 = { ...viewBox, x: dragging.origX - dx, y: dragging.origY - dy };
|
|
}
|
|
}
|
|
|
|
function onMouseUp(e: MouseEvent) {
|
|
if (volumeDragging) {
|
|
volumeDragging = null;
|
|
return;
|
|
}
|
|
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) savePositions();
|
|
dragging = null;
|
|
}
|
|
|
|
function onWheel(e: WheelEvent) {
|
|
e.preventDefault();
|
|
const pt = svgPoint(e);
|
|
const factor = e.deltaY > 0 ? 1.1 : 0.9;
|
|
viewBox = {
|
|
x: pt.x - (pt.x - viewBox.x) * factor,
|
|
y: pt.y - (pt.y - viewBox.y) * factor,
|
|
w: viewBox.w * factor,
|
|
h: viewBox.h * factor,
|
|
};
|
|
}
|
|
|
|
function onContextMenu(e: MouseEvent) {
|
|
e.preventDefault();
|
|
const target = e.target as HTMLElement;
|
|
|
|
// Right-click on edge
|
|
if (target.classList.contains('edge-path')) {
|
|
const edgeId = target.dataset.edgeId;
|
|
const link = $links.find(l => String(l.id) === edgeId);
|
|
if (link) {
|
|
selectedEdge = edgeId || null;
|
|
contextMenu = {
|
|
x: e.clientX, y: e.clientY,
|
|
linkId: link.id,
|
|
outputPortId: link.output_port_id,
|
|
inputPortId: link.input_port_id,
|
|
pinned: $patchbay.pinned_connections.includes(link.id),
|
|
};
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Right-click on node
|
|
const nodeGroup = target.closest('.node-group') as HTMLElement;
|
|
if (nodeGroup) {
|
|
const nodeId = Number(nodeGroup.dataset.nodeId);
|
|
const nd = $nodes.find(n => n.id === nodeId);
|
|
if (nd) {
|
|
nodeContextMenu = { x: e.clientX, y: e.clientY, nodeId, nodeName: nd.name };
|
|
}
|
|
}
|
|
}
|
|
|
|
function unloadNodeModule() {
|
|
if (!nodeContextMenu) return;
|
|
// Try to find and unload the module that created this node
|
|
// Use pactl to unload by looking up the module
|
|
fetch('/api/load-module', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ module: 'module-null-sink', args: '' }),
|
|
}).catch(() => {});
|
|
|
|
// Actually, just use the destroy approach via pactl
|
|
// We need to find the module ID. Let's use a different endpoint.
|
|
// For now, use pw-cli to destroy the node
|
|
const name = nodeContextMenu.nodeName;
|
|
fetch('/api/destroy-node', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ node_id: nodeContextMenu.nodeId }),
|
|
}).catch(() => {});
|
|
nodeContextMenu = null;
|
|
}
|
|
|
|
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(); loadPositions(); });
|
|
onDestroy(() => { destroyGraph(); });
|
|
</script>
|
|
|
|
<svelte:window onkeydown={onKey} onclick={() => { contextMenu = null; nodeContextMenu = null; }} />
|
|
|
|
<div class="wrap">
|
|
<div class="toolbar">
|
|
<span class="dot" class:on={$connected}></span>
|
|
<span>{$connected ? 'Connected' : 'Disconnected'}</span>
|
|
<span class="sep"></span>
|
|
|
|
<!-- Port type filters -->
|
|
<label class="filter" class:active={showAudio}><input type="checkbox" bind:checked={showAudio} /> <span class="fc audio"></span> Audio</label>
|
|
<label class="filter" class:active={showMidi}><input type="checkbox" bind:checked={showMidi} /> <span class="fc midi"></span> MIDI</label>
|
|
<label class="filter" class:active={showVideo}><input type="checkbox" bind:checked={showVideo} /> <span class="fc video"></span> Video</label>
|
|
<label class="filter" class:active={showOther}><input type="checkbox" bind:checked={showOther} /> <span class="fc other"></span> Other</label>
|
|
<span class="sep"></span>
|
|
|
|
<!-- Patchbay toggles -->
|
|
<button class="toggle" class:active={$patchbay.activated} onclick={() => setActivated(!$patchbay.activated)} title="Activated Mode: auto-restore connections">
|
|
{$patchbay.activated ? 'Active' : 'Inactive'}
|
|
</button>
|
|
<button class="toggle" class:active={$patchbay.exclusive} onclick={() => setExclusive(!$patchbay.exclusive)} title="Exclusive Mode: disconnect unprofiled links">
|
|
{$patchbay.exclusive ? 'Exclusive' : 'Permissive'}
|
|
</button>
|
|
<button class="toggle" class:active={$patchbay.auto_pin} onclick={() => setAutoPin(!$patchbay.auto_pin)} title="Auto-Pin new connections">
|
|
{$patchbay.auto_pin ? 'Auto-Pin ON' : 'Auto-Pin OFF'}
|
|
</button>
|
|
<button class="toggle" class:active={$patchbay.auto_disconnect} onclick={() => setAutoDisconnect(!$patchbay.auto_disconnect)} title="Auto-Disconnect pinned on deactivate">
|
|
{$patchbay.auto_disconnect ? 'Auto-Disc ON' : 'Auto-Disc OFF'}
|
|
</button>
|
|
<span class="sep"></span>
|
|
|
|
<!-- Dialogs -->
|
|
<button onclick={() => { showHideDialog = !showHideDialog; showMergeDialog = false; showProfileDialog = false; showRuleDialog = false; }} title="Node hiding rules">Hide Nodes</button>
|
|
<button onclick={() => { showMergeDialog = !showMergeDialog; showHideDialog = false; showProfileDialog = false; showRuleDialog = false; }} title="Node merging rules">Merge Nodes</button>
|
|
<button onclick={() => { showRuleDialog = !showRuleDialog; showHideDialog = false; showMergeDialog = false; showProfileDialog = false; }} title="Manage patchbay rules">Rules</button>
|
|
<button onclick={() => { showProfileDialog = !showProfileDialog; showHideDialog = false; showMergeDialog = false; showRuleDialog = false; }} title="Save/load profiles">Profiles</button>
|
|
<button onclick={() => { showVirtualMenu = !showVirtualMenu; showHideDialog = false; showMergeDialog = false; showProfileDialog = false; showRuleDialog = false; }} title="Add virtual device">+ Add</button>
|
|
|
|
<span class="stats">{$nodes.length}N {$ports.length}P {$links.length}L {#if $patchbay.pinned_connections.length > 0}{$patchbay.pinned_connections.length}p{/if}</span>
|
|
</div>
|
|
|
|
<!-- Virtual device dropdown -->
|
|
{#if showVirtualMenu}
|
|
<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 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=' + netPort); }}>TCP Network Server (port {netPort})</button>
|
|
<button onclick={async () => { showVirtualMenu = false; showNetworkDialog = { type: 'tunnel-sink' }; }}>TCP Tunnel Sink...</button>
|
|
<button onclick={async () => { showVirtualMenu = false; showNetworkDialog = { type: 'tunnel-source' }; }}>TCP Tunnel Source...</button>
|
|
</div>
|
|
{/if}
|
|
|
|
<!-- Network config dialog -->
|
|
{#if showNetworkDialog}
|
|
<div class="dialog" style="right:auto;left:50%;top:50%;transform:translate(-50%,-50%);width:300px">
|
|
<div class="dialog-header">
|
|
<span>{showNetworkDialog.type === 'tunnel-sink' ? 'TCP Tunnel Sink' : 'TCP Tunnel Source'}</span>
|
|
<button class="close" onclick={() => { showNetworkDialog = null; }}>X</button>
|
|
</div>
|
|
<div class="dialog-body">
|
|
<div class="input-row">
|
|
<span style="font-size:10px;color:#888;width:50px">Host:</span>
|
|
<input class="dlg-input" bind:value={netHost} placeholder="127.0.0.1" />
|
|
</div>
|
|
<div class="input-row">
|
|
<span style="font-size:10px;color:#888;width:50px">Port:</span>
|
|
<input class="dlg-input" bind:value={netPort} placeholder="4713" />
|
|
</div>
|
|
<div class="input-row" style="justify-content:flex-end">
|
|
<button onclick={() => { showNetworkDialog = null; }}>Cancel</button>
|
|
<button onclick={() => {
|
|
const mod = showNetworkDialog!.type === 'tunnel-sink' ? 'module-tunnel-sink' : 'module-tunnel-source';
|
|
showNetworkDialog = null;
|
|
loadModule(mod, 'server=tcp:' + netHost + ':' + netPort);
|
|
}}>Connect</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
|
|
<!-- 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}
|
|
oncontextmenu={onContextMenu}
|
|
class="canvas"
|
|
>
|
|
<defs>
|
|
<filter id="shadow" x="-10%" y="-10%" width="120%" height="120%">
|
|
<feDropShadow dx="1" dy="1" stdDeviation="2" flood-opacity="0.4" />
|
|
</filter>
|
|
</defs>
|
|
|
|
<!-- 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)}
|
|
{@const isSelected = selectedEdge === String(link.id)}
|
|
<path
|
|
d={bezierPath(outPos.x, outPos.y, inPos.x, inPos.y)}
|
|
stroke={isSelected ? '#fff' : color}
|
|
stroke-width={isSelected ? 3.5 : link.pinned ? 2.5 : 2}
|
|
fill="none"
|
|
class="edge-path"
|
|
data-edge-id={String(link.id)}
|
|
stroke-linecap="round"
|
|
stroke-dasharray={link.pinned ? 'none' : 'none'}
|
|
opacity={link.pinned ? 1 : 0.7}
|
|
/>
|
|
{#if link.pinned}
|
|
<circle cx={(outPos.x + inPos.x) / 2} cy={(outPos.y + inPos.y) / 2} r="3" fill="#ff0" opacity="0.8" />
|
|
{/if}
|
|
{/if}
|
|
{/each}
|
|
|
|
<!-- Connecting line -->
|
|
{#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.7"
|
|
/>
|
|
{/if}
|
|
|
|
<!-- Nodes -->
|
|
{#each graphNodes as nd (nd.id)}
|
|
{@const isSource = nd.mode === 'output'}
|
|
{@const isSink = nd.mode === 'input'}
|
|
{@const bg = isSource ? '#1a2a1a' : isSink ? '#2a1a1a' : '#1e1e2e'}
|
|
{@const border = isSource ? '#4a9' : isSink ? '#a44' : '#555'}
|
|
{@const headerBg = isSource ? '#263826' : isSink ? '#382626' : '#262638'}
|
|
|
|
<g class="node-group" data-node-id={String(nd.id)} filter="url(#shadow)">
|
|
<rect x={nd.x} y={nd.y} width={nd.width} height={nd.height} rx="4" fill={bg} stroke={border} stroke-width="1" />
|
|
<rect x={nd.x} y={nd.y} width={nd.width} height="22" rx="4" fill={headerBg} />
|
|
<rect x={nd.x} y={nd.y + 16} width={nd.width} height="6" fill={headerBg} />
|
|
<text x={nd.x + 6} y={nd.y + 15} font-size="10" font-family="monospace" fill="#ddd" font-weight="bold">
|
|
{nd.nick || nd.name}
|
|
</text>
|
|
<text x={nd.x + nd.width - 6} y={nd.y + 15} font-size="9" font-family="monospace" fill="#777" text-anchor="end">
|
|
[{nd.node_type}]
|
|
</text>
|
|
|
|
{#each nd.inPorts as port, i (port.id)}
|
|
{@const py = nd.y + 22 + i * 16 + 8}
|
|
{#if isPortVisible(port.port_type)}
|
|
<circle
|
|
cx={nd.x} cy={py} r="4"
|
|
fill={portColor(port.port_type)}
|
|
stroke={hoveredPort === port.id ? '#fff' : '#333'}
|
|
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 + 8} y={py + 3.5} font-size="9" font-family="monospace" fill="#999">{shortName(port.name)}</text>
|
|
{:else}
|
|
<circle cx={nd.x} cy={py} r="3" fill="#333" />
|
|
{/if}
|
|
{/each}
|
|
|
|
{#each nd.outPorts as port, i (port.id)}
|
|
{@const py = nd.y + 22 + i * 16 + 8}
|
|
{#if isPortVisible(port.port_type)}
|
|
<text x={nd.x + nd.width - 8} y={py + 3.5} font-size="9" font-family="monospace" fill="#999" text-anchor="end">{shortName(port.name)}</text>
|
|
<circle
|
|
cx={nd.x + nd.width} cy={py} r="4"
|
|
fill={portColor(port.port_type)}
|
|
stroke={hoveredPort === port.id ? '#fff' : '#333'}
|
|
stroke-width={hoveredPort === port.id ? 2 : 0.5}
|
|
class="port-circle"
|
|
data-port-id={String(port.id)}
|
|
onmouseenter={() => { hoveredPort = port.id; }}
|
|
onmouseleave={() => { hoveredPort = null; }}
|
|
/>
|
|
{:else}
|
|
<circle cx={nd.x + nd.width} cy={py} r="3" fill="#333" />
|
|
{/if}
|
|
{/each}
|
|
|
|
<!-- Volume slider + mute button at bottom of node -->
|
|
<rect
|
|
class="mute-btn"
|
|
x={nd.x + nd.width - 24} y={nd.y + nd.height - 17}
|
|
width="16" height="12" rx="2"
|
|
fill={nd.mute ? '#a44' : '#2a2a3e'}
|
|
stroke="#555" stroke-width="0.5"
|
|
/>
|
|
<text
|
|
class="mute-text"
|
|
x={nd.x + nd.width - 16} y={nd.y + nd.height - 8}
|
|
font-size="8" font-family="monospace"
|
|
fill={nd.mute ? '#fff' : '#888'}
|
|
text-anchor="middle"
|
|
>{nd.mute ? 'M' : 'm'}</text>
|
|
|
|
<!-- Volume bar background (visual only) -->
|
|
<rect x={nd.x + 8} y={nd.y + nd.height - 12} width={nd.width - 36} height="4" rx="2" fill="#333" />
|
|
<!-- Volume bar fill (visual only, clamped to 1.0) -->
|
|
<rect x={nd.x + 8} y={nd.y + nd.height - 12} width={(nd.width - 36) * Math.max(0, Math.min(1, nd.volume))} height="4" rx="2" fill={nd.mute ? '#666' : '#4a9'} />
|
|
<!-- Volume click/drag hit area (tall transparent rect over the whole slider) -->
|
|
<rect class="vol-hitarea" x={nd.x + 8} y={nd.y + nd.height - 18} width={nd.width - 36} height="14" fill="transparent" />
|
|
<!-- Volume handle circle -->
|
|
<circle class="vol-handle" cx={nd.x + 8 + (nd.width - 36) * Math.max(0, Math.min(1, nd.volume))} cy={nd.y + nd.height - 10} r="3.5" fill={nd.mute ? '#888' : '#6cb'} stroke="#fff" stroke-width="1" />
|
|
<!-- Volume % label -->
|
|
<text x={nd.x + 8} y={nd.y + nd.height - 15} font-size="7" font-family="monospace" fill="#888">
|
|
{Math.round(Math.max(0, Math.min(1, nd.volume)) * 100)}%
|
|
</text>
|
|
|
|
</g>
|
|
{/each}
|
|
</svg>
|
|
|
|
<!-- Context menu -->
|
|
{#if contextMenu}
|
|
<div class="ctx" style="left:{contextMenu.x}px;top:{contextMenu.y}px" role="menu">
|
|
<button onclick={() => { disconnectPorts(contextMenu!.outputPortId, contextMenu!.inputPortId); contextMenu = null; }}>Disconnect</button>
|
|
<button onclick={() => { togglePin(contextMenu!.linkId); contextMenu = null; }}>{contextMenu.pinned ? 'Unpin' : 'Pin'}</button>
|
|
</div>
|
|
{/if}
|
|
|
|
{#if nodeContextMenu}
|
|
<div class="ctx" style="left:{nodeContextMenu.x}px;top:{nodeContextMenu.y}px" role="menu">
|
|
<div class="ctx-title">{nodeContextMenu.nodeName}</div>
|
|
<button onclick={() => {
|
|
fetch('/api/destroy-node', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ node_id: nodeContextMenu!.nodeId }),
|
|
}).catch(() => {});
|
|
nodeContextMenu = null;
|
|
}}>Destroy Node</button>
|
|
</div>
|
|
{/if}
|
|
|
|
<!-- Hide Nodes Dialog -->
|
|
{#if showHideDialog}
|
|
<div class="dialog">
|
|
<div class="dialog-header">
|
|
<span>Node Hiding Rules</span>
|
|
<button class="close" onclick={() => { showHideDialog = false; }}>X</button>
|
|
</div>
|
|
<div class="dialog-body">
|
|
<p class="hint">Hide nodes matching pattern (regex or plain text).</p>
|
|
<div class="input-row">
|
|
<input bind:value={newHideRule} placeholder="e.g. Dummy|Freewheel or .*Driver.*" class="dlg-input" />
|
|
<button onclick={() => { if (newHideRule.trim()) { addHideRule(newHideRule.trim()); newHideRule = ''; } }}>Add</button>
|
|
</div>
|
|
<div class="rule-list">
|
|
{#each $patchbay.hide_rules as rule}
|
|
<div class="rule-item">
|
|
<span>{rule}</span>
|
|
<button onclick={() => removeHideRule(rule)}>Remove</button>
|
|
</div>
|
|
{/each}
|
|
{#if $patchbay.hide_rules.length === 0}
|
|
<div class="empty">No hiding rules.</div>
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
|
|
<!-- Merge Nodes Dialog -->
|
|
{#if showMergeDialog}
|
|
<div class="dialog">
|
|
<div class="dialog-header">
|
|
<span>Node Merging Rules</span>
|
|
<button class="close" onclick={() => { showMergeDialog = false; }}>X</button>
|
|
</div>
|
|
<div class="dialog-body">
|
|
<p class="hint">Merge nodes sharing a common name prefix into one block.</p>
|
|
<div class="input-row">
|
|
<input bind:value={newMergeRule} placeholder="e.g. Built-in Audio" class="dlg-input" />
|
|
<button onclick={() => { if (newMergeRule.trim()) { addMergeRule(newMergeRule.trim()); newMergeRule = ''; } }}>Add</button>
|
|
</div>
|
|
<div class="rule-list">
|
|
{#each $patchbay.merge_rules as rule}
|
|
<div class="rule-item">
|
|
<span>{rule}</span>
|
|
<button onclick={() => removeMergeRule(rule)}>Remove</button>
|
|
</div>
|
|
{/each}
|
|
{#if $patchbay.merge_rules.length === 0}
|
|
<div class="empty">No merge rules.</div>
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
|
|
<!-- Profile Dialog -->
|
|
{#if showProfileDialog}
|
|
<div class="dialog">
|
|
<div class="dialog-header">
|
|
<span>Profile Management</span>
|
|
<button class="close" onclick={() => { showProfileDialog = false; }}>X</button>
|
|
</div>
|
|
<div class="dialog-body">
|
|
<div class="input-row">
|
|
<input bind:value={newProfileName} placeholder="Profile name" class="dlg-input" />
|
|
<button onclick={() => { if (newProfileName.trim()) { saveProfile(newProfileName.trim()); } }}>Save Current</button>
|
|
</div>
|
|
<div class="rule-list">
|
|
{#each Object.entries($patchbay.profiles) as [name, profile]}
|
|
<div class="rule-item">
|
|
<span class:active-profile={name === $patchbay.active_profile}>{name} ({profile.connections.length} rules)</span>
|
|
<button onclick={() => loadProfile(name)}>Load</button>
|
|
<button onclick={() => deleteProfile(name)}>Delete</button>
|
|
</div>
|
|
{/each}
|
|
{#if Object.keys($patchbay.profiles).length === 0}
|
|
<div class="empty">No saved profiles.</div>
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
|
|
<!-- Rule Management Dialog -->
|
|
{#if showRuleDialog}
|
|
<div class="dialog">
|
|
<div class="dialog-header">
|
|
<span>Rule Management</span>
|
|
<button class="close" onclick={() => { showRuleDialog = false; }}>X</button>
|
|
</div>
|
|
<div class="dialog-body">
|
|
{#if $patchbay.active_profile && $patchbay.profiles[$patchbay.active_profile]}
|
|
{@const profile = $patchbay.profiles[$patchbay.active_profile]}
|
|
<p class="hint">Active profile: <strong>{profile.name}</strong> ({profile.connections.length} rules)</p>
|
|
<div class="rule-list scrollable">
|
|
{#each profile.connections as rule, i}
|
|
<div class="rule-item small">
|
|
<span>{rule.output_node_name}:{shortName(rule.output_port_name)} => {rule.input_node_name}:{shortName(rule.input_port_name)}</span>
|
|
<span class="type-badge {rule.output_port_type}">{rule.output_port_type}</span>
|
|
{#if rule.pinned}<span class="pin-badge">PIN</span>{/if}
|
|
</div>
|
|
{/each}
|
|
</div>
|
|
{:else}
|
|
<div class="empty">No active profile. Save a profile first via Profiles.</div>
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
|
|
<style>
|
|
.wrap { width: 100%; height: 100vh; background: #14141e; position: relative; overflow: hidden; }
|
|
.canvas { width: 100%; height: 100%; display: block; cursor: default; position: absolute; top: 0; left: 0; z-index: 1; }
|
|
|
|
.toolbar {
|
|
position: absolute; top: 0; left: 0; right: 0; z-index: 10;
|
|
display: flex; align-items: center; gap: 6px;
|
|
padding: 4px 10px;
|
|
background: rgba(16, 16, 24, 0.97);
|
|
border-bottom: 1px solid #333;
|
|
font-size: 11px; color: #aaa; font-family: monospace;
|
|
flex-wrap: wrap;
|
|
}
|
|
.dot { width: 7px; height: 7px; border-radius: 50%; background: #a44; flex-shrink: 0; }
|
|
.dot.on { background: #4a9; }
|
|
.sep { width: 1px; height: 16px; background: #444; margin: 0 2px; flex-shrink: 0; }
|
|
|
|
.filter {
|
|
display: flex; align-items: center; gap: 3px; cursor: pointer;
|
|
padding: 2px 5px; border-radius: 3px; font-size: 10px; user-select: none;
|
|
}
|
|
.filter:hover { background: rgba(255,255,255,0.05); }
|
|
.filter input { display: none; }
|
|
.filter .fc { width: 8px; height: 8px; border-radius: 2px; }
|
|
.filter .fc.audio { background: #4a9; }
|
|
.filter .fc.midi { background: #a44; }
|
|
.filter .fc.video { background: #49a; }
|
|
.filter .fc.other { background: #999; }
|
|
.filter:not(.active) { opacity: 0.4; }
|
|
.filter:not(.active) .fc { background: #555 !important; }
|
|
|
|
.toolbar button, .toggle {
|
|
padding: 2px 7px; background: #2a2a3e; border: 1px solid #444;
|
|
color: #aaa; font-size: 10px; cursor: pointer; border-radius: 3px; font-family: monospace;
|
|
white-space: nowrap;
|
|
}
|
|
.toolbar button:hover, .toggle:hover { background: #3a3a4e; }
|
|
.toggle.active { background: #1a3a2a; border-color: #4a9; color: #6c9; }
|
|
|
|
.stats { margin-left: auto; color: #555; }
|
|
|
|
/* Context menu */
|
|
.ctx {
|
|
position: fixed; z-index: 100;
|
|
background: #2a2a3e; border: 1px solid #555;
|
|
border-radius: 4px; box-shadow: 0 4px 12px rgba(0,0,0,0.6);
|
|
}
|
|
.ctx button {
|
|
display: block; width: 100%; padding: 5px 20px;
|
|
background: none; border: none; color: #ccc;
|
|
font-size: 12px; cursor: pointer; text-align: left; font-family: monospace;
|
|
}
|
|
.ctx button:hover { background: #444; }
|
|
.ctx-title {
|
|
padding: 4px 16px; font-size: 9px; color: #666;
|
|
border-bottom: 1px solid #444; font-family: monospace;
|
|
max-width: 200px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
|
}
|
|
|
|
/* Dialogs */
|
|
.dialog {
|
|
position: absolute; top: 40px; right: 10px; z-index: 20;
|
|
background: #1e1e2e; border: 1px solid #555; border-radius: 6px;
|
|
box-shadow: 0 4px 16px rgba(0,0,0,0.6);
|
|
width: 360px; max-height: 80vh; overflow: hidden;
|
|
display: flex; flex-direction: column;
|
|
font-family: monospace;
|
|
}
|
|
.dialog-header {
|
|
display: flex; justify-content: space-between; align-items: center;
|
|
padding: 8px 12px; background: #2a2a3e; border-bottom: 1px solid #444;
|
|
font-size: 12px; color: #ddd; font-weight: bold;
|
|
}
|
|
.close { background: none; border: none; color: #888; cursor: pointer; font-size: 14px; padding: 0 4px; }
|
|
.close:hover { color: #fff; }
|
|
.dialog-body { padding: 10px; overflow-y: auto; }
|
|
.hint { font-size: 10px; color: #666; margin: 0 0 8px; }
|
|
|
|
.input-row { display: flex; gap: 6px; margin-bottom: 8px; }
|
|
.dlg-input {
|
|
flex: 1; padding: 4px 8px; background: #14141e; border: 1px solid #444;
|
|
color: #ccc; font-size: 11px; border-radius: 3px; font-family: monospace;
|
|
}
|
|
.input-row button {
|
|
padding: 4px 10px; background: #2a2a3e; border: 1px solid #444;
|
|
color: #aaa; font-size: 11px; cursor: pointer; border-radius: 3px; font-family: monospace;
|
|
}
|
|
.input-row button:hover { background: #3a3a4e; }
|
|
|
|
.rule-list { display: flex; flex-direction: column; gap: 2px; }
|
|
.rule-list.scrollable { max-height: 300px; overflow-y: auto; }
|
|
.rule-item {
|
|
display: flex; align-items: center; gap: 6px;
|
|
padding: 3px 6px; background: #14141e; border-radius: 3px;
|
|
font-size: 10px; color: #aaa;
|
|
}
|
|
.rule-item span:first-child { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
.rule-item button {
|
|
padding: 1px 6px; background: #2a2a3e; border: 1px solid #444;
|
|
color: #888; font-size: 9px; cursor: pointer; border-radius: 2px; font-family: monospace;
|
|
flex-shrink: 0;
|
|
}
|
|
.rule-item button:hover { color: #f88; border-color: #a44; }
|
|
.rule-item.small { font-size: 9px; }
|
|
.active-profile { color: #6c9; font-weight: bold; }
|
|
|
|
.type-badge {
|
|
font-size: 8px; padding: 0 4px; border-radius: 2px; color: #fff;
|
|
}
|
|
.type-badge.audio { background: #4a9; }
|
|
.type-badge.midi { background: #a44; }
|
|
.type-badge.video { background: #49a; }
|
|
.pin-badge { font-size: 8px; padding: 0 3px; background: #aa4; color: #000; border-radius: 2px; }
|
|
|
|
.empty { font-size: 11px; color: #555; padding: 8px 0; text-align: center; }
|
|
|
|
.port-circle { cursor: crosshair; }
|
|
.port-circle:hover { filter: brightness(1.5); }
|
|
.edge-path { cursor: pointer; pointer-events: stroke; }
|
|
.edge-path:hover { stroke-width: 4; }
|
|
|
|
.mute-btn { cursor: pointer; }
|
|
.mute-btn:hover { filter: brightness(1.3); }
|
|
.mute-text { pointer-events: none; }
|
|
.vol-bg { pointer-events: none; }
|
|
.vol-bar { pointer-events: none; }
|
|
.vol-hitarea { cursor: pointer; }
|
|
.vol-handle { cursor: ew-resize; pointer-events: none; }
|
|
.vol-handle:hover { filter: brightness(1.3); }
|
|
.add-btn { background: #1a3a2a !important; border-color: #4a9 !important; color: #6c9 !important; }
|
|
|
|
.virt-menu {
|
|
position: absolute; top: 30px; z-index: 30;
|
|
right: 80px;
|
|
background: #2a2a3e; border: 1px solid #555;
|
|
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 {
|
|
display: block; width: 100%; padding: 6px 16px;
|
|
background: none; border: none; color: #ccc;
|
|
font-size: 11px; cursor: pointer; text-align: left; font-family: monospace;
|
|
white-space: nowrap;
|
|
}
|
|
.virt-menu button:hover { background: #444; }
|
|
|
|
</style>
|