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:
54 files changed
+90
-6225
No files matched your search
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
Reference in new issue
Block a user