Merge branch 'pre-release' into fixes/1.1.10/debug
This commit is contained in:
commit
af36b08af1
47 files changed
+5554
-970
No files matched your search
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -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 \
|
||||
|
||||
@@ -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 \
|
||||
|
||||
Generated
+2
-2
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
@@ -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)),
|
||||
};
|
||||
|
||||
|
||||
@@ -327,6 +327,7 @@ fn track_to_queue_track(track: &Track) -> QueueTrack {
|
||||
album_id,
|
||||
artist_id,
|
||||
streamable: track.streamable,
|
||||
source: Some("qobuz".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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))?
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 ============
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -322,6 +322,7 @@ export interface LocalLibraryTrack {
|
||||
bit_depth?: number;
|
||||
sample_rate: number;
|
||||
artwork_path?: string;
|
||||
source?: string;
|
||||
}
|
||||
|
||||
// ============ External API Types ============
|
||||
|
||||
+23
-5
@@ -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)
|
||||
|
||||
@@ -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 |
@@ -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 |
@@ -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 |
Reference in new issue
Block a user