diff --git a/qbz-nix-docs/OPTIMIZATION_PLAN.md b/qbz-nix-docs/OPTIMIZATION_PLAN.md
new file mode 100644
index 000000000..7010ba2a9
--- /dev/null
+++ b/qbz-nix-docs/OPTIMIZATION_PLAN.md
@@ -0,0 +1,306 @@
+# QBZ Performance Optimization Plan
+
+## Problem Statement
+QBZ consumes excessive resources when idle:
+- **~934MB RAM** (583MB main process + 351MB WebKitWebProcess)
+- **51 threads**
+- **~1.5% CPU** without playing audio
+- User was viewing ArtistDetailView (many images, long page)
+
+## Root Cause Analysis
+
+### Backend (Rust/Tauri)
+
+| Issue | Location | Impact |
+|-------|----------|--------|
+| Playback polling loop | `src-tauri/src/lib.rs:170-206` | 250ms interval, runs ALWAYS |
+| Audio thread polling | `src-tauri/src/player/mod.rs:498` | 100ms timeout, runs ALWAYS |
+| Media server HTTP polling | `src-tauri/src/cast/media_server.rs:62` | 250ms timeout, runs ALWAYS |
+| Audio cache in memory | `src-tauri/src/cache/mod.rs` | 500MB default limit |
+
+### Frontend (Svelte/WebKit)
+
+| Issue | Location | Impact |
+|-------|----------|--------|
+| **Lyrics 50ms interval** | `src/lib/stores/lyricsStore.ts:389` | 20 updates/sec even when hidden |
+| IntersectionObserver (4 thresholds) | `src/lib/components/views/ArtistDetailView.svelte:300` | Fires on every scroll |
+| No lazy loading for images | Multiple components | All images load immediately |
+| Transform scale on hover (JS) | `src/lib/components/AlbumCard.svelte:37` | Reflows on every hover |
+| 7 store subscriptions cascade | `src/routes/+page.svelte:1238-1336` | All fire on playback updates |
+| No list virtualization | QueuePanel, Favorites | Large DOM trees |
+| backdrop-filter blur | `src/lib/components/glass/GlassSurface.svelte:122` | GPU intensive |
+
+---
+
+## Phase 1: Critical Fixes (Idle CPU)
+
+### 1.1 Fix Lyrics Interval Leak
+**File:** `src/lib/stores/lyricsStore.ts`
+
+**Problem:** 50ms setInterval runs continuously even when lyrics sidebar is hidden.
+
+**Fix:**
+```typescript
+// Ensure interval is ONLY running when lyrics are visible AND playing
+export function startActiveLineUpdates(): void {
+ if (updateInterval !== null) return;
+ // Add visibility check here
+ updateInterval = window.setInterval(() => {
+ if (parsedLyrics.isSynced) {
+ updateActiveLine();
+ }
+ }, 50);
+}
+
+// Add explicit cleanup
+export function stopActiveLineUpdates(): void {
+ if (updateInterval !== null) {
+ window.clearInterval(updateInterval);
+ updateInterval = null;
+ }
+}
+```
+
+**Also fix in +page.svelte:** Ensure the $effect properly cleans up on all state changes.
+
+### 1.2 Conditional Playback Polling
+**File:** `src-tauri/src/lib.rs:170-206`
+
+**Problem:** 250ms polling loop runs forever, even when paused.
+
+**Fix:** Only poll when playing:
+```rust
+loop {
+ thread::sleep(Duration::from_millis(250));
+
+ // Skip polling if not playing
+ if !player_handle.is_playing() {
+ thread::sleep(Duration::from_millis(1000)); // Slower poll when paused
+ continue;
+ }
+
+ // ... rest of polling logic
+}
+```
+
+### 1.3 Reduce Audio Thread Timeout When Idle
+**File:** `src-tauri/src/player/mod.rs:498`
+
+**Problem:** 100ms recv_timeout even when not playing.
+
+**Fix:** Use longer timeout when idle:
+```rust
+let timeout = if is_playing.load(Ordering::Relaxed) {
+ Duration::from_millis(100)
+} else {
+ Duration::from_millis(500) // Slower when paused
+};
+
+match rx.recv_timeout(timeout) { ... }
+```
+
+---
+
+## Phase 2: Image Optimization
+
+### 2.1 Add Lazy Loading to All Images
+**Files:**
+- `src/lib/components/AlbumCard.svelte`
+- `src/lib/components/views/ArtistDetailView.svelte`
+- `src/lib/components/TrackRow.svelte`
+
+**Fix:** Add `loading="lazy"` to all `
` tags:
+```svelte
+
+```
+
+### 2.2 Use Smaller Image Sizes from Qobuz API
+**Current:** Using full-size images (e.g., 600x600)
+**Fix:** Request smaller sizes for thumbnails:
+- Cards: `?w=300`
+- Sidebar: `?w=100`
+- Detail headers: `?w=600`
+
+### 2.3 Optimize IntersectionObserver in ArtistDetailView
+**File:** `src/lib/components/views/ArtistDetailView.svelte:300-343`
+
+**Fix:** Reduce thresholds and debounce:
+```typescript
+jumpObserver = new IntersectionObserver(
+ (entries) => {
+ // Debounce callback
+ requestAnimationFrame(() => {
+ const visible = entries.find(entry => entry.isIntersecting);
+ if (visible) {
+ const targetId = sectionByElement.get(visible.target as HTMLDivElement);
+ if (targetId) activeJumpSection = targetId;
+ }
+ });
+ },
+ {
+ root: artistDetailEl,
+ rootMargin: '-20% 0px -60% 0px',
+ threshold: [0.5] // Single threshold instead of 4
+ }
+);
+```
+
+---
+
+## Phase 3: CSS Performance
+
+### 3.1 Use CSS :hover Instead of JS State
+**File:** `src/lib/components/AlbumCard.svelte`
+
+**Current (Bad):**
+```svelte
+
+```
+
+**Fix (Good):**
+```svelte
+
+
+
+```
+
+### 3.2 Conditional Glass Effects
+**File:** `src/lib/components/glass/GlassSurface.svelte`
+
+**Fix:** Add prop to disable blur when not needed:
+```svelte
+
+
+
+```
+
+---
+
+## Phase 4: Memory Optimization
+
+### 4.1 Reduce Default Audio Cache Size
+**File:** `src-tauri/src/cache/mod.rs`
+
+**Current:** 500MB default
+**Fix:** Reduce to 200MB or make configurable:
+```rust
+const DEFAULT_CACHE_SIZE: usize = 200 * 1024 * 1024; // 200MB
+```
+
+### 4.2 Add Cache Size Setting
+**New setting in Settings UI:**
+- Audio cache size: 100MB / 200MB / 500MB / 1GB
+- Store in settings, pass to AudioCache on init
+
+### 4.3 Lazy Initialize Cast Services
+**File:** `src-tauri/src/lib.rs`
+
+**Current:** Media server starts immediately on app launch.
+**Fix:** Only start when user opens cast panel:
+```rust
+// Don't start in build()
+// Start on first cast_start_discovery() call instead
+```
+
+---
+
+## Phase 5: List Virtualization (Future)
+
+### 5.1 Virtual Scrolling for QueuePanel
+**File:** `src/lib/components/QueuePanel.svelte`
+
+**Implementation:** Use svelte-virtual-list or custom implementation:
+- Only render visible items + buffer
+- Recycle DOM nodes on scroll
+- Target: Handle 1000+ tracks smoothly
+
+### 5.2 Virtual Scrolling for Favorites
+Similar implementation for favorites list.
+
+---
+
+## Implementation Order
+
+| Priority | Task | Impact | Effort |
+|----------|------|--------|--------|
+| P0 | Fix lyrics interval leak | HIGH | LOW |
+| P0 | Conditional playback polling | HIGH | LOW |
+| P1 | Lazy load images | HIGH | MEDIUM |
+| P1 | Reduce IntersectionObserver thresholds | MEDIUM | LOW |
+| P1 | CSS :hover instead of JS state | MEDIUM | LOW |
+| P2 | Reduce audio thread timeout when idle | MEDIUM | LOW |
+| P2 | Reduce default cache size | MEDIUM | LOW |
+| P2 | Smaller image sizes from API | MEDIUM | MEDIUM |
+| P3 | Lazy init cast services | LOW | MEDIUM |
+| P3 | Conditional glass blur | LOW | LOW |
+| P4 | List virtualization | MEDIUM | HIGH |
+
+---
+
+## Verification
+
+### Before/After Metrics
+1. Open btop or htop
+2. Launch QBZ
+3. Navigate to ArtistDetailView (same artist with many albums)
+4. Pause playback
+5. Wait 30 seconds
+6. Record: CPU%, Memory, Thread count
+
+### Expected Results After Optimization
+- **CPU (idle):** < 0.5% (down from 1.5%)
+- **RAM:** < 600MB total (down from ~934MB)
+- **Threads:** < 30 (down from 51)
+
+### Test Cases
+1. [ ] App idle on home view - CPU should be ~0%
+2. [ ] App idle on ArtistDetailView - CPU should be < 0.5%
+3. [ ] Playback paused - CPU should drop to idle levels
+4. [ ] Scroll through long artist page - no stuttering
+5. [ ] Open/close lyrics sidebar - no CPU spike after close
+6. [ ] Memory doesn't grow over time with normal use
+
+---
+
+## Files to Modify
+
+| File | Changes |
+|------|---------|
+| `src/lib/stores/lyricsStore.ts` | Fix interval cleanup |
+| `src-tauri/src/lib.rs` | Conditional polling |
+| `src-tauri/src/player/mod.rs` | Dynamic recv_timeout |
+| `src/lib/components/AlbumCard.svelte` | CSS hover, lazy img |
+| `src/lib/components/views/ArtistDetailView.svelte` | Reduce thresholds, lazy img |
+| `src/lib/components/TrackRow.svelte` | Lazy img |
+| `src-tauri/src/cache/mod.rs` | Reduce default size |
+| `src/lib/components/glass/GlassSurface.svelte` | Conditional blur |
+
+---
+
+## Notes
+
+- WebKitWebProcess (351MB) is largely unavoidable - it's the WebView rendering engine
+- Some memory is WebKit's internal caches and can't be controlled
+- Focus on CPU optimization first as it's more noticeable to users
+- Consider adding a "Low Resource Mode" toggle in settings for potato PCs
diff --git a/scripts/build-and-run.sh b/scripts/build-and-run.sh
new file mode 100755
index 000000000..f4accbced
--- /dev/null
+++ b/scripts/build-and-run.sh
@@ -0,0 +1,44 @@
+#!/bin/bash
+# Build QBZ in release mode and run the binary
+# Usage: ./scripts/build-and-run.sh [--skip-build]
+
+set -e
+
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
+BINARY_PATH="$PROJECT_DIR/src-tauri/target/release/qbz-nix"
+
+cd "$PROJECT_DIR"
+
+# Check for --skip-build flag
+SKIP_BUILD=false
+if [[ "$1" == "--skip-build" ]]; then
+ SKIP_BUILD=true
+fi
+
+# Build if needed
+if [[ "$SKIP_BUILD" == false ]] || [[ ! -f "$BINARY_PATH" ]]; then
+ echo "=== Building QBZ (release mode) ==="
+ echo "This may take a few minutes..."
+
+ # Use npm run tauri build which handles frontend + backend properly
+ npm run tauri build -- --no-bundle
+
+ echo ""
+ echo "=== Build complete ==="
+ echo "Binary: $BINARY_PATH"
+ echo "Size: $(du -h "$BINARY_PATH" | cut -f1)"
+ echo ""
+fi
+
+# Kill any existing instance
+pkill -f "qbz-nix" 2>/dev/null || true
+sleep 0.5
+
+echo "=== Running QBZ ==="
+echo "Monitor with: btop or htop"
+echo "Press Ctrl+C to stop"
+echo ""
+
+# Run the binary
+exec "$BINARY_PATH"
diff --git a/scripts/monitor-resources.sh b/scripts/monitor-resources.sh
new file mode 100755
index 000000000..7da693f91
--- /dev/null
+++ b/scripts/monitor-resources.sh
@@ -0,0 +1,52 @@
+#!/bin/bash
+# Monitor QBZ resource usage
+# Usage: ./scripts/monitor-resources.sh
+
+echo "=== QBZ Resource Monitor ==="
+echo "Waiting for QBZ process..."
+echo ""
+
+# Wait for process to start
+while ! pgrep -f "qbz-nix" > /dev/null 2>&1; do
+ sleep 0.5
+done
+
+PID=$(pgrep -f "qbz-nix" | head -1)
+echo "Found QBZ PID: $PID"
+echo ""
+echo "Press Ctrl+C to stop monitoring"
+echo "==========================================="
+echo ""
+
+# Monitor loop
+while true; do
+ if ! ps -p "$PID" > /dev/null 2>&1; then
+ echo "QBZ process ended"
+ exit 0
+ fi
+
+ # Get stats
+ CPU=$(ps -p "$PID" -o %cpu= 2>/dev/null | tr -d ' ')
+ MEM=$(ps -p "$PID" -o rss= 2>/dev/null | tr -d ' ')
+ MEM_MB=$((MEM / 1024))
+ THREADS=$(ps -p "$PID" -o nlwp= 2>/dev/null | tr -d ' ')
+
+ # Get WebKit process if exists
+ WEBKIT_PID=$(pgrep -f "WebKitWebProcess" | head -1 2>/dev/null)
+ if [[ -n "$WEBKIT_PID" ]]; then
+ WEBKIT_MEM=$(ps -p "$WEBKIT_PID" -o rss= 2>/dev/null | tr -d ' ')
+ WEBKIT_MB=$((WEBKIT_MEM / 1024))
+ WEBKIT_CPU=$(ps -p "$WEBKIT_PID" -o %cpu= 2>/dev/null | tr -d ' ')
+ else
+ WEBKIT_MB=0
+ WEBKIT_CPU=0
+ fi
+
+ TOTAL_MB=$((MEM_MB + WEBKIT_MB))
+
+ # Print stats
+ printf "\r[QBZ] CPU: %5s%% | RAM: %4dMB | Threads: %2s | [WebKit] CPU: %5s%% | RAM: %4dMB | TOTAL: %4dMB" \
+ "$CPU" "$MEM_MB" "$THREADS" "$WEBKIT_CPU" "$WEBKIT_MB" "$TOTAL_MB"
+
+ sleep 1
+done
diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs
index 135ef7a73..27beae47c 100644
--- a/src-tauri/src/lib.rs
+++ b/src-tauri/src/lib.rs
@@ -173,8 +173,18 @@ pub fn run() {
let mut last_track_id: u64 = 0;
loop {
- std::thread::sleep(std::time::Duration::from_millis(250));
+ // Check playing state first to determine sleep duration
+ let is_playing = player_state.is_playing();
+ // Adaptive polling: fast (250ms) when playing, slow (1000ms) when idle
+ let sleep_duration = if is_playing {
+ std::time::Duration::from_millis(250)
+ } else {
+ std::time::Duration::from_millis(1000)
+ };
+ std::thread::sleep(sleep_duration);
+
+ // Re-check after sleep (state might have changed)
let is_playing = player_state.is_playing();
let position = player_state.current_position();
let duration = player_state.duration();
diff --git a/src-tauri/src/player/mod.rs b/src-tauri/src/player/mod.rs
index f24d561da..e4d1f561d 100644
--- a/src-tauri/src/player/mod.rs
+++ b/src-tauri/src/player/mod.rs
@@ -494,8 +494,13 @@ impl Player {
log::info!("Audio thread ready and waiting for commands");
loop {
- // Use recv_timeout so we can periodically check if sink is empty
- match rx.recv_timeout(Duration::from_millis(100)) {
+ // Adaptive timeout: fast (100ms) when playing, slow (500ms) when idle
+ let timeout = if thread_state.is_playing.load(Ordering::SeqCst) {
+ Duration::from_millis(100)
+ } else {
+ Duration::from_millis(500)
+ };
+ match rx.recv_timeout(timeout) {
Ok(AudioCommand::Play { data, track_id, duration_secs }) => {
log::info!("Audio thread: playing track {}", track_id);
diff --git a/src/lib/stores/lyricsStore.ts b/src/lib/stores/lyricsStore.ts
index 07472e42d..80b9968f6 100644
--- a/src/lib/stores/lyricsStore.ts
+++ b/src/lib/stores/lyricsStore.ts
@@ -382,6 +382,14 @@ export function reset(): void {
// ============ Auto-update ============
let updateInterval: number | null = null;
+let isUpdatesActive = false;
+
+/**
+ * Check if active line updates are currently running
+ */
+export function isActiveLineUpdatesRunning(): boolean {
+ return updateInterval !== null;
+}
/**
* Start auto-updating active line (call when lyrics are synced and playing)
@@ -389,11 +397,16 @@ let updateInterval: number | null = null;
export function startActiveLineUpdates(): void {
if (updateInterval !== null) return;
- console.log('[Lyrics] Starting active line updates, isSynced:', parsedLyrics.isSynced);
+ isUpdatesActive = true;
+ console.log('[Lyrics] Starting active line updates');
updateInterval = window.setInterval(() => {
- if (parsedLyrics.isSynced) {
- updateActiveLine();
+ // Self-check: stop if no longer needed
+ if (!isUpdatesActive || !parsedLyrics.isSynced) {
+ console.log('[Lyrics] Auto-stopping interval (conditions no longer met)');
+ stopActiveLineUpdates();
+ return;
}
+ updateActiveLine();
}, 50); // 50ms for smooth progress
}
@@ -401,7 +414,9 @@ export function startActiveLineUpdates(): void {
* Stop auto-updating active line
*/
export function stopActiveLineUpdates(): void {
+ isUpdatesActive = false;
if (updateInterval !== null) {
+ console.log('[Lyrics] Stopping active line updates');
clearInterval(updateInterval);
updateInterval = null;
}
diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte
index 006f93bc5..4c5da0f09 100644
--- a/src/routes/+page.svelte
+++ b/src/routes/+page.svelte
@@ -1411,6 +1411,10 @@
} else {
stopActiveLineUpdates();
}
+ // Cleanup on effect re-run or component unmount
+ return () => {
+ stopActiveLineUpdates();
+ };
});
// Derived values for NowPlayingBar