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:
3 files changed
+244
No files matched your search
@@ -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();
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
Reference in new issue
Block a user