merge(pre-release): sync qconnect-poc-v3 with latest local integration state

cx
This commit is contained in:
vicrodh committed 2026-03-12 15:23:32 -06:00
commit 02155f54fe
445 files changed
+50564 -28577

No files matched your search

+81
View File
@@ -0,0 +1,81 @@
/**
* Image Cache Service
*
* Proxies Qobuz image URLs through the Rust backend cache.
* Uses an in-memory map to avoid repeated invoke calls for the same URL.
* When cache is disabled, returns the original URL immediately.
*/
import { invoke } from '@tauri-apps/api/core';
import { convertFileSrc } from '@tauri-apps/api/core';
// In-memory URL map: original URL -> resolved URL (cached path or original)
const resolvedUrls = new Map<string, string>();
// Track pending requests to avoid duplicate downloads
const pendingRequests = new Map<string, Promise<string>>();
// Whether the backend cache is enabled (loaded once, updated on settings change)
let cacheEnabled: boolean | null = null;
/**
* Resolve an image URL through the backend cache.
* Returns a file:// URL if cached, or the original URL if cache is disabled.
*/
export async function getCachedImageUrl(url: string): Promise<string> {
if (!url || url.startsWith('file://') || url.startsWith('asset://')) {
return url;
}
// Check in-memory cache first
const cached = resolvedUrls.get(url);
if (cached) return cached;
// Check if there's already a pending request for this URL
const pending = pendingRequests.get(url);
if (pending) return pending;
const promise = (async () => {
try {
const result = await invoke<string>('v2_get_cached_image', { url });
// Convert file:// paths to Tauri asset URLs for WebView access
let resolved: string;
if (result.startsWith('file://')) {
const filePath = result.slice(7); // Remove file:// prefix
resolved = convertFileSrc(filePath);
} else {
resolved = result;
}
resolvedUrls.set(url, resolved);
return resolved;
} catch {
// On failure, use original URL
resolvedUrls.set(url, url);
return url;
} finally {
pendingRequests.delete(url);
}
})();
pendingRequests.set(url, promise);
return promise;
}
/**
* Clear the in-memory URL map (e.g., when cache is cleared from settings).
*/
export function clearResolvedUrls(): void {
resolvedUrls.clear();
}
/**
* Preload a batch of image URLs into the cache.
* Fires and forgets — doesn't block rendering.
*/
export function preloadImages(urls: string[]): void {
for (const url of urls) {
if (url && !resolvedUrls.has(url) && !url.startsWith('file://') && !url.startsWith('asset://')) {
getCachedImageUrl(url).catch(() => {});
}
}
}
+190 -135
View File
@@ -1,178 +1,233 @@
/**
* MiniPlayer Mode Service
* Mini Player Window Service
*
* Handles switching between normal and miniplayer modes by resizing
* the main window instead of creating a separate window.
*
* Uses localStorage to persist original window state across navigation.
* New strategy:
* - Keep main window mounted/alive
* - Open mini player as a second Tauri window
* - Hide/show + focus windows without route remount in main
*/
import { getCurrentWindow, PhysicalSize, PhysicalPosition } from '@tauri-apps/api/window';
import { goto } from '$app/navigation';
import { Window, getCurrentWindow } from '@tauri-apps/api/window';
import { WebviewWindow } from '@tauri-apps/api/webviewWindow';
import {
getMiniPlayerState,
initMiniPlayerState,
setMiniPlayerAlwaysOnTop as setMiniPlayerAlwaysOnTopState,
setMiniPlayerOpen,
setMiniPlayerSurface,
type MiniPlayerSurface
} from '$lib/stores/uiStore';
import { getUseSystemTitleBar } from '$lib/stores/titleBarStore';
import { getUserItem } from '$lib/utils/userStorage';
// Miniplayer dimensions
const MINIPLAYER_WIDTH = 400;
const MINIPLAYER_HEIGHT = 200;
const MINI_PLAYER_LABEL = 'miniplayer';
const MAIN_WINDOW_LABEL = 'main';
const MINI_PLAYER_DEFAULT_VIEW_KEY = 'qbz-miniplayer-default-view';
const MINI_PLAYER_SURFACE_OPTIONS: MiniPlayerSurface[] = ['micro', 'compact', 'artwork', 'queue', 'lyrics'];
let isClosingAppFromMiniPlayer = false;
// Original app minimum size (from tauri.conf.json)
const ORIGINAL_MIN_WIDTH = 800;
const ORIGINAL_MIN_HEIGHT = 600;
// LocalStorage key for persisting window state
const STORAGE_KEY = 'miniplayer_original_state';
interface OriginalState {
width: number;
height: number;
x: number;
y: number;
maximized: boolean;
function isMiniPlayerSurface(value: string): value is MiniPlayerSurface {
return MINI_PLAYER_SURFACE_OPTIONS.includes(value as MiniPlayerSurface);
}
function saveOriginalState(state: OriginalState): void {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
console.log('[MiniPlayer] Saved state to localStorage:', state);
} catch (e) {
console.error('[MiniPlayer] Failed to save state:', e);
function resolveInitialSurface(requestedSurface?: MiniPlayerSurface): MiniPlayerSurface | null {
if (requestedSurface) {
return requestedSurface;
}
}
function loadOriginalState(): OriginalState | null {
try {
const data = localStorage.getItem(STORAGE_KEY);
if (data) {
const state = JSON.parse(data) as OriginalState;
console.log('[MiniPlayer] Loaded state from localStorage:', state);
return state;
}
} catch (e) {
console.error('[MiniPlayer] Failed to load state:', e);
const configuredDefault = getUserItem(MINI_PLAYER_DEFAULT_VIEW_KEY);
if (!configuredDefault || configuredDefault === 'remember') {
return null;
}
if (isMiniPlayerSurface(configuredDefault)) {
return configuredDefault;
}
return null;
}
function clearOriginalState(): void {
try {
localStorage.removeItem(STORAGE_KEY);
console.log('[MiniPlayer] Cleared state from localStorage');
} catch (e) {
console.error('[MiniPlayer] Failed to clear state:', e);
}
export interface AlwaysOnTopResult {
applied: boolean;
reason?: string;
}
/**
* Enter miniplayer mode - resize window and navigate to miniplayer route
*/
export async function enterMiniplayerMode(): Promise<void> {
console.log('[MiniPlayer] Entering miniplayer mode...');
function normalizeRoute(url: string): string {
// adapter-static fallback setup expects the app shell entrypoint.
// route segment is resolved by the SPA router after bootstrap.
return url.startsWith('/') ? url : `/${url}`;
}
try {
const window = getCurrentWindow();
async function waitForWindowCreation(windowRef: WebviewWindow): Promise<void> {
await new Promise<void>((resolve, reject) => {
let done = false;
// Store current window state
const maximized = await window.isMaximized();
const size = await window.innerSize();
const position = await window.innerPosition();
// Save to localStorage so it survives navigation
saveOriginalState({
width: size.width,
height: size.height,
x: position.x,
y: position.y,
maximized
void windowRef.once('tauri://created', () => {
if (done) return;
done = true;
resolve();
});
// If maximized, unmaximize first
if (maximized) {
await window.unmaximize();
void windowRef.once<string>('tauri://error', (event) => {
if (done) return;
done = true;
reject(new Error(String(event.payload)));
});
});
}
async function getOrCreateMiniPlayerWindow(): Promise<Window> {
const existing = await Window.getByLabel(MINI_PLAYER_LABEL);
if (existing) return existing;
const miniState = getMiniPlayerState();
const geometry = miniState.geometry;
const miniWindow = new WebviewWindow(MINI_PLAYER_LABEL, {
url: normalizeRoute('/miniplayer'),
title: 'QBZ Mini Player',
width: geometry.width,
height: geometry.height,
x: geometry.x,
y: geometry.y,
minWidth: 340,
minHeight: 57,
resizable: true,
decorations: getUseSystemTitleBar(),
alwaysOnTop: miniState.alwaysOnTop,
transparent: true,
focus: true
});
await waitForWindowCreation(miniWindow);
return miniWindow;
}
async function focusMainWindow(): Promise<void> {
const mainWindow = await Window.getByLabel(MAIN_WINDOW_LABEL);
if (!mainWindow) return;
await mainWindow.show();
await mainWindow.unminimize();
await mainWindow.setFocus();
}
/**
* Enter mini player mode:
* create/show mini player window and hide main window.
*/
export async function enterMiniplayerMode(surface?: MiniPlayerSurface): Promise<void> {
initMiniPlayerState();
const initialSurface = resolveInitialSurface(surface);
if (initialSurface) {
setMiniPlayerSurface(initialSurface);
}
try {
const currentWindow = getCurrentWindow();
const miniWindow = await getOrCreateMiniPlayerWindow();
await miniWindow.show();
await miniWindow.unminimize();
await miniWindow.setFocus();
// Hide main window only after mini is visible to avoid a blank app moment.
if (currentWindow.label === MAIN_WINDOW_LABEL) {
await currentWindow.hide();
} else {
const mainWindow = await Window.getByLabel(MAIN_WINDOW_LABEL);
if (mainWindow) await mainWindow.hide();
}
// IMPORTANT: Remove minimum size constraint first, then set size
console.log('[MiniPlayer] Removing min size constraint...');
await window.setMinSize(new PhysicalSize(MINIPLAYER_WIDTH, MINIPLAYER_HEIGHT));
// Small delay to ensure min size is applied before setting size
await new Promise(resolve => setTimeout(resolve, 50));
console.log('[MiniPlayer] Setting size to', MINIPLAYER_WIDTH, 'x', MINIPLAYER_HEIGHT);
await window.setSize(new PhysicalSize(MINIPLAYER_WIDTH, MINIPLAYER_HEIGHT));
await window.setResizable(true);
// Note: decorations stay false - app uses custom title bar (CSD)
await window.setAlwaysOnTop(true);
// Navigate to miniplayer route
await goto('/miniplayer');
console.log('[MiniPlayer] Entered miniplayer mode');
setMiniPlayerOpen(true);
console.info('[MiniPlayer] Entered mini player mode');
} catch (err) {
console.error('[MiniPlayer] Failed to enter miniplayer mode:', err);
console.error('[MiniPlayer] Failed to enter mini player mode:', err);
}
}
/**
* Exit miniplayer mode - restore original window state
* Exit mini player mode:
* hide mini player window and show/focus main window.
*/
export async function exitMiniplayerMode(): Promise<void> {
console.log('[MiniPlayer] exitMiniplayerMode called');
// Load original state from localStorage
const originalState = loadOriginalState();
console.log('[MiniPlayer] Original state:', originalState);
try {
const window = getCurrentWindow();
// Restore window properties
await window.setAlwaysOnTop(false);
// Note: decorations stay false - app uses custom title bar (CSD)
await window.setResizable(true);
// Restore minimum size constraint
console.log('[MiniPlayer] Restoring min size constraint...');
await window.setMinSize(new PhysicalSize(ORIGINAL_MIN_WIDTH, ORIGINAL_MIN_HEIGHT));
// Restore size
if (originalState) {
console.log('[MiniPlayer] Restoring size:', originalState.width, 'x', originalState.height);
await window.setSize(new PhysicalSize(originalState.width, originalState.height));
console.log('[MiniPlayer] Restoring position:', originalState.x, ',', originalState.y);
await window.setPosition(new PhysicalPosition(originalState.x, originalState.y));
if (originalState.maximized) {
console.log('[MiniPlayer] Restoring maximized state');
await window.maximize();
}
} else {
// Fallback to default size
console.log('[MiniPlayer] No original state, using defaults');
await window.setSize(new PhysicalSize(1280, 800));
const miniWindow = await Window.getByLabel(MINI_PLAYER_LABEL);
if (miniWindow) {
await miniWindow.hide();
}
// Clear saved state
clearOriginalState();
await focusMainWindow();
// Navigate back to main
console.log('[MiniPlayer] Navigating to /');
await goto('/');
console.log('[MiniPlayer] Exited miniplayer mode');
setMiniPlayerOpen(false);
console.info('[MiniPlayer] Restored main window');
} catch (err) {
console.error('[MiniPlayer] Failed to exit miniplayer mode:', err);
console.error('[MiniPlayer] Failed to restore main window:', err);
}
}
/**
* Set miniplayer always on top
* Best-effort always-on-top toggle.
*
* On some Wayland compositors this can be ignored or denied.
*/
export async function setMiniplayerAlwaysOnTop(alwaysOnTop: boolean): Promise<void> {
export async function setMiniplayerAlwaysOnTop(alwaysOnTop: boolean): Promise<AlwaysOnTopResult> {
try {
const window = getCurrentWindow();
await window.setAlwaysOnTop(alwaysOnTop);
const miniWindow = await Window.getByLabel(MINI_PLAYER_LABEL);
if (!miniWindow) {
setMiniPlayerAlwaysOnTopState(alwaysOnTop);
return { applied: false, reason: 'mini_window_not_open' };
}
await miniWindow.setAlwaysOnTop(alwaysOnTop);
setMiniPlayerAlwaysOnTopState(alwaysOnTop);
return { applied: true };
} catch (err) {
console.error('[MiniPlayer] Failed to set always on top:', err);
// Wayland or WM policy may reject always-on-top requests.
setMiniPlayerAlwaysOnTopState(false);
return {
applied: false,
reason: err instanceof Error ? err.message : String(err)
};
}
}
export async function closeMiniplayerWindow(): Promise<void> {
await exitMiniplayerMode();
}
/**
* Close the app from the mini player.
*
* Triggers the main window's close handler which either:
* - Hides all windows to tray (if close-to-tray is enabled)
* - Quits the app (if close-to-tray is disabled)
*/
export async function closeAppFromMiniplayer(): Promise<void> {
if (isClosingAppFromMiniPlayer) {
return;
}
isClosingAppFromMiniPlayer = true;
try {
const miniWindow = await Window.getByLabel(MINI_PLAYER_LABEL);
if (miniWindow) {
// Make the shutdown path single-window first to reduce teardown races.
await miniWindow.hide();
}
setMiniPlayerOpen(false);
await focusMainWindow();
await new Promise((resolve) => setTimeout(resolve, 80));
const mainWindow = await Window.getByLabel(MAIN_WINDOW_LABEL);
if (mainWindow) {
await mainWindow.close();
}
} catch (err) {
console.error('[MiniPlayer] Failed to close app:', err);
} finally {
isClosingAppFromMiniPlayer = false;
}
}
+2 -1
View File
@@ -185,7 +185,8 @@ export async function playTrack(
} else {
const result = await invoke<PlayTrackResult>('v2_play_track', {
trackId: track.id,
quality: getStreamingQuality()
quality: getStreamingQuality(),
durationSecs: track.duration ? Math.round(track.duration) : null
});
// Update track format based on actual stream format_id from Qobuz
+12 -6
View File
@@ -91,7 +91,8 @@ export async function getFormats(
export async function downloadAlbum(
albumId: string,
formatId: number,
destination: string
destination: string,
qualityDir: string = ''
): Promise<void> {
if (USE_MOCK) {
// In mock mode we just fire and forget the SSE stream
@@ -103,14 +104,16 @@ export async function downloadAlbum(
return invoke<void>('v2_purchases_download_album', {
albumId,
formatId,
destination
destination,
qualityDir
});
}
export async function downloadTrack(
trackId: number,
formatId: number,
destination: string
destination: string,
qualityDir: string = ''
): Promise<string> {
if (USE_MOCK) {
await fetch(`${MOCK_URL}/purchases/download/track/${trackId}`, {
@@ -121,7 +124,8 @@ export async function downloadTrack(
return invoke<string>('v2_purchases_download_track', {
trackId,
formatId,
destination
destination,
qualityDir
});
}
@@ -135,12 +139,14 @@ export async function getDownloadedTrackIds(): Promise<Set<number>> {
export async function markTrackDownloaded(
trackId: number,
albumId: string | undefined,
filePath: string
filePath: string,
formatId: number = 0
): Promise<void> {
return invoke<void>('v2_purchases_mark_downloaded', {
trackId,
albumId: albumId ?? null,
filePath
filePath,
formatId
});
}
+9 -5
View File
@@ -122,7 +122,7 @@ async function sendQconnectQueueCommandWithAdmission(
// ============ Queue Builders ============
export function buildQueueTrackFromQobuz(track: QobuzTrack): BackendQueueTrack {
const artwork = track.album?.image?.large || track.album?.image?.thumbnail || track.album?.image?.small || '';
const artwork = track.album?.image?.small || track.album?.image?.thumbnail || track.album?.image?.large || '';
// Log if track is not streamable (for debugging unavailable tracks)
if (track.streamable === false) {
@@ -143,7 +143,8 @@ export function buildQueueTrackFromQobuz(track: QobuzTrack): BackendQueueTrack {
album_id: track.album?.id || null,
artist_id: track.performer?.id ?? null,
streamable: track.streamable ?? true,
source: 'qobuz'
source: 'qobuz',
parental_warning: track.parental_warning ?? false
};
}
@@ -174,7 +175,8 @@ export function buildQueueTrackFromAlbumTrack(
album_id: track.albumId || albumId || null,
artist_id: track.artistId ?? artistId ?? null,
streamable: track.streamable ?? true,
source: 'qobuz'
source: 'qobuz',
parental_warning: track.parental_warning ?? false
};
}
@@ -198,7 +200,8 @@ export function buildQueueTrackFromPlaylistTrack(track: PlaylistTrack): BackendQ
album_id: track.albumId || null,
artist_id: track.artistId ?? null,
streamable: track.streamable ?? true,
source: 'qobuz'
source: 'qobuz',
parental_warning: track.parental_warning ?? false
};
}
@@ -492,7 +495,8 @@ export function buildQueueTrackFromDisplayTrack(track: DisplayTrack): BackendQue
is_local: track.isLocal ?? false,
album_id: track.albumId || null,
artist_id: track.artistId ?? null,
source: track.isLocal ? 'local' : 'qobuz'
source: track.isLocal ? 'local' : 'qobuz',
parental_warning: track.parental_warning ?? false
};
}
+48 -4
View File
@@ -27,6 +27,7 @@ const DEV_FAKE_BODY_KEY = 'qbz-updates-dev-fake-body';
let sessionId = '';
let launchFlowStarted = false;
let sessionIdSequence = 0;
/** Reset launch flow state on logout so re-login can re-evaluate. */
export function resetLaunchFlow(): void {
@@ -40,7 +41,19 @@ function ensureSessionId(): string {
sessionId = 'server';
return sessionId;
}
sessionId = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
let entropyPart = `${Date.now()}-${sessionIdSequence++}`;
if (typeof crypto !== 'undefined') {
if (typeof crypto.randomUUID === 'function') {
entropyPart = crypto.randomUUID();
} else if (typeof crypto.getRandomValues === 'function') {
const buffer = new Uint8Array(12);
crypto.getRandomValues(buffer);
entropyPart = Array.from(buffer, (byte) => byte.toString(16).padStart(2, '0')).join('');
}
}
sessionId = `${Date.now()}-${entropyPart}`;
try {
localStorage.setItem(SESSION_ID_KEY, sessionId);
} catch {
@@ -131,6 +144,7 @@ function getDevFakeRelease(currentVersion: string): ReleaseInfo | null {
export interface LaunchUpdateDecision {
currentVersion: string;
showFlatpakWelcome: boolean;
showSnapWelcome: boolean;
whatsNewRelease: ReleaseInfo | null;
updateRelease: ReleaseInfo | null;
}
@@ -160,6 +174,31 @@ export async function markFlatpakWelcomeShown(): Promise<void> {
}
}
/**
* Check if running in Snap and if welcome modal has been shown.
*/
async function shouldShowSnapWelcome(): Promise<boolean> {
try {
const isSnap = await invoke<boolean>('v2_is_running_in_snap');
if (!isSnap) return false;
const alreadyShown = await invoke<boolean>('has_snap_welcome_been_shown');
return !alreadyShown;
} catch {
return false;
}
}
/**
* Mark Snap welcome modal as shown.
*/
export async function markSnapWelcomeShown(): Promise<void> {
try {
await invoke('v2_mark_snap_welcome_shown');
} catch (error) {
console.debug('[Updates] Failed to mark snap welcome shown:', error);
}
}
/**
* Decide which modals should be shown on launch.
*
@@ -173,6 +212,7 @@ export async function decideLaunchModals(): Promise<LaunchUpdateDecision> {
return {
currentVersion: getCurrentVersion(),
showFlatpakWelcome: false,
showSnapWelcome: false,
whatsNewRelease: null,
updateRelease: null,
};
@@ -190,17 +230,21 @@ export async function decideLaunchModals(): Promise<LaunchUpdateDecision> {
const currentVersion = getCurrentVersion();
if (getSessionFlag(SESSION_CHECK_DONE_KEY)) {
return { currentVersion, showFlatpakWelcome: false, whatsNewRelease: null, updateRelease: null };
return { currentVersion, showFlatpakWelcome: false, showSnapWelcome: false, whatsNewRelease: null, updateRelease: null };
}
setSessionFlag(SESSION_CHECK_DONE_KEY);
// Collect all modals that need to be shown
let showFlatpakWelcome = false;
let showSnapWelcome = false;
let whatsNewRelease: ReleaseInfo | null = null;
let updateRelease: ReleaseInfo | null = null;
// Phase 1: Flatpak welcome (highest priority, shown first)
// Phase 1: Sandbox welcome (highest priority, shown first)
showFlatpakWelcome = await shouldShowFlatpakWelcome();
if (!showFlatpakWelcome) {
showSnapWelcome = await shouldShowSnapWelcome();
}
// Phase 2: What's New for current version
if (prefs.showWhatsNewOnLaunch && currentVersion) {
@@ -241,7 +285,7 @@ export async function decideLaunchModals(): Promise<LaunchUpdateDecision> {
}
}
return { currentVersion, showFlatpakWelcome, whatsNewRelease, updateRelease };
return { currentVersion, showFlatpakWelcome, showSnapWelcome, whatsNewRelease, updateRelease };
}
export async function openReleasePageAndAcknowledge(release: ReleaseInfo): Promise<void> {