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:
7 files changed
+176
-1
No files matched your search
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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> {
|
||||
|
||||
Reference in new issue
Block a user