Merge branch 'pre-release' into fixes/1.1.10/debug

This commit is contained in:
Victor RH committed 2026-02-09 07:54:28 -06:00
commit af36b08af1
47 files changed
+5554 -970

No files matched your search

+43 -3
View File
@@ -27,15 +27,55 @@ What actually happened.
## Environment
- QBZ version:
- Installation method: <!-- AppImage / .deb / .rpm / AUR / Tarball / Flatpak / Built from source -->
- Distribution:
- Desktop environment / WM:
- Display server: <!-- Wayland / X11 -->
- GPU: <!-- e.g. NVIDIA RTX 3070, AMD RX 6600 XT, Intel Iris Xe -->
- Audio backend (PipeWire / ALSA Direct / PulseAudio):
- Audio device / DAC (if relevant):
## Logs / screenshots
## Logs
If applicable, add logs or screenshots.
Please redact any sensitive information.
Logs help us diagnose problems much faster. To share them:
1. Open **Settings > Developer Mode > View Logs**
2. Click **Upload** on both the **Terminal** and **Console** tabs
3. Paste both URLs here:
- Terminal log URL:
- Console log URL:
<details>
<summary><strong>If QBZ crashes on startup</strong> (click to expand)</summary>
Run QBZ from a terminal to capture the output. Use the command that matches your installation:
```bash
# AppImage
./QBZ*.AppImage 2>&1 | tee /tmp/qbz-crash.log
# AUR / Tarball
qbz 2>&1 | tee /tmp/qbz-crash.log
# .deb / .rpm
qbz 2>&1 | tee /tmp/qbz-crash.log
# Flatpak
flatpak run com.blitzfc.qbz 2>&1 | tee /tmp/qbz-crash.log
```
Then upload the log and paste the URL here:
```bash
curl -F'file=@/tmp/qbz-crash.log' https://0x0.st
```
</details>
## Screenshots
If applicable, add screenshots to help illustrate the issue.
## Additional context
+3 -1
View File
@@ -20,10 +20,12 @@ Share your thoughts, impressions, or experience using QBZ.
## Environment (optional)
- QBZ version:
- Installation method: <!-- AppImage / .deb / .rpm / AUR / Tarball / Flatpak / Built from source -->
- Distribution:
- Desktop environment:
- Audio backend:
## Additional notes
Anything else you’d like to share.
Anything else you'd like to share.
+8 -1
View File
@@ -47,8 +47,15 @@ jobs:
- name: Install system dependencies
run: |
sudo apt-get update
# Pin WebKitGTK to 2.44.0-2 to prevent EGL_BAD_PARAMETER crash in AppImage
# See: https://github.com/tauri-apps/tauri/issues/11994
sudo apt-get install -y \
libwebkit2gtk-4.1-dev \
libwebkit2gtk-4.1-0=2.44.0-2 \
libwebkit2gtk-4.1-dev=2.44.0-2 \
libjavascriptcoregtk-4.1-0=2.44.0-2 \
libjavascriptcoregtk-4.1-dev=2.44.0-2 \
gir1.2-javascriptcoregtk-4.1=2.44.0-2 \
gir1.2-webkit2-4.1=2.44.0-2 \
libgtk-3-dev \
libappindicator3-dev \
librsvg2-dev \
+9 -2
View File
@@ -13,7 +13,7 @@ on:
jobs:
build-linux:
runs-on: ubuntu-latest
runs-on: ubuntu-24.04
permissions:
contents: write
steps:
@@ -80,8 +80,15 @@ jobs:
- name: Install system dependencies
run: |
sudo apt-get update
# Pin WebKitGTK to 2.44.0-2 to prevent EGL_BAD_PARAMETER crash in AppImage
# See: https://github.com/tauri-apps/tauri/issues/11994
sudo apt-get install -y \
libwebkit2gtk-4.1-dev \
libwebkit2gtk-4.1-0=2.44.0-2 \
libwebkit2gtk-4.1-dev=2.44.0-2 \
libjavascriptcoregtk-4.1-0=2.44.0-2 \
libjavascriptcoregtk-4.1-dev=2.44.0-2 \
gir1.2-javascriptcoregtk-4.1=2.44.0-2 \
gir1.2-webkit2-4.1=2.44.0-2 \
libgtk-3-dev \
libappindicator3-dev \
librsvg2-dev \
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "qbz-nix",
"version": "1.1.8-rc-5",
"version": "1.1.9",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "qbz-nix",
"version": "1.1.8-rc-5",
"version": "1.1.9",
"license": "MIT",
"dependencies": {
"@tauri-apps/api": "^2",
+1
View File
@@ -10,6 +10,7 @@
"preview": "vite preview",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"check:no-t-shadow": "bash ./scripts/check-no-t-shadow.sh",
"test": "vitest",
"test:run": "vitest run",
"tauri": "tauri"
+44
View File
@@ -0,0 +1,44 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
SRC_DIR="${ROOT_DIR}/src"
echo "Checking for forbidden i18n 't' shadowing patterns in .svelte files..."
had_issues=0
check_pattern() {
local label="$1"
local pattern="$2"
if rg -n --glob '*.svelte' "${pattern}" "${SRC_DIR}" >/tmp/qbz_t_shadow_check.out 2>/dev/null; then
echo
echo "Found ${label}:"
cat /tmp/qbz_t_shadow_check.out
had_issues=1
fi
}
# Template loop variable shadowing.
check_pattern "template each-loop shadowing (as t)" '\{#each[^\n]* as t\b'
# Dangerous callbacks in files using $t(...).
while IFS= read -r file; do
if rg -q '\b(map|forEach|filter|find|some|every|reduce)\(\s*t\s*=>|\b(map|forEach|filter|find|some|every|reduce)\(\s*\([^)]*,\s*t\s*\)\s*=>' "${file}"; then
echo
echo "Found callback shadowing in file using \$t(...): ${file}"
rg -n '\b(map|forEach|filter|find|some|every|reduce)\(\s*t\s*=>|\b(map|forEach|filter|find|some|every|reduce)\(\s*\([^)]*,\s*t\s*\)\s*=>' "${file}"
had_issues=1
fi
done < <(rg -l --glob '*.svelte' '\$t\(' "${SRC_DIR}")
# Any $derived expression using variable name t.
check_pattern "usage of t inside \$derived" '\$derived\([^)]*\bt\b'
if [[ "${had_issues}" -ne 0 ]]; then
echo
echo "check-no-t-shadow: FAIL"
exit 1
fi
echo "check-no-t-shadow: OK"
+1
View File
@@ -936,6 +936,7 @@ async fn play_album(
album_id: Some(album.id.clone()),
artist_id: t.performer.as_ref().map(|p| p.id),
streamable: t.streamable,
source: Some("qobuz".to_string()),
}
}).collect()
} else {
@@ -37,6 +37,7 @@ pub fn set_playback_context(
let content_source = match source.as_str() {
"qobuz" => ContentSource::Qobuz,
"local" => ContentSource::Local,
"plex" => ContentSource::Plex,
_ => return Err(format!("Invalid source: {}", source)),
};
+1
View File
@@ -327,6 +327,7 @@ fn track_to_queue_track(track: &Track) -> QueueTrack {
album_id,
artist_id,
streamable: track.streamable,
source: Some("qobuz".to_string()),
}
}
+119
View File
@@ -0,0 +1,119 @@
//! Graphics settings persistence
//!
//! Stores GPU/rendering preferences that take effect before WebView initialization.
//! These settings are device-level (not per-user) and persist across sessions.
//!
//! - hardware_acceleration: opt-in GPU rendering (default: off)
//! Env var QBZ_HARDWARE_ACCEL=1|0 always overrides the stored value.
use rusqlite::{Connection, params};
use serde::{Deserialize, Serialize};
use std::sync::{Arc, Mutex};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GraphicsSettings {
/// Enable hardware-accelerated rendering (requires restart)
pub hardware_acceleration: bool,
}
impl Default for GraphicsSettings {
fn default() -> Self {
Self {
hardware_acceleration: false,
}
}
}
pub struct GraphicsSettingsStore {
conn: Connection,
}
impl GraphicsSettingsStore {
pub fn new() -> Result<Self, String> {
let data_dir = dirs::data_dir()
.ok_or("Could not determine data directory")?
.join("qbz");
std::fs::create_dir_all(&data_dir)
.map_err(|e| format!("Failed to create data directory: {}", e))?;
let db_path = data_dir.join("graphics_settings.db");
let conn = Connection::open(&db_path)
.map_err(|e| format!("Failed to open graphics settings database: {}", e))?;
conn.execute_batch(
"CREATE TABLE IF NOT EXISTS graphics_settings (
id INTEGER PRIMARY KEY CHECK (id = 1),
hardware_acceleration INTEGER NOT NULL DEFAULT 0
);
INSERT OR IGNORE INTO graphics_settings (id, hardware_acceleration) VALUES (1, 0);"
).map_err(|e| format!("Failed to create graphics settings table: {}", e))?;
Ok(Self { conn })
}
pub fn get_settings(&self) -> Result<GraphicsSettings, String> {
self.conn
.query_row(
"SELECT hardware_acceleration FROM graphics_settings WHERE id = 1",
[],
|row| {
Ok(GraphicsSettings {
hardware_acceleration: row.get::<_, i64>(0)? != 0,
})
},
)
.map_err(|e| format!("Failed to get graphics settings: {}", e))
}
pub fn set_hardware_acceleration(&self, enabled: bool) -> Result<(), String> {
self.conn
.execute(
"UPDATE graphics_settings SET hardware_acceleration = ?1 WHERE id = 1",
params![enabled as i64],
)
.map_err(|e| format!("Failed to set hardware_acceleration: {}", e))?;
Ok(())
}
}
/// Thread-safe wrapper for Tauri state management
pub struct GraphicsSettingsState {
pub store: Arc<Mutex<Option<GraphicsSettingsStore>>>,
}
impl GraphicsSettingsState {
pub fn new() -> Result<Self, String> {
Ok(Self {
store: Arc::new(Mutex::new(Some(GraphicsSettingsStore::new()?))),
})
}
pub fn new_empty() -> Self {
Self {
store: Arc::new(Mutex::new(None)),
}
}
}
// Tauri commands
#[tauri::command]
pub fn get_graphics_settings(
state: tauri::State<'_, GraphicsSettingsState>,
) -> Result<GraphicsSettings, String> {
let guard = state.store.lock().map_err(|e| format!("Lock error: {}", e))?;
let store = guard.as_ref().ok_or("Graphics settings store not initialized")?;
store.get_settings()
}
#[tauri::command]
pub fn set_hardware_acceleration(
state: tauri::State<'_, GraphicsSettingsState>,
enabled: bool,
) -> Result<(), String> {
log::info!("[GraphicsSettings] Setting hardware_acceleration to {} (restart required)", enabled);
let guard = state.store.lock().map_err(|e| format!("Lock error: {}", e))?;
let store = guard.as_ref().ok_or("Graphics settings store not initialized")?;
store.set_hardware_acceleration(enabled)
}
+8
View File
@@ -12,6 +12,7 @@
pub mod audio_settings;
pub mod developer_settings;
pub mod download_settings;
pub mod graphics_settings;
pub mod playback_preferences;
pub mod favorites_preferences;
pub mod favorites_cache;
@@ -111,3 +112,10 @@ pub use developer_settings::{
get_developer_settings,
set_developer_force_dmabuf,
};
pub use graphics_settings::{
GraphicsSettings,
GraphicsSettingsState,
get_graphics_settings,
set_hardware_acceleration,
};
+28
View File
@@ -27,6 +27,7 @@ pub mod musicbrainz;
pub mod network;
pub mod offline;
pub mod playback_context;
pub mod plex;
pub mod player;
pub mod playlist_import;
pub mod queue;
@@ -241,6 +242,11 @@ pub fn run() {
log::warn!("Failed to initialize developer settings: {}. Using empty state.", e);
config::developer_settings::DeveloperSettingsState::new_empty()
});
let graphics_settings_state = config::graphics_settings::GraphicsSettingsState::new()
.unwrap_or_else(|e| {
log::warn!("Failed to initialize graphics settings: {}. Using empty state.", e);
config::graphics_settings::GraphicsSettingsState::new_empty()
});
// Clone settings for use in closures
let enable_tray = tray_settings.enable_tray;
@@ -418,6 +424,7 @@ pub fn run() {
.manage(artist_vectors_state)
.manage(blacklist_state)
.manage(developer_settings_state)
.manage(graphics_settings_state)
.invoke_handler(tauri::generate_handler![
// Auth commands
commands::init_client,
@@ -681,6 +688,24 @@ pub fn run() {
cast::dlna::commands::dlna_stop,
cast::dlna::commands::dlna_seek,
cast::dlna::commands::dlna_set_volume,
// Plex LAN-only POC commands
plex::plex_ping,
plex::plex_get_music_sections,
plex::plex_get_section_tracks,
plex::plex_get_track_metadata,
plex::plex_play_track,
plex::plex_auth_pin_start,
plex::plex_auth_pin_check,
plex::plex_open_auth_url,
plex::plex_cache_get_sections,
plex::plex_cache_save_sections,
plex::plex_cache_get_tracks,
plex::plex_cache_save_tracks,
plex::plex_cache_update_track_quality,
plex::plex_cache_get_albums,
plex::plex_cache_get_album_tracks,
plex::plex_cache_search_tracks,
plex::plex_cache_clear,
// AirPlay casting commands - DISABLED until RAOP implementation is complete
// See docs/AIRPLAY_IMPLEMENTATION_STATUS.md for details
// cast::airplay::commands::airplay_start_discovery,
@@ -903,6 +928,9 @@ pub fn run() {
// Developer settings commands
config::developer_settings::get_developer_settings,
config::developer_settings::set_developer_force_dmabuf,
// Graphics settings commands
config::graphics_settings::get_graphics_settings,
config::graphics_settings::set_hardware_acceleration,
// Log capture commands
logging::get_backend_logs,
logging::upload_logs_to_paste,
+58 -102
View File
@@ -109,11 +109,12 @@ fn main() {
// Addresses: https://github.com/vicrodh/qbz/issues/6
// https://github.com/vicrodh/qbz/issues/59
//
// WebKitGTK's DMA-BUF renderer can crash on Wayland with
// "Could not create default EGL display: EGL_BAD_PARAMETER. Aborting..."
// Known triggers: NVIDIA GPUs, AppImage builds (WebKitGTK version mismatch
// between build and host), and virtual machines.
// All env-var mitigations must be set BEFORE the WebView is initialized.
// The primary EGL crash ("Could not create default EGL display") in
// AppImage is fixed at the build level by pinning WebKitGTK to 2.44.0-2
// in the CI workflow (see tauri-apps/tauri#11994).
//
// Runtime workarounds below handle NVIDIA DMA-BUF issues, VM detection,
// and provide escape hatches for edge cases.
#[cfg(target_os = "linux")]
{
let is_wayland = std::env::var_os("WAYLAND_DISPLAY").is_some()
@@ -122,8 +123,28 @@ fn main() {
let is_vm = is_virtual_machine();
let force_software = std::env::var("QBZ_SOFTWARE_RENDER").as_deref() == Ok("1");
// Graphics settings: hardware acceleration opt-in (from Settings > Appearance)
// Default is OFF — safest for AppImage across heterogeneous hardware.
// Env var QBZ_HARDWARE_ACCEL=1|0 ALWAYS overrides the DB value (crash recovery).
let hw_accel_db = qbz_nix_lib::config::graphics_settings::GraphicsSettingsStore::new()
.ok()
.and_then(|store| store.get_settings().ok())
.map(|s| s.hardware_acceleration)
.unwrap_or(false);
let hardware_accel = match std::env::var("QBZ_HARDWARE_ACCEL").as_deref() {
Ok("1") => {
qbz_nix_lib::logging::log_startup("[QBZ] Env override: QBZ_HARDWARE_ACCEL=1 (GPU rendering forced on)");
true
}
Ok("0") => {
qbz_nix_lib::logging::log_startup("[QBZ] Env override: QBZ_HARDWARE_ACCEL=0 (GPU rendering forced off)");
false
}
_ => hw_accel_db,
};
qbz_nix_lib::logging::log_startup(&format!("[QBZ] Hardware acceleration: {}", if hardware_accel { "enabled" } else { "disabled (default)" }));
// Developer settings: force_dmabuf override (from Settings > Developer Mode)
// This sets the env var BEFORE the check below, so it integrates seamlessly
let dev_force_dmabuf = qbz_nix_lib::config::developer_settings::DeveloperSettingsStore::new()
.ok()
.and_then(|store| store.get_settings().ok())
@@ -135,12 +156,12 @@ fn main() {
qbz_nix_lib::logging::log_startup("[QBZ] To reset: run `qbz --reset-dmabuf`");
}
// User overrides - these ALWAYS take precedence
// User overrides
let force_dmabuf = std::env::var("QBZ_FORCE_DMABUF").as_deref() == Ok("1");
let disable_dmabuf = std::env::var("QBZ_DISABLE_DMABUF").as_deref() == Ok("1");
let force_x11 = std::env::var("QBZ_FORCE_X11").as_deref() == Ok("1");
// Diagnostic logging for transparency and support
// Diagnostic logging
qbz_nix_lib::logging::log_startup(&format!("[QBZ] Display server: {}", if is_wayland { "Wayland" } else { "X11" }));
if has_nvidia {
qbz_nix_lib::logging::log_startup("[QBZ] NVIDIA GPU detected");
@@ -149,17 +170,9 @@ fn main() {
qbz_nix_lib::logging::log_startup("[QBZ] Virtual machine detected");
}
// AppImage detection (APPIMAGE/APPDIR set by the AppImage runtime)
let is_appimage = std::env::var_os("APPIMAGE").is_some()
|| std::env::var_os("APPDIR").is_some();
if is_appimage {
qbz_nix_lib::logging::log_startup("[QBZ] Running as AppImage");
}
// --- Software rendering (GL layer) ---
// LIBGL_ALWAYS_SOFTWARE=1 forces Mesa to use llvmpipe for all GL
// contexts. Only needed in VMs or when the user explicitly requests it.
// Does NOT affect WebKit's DMA-BUF renderer decision (that is below).
// LIBGL_ALWAYS_SOFTWARE=1 forces Mesa to use llvmpipe for all GL contexts.
// Only needed in VMs or when the user explicitly requests it.
if force_software {
qbz_nix_lib::logging::log_startup("[QBZ] User override: forcing software rendering (QBZ_SOFTWARE_RENDER=1)");
std::env::set_var("LIBGL_ALWAYS_SOFTWARE", "1");
@@ -168,93 +181,36 @@ fn main() {
std::env::set_var("LIBGL_ALWAYS_SOFTWARE", "1");
}
// --- Opt-in: disable WebKit compositing mode ---
// WEBKIT_DISABLE_COMPOSITING_MODE=1 was previously set automatically
// on Wayland, but it causes a "ghost app" (process alive, tray/MPRIS OK,
// UI never renders) on Fedora + Wayland. Now opt-in only.
if std::env::var("QBZ_WEBKIT_DISABLE_COMPOSITING").as_deref() == Ok("1") {
qbz_nix_lib::logging::log_startup("[QBZ] User override: disabling WebKit compositing mode (QBZ_WEBKIT_DISABLE_COMPOSITING=1)");
std::env::set_var("WEBKIT_DISABLE_COMPOSITING_MODE", "1");
// --- GDK backend selection ---
if force_x11 && is_wayland {
qbz_nix_lib::logging::log_startup("[QBZ] User override: Forcing X11 backend (QBZ_FORCE_X11=1)");
std::env::set_var("GDK_BACKEND", "x11");
} else if is_wayland && std::env::var_os("GDK_BACKEND").is_none() {
std::env::set_var("GDK_BACKEND", "wayland");
std::env::set_var("GTK_CSD", "1");
}
// --- AppImage + Wayland: prevent ALL EGL usage (unconditional) ---
// AppImage builds bundle an incomplete/incompatible libEGL.so.
// On Wayland hosts, even with GDK_BACKEND=x11, WebKitGTK still
// initializes EGL for GPU compositing. The bundled libEGL sees
// WAYLAND_DISPLAY, tries the Wayland EGL platform, and aborts
// with "Could not create default EGL display: EGL_BAD_PARAMETER."
//
// There is NO runtime fallback once EGL is attempted.
// Three env vars are needed to fully prevent EGL initialization:
// GDK_BACKEND=x11 → GTK uses X11/XWayland (not Wayland)
// WEBKIT_DISABLE_COMPOSITING_MODE=1 → WebKitGTK skips GPU compositing
// (this is what actually prevents EGL init)
// WEBKIT_DISABLE_DMABUF_RENDERER=1 → extra safety: no DMA-BUF EGL path
//
// WEBKIT_DISABLE_COMPOSITING_MODE caused "ghost app" on native Wayland,
// but is safe on X11 (which we force here).
//
// Does NOT affect RPM, DEB, Flatpak, or native builds.
// Users who want to test native Wayland can set QBZ_FORCE_WAYLAND=1.
let appimage_forced_x11 = if is_appimage && is_wayland {
let user_wants_wayland = std::env::var("QBZ_FORCE_WAYLAND").as_deref() == Ok("1");
if user_wants_wayland {
qbz_nix_lib::logging::log_startup("[QBZ] AppImage on Wayland: user override QBZ_FORCE_WAYLAND=1, keeping native Wayland");
qbz_nix_lib::logging::log_startup("[QBZ] Warning: This may crash with EGL_BAD_PARAMETER on some systems");
false
} else {
qbz_nix_lib::logging::log_startup("[QBZ] AppImage on Wayland: forcing X11 + disabling GPU compositing to prevent EGL crash");
qbz_nix_lib::logging::log_startup("[QBZ] To test native Wayland: set QBZ_FORCE_WAYLAND=1");
std::env::set_var("GDK_BACKEND", "x11");
std::env::set_var("QT_QPA_PLATFORM", "xcb");
std::env::set_var("WEBKIT_DISABLE_COMPOSITING_MODE", "1");
std::env::set_var("WEBKIT_DISABLE_DMABUF_RENDERER", "1");
true
}
// --- DMA-BUF renderer control ---
// NVIDIA GPUs have known issues with WebKit's DMA-BUF renderer on
// Wayland, causing fatal protocol errors (Error 71).
// When hardware_accel is off, we also disable the DMA-BUF renderer
// as an extra safety layer for AppImage compatibility.
if force_dmabuf {
qbz_nix_lib::logging::log_startup("[QBZ] User override: Forcing DMA-BUF renderer enabled (QBZ_FORCE_DMABUF=1)");
qbz_nix_lib::logging::log_startup("[QBZ] Warning: This may cause crashes on some Wayland configurations");
} else if disable_dmabuf {
qbz_nix_lib::logging::log_startup("[QBZ] User override: Forcing DMA-BUF renderer disabled (QBZ_DISABLE_DMABUF=1)");
std::env::set_var("WEBKIT_DISABLE_DMABUF_RENDERER", "1");
} else if !hardware_accel {
qbz_nix_lib::logging::log_startup("[QBZ] Hardware acceleration disabled: disabling WebKit DMA-BUF renderer");
qbz_nix_lib::logging::log_startup("[QBZ] To enable: Settings > Appearance > Hardware Acceleration");
std::env::set_var("WEBKIT_DISABLE_DMABUF_RENDERER", "1");
} else if has_nvidia {
qbz_nix_lib::logging::log_startup("[QBZ] NVIDIA GPU detected: disabling WebKit DMA-BUF renderer");
qbz_nix_lib::logging::log_startup("[QBZ] To override: set QBZ_FORCE_DMABUF=1 (not recommended)");
std::env::set_var("WEBKIT_DISABLE_DMABUF_RENDERER", "1");
} else {
false
};
// --- GDK backend selection (non-AppImage paths) ---
if !appimage_forced_x11 {
if force_x11 && is_wayland {
qbz_nix_lib::logging::log_startup("[QBZ] User override: Forcing X11 backend (QBZ_FORCE_X11=1)");
std::env::set_var("GDK_BACKEND", "x11");
} else if is_wayland && std::env::var_os("GDK_BACKEND").is_none() {
// Non-AppImage Wayland: use native Wayland backend
std::env::set_var("GDK_BACKEND", "wayland");
// Prefer client-side decorations (we use custom titlebar anyway)
std::env::set_var("GTK_CSD", "1");
}
}
// --- DMA-BUF renderer control (non-AppImage paths) ---
// WebKitGTK's DMA-BUF renderer (default since 2.42) calls
// eglGetPlatformDisplay() during init. On some Wayland configurations
// this fails with EGL_BAD_PARAMETER and aborts the process.
// Known triggers: NVIDIA GPUs, virtual GPUs in VMs.
//
// Disabling the DMA-BUF renderer makes WebKit fall back to a simpler
// rendering path that does not require EGL platform display creation.
//
// Note: AppImage + Wayland already set WEBKIT_DISABLE_DMABUF_RENDERER
// above, so this block only runs for non-AppImage builds.
if !appimage_forced_x11 {
if force_dmabuf {
qbz_nix_lib::logging::log_startup("[QBZ] User override: Forcing DMA-BUF renderer enabled (QBZ_FORCE_DMABUF=1)");
qbz_nix_lib::logging::log_startup("[QBZ] Warning: This may cause crashes on some Wayland configurations");
// Do NOT set WEBKIT_DISABLE_DMABUF_RENDERER
} else if disable_dmabuf {
qbz_nix_lib::logging::log_startup("[QBZ] User override: Forcing DMA-BUF renderer disabled (QBZ_DISABLE_DMABUF=1)");
std::env::set_var("WEBKIT_DISABLE_DMABUF_RENDERER", "1");
} else if has_nvidia {
qbz_nix_lib::logging::log_startup("[QBZ] NVIDIA GPU detected: disabling WebKit DMA-BUF renderer");
qbz_nix_lib::logging::log_startup("[QBZ] To override: set QBZ_FORCE_DMABUF=1 (not recommended)");
std::env::set_var("WEBKIT_DISABLE_DMABUF_RENDERER", "1");
} else if std::env::var_os("WEBKIT_DISABLE_DMABUF_RENDERER").is_none() {
qbz_nix_lib::logging::log_startup("[QBZ] Non-NVIDIA GPU: using default WebKit renderer (hardware accelerated)");
}
qbz_nix_lib::logging::log_startup("[QBZ] Hardware acceleration enabled, using default WebKit renderer");
}
}
+1
View File
@@ -32,6 +32,7 @@ pub enum ContextType {
pub enum ContentSource {
Qobuz,
Local,
Plex,
}
/// Playback context - the semantic origin of playback
File diff suppressed because it is too large. Load diff
+3
View File
@@ -33,6 +33,9 @@ pub struct QueueTrack {
/// Whether the track is streamable on Qobuz (false = removed/unavailable)
#[serde(default = "default_streamable")]
pub streamable: bool,
/// Optional origin source (e.g. "qobuz", "local", "plex")
#[serde(default)]
pub source: Option<String>,
}
fn default_streamable() -> bool {
+25 -4
View File
@@ -27,6 +27,8 @@ pub struct PersistedQueueTrack {
pub artist_id: Option<u64>,
#[serde(default = "default_streamable")]
pub streamable: bool,
#[serde(default)]
pub source: Option<String>,
}
/// Represents the full persisted session state
@@ -110,7 +112,8 @@ impl SessionStore {
artwork_url TEXT,
hires INTEGER NOT NULL DEFAULT 0,
bit_depth INTEGER,
sample_rate REAL
sample_rate REAL,
source TEXT
);
-- Insert default row if not exists
@@ -158,6 +161,22 @@ impl SessionStore {
);
}
let has_source: bool = conn
.query_row(
"SELECT COUNT(*) FROM pragma_table_info('queue_tracks') WHERE name = 'source'",
[],
|row| row.get(0),
)
.unwrap_or(0) > 0;
if !has_source {
let _ = conn.execute_batch(
"
ALTER TABLE queue_tracks ADD COLUMN source TEXT;
"
);
}
Ok(Self { conn })
}
@@ -181,8 +200,8 @@ impl SessionStore {
// Insert queue tracks
for (pos, track) in session.queue_tracks.iter().enumerate() {
if let Err(e) = self.conn.execute(
"INSERT INTO queue_tracks (position, track_id, title, artist, album, duration_secs, artwork_url, hires, bit_depth, sample_rate, is_local, album_id, artist_id)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)",
"INSERT INTO queue_tracks (position, track_id, title, artist, album, duration_secs, artwork_url, hires, bit_depth, sample_rate, is_local, album_id, artist_id, source)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14)",
params![
pos as i64,
track.id as i64,
@@ -197,6 +216,7 @@ impl SessionStore {
track.is_local as i64,
track.album_id,
track.artist_id.map(|v| v as i64),
track.source,
],
) {
let _ = self.conn.execute("ROLLBACK", []);
@@ -258,7 +278,7 @@ impl SessionStore {
// Load queue tracks
let mut stmt = self.conn
.prepare("SELECT track_id, title, artist, album, duration_secs, artwork_url, hires, bit_depth, sample_rate, is_local, album_id, artist_id FROM queue_tracks ORDER BY position")
.prepare("SELECT track_id, title, artist, album, duration_secs, artwork_url, hires, bit_depth, sample_rate, is_local, album_id, artist_id, source FROM queue_tracks ORDER BY position")
.map_err(|e| format!("Failed to prepare queue query: {}", e))?;
let tracks: Vec<PersistedQueueTrack> = stmt
@@ -277,6 +297,7 @@ impl SessionStore {
album_id: row.get(10)?,
artist_id: row.get::<_, Option<i64>>(11)?.map(|v| v as u64),
streamable: true, // Default to true for persisted tracks
source: row.get(12)?,
})
})
.map_err(|e| format!("Failed to query queue tracks: {}", e))?
+19 -5
View File
@@ -35,8 +35,8 @@
onOpenContainingFolder?: () => void;
onReDownloadAlbum?: () => void;
downloadStateVersion?: number;
/** Source badge for local library: 'user' for local files, 'qobuz_download' for Qobuz offline */
sourceBadge?: 'user' | 'qobuz_download';
/** Source badge for local library: 'user' | 'qobuz_download' | 'plex' */
sourceBadge?: 'user' | 'qobuz_download' | 'plex';
artistId?: number;
onArtistClick?: (artistId: number) => void;
}
@@ -216,7 +216,7 @@
<!-- Image overlays placeholder when loaded -->
{#if !imageError && artwork}
<img src={artwork} alt={title} loading="lazy" decoding="async" onerror={handleImageError} />
<img class="artwork-image" src={artwork} alt={title} loading="lazy" decoding="async" onerror={handleImageError} />
{/if}
<!-- Action Overlay -->
@@ -276,9 +276,15 @@
<!-- Source Badge (Local Library only) -->
{#if sourceBadge}
<div class="source-badge" class:local-badge={sourceBadge === 'user'} title={sourceBadge === 'user' ? 'Local file' : 'Qobuz offline'}>
<div
class="source-badge"
class:local-badge={sourceBadge === 'user'}
title={sourceBadge === 'user' ? 'Local file' : sourceBadge === 'plex' ? 'Plex library' : 'Qobuz offline'}
>
{#if sourceBadge === 'user'}
<HardDrive size={14} />
{:else if sourceBadge === 'plex'}
<img src="/plex-logo.svg" alt="Plex" class="qobuz-badge-icon plex-logo-icon" />
{:else}
<img src="/qobuz-logo-filled.svg" alt="Qobuz" class="qobuz-badge-icon" />
{/if}
@@ -337,7 +343,7 @@
overflow: hidden;
}
.artwork-container img {
.artwork-container .artwork-image {
position: absolute;
inset: 0;
width: 100%;
@@ -392,6 +398,7 @@
width: 24px;
height: 24px;
color: var(--text-secondary);
border-radius: 4px;
}
.source-badge.local-badge {
@@ -405,6 +412,13 @@
height: 24px;
}
.source-badge .plex-logo-icon {
width: 18px;
height: 18px;
object-fit: contain;
filter: drop-shadow(0 1px 1px rgba(0, 0, 0, 0.45));
}
.action-overlay {
position: absolute;
inset: -1px;
+2 -2
View File
@@ -356,7 +356,7 @@
const tracks = await invoke<Array<{ id: number; file_path: string }>>('library_get_tracks_by_ids', {
trackIds
});
localTrackPaths = tracks.map(t => t.file_path);
localTrackPaths = tracks.map(track => track.file_path);
}
await invoke('add_tracks_to_pending_playlist', {
@@ -419,7 +419,7 @@
const tracks = await invoke<Array<{ id: number; file_path: string }>>('library_get_tracks_by_ids', {
trackIds
});
localTrackPaths = tracks.map(t => t.file_path);
localTrackPaths = tracks.map(track => track.file_path);
}
// Create pending playlist for sync when back online
+25 -2
View File
@@ -1,6 +1,7 @@
<script lang="ts">
import { onMount } from 'svelte';
import { Play, Pause, Heart, HardDrive, AlertCircle, Ban } from 'lucide-svelte';
import { t } from '$lib/i18n';
import TrackMenu from './TrackMenu.svelte';
import DownloadButton from './DownloadButton.svelte';
import {
@@ -24,6 +25,7 @@
quality?: string;
isPlaying?: boolean;
isLocal?: boolean; // Whether this is a local library track
localSource?: 'local' | 'plex';
isUnavailable?: boolean; // Track removed from Qobuz or otherwise unavailable
unavailableTooltip?: string; // Tooltip for unavailable indicator
isBlacklisted?: boolean; // Artist is blacklisted
@@ -71,6 +73,7 @@
quality,
isPlaying = false,
isLocal = false,
localSource = 'local',
isUnavailable = false,
unavailableTooltip,
isFavoriteOverride,
@@ -243,8 +246,16 @@
<!-- Download Indicator / Local Indicator (placeholder when hidden to maintain column width) -->
{#if isLocal}
<div class="local-indicator" title="Local track - only available on this device">
<HardDrive size={14} />
<div
class="local-indicator"
class:plex-source={localSource === 'plex'}
title={localSource === 'plex' ? $t('library.plexTrackIndicator') : $t('library.localTrackIndicator')}
>
{#if localSource === 'plex'}
<span class="plex-indicator-icon" aria-hidden="true"></span>
{:else}
<HardDrive size={14} />
{/if}
</div>
{:else if hideDownload}
<div class="download-placeholder"></div>
@@ -520,6 +531,18 @@
opacity: 0.6;
}
.local-indicator.plex-source {
opacity: 0.9;
}
.plex-indicator-icon {
width: 14px;
height: 14px;
background-color: var(--accent-primary);
-webkit-mask: url('/plex-mono.svg') center / contain no-repeat;
mask: url('/plex-mono.svg') center / contain no-repeat;
}
.download-placeholder {
width: 28px;
height: 28px;
@@ -17,7 +17,7 @@
bit_depth?: number;
sample_rate: number;
directory_path: string;
source?: string; // 'user' for local files, 'qobuz_download' for offline cached
source?: string; // 'user' | 'qobuz_download' | 'plex'
}
interface AlbumGroup {
@@ -324,7 +324,7 @@
onPlayNext={() => onAlbumQueueNext(album)}
onPlayLater={() => onAlbumQueueLater(album)}
onclick={() => onAlbumClick(album)}
sourceBadge={showSourceBadge ? (album.source === 'qobuz_download' ? 'qobuz_download' : 'user') : undefined}
sourceBadge={showSourceBadge ? (album.source === 'plex' ? 'plex' : (album.source === 'qobuz_download' ? 'qobuz_download' : 'user')) : undefined}
/>
{/each}
</div>
@@ -0,0 +1,482 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { t } from '$lib/i18n';
import { Disc3 } from 'lucide-svelte';
import AlbumCard from './AlbumCard.svelte';
import QualityBadge from './QualityBadge.svelte';
interface FavoriteAlbum {
id: string;
title: string;
artist: { id: number; name: string };
genre?: { name: string };
image: { small?: string; thumbnail?: string; large?: string };
release_date_original?: string;
hires: boolean;
maximum_bit_depth?: number;
maximum_sampling_rate?: number;
}
interface AlbumGroup {
key: string;
id: string;
albums: FavoriteAlbum[];
}
type VirtualItem =
| { type: 'header'; key: string; id: string; albumCount: number; height: number }
| { type: 'grid-row'; albums: FavoriteAlbum[]; height: number }
| { type: 'list-row'; album: FavoriteAlbum; height: number };
interface Props {
groups: AlbumGroup[];
showGroupHeaders: boolean;
viewMode: 'grid' | 'list';
onAlbumClick?: (albumId: string) => void;
onAlbumPlay?: (albumId: string) => void;
onAlbumPlayNext?: (albumId: string) => void;
onAlbumPlayLater?: (albumId: string) => void;
onAlbumShareQobuz?: (albumId: string) => void;
onAlbumShareSonglink?: (albumId: string) => void;
onAlbumDownload?: (albumId: string) => void;
onOpenAlbumFolder?: (albumId: string) => void;
onReDownloadAlbum?: (albumId: string) => void;
downloadStateVersion?: number;
isAlbumDownloaded?: (albumId: string) => boolean;
onAlbumClicked?: (albumId: string) => void;
scrollToGroupId?: string;
getQualityLabel?: (item: { hires?: boolean; maximum_bit_depth?: number; maximum_sampling_rate?: number }) => string;
getGenreLabel?: (album: FavoriteAlbum) => string;
getAlbumYear?: (album: FavoriteAlbum) => string | null;
}
let {
groups,
showGroupHeaders,
viewMode,
onAlbumClick,
onAlbumPlay,
onAlbumPlayNext,
onAlbumPlayLater,
onAlbumShareQobuz,
onAlbumShareSonglink,
onAlbumDownload,
onOpenAlbumFolder,
onReDownloadAlbum,
downloadStateVersion,
isAlbumDownloaded,
onAlbumClicked,
scrollToGroupId,
getQualityLabel,
getGenreLabel,
getAlbumYear,
}: Props = $props();
// Constants
const GRID_CARD_WIDTH = 180;
const GRID_CARD_HEIGHT = 290;
const GRID_GAP_X = 14;
const GRID_GAP_Y = 24;
const LIST_ROW_HEIGHT = 76; // 52px art + padding
const LIST_ROW_GAP = 8;
const HEADER_HEIGHT = 44;
const BUFFER_ITEMS = 5;
// State
let containerEl: HTMLDivElement | null = $state(null);
let scrollTop = $state(0);
let containerHeight = $state(0);
let containerWidth = $state(0);
// Computed: number of grid columns
let gridColumns = $derived.by(() => {
if (containerWidth === 0) return 1;
return Math.max(1, Math.floor((containerWidth + GRID_GAP_X) / (GRID_CARD_WIDTH + GRID_GAP_X)));
});
// Computed: flatten groups into virtual items
let virtualItems = $derived.by(() => {
const items: (VirtualItem & { top: number; groupId?: string })[] = [];
let currentTop = 0;
for (const group of groups) {
if (showGroupHeaders && group.key) {
items.push({
type: 'header',
key: group.key,
id: group.id,
albumCount: group.albums.length,
height: HEADER_HEIGHT,
top: currentTop,
groupId: group.id,
});
currentTop += HEADER_HEIGHT;
}
if (viewMode === 'grid') {
const rowHeight = GRID_CARD_HEIGHT + GRID_GAP_Y;
const cols = gridColumns;
for (let i = 0; i < group.albums.length; i += cols) {
const rowAlbums = group.albums.slice(i, i + cols);
items.push({
type: 'grid-row',
albums: rowAlbums,
height: GRID_CARD_HEIGHT,
top: currentTop,
});
currentTop += rowHeight;
}
} else {
const rowHeight = LIST_ROW_HEIGHT + LIST_ROW_GAP;
for (const album of group.albums) {
items.push({
type: 'list-row',
album,
height: LIST_ROW_HEIGHT,
top: currentTop,
});
currentTop += rowHeight;
}
}
}
return items;
});
// Computed: total height
let totalHeight = $derived(
virtualItems.length > 0
? virtualItems[virtualItems.length - 1].top + virtualItems[virtualItems.length - 1].height
: 0
);
// Binary search for first visible item
function binarySearchStart(items: typeof virtualItems, targetTop: number): number {
let low = 0;
let high = items.length - 1;
let result = 0;
while (low <= high) {
const mid = Math.floor((low + high) / 2);
const item = items[mid];
if (item.top + item.height > targetTop) {
result = mid;
high = mid - 1;
} else {
low = mid + 1;
}
}
return result;
}
// Binary search for last visible item
function binarySearchEnd(items: typeof virtualItems, targetBottom: number, startFrom: number): number {
let low = startFrom;
let high = items.length - 1;
let result = high;
while (low <= high) {
const mid = Math.floor((low + high) / 2);
const item = items[mid];
if (item.top > targetBottom) {
result = mid;
high = mid - 1;
} else {
low = mid + 1;
}
}
return result;
}
// Computed: visible items
let visibleItems = $derived.by(() => {
if (virtualItems.length === 0) return [];
const viewportTop = scrollTop;
const viewportBottom = scrollTop + containerHeight;
const firstVisible = binarySearchStart(virtualItems, viewportTop);
const lastVisible = binarySearchEnd(virtualItems, viewportBottom, firstVisible);
const startIdx = Math.max(0, firstVisible - BUFFER_ITEMS);
const endIdx = Math.min(virtualItems.length - 1, lastVisible + BUFFER_ITEMS);
return virtualItems.slice(startIdx, endIdx + 1);
});
// Group positions for scroll-to
let groupPositions = $derived.by(() => {
const map = new Map<string, number>();
for (const item of virtualItems) {
if (item.groupId) {
map.set(item.groupId, item.top);
}
}
return map;
});
function handleScroll(e: Event) {
scrollTop = (e.target as HTMLDivElement).scrollTop;
}
let resizeObserver: ResizeObserver | null = null;
onMount(() => {
if (containerEl) {
containerHeight = containerEl.clientHeight;
containerWidth = containerEl.clientWidth;
resizeObserver = new ResizeObserver((entries) => {
for (const entry of entries) {
containerHeight = entry.contentRect.height;
containerWidth = entry.contentRect.width;
}
});
resizeObserver.observe(containerEl);
}
});
onDestroy(() => {
resizeObserver?.disconnect();
});
// Scroll to group when requested
$effect(() => {
if (scrollToGroupId && containerEl) {
const position = groupPositions.get(scrollToGroupId);
if (position !== undefined) {
containerEl.scrollTo({ top: position, behavior: 'smooth' });
}
}
});
export function scrollToGroup(groupId: string) {
const position = groupPositions.get(groupId);
if (position !== undefined && containerEl) {
containerEl.scrollTo({ top: position, behavior: 'smooth' });
}
}
function getItemKey(item: typeof virtualItems[0]): string {
if (item.type === 'header') return `header-${item.id}`;
if (item.type === 'grid-row') return `grid-row-${item.albums[0]?.id ?? item.top}`;
return `list-row-${item.album.id}`;
}
function handleAlbumClickEvent(albumId: string) {
onAlbumClick?.(albumId);
onAlbumClicked?.(albumId);
}
</script>
<div class="virtual-container" bind:this={containerEl} onscroll={handleScroll}>
<div class="virtual-content" style="height: {totalHeight}px;">
{#each visibleItems as item (getItemKey(item))}
<div
class="virtual-item"
style="transform: translateY({item.top}px); height: {item.height}px;"
>
{#if item.type === 'header'}
<div class="album-group-header">
<span class="album-group-title">{item.key}</span>
<span class="album-group-count">{item.albumCount}</span>
</div>
{:else if item.type === 'grid-row'}
<div class="album-grid-row">
{#each item.albums as album (album.id)}
<AlbumCard
albumId={album.id}
artwork={album.image?.large || album.image?.thumbnail || ''}
title={album.title}
artist={album.artist.name}
genre={getGenreLabel?.(album) ?? album.genre?.name ?? ''}
releaseDate={album.release_date_original}
size="large"
quality={getQualityLabel?.(album) ?? ''}
onPlay={onAlbumPlay ? () => onAlbumPlay(album.id) : undefined}
onPlayNext={onAlbumPlayNext ? () => onAlbumPlayNext(album.id) : undefined}
onPlayLater={onAlbumPlayLater ? () => onAlbumPlayLater(album.id) : undefined}
onShareQobuz={onAlbumShareQobuz ? () => onAlbumShareQobuz(album.id) : undefined}
onShareSonglink={onAlbumShareSonglink ? () => onAlbumShareSonglink(album.id) : undefined}
onDownload={onAlbumDownload ? () => onAlbumDownload(album.id) : undefined}
isAlbumFullyDownloaded={isAlbumDownloaded?.(album.id) ?? false}
onOpenContainingFolder={onOpenAlbumFolder ? () => onOpenAlbumFolder(album.id) : undefined}
onReDownloadAlbum={onReDownloadAlbum ? () => onReDownloadAlbum(album.id) : undefined}
{downloadStateVersion}
onclick={() => handleAlbumClickEvent(album.id)}
/>
{/each}
</div>
{:else if item.type === 'list-row'}
{@const album = item.album}
<div class="album-row" role="button" tabindex="0" onclick={() => handleAlbumClickEvent(album.id)}>
<div class="album-row-art">
{#if album.image?.thumbnail || album.image?.small || album.image?.large}
<img src={album.image?.thumbnail || album.image?.small || album.image?.large} alt={album.title} loading="lazy" decoding="async" />
{:else}
<div class="artwork-placeholder">
<Disc3 size={28} />
</div>
{/if}
</div>
<div class="album-row-info">
<div class="album-row-title truncate">{album.title}</div>
<div class="album-row-meta">
<span>{album.artist.name}</span>
{#if getAlbumYear?.(album)}<span>{getAlbumYear(album)}</span>{/if}
</div>
</div>
<div class="album-row-quality">
<QualityBadge
bitDepth={album.maximum_bit_depth}
samplingRate={album.maximum_sampling_rate}
compact
/>
</div>
</div>
{/if}
</div>
{/each}
</div>
</div>
<style>
.virtual-container {
height: 100%;
overflow-y: auto;
overflow-x: hidden;
position: relative;
}
.virtual-container::-webkit-scrollbar {
width: 6px;
}
.virtual-container::-webkit-scrollbar-track {
background: transparent;
}
.virtual-container::-webkit-scrollbar-thumb {
background: var(--bg-tertiary);
border-radius: 3px;
}
.virtual-container::-webkit-scrollbar-thumb:hover {
background: var(--text-muted);
}
.virtual-content {
position: relative;
width: 100%;
}
.virtual-item {
position: absolute;
left: 0;
right: 0;
will-change: transform;
}
.album-group-header {
display: flex;
align-items: baseline;
gap: 8px;
padding: 12px 0 4px;
}
.album-group-title {
font-size: 14px;
font-weight: 700;
color: var(--text-primary);
letter-spacing: 0.04em;
text-transform: uppercase;
}
.album-group-count {
font-size: 12px;
color: var(--text-muted);
}
.album-grid-row {
display: flex;
flex-wrap: wrap;
gap: 24px 14px;
}
/* List mode styles */
.album-row {
display: grid;
grid-template-columns: 56px 1fr auto;
gap: 12px;
align-items: center;
padding: 10px 12px;
background: var(--bg-secondary);
border-radius: 10px;
cursor: pointer;
transition: background 150ms ease;
}
.album-row:hover {
background: var(--bg-tertiary);
}
.album-row-art {
width: 52px;
height: 52px;
border-radius: 8px;
overflow: hidden;
flex-shrink: 0;
}
.album-row-art img {
width: 100%;
height: 100%;
object-fit: cover;
}
.artwork-placeholder {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
background: var(--bg-tertiary);
color: var(--text-muted);
}
.album-row-info {
min-width: 0;
}
.album-row-title {
font-size: 13px;
font-weight: 500;
color: var(--text-primary);
margin-bottom: 4px;
}
.truncate {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.album-row-meta {
display: flex;
flex-wrap: wrap;
align-items: center;
font-size: 12px;
color: var(--text-muted);
}
.album-row-meta span + span::before {
content: "\2022";
margin: 0 8px;
color: var(--text-muted);
}
.album-row-quality {
display: flex;
justify-content: flex-end;
}
</style>
@@ -0,0 +1,385 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { t } from '$lib/i18n';
import { Mic2 } from 'lucide-svelte';
interface FavoriteArtist {
id: number;
name: string;
image?: { small?: string; thumbnail?: string; large?: string };
albums_count?: number;
}
interface ArtistGroup {
key: string;
id: string;
artists: FavoriteArtist[];
}
type VirtualItem =
| { type: 'header'; key: string; id: string; height: number }
| { type: 'row'; artists: FavoriteArtist[]; height: number };
interface Props {
groups: ArtistGroup[];
showGroupHeaders: boolean;
selectedArtistId?: number | null;
onArtistClick: (artistId: number) => void;
scrollToGroupId?: string;
}
let {
groups,
showGroupHeaders,
selectedArtistId = null,
onArtistClick,
scrollToGroupId,
}: Props = $props();
// Constants
const CARD_WIDTH = 160;
const CARD_HEIGHT = 200;
const GAP = 24;
const ROW_GAP = 24;
const HEADER_HEIGHT = 44;
const BUFFER_ITEMS = 5;
// State
let containerEl: HTMLDivElement | null = $state(null);
let scrollTop = $state(0);
let containerHeight = $state(0);
let containerWidth = $state(0);
// Computed: number of columns
let columns = $derived.by(() => {
if (containerWidth === 0) return 1;
return Math.max(1, Math.floor((containerWidth + GAP) / (CARD_WIDTH + GAP)));
});
// Computed: flatten groups into virtual items
let virtualItems = $derived.by(() => {
const items: (VirtualItem & { top: number; groupId?: string })[] = [];
let currentTop = 0;
const rowHeight = CARD_HEIGHT + ROW_GAP;
for (const group of groups) {
if (showGroupHeaders && group.key) {
items.push({
type: 'header',
key: group.key,
id: group.id,
height: HEADER_HEIGHT,
top: currentTop,
groupId: group.id,
});
currentTop += HEADER_HEIGHT;
}
const cols = columns;
for (let i = 0; i < group.artists.length; i += cols) {
const rowArtists = group.artists.slice(i, i + cols);
items.push({
type: 'row',
artists: rowArtists,
height: CARD_HEIGHT,
top: currentTop,
});
currentTop += rowHeight;
}
}
return items;
});
// Computed: total height
let totalHeight = $derived(
virtualItems.length > 0
? virtualItems[virtualItems.length - 1].top + virtualItems[virtualItems.length - 1].height
: 0
);
// Binary search for first visible item
function binarySearchStart(items: typeof virtualItems, targetTop: number): number {
let low = 0;
let high = items.length - 1;
let result = 0;
while (low <= high) {
const mid = Math.floor((low + high) / 2);
const item = items[mid];
if (item.top + item.height > targetTop) {
result = mid;
high = mid - 1;
} else {
low = mid + 1;
}
}
return result;
}
// Binary search for last visible item
function binarySearchEnd(items: typeof virtualItems, targetBottom: number, startFrom: number): number {
let low = startFrom;
let high = items.length - 1;
let result = high;
while (low <= high) {
const mid = Math.floor((low + high) / 2);
const item = items[mid];
if (item.top > targetBottom) {
result = mid;
high = mid - 1;
} else {
low = mid + 1;
}
}
return result;
}
// Computed: visible items
let visibleItems = $derived.by(() => {
if (virtualItems.length === 0) return [];
const viewportTop = scrollTop;
const viewportBottom = scrollTop + containerHeight;
const firstVisible = binarySearchStart(virtualItems, viewportTop);
const lastVisible = binarySearchEnd(virtualItems, viewportBottom, firstVisible);
const startIdx = Math.max(0, firstVisible - BUFFER_ITEMS);
const endIdx = Math.min(virtualItems.length - 1, lastVisible + BUFFER_ITEMS);
return virtualItems.slice(startIdx, endIdx + 1);
});
// Group positions for scroll-to
let groupPositions = $derived.by(() => {
const map = new Map<string, number>();
for (const item of virtualItems) {
if (item.groupId) {
map.set(item.groupId, item.top);
}
}
return map;
});
function handleScroll(e: Event) {
scrollTop = (e.target as HTMLDivElement).scrollTop;
}
let resizeObserver: ResizeObserver | null = null;
onMount(() => {
if (containerEl) {
containerHeight = containerEl.clientHeight;
containerWidth = containerEl.clientWidth;
resizeObserver = new ResizeObserver((entries) => {
for (const entry of entries) {
containerHeight = entry.contentRect.height;
containerWidth = entry.contentRect.width;
}
});
resizeObserver.observe(containerEl);
}
});
onDestroy(() => {
resizeObserver?.disconnect();
});
// Scroll to group when requested
$effect(() => {
if (scrollToGroupId && containerEl) {
const position = groupPositions.get(scrollToGroupId);
if (position !== undefined) {
containerEl.scrollTo({ top: position, behavior: 'smooth' });
}
}
});
export function scrollToGroup(groupId: string) {
const position = groupPositions.get(groupId);
if (position !== undefined && containerEl) {
containerEl.scrollTo({ top: position, behavior: 'smooth' });
}
}
function getItemKey(item: typeof virtualItems[0]): string {
if (item.type === 'header') return `header-${item.id}`;
return `row-${item.artists[0]?.id ?? item.top}`;
}
</script>
<div class="virtual-container" bind:this={containerEl} onscroll={handleScroll}>
<div class="virtual-content" style="height: {totalHeight}px;">
{#each visibleItems as item (getItemKey(item))}
<div
class="virtual-item"
style="transform: translateY({item.top}px); height: {item.height}px;"
>
{#if item.type === 'header'}
<div class="group-header">
<span class="group-title">{item.key}</span>
<span class="group-count">{groups.find(grp => grp.id === item.id)?.artists.length ?? ''}</span>
</div>
{:else if item.type === 'row'}
<div class="artist-row">
{#each item.artists as artist (artist.id)}
<button
class="artist-card"
class:selected={selectedArtistId === artist.id}
onclick={() => onArtistClick(artist.id)}
>
<div class="artist-image">
{#if artist.image?.large || artist.image?.thumbnail}
<img src={artist.image?.large || artist.image?.thumbnail} alt={artist.name} loading="lazy" decoding="async" />
{:else}
<div class="artist-placeholder">
<Mic2 size={32} />
</div>
{/if}
</div>
<div class="artist-name">{artist.name}</div>
{#if artist.albums_count}
<div class="artist-albums">{$t('library.albumCount', { values: { count: artist.albums_count } })}</div>
{/if}
</button>
{/each}
</div>
{/if}
</div>
{/each}
</div>
</div>
<style>
.virtual-container {
height: 100%;
overflow-y: auto;
overflow-x: hidden;
position: relative;
}
.virtual-container::-webkit-scrollbar {
width: 6px;
}
.virtual-container::-webkit-scrollbar-track {
background: transparent;
}
.virtual-container::-webkit-scrollbar-thumb {
background: var(--bg-tertiary);
border-radius: 3px;
}
.virtual-container::-webkit-scrollbar-thumb:hover {
background: var(--text-muted);
}
.virtual-content {
position: relative;
width: 100%;
}
.virtual-item {
position: absolute;
left: 0;
right: 0;
will-change: transform;
}
.group-header {
display: flex;
align-items: center;
justify-content: space-between;
color: var(--text-muted);
font-size: 12px;
letter-spacing: 0.08em;
text-transform: uppercase;
padding: 12px 0 4px;
}
.group-title {
font-weight: 600;
}
.group-count {
font-size: 12px;
}
.artist-row {
display: flex;
gap: 24px;
padding: 0;
}
.artist-card {
width: 160px;
flex-shrink: 0;
display: flex;
flex-direction: column;
align-items: center;
padding: 16px;
background-color: var(--bg-secondary);
border: none;
border-radius: 12px;
cursor: pointer;
transition: background-color 150ms ease;
box-sizing: border-box;
}
.artist-card:hover {
background-color: var(--bg-tertiary);
}
.artist-card.selected {
background-color: var(--bg-tertiary);
outline: 2px solid var(--accent-primary);
outline-offset: -2px;
}
.artist-image {
width: 120px;
height: 120px;
border-radius: 50%;
overflow: hidden;
margin-bottom: 12px;
background-color: var(--bg-tertiary);
}
.artist-image img {
width: 100%;
height: 100%;
object-fit: cover;
}
.artist-placeholder {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
color: var(--text-muted);
}
.artist-name {
font-size: 14px;
font-weight: 600;
color: var(--text-primary);
text-align: center;
overflow: hidden;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
width: 100%;
line-height: 1.3;
}
.artist-albums {
font-size: 12px;
color: var(--text-muted);
margin-top: 4px;
}
</style>
@@ -0,0 +1,409 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { t } from '$lib/i18n';
import { Mic2 } from 'lucide-svelte';
interface FavoriteArtist {
id: number;
name: string;
image?: { small?: string; thumbnail?: string; large?: string };
albums_count?: number;
}
interface ArtistGroup {
key: string;
id: string;
artists: FavoriteArtist[];
}
type VirtualItem =
| { type: 'header'; key: string; id: string; height: number }
| { type: 'artist'; artist: FavoriteArtist; height: number };
interface Props {
groups: ArtistGroup[];
showGroupHeaders: boolean;
selectedArtistId?: number | null;
onArtistSelect: (artist: FavoriteArtist) => void;
scrollToGroupId?: string;
}
let {
groups,
showGroupHeaders,
selectedArtistId = null,
onArtistSelect,
scrollToGroupId,
}: Props = $props();
// Constants
const HEADER_HEIGHT = 32;
const ROW_HEIGHT = 64; // 48px image + padding
const BUFFER_ITEMS = 10;
// State
let containerEl: HTMLDivElement | null = $state(null);
let scrollTop = $state(0);
let containerHeight = $state(0);
// Ticker animation state
let hoveredArtistId = $state<number | null>(null);
let artistNameOverflows = $state<Map<number, number>>(new Map());
const tickerSpeed = 40; // px per second
function measureArtistNameOverflow(artistId: number, element: HTMLElement | null) {
if (!element) return;
const textSpan = element.querySelector('.artist-name-text') as HTMLElement | null;
if (!textSpan) return;
const overflow = textSpan.scrollWidth - element.clientWidth;
if (overflow > 0) {
artistNameOverflows.set(artistId, overflow);
} else {
artistNameOverflows.delete(artistId);
}
}
function getArtistNameTickerStyle(artistId: number): string {
if (hoveredArtistId !== artistId) return '';
const overflow = artistNameOverflows.get(artistId);
if (!overflow || overflow <= 0) return '';
const duration = (overflow + 16) / tickerSpeed;
return `--ticker-offset: -${overflow + 16}px; --ticker-duration: ${duration}s;`;
}
// Computed: flatten groups into virtual items
let virtualItems = $derived.by(() => {
const items: (VirtualItem & { top: number; groupId?: string })[] = [];
let currentTop = 0;
for (const group of groups) {
if (showGroupHeaders && group.key) {
items.push({
type: 'header',
key: group.key,
id: group.id,
height: HEADER_HEIGHT,
top: currentTop,
groupId: group.id,
});
currentTop += HEADER_HEIGHT;
}
for (const artist of group.artists) {
items.push({
type: 'artist',
artist,
height: ROW_HEIGHT,
top: currentTop,
});
currentTop += ROW_HEIGHT;
}
}
return items;
});
// Computed: total height
let totalHeight = $derived(
virtualItems.length > 0
? virtualItems[virtualItems.length - 1].top + virtualItems[virtualItems.length - 1].height
: 0
);
// Binary search for first visible item
function binarySearchStart(items: typeof virtualItems, targetTop: number): number {
let low = 0;
let high = items.length - 1;
let result = 0;
while (low <= high) {
const mid = Math.floor((low + high) / 2);
const item = items[mid];
if (item.top + item.height > targetTop) {
result = mid;
high = mid - 1;
} else {
low = mid + 1;
}
}
return result;
}
// Binary search for last visible item
function binarySearchEnd(items: typeof virtualItems, targetBottom: number, startFrom: number): number {
let low = startFrom;
let high = items.length - 1;
let result = high;
while (low <= high) {
const mid = Math.floor((low + high) / 2);
const item = items[mid];
if (item.top > targetBottom) {
result = mid;
high = mid - 1;
} else {
low = mid + 1;
}
}
return result;
}
// Computed: visible items
let visibleItems = $derived.by(() => {
if (virtualItems.length === 0) return [];
const viewportTop = scrollTop;
const viewportBottom = scrollTop + containerHeight;
const firstVisible = binarySearchStart(virtualItems, viewportTop);
const lastVisible = binarySearchEnd(virtualItems, viewportBottom, firstVisible);
const startIdx = Math.max(0, firstVisible - BUFFER_ITEMS);
const endIdx = Math.min(virtualItems.length - 1, lastVisible + BUFFER_ITEMS);
return virtualItems.slice(startIdx, endIdx + 1);
});
// Group positions for scroll-to
let groupPositions = $derived.by(() => {
const map = new Map<string, number>();
for (const item of virtualItems) {
if (item.groupId) {
map.set(item.groupId, item.top);
}
}
return map;
});
function handleScroll(e: Event) {
scrollTop = (e.target as HTMLDivElement).scrollTop;
}
let resizeObserver: ResizeObserver | null = null;
onMount(() => {
if (containerEl) {
containerHeight = containerEl.clientHeight;
resizeObserver = new ResizeObserver((entries) => {
for (const entry of entries) {
containerHeight = entry.contentRect.height;
}
});
resizeObserver.observe(containerEl);
}
});
onDestroy(() => {
resizeObserver?.disconnect();
});
// Scroll to group when requested
$effect(() => {
if (scrollToGroupId && containerEl) {
const position = groupPositions.get(scrollToGroupId);
if (position !== undefined) {
containerEl.scrollTo({ top: position, behavior: 'smooth' });
}
}
});
export function scrollToGroup(groupId: string) {
const position = groupPositions.get(groupId);
if (position !== undefined && containerEl) {
containerEl.scrollTo({ top: position, behavior: 'smooth' });
}
}
function getItemKey(item: typeof virtualItems[0]): string {
if (item.type === 'header') return `header-${item.id}`;
return `artist-${item.artist.id}`;
}
</script>
<div class="virtual-container" bind:this={containerEl} onscroll={handleScroll}>
<div class="virtual-content" style="height: {totalHeight}px;">
{#each visibleItems as item (getItemKey(item))}
<div
class="virtual-item"
style="transform: translateY({item.top}px); height: {item.height}px;"
>
{#if item.type === 'header'}
<div class="group-header">{item.key}</div>
{:else if item.type === 'artist'}
{@const artist = item.artist}
{@const hasOverflow = artistNameOverflows.has(artist.id)}
{@const isHovered = hoveredArtistId === artist.id}
<button
class="artist-list-item"
class:selected={selectedArtistId === artist.id}
onclick={() => onArtistSelect(artist)}
onmouseenter={(e) => {
hoveredArtistId = artist.id;
const info = (e.currentTarget as HTMLElement).querySelector('.artist-list-name');
measureArtistNameOverflow(artist.id, info as HTMLElement);
}}
onmouseleave={() => { hoveredArtistId = null; }}
>
<div class="artist-list-image">
{#if artist.image?.thumbnail || artist.image?.small}
<img src={artist.image?.thumbnail || artist.image?.small} alt={artist.name} loading="lazy" decoding="async" />
{:else}
<div class="artist-list-placeholder">
<Mic2 size={20} />
</div>
{/if}
</div>
<div class="artist-list-info">
<div
class="artist-list-name"
class:scrollable={hasOverflow}
style={getArtistNameTickerStyle(artist.id)}
>
<span class="artist-name-text" class:animating={isHovered && hasOverflow}>{artist.name}</span>
</div>
{#if artist.albums_count}
<div class="artist-list-meta">{$t('library.albumCount', { values: { count: artist.albums_count } })}</div>
{/if}
</div>
</button>
{/if}
</div>
{/each}
</div>
</div>
<style>
.virtual-container {
height: 100%;
overflow-y: auto;
overflow-x: hidden;
position: relative;
padding: 4px 12px 4px 0;
-webkit-overflow-scrolling: touch;
scroll-behavior: smooth;
overscroll-behavior: contain;
}
.virtual-container::-webkit-scrollbar {
width: 4px;
}
.virtual-container::-webkit-scrollbar-track {
background: transparent;
}
.virtual-container::-webkit-scrollbar-thumb {
background: var(--bg-tertiary);
border-radius: 2px;
}
.virtual-container::-webkit-scrollbar-thumb:hover {
background: var(--text-muted);
}
.virtual-content {
position: relative;
width: 100%;
}
.virtual-item {
position: absolute;
left: 0;
right: 0;
will-change: transform;
}
.group-header {
font-size: 11px;
font-weight: 600;
color: var(--text-muted);
padding: 12px 8px 4px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.artist-list-item {
display: flex;
align-items: center;
gap: 12px;
width: 100%;
padding: 8px;
border: none;
background: transparent;
border-radius: 8px;
cursor: pointer;
transition: background-color 150ms ease;
text-align: left;
}
.artist-list-item:hover {
background: var(--bg-hover);
}
.artist-list-item.selected {
background: var(--bg-tertiary);
}
.artist-list-image {
width: 48px;
height: 48px;
border-radius: 50%;
overflow: hidden;
flex-shrink: 0;
background: var(--bg-tertiary);
}
.artist-list-image img {
width: 100%;
height: 100%;
object-fit: cover;
}
.artist-list-placeholder {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
color: var(--text-muted);
}
.artist-list-info {
flex: 1;
min-width: 0;
}
.artist-list-name {
font-size: 14px;
font-weight: 500;
color: var(--text-primary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.artist-list-name.scrollable {
text-overflow: clip;
}
.artist-list-name .artist-name-text {
display: inline-block;
}
.artist-list-name .artist-name-text.animating {
animation: artist-name-ticker var(--ticker-duration, 0s) linear infinite;
}
@keyframes artist-name-ticker {
0%, 20% { transform: translateX(0); }
70%, 80% { transform: translateX(var(--ticker-offset, 0)); }
90%, 100% { transform: translateX(0); }
}
.artist-list-meta {
font-size: 12px;
color: var(--text-muted);
margin-top: 2px;
}
</style>
@@ -218,7 +218,7 @@
}
const albumFullyDownloaded = $derived(
isAlbumFullyCached(album.tracks.map(t => t.id))
isAlbumFullyCached(album.tracks.map(track => track.id))
);
const isVariousArtists = $derived(
+81 -480
View File
@@ -8,6 +8,9 @@
import TrackRow from '../TrackRow.svelte';
import QualityBadge from '../QualityBadge.svelte';
import VirtualizedTrackList from '../VirtualizedTrackList.svelte';
import VirtualizedFavoritesArtistGrid from '../VirtualizedFavoritesArtistGrid.svelte';
import VirtualizedFavoritesArtistList from '../VirtualizedFavoritesArtistList.svelte';
import VirtualizedFavoritesAlbumGrid from '../VirtualizedFavoritesAlbumGrid.svelte';
import FavoritePlaylistCard from '../FavoritePlaylistCard.svelte';
import FavoritesEditModal from '../FavoritesEditModal.svelte';
import ViewTransition from '../ViewTransition.svelte';
@@ -211,7 +214,11 @@
async function loadAllAlbumOfflineCacheStatuses(albums: { id: string }[]) {
if (!checkAlbumFullyDownloaded || albums.length === 0) return;
await Promise.all(albums.map(album => loadAlbumOfflineCacheStatus(album.id)));
const BATCH = 10;
for (let i = 0; i < albums.length; i += BATCH) {
await Promise.all(albums.slice(i, i + BATCH).map(album => loadAlbumOfflineCacheStatus(album.id)));
if (i + BATCH < albums.length) await new Promise(r => setTimeout(r, 0));
}
}
function isAlbumDownloaded(albumId: string): boolean {
@@ -359,31 +366,6 @@
}
}
// Ticker animation for long artist names in sidepanel
let hoveredArtistId = $state<number | null>(null);
let artistNameOverflows = $state<Map<number, number>>(new Map());
const tickerSpeed = 40; // px per second
function measureArtistNameOverflow(artistId: number, element: HTMLElement | null) {
if (!element) return;
const textSpan = element.querySelector('.artist-name-text') as HTMLElement | null;
if (!textSpan) return;
const overflow = textSpan.scrollWidth - element.clientWidth;
if (overflow > 0) {
artistNameOverflows.set(artistId, overflow);
} else {
artistNameOverflows.delete(artistId);
}
}
function getArtistNameTickerStyle(artistId: number): string {
if (hoveredArtistId !== artistId) return '';
const overflow = artistNameOverflows.get(artistId);
if (!overflow || overflow <= 0) return '';
const duration = (overflow + 16) / tickerSpeed;
return `--ticker-offset: -${overflow + 16}px; --ticker-duration: ${duration}s;`;
}
let showTracksContextMenu = $state(false);
function resolveCustomIconSrc(path: string | null): string | null {
if (!path) return null;
@@ -1170,7 +1152,7 @@
</script>
<ViewTransition duration={200} distance={12} direction="down">
<div class="favorites-view" class:no-outer-scroll={(activeTab === 'tracks' && !loading && filteredTracks.length > 0) || (activeTab === 'artists' && artistViewMode === 'sidepanel')} bind:this={scrollContainer} onscroll={handleFavoritesScroll}>
<div class="favorites-view" class:no-outer-scroll={(activeTab === 'tracks' && !loading && filteredTracks.length > 0) || (activeTab === 'albums' && !loading && filteredAlbums.length > 0) || (activeTab === 'artists' && !loading && filteredArtists.length > 0)} bind:this={scrollContainer} onscroll={handleFavoritesScroll}>
<div class="top-bar">
{#if onBack}
<button class="back-btn" onclick={onBack}>
@@ -1617,84 +1599,40 @@
<Search size={48} />
<p>{$t('favorites.noAlbumsMatch', { values: { query: albumSearch } })}</p>
</div>
{:else if albumGroupingEnabled}
{@const groupedAlbums = groupAlbums(filteredAlbums, albumGroupMode)}
{@const alphaGroups = albumGroupMode === 'alpha'
? new Set(groupedAlbums.map(group => group.key))
{:else}
<!-- Virtualized album grid/list (grouped or ungrouped) -->
{@const albumGridGroups = albumGroupingEnabled
? groupAlbums(filteredAlbums, albumGroupMode)
: [{ key: '', id: 'all', albums: filteredAlbums }]}
{@const alphaGroups = albumGroupingEnabled && albumGroupMode === 'alpha'
? new Set(albumGridGroups.map(grp => grp.key))
: new Set<string>()}
<div class="album-sections">
<div class="album-group-list">
{#each groupedAlbums as group (group.id)}
<div class="album-group" id={group.id}>
<div class="album-group-header">
<span class="album-group-title">{group.key}</span>
<span class="album-group-count">{group.albums.length}</span>
</div>
{#if albumViewMode === 'grid'}
<div class="album-grid">
{#each group.albums as album (album.id)}
<AlbumCard
albumId={album.id}
artwork={album.image?.large || album.image?.thumbnail || ''}
title={album.title}
artist={album.artist.name}
artistId={album.artist.id}
onArtistClick={onArtistClick}
genre={getGenreLabel(album)}
releaseDate={album.release_date_original}
size="large"
quality={getQualityLabel(album)}
onPlay={onAlbumPlay ? () => onAlbumPlay(album.id) : undefined}
onPlayNext={onAlbumPlayNext ? () => onAlbumPlayNext(album.id) : undefined}
onPlayLater={onAlbumPlayLater ? () => onAlbumPlayLater(album.id) : undefined}
onShareQobuz={onAlbumShareQobuz ? () => onAlbumShareQobuz(album.id) : undefined}
onShareSonglink={onAlbumShareSonglink ? () => onAlbumShareSonglink(album.id) : undefined}
onDownload={onAlbumDownload ? () => onAlbumDownload(album.id) : undefined}
isAlbumFullyDownloaded={isAlbumDownloaded(album.id)}
onOpenContainingFolder={onOpenAlbumFolder ? () => onOpenAlbumFolder(album.id) : undefined}
onReDownloadAlbum={onReDownloadAlbum ? () => onReDownloadAlbum(album.id) : undefined}
{downloadStateVersion}
onclick={() => { onAlbumClick?.(album.id); loadAlbumOfflineCacheStatus(album.id); }}
/>
{/each}
</div>
{:else}
<div class="album-list">
{#each group.albums as album (album.id)}
<div class="album-row" role="button" tabindex="0" onclick={() => onAlbumClick?.(album.id)}>
<div class="album-row-art">
{#if album.image?.thumbnail || album.image?.small || album.image?.large}
<img src={album.image?.thumbnail || album.image?.small || album.image?.large} alt={album.title} loading="lazy" decoding="async" />
{:else}
<div class="artwork-placeholder">
<Disc3 size={28} />
</div>
{/if}
</div>
<div class="album-row-info">
<div class="album-row-title truncate">{album.title}</div>
<div class="album-row-meta">
<span>{album.artist.name}</span>
{#if getAlbumYear(album)}<span>{getAlbumYear(album)}</span>{/if}
</div>
</div>
<div class="album-row-quality">
<QualityBadge
bitDepth={album.maximum_bit_depth}
samplingRate={album.maximum_sampling_rate}
compact
/>
</div>
</div>
{/each}
</div>
{/if}
</div>
{/each}
<div class="virtualized-album-grid-container">
<VirtualizedFavoritesAlbumGrid
groups={albumGridGroups}
showGroupHeaders={albumGroupingEnabled}
viewMode={albumViewMode}
{onAlbumPlay}
{onAlbumPlayNext}
{onAlbumPlayLater}
{onAlbumShareQobuz}
{onAlbumShareSonglink}
{onAlbumDownload}
{onOpenAlbumFolder}
{onReDownloadAlbum}
{downloadStateVersion}
{isAlbumDownloaded}
onAlbumClick={onAlbumClick}
onAlbumClicked={(albumId) => loadAlbumOfflineCacheStatus(albumId)}
{getQualityLabel}
{getGenreLabel}
{getAlbumYear}
/>
</div>
{#if albumGroupMode === 'alpha'}
{#if albumGroupingEnabled && albumGroupMode === 'alpha'}
<div class="alpha-index">
{#each alphaIndexLetters as letter}
<button
@@ -1708,66 +1646,6 @@
</div>
{/if}
</div>
{:else}
{#if albumViewMode === 'grid'}
<div class="album-grid">
{#each filteredAlbums as album (album.id)}
<AlbumCard
albumId={album.id}
artwork={album.image?.large || album.image?.thumbnail || ''}
title={album.title}
artist={album.artist.name}
artistId={album.artist.id}
onArtistClick={onArtistClick}
genre={getGenreLabel(album)}
releaseDate={album.release_date_original}
size="large"
quality={getQualityLabel(album)}
onPlay={onAlbumPlay ? () => onAlbumPlay(album.id) : undefined}
onPlayNext={onAlbumPlayNext ? () => onAlbumPlayNext(album.id) : undefined}
onPlayLater={onAlbumPlayLater ? () => onAlbumPlayLater(album.id) : undefined}
onShareQobuz={onAlbumShareQobuz ? () => onAlbumShareQobuz(album.id) : undefined}
onShareSonglink={onAlbumShareSonglink ? () => onAlbumShareSonglink(album.id) : undefined}
onDownload={onAlbumDownload ? () => onAlbumDownload(album.id) : undefined}
isAlbumFullyDownloaded={isAlbumDownloaded(album.id)}
onOpenContainingFolder={onOpenAlbumFolder ? () => onOpenAlbumFolder(album.id) : undefined}
onReDownloadAlbum={onReDownloadAlbum ? () => onReDownloadAlbum(album.id) : undefined}
{downloadStateVersion}
onclick={() => { onAlbumClick?.(album.id); loadAlbumOfflineCacheStatus(album.id); }}
/>
{/each}
</div>
{:else}
<div class="album-list">
{#each filteredAlbums as album (album.id)}
<div class="album-row" role="button" tabindex="0" onclick={() => onAlbumClick?.(album.id)}>
<div class="album-row-art">
{#if album.image?.thumbnail || album.image?.small || album.image?.large}
<img src={album.image?.thumbnail || album.image?.small || album.image?.large} alt={album.title} loading="lazy" decoding="async" />
{:else}
<div class="artwork-placeholder">
<Disc3 size={28} />
</div>
{/if}
</div>
<div class="album-row-info">
<div class="album-row-title truncate">{album.title}</div>
<div class="album-row-meta">
<span>{album.artist.name}</span>
{#if getAlbumYear(album)}<span>{getAlbumYear(album)}</span>{/if}
</div>
</div>
<div class="album-row-quality">
<QualityBadge
bitDepth={album.maximum_bit_depth}
samplingRate={album.maximum_sampling_rate}
compact
/>
</div>
</div>
{/each}
</div>
{/if}
{/if}
</ViewTransition>
{:else if activeTab === 'artists'}
@@ -1787,50 +1665,14 @@
<!-- Two-column sidepanel view -->
{@const groupedArtistsSidepanel = groupArtists(filteredArtists)}
<div class="artist-two-column-layout">
<!-- Left column: Artists list grouped A-Z -->
<!-- Left column: Artists list grouped A-Z (virtualized) -->
<div class="artist-column">
<div class="artist-list-scroll">
{#each groupedArtistsSidepanel as group (group.id)}
<div class="artist-list-group-header" id={group.id}>{group.key}</div>
{#each group.artists as artist (artist.id)}
{@const hasOverflow = artistNameOverflows.has(artist.id)}
{@const isHovered = hoveredArtistId === artist.id}
<button
class="artist-list-item"
class:selected={selectedFavoriteArtist?.id === artist.id}
onclick={() => handleArtistSelect(artist)}
onmouseenter={(e) => {
hoveredArtistId = artist.id;
const info = (e.currentTarget as HTMLElement).querySelector('.artist-list-name');
measureArtistNameOverflow(artist.id, info as HTMLElement);
}}
onmouseleave={() => { hoveredArtistId = null; }}
>
<div class="artist-list-image">
{#if artist.image?.thumbnail || artist.image?.small}
<img src={artist.image?.thumbnail || artist.image?.small} alt={artist.name} />
{:else}
<div class="artist-list-placeholder">
<Mic2 size={20} />
</div>
{/if}
</div>
<div class="artist-list-info">
<div
class="artist-list-name"
class:scrollable={hasOverflow}
style={getArtistNameTickerStyle(artist.id)}
>
<span class="artist-name-text" class:animating={isHovered && hasOverflow}>{artist.name}</span>
</div>
{#if artist.albums_count}
<div class="artist-list-meta">{$t('library.albumCount', { values: { count: artist.albums_count } })}</div>
{/if}
</div>
</button>
{/each}
{/each}
</div>
<VirtualizedFavoritesArtistList
groups={groupedArtistsSidepanel}
showGroupHeaders={true}
selectedArtistId={selectedFavoriteArtist?.id ?? null}
onArtistSelect={handleArtistSelect}
/>
</div>
<!-- Right column: Selected artist's albums from Qobuz -->
@@ -2032,74 +1874,37 @@
{/if}
</div>
</div>
{:else if artistGroupingEnabled}
<!-- Grid view with grouping -->
{@const groupedArtists = groupArtists(filteredArtists)}
{@const artistAlphaGroups = new Set(groupedArtists.map(group => group.key))}
{:else}
<!-- Virtualized artist grid (grouped or ungrouped) -->
{@const artistGridGroups = artistGroupingEnabled
? groupArtists(filteredArtists)
: [{ key: '', id: 'all', artists: filteredArtists }]}
{@const artistAlphaGroups = artistGroupingEnabled
? new Set(artistGridGroups.map(grp => grp.key))
: new Set<string>()}
<div class="artist-sections">
<div class="artist-group-list">
{#each groupedArtists as group (group.id)}
<div class="artist-group" id={group.id}>
<div class="artist-group-header">
<span class="artist-group-title">{group.key}</span>
<span class="artist-group-count">{group.artists.length}</span>
</div>
<div class="artist-grid">
{#each group.artists as artist (artist.id)}
<button class="artist-card" onclick={() => onArtistClick?.(artist.id)}>
<div class="artist-image">
{#if artist.image?.large || artist.image?.thumbnail}
<img src={artist.image?.large || artist.image?.thumbnail} alt={artist.name} />
{:else}
<div class="artist-placeholder">
<Mic2 size={32} />
</div>
{/if}
</div>
<div class="artist-name">{artist.name}</div>
{#if artist.albums_count}
<div class="artist-albums">{artist.albums_count} albums</div>
{/if}
</button>
{/each}
</div>
</div>
{/each}
<div class="virtualized-artist-grid-container">
<VirtualizedFavoritesArtistGrid
groups={artistGridGroups}
showGroupHeaders={artistGroupingEnabled}
onArtistClick={(id) => onArtistClick?.(id)}
/>
</div>
<div class="alpha-index">
{#each alphaIndexLetters as letter}
<button
class="alpha-letter"
class:disabled={!artistAlphaGroups.has(letter)}
onclick={() => scrollToGroup('artist-alpha', letter, artistAlphaGroups)}
>
{letter}
</button>
{/each}
</div>
</div>
{:else}
<!-- Grid view without grouping -->
<div class="artist-grid">
{#each filteredArtists as artist (artist.id)}
<button class="artist-card" onclick={() => onArtistClick?.(artist.id)}>
<div class="artist-image">
{#if artist.image?.large || artist.image?.thumbnail}
<img src={artist.image?.large || artist.image?.thumbnail} alt={artist.name} />
{:else}
<div class="artist-placeholder">
<Mic2 size={32} />
</div>
{/if}
</div>
<div class="artist-name">{artist.name}</div>
{#if artist.albums_count}
<div class="artist-albums">{artist.albums_count} albums</div>
{/if}
</button>
{/each}
{#if artistGroupingEnabled}
<div class="alpha-index">
{#each alphaIndexLetters as letter}
<button
class="alpha-letter"
class:disabled={!artistAlphaGroups.has(letter)}
onclick={() => scrollToGroup('artist-alpha', letter, artistAlphaGroups)}
>
{letter}
</button>
{/each}
</div>
{/if}
</div>
{/if}
</ViewTransition>
@@ -2654,120 +2459,12 @@
overflow: hidden;
}
.album-grid {
display: flex;
flex-wrap: wrap;
gap: 24px 14px;
}
.album-sections {
display: flex;
gap: 12px;
align-items: flex-start;
}
.album-group-list {
flex: 1;
display: flex;
flex-direction: column;
gap: 24px;
}
.album-group-header {
display: flex;
align-items: baseline;
gap: 8px;
}
.album-group-title {
font-size: 14px;
font-weight: 700;
color: var(--text-primary);
letter-spacing: 0.04em;
text-transform: uppercase;
}
.album-group-count {
font-size: 12px;
color: var(--text-muted);
}
.album-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.album-row {
display: grid;
grid-template-columns: 56px 1fr auto;
gap: 12px;
align-items: center;
padding: 10px 12px;
background: var(--bg-secondary);
border-radius: 10px;
cursor: pointer;
transition: background 150ms ease;
}
.album-row:hover {
background: var(--bg-tertiary);
}
.album-row-art {
width: 52px;
height: 52px;
border-radius: 8px;
overflow: hidden;
flex-shrink: 0;
}
.album-row-art img {
width: 100%;
height: 100%;
object-fit: cover;
}
.artwork-placeholder {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
background: var(--bg-tertiary);
color: var(--text-muted);
}
.album-row-info {
min-width: 0;
}
.album-row-title {
font-size: 13px;
font-weight: 500;
color: var(--text-primary);
margin-bottom: 4px;
}
.album-row-meta {
display: flex;
flex-wrap: wrap;
align-items: center;
font-size: 12px;
color: var(--text-muted);
}
.album-row-meta span + span::before {
content: "\2022";
margin: 0 8px;
color: var(--text-muted);
}
.album-row-quality {
display: flex;
justify-content: flex-end;
}
.artist-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
@@ -2780,6 +2477,15 @@
align-items: flex-start;
}
.virtualized-artist-grid-container,
.virtualized-album-grid-container {
flex: 1;
height: calc(100vh - 380px);
min-height: 400px;
min-width: 0;
overflow: hidden;
}
.artist-group-list {
flex: 1;
display: flex;
@@ -2937,111 +2643,6 @@
padding-left: 18px;
}
.artist-list-scroll {
flex: 1;
overflow-y: auto;
padding: 4px 12px 4px 0;
/* Smooth scrolling optimizations */
-webkit-overflow-scrolling: touch;
scroll-behavior: smooth;
overscroll-behavior: contain;
will-change: scroll-position;
contain: strict;
}
.artist-list-group-header {
font-size: 11px;
font-weight: 600;
color: var(--text-muted);
padding: 12px 8px 4px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.artist-list-item {
display: flex;
align-items: center;
gap: 12px;
width: 100%;
padding: 8px;
border: none;
background: transparent;
border-radius: 8px;
cursor: pointer;
transition: background-color 150ms ease;
text-align: left;
}
.artist-list-item:hover {
background: var(--bg-hover);
}
.artist-list-item.selected {
background: var(--bg-tertiary);
}
.artist-list-image {
width: 48px;
height: 48px;
border-radius: 50%;
overflow: hidden;
flex-shrink: 0;
background: var(--bg-tertiary);
}
.artist-list-image img {
width: 100%;
height: 100%;
object-fit: cover;
}
.artist-list-placeholder {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
color: var(--text-muted);
}
.artist-list-info {
flex: 1;
min-width: 0;
}
.artist-list-name {
font-size: 14px;
font-weight: 500;
color: var(--text-primary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.artist-list-name.scrollable {
text-overflow: clip;
}
.artist-list-name .artist-name-text {
display: inline-block;
}
.artist-list-name .artist-name-text.animating {
animation: artist-name-ticker var(--ticker-duration, 0s) linear infinite;
}
@keyframes artist-name-ticker {
0%, 20% { transform: translateX(0); }
70%, 80% { transform: translateX(var(--ticker-offset, 0)); }
90%, 100% { transform: translateX(0); }
}
.artist-list-meta {
font-size: 12px;
color: var(--text-muted);
margin-top: 2px;
}
.artist-albums-column {
flex: 1;
display: flex;
+22 -15
View File
@@ -594,26 +594,26 @@
}
function buildContinueQueueTracks(tracks: DisplayTrack[]) {
return tracks.map(t => ({
id: t.id,
title: t.title,
artist: t.artist || 'Unknown Artist',
album: t.album || '',
duration_secs: t.durationSeconds,
artwork_url: t.albumArt || '',
hires: t.hires ?? false,
bit_depth: t.bitDepth ?? null,
sample_rate: t.samplingRate ?? null,
is_local: t.isLocal ?? false,
album_id: t.albumId || null,
artist_id: t.artistId ?? null,
return tracks.map(track => ({
id: track.id,
title: track.title,
artist: track.artist || 'Unknown Artist',
album: track.album || '',
duration_secs: track.durationSeconds,
artwork_url: track.albumArt || '',
hires: track.hires ?? false,
bit_depth: track.bitDepth ?? null,
sample_rate: track.samplingRate ?? null,
is_local: track.isLocal ?? false,
album_id: track.albumId || null,
artist_id: track.artistId ?? null,
}));
}
async function handleContinueTrackPlay(track: DisplayTrack, trackIndex: number) {
// Create continue listening context
if (continueTracks.length > 0) {
const trackIds = continueTracks.map(t => t.id);
const trackIds = continueTracks.map(track => track.id);
await setPlaybackContext(
'home_list',
@@ -921,7 +921,14 @@
<div class="header-actions">
<GenreFilterButton onFilterChange={handleGenreFilterChange} />
<button class="settings-btn" onclick={() => isSettingsModalOpen = true} title={$t('home.customizeHome')}>
<img src="/home-gear.svg" alt="Settings" class="settings-icon" />
<img
src="/home-gear.svg"
alt="Settings"
class="settings-icon"
width="22"
height="22"
style="width:22px;height:22px;filter:invert(1) opacity(0.8);"
/>
</button>
</div>
</div>
+502 -72
View File
@@ -72,6 +72,7 @@
artwork_path?: string;
last_modified: number;
indexed_at: number;
source?: string; // 'user' | 'qobuz_download' | 'plex'
}
interface LocalAlbum {
@@ -88,7 +89,71 @@
bit_depth?: number;
sample_rate: number;
directory_path: string;
source?: string; // 'user' for local files, 'qobuz_download' for offline cached
source?: string; // 'user' | 'qobuz_download' | 'plex'
}
interface PlexCachedAlbum {
id: string;
title: string;
artist: string;
artworkPath?: string;
trackCount: number;
totalDurationSecs: number;
format: string;
bitDepth?: number;
sampleRate: number;
source: string;
}
interface PlexCachedTrack {
id: number;
ratingKey: string;
title: string;
artist: string;
album: string;
durationSecs: number;
format: string;
bitDepth?: number;
sampleRate: number;
artworkPath?: string;
source: string;
albumKey: string;
}
interface PlexMusicSection {
key: string;
title: string;
}
interface PlexTrack {
ratingKey: string;
title: string;
artist?: string;
album?: string;
durationMs?: number;
artworkPath?: string;
partKey?: string;
container?: string;
codec?: string;
channels?: number;
bitrateKbps?: number;
samplingRateHz?: number;
bitDepth?: number;
}
interface PlexTrackMetadata {
ratingKey: string;
container?: string;
codec?: string;
samplingRateHz?: number;
bitDepth?: number;
}
interface PlexTrackQualityUpdate {
ratingKey: string;
container?: string;
samplingRateHz?: number;
bitDepth?: number;
}
interface LocalArtist {
@@ -158,6 +223,8 @@
isPlaybackActive?: boolean;
}
const PLEX_METADATA_WRITE_KEY = 'qbz-plex-poc-metadata-write-enabled';
let {
onAlbumClick,
onQobuzArtistClick,
@@ -319,7 +386,7 @@
if (sourceFiltersActive) {
const source = album.source ?? 'user';
if (filterLocalFiles && source === 'user') passesSource = true;
if (filterLocalFiles && (source === 'user' || source === 'plex')) passesSource = true;
if (filterOfflineCache && source === 'qobuz_download') passesSource = true;
}
@@ -419,6 +486,7 @@
let artistImageFetchInProgress = false; // Guard against concurrent fetches
let artistImageFetchAborted = false; // Flag to abort fetching
let trackSearch = $state('');
let tracksHydrationRequestId = 0;
let searchOpen = $state(false);
let searchInputEl: HTMLInputElement | undefined;
type TrackGroupMode = 'album' | 'artist' | 'name';
@@ -832,6 +900,9 @@
let loading = $state(false);
let scanning = $state(false);
let error = $state<string | null>(null);
let plexRepairAttempted = $state(false);
let plexRepairInProgress = $state(false);
let plexRepairQueued = $state(false);
let fetchingArtwork = $state(false);
let updatingArtwork = $state(false);
let hasDiscogsCredentials = $state(false);
@@ -971,19 +1042,24 @@
// If not found in loaded albums, we need to fetch album list first
if (!album) {
const allAlbums = await invoke<LocalAlbum[]>('library_get_albums', {
includeHidden: false,
excludeNetworkFolders: shouldExcludeNetworkFolders()
});
const includePlex = isPlexLibraryEnabled();
const [localAlbums, plexAlbumsRaw] = await Promise.all([
invoke<LocalAlbum[]>('library_get_albums', {
includeHidden: false,
excludeNetworkFolders: shouldExcludeNetworkFolders()
}),
includePlex
? invoke<PlexCachedAlbum[]>('plex_cache_get_albums').catch(() => [])
: Promise.resolve([])
]);
const allAlbums = [...localAlbums, ...plexAlbumsRaw.map(mapPlexAlbum)];
albums = allAlbums;
album = allAlbums.find(a => a.id === albumId);
}
if (album) {
selectedAlbum = album;
albumTracks = await invoke<LocalTrack[]>('library_get_album_tracks', {
albumGroupKey: album.id
});
albumTracks = await fetchAlbumTracks(album);
}
} catch (err) {
console.error('Failed to load album:', err);
@@ -1035,33 +1111,227 @@
}
}
async function loadLibraryData() {
console.log('[LocalLibrary] loadLibraryData START, isOffline:', isOffline);
loading = true;
error = null;
function isPlexLibraryEnabled(): boolean {
return getUserItem('qbz-plex-enabled') === 'true';
}
function buildPlexArtworkUrl(path: string): string {
const baseUrl = getUserItem('qbz-plex-poc-base-url') || '';
const token = getUserItem('qbz-plex-poc-token') || '';
if (!baseUrl || !token) return path;
const base = baseUrl.replace(/\/+$/, '');
const separator = path.includes('?') ? '&' : '?';
return `${base}${path}${separator}X-Plex-Token=${encodeURIComponent(token)}`;
}
function normalizePlexAlbumTitle(artist: string, album: string): string {
const artistName = artist.trim();
const albumTitle = album.trim();
if (!artistName || !albumTitle) return albumTitle;
for (const sep of [' - ', ' — ', ' – ', ': ']) {
const prefix = `${artistName}${sep}`;
if (albumTitle.startsWith(prefix)) {
return albumTitle.slice(prefix.length).trim();
}
}
return albumTitle;
}
function isLikelyLegacyPlexCache(plexAlbums: PlexCachedAlbum[]): boolean {
if (plexAlbums.length < 20) return false;
const suspicious = plexAlbums.filter(album =>
album.title.trim().toLowerCase() === album.artist.trim().toLowerCase()
).length;
return suspicious >= 20 || suspicious / plexAlbums.length >= 0.2;
}
async function repairLegacyPlexCache(): Promise<boolean> {
if (plexRepairInProgress) return false;
const baseUrl = getUserItem('qbz-plex-poc-base-url') || '';
const token = getUserItem('qbz-plex-poc-token') || '';
if (!baseUrl || !token) return false;
plexRepairInProgress = true;
try {
const startedAt = performance.now();
const sections = await invoke<PlexMusicSection[]>('plex_get_music_sections', {
baseUrl,
token
});
await invoke('plex_cache_save_sections', { serverId: null, sections });
const sectionMetrics = await Promise.all(
sections.map(async (section) => {
const sectionStart = performance.now();
const sectionTracks = await invoke<PlexTrack[]>('plex_get_section_tracks', {
baseUrl,
token,
sectionKey: section.key
});
await invoke('plex_cache_save_tracks', {
serverId: null,
sectionKey: section.key,
tracks: sectionTracks
});
return {
key: section.key,
tracks: sectionTracks.length,
ms: performance.now() - sectionStart
};
})
);
const totalTracks = sectionMetrics.reduce((sum, item) => sum + item.tracks, 0);
const elapsedMs = performance.now() - startedAt;
console.log('[PlexBench] cache repair completed', {
sections: sections.length,
totalTracks,
elapsedMs: Number(elapsedMs.toFixed(1)),
slowestSections: sectionMetrics
.slice()
.sort((a, b) => b.ms - a.ms)
.slice(0, 3)
.map((item) => ({ key: item.key, tracks: item.tracks, ms: Number(item.ms.toFixed(1)) }))
});
return true;
} catch (err) {
console.error('[LocalLibrary] Failed to auto-repair Plex cache:', err);
return false;
} finally {
plexRepairInProgress = false;
}
}
async function queuePlexRepairInBackground() {
if (plexRepairQueued || plexRepairInProgress) return;
plexRepairQueued = true;
try {
const repaired = await repairLegacyPlexCache();
if (repaired) {
await loadLibraryData({ background: true });
}
} finally {
plexRepairQueued = false;
}
}
function mapPlexAlbum(plexAlbum: PlexCachedAlbum): LocalAlbum {
const artist = plexAlbum.artist?.trim() || 'Unknown Artist';
const title = normalizePlexAlbumTitle(artist, plexAlbum.title || 'Unknown Album');
return {
id: plexAlbum.id,
title: title || 'Unknown Album',
artist,
artwork_path: plexAlbum.artworkPath,
track_count: plexAlbum.trackCount,
total_duration_secs: plexAlbum.totalDurationSecs,
format: plexAlbum.format,
bit_depth: plexAlbum.bitDepth,
sample_rate: plexAlbum.sampleRate,
directory_path: '',
source: 'plex'
};
}
function mapPlexTrack(plexTrack: PlexCachedTrack): LocalTrack {
const artist = plexTrack.artist?.trim() || 'Unknown Artist';
const album = normalizePlexAlbumTitle(artist, plexTrack.album || 'Unknown Album');
return {
id: plexTrack.id,
file_path: plexTrack.ratingKey,
title: plexTrack.title,
artist,
album: album || 'Unknown Album',
album_artist: artist,
album_group_key: plexTrack.albumKey,
album_group_title: album || 'Unknown Album',
track_number: undefined,
disc_number: undefined,
duration_secs: plexTrack.durationSecs,
format: plexTrack.format,
bit_depth: plexTrack.bitDepth,
sample_rate: plexTrack.sampleRate,
channels: 2,
file_size_bytes: 0,
artwork_path: plexTrack.artworkPath,
last_modified: 0,
indexed_at: 0,
source: 'plex'
};
}
async function loadLibraryData(options: { background?: boolean } = {}) {
const background = options.background === true;
const startedAt = performance.now();
console.log('[LocalLibrary] loadLibraryData START, isOffline:', isOffline, 'background:', background);
if (!background) {
loading = true;
error = null;
}
try {
const excludeNetwork = shouldExcludeNetworkFolders();
console.log('[LocalLibrary] Calling library_get_albums with excludeNetwork:', excludeNetwork);
const albumsResult = await invoke<LocalAlbum[]>('library_get_albums', {
includeHidden: false,
excludeNetworkFolders: excludeNetwork
});
console.log('[LocalLibrary] Received albums:', albumsResult.length);
console.log('[LocalLibrary] Calling library_get_stats');
const statsResult = await invoke<LibraryStats>('library_get_stats');
const fetchStart = performance.now();
const includePlex = isPlexLibraryEnabled();
const [albumsResult, statsResult, plexAlbumsRaw] = await Promise.all([
invoke<LocalAlbum[]>('library_get_albums', {
includeHidden: false,
excludeNetworkFolders: excludeNetwork
}),
invoke<LibraryStats>('library_get_stats'),
includePlex
? invoke<PlexCachedAlbum[]>('plex_cache_get_albums').catch(() => [])
: Promise.resolve([])
]);
const fetchMs = performance.now() - fetchStart;
let workingPlexAlbumsRaw = plexAlbumsRaw;
const shouldAttemptRepair =
includePlex &&
!plexRepairAttempted &&
(isLikelyLegacyPlexCache(workingPlexAlbumsRaw) || workingPlexAlbumsRaw.length <= 1);
if (shouldAttemptRepair) {
plexRepairAttempted = true;
void queuePlexRepairInBackground();
}
const mapStart = performance.now();
const plexAlbums = workingPlexAlbumsRaw.map(mapPlexAlbum);
const mapMs = performance.now() - mapStart;
console.log('[LocalLibrary] Received albums:', albumsResult.length, 'plex albums:', plexAlbums.length);
console.log('[LocalLibrary] Received stats:', statsResult);
albums = albumsResult;
stats = statsResult;
console.log('[LocalLibrary] loadLibraryData COMPLETE');
const plexTrackCount = plexAlbums.reduce((sum, album) => sum + album.track_count, 0);
const plexDurationSecs = plexAlbums.reduce((sum, album) => sum + album.total_duration_secs, 0);
albums = [...albumsResult, ...plexAlbums];
stats = {
...statsResult,
track_count: statsResult.track_count + plexTrackCount,
album_count: statsResult.album_count + plexAlbums.length,
total_duration_secs: statsResult.total_duration_secs + plexDurationSecs
};
console.log('[PlexBench] loadLibraryData', {
background,
localAlbums: albumsResult.length,
plexAlbums: plexAlbums.length,
fetchMs: Number(fetchMs.toFixed(1)),
mapMs: Number(mapMs.toFixed(1)),
totalMs: Number((performance.now() - startedAt).toFixed(1)),
repairQueued: shouldAttemptRepair
});
} catch (err) {
console.error('[LocalLibrary] Failed to load library:', err);
error = String(err);
if (!background) {
error = String(err);
}
} finally {
console.log('[LocalLibrary] Setting loading = false');
loading = false;
if (!background) {
console.log('[LocalLibrary] Setting loading = false');
loading = false;
}
}
}
@@ -1122,11 +1392,47 @@
console.log('[LocalLibrary] loadArtists START');
loading = true;
try {
console.log('[LocalLibrary] Calling library_get_artists');
artists = await invoke<LocalArtist[]>('library_get_artists', {
excludeNetworkFolders: shouldExcludeNetworkFolders()
});
console.log('[LocalLibrary] Received artists:', artists.length);
console.log('[LocalLibrary] Calling library_get_artists + plex_cache_get_albums');
const includePlex = isPlexLibraryEnabled();
const [localArtists, plexAlbumsRaw] = await Promise.all([
invoke<LocalArtist[]>('library_get_artists', {
excludeNetworkFolders: shouldExcludeNetworkFolders()
}),
includePlex
? invoke<PlexCachedAlbum[]>('plex_cache_get_albums').catch(() => [])
: Promise.resolve([])
]);
const plexArtistMap = new Map<string, { albumCount: number; trackCount: number }>();
for (const rawAlbum of plexAlbumsRaw) {
const name = (rawAlbum.artist || '').trim();
if (!name) continue;
const current = plexArtistMap.get(name) ?? { albumCount: 0, trackCount: 0 };
current.albumCount += 1;
current.trackCount += rawAlbum.trackCount ?? 0;
plexArtistMap.set(name, current);
}
const mergedArtistMap = new Map<string, LocalArtist>();
for (const localArtist of localArtists) {
mergedArtistMap.set(localArtist.name, { ...localArtist });
}
for (const [name, metrics] of plexArtistMap) {
const existing = mergedArtistMap.get(name);
if (existing) {
existing.album_count += metrics.albumCount;
existing.track_count += metrics.trackCount;
} else {
mergedArtistMap.set(name, {
name,
album_count: metrics.albumCount,
track_count: metrics.trackCount
});
}
}
artists = Array.from(mergedArtistMap.values()).sort((a, b) => a.name.localeCompare(b.name));
console.log('[LocalLibrary] Received artists:', artists.length, 'local:', localArtists.length, 'plex:', plexArtistMap.size);
// Load cached artist images from database
await loadCachedArtistImages();
// Fetch missing images in background if enabled
@@ -1145,15 +1451,37 @@
async function loadTracks(query = '') {
console.log('[LocalLibrary] loadTracks START, query:', query);
const requestId = ++tracksHydrationRequestId;
loading = true;
try {
console.log('[LocalLibrary] Calling library_search');
tracks = await invoke<LocalTrack[]>('library_search', {
query,
limit: 0, // 0 = no limit, virtualization handles any list size
excludeNetworkFolders: shouldExcludeNetworkFolders()
});
console.log('[LocalLibrary] Received tracks:', tracks.length);
console.log('[LocalLibrary] Calling library_search + plex_cache_search_tracks');
const includePlex = isPlexLibraryEnabled();
const [localTracks, plexTracksRaw] = await Promise.all([
invoke<LocalTrack[]>('library_search', {
query,
limit: 0, // 0 = no limit, virtualization handles any list size
excludeNetworkFolders: shouldExcludeNetworkFolders()
}),
includePlex
? invoke<PlexCachedTrack[]>('plex_cache_search_tracks', {
query
}).catch(() => [])
: Promise.resolve([])
]);
const mappedPlexTracks = plexTracksRaw.map(mapPlexTrack);
tracks = [...localTracks, ...mappedPlexTracks];
console.log('[LocalLibrary] Received tracks:', tracks.length, 'local:', localTracks.length, 'plex:', plexTracksRaw.length);
// Hydrate Plex quality in the background; don't block rendering track lists.
// Guard with requestId to avoid stale updates after a newer search.
void hydratePlexTrackQuality(mappedPlexTracks)
.then((hydratedPlexTracks) => {
if (requestId !== tracksHydrationRequestId) return;
tracks = [...localTracks, ...hydratedPlexTracks];
})
.catch((error) => {
console.warn('[LocalLibrary] Background Plex quality hydration failed:', error);
});
} catch (err) {
console.error('[LocalLibrary] Failed to load tracks:', err);
error = String(err);
@@ -1505,9 +1833,7 @@
// Also load album data immediately for responsive UI
selectedAlbum = album;
try {
albumTracks = await invoke<LocalTrack[]>('library_get_album_tracks', {
albumGroupKey: album.id
});
albumTracks = await fetchAlbumTracks(album);
} catch (err) {
console.error('Failed to load album tracks:', err);
}
@@ -1541,16 +1867,17 @@
async function handleTrackPlay(track: LocalTrack) {
try {
const trackSource = track.source === 'plex' ? 'plex' : 'local';
if (selectedAlbum && albumTracks.length > 0) {
const trackIndex = albumTracks.findIndex(t => t.id === track.id);
const trackIds = albumTracks.map(t => t.id);
const trackIds = albumTracks.map(track => track.id);
// Set playback context for Local Library album
await setPlaybackContext(
'local_library',
selectedAlbum.id,
selectedAlbum.title,
'local',
selectedAlbum.source === 'plex' ? 'plex' : 'local',
trackIds,
trackIndex >= 0 ? trackIndex : 0
);
@@ -1558,24 +1885,30 @@
await setQueueForAlbumTracks(albumTracks, trackIndex >= 0 ? trackIndex : 0);
} else if (activeTab === 'tracks' && tracks.length > 0) {
const orderedTracks = getDisplayedTrackOrder();
const trackIndex = orderedTracks.findIndex(t => t.id === track.id);
const trackIds = orderedTracks.map(t => t.id);
const sameSourceTracks = orderedTracks.filter(item => (item.source === 'plex') === (trackSource === 'plex'));
const trackIndex = sameSourceTracks.findIndex(item => item.id === track.id);
const trackIds = sameSourceTracks.map(item => item.id);
// Set playback context for Local Library tracks view
await setPlaybackContext(
'local_library',
'local-tracks',
trackSource === 'plex' ? 'plex-tracks' : 'local-tracks',
'Local Tracks',
'local',
trackSource,
trackIds,
trackIndex >= 0 ? trackIndex : 0
);
await setQueueForLocalTracks(orderedTracks, trackIndex >= 0 ? trackIndex : 0);
await setQueueForLocalTracks(sameSourceTracks, trackIndex >= 0 ? trackIndex : 0);
}
await invoke('library_play_track', { trackId: track.id });
onTrackPlay?.(track);
if (onTrackPlay) {
await Promise.resolve(onTrackPlay(track));
} else if (trackSource === 'local') {
await invoke('library_play_track', { trackId: track.id });
} else {
throw new Error('Plex playback handler not available');
}
} catch (err) {
console.error('Failed to play track:', err);
alert(`Failed to play: ${err}`);
@@ -1616,6 +1949,17 @@
async function fetchAlbumTracks(album: LocalAlbum): Promise<LocalTrack[]> {
try {
if (album.source === 'plex') {
if (!isPlexLibraryEnabled()) {
return [];
}
const plexTracks = await invoke<PlexCachedTrack[]>('plex_cache_get_album_tracks', {
albumKey: album.id
});
const mappedTracks = plexTracks.map(mapPlexTrack);
return await hydratePlexTrackQuality(mappedTracks);
}
return await invoke<LocalTrack[]>('library_get_album_tracks', {
albumGroupKey: album.id
});
@@ -1625,29 +1969,94 @@
}
}
function isLikelyFallbackPlexQuality(track: LocalTrack): boolean {
if (track.source !== 'plex') return false;
const format = (track.format || '').toLowerCase();
const bitDepth = track.bit_depth ?? 0;
const sampleRate = track.sample_rate ?? 0;
return format === 'flac' && bitDepth <= 16 && sampleRate <= 44100;
}
async function hydratePlexTrackQuality(tracks: LocalTrack[]): Promise<LocalTrack[]> {
const baseUrl = getUserItem('qbz-plex-poc-base-url') || '';
const token = getUserItem('qbz-plex-poc-token') || '';
if (!baseUrl || !token || tracks.length === 0) return tracks;
const candidates = tracks.filter((track) => isLikelyFallbackPlexQuality(track));
if (candidates.length === 0) return tracks;
const metadataEntries = await Promise.all(
candidates.map(async (track) => {
try {
const metadata = await invoke<PlexTrackMetadata>('plex_get_track_metadata', {
baseUrl,
token,
ratingKey: track.file_path
});
return [track.file_path, metadata] as const;
} catch (error) {
console.warn('[LocalLibrary] Failed to hydrate Plex track metadata for', track.file_path, error);
return null;
}
})
);
const metadataByRatingKey = new Map<string, PlexTrackMetadata>();
const qualityUpdates: PlexTrackQualityUpdate[] = [];
for (const entry of metadataEntries) {
if (!entry) continue;
metadataByRatingKey.set(entry[0], entry[1]);
qualityUpdates.push({
ratingKey: entry[0],
container: entry[1].container ?? entry[1].codec,
samplingRateHz: entry[1].samplingRateHz,
bitDepth: entry[1].bitDepth
});
}
if (metadataByRatingKey.size === 0) return tracks;
if (qualityUpdates.length > 0) {
invoke<number>('plex_cache_update_track_quality', { updates: qualityUpdates }).catch((error) => {
console.warn('[LocalLibrary] Failed to persist Plex track quality updates:', error);
});
}
return tracks.map((track) => {
const metadata = metadataByRatingKey.get(track.file_path);
if (!metadata) return track;
return {
...track,
format: (metadata.container ?? metadata.codec ?? track.format).toLowerCase(),
bit_depth: metadata.bitDepth ?? track.bit_depth,
sample_rate: metadata.samplingRateHz ?? track.sample_rate
};
});
}
async function setQueueForLocalTracks(tracks: LocalTrack[], startIndex = 0) {
console.log('[LocalLibrary Queue] Setting queue with', tracks.length, 'tracks, startIndex:', startIndex);
const queueTracks = tracks.map(t => ({
id: t.id,
title: t.title,
artist: t.artist,
album: t.album,
duration_secs: t.duration_secs,
artwork_url: t.artwork_path ? getArtworkUrl(t.artwork_path) : null,
hires: (t.bit_depth && t.bit_depth > 16) || t.sample_rate > 44100,
bit_depth: t.bit_depth ?? null,
sample_rate: t.sample_rate ?? null,
is_local: true,
const queueTracks = tracks.map(track => ({
source: track.source === 'plex' ? 'plex' : 'local',
id: track.id,
title: track.title,
artist: track.artist,
album: track.album,
duration_secs: track.duration_secs,
artwork_url: track.artwork_path ? getArtworkUrl(track.artwork_path) : null,
hires: (track.bit_depth && track.bit_depth > 16) || track.sample_rate > 44100,
bit_depth: track.bit_depth ?? null,
sample_rate: track.sample_rate ?? null,
is_local: track.source !== 'plex',
album_id: null, // Local tracks don't have Qobuz IDs
artist_id: null,
}));
console.log('[LocalLibrary Queue] Mapped to', queueTracks.length, 'queue tracks');
console.log('[LocalLibrary Queue] Track IDs:', queueTracks.map(t => t.id));
console.log('[LocalLibrary Queue] Track IDs:', queueTracks.map(track => track.id));
await invoke('set_queue', { tracks: queueTracks, startIndex });
onSetLocalQueue?.(tracks.map(t => t.id));
onSetLocalQueue?.(tracks.filter(track => track.source !== 'plex').map(track => track.id));
console.log('[LocalLibrary Queue] Queue set successfully');
}
@@ -1724,6 +2133,10 @@
function openAlbumEditModal() {
if (!selectedAlbum) return;
if (selectedAlbum.source === 'plex' && getUserItem(PLEX_METADATA_WRITE_KEY) !== 'true') {
showToast($t('settings.integrations.plexWriteDisabledNotice'), 'info');
return;
}
editingAlbumHidden = false;
albumMetadataRefreshed = false;
discogsImageOptions = [];
@@ -1879,8 +2292,10 @@
function getQualityBadge(track: LocalTrack): string {
const format = track.format.toUpperCase();
const bitDepth = track.bit_depth ?? 16;
const sampleRate = track.sample_rate / 1000; // Convert to kHz
const bitDepth = track.bit_depth && track.bit_depth > 0 ? String(track.bit_depth) : '--';
const sampleRate = track.sample_rate > 0
? Number((track.sample_rate / 1000).toFixed(1)).toString()
: '--';
// Format: "FLAC 24/96" style that audiophiles love
return `${format} ${bitDepth}/${sampleRate}`;
@@ -1891,17 +2306,20 @@
}
function formatSampleRate(hz: number): string {
if (hz <= 0) return '-- kHz';
return `${(hz / 1000).toFixed(1)} kHz`;
}
function formatBitDepth(bits?: number): string {
return bits ? `${bits}-bit` : '16-bit';
return bits && bits > 0 ? `${bits}-bit` : '--bit';
}
function getAlbumQualityBadge(album: LocalAlbum): string {
const format = album.format.toUpperCase();
const bitDepth = album.bit_depth ?? 16;
const sampleRate = album.sample_rate / 1000;
const bitDepth = album.bit_depth && album.bit_depth > 0 ? String(album.bit_depth) : '--';
const sampleRate = album.sample_rate > 0
? Number((album.sample_rate / 1000).toFixed(1)).toString()
: '--';
return `${format} ${bitDepth}/${sampleRate}`;
}
@@ -1959,6 +2377,8 @@
function getArtworkUrl(path?: string): string {
if (!path) return '';
if (/^https?:\/\//i.test(path)) return path;
if (path.startsWith('/library/')) return buildPlexArtworkUrl(path);
// For grid/list views, prefer thumbnails
const cachedThumb = thumbnailUrlCache.get(path);
@@ -1991,6 +2411,8 @@
// For album detail view, always use full resolution
function getFullArtworkUrl(path?: string): string {
if (!path) return '';
if (/^https?:\/\//i.test(path)) return path;
if (path.startsWith('/library/')) return buildPlexArtworkUrl(path);
const cached = artworkUrlCache.get(path);
if (cached) return cached;
const url = convertFileSrc(path);
@@ -2590,7 +3012,14 @@
<ArrowLeft size={16} />
<span>{$t('library.backToLibrary')}</span>
</button>
<button class="edit-btn" onclick={openAlbumEditModal} title={$t('actions.edit')}>
<button
class="edit-btn"
onclick={openAlbumEditModal}
title={selectedAlbum?.source === 'plex' && getUserItem(PLEX_METADATA_WRITE_KEY) !== 'true'
? $t('settings.integrations.plexWriteDisabledNotice')
: $t('actions.edit')}
disabled={selectedAlbum?.source === 'plex' && getUserItem(PLEX_METADATA_WRITE_KEY) !== 'true'}
>
<Edit3 size={16} />
</button>
</div>
@@ -2665,6 +3094,7 @@
quality={getQualityBadge(track)}
isPlaying={isPlaybackActive && activeTrackId === track.id}
isLocal={true}
localSource={track.source === 'plex' ? 'plex' : 'local'}
hideDownload={true}
hideFavorite={true}
onArtistClick={track.artist && track.artist !== selectedAlbum?.artist
@@ -2675,7 +3105,7 @@
onPlayNow: () => handleTrackPlay(track),
onPlayNext: onTrackPlayNext ? () => onTrackPlayNext(track) : undefined,
onPlayLater: onTrackPlayLater ? () => onTrackPlayLater(track) : undefined,
onAddToPlaylist: onTrackAddToPlaylist ? () => onTrackAddToPlaylist(track.id) : undefined
onAddToPlaylist: onTrackAddToPlaylist && track.source !== 'plex' ? () => onTrackAddToPlaylist(track.id) : undefined
}}
/>
{/each}
@@ -3364,7 +3794,7 @@
onPlayNext={() => handleAlbumQueueNextFromGrid(album)}
onPlayLater={() => handleAlbumQueueLaterFromGrid(album)}
onclick={() => handleAlbumClick(album)}
sourceBadge={album.source === 'qobuz_download' ? 'qobuz_download' : 'user'}
sourceBadge={album.source === 'plex' ? 'plex' : (album.source === 'qobuz_download' ? 'qobuz_download' : 'user')}
/>
{/each}
</div>
@@ -509,7 +509,7 @@
}));
// Update duration
playlist.duration = qobuzTracks.reduce((sum, t) => sum + t.duration, 0);
playlist.duration = qobuzTracks.reduce((sum, track) => sum + track.duration, 0);
} catch (err) {
console.error('Failed to load Qobuz tracks for pending playlist:', err);
tracks = [];
@@ -212,9 +212,18 @@
}
});
// Pre-computed playlist lookup by id (avoids O(n) find per call)
const playlistsById = $derived.by(() => {
const map = new Map<number, typeof playlists[0]>();
for (const p of playlists) {
map.set(p.id, p);
}
return map;
});
// Helper to get local content status for a playlist (calculated from actual data)
function getLocalContentStatus(playlistId: number): LocalContentStatus {
const playlist = playlists.find(p => p.id === playlistId);
const playlist = playlistsById.get(playlistId);
const localCount = localTrackCounts.get(playlistId) ?? 0;
const qobuzCount = playlist?.tracks_count ?? 0;
@@ -238,60 +247,44 @@
return false;
}
// Filtered and sorted playlists
// Filtered and sorted playlists (single-pass filter for search + visibility + folder)
const displayPlaylists = $derived.by(() => {
let result = [...playlists];
const query = searchQuery.trim().toLowerCase();
const hasSearch = query.length > 0;
const isOffline = offlineStatus.isOffline;
// Apply search filter first
if (searchQuery.trim()) {
const query = searchQuery.trim().toLowerCase();
result = result.filter(p => p.name.toLowerCase().includes(query));
}
const result = playlists.filter(p => {
// Search filter
if (hasSearch && !p.name.toLowerCase().includes(query)) return false;
// Apply filter based on mode (offline or regular)
if (offlineStatus.isOffline) {
// In offline mode, use offline-specific filters
if (filter === 'offline_all' || filter === 'all') {
// Show only playlists where ALL tracks are available offline
result = result.filter(p => {
const localStatus = getLocalContentStatus(p.id);
return localStatus === 'all_local';
});
} else if (filter === 'offline_partial') {
// Show only playlists with partial local content
result = result.filter(p => {
const localStatus = getLocalContentStatus(p.id);
return localStatus === 'some_local';
});
} else if (filter === 'offline_unavailable') {
// Show playlists with NO local content (view-only)
result = result.filter(p => {
const localStatus = getLocalContentStatus(p.id);
return localStatus === 'no' || localStatus === 'unknown';
});
} else if (filter === 'visible') {
result = result.filter(p => {
const settings = playlistSettings.get(p.id);
return !settings?.hidden && isPlaylistAvailableOffline(p.id);
});
} else if (filter === 'hidden') {
result = result.filter(p => playlistSettings.get(p.id)?.hidden);
}
} else {
// Regular online mode filters
if (filter === 'visible') {
result = result.filter(p => !playlistSettings.get(p.id)?.hidden);
} else if (filter === 'hidden') {
result = result.filter(p => playlistSettings.get(p.id)?.hidden);
}
// 'all' shows everything
}
// Filter by current folder
result = result.filter(p => {
// Folder filter
const settings = playlistSettings.get(p.id);
const playlistFolderId = settings?.folder_id ?? null;
return playlistFolderId === currentFolderId;
if (playlistFolderId !== currentFolderId) return false;
// Visibility / offline filter
if (isOffline) {
if (filter === 'offline_all' || filter === 'all') {
return getLocalContentStatus(p.id) === 'all_local';
} else if (filter === 'offline_partial') {
return getLocalContentStatus(p.id) === 'some_local';
} else if (filter === 'offline_unavailable') {
const status = getLocalContentStatus(p.id);
return status === 'no' || status === 'unknown';
} else if (filter === 'visible') {
return !settings?.hidden && isPlaylistAvailableOffline(p.id);
} else if (filter === 'hidden') {
return !!settings?.hidden;
}
} else {
if (filter === 'visible') {
return !settings?.hidden;
} else if (filter === 'hidden') {
return !!settings?.hidden;
}
}
return true;
});
// Apply sort
@@ -321,6 +314,15 @@
return result;
});
// Pre-computed index map: playlist id -> index in displayPlaylists (avoids O(n) findIndex per item)
const displayPlaylistIndexMap = $derived.by(() => {
const map = new Map<number, number>();
for (let i = 0; i < displayPlaylists.length; i++) {
map.set(displayPlaylists[i].id, i);
}
return map;
});
// Get current folder info
const currentFolder = $derived(
currentFolderId ? folders.find(f => f.id === currentFolderId) : null
@@ -1087,7 +1089,7 @@
>
<!-- Top row: reorder controls (when in custom sort mode) -->
{#if sort === 'custom' && !isUnavailable}
{@const playlistIndex = displayPlaylists.findIndex(p => p.id === playlist.id)}
{@const playlistIndex = displayPlaylistIndexMap.get(playlist.id) ?? 0}
<div class="grid-item-header">
<div class="reorder-controls">
<button
@@ -1208,7 +1210,7 @@
title={isUnavailable ? $t('offline.viewOnly') : undefined}
>
{#if sort === 'custom' && !isUnavailable}
{@const playlistIndex = displayPlaylists.findIndex(p => p.id === playlist.id)}
{@const playlistIndex = displayPlaylistIndexMap.get(playlist.id) ?? 0}
<div class="reorder-controls horizontal">
<button
class="reorder-btn"
+542 -173
View File
@@ -1,5 +1,5 @@
<script lang="ts">
import { onMount, tick } from 'svelte';
import { onMount, onDestroy, tick } from 'svelte';
import { invoke } from '@tauri-apps/api/core';
import { Search, Disc3, Music, Mic2, User, X, ChevronLeft, ChevronRight, Crown, Heart, Play, MoreHorizontal, ListPlus } from 'lucide-svelte';
import AlbumCard from '../AlbumCard.svelte';
@@ -424,7 +424,11 @@
async function loadAllAlbumDownloadStatuses(albums: { id: string }[]) {
if (!checkAlbumFullyDownloaded || albums.length === 0) return;
await Promise.all(albums.map(album => loadAlbumDownloadStatus(album.id)));
const BATCH = 10;
for (let i = 0; i < albums.length; i += BATCH) {
await Promise.all(albums.slice(i, i + BATCH).map(album => loadAlbumDownloadStatus(album.id)));
if (i + BATCH < albums.length) await new Promise(r => setTimeout(r, 0));
}
}
function isAlbumDownloaded(albumId: string): boolean {
@@ -658,26 +662,26 @@
}
function buildSearchQueueTracks(tracks: Track[]) {
return tracks.map(t => ({
id: t.id,
title: t.title,
artist: t.performer?.name || 'Unknown Artist',
album: t.album?.title || '',
duration_secs: t.duration,
artwork_url: t.album?.image?.large || t.album?.image?.thumbnail || t.album?.image?.small || '',
hires: t.hires_streamable ?? false,
bit_depth: t.maximum_bit_depth ?? null,
sample_rate: t.maximum_sampling_rate ?? null,
return tracks.map(track => ({
id: track.id,
title: track.title,
artist: track.performer?.name || 'Unknown Artist',
album: track.album?.title || '',
duration_secs: track.duration,
artwork_url: track.album?.image?.large || track.album?.image?.thumbnail || track.album?.image?.small || '',
hires: track.hires_streamable ?? false,
bit_depth: track.maximum_bit_depth ?? null,
sample_rate: track.maximum_sampling_rate ?? null,
is_local: false,
album_id: t.album?.id || null,
artist_id: t.performer?.id ?? null,
album_id: track.album?.id || null,
artist_id: track.performer?.id ?? null,
}));
}
async function handleSearchTrackPlay(track: Track, trackIndex: number) {
// Create search results context
if (trackResults && trackResults.items.length > 0) {
const trackIds = trackResults.items.map(t => t.id);
const trackIds = trackResults.items.map(track => track.id);
await setPlaybackContext(
'home_list', // Using home_list for search results (search type doesn't exist yet)
@@ -771,6 +775,303 @@
let visibleArtists = $derived(
artistsWithViewMore().slice(currentArtistPage * artistsPerPage, (currentArtistPage + 1) * artistsPerPage)
);
// ==========================================
// Inline virtualization for tab result lists
// ==========================================
// Shared virtualization helpers
function vBinarySearchStart(tops: number[], heights: number[], targetTop: number): number {
let low = 0;
let high = tops.length - 1;
let result = 0;
while (low <= high) {
const mid = Math.floor((low + high) / 2);
if (tops[mid] + heights[mid] > targetTop) {
result = mid;
high = mid - 1;
} else {
low = mid + 1;
}
}
return result;
}
function vBinarySearchEnd(tops: number[], targetBottom: number, startFrom: number): number {
let low = startFrom;
let high = tops.length - 1;
let result = high;
while (low <= high) {
const mid = Math.floor((low + high) / 2);
if (tops[mid] > targetBottom) {
result = mid;
high = mid - 1;
} else {
low = mid + 1;
}
}
return result;
}
// --- Albums tab virtualization ---
const ALBUM_CARD_WIDTH = 180;
const ALBUM_CARD_HEIGHT = 290;
const ALBUM_GAP_X = 14;
const ALBUM_GAP_Y = 24;
const LOAD_MORE_HEIGHT = 80;
const V_BUFFER = 5;
let albumsVirtualEl: HTMLDivElement | null = $state(null);
let albumsScrollTop = $state(0);
let albumsContainerHeight = $state(0);
let albumsContainerWidth = $state(0);
let albumsResizeObs: ResizeObserver | null = null;
let albumGridCols = $derived.by(() => {
if (albumsContainerWidth === 0) return 1;
return Math.max(1, Math.floor((albumsContainerWidth + ALBUM_GAP_X) / (ALBUM_CARD_WIDTH + ALBUM_GAP_X)));
});
interface AlbumVirtualRow {
type: 'row';
startIdx: number;
count: number;
top: number;
height: number;
}
interface LoadMoreVirtualItem {
type: 'load-more';
top: number;
height: number;
}
type AlbumVirtualItem = AlbumVirtualRow | LoadMoreVirtualItem;
let albumVirtualItems = $derived.by((): AlbumVirtualItem[] => {
if (!albumResults) return [];
const items: AlbumVirtualItem[] = [];
const total = albumResults.items.length;
const cols = albumGridCols;
const rowH = ALBUM_CARD_HEIGHT + ALBUM_GAP_Y;
let top = 0;
for (let i = 0; i < total; i += cols) {
items.push({
type: 'row',
startIdx: i,
count: Math.min(cols, total - i),
top,
height: ALBUM_CARD_HEIGHT,
});
top += rowH;
}
if (hasMoreAlbums) {
items.push({ type: 'load-more', top, height: LOAD_MORE_HEIGHT });
}
return items;
});
let albumTotalHeight = $derived(
albumVirtualItems.length > 0
? albumVirtualItems[albumVirtualItems.length - 1].top + albumVirtualItems[albumVirtualItems.length - 1].height
: 0
);
let albumVisibleItems = $derived.by(() => {
if (albumVirtualItems.length === 0) return [];
const tops = albumVirtualItems.map(vi => vi.top);
const heights = albumVirtualItems.map(vi => vi.height);
const first = vBinarySearchStart(tops, heights, albumsScrollTop);
const last = vBinarySearchEnd(tops, albumsScrollTop + albumsContainerHeight, first);
const s = Math.max(0, first - V_BUFFER);
const e = Math.min(albumVirtualItems.length - 1, last + V_BUFFER);
return albumVirtualItems.slice(s, e + 1);
});
function handleAlbumsScroll(e: Event) {
albumsScrollTop = (e.target as HTMLDivElement).scrollTop;
}
// --- Tracks tab virtualization ---
const TRACK_ROW_HEIGHT = 72;
const TRACK_ROW_GAP = 4;
let tracksVirtualEl: HTMLDivElement | null = $state(null);
let tracksScrollTop = $state(0);
let tracksContainerHeight = $state(0);
let tracksResizeObs: ResizeObserver | null = null;
interface TrackVirtualItem {
type: 'track';
index: number;
top: number;
height: number;
}
type TrackOrLoadMore = TrackVirtualItem | LoadMoreVirtualItem;
let trackVirtualItems = $derived.by((): TrackOrLoadMore[] => {
if (!trackResults) return [];
const items: TrackOrLoadMore[] = [];
const rowH = TRACK_ROW_HEIGHT + TRACK_ROW_GAP;
let top = 0;
for (let i = 0; i < trackResults.items.length; i++) {
items.push({ type: 'track', index: i, top, height: TRACK_ROW_HEIGHT });
top += rowH;
}
if (hasMoreTracks) {
items.push({ type: 'load-more', top, height: LOAD_MORE_HEIGHT });
}
return items;
});
let trackTotalHeight = $derived(
trackVirtualItems.length > 0
? trackVirtualItems[trackVirtualItems.length - 1].top + trackVirtualItems[trackVirtualItems.length - 1].height
: 0
);
let trackVisibleItems = $derived.by(() => {
if (trackVirtualItems.length === 0) return [];
const tops = trackVirtualItems.map(vi => vi.top);
const heights = trackVirtualItems.map(vi => vi.height);
const first = vBinarySearchStart(tops, heights, tracksScrollTop);
const last = vBinarySearchEnd(tops, tracksScrollTop + tracksContainerHeight, first);
const s = Math.max(0, first - V_BUFFER);
const e = Math.min(trackVirtualItems.length - 1, last + V_BUFFER);
return trackVirtualItems.slice(s, e + 1);
});
function handleTracksScroll(e: Event) {
tracksScrollTop = (e.target as HTMLDivElement).scrollTop;
}
// --- Artists tab virtualization ---
const ARTIST_CARD_WIDTH = 160;
const ARTIST_CARD_HEIGHT = 220;
const ARTIST_GAP = 24;
let artistsVirtualEl: HTMLDivElement | null = $state(null);
let artistsScrollTop = $state(0);
let artistsContainerHeight = $state(0);
let artistsContainerWidth = $state(0);
let artistsResizeObs: ResizeObserver | null = null;
let artistGridCols = $derived.by(() => {
if (artistsContainerWidth === 0) return 1;
return Math.max(1, Math.floor((artistsContainerWidth + ARTIST_GAP) / (ARTIST_CARD_WIDTH + ARTIST_GAP)));
});
interface ArtistVirtualRow {
type: 'row';
startIdx: number;
count: number;
top: number;
height: number;
}
type ArtistVirtualItem = ArtistVirtualRow | LoadMoreVirtualItem;
let artistVirtualItems = $derived.by((): ArtistVirtualItem[] => {
if (!artistResults) return [];
const items: ArtistVirtualItem[] = [];
const total = artistResults.items.length;
const cols = artistGridCols;
const rowH = ARTIST_CARD_HEIGHT + ARTIST_GAP;
let top = 0;
for (let i = 0; i < total; i += cols) {
items.push({
type: 'row',
startIdx: i,
count: Math.min(cols, total - i),
top,
height: ARTIST_CARD_HEIGHT,
});
top += rowH;
}
if (hasMoreArtists) {
items.push({ type: 'load-more', top, height: LOAD_MORE_HEIGHT });
}
return items;
});
let artistTotalHeight = $derived(
artistVirtualItems.length > 0
? artistVirtualItems[artistVirtualItems.length - 1].top + artistVirtualItems[artistVirtualItems.length - 1].height
: 0
);
let artistVisibleItems = $derived.by(() => {
if (artistVirtualItems.length === 0) return [];
const tops = artistVirtualItems.map(vi => vi.top);
const heights = artistVirtualItems.map(vi => vi.height);
const first = vBinarySearchStart(tops, heights, artistsScrollTop);
const last = vBinarySearchEnd(tops, artistsScrollTop + artistsContainerHeight, first);
const s = Math.max(0, first - V_BUFFER);
const e = Math.min(artistVirtualItems.length - 1, last + V_BUFFER);
return artistVirtualItems.slice(s, e + 1);
});
function handleArtistsScroll(e: Event) {
artistsScrollTop = (e.target as HTMLDivElement).scrollTop;
}
// --- ResizeObserver setup/teardown ---
function setupResizeObservers() {
if (albumsVirtualEl && !albumsResizeObs) {
albumsContainerHeight = albumsVirtualEl.clientHeight;
albumsContainerWidth = albumsVirtualEl.clientWidth;
albumsResizeObs = new ResizeObserver((entries) => {
for (const entry of entries) {
albumsContainerHeight = entry.contentRect.height;
albumsContainerWidth = entry.contentRect.width;
}
});
albumsResizeObs.observe(albumsVirtualEl);
}
if (tracksVirtualEl && !tracksResizeObs) {
tracksContainerHeight = tracksVirtualEl.clientHeight;
tracksResizeObs = new ResizeObserver((entries) => {
for (const entry of entries) {
tracksContainerHeight = entry.contentRect.height;
}
});
tracksResizeObs.observe(tracksVirtualEl);
}
if (artistsVirtualEl && !artistsResizeObs) {
artistsContainerHeight = artistsVirtualEl.clientHeight;
artistsContainerWidth = artistsVirtualEl.clientWidth;
artistsResizeObs = new ResizeObserver((entries) => {
for (const entry of entries) {
artistsContainerHeight = entry.contentRect.height;
artistsContainerWidth = entry.contentRect.width;
}
});
artistsResizeObs.observe(artistsVirtualEl);
}
}
// Setup observers when elements bind
$effect(() => {
if (albumsVirtualEl || tracksVirtualEl || artistsVirtualEl) {
setupResizeObservers();
}
});
onDestroy(() => {
albumsResizeObs?.disconnect();
tracksResizeObs?.disconnect();
artistsResizeObs?.disconnect();
});
</script>
<svelte:window onclick={handleClickOutsidePopularMenu} />
@@ -1454,183 +1755,202 @@
{#if albumResults.items.length === 0}
<div class="no-results">{$t('search.noAlbumsFor', { values: { query } })}</div>
{:else}
<div class="albums-grid">
{#each albumResults.items as album}
<AlbumCard
albumId={album.id}
artwork={getAlbumArtwork(album)}
title={album.title}
artist={album.artist?.name || 'Unknown Artist'}
artistId={album.artist?.id}
onArtistClick={onArtistClick}
genre={getGenreLabel(album)}
releaseDate={album.release_date_original}
size="large"
quality={getQualityLabel(album)}
onPlay={onAlbumPlay ? () => onAlbumPlay(album.id) : undefined}
onPlayNext={onAlbumPlayNext ? () => onAlbumPlayNext(album.id) : undefined}
onPlayLater={onAlbumPlayLater ? () => onAlbumPlayLater(album.id) : undefined}
onAddAlbumToPlaylist={onAddAlbumToPlaylist ? () => onAddAlbumToPlaylist(album.id) : undefined}
onShareQobuz={onAlbumShareQobuz ? () => onAlbumShareQobuz(album.id) : undefined}
onShareSonglink={onAlbumShareSonglink ? () => onAlbumShareSonglink(album.id) : undefined}
onDownload={onAlbumDownload ? () => onAlbumDownload(album.id) : undefined}
isAlbumFullyDownloaded={isAlbumDownloaded(album.id)}
onOpenContainingFolder={onOpenAlbumFolder ? () => onOpenAlbumFolder(album.id) : undefined}
onReDownloadAlbum={onReDownloadAlbum ? () => onReDownloadAlbum(album.id) : undefined}
{downloadStateVersion}
onclick={() => { onAlbumClick?.(album.id); loadAlbumDownloadStatus(album.id); }}
/>
{/each}
</div>
{#if hasMoreAlbums}
<div class="load-more-container">
<button class="load-more-btn" onclick={loadMore} disabled={isLoadingMore}>
{isLoadingMore ? $t('actions.loading') : $t('artist.loadMore') + ` (${albumResults.items.length} / ${albumResults.total})`}
</button>
<div class="virtual-scroll-container" bind:this={albumsVirtualEl} onscroll={handleAlbumsScroll}>
<div class="virtual-scroll-content" style="height: {albumTotalHeight}px;">
{#each albumVisibleItems as vItem (vItem.type === 'row' ? `row-${vItem.startIdx}` : 'load-more')}
<div class="virtual-scroll-item" style="transform: translateY({vItem.top}px); height: {vItem.height}px;">
{#if vItem.type === 'row'}
<div class="albums-grid-row">
{#each albumResults.items.slice(vItem.startIdx, vItem.startIdx + vItem.count) as album (album.id)}
<AlbumCard
albumId={album.id}
artwork={getAlbumArtwork(album)}
title={album.title}
artist={album.artist?.name || 'Unknown Artist'}
genre={getGenreLabel(album)}
releaseDate={album.release_date_original}
size="large"
quality={getQualityLabel(album)}
onPlay={onAlbumPlay ? () => onAlbumPlay(album.id) : undefined}
onPlayNext={onAlbumPlayNext ? () => onAlbumPlayNext(album.id) : undefined}
onPlayLater={onAlbumPlayLater ? () => onAlbumPlayLater(album.id) : undefined}
onAddAlbumToPlaylist={onAddAlbumToPlaylist ? () => onAddAlbumToPlaylist(album.id) : undefined}
onShareQobuz={onAlbumShareQobuz ? () => onAlbumShareQobuz(album.id) : undefined}
onShareSonglink={onAlbumShareSonglink ? () => onAlbumShareSonglink(album.id) : undefined}
onDownload={onAlbumDownload ? () => onAlbumDownload(album.id) : undefined}
isAlbumFullyDownloaded={isAlbumDownloaded(album.id)}
onOpenContainingFolder={onOpenAlbumFolder ? () => onOpenAlbumFolder(album.id) : undefined}
onReDownloadAlbum={onReDownloadAlbum ? () => onReDownloadAlbum(album.id) : undefined}
{downloadStateVersion}
onclick={() => { onAlbumClick?.(album.id); loadAlbumDownloadStatus(album.id); }}
/>
{/each}
</div>
{:else}
<div class="load-more-container">
<button class="load-more-btn" onclick={loadMore} disabled={isLoadingMore}>
{isLoadingMore ? $t('actions.loading') : $t('artist.loadMore') + ` (${albumResults.items.length} / ${albumResults.total})`}
</button>
</div>
{/if}
</div>
{/each}
</div>
{/if}
</div>
{/if}
{:else if activeTab === 'tracks' && trackResults}
{#if trackResults.items.length === 0}
<div class="no-results">{$t('search.noTracksFor', { values: { query } })}</div>
{:else}
<div class="tracks-list">
{#each trackResults.items as track, index}
{@const isActiveTrack = isPlaybackActive && activeTrackId === track.id}
{@const isTrackDownloaded = checkTrackDownloaded?.(track.id) || false}
<div
class="track-row"
class:playing={isActiveTrack}
role="button"
tabindex="0"
onclick={() => handleSearchTrackPlay(track, index)}
onkeydown={(e) => e.key === 'Enter' && handleSearchTrackPlay(track, index)}
>
<div class="track-number">{index + 1}</div>
<div class="track-artwork-container">
<!-- Placeholder always visible as background -->
<div class="track-artwork-placeholder">
<Music size={20} />
</div>
<!-- Image overlays placeholder when loaded -->
{#if !failedTrackImages.has(track.id) && getTrackArtwork(track)}
<img src={getTrackArtwork(track)} alt={track.title} class="track-artwork" loading="lazy" decoding="async" onerror={() => handleTrackImageError(track.id)} />
{/if}
<button
class="track-play-overlay"
class:is-playing={isActiveTrack}
onclick={(e) => {
if (isActiveTrack) {
handlePausePlayback(e);
} else {
e.stopPropagation();
handleSearchTrackPlay(track, index);
}
}}
aria-label={isActiveTrack ? 'Pause track' : 'Play track'}
>
<span class="play-icon" aria-hidden="true">
<svg width="24" height="24" viewBox="0 0 24 24" fill="white">
<path d="M8 5v14l11-7z"/>
</svg>
</span>
<div class="playing-indicator" aria-hidden="true">
<div class="bar"></div>
<div class="bar"></div>
<div class="bar"></div>
</div>
<span class="pause-icon" aria-hidden="true">
<svg width="20" height="20" viewBox="0 0 24 24" fill="white">
<path d="M6 4h4v16H6V4zm8 0h4v16h-4V4z"/>
</svg>
</span>
</button>
</div>
<div class="track-info">
<div class="track-title">{track.title}</div>
{#if track.performer?.id && onTrackGoToArtist}
<button
class="track-artist track-link"
type="button"
onclick={(event) => {
event.stopPropagation();
onTrackGoToArtist?.(track.performer!.id!);
}}
<div class="virtual-scroll-container" bind:this={tracksVirtualEl} onscroll={handleTracksScroll}>
<div class="virtual-scroll-content" style="height: {trackTotalHeight}px;">
{#each trackVisibleItems as vItem (vItem.type === 'track' ? `track-${vItem.index}` : 'load-more')}
<div class="virtual-scroll-item" style="transform: translateY({vItem.top}px); height: {vItem.height}px;">
{#if vItem.type === 'track'}
{@const track = trackResults.items[vItem.index]}
{@const index = vItem.index}
{@const isActiveTrack = isPlaybackActive && activeTrackId === track.id}
{@const isTrackDownloaded = checkTrackDownloaded?.(track.id) || false}
<div
class="track-row"
class:playing={isActiveTrack}
role="button"
tabindex="0"
onclick={() => handleSearchTrackPlay(track, index)}
onkeydown={(e) => e.key === 'Enter' && handleSearchTrackPlay(track, index)}
>
{track.performer?.name || 'Unknown Artist'}
</button>
<div class="track-number">{index + 1}</div>
<div class="track-artwork-container">
<div class="track-artwork-placeholder">
<Music size={20} />
</div>
{#if !failedTrackImages.has(track.id) && getTrackArtwork(track)}
<img src={getTrackArtwork(track)} alt={track.title} class="track-artwork" loading="lazy" decoding="async" onerror={() => handleTrackImageError(track.id)} />
{/if}
<button
class="track-play-overlay"
class:is-playing={isActiveTrack}
onclick={(e) => {
if (isActiveTrack) {
handlePausePlayback(e);
} else {
e.stopPropagation();
handleSearchTrackPlay(track, index);
}
}}
aria-label={isActiveTrack ? 'Pause track' : 'Play track'}
>
<span class="play-icon" aria-hidden="true">
<svg width="24" height="24" viewBox="0 0 24 24" fill="white">
<path d="M8 5v14l11-7z"/>
</svg>
</span>
<div class="playing-indicator" aria-hidden="true">
<div class="bar"></div>
<div class="bar"></div>
<div class="bar"></div>
</div>
<span class="pause-icon" aria-hidden="true">
<svg width="20" height="20" viewBox="0 0 24 24" fill="white">
<path d="M6 4h4v16H6V4zm8 0h4v16h-4V4z"/>
</svg>
</span>
</button>
</div>
<div class="track-info">
<div class="track-title">{track.title}</div>
{#if track.performer?.id && onTrackGoToArtist}
<button
class="track-artist track-link"
type="button"
onclick={(event) => {
event.stopPropagation();
onTrackGoToArtist?.(track.performer!.id!);
}}
>
{track.performer?.name || 'Unknown Artist'}
</button>
{:else}
<div class="track-artist">{track.performer?.name || 'Unknown Artist'}</div>
{/if}
</div>
<div class="track-quality">
<QualityBadge
bitDepth={track.maximum_bit_depth}
samplingRate={track.maximum_sampling_rate}
compact
/>
</div>
<div class="track-duration">{formatDuration(track.duration)}</div>
<div class="track-actions">
<TrackMenu
onPlayNow={() => handleSearchTrackPlay(track, index)}
onPlayNext={onTrackPlayNext ? () => onTrackPlayNext(track) : undefined}
onPlayLater={onTrackPlayLater ? () => onTrackPlayLater(track) : undefined}
onAddFavorite={onTrackAddFavorite ? () => onTrackAddFavorite(track.id) : undefined}
onAddToPlaylist={onTrackAddToPlaylist ? () => onTrackAddToPlaylist(track.id) : undefined}
onShareQobuz={onTrackShareQobuz ? () => onTrackShareQobuz(track.id) : undefined}
onShareSonglink={onTrackShareSonglink ? () => onTrackShareSonglink(track) : undefined}
onGoToAlbum={track.album?.id && onTrackGoToAlbum ? (() => { const albumId = track.album!.id!; return () => onTrackGoToAlbum(albumId); })() : undefined}
onGoToArtist={track.performer?.id && onTrackGoToArtist ? (() => { const artistId = track.performer!.id!; return () => onTrackGoToArtist(artistId); })() : undefined}
onShowInfo={onTrackShowInfo ? () => onTrackShowInfo(track.id) : undefined}
onDownload={onTrackDownload ? () => onTrackDownload(track) : undefined}
isTrackDownloaded={isTrackDownloaded}
onReDownload={isTrackDownloaded && onTrackReDownload ? () => onTrackReDownload(track) : undefined}
onRemoveDownload={isTrackDownloaded && onTrackRemoveDownload ? () => onTrackRemoveDownload(track.id) : undefined}
/>
</div>
</div>
{:else}
<div class="track-artist">{track.performer?.name || 'Unknown Artist'}</div>
<div class="load-more-container">
<button class="load-more-btn" onclick={loadMore} disabled={isLoadingMore}>
{isLoadingMore ? $t('actions.loading') : $t('artist.loadMore') + ` (${trackResults.items.length} / ${trackResults.total})`}
</button>
</div>
{/if}
</div>
<div class="track-quality">
<QualityBadge
bitDepth={track.maximum_bit_depth}
samplingRate={track.maximum_sampling_rate}
compact
/>
</div>
<div class="track-duration">{formatDuration(track.duration)}</div>
<div class="track-actions">
<TrackMenu
onPlayNow={() => handleSearchTrackPlay(track, index)}
onPlayNext={onTrackPlayNext ? () => onTrackPlayNext(track) : undefined}
onPlayLater={onTrackPlayLater ? () => onTrackPlayLater(track) : undefined}
onAddFavorite={onTrackAddFavorite ? () => onTrackAddFavorite(track.id) : undefined}
onAddToPlaylist={onTrackAddToPlaylist ? () => onTrackAddToPlaylist(track.id) : undefined}
onShareQobuz={onTrackShareQobuz ? () => onTrackShareQobuz(track.id) : undefined}
onShareSonglink={onTrackShareSonglink ? () => onTrackShareSonglink(track) : undefined}
onGoToAlbum={track.album?.id && onTrackGoToAlbum ? (() => { const albumId = track.album!.id!; return () => onTrackGoToAlbum(albumId); })() : undefined}
onGoToArtist={track.performer?.id && onTrackGoToArtist ? (() => { const artistId = track.performer!.id!; return () => onTrackGoToArtist(artistId); })() : undefined}
onShowInfo={onTrackShowInfo ? () => onTrackShowInfo(track.id) : undefined}
onDownload={onTrackDownload ? () => onTrackDownload(track) : undefined}
isTrackDownloaded={isTrackDownloaded}
onReDownload={isTrackDownloaded && onTrackReDownload ? () => onTrackReDownload(track) : undefined}
onRemoveDownload={isTrackDownloaded && onTrackRemoveDownload ? () => onTrackRemoveDownload(track.id) : undefined}
/>
</div>
</div>
{/each}
</div>
{#if hasMoreTracks}
<div class="load-more-container">
<button class="load-more-btn" onclick={loadMore} disabled={isLoadingMore}>
{isLoadingMore ? $t('actions.loading') : $t('artist.loadMore') + ` (${trackResults.items.length} / ${trackResults.total})`}
</button>
{/each}
</div>
{/if}
</div>
{/if}
{:else if activeTab === 'artists' && artistResults}
{#if artistResults.items.length === 0}
<div class="no-results">{$t('search.noArtistsFor', { values: { query } })}</div>
{:else}
<div class="artists-grid">
{#each artistResults.items as artist}
<button class="artist-card" onclick={() => onArtistClick?.(artist.id)}>
<div class="artist-image-wrapper">
<!-- Placeholder always visible as background -->
<div class="artist-image-placeholder">
<User size={40} />
</div>
<!-- Image overlays placeholder when loaded -->
{#if !failedArtistImages.has(artist.id) && getArtistImage(artist)}
<img src={getArtistImage(artist)} alt={artist.name} class="artist-image" loading="lazy" decoding="async" onerror={() => handleArtistImageError(artist.id)} />
<div class="virtual-scroll-container" bind:this={artistsVirtualEl} onscroll={handleArtistsScroll}>
<div class="virtual-scroll-content" style="height: {artistTotalHeight}px;">
{#each artistVisibleItems as vItem (vItem.type === 'row' ? `row-${vItem.startIdx}` : 'load-more')}
<div class="virtual-scroll-item" style="transform: translateY({vItem.top}px); height: {vItem.height}px;">
{#if vItem.type === 'row'}
<div class="artists-grid-row">
{#each artistResults.items.slice(vItem.startIdx, vItem.startIdx + vItem.count) as artist (artist.id)}
<button class="artist-card" onclick={() => onArtistClick?.(artist.id)}>
<div class="artist-image-wrapper">
<div class="artist-image-placeholder">
<User size={40} />
</div>
{#if !failedArtistImages.has(artist.id) && getArtistImage(artist)}
<img src={getArtistImage(artist)} alt={artist.name} class="artist-image" loading="lazy" decoding="async" onerror={() => handleArtistImageError(artist.id)} />
{/if}
</div>
<div class="artist-name">{artist.name}</div>
{#if artist.albums_count}
<div class="artist-albums">{$t('library.albumCount', { values: { count: artist.albums_count } })}</div>
{/if}
</button>
{/each}
</div>
{:else}
<div class="load-more-container">
<button class="load-more-btn" onclick={loadMore} disabled={isLoadingMore}>
{isLoadingMore ? $t('actions.loading') : $t('artist.loadMore') + ` (${artistResults.items.length} / ${artistResults.total})`}
</button>
</div>
{/if}
</div>
<div class="artist-name">{artist.name}</div>
{#if artist.albums_count}
<div class="artist-albums">{$t('library.albumCount', { values: { count: artist.albums_count } })}</div>
{/if}
</button>
{/each}
</div>
{#if hasMoreArtists}
<div class="load-more-container">
<button class="load-more-btn" onclick={loadMore} disabled={isLoadingMore}>
{isLoadingMore ? $t('actions.loading') : $t('artist.loadMore') + ` (${artistResults.items.length} / ${artistResults.total})`}
</button>
{/each}
</div>
{/if}
</div>
{/if}
{:else if activeTab === 'playlists' && playlistResults}
@@ -1948,6 +2268,55 @@
to { transform: rotate(360deg); }
}
/* Virtual scroll containers for tab results */
.virtual-scroll-container {
height: calc(100vh - 280px);
min-height: 400px;
overflow-y: auto;
overflow-x: hidden;
position: relative;
}
.virtual-scroll-container::-webkit-scrollbar {
width: 6px;
}
.virtual-scroll-container::-webkit-scrollbar-track {
background: transparent;
}
.virtual-scroll-container::-webkit-scrollbar-thumb {
background: var(--bg-tertiary);
border-radius: 3px;
}
.virtual-scroll-container::-webkit-scrollbar-thumb:hover {
background: var(--text-muted);
}
.virtual-scroll-content {
position: relative;
width: 100%;
}
.virtual-scroll-item {
position: absolute;
left: 0;
right: 0;
will-change: transform;
}
.albums-grid-row {
display: flex;
flex-wrap: wrap;
gap: 24px 14px;
}
.artists-grid-row {
display: flex;
gap: 24px;
}
.albums-grid {
display: flex;
flex-wrap: wrap;
+863 -1
View File
@@ -134,6 +134,42 @@
url: string;
}
interface PlexServerInfo {
friendlyName?: string | null;
version?: string | null;
machineIdentifier?: string | null;
}
interface PlexMusicSection {
key: string;
title: string;
}
interface PlexPinStartResult {
pinId: number;
code: string;
authUrl: string;
expiresIn?: number | null;
}
interface PlexPinCheckResult {
authorized: boolean;
expired: boolean;
authToken?: string | null;
expiresIn?: number | null;
}
interface PlexTrack {
ratingKey: string;
title: string;
artist?: string | null;
album?: string | null;
durationMs?: number | null;
artworkPath?: string | null;
bitDepth?: number | null;
samplingRateHz?: number | null;
}
let {
onBack,
onLogout,
@@ -226,6 +262,7 @@
let storageCollapsed = $state(true);
let developerCollapsed = $state(true);
let forceDmabuf = $state(false);
let hardwareAcceleration = $state(false);
let showLogsModal = $state(false);
// Navigation section IDs with translation keys
@@ -703,6 +740,37 @@
let showRemoteControlGuide = $state(false);
let remoteControlCollapsed = $state(true);
// Plex LAN POC state
let plexEnabled = $state(getUserItem('qbz-plex-enabled') === 'true');
let plexUiCollapsed = $state(getUserItem('qbz-plex-ui-collapsed') === 'true');
let plexManualTokenMode = $state(false);
let plexServerUrl = $state('http://127.0.0.1');
let plexBaseUrl = $state(getUserItem('qbz-plex-poc-base-url') || 'http://127.0.0.1:32400');
let plexToken = $state(getUserItem('qbz-plex-poc-token') || '');
let plexMetadataWriteEnabled = $state(getUserItem('qbz-plex-poc-metadata-write-enabled') === 'true');
let plexSections = $state<PlexMusicSection[]>([]);
let plexTracks = $state<PlexTrack[]>([]);
let plexSectionTrackCounts = $state<Record<string, number>>({});
let plexSelectedSectionKeys = $state<string[]>([]);
let plexStatusKey = $state('settings.integrations.plexStatusIdle');
let plexStatusValues = $state<Record<string, string | number>>({});
let plexBusy = $state(false);
let plexLastError = $state('');
let plexAuthBusy = $state(false);
let plexAuthPinId = $state<number | null>(null);
let plexAuthCode = $state('');
let plexAuthUrl = $state('');
let plexAuthClientId = $state(getUserItem('qbz-plex-poc-client-id') || '');
let plexAuthPollTimer: ReturnType<typeof setInterval> | null = null;
const PLEX_ENABLED_KEY = 'qbz-plex-enabled';
const PLEX_UI_COLLAPSED_KEY = 'qbz-plex-ui-collapsed';
const PLEX_CACHE_SELECTED_SECTIONS_KEY = 'qbz-plex-poc-selected-sections';
const PLEX_CACHE_SELECTED_SECTION_KEY = 'qbz-plex-poc-selected-section';
const PLEX_CACHE_SERVER_ID_KEY = 'qbz-plex-poc-machine-id';
const PLEX_CLIENT_ID_KEY = 'qbz-plex-poc-client-id';
const PLEX_METADATA_WRITE_KEY = 'qbz-plex-poc-metadata-write-enabled';
// Load saved settings on mount
onMount(() => {
// Load theme
@@ -781,6 +849,13 @@
// Load remote control status
loadRemoteControlStatus();
// Warm-start Plex panel from local cache and refresh in background
hydratePlexAddressFieldsFromBaseUrl();
if (plexEnabled) {
void loadPlexCachedState();
void refreshPlexInBackground();
}
// Load notification preferences
loadToastsPreference();
toastsEnabled = getToastsEnabled();
@@ -811,6 +886,11 @@
forceDmabuf = settings.force_dmabuf;
}).catch(() => {});
// Load graphics settings
invoke('get_graphics_settings').then((settings: any) => {
hardwareAcceleration = settings.hardware_acceleration;
}).catch(() => {});
// Subscribe to offline state changes
const unsubscribeOffline = subscribeOffline(() => {
offlineStatus = getOfflineStatus();
@@ -853,6 +933,9 @@
settingsViewEl?.addEventListener('scroll', handleScroll);
return () => {
if (plexAuthPollTimer) {
clearInterval(plexAuthPollTimer);
}
unsubscribeOffline();
unsubscribeZoom();
unsubscribeTitleBar();
@@ -1064,6 +1147,454 @@
}
}
function normalizePlexServerUrl(value: string): string {
const trimmed = value.trim();
if (!trimmed) return '';
return /^https?:\/\//i.test(trimmed) ? trimmed : `http://${trimmed}`;
}
function isPrivateIpv4(hostname: string): boolean {
if (!/^\d+\.\d+\.\d+\.\d+$/.test(hostname)) return false;
const octets = hostname.split('.').map(Number);
if (octets.some((octet) => Number.isNaN(octet) || octet < 0 || octet > 255)) return false;
if (octets[0] === 10) return true;
if (octets[0] === 127) return true;
if (octets[0] === 192 && octets[1] === 168) return true;
if (octets[0] === 172 && octets[1] >= 16 && octets[1] <= 31) return true;
return false;
}
function isLocalPlexAddress(urlInput: string): boolean {
const normalized = normalizePlexServerUrl(urlInput);
if (!normalized) return false;
try {
const parsed = new URL(normalized);
const host = parsed.hostname.toLowerCase();
if (host === 'localhost' || host === '::1') return true;
if (host.endsWith('.local') || host.endsWith('.lan')) return true;
if (!host.includes('.')) return true;
return isPrivateIpv4(host);
} catch {
return false;
}
}
function resolvePlexBaseUrl(): string {
const normalizedUrl = normalizePlexServerUrl(plexServerUrl);
if (!normalizedUrl) return '';
try {
const parsed = new URL(normalizedUrl);
if (!['http:', 'https:'].includes(parsed.protocol)) return '';
if (!parsed.port) {
parsed.port = '32400';
}
return `${parsed.protocol}//${parsed.host}`;
} catch {
return '';
}
}
function hydratePlexAddressFieldsFromBaseUrl() {
try {
const parsed = new URL(plexBaseUrl || 'http://127.0.0.1:32400');
plexServerUrl = `${parsed.protocol}//${parsed.host}`;
} catch {
plexServerUrl = 'http://127.0.0.1';
}
}
function canUsePlexRequests(): boolean {
return plexEnabled && isLocalPlexAddress(plexServerUrl) && !!resolvePlexBaseUrl() && !!plexToken.trim();
}
function persistPlexConfig() {
plexBaseUrl = resolvePlexBaseUrl();
setUserItem('qbz-plex-poc-base-url', plexBaseUrl);
setUserItem('qbz-plex-poc-token', plexToken.trim());
}
function persistPlexSelectedSections() {
setUserItem(PLEX_CACHE_SELECTED_SECTIONS_KEY, JSON.stringify(plexSelectedSectionKeys));
if (plexSelectedSectionKeys.length === 1) {
setUserItem(PLEX_CACHE_SELECTED_SECTION_KEY, plexSelectedSectionKeys[0]);
} else {
removeUserItem(PLEX_CACHE_SELECTED_SECTION_KEY);
}
}
function readPersistedPlexSelectedSections(): string[] {
const raw = getUserItem(PLEX_CACHE_SELECTED_SECTIONS_KEY);
if (raw) {
try {
const parsed = JSON.parse(raw);
if (Array.isArray(parsed)) {
return parsed.filter((item): item is string => typeof item === 'string' && item.trim().length > 0);
}
} catch (error) {
console.warn('Failed parsing persisted Plex section keys:', error);
}
}
const legacySingle = getUserItem(PLEX_CACHE_SELECTED_SECTION_KEY);
return legacySingle ? [legacySingle] : [];
}
function handlePlexEnabledToggle(enabled: boolean) {
plexEnabled = enabled;
setUserItem(PLEX_ENABLED_KEY, enabled ? 'true' : 'false');
if (!enabled) {
plexStatusKey = 'settings.integrations.plexStatusDisabled';
plexStatusValues = {};
return;
}
plexStatusKey = 'settings.integrations.plexStatusIdle';
plexStatusValues = {};
void loadPlexCachedState();
void refreshPlexInBackground();
}
function togglePlexUiCollapsed() {
plexUiCollapsed = !plexUiCollapsed;
setUserItem(PLEX_UI_COLLAPSED_KEY, plexUiCollapsed ? 'true' : 'false');
}
function handlePlexMetadataWriteToggle(enabled: boolean) {
plexMetadataWriteEnabled = enabled;
setUserItem(PLEX_METADATA_WRITE_KEY, enabled ? 'true' : 'false');
}
function ensurePlexClientId(): string {
if (plexAuthClientId) return plexAuthClientId;
const generated = (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function')
? `qbz-${crypto.randomUUID()}`
: `qbz-${Date.now()}-${Math.floor(Math.random() * 1_000_000)}`;
plexAuthClientId = generated;
setUserItem(PLEX_CLIENT_ID_KEY, generated);
return generated;
}
async function handlePlexConnectEasy() {
if (!plexEnabled || plexAuthBusy || !isLocalPlexAddress(plexServerUrl) || !resolvePlexBaseUrl()) return;
plexAuthBusy = true;
plexLastError = '';
persistPlexConfig();
try {
const clientIdentifier = ensurePlexClientId();
const pin = await invoke<PlexPinStartResult>('plex_auth_pin_start', { clientIdentifier });
plexAuthPinId = pin.pinId;
plexAuthCode = pin.code;
plexAuthUrl = pin.authUrl;
plexStatusKey = 'settings.integrations.plexStatusAuthPending';
plexStatusValues = { code: pin.code };
if (plexAuthPollTimer) {
clearInterval(plexAuthPollTimer);
}
plexAuthPollTimer = setInterval(async () => {
if (!plexAuthPinId) return;
try {
const check = await invoke<PlexPinCheckResult>('plex_auth_pin_check', {
clientIdentifier,
pinId: plexAuthPinId,
code: plexAuthCode || null
});
if (check.authorized && check.authToken) {
plexToken = check.authToken;
persistPlexConfig();
plexStatusKey = 'settings.integrations.plexStatusAuthConnected';
plexStatusValues = {};
if (plexAuthPollTimer) {
clearInterval(plexAuthPollTimer);
plexAuthPollTimer = null;
}
plexAuthPinId = null;
plexAuthCode = '';
void runPlexAutoSetup();
} else if (check.expired) {
plexStatusKey = 'settings.integrations.plexStatusAuthExpired';
plexStatusValues = {};
if (plexAuthPollTimer) {
clearInterval(plexAuthPollTimer);
plexAuthPollTimer = null;
}
plexAuthPinId = null;
plexAuthCode = '';
}
} catch (error) {
console.warn('Plex auth polling failed:', error);
}
}, 2500);
} catch (error) {
console.error('Failed starting Plex easy connect:', error);
setPlexError(error);
} finally {
plexAuthBusy = false;
}
}
function handleOpenPlexAuthUrl() {
if (!plexAuthUrl) return;
invoke('plex_open_auth_url', { url: plexAuthUrl }).catch((error) => {
console.error('Failed opening Plex auth URL:', error);
setPlexError(error);
});
}
async function handleCopyPlexCode() {
if (!plexAuthCode) return;
try {
await copyToClipboard(plexAuthCode);
showToast(get(t)('settings.integrations.plexCodeCopied'), 'success');
} catch (error) {
console.error('Failed copying Plex code:', error);
showToast(get(t)('settings.integrations.plexCodeCopyFailed'), 'error');
}
}
async function handlePlexTokenBlur() {
persistPlexConfig();
if (canUsePlexRequests()) {
await runPlexAutoSetup();
}
}
function setPlexError(error: unknown) {
const message = String(error);
plexLastError = message;
plexStatusKey = 'settings.integrations.plexStatusError';
plexStatusValues = { error: message };
}
async function handlePlexDisconnect() {
const confirmed = await ask(get(t)('settings.integrations.plexDisconnectConfirmDesc'), {
title: get(t)('settings.integrations.plexDisconnectConfirmTitle'),
kind: 'warning',
okLabel: get(t)('settings.integrations.plexDisconnectConfirmOk'),
cancelLabel: get(t)('actions.cancel')
});
if (!confirmed) return;
if (plexAuthPollTimer) {
clearInterval(plexAuthPollTimer);
plexAuthPollTimer = null;
}
plexAuthPinId = null;
plexAuthCode = '';
plexAuthUrl = '';
plexAuthBusy = false;
plexToken = '';
plexSections = [];
plexTracks = [];
plexSelectedSectionKeys = [];
plexStatusKey = 'settings.integrations.plexStatusDisconnected';
plexStatusValues = {};
plexLastError = '';
removeUserItem('qbz-plex-poc-token');
removeUserItem(PLEX_CACHE_SELECTED_SECTIONS_KEY);
removeUserItem(PLEX_CACHE_SELECTED_SECTION_KEY);
removeUserItem(PLEX_CACHE_SERVER_ID_KEY);
try {
await invoke('plex_cache_clear');
} catch (error) {
console.warn('Failed clearing Plex cache:', error);
}
}
async function handlePlexClearCache() {
const confirmed = await ask(get(t)('settings.integrations.plexClearCacheConfirmDesc'), {
title: get(t)('settings.integrations.plexClearCacheConfirmTitle'),
kind: 'warning',
okLabel: get(t)('settings.integrations.plexClearCacheConfirmOk'),
cancelLabel: get(t)('actions.cancel')
});
if (!confirmed) return;
try {
await invoke('plex_cache_clear');
plexTracks = [];
plexStatusKey = 'settings.integrations.plexStatusCacheCleared';
plexStatusValues = {};
plexLastError = '';
removeUserItem(PLEX_CACHE_SERVER_ID_KEY);
} catch (error) {
console.error('Failed clearing Plex cache:', error);
setPlexError(error);
}
}
async function loadPlexCachedState() {
if (!plexEnabled) return;
try {
const cachedSections = await invoke<PlexMusicSection[]>('plex_cache_get_sections');
if (Array.isArray(cachedSections) && cachedSections.length > 0) {
plexSections = cachedSections;
}
const persistedSections = readPersistedPlexSelectedSections();
if (persistedSections.length > 0) {
plexSelectedSectionKeys = persistedSections;
}
const cachedTracks = await invoke<PlexTrack[]>('plex_cache_get_tracks', {
sectionKey: null
});
if (Array.isArray(cachedTracks) && cachedTracks.length > 0) {
plexTracks = cachedTracks;
plexStatusKey = 'settings.integrations.plexStatusCacheLoaded';
plexStatusValues = { count: cachedTracks.length };
}
} catch (error) {
console.warn('Failed to load Plex cached state:', error);
}
}
async function refreshPlexInBackground() {
if (!canUsePlexRequests()) return;
await runPlexAutoSetup();
}
async function handlePlexPing(): Promise<boolean> {
if (!canUsePlexRequests()) return false;
plexBusy = true;
plexLastError = '';
persistPlexConfig();
try {
const info = await invoke<PlexServerInfo>('plex_ping', {
baseUrl: plexBaseUrl.trim(),
token: plexToken.trim()
});
if (info.machineIdentifier) {
setUserItem(PLEX_CACHE_SERVER_ID_KEY, info.machineIdentifier);
}
plexStatusKey = 'settings.integrations.plexStatusConnected';
plexStatusValues = {
server: info.friendlyName || info.machineIdentifier || 'Plex',
version: info.version || '?'
};
return true;
} catch (error) {
console.error('Failed Plex ping:', error);
setPlexError(error);
return false;
} finally {
plexBusy = false;
}
}
async function syncSelectedPlexLibraries() {
if (!canUsePlexRequests()) return;
plexBusy = true;
plexLastError = '';
try {
const serverId = getUserItem(PLEX_CACHE_SERVER_ID_KEY) || null;
await invoke('plex_cache_clear');
if (plexSections.length > 0) {
await invoke<number>('plex_cache_save_sections', {
serverId,
sections: plexSections
});
}
if (plexSelectedSectionKeys.length === 0) {
plexTracks = [];
plexStatusKey = 'settings.integrations.plexStatusTracksLoaded';
plexStatusValues = { count: 0 };
return;
}
let totalCount = 0;
const sectionCounts: Record<string, number> = {};
for (const sectionKey of plexSelectedSectionKeys) {
const sectionTracks = await invoke<PlexTrack[]>('plex_get_section_tracks', {
baseUrl: plexBaseUrl.trim(),
token: plexToken.trim(),
sectionKey
});
const count = sectionTracks.length;
sectionCounts[sectionKey] = count;
totalCount += count;
await invoke<number>('plex_cache_save_tracks', {
serverId,
sectionKey,
tracks: sectionTracks
});
}
plexSectionTrackCounts = { ...plexSectionTrackCounts, ...sectionCounts };
plexTracks = await invoke<PlexTrack[]>('plex_cache_get_tracks', { sectionKey: null });
plexStatusKey = 'settings.integrations.plexStatusTracksLoaded';
plexStatusValues = { count: totalCount };
} catch (error) {
console.error('Failed syncing selected Plex libraries:', error);
setPlexError(error);
} finally {
plexBusy = false;
}
}
async function handlePlexLoadSections(options: { autoSyncSelected?: boolean } = {}) {
if (!canUsePlexRequests()) return;
plexBusy = true;
plexLastError = '';
persistPlexConfig();
try {
const sections = await invoke<PlexMusicSection[]>('plex_get_music_sections', {
baseUrl: plexBaseUrl.trim(),
token: plexToken.trim()
});
plexSections = sections;
await invoke<number>('plex_cache_save_sections', {
serverId: getUserItem(PLEX_CACHE_SERVER_ID_KEY) || null,
sections
});
const available = new Set(sections.map((section) => section.key));
const persisted = readPersistedPlexSelectedSections().filter((key) => available.has(key));
plexSelectedSectionKeys = persisted.length > 0 ? persisted : sections.map((section) => section.key);
persistPlexSelectedSections();
plexStatusKey = 'settings.integrations.plexStatusSectionsLoaded';
plexStatusValues = { count: sections.length };
if (options.autoSyncSelected !== false) {
await syncSelectedPlexLibraries();
}
} catch (error) {
console.error('Failed loading Plex sections:', error);
setPlexError(error);
} finally {
plexBusy = false;
}
}
async function runPlexAutoSetup() {
const connected = await handlePlexPing();
if (!connected) return;
await handlePlexLoadSections({ autoSyncSelected: true });
}
async function handlePlexLibraryToggle(sectionKey: string, checked: boolean) {
const current = new Set(plexSelectedSectionKeys);
if (checked) {
current.add(sectionKey);
} else {
current.delete(sectionKey);
const nextCounts = { ...plexSectionTrackCounts };
delete nextCounts[sectionKey];
plexSectionTrackCounts = nextCounts;
}
plexSelectedSectionKeys = plexSections
.map((section) => section.key)
.filter((key) => current.has(key));
persistPlexSelectedSections();
await syncSelectedPlexLibraries();
}
async function loadRemoteControlStatus() {
try {
const status = await invoke<RemoteControlStatus>('remote_control_get_status');
@@ -2171,6 +2702,17 @@
}
}
async function handleHardwareAccelerationChange(enabled: boolean) {
try {
await invoke('set_hardware_acceleration', { enabled });
hardwareAcceleration = enabled;
showToast($t('settings.developer.restartRequired'), 'info');
} catch (err) {
console.error('Failed to set hardware_acceleration:', err);
showToast(String(err), 'error');
}
}
async function handleForceDmabufChange(enabled: boolean) {
try {
await invoke('set_developer_force_dmabuf', { enabled });
@@ -2664,6 +3206,14 @@
/>
</div>
<div class="setting-row">
<div class="setting-info">
<span class="setting-label">{$t('settings.appearance.hardwareAcceleration')}</span>
<span class="setting-desc">{$t('settings.appearance.hardwareAccelerationDesc')}</span>
</div>
<Toggle enabled={hardwareAcceleration} onchange={(v) => handleHardwareAccelerationChange(v)} />
</div>
<!-- System Tray subsection -->
<h4 class="subsection-title">{$t('settings.appearance.tray.title')}</h4>
<div class="setting-row">
@@ -2943,7 +3493,7 @@
{/if}
<!-- MusicBrainz Integration -->
<div class="setting-row last">
<div class="setting-row">
<div class="setting-info">
<span class="setting-label">{$t('settings.integrations.musicbrainz')}</span>
<small class="setting-note">
@@ -2952,6 +3502,200 @@
</div>
<Toggle enabled={musicbrainzEnabled} onchange={handleMusicBrainzChange} />
</div>
<div class="setting-row">
<div class="setting-info">
<span class="setting-label">{$t('settings.integrations.plexConnection')}</span>
<small class="setting-note">{$t('settings.integrations.plexConnectionLore')}</small>
</div>
<div class="setting-row-controls">
<Toggle enabled={plexEnabled} onchange={handlePlexEnabledToggle} />
<button
class="setting-link-button section-collapse-btn"
onclick={togglePlexUiCollapsed}
title={$t('settings.integrations.plexCollapseHint')}
>
{#if plexUiCollapsed}
<ChevronDown size={16} />
{:else}
<ChevronUp size={16} />
{/if}
</button>
</div>
</div>
{#if plexEnabled && !plexUiCollapsed}
<div class="setting-row plex-two-column-row">
<div class="setting-info">
<span class="setting-label">{$t('settings.integrations.plexServerUrl')}:</span>
<small class="setting-note">{$t('settings.integrations.plexServerUrlHelp')}</small>
</div>
<input
id="plex-server-url"
class="remote-control-input plex-server-url-input"
type="text"
bind:value={plexServerUrl}
placeholder={$t('settings.integrations.plexServerUrlPlaceholder')}
oninput={() => persistPlexConfig()}
onblur={() => persistPlexConfig()}
/>
</div>
<div class="setting-row plex-two-column-row">
<div class="setting-info">
<span class="setting-label">{$t('settings.integrations.plexAuthorizeLabel')}</span>
<small class="setting-note">{$t('settings.integrations.plexAuthorizeGenerateHelp')}</small>
</div>
<button
class="connect-btn plex-action-btn"
onclick={handlePlexConnectEasy}
disabled={plexBusy || plexAuthBusy || !isLocalPlexAddress(plexServerUrl) || !resolvePlexBaseUrl()}
>
{plexAuthBusy ? $t('actions.loading') : $t('settings.integrations.plexActionGenerateCode')}
</button>
</div>
{#if plexAuthCode}
<div class="plex-divider"></div>
<div class="setting-row plex-two-column-row">
<div class="setting-info">
<span class="setting-label">{$t('settings.integrations.plexLinkWithPlex')}</span>
<small class="setting-note">{$t('settings.integrations.plexLinkWithPlexHelp')}</small>
</div>
<div class="plex-code-row">
<input
class="remote-control-input plex-code-input"
type="text"
readonly
value={plexAuthCode}
title={$t('settings.integrations.plexCodeTooltip')}
/>
<button
class="connect-btn plex-action-btn"
onclick={handleCopyPlexCode}
title={$t('settings.integrations.plexCodeTooltip')}
>
{$t('settings.integrations.plexActionCopyCode')}
</button>
</div>
</div>
<div class="setting-row plex-two-column-row">
<div class="setting-info">
<span class="setting-label">{$t('settings.integrations.plexAuthorizeUsingCode')}</span>
<small class="setting-note">{$t('settings.integrations.plexAuthorizeHelp')}</small>
</div>
<button
class="connect-btn plex-action-btn"
onclick={handleOpenPlexAuthUrl}
disabled={!plexAuthUrl}
>
{$t('settings.integrations.plexActionOpenAuth')}
</button>
</div>
{/if}
<div class="setting-row">
<div class="setting-info">
<span class="setting-label">{$t('settings.integrations.plexManualTokenToggle')}</span>
<small class="setting-note">{$t('settings.integrations.plexManualTokenHelp')}</small>
</div>
<Toggle enabled={plexManualTokenMode} onchange={(enabled) => plexManualTokenMode = enabled} />
</div>
{#if plexManualTokenMode}
<div class="setting-row plex-two-column-row">
<span class="setting-label">{$t('settings.integrations.plexToken')}</span>
<input
class="remote-control-input"
type="password"
bind:value={plexToken}
placeholder={$t('settings.integrations.plexTokenPlaceholder')}
onblur={handlePlexTokenBlur}
/>
</div>
{/if}
<div class="setting-row plex-two-column-row">
<div class="setting-info">
<span class="setting-label">{$t('settings.integrations.plexConnectionCheckLabel')}</span>
{#if plexStatusKey === 'settings.integrations.plexStatusConnected'}
<small class="setting-note plex-connected-note">{$t(plexStatusKey, { values: plexStatusValues })}</small>
{:else}
<small class="setting-note">{$t(plexStatusKey, { values: plexStatusValues })}</small>
{/if}
</div>
<button
class="connect-btn plex-action-btn"
onclick={() => handlePlexPing()}
disabled={plexBusy || !canUsePlexRequests()}
>
{plexBusy ? $t('actions.loading') : $t('settings.integrations.plexActionPing')}
</button>
</div>
<div class="setting-row plex-two-column-row">
<div class="setting-info">
<span class="setting-label">{$t('settings.integrations.plexGetLibrariesLabel')}</span>
<small class="setting-note">{$t('settings.integrations.plexGetLibrariesHelp')}</small>
</div>
<button
class="connect-btn plex-action-btn"
onclick={() => handlePlexLoadSections({ autoSyncSelected: true })}
disabled={plexBusy || !canUsePlexRequests()}
>
{plexBusy ? $t('actions.loading') : $t('settings.integrations.plexActionLoadSections')}
</button>
</div>
<div class="setting-row plex-libraries-block">
<span class="setting-label">{$t('settings.integrations.plexMusicLibraries')}</span>
<div class="plex-libraries-grid">
{#each plexSections as plexSection}
<label class="plex-library-item">
<input
type="checkbox"
checked={plexSelectedSectionKeys.includes(plexSection.key)}
disabled={plexBusy || !canUsePlexRequests()}
onchange={(event) => handlePlexLibraryToggle(plexSection.key, (event.currentTarget as HTMLInputElement).checked)}
/>
<span class="plex-library-name">{plexSection.title}</span>
{#if plexSectionTrackCounts[plexSection.key] !== undefined}
<span class="plex-library-count">({plexSectionTrackCounts[plexSection.key]} {$t('settings.integrations.plexTracksShort')})</span>
{/if}
</label>
{/each}
</div>
{#if plexLastError}
<small class="setting-note plex-error-note">{plexLastError}</small>
{/if}
</div>
<div class="setting-row plex-two-column-row">
<span class="setting-label">{$t('settings.integrations.plexDisconnectRowLabel')}</span>
<button
class="connect-btn plex-action-btn"
onclick={handlePlexDisconnect}
disabled={plexBusy || plexAuthBusy || !plexToken.trim()}
>
{$t('settings.integrations.plexActionDisconnect')}
</button>
</div>
<div class="setting-row last plex-two-column-row">
<div class="setting-info">
<span class="setting-label">{$t('settings.integrations.plexClearCacheRowLabel')}</span>
<small class="setting-note">{$t('settings.integrations.plexClearCacheRowHelp')}</small>
</div>
<button
class="connect-btn plex-action-btn"
onclick={handlePlexClearCache}
disabled={plexBusy || plexAuthBusy}
>
{$t('settings.integrations.plexClearButton')}
</button>
</div>
{/if}
</section>
<!-- Updates Section -->
@@ -4623,6 +5367,124 @@ flatpak override --user --filesystem=/home/USUARIO/Música com.blitzfc.qbz</pre>
word-break: break-all;
}
.setting-row-controls {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
align-self: center;
}
.section-collapse-btn {
width: 30px;
height: 30px;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0;
line-height: 1;
}
.plex-server-url-input {
width: 210px;
max-width: 210px;
}
.plex-two-column-row {
align-items: center;
}
.plex-divider {
width: 100%;
height: 1px;
background: var(--border-subtle);
margin: 2px 0;
}
.plex-code-row {
display: flex;
align-items: center;
gap: 8px;
justify-content: flex-end;
}
.plex-code-input {
flex: 1;
font-family: 'JetBrains Mono', 'Fira Code', monospace;
letter-spacing: 0.06em;
}
.plex-action-btn {
min-width: 170px;
background: none;
border: 1px solid var(--border-subtle);
color: var(--text-secondary);
}
.plex-action-btn:hover:not(:disabled) {
color: var(--text-primary);
border-color: var(--border-default);
background: var(--bg-hover);
}
.plex-libraries-block {
display: flex;
flex-direction: column;
gap: 8px;
align-items: start;
width: 100%;
height: auto;
min-height: unset;
}
.setting-row.plex-libraries-block {
height: auto;
min-height: 48px;
align-items: stretch;
padding-bottom: 14px;
}
.plex-libraries-grid {
border: 1px solid var(--border-subtle);
border-radius: 10px;
background: var(--bg-tertiary);
padding: 10px;
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px 16px;
width: 100%;
margin: 0 auto;
max-height: 130px;
overflow-y: auto;
}
.plex-library-item {
display: inline-flex;
align-items: center;
gap: 8px;
min-height: 28px;
color: var(--text-primary);
}
.plex-library-name {
font-size: 13px;
font-weight: 500;
}
.plex-library-count {
font-size: 11px;
color: var(--text-muted);
}
.plex-connected-note {
color: #86efac;
}
.plex-error-note {
color: #fca5a5;
word-break: break-word;
}
</style>
<MigrationModal
+69 -4
View File
@@ -164,7 +164,9 @@
"editAlbumInfo": "Albuminformationen bearbeiten",
"editAlbumMetadataTitle": "Album-Metadaten für die LocalLibrary-Indizierung und Suche bearbeiten",
"hideAlbum": "Dieses Album in der Bibliothek ausblenden",
"hideAlbumHint": "Verborgene Alben können unter Einstellungen angezeigt werden"
"hideAlbumHint": "Verborgene Alben können unter Einstellungen angezeigt werden",
"localTrackIndicator": "Lokaler Titel - nur auf diesem Gerät verfügbar",
"plexTrackIndicator": "Plex-Titel - aus deiner lokalen Plex-Bibliothek bereitgestellt"
},
"album": {
"tracks": "Titel",
@@ -611,6 +613,8 @@
"split-suggestions": "Geteilt: Vorschläge",
"split-queue": "Geteilt: Warteschlange"
},
"hardwareAcceleration": "Hardwarebeschleunigung (GPU)",
"hardwareAccelerationDesc": "GPU-beschleunigtes Rendering aktivieren. Standardmäßig deaktiviert für maximale Kompatibilität. Falls QBZ nach dem Aktivieren beim Start abstürzt: QBZ_HARDWARE_ACCEL=0 qbz",
"tray": {
"title": "Systemtray",
"enableTray": "Tray-Symbol aktivieren",
@@ -627,7 +631,7 @@
"fetchArtistImagesDesc": "Künstlerfotos von Qobuz™ in der lokalen Bibliothek anzeigen"
},
"contentFiltering": {
"title": "Inhaltsfilterung",
"title": "Blacklist",
"artistBlacklist": "Künstler-Blacklist",
"artistsBlocked": "{count} Künstler blockiert",
"manage": "Verwalten",
@@ -657,7 +661,38 @@
"musicbrainzDesc": "Beziehungsdaten zwischen Künstlern und erweiterte Metadaten von MusicBrainz aktivieren.",
"musicbrainzCache": "MusicBrainz-Cache",
"listenbrainz": "ListenBrainz",
"remoteControl": "Fernsteuerung",
"plexActionEasyConnect": "Mit Plex verbinden",
"plexActionGenerateCode": "Code erzeugen",
"plexActionLoadSections": "Bereiche synchronisieren",
"plexActionOpenAuth": "Plex-Anmeldung öffnen",
"plexActionDisconnect": "Plex trennen",
"plexActionClearCache": "Lokalen Plex-Cache leeren",
"plexActionCopyCode": "Code kopieren",
"plexEasyConnectTitle": "Einfache Verbindung",
"plexEasyConnectDesc": "Öffne plex.tv/link im Browser, gib den hier angezeigten Code ein, autorisiere QBZ und kehre zurück.",
"plexCodeLabel": "Mit diesem Code autorisieren",
"plexAuthCode": "Plex-Code: {code}",
"plexCodeCopied": "Plex-Code kopiert.",
"plexCodeCopyFailed": "Plex-Code konnte nicht kopiert werden.",
"plexStatusIdle": "Bereit für Plex-LAN-Prüfungen.",
"plexStatusDisabled": "Die Plex-Verbindung ist deaktiviert.",
"plexStatusAuthPending": "Plex-Anmeldung gestartet. Schließe die Autorisierung mit dem Code {code} ab.",
"plexStatusAuthConnected": "Plex-Token erfolgreich empfangen.",
"plexStatusAuthExpired": "Der Plex-Anmeldecode ist abgelaufen. Starte die Verbindung erneut.",
"plexStatusDisconnected": "Plex-Verbindung und zwischengespeicherte Metadaten wurden entfernt.",
"plexStatusCacheCleared": "Lokaler Plex-Cache wurde geleert.",
"plexDisconnectConfirmTitle": "Plex trennen?",
"plexDisconnectConfirmDesc": "Dadurch wird das Plex-Token entfernt und der Plex-Metadaten-Cache geleert.",
"plexDisconnectConfirmOk": "Trennen",
"plexMetadataWrite": "Plex-Metadaten schreiben (experimentell)",
"plexMetadataWriteDesc": "Standardmäßig deaktiviert. Lass es aus, um nur lokale Metadatenbearbeitung in QBZ zu nutzen und keine Änderungen nach Plex zurückzuschreiben.",
"plexWriteDisabledNotice": "Plex-Metadaten-Schreiben ist deaktiviert. Aktiviere es in den Einstellungen, wenn du Plex-Album-Metadaten bearbeiten möchtest.",
"plexClearCacheConfirmTitle": "Lokalen Plex-Cache leeren?",
"plexClearCacheConfirmDesc": "Dadurch werden lokal in QBZ gespeicherte Plex-Bereiche/Titel entfernt. Dein Plex-Token bleibt verbunden.",
"plexClearCacheConfirmOk": "Cache leeren",
"plexLibraryIntegrationNote": "Plex-Musik wird in die Local Library integriert (gleicher Queue-/Player-Flow, keine separate Registerkarte).",
"plexLocalOnlyDirectPlayNote": "Nur Plex-Bibliotheken mit lokalen Dateien werden unterstützt, um Direct Play zu garantieren.",
"remoteControl": "Remote",
"remoteControlDesc": "Steuere QBZ von einem anderen Gerät im selben Netzwerk.",
"remoteControlEnable": "Fernsteuerung aktivieren",
"remoteControlPort": "Port",
@@ -679,7 +714,37 @@
"remoteControlRegenerateConfirm": "Neu erzeugen",
"remoteControlQrHelp": "Diesen QR-Code mit QBZ-Control scannen, um zu koppeln.",
"remoteControlUrlLabel": "Lokale URL",
"remoteControlQrAlt": "QR-Code zur Kopplung"
"remoteControlQrAlt": "QR-Code zur Kopplung",
"plexConnection": "Plex-Verbindung",
"plexConnectionLore": "Greife direkt aus der QBZ Local Library auf deine Plex-Bibliothek zu. Diese Integration unterstützt nur lokale und LAN-Plex-Server, um Direct Play zu garantieren.",
"plexCollapseHint": "Dies blendet nur den Block aus. Deine Einstellungen bleiben unverändert.",
"plexServerUrl": "Plex-Server-URL",
"plexServerUrlPlaceholder": "http://lokale-ip-adresse:32400",
"plexServerUrlHelp": "Muss eine lokale oder lokale LAN-URL sein.",
"plexServerPort": "Port",
"plexLocalAddressRequired": "Verwende eine lokale oder LAN-Plex-Adresse. Remote-Endpunkte werden in diesem Modus nicht unterstützt.",
"plexAuthorizeSectionTitle": "QBZ autorisieren",
"plexAuthorizeSectionLore": "Autorisiere QBZ, um sich mit deinem Plex-Server zu verbinden.",
"plexAuthorizeHelp": "Falls es sich nicht automatisch öffnet, besuche https://plex.tv/link.",
"plexAuthorizeLabel": "QBZ autorisieren",
"plexAuthorizeGenerateHelp": "Dadurch wird ein 4-stelliger Code erzeugt.",
"plexCodeEmpty": "Noch kein Code erzeugt",
"plexCodeTooltip": "Code kopieren",
"plexLinkWithPlex": "Mit Plex verknüpfen",
"plexLinkWithPlexHelp": "Notiere dir diesen Einmalcode.",
"plexManualTokenToggle": "Oder gib dein Plex X-Token direkt ein",
"plexManualTokenHelp": "Optional. Nicht erforderlich, wenn der Code-Flow oben funktioniert hat.",
"plexAuthorizeUsingCode": "QBZ mit dem vorherigen Code autorisieren",
"plexConnectionCheckLabel": "Plex-Verbindung prüfen",
"plexGetLibrariesLabel": "Plex-Musikbibliotheken laden",
"plexGetLibrariesHelp": "Neu synchronisieren, wenn auf dem Plex-Server neue Bibliotheken hinzugefügt wurden.",
"plexMusicLibraries": "Musikbibliotheken",
"plexTracksShort": "Titel",
"plexDisconnectRowLabel": "Plex trennen",
"plexClearCacheRowLabel": "Plex-Cache in QBZ leeren",
"plexClearCacheRowHelp": "Empfohlen bei Inkonsistenzen in der Bibliothek.",
"plexClearButton": "Leeren",
"plexStatusCacheLoaded": "{count} zwischengespeicherte Titel geladen. Aktualisierung von Plex im Hintergrund."
},
"storage": {
"title": "Speicher",
+90 -4
View File
@@ -167,7 +167,9 @@
"editAlbumInfo": "Edit album info",
"editAlbumMetadataTitle": "Edit album metadata for LocalLibrary indexing and search",
"hideAlbum": "Hide this album from library",
"hideAlbumHint": "Hidden albums can be viewed from Settings"
"hideAlbumHint": "Hidden albums can be viewed from Settings",
"localTrackIndicator": "Local track - only available on this device",
"plexTrackIndicator": "Plex track - served from your local Plex library"
},
"album": {
"tracks": "tracks",
@@ -629,6 +631,8 @@
"split-suggestions": "Split: Suggestions",
"split-queue": "Split: Queue"
},
"hardwareAcceleration": "Hardware acceleration (GPU)",
"hardwareAccelerationDesc": "Enable GPU-accelerated rendering. Disabled by default for maximum compatibility. If QBZ crashes on startup after enabling, run: QBZ_HARDWARE_ACCEL=0 qbz",
"tray": {
"title": "System Tray",
"enableTray": "Enable tray icon",
@@ -645,7 +649,7 @@
"fetchArtistImagesDesc": "Show artist photos from Qobuz™ in local library"
},
"contentFiltering": {
"title": "Content Filtering",
"title": "Blacklist",
"artistBlacklist": "Artist Blacklist",
"artistsBlocked": "{count} artists blocked",
"manage": "Manage",
@@ -675,7 +679,7 @@
"musicbrainzDesc": "Enable artist relationships and enhanced metadata from MusicBrainz.",
"musicbrainzCache": "MusicBrainz Cache",
"listenbrainz": "ListenBrainz",
"remoteControl": "Remote Control",
"remoteControl": "Remote",
"remoteControlExperimental": "Experimental",
"remoteControlDesc": "Control QBZ from another device on the same network.",
"remoteControlSetupGuide": "Setup Guide",
@@ -699,7 +703,89 @@
"remoteControlRegenerateConfirm": "Regenerate",
"remoteControlQrHelp": "Scan this QR code with QBZ-Control to pair.",
"remoteControlUrlLabel": "Local URL",
"remoteControlQrAlt": "Pairing QR code"
"remoteControlQrAlt": "Pairing QR code",
"plexConnection": "Plex Connection",
"plexConnectionLore": "Access your Plex library directly from QBZ Local Library. This integration supports only local and LAN Plex servers to guarantee direct play.",
"plexCollapseHint": "This only hides the block. Your settings stay unchanged.",
"plexServerUrl": "Plex Server URL",
"plexServerUrlPlaceholder": "http://local-ip-address:32400",
"plexServerUrlHelp": "Must be a local or local-LAN URL.",
"plexServerPort": "Port",
"plexLocalAddressRequired": "Use a local or LAN Plex address. Remote Plex endpoints are not supported for this mode.",
"plexAuthorizeSectionTitle": "Authorize QBZ",
"plexAuthorizeSectionLore": "Authorize QBZ to connect with your Plex server.",
"plexAuthorizeHelp": "If it does not open automatically, visit https://plex.tv/link.",
"plexAuthorizeLabel": "Authorize QBZ",
"plexAuthorizeGenerateHelp": "This will generate a 4-character code.",
"plexCodeEmpty": "No code generated yet",
"plexCodeTooltip": "Copy code",
"plexLinkWithPlex": "Link with Plex",
"plexLinkWithPlexHelp": "Take note of this one-time code.",
"plexManualTokenToggle": "Or enter your Plex X-Token directly",
"plexManualTokenHelp": "Optional. Not required if the code flow above worked.",
"plexAuthorizeUsingCode": "Authorize QBZ using previous code",
"plexConnectionCheckLabel": "Check Plex connection",
"plexGetLibrariesLabel": "Get Plex music libraries",
"plexGetLibrariesHelp": "Re-sync if new libraries were added on your Plex server.",
"plexMusicLibraries": "Music libraries",
"plexTracksShort": "tracks",
"plexDisconnectRowLabel": "Disconnect Plex",
"plexClearCacheRowLabel": "Clear Plex cache in QBZ",
"plexClearCacheRowHelp": "Recommended if you notice library inconsistencies.",
"plexClearButton": "Clear",
"plexPoc": "Plex LAN POC",
"plexPocDesc": "Experimental local-only integration. Uses direct media parts from Plex without replacing QBZ playback.",
"plexLibraryIntegrationNote": "Plex music is integrated into Local Library (same queue/player flow, no separate tab).",
"plexLocalOnlyDirectPlayNote": "Only Plex libraries pointing to local files are supported to guarantee direct play.",
"plexMetadataWrite": "Plex metadata write (experimental)",
"plexMetadataWriteDesc": "Disabled by default. Keep off to use local-only metadata edits in QBZ and avoid writing changes back to Plex.",
"plexWriteDisabledNotice": "Plex metadata write is disabled. Enable it in Settings if you want to edit Plex album metadata.",
"plexBaseUrl": "Plex Base URL",
"plexBaseUrlPlaceholder": "http://127.0.0.1:32400",
"plexToken": "Plex Token",
"plexTokenPlaceholder": "Paste your X-Plex-Token",
"plexActionPing": "Ping Plex",
"plexActionEasyConnect": "Connect with Plex",
"plexActionGenerateCode": "Generate Code",
"plexActionOpenAuth": "Open Plex Sign-In",
"plexActionDisconnect": "Disconnect Plex",
"plexActionClearCache": "Clear Local Plex Cache",
"plexActionCopyCode": "Copy Code",
"plexActionLoadSections": "Sync Sections",
"plexActionLoadTracks": "Load Tracks",
"plexActionPlay": "Play",
"plexActionPlaying": "Playing...",
"plexEasyConnectTitle": "Easy Connection",
"plexEasyConnectDesc": "Open plex.tv/link in your browser, enter the code shown here, approve QBZ, then return.",
"plexCodeLabel": "Authorize with this code",
"plexAuthCode": "Plex code: {code}",
"plexCodeCopied": "Plex code copied.",
"plexCodeCopyFailed": "Could not copy Plex code.",
"plexMusicSection": "Music Section",
"plexSelectSection": "Select a section",
"plexStatusIdle": "Ready for Plex LAN checks.",
"plexStatusDisabled": "Plex connection is disabled.",
"plexStatusAuthPending": "Plex sign-in started. Complete authorization with code {code}.",
"plexStatusAuthConnected": "Plex token received successfully.",
"plexStatusAuthExpired": "Plex sign-in code expired. Start the connection again.",
"plexStatusDisconnected": "Plex connection and cached metadata were cleared.",
"plexStatusCacheCleared": "Local Plex cache was cleared.",
"plexStatusConnected": "Connected to {server} (v{version}).",
"plexStatusSectionsLoaded": "Loaded {count} music sections.",
"plexStatusTracksLoaded": "Loaded {count} tracks.",
"plexStatusCacheLoaded": "Loaded {count} cached tracks. Refreshing from Plex in background.",
"plexStatusPlaying": "Playing: {title}",
"plexStatusPlayingDirect": "Playing direct from Plex: {title} ({rate} Hz / {bits}-bit, {contentType}).",
"plexStatusPlayingFallback": "Playing {title}, but direct play is not confirmed (part: {partKey}).",
"plexStatusError": "Plex error: {error}",
"plexDisconnectConfirmTitle": "Disconnect Plex?",
"plexDisconnectConfirmDesc": "This removes the Plex token and clears cached Plex metadata.",
"plexDisconnectConfirmOk": "Disconnect",
"plexClearCacheConfirmTitle": "Clear local Plex cache?",
"plexClearCacheConfirmDesc": "This removes locally cached Plex sections/tracks in QBZ. Your Plex token remains connected.",
"plexClearCacheConfirmOk": "Clear cache",
"plexNoTracksLoaded": "No tracks loaded yet.",
"plexTrackMeta": "{artist} - {album} - {rate} Hz / {bits}-bit"
},
"storage": {
"title": "Storage",
+90 -4
View File
@@ -167,7 +167,9 @@
"editAlbumInfo": "Editar información del álbum",
"editAlbumMetadataTitle": "Editar metadatos del álbum para indexación y búsqueda en LocalLibrary",
"hideAlbum": "Ocultar este álbum de la biblioteca",
"hideAlbumHint": "Los álbumes ocultos se pueden ver desde Configuración"
"hideAlbumHint": "Los álbumes ocultos se pueden ver desde Configuración",
"localTrackIndicator": "Pista local - solo disponible en este dispositivo",
"plexTrackIndicator": "Pista de Plex - servida desde tu biblioteca local de Plex"
},
"album": {
"tracks": "pistas",
@@ -629,6 +631,8 @@
"split-suggestions": "Dividido: Sugerencias",
"split-queue": "Dividido: Cola"
},
"hardwareAcceleration": "Aceleración por hardware (GPU)",
"hardwareAccelerationDesc": "Habilitar renderizado acelerado por GPU. Desactivado por defecto para máxima compatibilidad. Si QBZ se cierra al iniciar después de activar esto, ejecuta: QBZ_HARDWARE_ACCEL=0 qbz",
"tray": {
"title": "Bandeja del Sistema",
"enableTray": "Habilitar icono de bandeja",
@@ -645,7 +649,7 @@
"fetchArtistImagesDesc": "Mostrar fotos de artistas de Qobuz™ en la biblioteca local"
},
"contentFiltering": {
"title": "Filtrado de Contenido",
"title": "Lista negra",
"artistBlacklist": "Lista negra de artistas",
"artistsBlocked": "{count} artistas bloqueados",
"manage": "Administrar",
@@ -675,7 +679,7 @@
"musicbrainzDesc": "Habilita relaciones de artistas y metadatos mejorados de MusicBrainz.",
"musicbrainzCache": "Caché de MusicBrainz",
"listenbrainz": "ListenBrainz",
"remoteControl": "Control Remoto",
"remoteControl": "Remoto",
"remoteControlExperimental": "Experimental",
"remoteControlDesc": "Controla QBZ desde otro dispositivo en la misma red.",
"remoteControlSetupGuide": "Guía de configuración",
@@ -699,7 +703,89 @@
"remoteControlRegenerateConfirm": "Regenerar",
"remoteControlQrHelp": "Escanea este código QR con QBZ-Control para emparejar.",
"remoteControlUrlLabel": "URL local",
"remoteControlQrAlt": "Código QR de emparejamiento"
"remoteControlQrAlt": "Código QR de emparejamiento",
"plexConnection": "Conexión Plex",
"plexConnectionLore": "Accede a tu biblioteca de Plex desde QBZ Local Library. Esta integración solo soporta servidores Plex locales o en LAN para garantizar direct play.",
"plexCollapseHint": "Esto solo oculta el bloque. Tu configuración no cambia.",
"plexServerUrl": "URL del servidor Plex",
"plexServerUrlPlaceholder": "http://ip-local:32400",
"plexServerUrlHelp": "Debe ser una URL local o local-LAN.",
"plexServerPort": "Puerto",
"plexLocalAddressRequired": "Usa una dirección Plex local o LAN. Los endpoints remotos no están soportados en este modo.",
"plexAuthorizeSectionTitle": "Autorizar QBZ",
"plexAuthorizeSectionLore": "Autoriza QBZ para conectarse con tu servidor Plex.",
"plexAuthorizeHelp": "Si no se abre automáticamente, visita https://plex.tv/link.",
"plexAuthorizeLabel": "Autorizar QBZ",
"plexAuthorizeGenerateHelp": "Esto generará un código de 4 caracteres.",
"plexCodeEmpty": "Aún no se generó código",
"plexCodeTooltip": "Copiar código",
"plexLinkWithPlex": "Vincular con Plex",
"plexLinkWithPlexHelp": "Toma nota de este código de un solo uso.",
"plexManualTokenToggle": "O ingresa tu Plex X-Token manualmente",
"plexManualTokenHelp": "Opcional. No es necesario si el flujo de código funcionó.",
"plexAuthorizeUsingCode": "Autoriza QBZ con el código anterior",
"plexConnectionCheckLabel": "Comprobar conexión con Plex",
"plexGetLibrariesLabel": "Obtener bibliotecas de música de Plex",
"plexGetLibrariesHelp": "Re-sincroniza si agregaste bibliotecas nuevas en tu servidor Plex.",
"plexMusicLibraries": "Bibliotecas de música",
"plexTracksShort": "pistas",
"plexDisconnectRowLabel": "Desconectar Plex",
"plexClearCacheRowLabel": "Limpiar caché de Plex en QBZ",
"plexClearCacheRowHelp": "Recomendado si notas inconsistencias en la biblioteca.",
"plexClearButton": "Limpiar",
"plexPoc": "Plex LAN POC",
"plexPocDesc": "Integración experimental solo local. Usa partes directas de medios desde Plex sin reemplazar la reproducción de QBZ.",
"plexLibraryIntegrationNote": "La música de Plex se integra en Local Library (misma cola/reproductor, sin pestaña separada).",
"plexLocalOnlyDirectPlayNote": "Solo se soportan bibliotecas de Plex que apunten a archivos locales para garantizar direct play.",
"plexMetadataWrite": "Escritura de metadatos en Plex (experimental)",
"plexMetadataWriteDesc": "Desactivado por defecto. Déjalo apagado para usar edición local de metadatos en QBZ y evitar escribir cambios en Plex.",
"plexWriteDisabledNotice": "La escritura de metadatos en Plex está desactivada. Actívala en Settings si quieres editar metadatos de álbumes Plex.",
"plexBaseUrl": "URL base de Plex",
"plexBaseUrlPlaceholder": "http://127.0.0.1:32400",
"plexToken": "Token de Plex",
"plexTokenPlaceholder": "Pega tu X-Plex-Token",
"plexActionPing": "Probar Plex",
"plexActionEasyConnect": "Conectar con Plex",
"plexActionGenerateCode": "Generar código",
"plexActionOpenAuth": "Abrir inicio de sesión de Plex",
"plexActionDisconnect": "Desconectar Plex",
"plexActionClearCache": "Limpiar caché local de Plex",
"plexActionCopyCode": "Copiar código",
"plexActionLoadSections": "Sincronizar secciones",
"plexActionLoadTracks": "Cargar pistas",
"plexActionPlay": "Reproducir",
"plexActionPlaying": "Reproduciendo...",
"plexEasyConnectTitle": "Conexión rápida",
"plexEasyConnectDesc": "Abre plex.tv/link en tu navegador, introduce el código mostrado aquí, autoriza QBZ y vuelve.",
"plexCodeLabel": "Autoriza con este código",
"plexAuthCode": "Código de Plex: {code}",
"plexCodeCopied": "Código de Plex copiado.",
"plexCodeCopyFailed": "No se pudo copiar el código de Plex.",
"plexMusicSection": "Sección de música",
"plexSelectSection": "Selecciona una sección",
"plexStatusIdle": "Listo para pruebas LAN con Plex.",
"plexStatusDisabled": "La conexión con Plex está desactivada.",
"plexStatusAuthPending": "Inicio de sesión de Plex iniciado. Completa la autorización con el código {code}.",
"plexStatusAuthConnected": "Token de Plex recibido correctamente.",
"plexStatusAuthExpired": "El código de inicio de Plex expiró. Inicia la conexión nuevamente.",
"plexStatusDisconnected": "Se limpiaron la conexión y la caché de metadatos de Plex.",
"plexStatusCacheCleared": "Se limpió la caché local de Plex.",
"plexStatusConnected": "Conectado a {server} (v{version}).",
"plexStatusSectionsLoaded": "Se cargaron {count} secciones de música.",
"plexStatusTracksLoaded": "Se cargaron {count} pistas.",
"plexStatusCacheLoaded": "Se cargaron {count} pistas en caché. Actualizando desde Plex en segundo plano.",
"plexStatusPlaying": "Reproduciendo: {title}",
"plexStatusPlayingDirect": "Reproducción directa desde Plex: {title} ({rate} Hz / {bits}-bit, {contentType}).",
"plexStatusPlayingFallback": "Reproduciendo {title}, pero no se confirmó reproducción directa (part: {partKey}).",
"plexStatusError": "Error de Plex: {error}",
"plexDisconnectConfirmTitle": "¿Desconectar Plex?",
"plexDisconnectConfirmDesc": "Esto elimina el token de Plex y limpia la caché de metadatos de Plex.",
"plexDisconnectConfirmOk": "Desconectar",
"plexClearCacheConfirmTitle": "¿Limpiar caché local de Plex?",
"plexClearCacheConfirmDesc": "Esto elimina las secciones/pistas de Plex almacenadas localmente en QBZ. Tu token de Plex seguirá conectado.",
"plexClearCacheConfirmOk": "Limpiar caché",
"plexNoTracksLoaded": "Aún no se cargaron pistas.",
"plexTrackMeta": "{artist} - {album} - {rate} Hz / {bits}-bit"
},
"storage": {
"title": "Almacenamiento",
+69 -4
View File
@@ -164,7 +164,9 @@
"editAlbumInfo": "Modifier les informations de l'album",
"editAlbumMetadataTitle": "Modifier les métadonnées de l'album pour l'indexation et la recherche dans la bibliothèque locale",
"hideAlbum": "Masquer cet album de la bibliothèque",
"hideAlbumHint": "Les albums cachés peuvent être consultés dans Paramètres"
"hideAlbumHint": "Les albums cachés peuvent être consultés dans Paramètres",
"localTrackIndicator": "Piste locale - disponible uniquement sur cet appareil",
"plexTrackIndicator": "Piste Plex - diffusée depuis votre bibliothèque Plex locale"
},
"album": {
"tracks": "pistes",
@@ -611,6 +613,8 @@
"split-suggestions": "Divisé : Suggestions",
"split-queue": "Divisé : File d'attente"
},
"hardwareAcceleration": "Accélération matérielle (GPU)",
"hardwareAccelerationDesc": "Activer le rendu accéléré par GPU. Désactivé par défaut pour une compatibilité maximale. Si QBZ plante au démarrage après activation : QBZ_HARDWARE_ACCEL=0 qbz",
"tray": {
"title": "Barre d'état système",
"enableTray": "Activer l'icône dans la barre système",
@@ -627,7 +631,7 @@
"fetchArtistImagesDesc": "Afficher les photos d'artistes de Qobuz™ dans la bibliothèque locale"
},
"contentFiltering": {
"title": "Filtrage du contenu",
"title": "Liste noire",
"artistBlacklist": "Liste noire des artistes",
"artistsBlocked": "{count} artistes bloqués",
"manage": "Gérer",
@@ -657,7 +661,38 @@
"musicbrainzDesc": "Activer les relations d'artistes et les métadonnées améliorées depuis MusicBrainz.",
"musicbrainzCache": "Cache MusicBrainz",
"listenbrainz": "ListenBrainz",
"remoteControl": "Contrôle à distance",
"plexActionEasyConnect": "Se connecter avec Plex",
"plexActionGenerateCode": "Générer le code",
"plexActionLoadSections": "Synchroniser les sections",
"plexActionOpenAuth": "Ouvrir la connexion Plex",
"plexActionDisconnect": "Déconnecter Plex",
"plexActionClearCache": "Effacer le cache Plex local",
"plexActionCopyCode": "Copier le code",
"plexEasyConnectTitle": "Connexion simplifiée",
"plexEasyConnectDesc": "Ouvrez plex.tv/link dans votre navigateur, saisissez le code affiché ici, autorisez QBZ puis revenez.",
"plexCodeLabel": "Autorisez avec ce code",
"plexAuthCode": "Code Plex : {code}",
"plexCodeCopied": "Code Plex copié.",
"plexCodeCopyFailed": "Impossible de copier le code Plex.",
"plexStatusIdle": "Prêt pour les vérifications Plex LAN.",
"plexStatusDisabled": "La connexion Plex est désactivée.",
"plexStatusAuthPending": "Connexion Plex démarrée. Terminez l'autorisation avec le code {code}.",
"plexStatusAuthConnected": "Jeton Plex reçu avec succès.",
"plexStatusAuthExpired": "Le code de connexion Plex a expiré. Relancez la connexion.",
"plexStatusDisconnected": "La connexion Plex et le cache de métadonnées ont été effacés.",
"plexStatusCacheCleared": "Le cache Plex local a été effacé.",
"plexDisconnectConfirmTitle": "Déconnecter Plex ?",
"plexDisconnectConfirmDesc": "Cela supprime le jeton Plex et efface le cache de métadonnées Plex.",
"plexDisconnectConfirmOk": "Déconnecter",
"plexMetadataWrite": "Écriture des métadonnées Plex (expérimental)",
"plexMetadataWriteDesc": "Désactivé par défaut. Laissez cette option désactivée pour utiliser uniquement l'édition locale des métadonnées dans QBZ et éviter de renvoyer des changements vers Plex.",
"plexWriteDisabledNotice": "L'écriture des métadonnées Plex est désactivée. Activez-la dans les paramètres si vous voulez modifier les métadonnées des albums Plex.",
"plexClearCacheConfirmTitle": "Effacer le cache Plex local ?",
"plexClearCacheConfirmDesc": "Cela supprime les sections/pistes Plex mises en cache localement dans QBZ. Votre jeton Plex reste connecté.",
"plexClearCacheConfirmOk": "Effacer le cache",
"plexLibraryIntegrationNote": "La musique Plex est intégrée dans la Local Library (même flux file d'attente/lecteur, sans onglet séparé).",
"plexLocalOnlyDirectPlayNote": "Seules les bibliothèques Plex pointant vers des fichiers locaux sont prises en charge pour garantir le direct play.",
"remoteControl": "Remote",
"remoteControlDesc": "Contrôlez QBZ depuis un autre appareil sur le même réseau.",
"remoteControlEnable": "Activer le contrôle à distance",
"remoteControlPort": "Port",
@@ -679,7 +714,37 @@
"remoteControlRegenerateConfirm": "Régénérer",
"remoteControlQrHelp": "Scannez ce code QR avec QBZ-Control pour appairer.",
"remoteControlUrlLabel": "URL locale",
"remoteControlQrAlt": "Code QR d'appairage"
"remoteControlQrAlt": "Code QR d'appairage",
"plexConnection": "Connexion Plex",
"plexConnectionLore": "Accédez à votre bibliothèque Plex depuis la Local Library de QBZ. Cette intégration prend uniquement en charge les serveurs Plex locaux et LAN pour garantir le direct play.",
"plexCollapseHint": "Cela masque seulement le bloc. Vos réglages restent inchangés.",
"plexServerUrl": "URL du serveur Plex",
"plexServerUrlPlaceholder": "http://adresse-ip-locale:32400",
"plexServerUrlHelp": "Doit être une URL locale ou locale-LAN.",
"plexServerPort": "Port",
"plexLocalAddressRequired": "Utilisez une adresse Plex locale ou LAN. Les endpoints distants ne sont pas pris en charge dans ce mode.",
"plexAuthorizeSectionTitle": "Autoriser QBZ",
"plexAuthorizeSectionLore": "Autorisez QBZ à se connecter à votre serveur Plex.",
"plexAuthorizeHelp": "Si cela ne s'ouvre pas automatiquement, visitez https://plex.tv/link.",
"plexAuthorizeLabel": "Autoriser QBZ",
"plexAuthorizeGenerateHelp": "Cela générera un code de 4 caractères.",
"plexCodeEmpty": "Aucun code généré pour le moment",
"plexCodeTooltip": "Copier le code",
"plexLinkWithPlex": "Associer avec Plex",
"plexLinkWithPlexHelp": "Notez ce code à usage unique.",
"plexManualTokenToggle": "Ou saisissez directement votre Plex X-Token",
"plexManualTokenHelp": "Optionnel. Pas nécessaire si le flux de code ci-dessus a fonctionné.",
"plexAuthorizeUsingCode": "Autoriser QBZ avec le code précédent",
"plexConnectionCheckLabel": "Vérifier la connexion Plex",
"plexGetLibrariesLabel": "Récupérer les bibliothèques musicales Plex",
"plexGetLibrariesHelp": "Re-synchronisez si de nouvelles bibliothèques ont été ajoutées sur votre serveur Plex.",
"plexMusicLibraries": "Bibliothèques musicales",
"plexTracksShort": "pistes",
"plexDisconnectRowLabel": "Déconnecter Plex",
"plexClearCacheRowLabel": "Effacer le cache Plex dans QBZ",
"plexClearCacheRowHelp": "Recommandé si vous remarquez des incohérences dans la bibliothèque.",
"plexClearButton": "Effacer",
"plexStatusCacheLoaded": "{count} pistes en cache chargées. Actualisation depuis Plex en arrière-plan."
},
"storage": {
"title": "Stockage",
+50 -6
View File
@@ -55,6 +55,12 @@ interface PlayTrackResult {
format_id: number | null;
}
/** Result from plex_play_track command */
interface PlexPlayTrackResult {
sampling_rate_hz?: number | null;
bit_depth?: number | null;
}
/**
* Convert Qobuz format_id to format string for QualityBadge
* 5=MP3, 6=FLAC 16-bit (CD), 7=FLAC 24-bit (Hi-Res), 27=FLAC 24-bit Hi-Res+
@@ -72,6 +78,7 @@ function formatIdToString(formatId: number | null): string | undefined {
export interface PlayTrackOptions {
isLocal?: boolean;
source?: 'qobuz' | 'local' | 'plex';
showLoadingToast?: boolean;
showSuccessToast?: boolean;
/** When true, skip stop_playback and play_track — backend already has audio playing via gapless */
@@ -110,6 +117,7 @@ export async function playTrack(
): Promise<boolean> {
const {
isLocal = false,
source = isLocal ? 'local' : 'qobuz',
showLoadingToast = true,
showSuccessToast = true,
gaplessTransition = false
@@ -154,7 +162,27 @@ export async function playTrack(
});
} else {
// Use appropriate local playback command
if (isLocal) {
if (source === 'plex') {
const plexBaseUrl = getUserItem('qbz-plex-poc-base-url') || '';
const plexToken = getUserItem('qbz-plex-poc-token') || '';
if (!plexBaseUrl || !plexToken) {
throw new Error('Missing Plex base URL or token');
}
const result = await invoke<PlexPlayTrackResult>('plex_play_track', {
baseUrl: plexBaseUrl,
token: plexToken,
ratingKey: String(track.id)
});
// Plex metadata endpoint provides stream audio details even when queue list did not.
if (result.sampling_rate_hz && result.sampling_rate_hz > 0) {
track.samplingRate = result.sampling_rate_hz / 1000;
}
if (result.bit_depth && result.bit_depth > 0) {
track.bitDepth = result.bit_depth;
}
setCurrentTrack(track);
} else if (isLocal) {
await invoke('library_play_track', { trackId: track.id });
} else {
const result = await invoke<PlayTrackResult>('play_track', {
@@ -181,7 +209,7 @@ export async function playTrack(
}
// Log play event for recommendations (fire-and-forget)
if (!isLocal) {
if (!isLocal && source !== 'plex') {
void logRecoEvent({
eventType: 'play',
itemType: 'track',
@@ -224,7 +252,7 @@ export async function playTrack(
);
// Check favorite status (only for Qobuz tracks)
if (!isLocal) {
if (!isLocal && source !== 'plex') {
const isFav = await checkTrackFavorite(track.id);
setIsFavorite(isFav);
} else {
@@ -252,18 +280,34 @@ export async function playTrack(
console.log('Auto-skipping to next track:', next.title);
// Small delay to let the toast be visible
setTimeout(() => {
const nextSource = (next.source === 'plex' || next.source === 'local' || next.source === 'qobuz')
? next.source
: (next.is_local ? 'local' : 'qobuz');
const nextSamplingRate = next.sample_rate == null
? undefined
: (next.is_local || nextSource === 'plex')
? next.sample_rate / 1000
: next.sample_rate;
playTrack({
id: next.id,
title: next.title,
artist: next.artist,
album: next.album,
duration: next.duration_secs,
artwork: next.artwork_url || undefined,
artwork: next.artwork_url || '',
quality: next.hires ? 'Hi-Res' : 'CD Quality',
albumId: next.album_id || undefined,
artistId: next.artist_id || undefined,
bitDepth: next.bit_depth || undefined,
samplingRate: next.sample_rate || undefined
}, { showLoadingToast: true, showSuccessToast: true });
samplingRate: nextSamplingRate,
source: nextSource,
isLocal: next.is_local
}, {
isLocal: next.is_local ?? false,
source: nextSource,
showLoadingToast: true,
showSuccessToast: true
});
}, 500);
} else {
setIsPlaying(false);
+17 -8
View File
@@ -46,7 +46,8 @@ export function buildQueueTrackFromQobuz(track: QobuzTrack): BackendQueueTrack {
is_local: false,
album_id: track.album?.id || null,
artist_id: track.performer?.id ?? null,
streamable: track.streamable ?? true
streamable: track.streamable ?? true,
source: 'qobuz'
};
}
@@ -76,7 +77,8 @@ export function buildQueueTrackFromAlbumTrack(
is_local: false,
album_id: track.albumId || albumId || null,
artist_id: track.artistId ?? artistId ?? null,
streamable: track.streamable ?? true
streamable: track.streamable ?? true,
source: 'qobuz'
};
}
@@ -99,14 +101,18 @@ export function buildQueueTrackFromPlaylistTrack(track: PlaylistTrack): BackendQ
is_local: false,
album_id: track.albumId || null,
artist_id: track.artistId ?? null,
streamable: track.streamable ?? true
streamable: track.streamable ?? true,
source: 'qobuz'
};
}
export function buildQueueTrackFromLocalTrack(track: LocalLibraryTrack): BackendQueueTrack {
const artwork = track.artwork_path ? convertFileSrc(track.artwork_path) : null;
const artwork = track.artwork_path
? (/^https?:\/\//i.test(track.artwork_path) ? track.artwork_path : convertFileSrc(track.artwork_path))
: null;
// Local tracks are hi-res if bit_depth > 16 or sample_rate > 44100
const isHires = Boolean((track.bit_depth && track.bit_depth > 16) || (track.sample_rate && track.sample_rate > 44100));
const isPlexTrack = track.source === 'plex';
return {
id: track.id,
title: track.title,
@@ -117,10 +123,11 @@ export function buildQueueTrackFromLocalTrack(track: LocalLibraryTrack): Backend
hires: isHires,
bit_depth: track.bit_depth ?? null,
sample_rate: track.sample_rate ?? null,
is_local: true,
is_local: !isPlexTrack,
album_id: null, // Local tracks don't have Qobuz album IDs
artist_id: null, // Local tracks don't have Qobuz artist IDs
streamable: true // Local tracks are always playable
streamable: true, // Local tracks are always playable
source: isPlexTrack ? 'plex' : 'local'
};
}
@@ -163,11 +170,13 @@ export async function queuePlaylistTrackLater(track: PlaylistTrack): Promise<voi
}
export async function queueLocalTrackNext(track: LocalLibraryTrack): Promise<void> {
await queueTrackNext(buildQueueTrackFromLocalTrack(track), true);
const isPlexTrack = track.source === 'plex';
await queueTrackNext(buildQueueTrackFromLocalTrack(track), !isPlexTrack);
}
export async function queueLocalTrackLater(track: LocalLibraryTrack): Promise<void> {
await queueTrackLater(buildQueueTrackFromLocalTrack(track), true);
const isPlexTrack = track.source === 'plex';
await queueTrackLater(buildQueueTrackFromLocalTrack(track), !isPlexTrack);
}
// ============ Favorites ============
+1 -1
View File
@@ -10,7 +10,7 @@ import { invoke } from '@tauri-apps/api/core';
// ============ Types ============
export type ContextType = 'album' | 'playlist' | 'artist_top' | 'home_list' | 'favorites' | 'local_library' | 'radio';
export type ContentSource = 'qobuz' | 'local';
export type ContentSource = 'qobuz' | 'local' | 'plex';
export interface PlaybackContext {
type: ContextType;
+40 -10
View File
@@ -49,6 +49,7 @@ export interface PlayingTrack {
samplingRate?: number;
format?: string;
isLocal?: boolean;
source?: string;
// Optional IDs for recommendation tracking
albumId?: string;
artistId?: number;
@@ -93,10 +94,16 @@ interface QueueTrack {
bit_depth: number | null;
sample_rate: number | null;
is_local?: boolean;
source?: string;
album_id?: string | null;
artist_id?: number | null;
}
interface PlexPlayTrackResult {
sampling_rate_hz?: number | null;
bit_depth?: number | null;
}
// ============ State ============
/**
@@ -334,11 +341,27 @@ export async function togglePlay(): Promise<void> {
const savedPosition = pendingSessionRestore.position;
pendingSessionRestore = null; // Clear before loading
// Check if this is a local track (negative ID) or Qobuz track
if (currentTrack.id < 0) {
// Local track - use library_play_track with positive ID
// Restore source-specific playback
if (currentTrack.source === 'plex') {
const plexBaseUrl = getUserItem('qbz-plex-poc-base-url') || '';
const plexToken = getUserItem('qbz-plex-poc-token') || '';
if (!plexBaseUrl || !plexToken) {
throw new Error('Missing Plex configuration for session restore');
}
const result = await invoke<PlexPlayTrackResult>('plex_play_track', {
baseUrl: plexBaseUrl,
token: plexToken,
ratingKey: String(currentTrack.id)
});
if (result.sampling_rate_hz && result.sampling_rate_hz > 0) {
currentTrack.samplingRate = result.sampling_rate_hz / 1000;
}
if (result.bit_depth && result.bit_depth > 0) {
currentTrack.bitDepth = result.bit_depth;
}
} else if (currentTrack.isLocal || currentTrack.id < 0) {
// Local filesystem track
const localTrackId = Math.abs(currentTrack.id);
console.log('[Player] Restoring local track:', localTrackId);
await invoke('library_play_track', { trackId: localTrackId });
} else {
// Qobuz track - use play_track with duration for seekbar
@@ -539,6 +562,12 @@ async function handlePlaybackEvent(event: PlaybackEvent): Promise<void> {
try {
const queueTrack = await invoke<QueueTrack | null>('get_current_queue_track');
if (queueTrack && queueTrack.id === event.track_id) {
const rawRate = queueTrack.sample_rate ?? undefined;
const normalizedRate = rawRate == null
? undefined
: (queueTrack.is_local || queueTrack.source === 'plex')
? rawRate / 1000
: rawRate;
currentTrack = {
id: queueTrack.id,
title: queueTrack.title,
@@ -547,11 +576,12 @@ async function handlePlaybackEvent(event: PlaybackEvent): Promise<void> {
artwork: queueTrack.artwork_url || '',
duration: queueTrack.duration_secs,
quality: queueTrack.hires ? 'Hi-Res' : 'CD Quality',
bitDepth: queueTrack.bit_depth,
samplingRate: queueTrack.sample_rate,
bitDepth: queueTrack.bit_depth ?? undefined,
samplingRate: normalizedRate,
isLocal: queueTrack.is_local,
albumId: queueTrack.album_id,
artistId: queueTrack.artist_id
source: queueTrack.source ?? undefined,
albumId: queueTrack.album_id ?? undefined,
artistId: queueTrack.artist_id ?? undefined
};
duration = queueTrack.duration_secs;
// Update playback state from event
@@ -587,8 +617,8 @@ async function handlePlaybackEvent(event: PlaybackEvent): Promise<void> {
persistVolume(volume);
}
// Update track with actual stream quality (issue #34)
// The backend now sends the real sample rate and bit depth from the decoded stream
// Update track with actual stream quality (issue #34).
// For Plex this is needed because cached metadata can be stale/inaccurate.
if (event.sample_rate && event.sample_rate > 0) {
// Convert Hz to kHz for display (44100 -> 44.1)
currentTrack.samplingRate = event.sample_rate / 1000;
+2
View File
@@ -33,6 +33,8 @@ export interface BackendQueueTrack {
artist_id?: number | null;
/** Whether the track is streamable on Qobuz (false = removed/unavailable) */
streamable?: boolean;
/** Track source: qobuz | local | plex */
source?: string;
}
interface BackendQueueState {
+1
View File
@@ -322,6 +322,7 @@ export interface LocalLibraryTrack {
bit_depth?: number;
sample_rate: number;
artwork_path?: string;
source?: string;
}
// ============ External API Types ============
+23 -5
View File
@@ -1477,7 +1477,8 @@
// Helper to play a track from the queue (with offline skip support)
async function playQueueTrack(track: BackendQueueTrack, skippedIds = new Set<number>(), gaplessTransition = false) {
const isLocal = isLocalTrack(track.id);
const source = track.source ?? (track.is_local ? 'local' : 'qobuz');
const isLocal = source !== 'qobuz' || isLocalTrack(track.id);
// In offline mode, check if track is available
if (offlineStatus.isOffline && !isLocal) {
@@ -1528,9 +1529,10 @@
// Only convert Hz to kHz for local tracks. Qobuz tracks are already in kHz.
samplingRate: isLocal && track.sample_rate ? track.sample_rate / 1000 : track.sample_rate ?? undefined,
isLocal,
source,
albumId: track.album_id ?? undefined,
artistId: track.artist_id ?? undefined
}, { isLocal, showLoadingToast: false, gaplessTransition });
}, { isLocal, source: source as 'qobuz' | 'local' | 'plex', showLoadingToast: false, gaplessTransition });
}
// Play a specific track from the queue panel
@@ -2097,7 +2099,22 @@
// DO NOT clear context - LocalLibraryView already sets it correctly
// await clearPlaybackContext();
const artwork = track.artwork_path ? convertFileSrc(track.artwork_path) : '';
const source = track.source === 'plex' ? 'plex' : 'local';
const plexBaseUrl = getUserItem('qbz-plex-poc-base-url') || '';
const plexToken = getUserItem('qbz-plex-poc-token') || '';
const resolveArtwork = (path?: string): string => {
if (!path) return '';
if (/^https?:\/\//i.test(path)) return path;
if (source === 'plex' && path.startsWith('/library/') && plexBaseUrl && plexToken) {
const base = plexBaseUrl.replace(/\/+$/, '');
const sep = path.includes('?') ? '&' : '?';
return `${base}${path}${sep}X-Plex-Token=${encodeURIComponent(plexToken)}`;
}
return convertFileSrc(path);
};
const artwork = track.artwork_path
? resolveArtwork(track.artwork_path)
: '';
const quality = track.bit_depth && track.sample_rate
? (track.bit_depth >= 24 || track.sample_rate > 48000
? `${track.bit_depth}bit/${track.sample_rate / 1000}kHz`
@@ -2115,8 +2132,9 @@
bitDepth: track.bit_depth,
samplingRate: track.sample_rate ? track.sample_rate / 1000 : undefined, // Convert Hz to kHz (44100 → 44.1) - NO ROUNDING
format: track.format,
isLocal: true
}, { isLocal: true });
isLocal: source !== 'plex',
source
}, { isLocal: source !== 'plex', source });
}
// Handle setting queue from local library (tracks need different playback command)
+2 -3
View File
@@ -1,8 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<g>
<path fill="none" d="M0 0h24v24H0z"/>
<path d="M19 21H5a1 1 0 0 1-1-1v-9H1l10.327-9.388a1 1 0 0 1 1.346 0L23 11h-3v9a1 1 0 0 1-1 1zM6 19h12V9.157l-6-5.454-6 5.454V19zm2.591-5.191a3.508 3.508 0 0 1 0-1.622l-.991-.572 1-1.732.991.573a3.495 3.495 0 0 1 1.404-.812V8.5h2v1.144c.532.159 1.01.44 1.404.812l.991-.573 1 1.731-.991.573a3.508 3.508 0 0 1 0 1.622l.991.572-1 1.731-.991-.572a3.495 3.495 0 0 1-1.404.811v1.145h-2V16.35a3.495 3.495 0 0 1-1.404-.811l-.991.572-1-1.73.991-.573zm3.404.688a1.5 1.5 0 1 0 0-2.998 1.5 1.5 0 0 0 0 2.998z"/>
</g>
</svg>
</svg>

Before

Width:  |  Height:  |  Size: 787 B

After

Width:  |  Height:  |  Size: 677 B

+7
View File
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg xmlns="http://www.w3.org/2000/svg"
aria-label="Plex" role="img"
viewBox="0 0 512 512"><rect
width="512" height="512"
rx="15%"
fill="#282a2d"/><path d="m256 70h-108l108 186-108 186h108l108-186z" fill="#e5a00d"/></svg>

After

Width:  |  Height:  |  Size: 340 B

+2
View File
@@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 192 192" xmlns="http://www.w3.org/2000/svg" fill="none" stroke="#000000" stroke-linejoin="round" stroke-width="12"><path d="M22 25.5h48L116 94l-46 68.5H22L68.5 94Zm109.8 56L108 46l14-20.5h48zm-.3 23.5c10.979 17.625 25.52 38.875 38.5 49.5-11.149 13.635-34.323 32.278-62.5-14z"/></svg>

After

Width:  |  Height:  |  Size: 450 B