Files
pwweb/frontend/src/lib/stores.ts
joren db48781221 fix: optimistically update nodes store when loading profile volumes
setNodeVolume/setNodeMute only send API requests; the nodes store
wasn't updated until the backend broadcast a graph change, so sliders
showed stale values. Now the store is updated immediately before
firing the API calls.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-02 19:16:55 +02:00

515 lines
15 KiB
TypeScript

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
export const nodes = writable<Node[]>([]);
export const ports = writable<Port[]>([]);
export const links = writable<Link[]>([]);
export const connected = writable(false);
// 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: [],
aliases: {},
});
// 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;
});
export const nodeById = derived(nodes, ($nodes) => {
const map = new Map<number, Node>();
for (const n of $nodes) map.set(n.id, n);
return map;
});
// SSE init
let unsubscribe: (() => void) | null = null;
function applyGraph(graph: GraphMessage) {
nodes.set(graph.nodes);
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;
// 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, aliases: data.aliases ?? {} } as PatchbayState);
}
}
} catch {}
// Fetch initial graph
try {
const res = await fetch('/api/graph');
if (res.ok) {
applyGraph(await res.json());
}
} catch (e) {
console.warn('[graph] REST fetch failed:', e);
}
unsubscribe = subscribe((graph: GraphMessage) => {
applyGraph(graph);
});
}
export function destroyGraph() {
if (unsubscribe) {
unsubscribe();
unsubscribe = null;
connected.set(false);
}
}
// 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 volumes: Record<string, number> = {};
const mutes: Record<string, boolean> = {};
for (const n of currentNodes) {
volumes[n.name] = n.volume;
mutes[n.name] = n.mute;
}
const profile: PatchbayProfile = {
name,
connections,
hide_rules: [...pb.hide_rules],
merge_rules: [...pb.merge_rules],
volumes,
mutes,
};
patchbay.update(pb => ({
...pb,
profiles: { ...pb.profiles, [name]: profile },
active_profile: name,
}));
savePatchbayState();
}
async function applyProfileVolumes(profile: PatchbayProfile) {
if (!profile.volumes && !profile.mutes) return;
const currentNodes = get_store_value(nodes);
// Optimistically update store so sliders reflect new values immediately
nodes.update(ns => ns.map(n => {
const vol = profile.volumes?.[n.name];
const mute = profile.mutes?.[n.name];
if (vol !== undefined || mute !== undefined) {
return { ...n, ...(vol !== undefined ? { volume: vol } : {}), ...(mute !== undefined ? { mute } : {}) };
}
return n;
}));
for (const n of currentNodes) {
if (profile.volumes?.[n.name] !== undefined) {
await setNodeVolume(n.id, profile.volumes[n.name]);
}
if (profile.mutes?.[n.name] !== undefined) {
await setNodeMute(n.id, profile.mutes[n.name]);
}
}
}
export function loadProfile(name: string) {
patchbay.update(pb => ({ ...pb, active_profile: name }));
const pb = get_store_value(patchbay);
// Always apply connections when explicitly loading a profile
applyPatchbay(pb);
const profile = pb.profiles[name];
if (profile) applyProfileVolumes(profile);
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();
}
// Node aliases (custom display names, keyed by PW node name)
export function setAlias(pwName: string, alias: string) {
patchbay.update(pb => {
const aliases = { ...pb.aliases };
if (alias.trim()) {
aliases[pwName] = alias.trim();
} else {
delete aliases[pwName]; // empty string = remove alias
}
return { ...pb, aliases };
});
savePatchbayState();
}
// Volume control
export async function setNodeVolume(nodeId: number, volume: number) {
try {
await fetch('/api/volume', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ node_id: nodeId, volume }),
});
} catch (e) {
console.error('[api] volume failed:', e);
}
}
export async function setNodeMute(nodeId: number, mute: boolean) {
try {
await fetch('/api/mute', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ node_id: nodeId, mute }),
});
} catch (e) {
console.error('[api] mute failed:', e);
}
}
// Virtual devices
export async function createNullSink(name: string): Promise<number | null> {
try {
const res = await fetch('/api/create-null-sink', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name }),
});
const data = await res.json();
return data.module_id > 0 ? data.module_id : null;
} catch (e) {
console.error('[api] create-null-sink failed:', e);
return null;
}
}
export async function createLoopback(name: string): Promise<number | null> {
try {
const res = await fetch('/api/create-loopback', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name }),
});
const data = await res.json();
return data.module_id > 0 ? data.module_id : null;
} catch (e) {
console.error('[api] create-loopback failed:', e);
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) {
try {
await fetch('/api/unload-module', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ module_id: moduleId }),
});
} catch (e) {
console.error('[api] unload-module failed:', e);
}
}
// Quantum (buffer size) control
export async function getQuantum(): Promise<number> {
try {
const res = await fetch('/api/quantum');
const data = await res.json();
return data.quantum || 0;
} catch { return 0; }
}
export async function setQuantum(quantum: number) {
try {
await fetch('/api/quantum', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ quantum }),
});
} catch (e) {
console.error('[api] set-quantum failed:', e);
}
}
export { connectPorts, disconnectPorts };