feat(qconnect): route queue reorder/remove/clear to the remote queue

Extend the QconnectQueueCommandType union with queue_reorder_tracks,
queue_remove_tracks and clear_queue, and add the exported
reorderQconnectQueueIfRemote / removeQconnectQueueItemsIfRemote /
clearQconnectQueueIfRemote routing functions. Each returns true when the remote
queue handled (or reported) the mutation and false when the caller should fall
back to the local store. Add the three qconnect.remoteQueue* toast keys to all
five locales and a covering test for the connected/disconnected paths.

cc
This commit is contained in:
vicrodh committed 2026-06-01 04:48:54 -06:00
1 parent c4016e539f
commit 145859584f
7 files changed
+176 -1

No files matched your search

+3
View File
@@ -1843,6 +1843,9 @@
"remoteQueuedNext": "In Qobuz Connect zur nächsten Wiedergabe eingereiht.",
"remoteQueuedLater": "Zur Qobuz Connect-Warteschlange hinzugefügt.",
"remoteQueueFailed": "Aktualisierung der Qobuz Connect-Warteschlange fehlgeschlagen.",
"remoteQueueReordered": "In Qobuz Connect neu angeordnet",
"remoteQueueRemoved": "Aus Qobuz Connect entfernt",
"remoteQueueCleared": "Qobuz Connect-Warteschlange geleert",
"rendererDisconnected": "Das entfernte Gerät hat die Verbindung zu Qobuz Connect getrennt.",
"rendererUnreachable": "Das entfernte Gerät reagiert nicht mehr.",
"sessionTitle": "Sitzung",
+3
View File
@@ -1843,6 +1843,9 @@
"remoteQueuedNext": "Queued on Qobuz Connect to play next.",
"remoteQueuedLater": "Added to Qobuz Connect queue.",
"remoteQueueFailed": "Failed to update Qobuz Connect queue.",
"remoteQueueReordered": "Reordered on Qobuz Connect",
"remoteQueueRemoved": "Removed from Qobuz Connect",
"remoteQueueCleared": "Cleared Qobuz Connect queue",
"rendererDisconnected": "The remote device disconnected from Qobuz Connect.",
"rendererUnreachable": "The remote device stopped responding.",
"sessionTitle": "Session",
+3
View File
@@ -1843,6 +1843,9 @@
"remoteQueuedNext": "Agregada para reproducir a continuacion en Qobuz Connect.",
"remoteQueuedLater": "Agregada a la cola de Qobuz Connect.",
"remoteQueueFailed": "No se pudo actualizar la cola de Qobuz Connect.",
"remoteQueueReordered": "Reordenado en Qobuz Connect",
"remoteQueueRemoved": "Eliminado de Qobuz Connect",
"remoteQueueCleared": "Cola de Qobuz Connect vaciada",
"rendererDisconnected": "El dispositivo remoto se desconectó de Qobuz Connect.",
"rendererUnreachable": "El dispositivo remoto dejó de responder.",
"sessionTitle": "Sesion",
+3
View File
@@ -1843,6 +1843,9 @@
"remoteQueuedNext": "Ajouté à Qobuz Connect pour lecture suivante.",
"remoteQueuedLater": "Ajouté à la file Qobuz Connect.",
"remoteQueueFailed": "Échec de la mise à jour de la file Qobuz Connect.",
"remoteQueueReordered": "Réordonné sur Qobuz Connect",
"remoteQueueRemoved": "Retiré de Qobuz Connect",
"remoteQueueCleared": "File Qobuz Connect vidée",
"rendererDisconnected": "L'appareil distant s'est déconnecté de Qobuz Connect.",
"rendererUnreachable": "L'appareil distant ne répond plus.",
"sessionTitle": "Session",
+3
View File
@@ -1843,6 +1843,9 @@
"remoteQueuedNext": "Adicionado ao Qobuz Connect para reprodução seguinte.",
"remoteQueuedLater": "Adicionado à fila do Qobuz Connect.",
"remoteQueueFailed": "Falha ao atualizar a fila do Qobuz Connect.",
"remoteQueueReordered": "Reordenado no Qobuz Connect",
"remoteQueueRemoved": "Removido do Qobuz Connect",
"remoteQueueCleared": "Fila do Qobuz Connect esvaziada",
"rendererDisconnected": "O dispositivo remoto desconectou-se do Qobuz Connect.",
"rendererUnreachable": "O dispositivo remoto parou de responder.",
"sessionTitle": "Sessão",
@@ -0,0 +1,90 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
const invokeMock = vi.fn();
vi.mock('@tauri-apps/api/core', () => ({
invoke: (...a: unknown[]) => invokeMock(...a),
convertFileSrc: (p: string) => p
}));
vi.mock('@tauri-apps/api/event', () => ({ emit: vi.fn() }));
vi.mock('@tauri-apps/plugin-clipboard-manager', () => ({ writeText: vi.fn() }));
vi.mock('$lib/stores/toastStore', () => ({ showToast: vi.fn() }));
vi.mock('$lib/i18n', () => ({
t: {
subscribe: (fn: (v: (k: string) => string) => void) => {
fn((k) => k);
return () => {};
}
}
}));
vi.mock('$lib/stores/queueStore', () => ({ addToQueue: vi.fn(), addToQueueNext: vi.fn() }));
vi.mock('$lib/stores/playerStore', () => ({ getPlayerState: () => ({ currentTrack: null }) }));
vi.mock('$lib/services/playbackService', () => ({ addTrackToFavorites: vi.fn() }));
vi.mock('$lib/stores/uiStore', () => ({ openPlaylistModal: vi.fn() }));
import {
reorderQconnectQueueIfRemote,
removeQconnectQueueItemsIfRemote,
clearQconnectQueueIfRemote
} from './trackActions';
function setConnected(connected: boolean) {
invokeMock.mockImplementation((cmd: string) =>
cmd === 'v2_qconnect_status'
? Promise.resolve({ transport_connected: connected })
: Promise.resolve(undefined)
);
}
describe('QConnect remote queue mutation routing', () => {
beforeEach(() => invokeMock.mockReset());
afterEach(() => vi.clearAllMocks());
it('routes reorder to queue_reorder_tracks when connected', async () => {
setConnected(true);
expect(await reorderQconnectQueueIfRemote([10, 30, 20])).toBe(true);
expect(invokeMock).toHaveBeenCalledWith('v2_qconnect_send_command_with_admission', {
request: {
command_type: 'queue_reorder_tracks',
origin: 'qobuz_online',
track_origins: [],
payload: { queue_item_ids: [10, 30, 20] }
}
});
});
it('routes remove to queue_remove_tracks when connected', async () => {
setConnected(true);
expect(await removeQconnectQueueItemsIfRemote([42])).toBe(true);
expect(invokeMock).toHaveBeenCalledWith('v2_qconnect_send_command_with_admission', {
request: {
command_type: 'queue_remove_tracks',
origin: 'qobuz_online',
track_origins: [],
payload: { queue_item_ids: [42] }
}
});
});
it('routes clear to clear_queue with empty payload when connected', async () => {
setConnected(true);
expect(await clearQconnectQueueIfRemote()).toBe(true);
expect(invokeMock).toHaveBeenCalledWith('v2_qconnect_send_command_with_admission', {
request: {
command_type: 'clear_queue',
origin: 'qobuz_online',
track_origins: [],
payload: {}
}
});
});
it('returns false (local fallback) when NOT connected and never dispatches', async () => {
setConnected(false);
expect(await reorderQconnectQueueIfRemote([1, 2])).toBe(false);
expect(await removeQconnectQueueItemsIfRemote([1])).toBe(false);
expect(await clearQconnectQueueIfRemote()).toBe(false);
expect(
invokeMock.mock.calls.filter(([c]) => c === 'v2_qconnect_send_command_with_admission')
).toHaveLength(0);
});
});
+71 -1
View File
@@ -77,7 +77,13 @@ type QconnectAdmissionResult = {
handoff_intent: 'continue_locally' | 'send_to_connect';
};
type QconnectQueueCommandType = 'queue_add_tracks' | 'queue_insert_tracks' | 'queue_load_tracks';
type QconnectQueueCommandType =
| 'queue_add_tracks'
| 'queue_insert_tracks'
| 'queue_load_tracks'
| 'queue_reorder_tracks'
| 'queue_remove_tracks'
| 'clear_queue';
type QueueTrackActionOptions = {
silent?: boolean;
};
@@ -652,6 +658,70 @@ export async function loadQconnectQueue(
}
}
// ============ Remote Queue Mutation Routing (P1-4) ============
//
// When a peer renderer owns playback, reorder/remove/clear must mutate the
// REMOTE queue instead of the local store. Each function returns true when the
// remote queue handled it (caller must NOT also mutate the local store), and
// false when the caller should fall back to the local store. The targets
// (queue_reorder_tracks / queue_remove_tracks / clear_queue) do not require
// admission; origin 'qobuz_online' is ignored for non-admission commands.
export async function reorderQconnectQueueIfRemote(orderedQueueItemIds: number[]): Promise<boolean> {
if (!(await isQconnectConnected())) return false;
try {
await sendQconnectQueueCommandWithAdmission('queue_reorder_tracks', 'qobuz_online', {
queue_item_ids: orderedQueueItemIds
});
await emitQconnectDiagnostic('qconnect:queue_reorder_sent', 'info', {
count: orderedQueueItemIds.length,
preview: orderedQueueItemIds.slice(0, 8)
});
showToast(translate('qconnect.remoteQueueReordered'), 'success');
return true;
} catch (err) {
console.error('[QConnect/Reorder] FAILED:', err);
await emitQconnectDiagnostic('qconnect:queue_reorder_failed', 'error', { error: String(err) });
showToast(translate('qconnect.remoteQueueFailed'), 'error');
return true; // handled (and reported) — do NOT silently fall back and double-mutate
}
}
export async function removeQconnectQueueItemsIfRemote(queueItemIds: number[]): Promise<boolean> {
if (!(await isQconnectConnected())) return false;
try {
await sendQconnectQueueCommandWithAdmission('queue_remove_tracks', 'qobuz_online', {
queue_item_ids: queueItemIds
});
await emitQconnectDiagnostic('qconnect:queue_remove_sent', 'info', {
count: queueItemIds.length,
preview: queueItemIds.slice(0, 8)
});
showToast(translate('qconnect.remoteQueueRemoved'), 'success');
return true;
} catch (err) {
console.error('[QConnect/Remove] FAILED:', err);
await emitQconnectDiagnostic('qconnect:queue_remove_failed', 'error', { error: String(err) });
showToast(translate('qconnect.remoteQueueFailed'), 'error');
return true;
}
}
export async function clearQconnectQueueIfRemote(): Promise<boolean> {
if (!(await isQconnectConnected())) return false;
try {
await sendQconnectQueueCommandWithAdmission('clear_queue', 'qobuz_online', {});
await emitQconnectDiagnostic('qconnect:queue_clear_sent', 'info', {});
showToast(translate('qconnect.remoteQueueCleared'), 'success');
return true;
} catch (err) {
console.error('[QConnect/Clear] FAILED:', err);
await emitQconnectDiagnostic('qconnect:queue_clear_failed', 'error', { error: String(err) });
showToast(translate('qconnect.remoteQueueFailed'), 'error');
return true;
}
}
// ============ Convenience Queue Functions ============
export async function queueQobuzTrackNext(track: QobuzTrack): Promise<void> {