Fix blank screen: rename stores to .svelte.ts for rune support
All checks were successful
Release / Bump version and tag (push) Successful in 7s
All checks were successful
Release / Bump version and tag (push) Successful in 7s
Svelte 5 runes ($state, $derived, $effect) are only compiled in .svelte and .svelte.ts files. The stores used runes in plain .ts files, which meant $state was treated as an undefined function at runtime, crashing the JS before anything rendered. - Renamed backend.ts -> backend.svelte.ts - Renamed config.ts -> config.svelte.ts - Renamed transcriptions.ts -> transcriptions.svelte.ts - Added .svelte.ts to Vite resolve extensions - Added missing obsUrl/syncUrl getters to backend store Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
274
src/lib/stores/backend.svelte.ts
Normal file
274
src/lib/stores/backend.svelte.ts
Normal file
@@ -0,0 +1,274 @@
|
||||
/**
|
||||
* Backend store - manages WebSocket connection and REST API communication
|
||||
* with the Python backend server running on localhost.
|
||||
*
|
||||
* The backend port defaults to 8081 but can be updated at runtime via
|
||||
* `setPort()`. The WebSocket connects to /ws/control for real-time push
|
||||
* of transcriptions, previews, and state changes.
|
||||
*/
|
||||
|
||||
export type ConnectionState = "connecting" | "connected" | "disconnected" | "error";
|
||||
export type AppState = "initializing" | "ready" | "transcribing" | "reloading" | "error";
|
||||
|
||||
interface BackendState {
|
||||
port: number;
|
||||
connectionState: ConnectionState;
|
||||
appState: AppState;
|
||||
stateMessage: string;
|
||||
deviceInfo: string;
|
||||
wsConnection: WebSocket | null;
|
||||
version: string;
|
||||
lastError: string;
|
||||
}
|
||||
|
||||
let state = $state<BackendState>({
|
||||
port: 8081,
|
||||
connectionState: "disconnected",
|
||||
appState: "initializing",
|
||||
stateMessage: "Connecting to backend...",
|
||||
deviceInfo: "",
|
||||
wsConnection: null,
|
||||
version: "1.4.0",
|
||||
lastError: "",
|
||||
});
|
||||
|
||||
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let reconnectAttempts = 0;
|
||||
const MAX_RECONNECT_DELAY_MS = 30_000;
|
||||
const BASE_RECONNECT_DELAY_MS = 1_000;
|
||||
|
||||
// ── URL helpers ──────────────────────────────────────────────────────
|
||||
|
||||
function apiUrl(path: string): string {
|
||||
const normalised = path.startsWith("/") ? path : `/${path}`;
|
||||
return `http://localhost:${state.port}${normalised}`;
|
||||
}
|
||||
|
||||
async function apiFetch(path: string, options?: RequestInit): Promise<Response> {
|
||||
const url = apiUrl(path);
|
||||
const method = options?.method?.toUpperCase() ?? "GET";
|
||||
const headers = new Headers(options?.headers);
|
||||
if (method !== "GET" && !headers.has("Content-Type")) {
|
||||
headers.set("Content-Type", "application/json");
|
||||
}
|
||||
return fetch(url, { ...options, headers });
|
||||
}
|
||||
|
||||
// ── WebSocket management ─────────────────────────────────────────────
|
||||
|
||||
function connectWebSocket() {
|
||||
// Tear down any existing connection
|
||||
disconnect();
|
||||
|
||||
state.connectionState = "connecting";
|
||||
reconnectAttempts = 0;
|
||||
|
||||
_openSocket();
|
||||
}
|
||||
|
||||
function _openSocket() {
|
||||
const wsUrl = `ws://localhost:${state.port}/ws/control`;
|
||||
|
||||
try {
|
||||
const ws = new WebSocket(wsUrl);
|
||||
|
||||
ws.onopen = () => {
|
||||
state.connectionState = "connected";
|
||||
state.lastError = "";
|
||||
reconnectAttempts = 0;
|
||||
if (reconnectTimer) {
|
||||
clearTimeout(reconnectTimer);
|
||||
reconnectTimer = null;
|
||||
}
|
||||
};
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
handleWebSocketMessage(data);
|
||||
} catch {
|
||||
// ignore parse errors
|
||||
}
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
state.wsConnection = null;
|
||||
if (state.connectionState !== "disconnected") {
|
||||
state.connectionState = "error";
|
||||
state.stateMessage = "Disconnected from backend";
|
||||
_scheduleReconnect();
|
||||
}
|
||||
};
|
||||
|
||||
ws.onerror = () => {
|
||||
state.lastError = "WebSocket error";
|
||||
// onclose fires after this, which handles reconnect
|
||||
};
|
||||
|
||||
state.wsConnection = ws;
|
||||
} catch {
|
||||
state.connectionState = "error";
|
||||
state.stateMessage = "Failed to connect";
|
||||
_scheduleReconnect();
|
||||
}
|
||||
}
|
||||
|
||||
function _scheduleReconnect() {
|
||||
if (reconnectTimer) return;
|
||||
|
||||
const delay = Math.min(
|
||||
BASE_RECONNECT_DELAY_MS * Math.pow(2, reconnectAttempts),
|
||||
MAX_RECONNECT_DELAY_MS,
|
||||
);
|
||||
reconnectAttempts++;
|
||||
|
||||
reconnectTimer = setTimeout(() => {
|
||||
reconnectTimer = null;
|
||||
if (state.connectionState !== "disconnected") {
|
||||
state.connectionState = "connecting";
|
||||
_openSocket();
|
||||
}
|
||||
}, delay);
|
||||
}
|
||||
|
||||
function disconnect() {
|
||||
if (reconnectTimer) {
|
||||
clearTimeout(reconnectTimer);
|
||||
reconnectTimer = null;
|
||||
}
|
||||
state.connectionState = "disconnected";
|
||||
if (state.wsConnection) {
|
||||
const ws = state.wsConnection;
|
||||
ws.onclose = null;
|
||||
ws.onerror = null;
|
||||
ws.close();
|
||||
state.wsConnection = null;
|
||||
}
|
||||
}
|
||||
|
||||
// ── WebSocket message handling ───────────────────────────────────────
|
||||
|
||||
function handleWebSocketMessage(data: Record<string, unknown>) {
|
||||
// Handle state changes locally
|
||||
if (data.type === "state_changed") {
|
||||
if (data.state) {
|
||||
state.appState = data.state as AppState;
|
||||
}
|
||||
if (data.message) {
|
||||
state.stateMessage = data.message as string;
|
||||
}
|
||||
}
|
||||
|
||||
if (data.type === "error") {
|
||||
state.lastError = (data.message as string) ?? "Unknown error";
|
||||
}
|
||||
|
||||
// Dispatch to window for other stores (transcriptions, etc.)
|
||||
if (data.type === "transcription") {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("backend:transcription", { detail: data })
|
||||
);
|
||||
} else if (data.type === "preview") {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("backend:preview", { detail: data })
|
||||
);
|
||||
} else if (data.type === "credits_low") {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("backend:credits_low", { detail: data })
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Port management ──────────────────────────────────────────────────
|
||||
|
||||
function setPort(newPort: number) {
|
||||
if (newPort === state.port) return;
|
||||
state.port = newPort;
|
||||
// Reconnect with new port if we had a connection
|
||||
if (state.connectionState !== "disconnected") {
|
||||
connectWebSocket();
|
||||
}
|
||||
}
|
||||
|
||||
// ── Typed REST helpers ───────────────────────────────────────────────
|
||||
|
||||
async function apiGet<T = unknown>(path: string): Promise<T> {
|
||||
const resp = await apiFetch(path);
|
||||
if (!resp.ok) throw new Error(`GET ${path} failed: ${resp.status}`);
|
||||
return resp.json();
|
||||
}
|
||||
|
||||
async function apiPost<T = unknown>(
|
||||
path: string,
|
||||
body?: unknown
|
||||
): Promise<T> {
|
||||
const resp = await apiFetch(path, {
|
||||
method: "POST",
|
||||
body: body !== undefined ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
if (!resp.ok) throw new Error(`POST ${path} failed: ${resp.status}`);
|
||||
return resp.json();
|
||||
}
|
||||
|
||||
async function apiPut<T = unknown>(
|
||||
path: string,
|
||||
body?: unknown
|
||||
): Promise<T> {
|
||||
const resp = await apiFetch(path, {
|
||||
method: "PUT",
|
||||
body: body !== undefined ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
if (!resp.ok) throw new Error(`PUT ${path} failed: ${resp.status}`);
|
||||
return resp.json();
|
||||
}
|
||||
|
||||
// ── Public API ───────────────────────────────────────────────────────
|
||||
|
||||
export const backendStore = {
|
||||
get port() {
|
||||
return state.port;
|
||||
},
|
||||
get connectionState() {
|
||||
return state.connectionState;
|
||||
},
|
||||
get connected() {
|
||||
return state.connectionState === "connected";
|
||||
},
|
||||
get appState() {
|
||||
return state.appState;
|
||||
},
|
||||
get stateMessage() {
|
||||
return state.stateMessage;
|
||||
},
|
||||
get deviceInfo() {
|
||||
return state.deviceInfo;
|
||||
},
|
||||
get version() {
|
||||
return state.version;
|
||||
},
|
||||
get lastError() {
|
||||
return state.lastError;
|
||||
},
|
||||
get apiBaseUrl() {
|
||||
return `http://localhost:${state.port}`;
|
||||
},
|
||||
get wsUrl() {
|
||||
return `ws://localhost:${state.port}/ws/control`;
|
||||
},
|
||||
get obsUrl() {
|
||||
// OBS display runs on the web server port (one below the API port)
|
||||
const obsPort = state.port > 0 ? state.port - 1 : 8080;
|
||||
return `http://localhost:${obsPort}`;
|
||||
},
|
||||
get syncUrl() {
|
||||
return "";
|
||||
},
|
||||
setPort,
|
||||
connect: connectWebSocket,
|
||||
disconnect,
|
||||
apiUrl,
|
||||
apiFetch,
|
||||
apiGet,
|
||||
apiPost,
|
||||
apiPut,
|
||||
};
|
||||
Reference in New Issue
Block a user