chore: remove qbzd headless daemon and its client integration

qbzd was a premature daemon experiment that never shipped (gated off
behind QBZD_ENABLED, no user access). Its ratatui 0.29 dependency pinned
unicode-width =0.2.0, which conflicts with slint's annotate-snippets in a
shared workspace. Remove it entirely; it will be reimplemented from
scratch later on the clean, UI-agnostic shell.

- Delete crates/qbzd daemon crate + workspace member (drops ratatui,
  crossterm and the other qbzd-only deps from the lock).
- Delete src-tauri qbzd_discovery (mDNS) + its commands/state wiring in
  lib.rs; drop the direct mdns-sd dep (Chromecast keeps its own via
  qbz-cast).
- Delete remoteApi/remoteEvents/playbackTargetStore; neutralize
  commandRouter to local-only dispatch (isRemote/skipIfRemote are now
  always-false no-ops so the ~100 guard call-sites keep compiling).
- Strip qbzd branches from playbackService and the remote banner from
  NowPlayingBar; excise qbzd from CastPicker (Chromecast/DLNA kept).
- Remove qbzd i18n keys (5 locales), README daemon node, gitignore note.

cargo check (crates + src-tauri) and svelte-check all green.

cc
This commit is contained in:
vicrodh committed 2026-05-25 15:08:14 -06:00
1 parent 6ed659c318
commit 47db92ff92
54 files changed
+90 -6225

No files matched your search

+6 -348
View File
@@ -11,49 +11,6 @@
type CastProtocol,
type CastDevice
} from '$lib/stores/castStore';
import {
connectToRemote,
disconnectFromRemote,
isRemoteMode,
playbackTarget,
} from '$lib/stores/playbackTargetStore';
import { connectRemoteEvents, disconnectRemoteEvents } from '$lib/services/remoteEvents';
import { pingRemote } from '$lib/services/remoteApi';
interface QbzdDevice {
name: string;
host: string;
port: number;
baseUrl: string;
}
const QBZD_STORAGE_KEY = 'qbzd-saved-daemons';
interface SavedDaemon {
name: string;
baseUrl: string;
lastConnected: number;
}
function loadSavedDaemons(): SavedDaemon[] {
try {
const raw = localStorage.getItem(QBZD_STORAGE_KEY);
return raw ? JSON.parse(raw) : [];
} catch { return []; }
}
function saveDaemon(name: string, baseUrl: string) {
const saved = loadSavedDaemons().filter(d => d.baseUrl !== baseUrl);
saved.unshift({ name, baseUrl, lastConnected: Date.now() });
// Keep max 5 recent
localStorage.setItem(QBZD_STORAGE_KEY, JSON.stringify(saved.slice(0, 5)));
}
function removeSavedDaemon(baseUrl: string) {
const saved = loadSavedDaemons().filter(d => d.baseUrl !== baseUrl);
localStorage.setItem(QBZD_STORAGE_KEY, JSON.stringify(saved));
savedDaemons = loadSavedDaemons();
}
interface Props {
isOpen: boolean;
@@ -62,17 +19,9 @@
let { isOpen, onClose }: Props = $props();
// Feature flag: qbzd (QBZ Daemon) is not ready for release yet. Keep the
// code paths but hide the tab and skip the backend invocations so users
// don't see a broken option. Flip to `true` when qbzd ships.
const QBZD_ENABLED = false;
let activeProtocol = $state<CastProtocol | 'qbzd'>(QBZD_ENABLED ? 'qbzd' : 'chromecast');
let manualAddress = $state('');
let savedDaemons = $state<SavedDaemon[]>(loadSavedDaemons());
let activeProtocol = $state<CastProtocol>('chromecast');
let chromecastDevices = $state<CastDevice[]>([]);
let dlnaDevices = $state<CastDevice[]>([]);
let qbzdDevices = $state<QbzdDevice[]>([]);
let loading = $state(false);
let error = $state<string | null>(null);
let discoveryStarted = $state(false);
@@ -92,7 +41,6 @@
switch (activeProtocol) {
case 'chromecast': return chromecastDevices;
case 'dlna': return dlnaDevices;
case 'qbzd': return []; // qbzd uses its own list
}
});
@@ -136,7 +84,6 @@
await Promise.allSettled([
invoke('v2_cast_start_discovery'),
invoke('v2_dlna_start_discovery'),
...(QBZD_ENABLED ? [invoke('v2_qbzd_start_discovery')] : []),
]);
// Poll for devices
pollDevices();
@@ -156,7 +103,6 @@
await Promise.allSettled([
invoke('v2_cast_stop_discovery'),
invoke('v2_dlna_stop_discovery'),
...(QBZD_ENABLED ? [invoke('v2_qbzd_stop_discovery')] : []),
]);
} catch (err) {
console.error('Failed to stop discovery:', err);
@@ -171,9 +117,8 @@
const results = await Promise.allSettled([
invoke<CastDevice[]>('v2_cast_get_devices'),
invoke<CastDevice[]>('v2_dlna_get_devices'),
...(QBZD_ENABLED ? [invoke<QbzdDevice[]>('v2_qbzd_get_devices')] : []),
]);
const [chromecast, dlna, qbzd] = results;
const [chromecast, dlna] = results;
if (chromecast.status === 'fulfilled') {
chromecastDevices = chromecast.value as CastDevice[];
@@ -181,9 +126,6 @@
if (dlna.status === 'fulfilled') {
dlnaDevices = dlna.value as CastDevice[];
}
if (QBZD_ENABLED && qbzd && qbzd.status === 'fulfilled') {
qbzdDevices = qbzd.value as QbzdDevice[];
}
} catch (err) {
console.error('Failed to get devices:', err);
}
@@ -211,111 +153,21 @@
async function handleDisconnect() {
try {
if ($isRemoteMode) {
disconnectRemoteEvents();
disconnectFromRemote();
} else {
await disconnect();
}
await disconnect();
} catch (err) {
error = String(err);
}
}
async function handleQbzdConnect(device: QbzdDevice) {
connecting = true;
error = null;
try {
const reachable = await pingRemote(device.baseUrl, '');
if (!reachable) {
error = `Cannot reach ${device.name} at ${device.baseUrl}`;
return;
}
connectToRemote(device.baseUrl, '', device.name);
connectRemoteEvents();
saveDaemon(device.name, device.baseUrl);
savedDaemons = loadSavedDaemons();
onClose();
} catch (err) {
error = String(err);
} finally {
connecting = false;
}
}
async function handleManualConnect() {
let addr = manualAddress.trim();
if (!addr) return;
// Add http:// if missing
if (!addr.startsWith('http://') && !addr.startsWith('https://')) {
addr = `http://${addr}`;
}
// Add default port if missing
try {
const url = new URL(addr);
if (!url.port) {
addr = `${url.protocol}//${url.hostname}:8182`;
}
} catch {
error = 'Invalid address';
return;
}
connecting = true;
error = null;
try {
const reachable = await pingRemote(addr, '');
if (!reachable) {
error = `Cannot reach daemon at ${addr}`;
return;
}
// Extract name from hostname
const name = new URL(addr).hostname;
connectToRemote(addr, '', name);
connectRemoteEvents();
saveDaemon(name, addr);
savedDaemons = loadSavedDaemons();
manualAddress = '';
onClose();
} catch (err) {
error = String(err);
} finally {
connecting = false;
}
}
async function handleSavedConnect(saved: SavedDaemon) {
connecting = true;
error = null;
try {
const reachable = await pingRemote(saved.baseUrl, '');
if (!reachable) {
error = `Cannot reach ${saved.name} at ${saved.baseUrl}`;
return;
}
connectToRemote(saved.baseUrl, '', saved.name);
connectRemoteEvents();
saveDaemon(saved.name, saved.baseUrl);
savedDaemons = loadSavedDaemons();
onClose();
} catch (err) {
error = String(err);
} finally {
connecting = false;
}
}
function getProtocolIcon(protocol: CastProtocol | 'qbzd') {
function getProtocolIcon(protocol: CastProtocol) {
switch (protocol) {
case 'qbzd': return Monitor;
case 'chromecast': return Cast;
case 'dlna': return Tv;
}
}
function getProtocolLabel(protocol: CastProtocol | 'qbzd'): string {
function getProtocolLabel(protocol: CastProtocol): string {
switch (protocol) {
case 'qbzd': return 'QBZ Daemon';
case 'chromecast': return 'Chromecast';
case 'dlna': return 'DLNA';
}
@@ -333,21 +185,7 @@
</div>
<!-- Connected Device Banner -->
{#if $isRemoteMode}
<div class="connected-banner">
<div class="connected-info">
<Monitor size={20} />
<div class="connected-text">
<span class="connected-label">{$t('player.connectedTo')}</span>
<span class="connected-name">{$playbackTarget.name}</span>
</div>
</div>
<button class="disconnect-btn" onclick={handleDisconnect}>
<Power size={16} />
<span>{$t('settings.integrations.disconnect')}</span>
</button>
</div>
{:else if castState.isConnected && castState.device}
{#if castState.isConnected && castState.device}
{@const ProtocolIcon = getProtocolIcon(castState.protocol!)}
<div class="connected-banner">
<div class="connected-info">
@@ -365,19 +203,6 @@
{:else}
<!-- Protocol Tabs (only show when not connected) -->
<div class="protocol-tabs">
{#if QBZD_ENABLED}
<button
class="protocol-tab"
class:active={activeProtocol === 'qbzd'}
onclick={() => activeProtocol = 'qbzd'}
>
<Monitor size={16} />
<span>QBZ Daemon</span>
{#if qbzdDevices.length > 0}
<span class="count">{qbzdDevices.length}</span>
{/if}
</button>
{/if}
<button
class="protocol-tab"
class:active={activeProtocol === 'chromecast'}
@@ -412,68 +237,6 @@
<div class="error">
<p>{error}</p>
</div>
{:else if activeProtocol === 'qbzd'}
<!-- QBZ Daemon: manual connect + saved + discovered -->
<div class="qbzd-section">
<!-- Manual connect -->
<div class="manual-connect">
<input
type="text"
class="manual-input"
bind:value={manualAddress}
placeholder="192.168.1.50:8182"
onkeydown={(e) => e.key === 'Enter' && handleManualConnect()}
/>
<button class="connect-btn" onclick={handleManualConnect} disabled={!manualAddress.trim()}>
{$t('integrations.connect')}
</button>
</div>
<!-- Saved (recent) daemons -->
{#if savedDaemons.length > 0}
<div class="section-label">{$t('player.recentDaemons')}</div>
<div class="devices">
{#each savedDaemons as saved}
<div class="device-row">
<button class="device" onclick={() => handleSavedConnect(saved)}>
<Monitor size={24} />
<div class="device-info">
<span class="device-name">{saved.name}</span>
<span class="device-ip">{saved.baseUrl}</span>
</div>
<Wifi size={20} class="cast-icon" />
</button>
<button class="remove-saved" onclick={() => removeSavedDaemon(saved.baseUrl)} title="Remove">
<X size={14} />
</button>
</div>
{/each}
</div>
{/if}
<!-- Discovered via mDNS -->
{#if qbzdDevices.length > 0}
<div class="section-label">{$t('player.discoveredDaemons')}</div>
<div class="devices">
{#each qbzdDevices as device}
<button class="device" onclick={() => handleQbzdConnect(device)}>
<Monitor size={24} />
<div class="device-info">
<span class="device-name">{device.name}</span>
<span class="device-ip">{device.host}:{device.port}</span>
</div>
<Wifi size={20} class="cast-icon" />
</button>
{/each}
</div>
{:else if loading}
<div class="section-label">{$t('player.discoveredDaemons')}</div>
<div class="scanning-hint">
<LoaderCircle size={14} class="spin" />
<span>{$t('toast.loadingDevices')}</span>
</div>
{/if}
</div>
{:else if devices().length === 0 && (loading || scanning)}
<div class="loading">
<LoaderCircle size={32} class="spin" />
@@ -673,111 +436,6 @@
color: var(--text-muted);
}
.qbzd-section {
display: flex;
flex-direction: column;
gap: 12px;
}
.manual-connect {
display: flex;
gap: 8px;
padding: 0 16px;
}
.manual-input {
flex: 1;
padding: 8px 12px;
background: var(--bg-tertiary);
border: 1px solid var(--border-subtle);
border-radius: 6px;
color: var(--text-primary);
font-size: 13px;
font-family: monospace;
}
.manual-input::placeholder {
color: var(--text-muted);
}
.manual-input:focus {
outline: none;
border-color: var(--accent-primary);
}
.connect-btn {
padding: 8px 16px;
background: var(--accent-primary);
color: var(--btn-primary-text);
border: none;
border-radius: 6px;
font-size: 13px;
font-weight: 500;
cursor: pointer;
white-space: nowrap;
}
.connect-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.connect-btn:hover:not(:disabled) {
filter: brightness(1.1);
}
.section-label {
font-size: 11px;
font-weight: 600;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.05em;
padding: 0 16px;
}
.device-row {
display: flex;
align-items: center;
}
.device-row .device {
flex: 1;
}
.remove-saved {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
background: none;
border: none;
color: var(--text-muted);
cursor: pointer;
border-radius: 50%;
margin-right: 8px;
opacity: 0;
transition: opacity 150ms ease;
}
.device-row:hover .remove-saved {
opacity: 1;
}
.remove-saved:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
.scanning-hint {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 16px;
font-size: 12px;
color: var(--text-muted);
}
.device :global(.cast-icon) {
color: var(--text-muted);
opacity: 0;
+2 -44
View File
@@ -13,7 +13,6 @@
Volume2,
VolumeX,
Volume1,
Monitor,
Cast,
MicVocal,
Maximize2,
@@ -22,8 +21,6 @@
MoreHorizontal
} from 'lucide-svelte';
import { t } from 'svelte-i18n';
import { isRemoteMode, playbackTarget } from '$lib/stores/playbackTargetStore';
import { disconnectFromRemote } from '$lib/stores/playbackTargetStore';
import QualityBadge from './QualityBadge.svelte';
import AudioOutputBadges from './AudioOutputBadges.svelte';
import QconnectBadge from './QconnectBadge.svelte';
@@ -394,19 +391,12 @@
// Set CSS variable for player bar height so page layout adjusts
$effect(() => {
const barHeight = $isRemoteMode ? 128 : 104;
const barHeight = 104;
document.documentElement.style.setProperty('--player-bar-height', `${barHeight}px`);
});
</script>
<div class="now-playing-bar" class:has-remote-banner={$isRemoteMode} class:narrow={isNarrowBar} bind:this={barRef}>
{#if $isRemoteMode}
<div class="remote-indicator">
<Monitor size={14} />
<span>{$t('player.controllingRemote', { values: { name: $playbackTarget.name || 'Remote' } })}</span>
<button class="remote-disconnect" onclick={disconnectFromRemote}>{$t('settings.integrations.disconnect')}</button>
</div>
{/if}
<div class="now-playing-bar" class:narrow={isNarrowBar} bind:this={barRef}>
<!-- Top: Full-width Seekbar -->
<div class="seekbar-container">
<span class="time current">{formatTime(currentTime)}</span>
@@ -934,38 +924,6 @@
flex-direction: column;
}
.now-playing-bar.has-remote-banner {
height: 128px;
}
.remote-indicator {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 4px 12px;
background: var(--accent-primary);
color: var(--btn-primary-text);
font-size: 12px;
font-weight: 500;
letter-spacing: 0.02em;
}
.remote-disconnect {
background: rgba(255, 255, 255, 0.2);
border: none;
color: white;
font-size: 11px;
padding: 2px 8px;
border-radius: 4px;
cursor: pointer;
margin-left: 4px;
}
.remote-disconnect:hover {
background: rgba(255, 255, 255, 0.35);
}
/* ===== Seekbar ===== */
.seekbar-container {
display: flex;
-5
View File
@@ -1466,11 +1466,6 @@
},
"player": {
"nowPlaying": "Aktuell abgespielt",
"controllingRemote": "Steuert: {name}",
"noQbzdFound": "Keine QBZ-Daemons in Ihrem Netzwerk gefunden",
"noQbzdFoundHint": "Stellen Sie sicher, dass qbzd auf dem Zielgerat lauft",
"recentDaemons": "Zuletzt verwendet",
"discoveredDaemons": "Im Netzwerk entdeckt",
"queue": "Warteschlange",
"upNext": "Als Nächstes",
"ofTotal": "von",
-5
View File
@@ -1466,11 +1466,6 @@
},
"player": {
"nowPlaying": "Now Playing",
"controllingRemote": "Controlling: {name}",
"noQbzdFound": "No QBZ Daemons found on your network",
"noQbzdFoundHint": "Make sure qbzd is running on the target device",
"recentDaemons": "Recent",
"discoveredDaemons": "Discovered on network",
"queue": "Queue",
"upNext": "Up Next",
"ofTotal": "of",
-5
View File
@@ -1466,11 +1466,6 @@
},
"player": {
"nowPlaying": "Reproduciendo",
"controllingRemote": "Controlando: {name}",
"noQbzdFound": "No se encontraron daemons QBZ en tu red",
"noQbzdFoundHint": "Asegurate de que qbzd este corriendo en el dispositivo",
"recentDaemons": "Recientes",
"discoveredDaemons": "Descubiertos en la red",
"queue": "Cola",
"upNext": "Siguiente",
"ofTotal": "de",
-5
View File
@@ -1466,11 +1466,6 @@
},
"player": {
"nowPlaying": "Lecture en cours",
"controllingRemote": "Controle : {name}",
"noQbzdFound": "Aucun daemon QBZ trouve sur votre reseau",
"noQbzdFoundHint": "Assurez-vous que qbzd est en cours d'execution sur l'appareil cible",
"recentDaemons": "Recents",
"discoveredDaemons": "Decouverts sur le reseau",
"queue": "File d'attente",
"upNext": "À suivre",
"ofTotal": "de",
-5
View File
@@ -1466,11 +1466,6 @@
},
"player": {
"nowPlaying": "Tocando Agora",
"controllingRemote": "Controlando: {name}",
"noQbzdFound": "Nenhum daemon QBZ encontrado na sua rede",
"noQbzdFoundHint": "Certifique-se de que o qbzd esta em execucao no dispositivo alvo",
"recentDaemons": "Recentes",
"discoveredDaemons": "Descobertos na rede",
"queue": "Fila",
"upNext": "A Seguir",
"ofTotal": "de",
+51 -161
View File
@@ -1,103 +1,61 @@
/**
* Command Router
*
* Routes commands to either Tauri invoke() or remote HTTP API based on
* the current playback target. This is the single point where local vs
* remote bifurcation happens — stores and services call these functions
* instead of invoke() directly for playback/queue operations.
* Thin dispatch layer over Tauri invoke() for playback, queue, and audio
* commands. Stores and services call these functions instead of invoke()
* directly so the V2 command names live in one place.
*
* Only playback, queue, and favorites operations route remotely.
* Everything else (settings, library, integrations) always stays local.
* This used to bifurcate between local invoke() and a remote HTTP daemon
* (qbzd). The qbzd daemon was a premature experiment and has been removed;
* the app always controls local playback now. `isRemote()` / `skipIfRemote()`
* are kept as always-false no-ops so the ~100 `if (skipIfRemote()) return`
* guards scattered across stores/services keep compiling without edits.
*/
import { invoke } from '@tauri-apps/api/core';
import { getTarget } from '$lib/stores/playbackTargetStore';
import { remotePost, remoteGet } from '$lib/services/remoteApi';
// ==================== Remote-Aware Helpers ====================
// ==================== Local-only no-ops (kept for call-site compatibility) ====================
/** Check if currently targeting a remote daemon */
/** Remote targeting was removed — the app is always local. */
export function isRemote(): boolean {
return getTarget().type === 'qbzd';
return false;
}
/**
* Guard for local-only operations. Returns true if we should skip.
* Use at the start of any function that only makes sense locally
* (offline cache, window settings, visualizer, etc.)
* Guard kept for call-site compatibility. Always false now (no remote target),
* so guarded local-only operations always proceed.
*/
export function skipIfRemote(): boolean {
return getTarget().type === 'qbzd';
}
/** Fetch from remote API (GET), or null if local */
export async function remoteGetOrNull<T>(path: string): Promise<T | null> {
if (!isRemote()) return null;
return remoteGet<T>(path);
return false;
}
// ==================== Playback ====================
export async function cmdPause(): Promise<void> {
const target = getTarget();
if (target.type === 'qbzd') {
await remotePost('/api/playback/pause');
} else {
await invoke('v2_pause_playback');
}
await invoke('v2_pause_playback');
}
export async function cmdResume(): Promise<void> {
const target = getTarget();
if (target.type === 'qbzd') {
await remotePost('/api/playback/play');
} else {
await invoke('v2_resume_playback');
}
await invoke('v2_resume_playback');
}
export async function cmdStop(): Promise<void> {
const target = getTarget();
if (target.type === 'qbzd') {
await remotePost('/api/playback/stop');
} else {
await invoke('v2_stop_playback');
}
await invoke('v2_stop_playback');
}
export async function cmdSeek(position: number): Promise<void> {
const target = getTarget();
if (target.type === 'qbzd') {
await remotePost('/api/playback/seek', { position_secs: Math.floor(position) });
} else {
await invoke('v2_seek', { position: Math.floor(position) });
}
await invoke('v2_seek', { position: Math.floor(position) });
}
export async function cmdSetVolume(volume: number): Promise<void> {
const target = getTarget();
if (target.type === 'qbzd') {
await remotePost('/api/playback/volume', { volume });
} else {
await invoke('v2_set_volume', { volume });
}
await invoke('v2_set_volume', { volume });
}
export async function cmdNext(): Promise<unknown> {
const target = getTarget();
if (target.type === 'qbzd') {
return remotePost('/api/playback/next');
} else {
return invoke('v2_next_track');
}
return invoke('v2_next_track');
}
export async function cmdPrevious(): Promise<unknown> {
const target = getTarget();
if (target.type === 'qbzd') {
return remotePost('/api/playback/previous');
} else {
return invoke('v2_previous_track');
}
return invoke('v2_previous_track');
}
export async function cmdPlayTrack(
@@ -107,142 +65,74 @@ export async function cmdPlayTrack(
forceLowestQuality?: boolean | null,
startPositionSecs?: number | null,
): Promise<unknown> {
const target = getTarget();
if (target.type === 'qbzd') {
return remotePost('/api/playback/play-track', { track_id: trackId, quality });
} else {
// Parameter names MUST match Tauri's camelCase mapping of Rust
// v2_play_track(track_id, quality, force_lowest_quality, duration_secs,
// start_position_secs). duration_secs is required on the streaming
// path — without it the backend stores duration=0 and
// current_position() clamps to 0, freezing the seekbar (seen on
// session-restore first play). start_position_secs is still accepted
// by the backend but the frontend no longer uses it: session resume
// now seeks to the saved offset only on a cache hit, where the audio
// is fully in memory and Seek lands (see playerStore togglePlay).
return invoke('v2_play_track', {
trackId,
quality: quality ?? null,
forceLowestQuality: forceLowestQuality ?? null,
durationSecs: durationSecs ?? null,
startPositionSecs: startPositionSecs ?? null,
});
}
// Parameter names MUST match Tauri's camelCase mapping of Rust
// v2_play_track(track_id, quality, force_lowest_quality, duration_secs,
// start_position_secs). duration_secs is required on the streaming
// path — without it the backend stores duration=0 and
// current_position() clamps to 0, freezing the seekbar (seen on
// session-restore first play). start_position_secs is still accepted
// by the backend but the frontend no longer uses it: session resume
// now seeks to the saved offset only on a cache hit, where the audio
// is fully in memory and Seek lands (see playerStore togglePlay).
return invoke('v2_play_track', {
trackId,
quality: quality ?? null,
forceLowestQuality: forceLowestQuality ?? null,
durationSecs: durationSecs ?? null,
startPositionSecs: startPositionSecs ?? null,
});
}
// ==================== Queue ====================
export async function cmdSetQueue(tracks: unknown[], startIndex: number): Promise<void> {
const target = getTarget();
if (target.type === 'qbzd') {
await remotePost('/api/queue/set', { tracks, start_index: startIndex });
} else {
await invoke('v2_set_queue', { tracks, startIndex });
}
await invoke('v2_set_queue', { tracks, startIndex });
}
export async function cmdAddToQueue(track: unknown): Promise<void> {
const target = getTarget();
if (target.type === 'qbzd') {
await remotePost('/api/queue/add', { tracks: [track] });
} else {
await invoke('v2_add_to_queue', { track });
}
await invoke('v2_add_to_queue', { track });
}
export async function cmdAddToQueueNext(track: unknown): Promise<void> {
const target = getTarget();
if (target.type === 'qbzd') {
await remotePost('/api/queue/add-next', { tracks: [track] });
} else {
await invoke('v2_add_to_queue_next', { track });
}
await invoke('v2_add_to_queue_next', { track });
}
export async function cmdAddTracksToQueue(tracks: unknown[]): Promise<void> {
const target = getTarget();
if (target.type === 'qbzd') {
await remotePost('/api/queue/add', { tracks });
} else {
await invoke('v2_add_tracks_to_queue', { tracks });
}
await invoke('v2_add_tracks_to_queue', { tracks });
}
export async function cmdAddTracksToQueueNext(tracks: unknown[]): Promise<void> {
const target = getTarget();
if (target.type === 'qbzd') {
await remotePost('/api/queue/add-next', { tracks });
} else {
await invoke('v2_add_tracks_to_queue_next', { tracks });
}
await invoke('v2_add_tracks_to_queue_next', { tracks });
}
export async function cmdClearQueue(opts?: { includeCurrent?: boolean }): Promise<void> {
const target = getTarget();
if (target.type === 'qbzd') {
await remotePost('/api/queue/clear');
} else {
await invoke('v2_clear_queue', { includeCurrent: opts?.includeCurrent ?? false });
}
await invoke('v2_clear_queue', { includeCurrent: opts?.includeCurrent ?? false });
}
export async function cmdToggleShuffle(): Promise<void> {
const target = getTarget();
if (target.type === 'qbzd') {
// Remote: get current state and toggle
const queue = await remoteGet<{ shuffle: boolean }>('/api/queue');
await remotePost('/api/queue/shuffle', { enabled: !queue.shuffle });
} else {
await invoke('v2_toggle_shuffle');
}
await invoke('v2_toggle_shuffle');
}
export async function cmdSetRepeatMode(mode: string): Promise<void> {
const target = getTarget();
if (target.type === 'qbzd') {
await remotePost('/api/queue/repeat', { mode });
} else {
// Map frontend mode names to V2 command format
const v2Mode = mode === 'one' ? 'One' : mode === 'all' ? 'All' : 'Off';
await invoke('v2_set_repeat_mode', { mode: v2Mode });
}
// Map frontend mode names to V2 command format
const v2Mode = mode === 'one' ? 'One' : mode === 'all' ? 'All' : 'Off';
await invoke('v2_set_repeat_mode', { mode: v2Mode });
}
// ==================== Audio Settings ====================
export async function cmdGetAudioSettings(): Promise<unknown> {
const target = getTarget();
if (target.type === 'qbzd') {
return remoteGet('/api/audio/settings');
} else {
return invoke('v2_get_audio_settings');
}
return invoke('v2_get_audio_settings');
}
export async function cmdGetAvailableBackends(): Promise<unknown> {
const target = getTarget();
if (target.type === 'qbzd') {
return remoteGet('/api/audio/backends');
} else {
return invoke('v2_get_available_backends');
}
return invoke('v2_get_available_backends');
}
export async function cmdGetDevicesForBackend(backendType: string): Promise<unknown> {
const target = getTarget();
if (target.type === 'qbzd') {
return remoteGet(`/api/audio/devices?backend=${encodeURIComponent(backendType)}`);
} else {
return invoke('v2_get_devices_for_backend', { backendType });
}
return invoke('v2_get_devices_for_backend', { backendType });
}
export async function cmdGetHardwareAudioStatus(): Promise<unknown> {
const target = getTarget();
if (target.type === 'qbzd') {
return remoteGet('/api/audio/hardware-status');
} else {
return invoke('v2_get_hardware_audio_status');
}
return invoke('v2_get_hardware_audio_status');
}
+12 -35
View File
@@ -14,8 +14,6 @@ import { get } from 'svelte/store';
import { t } from '$lib/i18n';
import { formatTrackTitle } from '$lib/utils/trackTitle';
import { cmdStop, cmdPlayTrack, skipIfRemote } from '$lib/services/commandRouter';
import { getTarget } from '$lib/stores/playbackTargetStore';
import { remotePost } from '$lib/services/remoteApi';
import { getUserItem, setUserItem } from '$lib/utils/userStorage';
import {
isPlaybackSourceLocal,
@@ -242,18 +240,6 @@ export async function playTrack(
try {
let handledRemotely = false;
const target = getTarget();
// When controlling a qbzd daemon, send play directly to daemon.
// No local stop, no QConnect, no metadata — daemon handles everything.
if (target.type === 'qbzd') {
await remotePost('/api/playback/play-track', {
track_id: track.id,
quality: effectiveQuality(track),
});
setIsPlaying(true);
return true;
}
if (!gaplessTransition && !isLocal && source !== 'plex') {
handledRemotely = await handoffPlayTrackToRemoteRenderer(track.id);
@@ -332,28 +318,19 @@ export async function playTrack(
} else if (isLocal) {
await invoke('v2_library_play_track', { trackId: track.id });
} else {
// Route to daemon or local based on playback target
const target = getTarget();
if (target.type === 'qbzd') {
await remotePost('/api/playback/play-track', {
track_id: track.id,
quality: effectiveQuality(track),
});
} else {
const result = await invoke<PlayTrackResult>('v2_play_track', {
trackId: track.id,
quality: effectiveQuality(track),
durationSecs: track.duration ? Math.round(track.duration) : null,
forceLowestQuality: forceLowestQuality || null
});
const result = await invoke<PlayTrackResult>('v2_play_track', {
trackId: track.id,
quality: effectiveQuality(track),
durationSecs: track.duration ? Math.round(track.duration) : null,
forceLowestQuality: forceLowestQuality || null
});
// Update track format based on actual stream format_id from Qobuz
const actualFormat = formatIdToString(result.format_id);
if (actualFormat) {
track.format = actualFormat;
// Re-set current track to update the UI with actual format
setCurrentTrack(track);
}
// Update track format based on actual stream format_id from Qobuz
const actualFormat = formatIdToString(result.format_id);
if (actualFormat) {
track.format = actualFormat;
// Re-set current track to update the UI with actual format
setCurrentTrack(track);
}
}
}
-96
View File
@@ -1,96 +0,0 @@
/**
* 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 } {
const target = getTarget();
if (target.type !== 'qbzd' || !target.baseUrl) {
throw new Error('Not in remote mode');
}
return target as PlaybackTarget & { type: 'qbzd'; baseUrl: string };
}
/** Make a GET request to the remote daemon */
export async function remoteGet<T = unknown>(path: string): Promise<T> {
const target = requireRemote();
const headers: Record<string, string> = {};
if (target.token) headers['X-API-Key'] = target.token;
const response = await fetch(`${target.baseUrl}${path}`, { headers });
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 headers: Record<string, string> = { 'Content-Type': 'application/json' };
if (target.token) headers['X-API-Key'] = target.token;
const response = await fetch(`${target.baseUrl}${path}`, {
method: 'POST',
headers,
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 headers: Record<string, string> = {};
if (token) headers['X-API-Key'] = token;
const response = await fetch(`${baseUrl}/api/ping`, {
headers,
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 headers: Record<string, string> = {};
if (token) headers['X-API-Key'] = token;
const response = await fetch(`${baseUrl}/api/info`, {
headers,
signal: AbortSignal.timeout(5000),
});
if (!response.ok) throw new Error('Failed to get daemon info');
return response.json();
}
-105
View File
@@ -1,105 +0,0 @@
/**
* 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;
}
-52
View File
@@ -1,52 +0,0 @@
/**
* 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) {
// Strip trailing slashes without a regex (regex `\/+$` was flagged as a
// potential ReDoS risk; the linear loop below is trivially bounded).
let normalizedUrl = baseUrl;
while (normalizedUrl.endsWith('/')) {
normalizedUrl = normalizedUrl.slice(0, -1);
}
playbackTarget.set({
type: 'qbzd',
baseUrl: normalizedUrl,
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);
}