feat(qbzd): add remote playback target store and API services

New files for desktop controller mode (Phase 5):
- playbackTargetStore.ts: local/remote mode switching
- remoteApi.ts: HTTP client for daemon API (GET/POST + error handling)
- remoteEvents.ts: SSE client for real-time daemon events

cc
This commit is contained in:
vicrodh committed 2026-04-09 04:09:24 -06:00
1 parent 3b8555992e
commit 0d94f21479
3 files changed
+244

No files matched your search

+93
View File
@@ -0,0 +1,93 @@
/**
* Remote API Service
*
* HTTP client for communicating with a remote qbzd daemon.
* Used when playbackTarget.type === 'qbzd'.
*
* All methods throw if not in remote mode — callers must check first.
*/
import { getTarget, type PlaybackTarget } from '$lib/stores/playbackTargetStore';
export class RemoteApiError extends Error {
constructor(
public status: number,
public code: string,
message: string,
) {
super(message);
this.name = 'RemoteApiError';
}
}
function requireRemote(): PlaybackTarget & { type: 'qbzd'; baseUrl: string; token: string } {
const target = getTarget();
if (target.type !== 'qbzd' || !target.baseUrl || !target.token) {
throw new Error('Not in remote mode');
}
return target as PlaybackTarget & { type: 'qbzd'; baseUrl: string; token: string };
}
/** Make a GET request to the remote daemon */
export async function remoteGet<T = unknown>(path: string): Promise<T> {
const target = requireRemote();
const response = await fetch(`${target.baseUrl}${path}`, {
headers: {
'X-API-Key': target.token,
},
});
if (!response.ok) {
const body = await response.text().catch(() => '');
throw new RemoteApiError(response.status, 'request_failed', body || response.statusText);
}
return response.json();
}
/** Make a POST request to the remote daemon */
export async function remotePost<T = unknown>(path: string, body?: unknown): Promise<T> {
const target = requireRemote();
const response = await fetch(`${target.baseUrl}${path}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-API-Key': target.token,
},
body: body ? JSON.stringify(body) : undefined,
});
if (!response.ok) {
const text = await response.text().catch(() => '');
throw new RemoteApiError(response.status, 'request_failed', text || response.statusText);
}
// Some endpoints return plain text ("ok"), not JSON
const text = await response.text();
try {
return JSON.parse(text);
} catch {
return text as unknown as T;
}
}
/** Check if a remote daemon is reachable */
export async function pingRemote(baseUrl: string, token: string): Promise<boolean> {
try {
const response = await fetch(`${baseUrl}/api/ping`, {
headers: { 'X-API-Key': token },
signal: AbortSignal.timeout(5000),
});
return response.ok;
} catch {
return false;
}
}
/** Get daemon info (name, version, capabilities) */
export async function getRemoteInfo(
baseUrl: string,
token: string,
): Promise<{ name: string; version: string; cache: Record<string, number> }> {
const response = await fetch(`${baseUrl}/api/info`, {
headers: { 'X-API-Key': token },
signal: AbortSignal.timeout(5000),
});
if (!response.ok) throw new Error('Failed to get daemon info');
return response.json();
}
+105
View File
@@ -0,0 +1,105 @@
/**
* Remote Events Service
*
* SSE client that connects to a remote qbzd daemon's /api/events endpoint.
* When in remote mode, this replaces Tauri's event system for playback
* and queue state updates.
*/
import { getTarget } from '$lib/stores/playbackTargetStore';
type EventHandler = (data: unknown) => void;
let eventSource: EventSource | null = null;
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
const handlers: Map<string, EventHandler[]> = new Map();
/** Connect to the remote daemon's SSE stream */
export function connectRemoteEvents() {
disconnectRemoteEvents();
const target = getTarget();
if (target.type !== 'qbzd' || !target.baseUrl || !target.token) return;
const url = `${target.baseUrl}/api/events?token=${encodeURIComponent(target.token)}`;
console.log('[RemoteEvents] Connecting to SSE:', target.baseUrl);
eventSource = new EventSource(url);
eventSource.onopen = () => {
console.log('[RemoteEvents] SSE connected');
if (reconnectTimer) {
clearTimeout(reconnectTimer);
reconnectTimer = null;
}
};
eventSource.onerror = () => {
console.warn('[RemoteEvents] SSE error, will reconnect...');
disconnectRemoteEvents();
reconnectTimer = setTimeout(connectRemoteEvents, 3000);
};
// Register handlers for each event type
const eventTypes = [
'playback',
'queue',
'track-started',
'track-ended',
'favorites-updated',
'playlist-created',
'playlist-updated',
'error',
'runtime',
'logged-in',
'logged-out',
];
for (const type of eventTypes) {
eventSource.addEventListener(type, (e: MessageEvent) => {
try {
const data = JSON.parse(e.data);
const typeHandlers = handlers.get(type);
if (typeHandlers) {
for (const handler of typeHandlers) {
handler(data);
}
}
} catch (err) {
console.warn(`[RemoteEvents] Failed to parse ${type} event:`, err);
}
});
}
}
/** Disconnect from the remote SSE stream */
export function disconnectRemoteEvents() {
if (eventSource) {
eventSource.close();
eventSource = null;
}
if (reconnectTimer) {
clearTimeout(reconnectTimer);
reconnectTimer = null;
}
}
/** Register a handler for a specific event type */
export function onRemoteEvent(type: string, handler: EventHandler): () => void {
const existing = handlers.get(type) || [];
existing.push(handler);
handlers.set(type, existing);
// Return unsubscribe function
return () => {
const list = handlers.get(type);
if (list) {
const idx = list.indexOf(handler);
if (idx !== -1) list.splice(idx, 1);
}
};
}
/** Check if SSE is currently connected */
export function isRemoteEventsConnected(): boolean {
return eventSource !== null && eventSource.readyState === EventSource.OPEN;
}
+46
View File
@@ -0,0 +1,46 @@
/**
* Playback Target Store
*
* Tracks whether the app is controlling local playback or a remote qbzd daemon.
* When targeting a remote daemon, playback/queue/search commands route to
* the HTTP API instead of Tauri invoke().
*/
import { writable, get, derived } from 'svelte/store';
export interface PlaybackTarget {
type: 'local' | 'qbzd';
/** HTTP base URL of the daemon (e.g., "http://192.168.1.50:8182") */
baseUrl?: string;
/** API token for authentication */
token?: string;
/** Human-readable name (e.g., "raspberrypi", "living-room") */
name?: string;
}
/** Current playback target — local by default */
export const playbackTarget = writable<PlaybackTarget>({ type: 'local' });
/** Whether we're currently controlling a remote daemon */
export const isRemoteMode = derived(playbackTarget, ($t) => $t.type === 'qbzd');
/** Connect to a remote qbzd daemon */
export function connectToRemote(baseUrl: string, token: string, name?: string) {
playbackTarget.set({
type: 'qbzd',
baseUrl: baseUrl.replace(/\/+$/, ''), // strip trailing slash
token,
name: name || new URL(baseUrl).hostname,
});
console.log(`[PlaybackTarget] Connected to remote: ${name || baseUrl}`);
}
/** Disconnect from remote, return to local playback */
export function disconnectFromRemote() {
playbackTarget.set({ type: 'local' });
console.log('[PlaybackTarget] Switched to local playback');
}
/** Get the current target (non-reactive, for use in async functions) */
export function getTarget(): PlaybackTarget {
return get(playbackTarget);
}