merge: pull smoke-2026-07-25 backend for the Library All POC phase

Brings the windowed Library All backend (qbz-library) and the latest
Slint reference implementation into the POC base. POC branch only;
pre-release untouched.

km
This commit is contained in:
vicrodh committed 2026-07-27 14:05:08 -06:00
commit b405755c1a
110 files changed
+8082 -687

No files matched your search

+144 -3
View File
@@ -358,6 +358,7 @@ dependencies = [
"parking_lot",
"percent-encoding",
"windows-sys 0.60.2",
"wl-clipboard-rs",
"x11rb",
]
@@ -2469,6 +2470,12 @@ version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ce7134b9999ecaf8bcd65542e436736ef32ddca1b3e06094cb6ec5755203b80"
[[package]]
name = "fixedbitset"
version = "0.5.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99"
[[package]]
name = "flate2"
version = "1.1.9"
@@ -2848,6 +2855,40 @@ dependencies = [
"weezl",
]
[[package]]
name = "gilrs"
version = "0.11.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "902fb00d3f6398e635be22e5c837b303c501835cca7ac11a47bba138f7aafdd8"
dependencies = [
"fnv",
"gilrs-core",
"log",
"uuid 1.22.0",
"vec_map",
]
[[package]]
name = "gilrs-core"
version = "0.6.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc7f0ce6237abcc0523f2a5502b1e3fe5802daaae47ac14e166fe49551301ea9"
dependencies = [
"inotify",
"js-sys",
"libc",
"libudev-sys",
"log",
"nix 0.31.3",
"objc2-core-foundation",
"objc2-io-kit",
"uuid 1.22.0",
"vec_map",
"wasm-bindgen",
"web-sys",
"windows 0.62.2",
]
[[package]]
name = "gl_generator"
version = "0.14.0"
@@ -3802,6 +3843,26 @@ dependencies = [
"rustversion",
]
[[package]]
name = "inotify"
version = "0.11.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "153be1941a183ec9ccd095ddbe17a8b8d435ef6c76e9e02451b933c3999af2c8"
dependencies = [
"bitflags 2.13.0",
"inotify-sys",
"libc",
]
[[package]]
name = "inotify-sys"
version = "0.1.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c033f80b2c113cdf91ab7a33faa9cbc014726dcad99880c8609af2a370edf37d"
dependencies = [
"libc",
]
[[package]]
name = "inout"
version = "0.1.4"
@@ -4190,9 +4251,9 @@ checksum = "7a79a3332a6609480d7d0c9eab957bca6b455b91bb84e66d19f5ff66294b85b8"
[[package]]
name = "libc"
version = "0.2.183"
version = "0.2.189"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d"
checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"
[[package]]
name = "libdbus-sys"
@@ -4748,6 +4809,18 @@ dependencies = [
"libc",
]
[[package]]
name = "nix"
version = "0.31.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d"
dependencies = [
"bitflags 2.13.0",
"cfg-if",
"cfg_aliases",
"libc",
]
[[package]]
name = "no_std_io2"
version = "0.9.4"
@@ -5214,6 +5287,17 @@ dependencies = [
"objc2-core-foundation",
]
[[package]]
name = "objc2-io-kit"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "33fafba39597d6dc1fb709123dfa8289d39406734be322956a69f0931c73bb15"
dependencies = [
"bitflags 2.13.0",
"libc",
"objc2-core-foundation",
]
[[package]]
name = "objc2-io-surface"
version = "0.3.2"
@@ -5447,6 +5531,16 @@ dependencies = [
"pin-project-lite",
]
[[package]]
name = "os_pipe"
version = "1.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967"
dependencies = [
"libc",
"windows-sys 0.61.2",
]
[[package]]
name = "owned_ttf_parser"
version = "0.25.1"
@@ -5614,6 +5708,17 @@ dependencies = [
"pest",
]
[[package]]
name = "petgraph"
version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8701b58ea97060d5e5b155d383a69952a60943f0e6dfe30b04c287beb0b27455"
dependencies = [
"fixedbitset 0.5.7",
"hashbrown 0.15.5",
"indexmap",
]
[[package]]
name = "phf"
version = "0.11.3"
@@ -6053,6 +6158,7 @@ dependencies = [
"dirs 5.0.1",
"env_logger",
"futures-util",
"gilrs",
"i-slint-backend-winit",
"i-slint-renderer-femtovg",
"image",
@@ -8585,7 +8691,7 @@ dependencies = [
"fancy-regex",
"filedescriptor",
"finl_unicode",
"fixedbitset",
"fixedbitset 0.4.2",
"hex",
"lazy_static",
"libc",
@@ -9049,6 +9155,17 @@ dependencies = [
"strength_reduce",
]
[[package]]
name = "tree_magic_mini"
version = "3.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8765b90061cba6c22b5831f675da109ae5561588290f9fa2317adab2714d5a6"
dependencies = [
"memchr",
"nom 8.0.0",
"petgraph",
]
[[package]]
name = "try-lock"
version = "0.2.5"
@@ -9344,6 +9461,12 @@ version = "0.2.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
[[package]]
name = "vec_map"
version = "0.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f1bddf1187be692e79c5ffeab891132dfb0f236ed36a43c7ed39f1165ee20191"
[[package]]
name = "version_check"
version = "0.9.5"
@@ -10673,6 +10796,24 @@ dependencies = [
"wasmparser",
]
[[package]]
name = "wl-clipboard-rs"
version = "0.9.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e9651471a32e87d96ef3a127715382b2d11cc7c8bb9822ded8a7cc94072eb0a3"
dependencies = [
"libc",
"log",
"os_pipe",
"rustix 1.1.4",
"thiserror 2.0.18",
"tree_magic_mini",
"wayland-backend",
"wayland-client",
"wayland-protocols",
"wayland-protocols-wlr",
]
[[package]]
name = "write-fonts"
version = "0.45.0"
+1
View File
@@ -1,4 +1,5 @@
pub mod diagnostics;
pub mod memory_watchdog;
pub mod offline_mode;
pub mod graphics_autoconfig;
pub mod playback_context;
+86
View File
@@ -0,0 +1,86 @@
//! Memory-pressure watchdog.
//!
//! Every 15 s, reads the host's memory pressure from `qbz-core`'s
//! `system_capabilities` and reacts:
//!
//! * `is_critical` (< 5 % available) — evict the L1 in-memory audio cache
//! (the L2 disk files stay; disk is not the scarce resource) and latch
//! [`prefetch_halted`] so no new prefetch downloads start.
//! * `is_low` (< 15 % available) — pause HiRes prefetch for that tick via
//! [`hires_prefetch_paused`]; a normal-lossless prefetch is ~4x smaller.
//! * healthy again — clear both latches and log the recovery at debug.
//! There is deliberately no fancier unlatching logic.
//!
//! Both frontends (`qbz`, `qbzd`) spawn the loop once at startup with the
//! shared [`Player`] handle; the prefetch path in
//! [`crate::playback_driver`] consults the latches before starting work.
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::time::Duration;
use qbz_player::Player;
/// How often the watchdog samples /proc/meminfo.
const TICK_SECS: u64 = 15;
/// Latched on critical pressure: no new prefetch downloads may start.
static PREFETCH_HALTED: AtomicBool = AtomicBool::new(false);
/// Set on low/critical pressure ticks: prefetch downgrades HiRes to
/// Lossless. Follows the latest tick rather than latching.
static HIRES_PREFETCH_PAUSED: AtomicBool = AtomicBool::new(false);
/// True while the watchdog has halted prefetch downloads (critical
/// pressure). Checked by the prefetch path before starting any download.
pub fn prefetch_halted() -> bool {
PREFETCH_HALTED.load(Ordering::Relaxed)
}
/// True when the latest watchdog tick saw low (or critical) memory
/// pressure — prefetch should stay at Lossless this round.
pub fn hires_prefetch_paused() -> bool {
HIRES_PREFETCH_PAUSED.load(Ordering::Relaxed)
}
/// Spawn the watchdog loop on the current tokio runtime. Runs for the
/// process lifetime; the returned handle lets a caller with an ordered
/// shutdown abort it explicitly.
pub fn spawn(player: Arc<Player>) -> tokio::task::JoinHandle<()> {
tokio::spawn(async move {
let mut interval = tokio::time::interval(Duration::from_secs(TICK_SECS));
loop {
interval.tick().await;
// None on platforms without /proc/meminfo — nothing to do.
let Some(pressure) = qbz_core::system_capabilities::read_memory_pressure() else {
continue;
};
if pressure.is_critical {
log::warn!(
"[memwatch] critical memory pressure: {:.1}% available ({} MB of {} MB) — evicting L1 audio cache, halting prefetch",
pressure.available_pct,
pressure.mem_available_kb / 1024,
pressure.mem_total_kb / 1024,
);
player.evict_l1_audio_cache();
PREFETCH_HALTED.store(true, Ordering::Relaxed);
HIRES_PREFETCH_PAUSED.store(true, Ordering::Relaxed);
} else if pressure.is_low {
HIRES_PREFETCH_PAUSED.store(true, Ordering::Relaxed);
log::debug!(
"[memwatch] low memory pressure: {:.1}% available — hires prefetch paused this tick",
pressure.available_pct,
);
} else {
let was_halted = PREFETCH_HALTED.swap(false, Ordering::Relaxed);
let was_paused = HIRES_PREFETCH_PAUSED.swap(false, Ordering::Relaxed);
if was_halted || was_paused {
log::debug!(
"[memwatch] memory pressure recovered ({:.1}% available) — prefetch re-enabled",
pressure.available_pct,
);
}
}
}
})
}
+65 -2
View File
@@ -561,8 +561,22 @@ async fn prefetch_successors<A: FrontendAdapter + Send + Sync + 'static>(
runtime: &AppRuntime<A>,
quality: Quality,
) {
// Memory-pressure watchdog: critical pressure latched a halt — do not
// start a new download (issue #660).
if crate::memory_watchdog::prefetch_halted() {
log::debug!("[qbzd] driver: prefetch skipped — memory-pressure halt active");
return;
}
let profile = qbz_core::system_capabilities::memory_profile();
let core = runtime.core();
let upcoming = core.peek_upcoming(1).await;
// Depth honors the profile's prefetch_count cap. The daemon's
// historical depth is 1 successor per advance, which is within BOTH
// classes' caps (LowMemory 1, Normal 5), so this changes nothing today
// and clamps automatically if the depth ever grows. There is no
// concurrency loop here — the single prefetch below is awaited — so
// max_concurrent_prefetch (>= 1 in both classes) is trivially honored.
let depth = 1usize.min(profile.prefetch_count);
let upcoming = core.peek_upcoming(depth).await;
let Some(next) = upcoming.into_iter().next() else {
return;
};
@@ -578,11 +592,34 @@ async fn prefetch_successors<A: FrontendAdapter + Send + Sync + 'static>(
let Some(client) = guard.as_ref() else {
return;
};
if let Err(e) = player.prefetch_into_cache(client, next.id, quality).await {
// Quality cap: the LowMemory profile disallows HiRes prefetch entirely
// (~60 MB/track → ~15 MB at Lossless); the watchdog additionally pauses
// HiRes prefetch on low-pressure ticks. This caps only the warm-ahead
// copy — playback of the user's selected quality is unaffected.
let allow_hires =
profile.allow_hires_prefetch && !crate::memory_watchdog::hires_prefetch_paused();
let prefetch_quality = cap_prefetch_quality(quality, allow_hires);
if let Err(e) = player
.prefetch_into_cache(client, next.id, prefetch_quality)
.await
{
log::debug!("[qbzd] driver: prefetch track {} failed: {e}", next.id);
}
}
/// Pure prefetch-quality cap: when HiRes prefetch is disallowed (LowMemory
/// profile or a low-pressure watchdog tick), HiRes/UltraHiRes warm-aheads
/// downgrade to Lossless; anything else passes through unchanged.
fn cap_prefetch_quality(requested: Quality, allow_hires: bool) -> Quality {
if allow_hires {
return requested;
}
match requested {
Quality::HiRes | Quality::UltraHiRes => Quality::Lossless,
q => q,
}
}
/// Project the live queue into the decision's [`QueueSnapshot`].
async fn queue_snapshot<A: FrontendAdapter + Send + Sync + 'static>(
core: &QbzCore<A>,
@@ -890,4 +927,30 @@ mod tests {
assert!(!a.contains(&DriverAction::AdvanceAndPlay));
assert!(a.contains(&DriverAction::ReportEdge)); // play-state edge
}
#[test]
fn prefetch_quality_uncapped_when_hires_allowed() {
// Normal profile, healthy pressure: every tier passes through.
for q in [
Quality::Mp3,
Quality::Lossless,
Quality::HiRes,
Quality::UltraHiRes,
] {
assert_eq!(cap_prefetch_quality(q, true), q);
}
}
#[test]
fn prefetch_quality_caps_hires_to_lossless_when_disallowed() {
// LowMemory profile / low-pressure tick: HiRes warm-aheads shrink.
assert_eq!(cap_prefetch_quality(Quality::HiRes, false), Quality::Lossless);
assert_eq!(
cap_prefetch_quality(Quality::UltraHiRes, false),
Quality::Lossless
);
// Lower tiers are untouched.
assert_eq!(cap_prefetch_quality(Quality::Lossless, false), Quality::Lossless);
assert_eq!(cap_prefetch_quality(Quality::Mp3, false), Quality::Mp3);
}
}
+5
View File
@@ -318,6 +318,11 @@ mod tests {
}
fn test_runtime() -> AppRuntime<NoOpAdapter> {
// QbzCore builds a reqwest client (MusicBrainz); since reqwest no
// longer pulls a rustls provider (#663), tests must install the
// process-level one explicitly, exactly like the real binaries do at
// startup — otherwise Client construction panics with "No provider set".
crate::ensure_crypto_provider();
AppRuntime::with_audio_settings(NoOpAdapter, None, AudioSettings::default(), None)
}
+73 -3
View File
@@ -885,9 +885,18 @@ impl AlsaDirectStream {
use alsa::mixer::SelemChannelId::*;
use alsa::mixer::{Mixer, SelemId};
// Open mixer for device
let mixer = Mixer::new(&self.device_id, false)
.map_err(|e| format!("Failed to open mixer for {}: {}", self.device_id, e))?;
// Mixer controls live on the CARD ctl device, not on the PCM alias the
// stream opened: `iec958:CARD=x,DEV=0` (HiFiBerry Digi, #331/#659),
// `hdmi:`, `front:`… are PCM plugin ids the mixer can't attach to, and
// a `DEV`-qualified `hw:` id isn't a valid ctl name either. Open the
// mixer on the derived `hw:CARD=<name>` instead.
let ctl_name = mixer_ctl_name(&self.device_id);
let mixer = Mixer::new(&ctl_name, false).map_err(|e| {
format!(
"Failed to open mixer for {} (ctl {}): {}",
self.device_id, ctl_name, e
)
})?;
// Try to find a volume control element
// Common names: "Master", "PCM", "Speaker", "Headphone"
@@ -970,3 +979,64 @@ impl AlsaDirectStream {
false
}
}
/// Derive the ALSA **card** ctl id from a PCM device id for mixer access.
///
/// Mixer elements are per-card; attaching a mixer to a PCM plugin alias
/// (`iec958:`, `hdmi:`, `front:`, `sysdefault:`…) or a `DEV`-qualified `hw:`
/// id fails. Maps every shape to `hw:CARD=<name>` when a `CARD=` argument is
/// present, to `hw:<n>` for the numeric `hw:N,M` / `plughw:N,M` forms, and
/// falls back to the raw id otherwise (e.g. `default` — which then fails with
/// the same "no mixer" outcome as before, no regression).
#[cfg(any(target_os = "linux", test))]
pub(crate) fn mixer_ctl_name(device_id: &str) -> String {
if let Some((_, args)) = device_id.split_once(':') {
for arg in args.split(',') {
if let Some(name) = arg.trim().strip_prefix("CARD=") {
if !name.is_empty() {
return format!("hw:CARD={name}");
}
}
}
// Numeric form: `hw:1,0` / `plughw:1` → `hw:1` (card index only).
if let Some(first) = args.split(',').next() {
let first = first.trim();
if first.parse::<u32>().is_ok() {
return format!("hw:{first}");
}
}
}
device_id.to_string()
}
#[cfg(test)]
mod tests {
use super::mixer_ctl_name;
#[test]
fn mixer_ctl_name_maps_card_forms() {
// HiFiBerry Digi S/PDIF (#331/#659) and other plugin aliases.
assert_eq!(
mixer_ctl_name("iec958:CARD=sndrpihifiberry,DEV=0"),
"hw:CARD=sndrpihifiberry"
);
assert_eq!(mixer_ctl_name("hw:CARD=C20,DEV=0"), "hw:CARD=C20");
assert_eq!(mixer_ctl_name("front:CARD=PCH,DEV=0"), "hw:CARD=PCH");
assert_eq!(mixer_ctl_name("hdmi:CARD=NVidia,DEV=1"), "hw:CARD=NVidia");
assert_eq!(
mixer_ctl_name("sysdefault:CARD=DacMagic,DEV=0"),
"hw:CARD=DacMagic"
);
// CARD= in a non-first position.
assert_eq!(mixer_ctl_name("hw:DEV=0,CARD=DacMagic"), "hw:CARD=DacMagic");
}
#[test]
fn mixer_ctl_name_numeric_and_fallback() {
assert_eq!(mixer_ctl_name("hw:1,0"), "hw:1");
assert_eq!(mixer_ctl_name("plughw:2"), "hw:2");
// No CARD= and no numeric arg → unchanged.
assert_eq!(mixer_ctl_name("default"), "default");
assert_eq!(mixer_ctl_name("pulse"), "pulse");
}
}
+13
View File
@@ -251,6 +251,19 @@ impl AudioCache {
}
}
/// Drop every L1 in-memory entry WITHOUT touching the L2 disk cache or
/// the fetching/failed bookkeeping. The memory-pressure watchdog's
/// relief valve: RAM is the scarce resource under pressure, the disk
/// files cost nothing to keep and save a re-download on recovery.
pub fn evict_all_memory(&self) {
let mut state = self.state.lock().unwrap();
let dropped = state.tracks.len();
state.tracks.clear();
state.access_order.clear();
state.current_size = 0;
log::info!("L1 memory cache evicted ({dropped} tracks dropped, L2 disk cache kept)");
}
/// Get cache statistics
pub fn stats(&self) -> CacheStats {
let state = self.state.lock().unwrap();
+77
View File
@@ -244,6 +244,83 @@ impl PlaybackCache {
}
}
/// Insert a track whose bytes are streamed in by `fill` rather than
/// passed as an already-materialized slice. Used by the low-memory
/// oversized-track path, where a second full in-RAM copy of the track
/// just to hand `insert` a `&[u8]` is exactly what we're avoiding —
/// `fill` typically chunked-copies straight out of the playback buffer.
///
/// `size_hint` is the expected byte count: it drives the too-large
/// rejection and pre-write eviction; the entry records the actual bytes
/// written.
pub fn insert_from<F>(&self, track_id: u64, size_hint: u64, fill: F)
where
F: FnOnce(&mut fs::File) -> std::io::Result<usize>,
{
// Don't cache if larger than max size
if size_hint > self.max_size_bytes {
log::debug!(
"Track {} too large for playback cache ({} MB > {} MB)",
track_id,
size_hint / (1024 * 1024),
self.max_size_bytes / (1024 * 1024)
);
return;
}
// Evict old entries if needed
self.evict_if_needed(size_hint);
let path = self.track_path(track_id);
match fs::File::create(&path) {
Ok(mut file) => match fill(&mut file) {
Ok(written) => {
let size = written as u64;
let mut state = self.state.lock().unwrap();
// Remove old entry if exists
if let Some(old) = state.entries.remove(&track_id) {
state.current_size = state.current_size.saturating_sub(old.size_bytes);
}
state.entries.insert(
track_id,
CacheEntry {
track_id,
size_bytes: size,
last_accessed: SystemTime::now(),
},
);
state.current_size += size;
log::info!(
"Saved track {} to playback cache ({} KB). Total: {} MB / {} MB",
track_id,
size / 1024,
state.current_size / (1024 * 1024),
self.max_size_bytes / (1024 * 1024)
);
}
Err(e) => {
log::warn!(
"Failed to stream playback cache file for track {}: {}",
track_id,
e
);
let _ = fs::remove_file(&path);
}
},
Err(e) => {
log::warn!(
"Failed to create playback cache file for track {}: {}",
track_id,
e
);
}
}
}
/// Evict oldest entries to make room for new data
fn evict_if_needed(&self, needed_bytes: u64) {
let mut state = self.state.lock().unwrap();
+51 -32
View File
@@ -68,43 +68,62 @@ impl DlnaDiscovery {
if let Ok(stream) = discover {
let mut stream = std::pin::pin!(stream);
while let Ok(Some(device)) = stream.try_next().await {
let id = device.udn().to_string();
let name = device.friendly_name().to_string();
let manufacturer = device.manufacturer().to_string();
let model = device.model_name().to_string();
let url = device.url().to_string();
let ip = device.url().host().unwrap_or("unknown").to_string();
// Error-tolerant loop: a single device whose description
// fetch/parse fails must NOT kill the whole window (the
// old `while let Ok(...)` broke on the first Err and
// silently dropped every other responder, forever — the
// #667 class of silent failure). Log and continue.
loop {
match stream.try_next().await {
Ok(Some(device)) => {
let id = device.udn().to_string();
let name = device.friendly_name().to_string();
let manufacturer = device.manufacturer().to_string();
let model = device.model_name().to_string();
let url = device.url().to_string();
let ip = device.url().host().unwrap_or("unknown").to_string();
let mut has_av_transport = false;
let mut has_rendering_control = false;
let mut has_av_transport = false;
let mut has_rendering_control = false;
for service in device.services_iter() {
// Version-agnostic match: a renderer advertising only
// AVTransport:2/:3 (not :1) must still count as capable.
let service_type = service.service_type().to_string();
if service_type.contains(":service:AVTransport:") {
has_av_transport = true;
for service in device.services_iter() {
// Version-agnostic match: a renderer advertising only
// AVTransport:2/:3 (not :1) must still count as capable.
let service_type = service.service_type().to_string();
if service_type.contains(":service:AVTransport:") {
has_av_transport = true;
}
if service_type.contains(":service:RenderingControl:") {
has_rendering_control = true;
}
}
log::info!(
"[Cast] DLNA discovered: '{name}' ({manufacturer} {model}) at {ip} avt={has_av_transport} rc={has_rendering_control}"
);
let discovered = DiscoveredDlnaDevice {
id: id.clone(),
name,
manufacturer,
model,
ip,
url,
has_av_transport,
has_rendering_control,
};
if let Ok(mut state) = state.lock() {
state.devices.insert(id, discovered);
}
}
if service_type.contains(":service:RenderingControl:") {
has_rendering_control = true;
Ok(None) => break, // window elapsed
Err(e) => {
log::warn!(
"[Cast] DLNA discovery: skipping a device (description fetch/parse failed): {e}"
);
}
}
let discovered = DiscoveredDlnaDevice {
id: id.clone(),
name,
manufacturer,
model,
ip,
url,
has_av_transport,
has_rendering_control,
};
if let Ok(mut state) = state.lock() {
state.devices.insert(id, discovered);
}
}
}
+12
View File
@@ -582,6 +582,18 @@ impl<A: FrontendAdapter + Send + Sync + 'static> QbzCore<A> {
.await;
}
/// Add a track at the END of the manual block ("Play later", #442):
/// after every play-next / play-later already queued, before the source
/// resumes. "Add to queue" stays untouched at the absolute end.
pub async fn add_track_later(&self, track: QueueTrack) {
let queue = self.queue.write().await;
queue.add_track_later(track);
self.emit(CoreEvent::QueueUpdated {
state: queue.get_state(),
})
.await;
}
/// Set the entire queue (replaces existing)
pub async fn set_queue(&self, tracks: Vec<QueueTrack>, start_index: Option<usize>) {
// Any queue replacement drops the offline-only-playlist stamp; the
+78
View File
@@ -78,6 +78,40 @@ fn has_redaction_candidate(lower: &str) -> bool {
NEEDLES.iter().any(|n| lower.contains(n))
}
/// IPv4 octet (0-255) — the range guard is what keeps version strings
/// ("1.16.1", "8.2.0") and timestamps out of the mask.
const OCTET: &str = r"(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])";
/// IP-masking patterns (owner 2026-07-24): ANY address — LAN or public — is
/// masked to its LAST segment only (`192.168.178.211` → `*.*.*.211`), so
/// uploaded logs stop leaking the user's network topology. The port survives.
///
/// IPv6 groups must START with a 4-hex-digit group — that is what keeps time
/// strings ("11:41:48.054") and MAC addresses out of the mask (regex has no
/// lookaround, so the width anchor does the disambiguation).
fn ip_patterns() -> &'static Vec<Regex> {
static PATTERNS: OnceLock<Vec<Regex>> = OnceLock::new();
PATTERNS.get_or_init(|| {
[
// IPv4: mask the first three octets, keep the last (capture 1).
format!(r"\b(?:{OCTET}\.){{3}}({OCTET})\b"),
// IPv6 full form (first group 4 hex wide — see the doc above).
r"\b[0-9a-fA-F]{4}:(?:[0-9a-fA-F]{1,4}:){1,6}([0-9a-fA-F]{1,4})\b".to_string(),
// IPv6 `::`-compressed form (same 4-wide anchor).
r"\b[0-9a-fA-F]{4}(?::[0-9a-fA-F]{0,4})*::(?:[0-9a-fA-F]{1,4}:)*([0-9a-fA-F]{1,4})\b".to_string(),
]
.iter()
.filter_map(|p| Regex::new(p).ok())
.collect()
})
}
/// Cheap pre-check for the IP layer: a dot or colon and a digit, O(n). (The
/// colon is required — IPv6 has no dots.)
fn has_ip_candidate(line: &str) -> bool {
(line.contains('.') || line.contains(':')) && line.bytes().any(|b| b.is_ascii_digit())
}
/// Redact secrets from a single log line. Literal live-secret layer first, then regex.
pub fn redact(line: &str) -> String {
let mut out = line.to_string();
@@ -103,6 +137,16 @@ pub fn redact(line: &str) -> String {
}
}
// Layer 3 — IP masking (`*.*.*.<last>` for v4, `*:*:<last>` for v6).
if has_ip_candidate(&out) {
for (i, re) in ip_patterns().iter().enumerate() {
if re.is_match(&out) {
let replacement = if i == 0 { "*.*.*.${1}" } else { "*:*:${1}" };
out = re.replace_all(&out, replacement).into_owned();
}
}
}
out
}
@@ -141,4 +185,38 @@ mod tests {
let r = redact("value abc here");
assert!(r.contains("abc"), "short value should not be scrubbed: {r}");
}
#[test]
fn masks_ipv4_keep_last_octet() {
let r = redact("DLNA: Set URI to http://192.168.178.211:9876/audio/***/235314822");
assert_eq!(
r,
"DLNA: Set URI to http://*.*.*.211:9876/audio/***/235314822"
);
// Public addresses get the same treatment.
let r = redact("dial 203.0.113.9 failed");
assert!(r.contains("*.*.*.9"), "public IPv4 survived: {r}");
assert!(!r.contains("203.0.113"), "public IPv4 leaked: {r}");
}
#[test]
fn ip_mask_leaves_versions_and_timestamps_alone() {
for s in [
"2026-07-24 11:41:48.054 INFO qbz",
"bundle 8.2.0-b034",
"slint 1.16.1",
"kernel 6.17.0-40-generic",
"rate 44.1kHz 24-bit / 192 kHz",
] {
assert_eq!(redact(s), s, "non-IP was mangled: {s}");
}
}
#[test]
fn masks_ipv6() {
let r = redact("bind fe80:0000:0000:0000:0224:a4ff:fe12:3456 up");
assert!(r.contains("*:*:3456"), "full IPv6 survived: {r}");
let r2 = redact("bind fe80::224:a4ff:fe12:3456 up");
assert!(r2.contains("*:*:3456"), "compressed IPv6 survived: {r2}");
}
}
+1 -1
View File
@@ -13,7 +13,7 @@
use std::sync::Arc;
mod types;
pub use types::{MediaEvent, MediaIntegration, PlaybackStatus, TrackMeta};
pub use types::{xesam_url_for, MediaEvent, MediaIntegration, PlaybackStatus, TrackMeta};
pub mod notify;
pub use notify::{show_track_notification, NotificationMeta};
+3
View File
@@ -105,6 +105,9 @@ fn build_metadata(meta: &TrackMeta) -> Metadata {
if let Some(url) = &meta.art_url {
b = b.art_url(url.clone());
}
if let Some(url) = &meta.url {
b = b.url(url.clone());
}
b.build()
}
+20
View File
@@ -13,6 +13,26 @@ pub struct TrackMeta {
/// is NOT the application icon (that is resolved by GNOME from the MPRIS
/// `DesktopEntry` property → the installed .desktop → `Icon=`).
pub art_url: Option<String>,
/// `xesam:url` (#658) — the track's PUBLIC web link so external scripts
/// (playerctl widgets, handoff automation, scrobblers) can identify the
/// exact song: `https://open.qobuz.com/track/<id>` for Qobuz catalog rows
/// (offline `qobuz_download` keeps its real Qobuz id, so it qualifies too).
/// None for local/Plex rows — QueueTrack carries no file path to map to a
/// file:// URI.
pub url: Option<String>,
}
/// Build the `xesam:url` for a queue track (#658): the public Qobuz web link
/// (`https://open.qobuz.com/track/<id>`) for catalog tracks — offline
/// `qobuz_download` rows qualify too, their id IS the real Qobuz id. None for
/// `local` / `plex` rows: QueueTrack carries no file path to map to a file://
/// URI, and a synthetic local id would produce a bogus Qobuz link.
pub fn xesam_url_for(source: Option<&str>, track_id: u64) -> Option<String> {
match source.map(|s| s.to_ascii_lowercase()) {
Some(ref s) if s == "local" || s == "plex" => None,
_ if track_id > 0 => Some(format!("https://open.qobuz.com/track/{track_id}")),
_ => None,
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+4
View File
@@ -100,6 +100,10 @@ pub struct QueueState {
pub repeat: RepeatMode,
pub total_tracks: usize,
pub stop_after_track_id: Option<u64>,
/// Manual-block size (#442): how many entries right after `current_index`
/// were hand-queued ("Play next" / "Play later") and play before the
/// source resumes.
pub manual_next_count: usize,
}
// ============ Playback State ============
@@ -0,0 +1,93 @@
//! Process-wide memory tuning for the player.
//!
//! `qbz-core`'s `system_capabilities` detects the host's memory profile
//! (Normal vs LowMemory), but `qbz-core` depends on `qbz-player` — the
//! player cannot read the profile back without a dependency cycle. So the
//! frontend binaries (`qbz`, `qbzd`) read the profile once at startup and
//! push the derived values down here, exactly like the pre-existing
//! [`super::streaming_source::set_max_initial_buffer_bytes`] cap.
//!
//! Every knob defaults to today's hardcoded Normal behavior, so a consumer
//! that never calls [`apply_memory_tuning`] (tests, auxiliary tools) is
//! byte-identical to before.
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
/// Whether the host profile is LowMemory (< 2 GB RAM, Raspberry Pi-class).
/// Default false = Normal — behavioral changes gated on this flag must keep
/// Normal hosts unchanged.
static LOW_MEMORY_CLASS: AtomicBool = AtomicBool::new(false);
/// L1 (in-memory) audio-cache budget in bytes. Defaults to the historical
/// hardcoded 400 MB; the LowMemory profile lowers it to 50 MB so the cache
/// cannot eat 40 % of a 1 GB host (issue #660).
static AUDIO_CACHE_L1_MAX_BYTES: AtomicUsize = AtomicUsize::new(400 * 1024 * 1024);
/// Push the host memory profile's derived values into the player. Called
/// once at process start by the frontend binaries; not designed for
/// mid-session mutation (the L1 cache reads its budget at construction).
pub fn apply_memory_tuning(
low_memory: bool,
l1_max_bytes: usize,
max_initial_buffer_bytes: usize,
) {
LOW_MEMORY_CLASS.store(low_memory, Ordering::Relaxed);
AUDIO_CACHE_L1_MAX_BYTES.store(l1_max_bytes, Ordering::Relaxed);
super::streaming_source::set_max_initial_buffer_bytes(max_initial_buffer_bytes);
}
/// True when the host was classified LowMemory at startup.
pub fn is_low_memory_class() -> bool {
LOW_MEMORY_CLASS.load(Ordering::Relaxed)
}
/// The configured L1 audio-cache budget in bytes.
pub fn audio_cache_l1_max_bytes() -> usize {
AUDIO_CACHE_L1_MAX_BYTES.load(Ordering::Relaxed)
}
/// Pure predicate: a completed track of `len` bytes is "oversized" for an
/// L1 budget of `l1_max_bytes` when it exceeds a quarter of that budget.
///
/// Rationale for the /4: the streaming buffer already holds one full copy
/// of the track in RAM for playback, so promoting an oversized track into
/// L1 (plus the promotion clone itself) transiently triples its footprint.
/// Anything beyond a quarter of the L1 budget would also evict every other
/// cached track on insert, so the cache stop pays for itself only for
/// smaller tracks. With the LowMemory 50 MB budget this flags tracks over
/// 12.5 MB; the predicate is only consulted on LowMemory hosts.
pub fn oversized_for_l1(len: usize, l1_max_bytes: usize) -> bool {
len > l1_max_bytes / 4
}
#[cfg(test)]
mod tests {
use super::*;
const LOW_L1: usize = 50 * 1024 * 1024; // LowMemory profile budget
#[test]
fn oversized_boundary_is_quarter_of_budget() {
let threshold = LOW_L1 / 4;
assert!(!oversized_for_l1(threshold, LOW_L1)); // exactly at: still cached
assert!(oversized_for_l1(threshold + 1, LOW_L1));
assert!(!oversized_for_l1(0, LOW_L1));
}
#[test]
fn normal_budget_flags_only_very_large_tracks() {
// The predicate is gated on the LowMemory class at the call sites,
// but with the Normal 400 MB budget the threshold would be 100 MB.
let normal = 400 * 1024 * 1024;
assert!(!oversized_for_l1(60 * 1024 * 1024, normal)); // typical HiRes
assert!(oversized_for_l1(200 * 1024 * 1024, normal)); // issue #660 track
}
#[test]
fn defaults_match_historical_hardcoded_behavior() {
// A process that never applied tuning: Normal class, 400 MB L1 —
// identical to the pre-wiring hardcoded values.
assert!(!is_low_memory_class());
assert_eq!(audio_cache_l1_max_bytes(), 400 * 1024 * 1024);
}
}
+127 -23
View File
@@ -13,6 +13,11 @@
mod playback_engine;
mod streaming_source;
pub mod memory_tuning;
pub use memory_tuning::{
apply_memory_tuning, audio_cache_l1_max_bytes, is_low_memory_class, oversized_for_l1,
};
pub use streaming_source::{
max_initial_buffer_bytes, set_max_initial_buffer_bytes, BufferWriter, BufferedMediaSource,
InMemorySource, IncrementalStreamingSource, StreamingConfig,
@@ -1603,6 +1608,11 @@ impl Player {
const PAUSE_SUSPEND_DELAY_MS: u64 = 2000;
let mut pause_suspend_deadline: Option<Instant> = None;
let mut last_empty_check = Instant::now();
// Latch so the low-memory oversized-track promotion skip logs
// once per track instead of on every 500 ms idle tick. Reset
// whenever the streaming source is absent or still downloading
// (i.e. the next track re-arms the log).
let mut low_mem_promotion_skip_logged = false;
// Current track's normalization gain factor (stored for reuse on resume/seek)
let mut current_normalization_gain: Option<f32> = None;
// Current track's dynamic gain atomic (shared with DynamicAmplify + LoudnessAnalyzer)
@@ -3721,18 +3731,59 @@ impl Player {
if let Some(streaming_src) = current_streaming_source.as_ref() {
if streaming_src.is_complete() {
if current_audio_data.is_none() {
if let Some(full_data) =
streaming_src.take_complete_data()
{
log::info!(
"Streaming promotion: full track buffered ({} bytes), enabling cached transition path",
full_data.len()
);
current_audio_data = Some(full_data);
// Low-memory profile + oversized
// track: skip the promotion clone
// (take_complete_data copies the
// whole buffer — a persistent 2x
// RSS for the rest of the track)
// and KEEP the streaming source:
// seek/resume then read the
// single buffered copy. The
// gapless pre-queue gate below
// requires the source cleared, so
// such a track transitions with a
// small gap instead of gapless —
// accepted trade vs. an OOM kill
// on 1 GB hosts (issue #660). The
// L2 disk copy is written by the
// CMAF feeder task, off this
// thread.
let skip_promotion =
memory_tuning::is_low_memory_class()
&& memory_tuning::oversized_for_l1(
streaming_src.buffer_size(),
memory_tuning::audio_cache_l1_max_bytes(),
);
if skip_promotion {
if !low_mem_promotion_skip_logged {
low_mem_promotion_skip_logged = true;
log::info!(
"Streaming promotion skipped (low-memory host): {} bytes exceeds the oversized threshold — keeping the single buffered copy",
streaming_src.buffer_size()
);
}
} else {
if let Some(full_data) =
streaming_src.take_complete_data()
{
log::info!(
"Streaming promotion: full track buffered ({} bytes), enabling cached transition path",
full_data.len()
);
current_audio_data = Some(full_data);
}
clear_streaming_source = true;
}
} else {
clear_streaming_source = true;
}
clear_streaming_source = true;
} else {
// Still downloading: re-arm the
// skip log for the next track.
low_mem_promotion_skip_logged = false;
}
} else {
low_mem_promotion_skip_logged = false;
}
if clear_streaming_source {
current_streaming_source = None;
@@ -3992,17 +4043,21 @@ impl Player {
}
});
// Two-level playback cache: L1 in memory (~400 MB), L2 on disk
// (~800 MB). A disk-cache failure degrades to L1-only rather than
// aborting player creation.
// Two-level playback cache: L1 in memory, L2 on disk (~800 MB). The
// L1 budget comes from the host memory profile pushed down via
// `memory_tuning` at process start (400 MB Normal / 50 MB LowMemory)
// — on a 1 GB host the historical 400 MB hardcode alone could eat
// 40 % of RAM (issue #660). A disk-cache failure degrades to
// L1-only rather than aborting player creation.
let l1_max_bytes = memory_tuning::audio_cache_l1_max_bytes();
let audio_cache = match qbz_cache::PlaybackCache::new(800 * 1024 * 1024) {
Ok(pc) => Arc::new(qbz_cache::AudioCache::with_playback_cache(
400 * 1024 * 1024,
l1_max_bytes,
Arc::new(pc),
)),
Err(e) => {
log::warn!("Playback disk cache unavailable: {e}; memory cache only");
Arc::new(qbz_cache::AudioCache::new(400 * 1024 * 1024))
Arc::new(qbz_cache::AudioCache::new(l1_max_bytes))
}
};
@@ -4188,6 +4243,7 @@ impl Player {
track_id,
cache,
skip_cache,
total_flac_size,
)
.await
{
@@ -4382,6 +4438,13 @@ impl Player {
self.audio_cache.clear();
}
/// Drop only the L1 in-memory entries, keeping the L2 disk cache. The
/// memory-pressure watchdog's relief valve (issue #660): freeing RAM is
/// what relieves the pressure; the disk files cost nothing to keep.
pub fn evict_l1_audio_cache(&self) {
self.audio_cache.evict_all_memory();
}
/// Fetch a track's audio bytes for a gapless handoff: L1 memory →
/// L2 disk → CMAF `download_full` (legacy full download as fallback).
/// Does not start playback — the caller passes the bytes to
@@ -4572,6 +4635,13 @@ impl Player {
/// then fetches each audio segment, decrypts encrypted frames, and pushes
/// the resulting FLAC frame data to the streaming buffer. The player
/// starts playing as soon as enough data is buffered.
///
/// `expected_total_bytes` is the assembled FLAC size known from the
/// segment table; on a LowMemory host it decides up front whether the
/// track is oversized for the L1 budget, in which case NO parallel RAM
/// accumulator is built and the finished track goes straight to the L2
/// disk cache, streamed out of the playback buffer in chunks (issue
/// #660).
async fn cmaf_stream_segments(
url_template: &str,
n_segments: u8,
@@ -4581,6 +4651,7 @@ impl Player {
track_id: u64,
cache: Arc<qbz_cache::AudioCache>,
skip_cache: bool,
expected_total_bytes: u64,
) -> Result<(), String> {
struct FailGuard {
writer: BufferWriter,
@@ -4615,11 +4686,21 @@ impl Player {
let mut total_written: u64 = flac_header.len() as u64;
// Accumulate the assembled FLAC (header + decrypted frames) so the
// finished track can be cached for instant replay. Empty when
// `skip_cache` (streaming_only) is set.
let mut cache_data: Vec<u8> = if skip_cache {
Vec::new()
} else {
// `skip_cache` (streaming_only) is set — and never built at all for
// an oversized track on a LowMemory host, where a second full copy
// of the track in RAM next to the playback buffer is the issue #660
// OOM recipe; that track goes to the L2 disk cache at the end.
let low_mem_oversized = !skip_cache
&& memory_tuning::is_low_memory_class()
&& memory_tuning::oversized_for_l1(
expected_total_bytes as usize,
memory_tuning::audio_cache_l1_max_bytes(),
);
let accumulate_cache = !skip_cache && !low_mem_oversized;
let mut cache_data: Vec<u8> = if accumulate_cache {
flac_header.clone()
} else {
Vec::new()
};
let start = Instant::now();
@@ -4655,7 +4736,7 @@ impl Player {
let _ = writer.error(msg.clone());
return Err(msg);
}
if !skip_cache {
if accumulate_cache {
cache_data.extend_from_slice(&frame);
}
total_written += frame.len() as u64;
@@ -4670,7 +4751,7 @@ impl Player {
let _ = writer.error(msg.clone());
return Err(msg);
}
if !skip_cache {
if accumulate_cache {
cache_data.extend_from_slice(trailing);
}
total_written += trailing.len() as u64;
@@ -4716,10 +4797,32 @@ impl Player {
// Cache the assembled FLAC (header + decrypted frames) for instant
// replay on the next play of this track.
if !skip_cache && !cache_data.is_empty() {
if accumulate_cache && !cache_data.is_empty() {
let bytes = cache_data.len();
cache.insert(track_id, cache_data);
log::info!("[CMAF-STREAM] Track {} cached ({} bytes)", track_id, bytes);
} else if low_mem_oversized {
// Oversized track on a low-memory host: no RAM accumulator was
// built. Persist straight to the L2 disk playback cache,
// streamed out of the playback buffer in chunks — no second
// full in-RAM copy, and the per-chunk lock keeps the audio
// reader starved for at most ~1 MB of writes at a time.
if let Some(playback_cache) = cache.get_playback_cache() {
playback_cache.insert_from(track_id, total_written, |file| {
writer.write_buffered_to(file)
});
log::info!(
"[CMAF-STREAM] Track {} persisted to L2 disk cache only ({} bytes, low-memory oversized)",
track_id,
total_written
);
} else {
log::info!(
"[CMAF-STREAM] Track {} ({} bytes) oversized for low-memory L1 and no disk cache available — not cached",
track_id,
total_written
);
}
}
Ok(())
@@ -5130,8 +5233,9 @@ impl Player {
// unlike `speed_mbps`, which is estimated from the tiny init fetch and
// is latency-dominated — it lands on the slowest ladder rung for every
// connection. Clamped to 256KB (format-detection minimum) .. 8MB (the
// process-wide ladder cap has no desktop caller, so this is the
// effective ceiling protecting low-memory hosts).
// process-wide ladder cap is set from the host memory profile at
// process start, so on a LowMemory host `from_speed_mbps` already
// clamped lower; the 8MB ceiling here is the last line of defense).
let user_secs = self
.audio_settings
.lock()
@@ -169,6 +169,23 @@ pub fn max_initial_buffer_bytes() -> usize {
MAX_INITIAL_BUFFER_BYTES.load(std::sync::atomic::Ordering::Relaxed)
}
/// Absolute ceiling for the up-front buffer reservation: 1 GiB. Guards
/// against absurd Content-Length values reserving more RAM than any real
/// track needs.
const MAX_PREALLOC_BYTES: u64 = 1024 * 1024 * 1024;
/// Pure decision for the up-front reservation in [`BufferedMediaSource::new`]:
/// `Some(capacity)` when `total_size` is known and non-zero (capped at
/// [`MAX_PREALLOC_BYTES`]), `None` when the caller should fall back to the
/// config's initial-buffer capacity.
fn prealloc_capacity(total_size: Option<u64>) -> Option<usize> {
let total = total_size?;
if total == 0 {
return None;
}
Some(total.min(MAX_PREALLOC_BYTES) as usize)
}
/// Internal state shared between reader and writer
struct BufferState {
/// Accumulated data from HTTP response
@@ -199,9 +216,30 @@ impl BufferedMediaSource {
/// Returns the source and a writer for pushing downloaded chunks.
/// The writer should be used from the async download task.
pub fn new(config: StreamingConfig, total_size: Option<u64>) -> (Self, BufferWriter) {
// When the total track size is known, reserve it up front: the
// buffer ends up holding the whole track either way, so this skips
// the ~8 doubling reallocs (and their memcpy) a 60-400 MB download
// would otherwise pay. Falls back to the initial-buffer capacity
// when the reservation fails (e.g. a 424 MB track on 32-bit, where
// the contiguous range may not exist) or the size is unknown.
let data = match prealloc_capacity(total_size) {
Some(cap) => {
let mut v = Vec::new();
if v.try_reserve_exact(cap).is_err() {
log::warn!(
"Stream buffer: could not reserve {} bytes up front; growing dynamically",
cap
);
v = Vec::with_capacity(config.initial_buffer_bytes);
}
v
}
None => Vec::with_capacity(config.initial_buffer_bytes),
};
let state = Arc::new((
Mutex::new(BufferState {
data: Vec::with_capacity(config.initial_buffer_bytes),
data,
download_complete: false,
download_error: None,
total_size,
@@ -550,6 +588,30 @@ impl BufferWriter {
0
}
}
/// Copy the buffered bytes into `out` in bounded chunks, re-taking the
/// state lock per chunk instead of holding it for the whole transfer,
/// so live readers are starved for at most one chunk's write instead of
/// a full-track copy. Used by the low-memory oversized-track path to
/// persist the finished track to the L2 disk cache straight from the
/// playback buffer — no second full in-RAM copy. Returns bytes written.
pub fn write_buffered_to<W: std::io::Write>(&self, out: &mut W) -> IoResult<usize> {
const CHUNK: usize = 1024 * 1024;
let (lock, _) = &*self.state;
let mut offset = 0usize;
loop {
let state = lock
.lock()
.map_err(|_| IoError::new(ErrorKind::Other, "Failed to acquire buffer lock"))?;
if offset >= state.data.len() {
break;
}
let end = (offset + CHUNK).min(state.data.len());
out.write_all(&state.data[offset..end])?;
offset = end;
}
Ok(offset)
}
}
// =============================================================================
@@ -1100,6 +1162,67 @@ mod tests {
assert_eq!(cfg.max_buffer_bytes, 100 * 1024 * 1024);
}
#[test]
fn prealloc_capacity_decision() {
// Unknown or zero size: no reservation, caller uses the initial
// buffer capacity instead.
assert_eq!(prealloc_capacity(None), None);
assert_eq!(prealloc_capacity(Some(0)), None);
// Known size reserves exactly that (no doubling slack).
assert_eq!(prealloc_capacity(Some(60 * 1024 * 1024)), Some(60 * 1024 * 1024));
assert_eq!(prealloc_capacity(Some(1)), Some(1));
// Absurd sizes are capped at 1 GiB.
assert_eq!(
prealloc_capacity(Some(8 * 1024 * 1024 * 1024)),
Some(MAX_PREALLOC_BYTES as usize)
);
}
#[test]
fn new_source_preallocates_known_total_size() {
let config = StreamingConfig {
initial_buffer_bytes: 16,
max_buffer_bytes: 100,
};
let (_source, writer) = BufferedMediaSource::new(config, Some(4096));
// The writer's buffer should have reserved the full track up front.
// (No public capacity accessor — push the full size and ensure the
// data lands intact, which is the observable contract.)
let payload = vec![0xABu8; 4096];
writer.push_chunk(&payload).unwrap();
writer.complete().unwrap();
assert_eq!(writer.buffer_size(), 4096);
}
#[test]
fn write_buffered_to_copies_full_contents() {
let config = StreamingConfig {
initial_buffer_bytes: 4,
max_buffer_bytes: 100,
};
let (_source, writer) = BufferedMediaSource::new(config, None);
writer.push_chunk(b"Hello, ").unwrap();
writer.push_chunk(b"world!").unwrap();
writer.complete().unwrap();
let mut out: Vec<u8> = Vec::new();
let written = writer.write_buffered_to(&mut out).unwrap();
assert_eq!(written, 13);
assert_eq!(&out, b"Hello, world!");
}
#[test]
fn write_buffered_to_handles_empty_buffer() {
let config = StreamingConfig {
initial_buffer_bytes: 4,
max_buffer_bytes: 100,
};
let (_source, writer) = BufferedMediaSource::new(config, None);
let mut out: Vec<u8> = Vec::new();
assert_eq!(writer.write_buffered_to(&mut out).unwrap(), 0);
assert!(out.is_empty());
}
#[test]
fn test_basic_read_write() {
let config = StreamingConfig {
+69
View File
@@ -36,6 +36,11 @@ struct InternalState {
history: VecDeque<usize>,
/// Track ID to stop after (optional)
stop_after_track_id: Option<u64>,
/// Manual-block size (#442): how many entries right after `current_index`
/// were added by hand ("Play next" / "Play later"). The block always
/// plays before the source (album/playlist) resumes; "Add to queue" does
/// NOT extend it (it appends to the absolute end, untouched).
manual_next_count: usize,
}
/// Queue manager for handling playback queue
@@ -61,6 +66,7 @@ impl QueueManager {
repeat: RepeatMode::Off,
history: VecDeque::with_capacity(50),
stop_after_track_id: None,
manual_next_count: 0,
}),
}
}
@@ -147,6 +153,8 @@ impl QueueManager {
} else {
state.tracks.insert(insert_index, track);
}
// The track joins the manual block (#442).
state.manual_next_count += 1;
if state.shuffle {
for idx in state.shuffle_order.iter_mut() {
@@ -170,10 +178,51 @@ impl QueueManager {
}
}
/// Add a track at the END of the manual block (#442 "Play later"): after
/// every play-next / play-later already queued, before the source
/// (album/playlist) resumes. "Add to queue" stays untouched at the
/// absolute end. With shuffle on, degrades to play-next — the block
/// concept is meaningless once the order is reshuffled.
pub fn add_track_later(&self, track: QueueTrack) {
let mut state = self.state.lock().unwrap();
let insert_index = if state.shuffle {
state.current_index.map(|idx| idx + 1).unwrap_or(0)
} else {
state
.current_index
.map(|idx| idx + 1 + state.manual_next_count)
.unwrap_or(state.manual_next_count)
};
let insert_index = insert_index.min(state.tracks.len());
state.tracks.insert(insert_index, track);
state.manual_next_count += 1;
if state.shuffle {
for idx in state.shuffle_order.iter_mut() {
if *idx >= insert_index {
*idx += 1;
}
}
let new_idx = insert_index;
let next_pos = if state.current_index.is_some() {
state.shuffle_position + 1
} else {
state.shuffle_order.len()
};
if next_pos >= state.shuffle_order.len() {
state.shuffle_order.push(new_idx);
} else {
state.shuffle_order.insert(next_pos, new_idx);
}
}
}
/// Set the entire queue (replaces existing)
pub fn set_queue(&self, new_tracks: Vec<QueueTrack>, start_index: Option<usize>) {
let mut state = self.state.lock().unwrap();
state.stop_after_track_id = None;
// A full replacement is a new source: the manual block dissolves (#442).
state.manual_next_count = 0;
// Remap history by track id BEFORE replacing tracks so that legitimate
// plays survive queue version bumps / reorders. Entries whose track is
// no longer present are dropped. See bug #316.
@@ -259,6 +308,7 @@ impl QueueManager {
pub fn clear(&self, keep_current: bool) {
let mut state = self.state.lock().unwrap();
state.stop_after_track_id = None;
state.manual_next_count = 0;
if keep_current {
// Keep the track at `current_index`, not always `tracks[0]`.
@@ -307,6 +357,13 @@ impl QueueManager {
let removed = state.tracks.remove(index);
// Removing an entry inside the manual block shrinks it (#442).
if let Some(curr_idx) = state.current_index {
if index > curr_idx && index <= curr_idx + state.manual_next_count {
state.manual_next_count = state.manual_next_count.saturating_sub(1);
}
}
// Invalidate marker if the removed track matches
if state.stop_after_track_id == Some(removed.id) {
state.stop_after_track_id = None;
@@ -722,6 +779,14 @@ impl QueueManager {
};
state.current_index = next_idx;
// Manual-block bookkeeping (#442): advancing past a manual entry
// shrinks the block; exhausting or wrapping the queue dissolves it.
match next_idx {
Some(0) | None => state.manual_next_count = 0,
Some(_) => {
state.manual_next_count = state.manual_next_count.saturating_sub(1)
}
}
next_idx.and_then(|idx| state.tracks.get(idx).cloned())
}
@@ -870,6 +935,8 @@ impl QueueManager {
state.shuffle = enabled;
if enabled {
// The manual block is meaningless once the order is reshuffled (#442).
state.manual_next_count = 0;
Self::regenerate_shuffle_order_internal(&mut state);
// Enabling shuffle during active playback must keep current track
@@ -1019,6 +1086,7 @@ impl QueueManager {
repeat: state.repeat,
total_tracks: state.tracks.len(),
stop_after_track_id: state.stop_after_track_id,
manual_next_count: state.manual_next_count,
}
}
@@ -1078,6 +1146,7 @@ impl QueueManager {
repeat: state.repeat,
total_tracks: state.tracks.len(),
stop_after_track_id: state.stop_after_track_id,
manual_next_count: state.manual_next_count,
}
}
@@ -4813,3 +4813,57 @@ msgstr "Künstler aus Empfehlungen abgelehnt"
msgid "{} restored to Recommendations"
msgstr "{} wieder in Empfehlungen aufgenommen"
msgid "LIBRARY › ALL"
msgstr "BIBLIOTHEK › ALLE"
msgid "Show local files & Plex"
msgstr "Lokale Dateien & Plex anzeigen"
msgid "Include your favorited local files and Plex items in the Library All feed."
msgstr "Zeigt deine favorisierten lokalen Dateien und Plex-Elemente im Bibliotheksfeed Alle an."
msgid "Play indicator animation"
msgstr "Wiedergabeindikator-Animation"
msgid "Animate the now-playing row with equalizer bars. Off (default) shows a static pause icon with an accent edge mark — lighter on CPU."
msgstr "Animiert die gerade spielende Zeile mit Equalizer-Balken. Aus (Standard) zeigt ein statisches Pause-Symbol mit einer Akzentmarkierung am Rand — schont die CPU."
msgid "Invert swipe navigation direction"
msgstr "Wischrichtung für Navigation umkehren"
msgid "Swap the two-finger touchpad swipe: left goes back, right goes forward."
msgstr "Vertauscht den Zwei-Finger-Wisch auf dem Touchpad: links geht zurück, rechts geht vorwärts."
msgid "Local items in Library All"
msgstr "Lokale Elemente in Bibliothek › Alle"
msgid "Favorited items only: what you hearted. Entire local library: every folder and Plex album, artist and track."
msgstr "Nur Favoriten: was du markiert hast. Gesamte lokale Bibliothek: jedes Album, jeder Künstler und jeder Titel aus Ordnern und Plex."
msgid "Favorited items only"
msgstr "Nur Favoriten"
msgid "Entire local library"
msgstr "Gesamte lokale Bibliothek"
msgid "Select multiple albums"
msgstr "Mehrere Alben auswählen"
msgid "Play later"
msgstr "Später abspielen"
msgid "Added to play later"
msgstr "Für später hinzugefügt"
msgid "Next in queue"
msgstr "Als Nächstes in der Warteschlange"
msgid "Next up"
msgstr "Als Nächstes"
msgid "Removed {} tracks from favorites"
msgstr "{} Titel aus Favoriten entfernt"
msgid "Removing {} tracks from favorites…"
msgstr "{} Titel werden aus Favoriten entfernt…"
@@ -609,3 +609,57 @@ msgstr "Artist dismissed from Recommendations"
msgid "{} restored to Recommendations"
msgstr "{} restored to Recommendations"
msgid "LIBRARY › ALL"
msgstr "LIBRARY › ALL"
msgid "Show local files & Plex"
msgstr "Show local files & Plex"
msgid "Include your favorited local files and Plex items in the Library All feed."
msgstr "Include your favorited local files and Plex items in the Library All feed."
msgid "Play indicator animation"
msgstr "Play indicator animation"
msgid "Animate the now-playing row with equalizer bars. Off (default) shows a static pause icon with an accent edge mark — lighter on CPU."
msgstr "Animate the now-playing row with equalizer bars. Off (default) shows a static pause icon with an accent edge mark — lighter on CPU."
msgid "Invert swipe navigation direction"
msgstr "Invert swipe navigation direction"
msgid "Swap the two-finger touchpad swipe: left goes back, right goes forward."
msgstr "Swap the two-finger touchpad swipe: left goes back, right goes forward."
msgid "Local items in Library All"
msgstr "Local items in Library All"
msgid "Favorited items only: what you hearted. Entire local library: every folder and Plex album, artist and track."
msgstr "Favorited items only: what you hearted. Entire local library: every folder and Plex album, artist and track."
msgid "Favorited items only"
msgstr "Favorited items only"
msgid "Entire local library"
msgstr "Entire local library"
msgid "Select multiple albums"
msgstr "Select multiple albums"
msgid "Play later"
msgstr "Play later"
msgid "Added to play later"
msgstr "Added to play later"
msgid "Next in queue"
msgstr "Next in queue"
msgid "Next up"
msgstr "Next up"
msgid "Removed {} tracks from favorites"
msgstr "Removed {} tracks from favorites"
msgid "Removing {} tracks from favorites…"
msgstr "Removing {} tracks from favorites…"
@@ -4843,3 +4843,57 @@ msgstr "Artista descartado de Recomendaciones"
msgid "{} restored to Recommendations"
msgstr "{} restaurado en Recomendaciones"
msgid "LIBRARY › ALL"
msgstr "BIBLIOTECA › TODOS"
msgid "Show local files & Plex"
msgstr "Mostrar archivos locales y Plex"
msgid "Include your favorited local files and Plex items in the Library All feed."
msgstr "Incluye tus archivos locales y elementos de Plex favoritos en el feed Todos de la biblioteca."
msgid "Play indicator animation"
msgstr "Animación del indicador de reproducción"
msgid "Animate the now-playing row with equalizer bars. Off (default) shows a static pause icon with an accent edge mark — lighter on CPU."
msgstr "Anima la fila en reproducción con barras de ecualizador. Apagado (default) muestra un ícono de pausa estático con una marca de acento en el borde — más ligero en CPU."
msgid "Invert swipe navigation direction"
msgstr "Invertir la dirección del gesto de navegación"
msgid "Swap the two-finger touchpad swipe: left goes back, right goes forward."
msgstr "Invierte el deslizamiento con dos dedos del trackpad: izquierda va atrás, derecha va adelante."
msgid "Local items in Library All"
msgstr "Elementos locales en Biblioteca Todo"
msgid "Favorited items only: what you hearted. Entire local library: every folder and Plex album, artist and track."
msgstr "Solo favoritos: lo que marcaste con corazón. Toda la biblioteca local: cada álbum, artista y track de carpetas y Plex."
msgid "Favorited items only"
msgstr "Solo favoritos"
msgid "Entire local library"
msgstr "Toda la biblioteca local"
msgid "Select multiple albums"
msgstr "Seleccionar varios álbumes"
msgid "Play later"
msgstr "Reproducir después"
msgid "Added to play later"
msgstr "Agregado para después"
msgid "Next in queue"
msgstr "Siguiente en la cola"
msgid "Next up"
msgstr "A continuación"
msgid "Removed {} tracks from favorites"
msgstr "Se quitaron {} tracks de favoritos"
msgid "Removing {} tracks from favorites…"
msgstr "Quitando {} tracks de favoritos…"
@@ -4813,3 +4813,57 @@ msgstr "Artiste écarté des Recommandations"
msgid "{} restored to Recommendations"
msgstr "{} rétabli dans Recommandations"
msgid "LIBRARY › ALL"
msgstr "BIBLIOTHÈQUE › TOUT"
msgid "Show local files & Plex"
msgstr "Afficher les fichiers locaux et Plex"
msgid "Include your favorited local files and Plex items in the Library All feed."
msgstr "Inclut vos fichiers locaux et éléments Plex favoris dans le flux Tout de la bibliothèque."
msgid "Play indicator animation"
msgstr "Animation de l'indicateur de lecture"
msgid "Animate the now-playing row with equalizer bars. Off (default) shows a static pause icon with an accent edge mark — lighter on CPU."
msgstr "Anime la ligne en cours de lecture avec des barres d'égaliseur. Désactivée (défaut), une icône de pause statique avec une marque d'accent sur le bord s'affiche — plus léger pour le CPU."
msgid "Invert swipe navigation direction"
msgstr "Inverser la direction du geste de navigation"
msgid "Swap the two-finger touchpad swipe: left goes back, right goes forward."
msgstr "Inverse le balayage à deux doigts du pavé tactile : gauche recule, droite avance."
msgid "Local items in Library All"
msgstr "Éléments locaux dans Bibliothèque › Tout"
msgid "Favorited items only: what you hearted. Entire local library: every folder and Plex album, artist and track."
msgstr "Favoris uniquement : ce que vous avez marqué. Toute la bibliothèque locale : chaque album, artiste et piste des dossiers et de Plex."
msgid "Favorited items only"
msgstr "Favoris uniquement"
msgid "Entire local library"
msgstr "Toute la bibliothèque locale"
msgid "Select multiple albums"
msgstr "Sélectionner plusieurs albums"
msgid "Play later"
msgstr "Lire plus tard"
msgid "Added to play later"
msgstr "Ajouté pour plus tard"
msgid "Next in queue"
msgstr "Suivant dans la file d'attente"
msgid "Next up"
msgstr "À suivre"
msgid "Removed {} tracks from favorites"
msgstr "{} pistes retirées des favoris"
msgid "Removing {} tracks from favorites…"
msgstr "Suppression de {} pistes des favoris…"
@@ -4801,3 +4801,57 @@ msgstr "アーティストをおすすめから除外しました"
msgid "{} restored to Recommendations"
msgstr "{} をおすすめに復元しました"
msgid "LIBRARY › ALL"
msgstr "ライブラリ › すべて"
msgid "Show local files & Plex"
msgstr "ローカルファイルと Plex を表示"
msgid "Include your favorited local files and Plex items in the Library All feed."
msgstr "お気に入りのローカルファイルと Plex のアイテムをライブラリの「すべて」フィードに含めます。"
msgid "Play indicator animation"
msgstr "再生インジケーターのアニメーション"
msgid "Animate the now-playing row with equalizer bars. Off (default) shows a static pause icon with an accent edge mark — lighter on CPU."
msgstr "再生中の行をイコライザーバーでアニメーション表示します。オフ(デフォルト)では静的な一時停止アイコンと行の端にアクセントマークを表示し、CPU負荷を抑えます。"
msgid "Invert swipe navigation direction"
msgstr "スワイプナビゲーションの方向を反転"
msgid "Swap the two-finger touchpad swipe: left goes back, right goes forward."
msgstr "トラックパッドの2本指スワイプを入れ替えます:左で戻る、右で進む。"
msgid "Local items in Library All"
msgstr "ライブラリ「すべて」のローカル項目"
msgid "Favorited items only: what you hearted. Entire local library: every folder and Plex album, artist and track."
msgstr "お気に入りのみ:ハートを付けた項目。ローカルライブラリ全体:フォルダと Plex のすべてのアルバム・アーティスト・トラック。"
msgid "Favorited items only"
msgstr "お気に入りのみ"
msgid "Entire local library"
msgstr "ローカルライブラリ全体"
msgid "Select multiple albums"
msgstr "複数のアルバムを選択"
msgid "Play later"
msgstr "後で再生"
msgid "Added to play later"
msgstr "「後で再生」に追加しました"
msgid "Next in queue"
msgstr "キューの次"
msgid "Next up"
msgstr "次に再生"
msgid "Removed {} tracks from favorites"
msgstr "{} トラックをお気に入りから削除しました"
msgid "Removing {} tracks from favorites…"
msgstr "{} トラックをお気に入りから削除中…"
@@ -4825,3 +4825,57 @@ msgstr "Artiest afgewezen voor Aanbevelingen"
msgid "{} restored to Recommendations"
msgstr "{} hersteld in Aanbevelingen"
msgid "LIBRARY › ALL"
msgstr "BIBLIOTHEEK › ALLES"
msgid "Show local files & Plex"
msgstr "Lokale bestanden en Plex tonen"
msgid "Include your favorited local files and Plex items in the Library All feed."
msgstr "Toon je favoriete lokale bestanden en Plex-items in de Bibliotheek-feed Alles."
msgid "Play indicator animation"
msgstr "Afspeelindicator-animatie"
msgid "Animate the now-playing row with equalizer bars. Off (default) shows a static pause icon with an accent edge mark — lighter on CPU."
msgstr "Animeert de nu-spelende rij met equalizerbalken. Uit (standaard) toont een statisch pauze-icoon met een accentmarkering op de rand — lichter voor de CPU."
msgid "Invert swipe navigation direction"
msgstr "Veegnavigatierichting omkeren"
msgid "Swap the two-finger touchpad swipe: left goes back, right goes forward."
msgstr "Wisselt de tweevingerveeg op het touchpad om: links gaat terug, rechts gaat vooruit."
msgid "Local items in Library All"
msgstr "Lokale items in Bibliotheek › Alles"
msgid "Favorited items only: what you hearted. Entire local library: every folder and Plex album, artist and track."
msgstr "Alleen favorieten: wat je hebt gemarkeerd. Hele lokale bibliotheek: elk album, artiest en nummer uit mappen en Plex."
msgid "Favorited items only"
msgstr "Alleen favorieten"
msgid "Entire local library"
msgstr "Hele lokale bibliotheek"
msgid "Select multiple albums"
msgstr "Meerdere albums selecteren"
msgid "Play later"
msgstr "Later afspelen"
msgid "Added to play later"
msgstr "Toegevoegd voor later"
msgid "Next in queue"
msgstr "Volgende in de wachtrij"
msgid "Next up"
msgstr "Hierna"
msgid "Removed {} tracks from favorites"
msgstr "{} nummers verwijderd uit favorieten"
msgid "Removing {} tracks from favorites…"
msgstr "{} nummers worden verwijderd uit favorieten…"
@@ -4813,3 +4813,57 @@ msgstr "Artista dispensado das Recomendações"
msgid "{} restored to Recommendations"
msgstr "{} restaurado em Recomendações"
msgid "LIBRARY › ALL"
msgstr "BIBLIOTECA › TUDO"
msgid "Show local files & Plex"
msgstr "Mostrar ficheiros locais e Plex"
msgid "Include your favorited local files and Plex items in the Library All feed."
msgstr "Inclui os seus ficheiros locais e itens do Plex favoritos no feed Tudo da biblioteca."
msgid "Play indicator animation"
msgstr "Animação do indicador de reprodução"
msgid "Animate the now-playing row with equalizer bars. Off (default) shows a static pause icon with an accent edge mark — lighter on CPU."
msgstr "Anima a linha em reprodução com barras de equalizador. Desligado (padrão) mostra um ícone de pausa estático com uma marca de destaque na borda — mais leve para a CPU."
msgid "Invert swipe navigation direction"
msgstr "Inverter a direção do gesto de navegação"
msgid "Swap the two-finger touchpad swipe: left goes back, right goes forward."
msgstr "Troca o deslizar com dois dedos no touchpad: esquerda volta, direita avança."
msgid "Local items in Library All"
msgstr "Itens locais na Biblioteca Tudo"
msgid "Favorited items only: what you hearted. Entire local library: every folder and Plex album, artist and track."
msgstr "Somente favoritos: o que você marcou. Toda a biblioteca local: cada álbum, artista e faixa das pastas e do Plex."
msgid "Favorited items only"
msgstr "Somente favoritos"
msgid "Entire local library"
msgstr "Toda a biblioteca local"
msgid "Select multiple albums"
msgstr "Selecionar vários álbuns"
msgid "Play later"
msgstr "Tocar depois"
msgid "Added to play later"
msgstr "Adicionado para depois"
msgid "Next in queue"
msgstr "A seguir na fila"
msgid "Next up"
msgstr "A seguir"
msgid "Removed {} tracks from favorites"
msgstr "{} faixas removidas dos favoritos"
msgid "Removing {} tracks from favorites…"
msgstr "Removendo {} faixas dos favoritos…"
@@ -4827,3 +4827,57 @@ msgstr "Исполнитель отклонён из Рекомендаций"
msgid "{} restored to Recommendations"
msgstr "{} возвращён в Рекомендации"
msgid "LIBRARY › ALL"
msgstr "БИБЛИОТЕКА › ВСЁ"
msgid "Show local files & Plex"
msgstr "Показывать локальные файлы и Plex"
msgid "Include your favorited local files and Plex items in the Library All feed."
msgstr "Показывать избранные локальные файлы и элементы Plex в ленте «Всё» библиотеки."
msgid "Play indicator animation"
msgstr "Анимация индикатора воспроизведения"
msgid "Animate the now-playing row with equalizer bars. Off (default) shows a static pause icon with an accent edge mark — lighter on CPU."
msgstr "Анимирует строку воспроизведения полосами эквалайзера. Выкл. (по умолчанию) — статичный значок паузы с акцентной меткой на краю строки, легче для CPU."
msgid "Invert swipe navigation direction"
msgstr "Инвертировать направление жеста навигации"
msgid "Swap the two-finger touchpad swipe: left goes back, right goes forward."
msgstr "Меняет местами свайп двумя пальцами по тачпаду: влево — назад, вправо — вперёд."
msgid "Local items in Library All"
msgstr "Локальные элементы в «Всё» библиотеки"
msgid "Favorited items only: what you hearted. Entire local library: every folder and Plex album, artist and track."
msgstr "Только избранное: отмеченное вами. Вся локальная библиотека: каждый альбом, исполнитель и трек из папок и Plex."
msgid "Favorited items only"
msgstr "Только избранное"
msgid "Entire local library"
msgstr "Вся локальная библиотека"
msgid "Select multiple albums"
msgstr "Выбрать несколько альбомов"
msgid "Play later"
msgstr "Слушать позже"
msgid "Added to play later"
msgstr "Добавлено «на позже»"
msgid "Next in queue"
msgstr "Далее в очереди"
msgid "Next up"
msgstr "Далее"
msgid "Removed {} tracks from favorites"
msgstr "{} треков удалено из избранного"
msgid "Removing {} tracks from favorites…"
msgstr "Удаление {} треков из избранного…"
+2 -1
View File
@@ -793,7 +793,8 @@ export component AlbumPageView inherits Rectangle {
selected-count: AlbumState.selected-count;
actions: [
{ id: "select-all", label: @tr("Select all"), icon: @image-url("../assets/icons/square-check-big.svg"), danger: false, needs-selection: false },
{ id: "play-next", label: @tr("Play next"), icon: @image-url("../assets/icons/list-plus.svg"), danger: false, needs-selection: true },
{ id: "play-next", label: @tr("Play next"), icon: @image-url("../assets/icons/list-start.svg"), danger: false, needs-selection: true },
{ id: "play-later", label: @tr("Play later"), icon: @image-url("../assets/icons/list-plus.svg"), danger: false, needs-selection: true },
{ id: "queue", label: @tr("Add to queue"), icon: @image-url("../assets/icons/list-end.svg"), danger: false, needs-selection: true },
{ id: "add-to-playlist", label: @tr("Add to playlist"), icon: @image-url("../assets/icons/list-music.svg"), danger: false, needs-selection: true },
{ id: "add-to-favorites", label: @tr("Add to Library"), icon: @image-url("../assets/icons/heart.svg"), danger: false, needs-selection: true },
+3 -2
View File
@@ -774,7 +774,7 @@ export component ArtistPageView inherits Rectangle {
close-policy: close-on-click;
ContextMenu {
ContextMenuItem {
icon: @image-url("../assets/icons/list-plus.svg");
icon: @image-url("../assets/icons/list-start.svg");
label: @tr("Play all next");
clicked => {
ArtistActions.top-tracks-menu-action("next-all");
@@ -816,7 +816,8 @@ export component ArtistPageView inherits Rectangle {
selected-count: ArtistState.top-tracks-selected-count;
actions: [
{ id: "select-all", label: @tr("Select all"), icon: @image-url("../assets/icons/square-check-big.svg"), danger: false, needs-selection: false },
{ id: "play-next", label: @tr("Play next"), icon: @image-url("../assets/icons/list-plus.svg"), danger: false, needs-selection: true },
{ id: "play-next", label: @tr("Play next"), icon: @image-url("../assets/icons/list-start.svg"), danger: false, needs-selection: true },
{ id: "play-later", label: @tr("Play later"), icon: @image-url("../assets/icons/list-plus.svg"), danger: false, needs-selection: true },
{ id: "queue", label: @tr("Add to queue"), icon: @image-url("../assets/icons/list-end.svg"), danger: false, needs-selection: true },
{ id: "add-to-playlist", label: @tr("Add to playlist"), icon: @image-url("../assets/icons/list-music.svg"), danger: false, needs-selection: true },
{ id: "add-to-mixtape", label: @tr("Add to Mixtape/Collection"), icon: @image-url("../assets/icons/cassette-tape.svg"), danger: false, needs-selection: true },
@@ -0,0 +1,14 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M12.409 5.824c-.702.792-1.15 1.496-1.415 2.166l2.153 2.156a.5.5 0 0 1 0 .707l-2.293 2.293a.5.5 0 0 0 0 .707L12 15" />
<path d="M13.508 20.313a2 2 0 0 1-3 .019L5 15c-1.5-1.5-3-3.2-3-5.5a5.5 5.5 0 0 1 9.591-3.677.6.6 0 0 0 .818.001A5.5 5.5 0 0 1 22 9.5c0 2.29-1.5 4-3 5.5z" />
</svg>

After

Width:  |  Height:  |  Size: 496 B

@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#ffffff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 5H11" />
<path d="M21 12H11" />
<path d="M21 19H11" />
<path d="m3 8 4 4-4 4" />
</svg>

After

Width:  |  Height:  |  Size: 286 B

@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#ffffff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M3 5h6" />
<path d="M3 12h13" />
<path d="M3 19h13" />
<path d="m16 8-3-3 3-3" />
<path d="M21 19V7a2 2 0 0 0-2-2h-6" />
</svg>

After

Width:  |  Height:  |  Size: 324 B

+10 -3
View File
@@ -382,8 +382,8 @@ export component AlbumCard inherits Rectangle {
x: more-btn.absolute-position.x + more-btn.width / 2 - artwork-box.absolute-position.x;
y: more-btn.absolute-position.y + more-btn.height / 2 - artwork-box.absolute-position.y;
width: 196px;
// 6 items max (Open/Play/Play-next/Queue + favorite + block).
height: 6 * 33px + 10px;
// 7 items max (Open/Play/Play-next/Play-later/Queue + favorite + block).
height: 7 * 33px + 10px;
ContextMenu {
ContextMenuItem {
icon: @image-url("../assets/icons/library-big.svg");
@@ -400,12 +400,19 @@ export component AlbumCard inherits Rectangle {
}
}
ContextMenuItem {
icon: @image-url("../assets/icons/list-plus.svg");
icon: @image-url("../assets/icons/list-start.svg");
label: @tr("Play next");
clicked => {
root.media-action("album", root.album.id, "play-next");
}
}
ContextMenuItem {
icon: @image-url("../assets/icons/list-plus.svg");
label: @tr("Play later");
clicked => {
root.media-action("album", root.album.id, "play-later");
}
}
ContextMenuItem {
icon: @image-url("../assets/icons/list-end.svg");
label: @tr("Add to queue");
+11 -4
View File
@@ -49,7 +49,7 @@ component OverlayButton inherits Rectangle {
export component PlaylistCard inherits Rectangle {
in property <SearchPlaylistItem> playlist;
// kind ("playlist"), playlist id, action ("play" | "play-next" |
// "queue" | "favorite" | "open").
// "play-later" | "queue" | "favorite" | "open").
callback media-action(string, string, string);
// When true, a click on the card BODY opens the playlist ("open")
// instead of playing it — the Favorites grid wants body=open, the play
@@ -188,8 +188,8 @@ export component PlaylistCard inherits Rectangle {
// + 33px/row) so the panel never clips or over-reserves.
playlist-menu := PopupWindow {
property <int> item-count: root.playlist.is-owned
? 4
: (root.playlist.is-copied ? 4 : 5);
? 5
: (root.playlist.is-copied ? 5 : 6);
x: 26px;
y: 56px;
width: 196px;
@@ -203,12 +203,19 @@ export component PlaylistCard inherits Rectangle {
}
}
ContextMenuItem {
icon: @image-url("../assets/icons/list-plus.svg");
icon: @image-url("../assets/icons/list-start.svg");
label: @tr("Play next");
clicked => {
root.media-action("playlist", root.playlist.id, "play-next");
}
}
ContextMenuItem {
icon: @image-url("../assets/icons/list-plus.svg");
label: @tr("Play later");
clicked => {
root.media-action("playlist", root.playlist.id, "play-later");
}
}
ContextMenuItem {
icon: @image-url("../assets/icons/list-end.svg");
label: @tr("Add to queue");
+11 -4
View File
@@ -79,7 +79,7 @@ export component TrackCard inherits Rectangle {
animate opacity { duration: 250ms; }
callback clicked(string);
// kind ("track"), track id, action ("play" | "queue" | "play-next" |
// "favorite" | "open").
// "play-later" | "favorite" | "open").
callback media-action(string, string, string);
in property <length> card-height: 246px;
@@ -202,8 +202,8 @@ export component TrackCard inherits Rectangle {
x: 26px;
y: 56px;
width: 196px;
// Play / Play-next / Queue / Go-to-artist / Go-to-album / favorite.
height: 6 * 33px + 10px;
// Play / Play-next / Play-later / Queue / Go-to-artist / Go-to-album / favorite.
height: 7 * 33px + 10px;
ContextMenu {
ContextMenuItem {
icon: @image-url("../assets/icons/play.svg");
@@ -213,12 +213,19 @@ export component TrackCard inherits Rectangle {
}
}
ContextMenuItem {
icon: @image-url("../assets/icons/list-plus.svg");
icon: @image-url("../assets/icons/list-start.svg");
label: @tr("Play next");
clicked => {
root.media-action("track", root.track.id, "play-next");
}
}
ContextMenuItem {
icon: @image-url("../assets/icons/list-plus.svg");
label: @tr("Play later");
clicked => {
root.media-action("track", root.track.id, "play-later");
}
}
ContextMenuItem {
icon: @image-url("../assets/icons/list-end.svg");
label: @tr("Add to queue");
+504 -310
View File
@@ -68,6 +68,104 @@ component FavTabMenu inherits SegmentedTabBar {
selected(id) => { FavoritesActions.select-tab(id); }
}
// ---------------------------------------------------------------------------
// Viewport windowing engine for the Library "All" grid + list — POST-LAYOUT
// SNAPSHOT SAMPLING, the SAME class-kill as AlbumGrid's (see the long comment
// in AlbumCollectionView.slint; the "Recursion detected" panic class).
//
// Rules copied from there, do not break them:
// - `visible-top` / `visible-height` / `content-offset` are POLLED, never
// observed: read ONLY from the sampler Timer below. Wiring them to live
// layout expressions (-flick.viewport-y / flick.height) is safe BECAUSE
// nothing here evaluates them from a binding or `changed` handler.
// - The band state is PLAIN properties written ONLY by the Timer. No
// `changed` handlers, no init-time reads of the inputs.
// Consumers gate each slot on `first-item`/`last-item` (plain, Timer-written)
// and report through `window-changed` so Rust dispatches covers for the band.
// ---------------------------------------------------------------------------
component ViewportWindow inherits Rectangle {
in property <length> visible-top: 0px;
in property <length> visible-height: 0px;
// This content's approximate y inside the Flickable's CONTENT coordinates;
// small errors are absorbed by the one-viewport margin.
in property <length> content-offset: 0px;
in property <length> row-stride: 1px;
in property <int> columns: 1;
callback window-changed(int /* first item index */, int /* last item index */);
// Snapshot band — plain values, sampler-written. The pre-seed [0, 3]
// keeps roughly the first viewport of slots mounted for the <=80ms
// between mount and the first tick (placeholder tiles cover the rest).
property <int> target-first-row: 0;
property <int> target-last-row: 3;
// Mounted band = target band + contract hysteresis. Expansion follows the
// target immediately; contraction lags by `hysteresis-rows` so a few-px
// oscillation across a row boundary cannot mount/unmount a whole row on
// every direction change.
property <int> hysteresis-rows: 1;
property <int> first-window-row: 0;
property <int> last-window-row: 3;
// Item-index form of the mounted band — the slot gates read these.
out property <int> first-item: 0;
out property <int> last-item: 3 * Math.max(1, root.columns) - 1;
// Last column count reported through window-changed (columns shift on
// resize, which re-keys the band's ITEM indices).
property <int> notified-columns: 0;
pure function desired-first-row() -> int {
if (root.target-first-row < root.first-window-row) {
return root.target-first-row;
}
if (root.target-first-row > root.first-window-row + root.hysteresis-rows) {
return root.target-first-row - root.hysteresis-rows;
}
return root.first-window-row;
}
pure function desired-last-row() -> int {
if (root.target-last-row > root.last-window-row) {
return root.target-last-row;
}
if (root.target-last-row < root.last-window-row - root.hysteresis-rows) {
return root.target-last-row + root.hysteresis-rows;
}
return root.last-window-row;
}
function apply-window() {
if (root.desired-first-row() != root.first-window-row
|| root.desired-last-row() != root.last-window-row) {
root.first-window-row = root.desired-first-row();
root.last-window-row = root.desired-last-row();
root.publish();
}
}
// The sampler: reads the live inputs (safe here — event-loop context),
// recomputes the target band, applies hysteresis, and notifies on change.
// ~12.5 Hz is enough: the band carries a full-viewport margin, and the
// placeholder tiles are the designed fast-scroll fallback.
function sample-window() {
root.target-first-row = Math.max(0, Math.floor(
((root.visible-top - root.content-offset) - root.visible-height)
/ root.row-stride));
root.target-last-row = Math.max(0, Math.floor(
((root.visible-top - root.content-offset) + 2 * root.visible-height)
/ root.row-stride));
root.apply-window();
if (root.columns != root.notified-columns) {
root.notified-columns = root.columns;
root.publish();
}
}
Timer {
interval: 80ms;
running: true;
triggered => { root.sample-window(); }
}
function publish() {
root.first-item = root.first-window-row * Math.max(1, root.columns);
root.last-item = (root.last-window-row + 1) * Math.max(1, root.columns) - 1;
root.window-changed(root.first-item, root.last-item);
}
}
// One underline sub-tab for the Playlists tab (Library / Following).
// Underline style, NOT a pill (ADR-008). Mirrors the FavTab pattern.
component PlaylistSubTab inherits Rectangle {
@@ -411,6 +509,18 @@ export component FavoritesView inherits Rectangle {
clicked => { FavoritesActions.albums-shuffle(); }
}
}
// Multi-select — LIST mode only (owner 2026-07-24): bulk
// un-favorite / play-next / queue on the checked rows.
if FavoritesState.albums-view-mode == "list": VerticalLayout {
alignment: center;
CircleAction {
icon: @image-url("../assets/icons/square-check-big.svg");
active: FavoritesState.albums-multi-select;
on-surface: true;
tooltip: @tr("Select multiple albums");
clicked => { FavoritesActions.albums-toggle-multi-select(); }
}
}
}
if FavoritesState.active-tab == "artists"
&& FavoritesState.artists-visible.length > 0: HorizontalLayout {
@@ -693,9 +803,15 @@ export component FavoritesView inherits Rectangle {
sm: true;
query <=> LibraryAllState.search;
edited(q) => {
LibraryAllActions.search(q);
all-search-debounce.restart();
}
}
// Derive once per typing pause, not per keystroke — with
// thousands of items each derive swaps the visible model.
all-search-debounce := Timer {
interval: 250ms;
triggered => { LibraryAllActions.search(LibraryAllState.search); }
}
}
// Source switches — Purchases / Favorites / Following. Tooltips
// spell out each cryptic icon (owner request).
@@ -729,7 +845,9 @@ export component FavoritesView inherits Rectangle {
clicked => { LibraryAllActions.toggle-source("following"); }
}
}
// Show local files + Plex (local-favorites layer).
// Show local files + Plex (filter lives IN the view; the
// Settings row only picks the SCOPE: favorites vs whole
// local library).
VerticalLayout {
alignment: center;
ToggleButton {
@@ -932,10 +1050,12 @@ export component FavoritesView inherits Rectangle {
}
}
}
// Grid — non-windowed flow (columns from width), one card per
// item keyed off `kind`, reusing the existing card components.
// Grid — WINDOWED flow (columns from width), one card-slot per
// item keyed off `kind`. Only slots near the viewport mount a
// real card; the rest are same-footprint placeholder tiles
// (ViewportWindow engine — the AlbumGrid class-kill pattern).
if !LibraryAllState.loading && LibraryAllState.load-error == ""
&& LibraryAllState.view-mode != "list": Flickable {
&& LibraryAllState.view-mode != "list": all-grid-flick := Flickable {
viewport-height: all-grid.height + 32px;
all-grid := Rectangle {
property <length> cw: 200px;
@@ -950,13 +1070,30 @@ export component FavoritesView inherits Rectangle {
width: parent.width - 64px;
height: self.rows > 0 ? self.rows * (self.ch + self.gap) : 0px;
for item[i] in LibraryAllState.items-visible: Rectangle {
all-grid-win := ViewportWindow {
visible-top: -all-grid-flick.viewport-y;
visible-height: all-grid-flick.height;
content-offset: 16px;
row-stride: all-grid.ch + all-grid.gap;
columns: all-grid.columns;
window-changed(first, last) => {
LibraryAllActions.window-changed(first, last);
}
}
for item[i] in LibraryAllState.items-visible: card-slot := Rectangle {
x: Math.mod(i, all-grid.columns) * (all-grid.cw + all-grid.gap);
y: Math.floor(i / all-grid.columns) * (all-grid.ch + all-grid.gap);
width: all-grid.cw;
height: all-grid.ch;
property <bool> in-window: i >= all-grid-win.first-item
&& i <= all-grid-win.last-item;
// Placeholder tile while the real card is unmounted
// (fast scroll).
border-radius: 8px;
background: card-slot.in-window ? transparent : Theme.surface-elevated;
if item.kind == "track": TrackCard {
if card-slot.in-window && item.kind == "track": TrackCard {
show-source-badge: LibraryAllState.show-local;
track: {
id: item.id,
@@ -981,7 +1118,7 @@ export component FavoritesView inherits Rectangle {
}
}
}
if item.kind == "album": AlbumCard {
if card-slot.in-window && item.kind == "album": AlbumCard {
show-source-badge: LibraryAllState.show-local;
album: {
id: item.id,
@@ -992,6 +1129,7 @@ export component FavoritesView inherits Rectangle {
quality-tier: item.quality-tier,
quality-label: item.quality-detail,
source: item.source,
is-favorite: item.is-favorite,
};
clicked(id) => { FavoritesActions.open-album(id); }
media-action(kind, id, action) => {
@@ -1002,16 +1140,21 @@ export component FavoritesView inherits Rectangle {
}
}
}
if item.kind == "artist": ArtistGridCard {
if card-slot.in-window && item.kind == "artist": ArtistGridCard {
artist: {
id: item.id,
title: item.title,
artwork: item.image,
following: item.is-favorite,
};
follow-mode: "none";
clicked(id) => { FavoritesActions.open-artist(id); }
media-action(kind, id, action) => {
if (action == "follow") {
FavoritesActions.artist-action(id, action);
}
}
}
if item.kind == "playlist": PlaylistCard {
if card-slot.in-window && item.kind == "playlist": PlaylistCard {
playlist: {
id: item.id,
title: item.title,
@@ -1032,7 +1175,7 @@ export component FavoritesView inherits Rectangle {
}
}
}
if item.kind == "label": LabelCard {
if card-slot.in-window && item.kind == "label": LabelCard {
label: {
id: item.id,
name: item.title,
@@ -1044,10 +1187,23 @@ export component FavoritesView inherits Rectangle {
}
}
}
// List view — compact mixed rows (cover + title + subtitle).
// List view — WINDOWED compact mixed rows (cover + title +
// subtitle). Only rows near the viewport mount their content
// (cells + context menu); off-band rows are same-footprint
// placeholder tiles (ViewportWindow engine, AlbumGrid pattern).
if !LibraryAllState.loading && LibraryAllState.load-error == ""
&& LibraryAllState.view-mode == "list": Flickable {
&& LibraryAllState.view-mode == "list": all-list-flick := Flickable {
viewport-height: all-list.preferred-height + 32px;
all-list-win := ViewportWindow {
visible-top: -all-list-flick.viewport-y;
visible-height: all-list-flick.height;
content-offset: 16px;
row-stride: 58px; // 56px row + 2px spacing
columns: 1;
window-changed(first, last) => {
LibraryAllActions.window-changed(first, last);
}
}
all-list := VerticalLayout {
x: 32px;
y: 16px;
@@ -1055,9 +1211,12 @@ export component FavoritesView inherits Rectangle {
spacing: 2px;
for item[i] in LibraryAllState.items-visible: row-rect := Rectangle {
height: 56px;
property <bool> in-window: i >= all-list-win.first-item
&& i <= all-list-win.last-item;
border-radius: 6px;
background: row-ta.has-hover || menu-btn-ta.has-hover
? Theme.surface-hover : transparent;
// Placeholder tile while the row content is
// unmounted (fast scroll).
background: row-rect.in-window ? transparent : Theme.surface-elevated;
// Context-menu opener position (row-local px). Defaults
// by the ⋯ button; a right-click anywhere on the row
// overrides it to the cursor (window pointer minus the
@@ -1065,315 +1224,329 @@ export component FavoritesView inherits Rectangle {
// the right-click handler so the whole row is sensitive.
property <length> menu-x: row-rect.width - 206px;
property <length> menu-y: 48px;
// Row body — click plays/opens by kind; right-click opens
// the menu at the cursor. Behind the cell TouchAreas.
row-ta := TouchArea {
mouse-cursor: pointer;
clicked => {
if (item.kind == "track") {
FavoritesActions.track-action(item.id, "play");
} else if (item.kind == "album") {
FavoritesActions.open-album(item.id);
} else if (item.kind == "artist") {
FavoritesActions.open-artist(item.id);
} else if (item.kind == "playlist") {
FavoritesActions.open-playlist(item.id);
} else if (item.kind == "label") {
FavoritesActions.open-label(item.id, item.title);
}
}
pointer-event(e) => {
if (e.kind == PointerEventKind.down
&& e.button == PointerEventButton.right) {
row-rect.menu-x = ShellState.pointer-x - row-rect.absolute-position.x;
row-rect.menu-y = ShellState.pointer-y - row-rect.absolute-position.y;
row-menu.show();
}
}
}
HorizontalLayout {
padding-left: 12px;
padding-right: 12px;
spacing: 12px;
// Col — artwork (round for artists) + hover play.
VerticalLayout {
alignment: center;
Rectangle {
width: 44px;
height: 44px;
border-radius: item.kind == "artist" ? 22px : 6px;
background: Theme.surface-elevated;
clip: true;
Image {
width: 100%;
height: 100%;
source: item.image;
image-fit: item.kind == "label" ? contain : cover;
if row-rect.in-window: Rectangle {
border-radius: 6px;
background: row-ta.has-hover || menu-btn-ta.has-hover
? Theme.surface-hover : transparent;
// Row body — click plays/opens by kind; right-click
// opens the menu at the cursor. Behind the cells.
row-ta := TouchArea {
mouse-cursor: pointer;
clicked => {
if (item.kind == "track") {
FavoritesActions.track-action(item.id, "play");
} else if (item.kind == "album") {
FavoritesActions.open-album(item.id);
} else if (item.kind == "artist") {
FavoritesActions.open-artist(item.id);
} else if (item.kind == "playlist") {
FavoritesActions.open-playlist(item.id);
} else if (item.kind == "label") {
FavoritesActions.open-label(item.id, item.title);
}
if item.kind == "track" || item.kind == "album" || item.kind == "playlist": Rectangle {
width: 100%;
height: 100%;
background: #000000a6;
opacity: play-ta.has-hover ? 1.0 : 0.0;
animate opacity { duration: 150ms; }
QbzIcon {
source: @image-url("../assets/icons/play-fill.svg");
width: 16px;
height: 16px;
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
tint: #ffffff;
}
pointer-event(e) => {
if (e.kind == PointerEventKind.down
&& e.button == PointerEventButton.right) {
row-rect.menu-x = ShellState.pointer-x - row-rect.absolute-position.x;
row-rect.menu-y = ShellState.pointer-y - row-rect.absolute-position.y;
row-menu.show();
}
}
}
HorizontalLayout {
padding-left: 12px;
padding-right: 12px;
spacing: 12px;
// Col — artwork (round for artists) + hover play.
VerticalLayout {
alignment: center;
Rectangle {
width: 44px;
height: 44px;
border-radius: item.kind == "artist" ? 22px : 6px;
background: Theme.surface-elevated;
clip: true;
Image {
width: 100%;
height: 100%;
source: item.image;
image-fit: item.kind == "label" ? contain : cover;
}
play-ta := TouchArea {
mouse-cursor: pointer;
clicked => {
if (item.kind == "track") { FavoritesActions.track-action(item.id, "play"); }
else if (item.kind == "album") { FavoritesActions.album-action(item.id, "play"); }
else { FavoritesActions.playlist-action(item.id, "play"); }
if item.kind == "track" || item.kind == "album" || item.kind == "playlist": Rectangle {
width: 100%;
height: 100%;
background: #000000a6;
opacity: play-ta.has-hover ? 1.0 : 0.0;
animate opacity { duration: 150ms; }
QbzIcon {
source: @image-url("../assets/icons/play-fill.svg");
width: 16px;
height: 16px;
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
tint: #ffffff;
}
pointer-event(e) => {
if (e.kind == PointerEventKind.down && e.button == PointerEventButton.right) {
row-rect.menu-x = ShellState.pointer-x - row-rect.absolute-position.x;
row-rect.menu-y = ShellState.pointer-y - row-rect.absolute-position.y;
row-menu.show();
play-ta := TouchArea {
mouse-cursor: pointer;
clicked => {
if (item.kind == "track") { FavoritesActions.track-action(item.id, "play"); }
else if (item.kind == "album") { FavoritesActions.album-action(item.id, "play"); }
else { FavoritesActions.playlist-action(item.id, "play"); }
}
pointer-event(e) => {
if (e.kind == PointerEventKind.down && e.button == PointerEventButton.right) {
row-rect.menu-x = ShellState.pointer-x - row-rect.absolute-position.x;
row-rect.menu-y = ShellState.pointer-y - row-rect.absolute-position.y;
row-menu.show();
}
}
}
}
}
}
}
// Col — title + subtitle (artist link for track/album).
VerticalLayout {
alignment: center;
horizontal-stretch: 1;
spacing: 2px;
title-ta := TouchArea {
height: 18px;
mouse-cursor: pointer;
clicked => {
if (item.kind == "track") { FavoritesActions.track-action(item.id, "play"); }
else if (item.kind == "album") { FavoritesActions.open-album(item.id); }
else if (item.kind == "artist") { FavoritesActions.open-artist(item.id); }
else if (item.kind == "playlist") { FavoritesActions.open-playlist(item.id); }
else { FavoritesActions.open-label(item.id, item.title); }
}
pointer-event(e) => {
if (e.kind == PointerEventKind.down && e.button == PointerEventButton.right) {
row-rect.menu-x = ShellState.pointer-x - row-rect.absolute-position.x;
row-rect.menu-y = ShellState.pointer-y - row-rect.absolute-position.y;
row-menu.show();
}
}
Text {
width: 100%;
text: item.title;
color: title-ta.has-hover ? Theme.accent : Theme.text-primary;
font-size: 14px;
font-weight: Typography.medium;
overflow: elide;
vertical-alignment: center;
}
}
if item.subtitle != "": sub-ta := TouchArea {
height: 16px;
mouse-cursor: (item.artist-id != "" && (item.kind == "track" || item.kind == "album")) ? pointer : default;
clicked => {
if (item.artist-id != "" && (item.kind == "track" || item.kind == "album")) {
FavoritesActions.open-artist(item.artist-id);
}
}
pointer-event(e) => {
if (e.kind == PointerEventKind.down && e.button == PointerEventButton.right) {
row-rect.menu-x = ShellState.pointer-x - row-rect.absolute-position.x;
row-rect.menu-y = ShellState.pointer-y - row-rect.absolute-position.y;
row-menu.show();
}
}
Text {
width: 100%;
text: item.subtitle;
color: (item.artist-id != "" && (item.kind == "track" || item.kind == "album") && sub-ta.has-hover)
? Theme.accent : Theme.text-muted;
font-size: 12px;
overflow: elide;
vertical-alignment: center;
}
}
}
// Col — type (icon + label).
Rectangle {
width: 116px;
HorizontalLayout {
spacing: 6px;
alignment: start;
VerticalLayout {
alignment: center;
QbzIcon {
source: item.kind == "track" ? @image-url("../assets/icons/music.svg")
: item.kind == "playlist" ? @image-url("../assets/icons/list-music.svg")
: item.kind == "artist" ? @image-url("../assets/icons/user.svg")
: item.kind == "label" ? @image-url("../assets/icons/disc-3.svg")
: @image-url("../assets/icons/disc.svg");
width: 13px;
height: 13px;
tint: Theme.text-muted;
}
}
Text {
text: item.kind == "track" ? @tr("Track")
: item.kind == "album" ? @tr("Album")
: item.kind == "artist" ? @tr("Artist")
: item.kind == "playlist" ? @tr("Playlist")
: @tr("Label");
color: Theme.text-muted;
font-size: 10px;
font-weight: Typography.semibold;
letter-spacing: 1.2px;
vertical-alignment: center;
}
}
}
// Col — source glyph. Only when local/Plex can
// appear (show-local on); otherwise the whole feed
// is Qobuz, so the column is hidden rather than
// painting the Qobuz mark on every row.
if LibraryAllState.show-local: Rectangle {
width: 44px;
SourceGlyph {
kind: item.source;
x: 0;
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
}
}
// Col — quality (albums + tracks; the row badge).
Rectangle {
width: 150px;
if (item.kind == "album" || item.kind == "track") && item.quality-tier != "": QualityBadgeFull {
tier: item.quality-tier;
detail: item.quality-detail;
x: 0;
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
}
}
// Col — favorite (heart) / follow (person) indicator.
VerticalLayout {
alignment: center;
QbzIcon {
source: item.kind == "artist"
? @image-url("../assets/icons/user-plus.svg")
: (item.is-favorite
? @image-url("../assets/icons/heart-filled.svg")
: @image-url("../assets/icons/heart.svg"));
width: 18px;
height: 18px;
tint: Theme.accent;
}
}
// Col — context-menu button.
VerticalLayout {
alignment: center;
menu-btn := Rectangle {
width: 30px;
height: 30px;
border-radius: 6px;
background: menu-btn-ta.has-hover ? Theme.surface-elevated : transparent;
QbzIcon {
source: @image-url("../assets/icons/ellipsis.svg");
width: 16px;
height: 16px;
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
tint: Theme.text-secondary;
}
menu-btn-ta := TouchArea {
// Col — title + subtitle (artist link for track/album).
VerticalLayout {
alignment: center;
horizontal-stretch: 1;
spacing: 2px;
title-ta := TouchArea {
height: 18px;
mouse-cursor: pointer;
clicked => {
// The ⋯ opener: anchor the menu by
// the button (right-click uses the
// cursor instead).
row-rect.menu-x = row-rect.width - 206px;
row-rect.menu-y = 48px;
row-menu.show();
if (item.kind == "track") { FavoritesActions.track-action(item.id, "play"); }
else if (item.kind == "album") { FavoritesActions.open-album(item.id); }
else if (item.kind == "artist") { FavoritesActions.open-artist(item.id); }
else if (item.kind == "playlist") { FavoritesActions.open-playlist(item.id); }
else { FavoritesActions.open-label(item.id, item.title); }
}
pointer-event(e) => {
if (e.kind == PointerEventKind.down && e.button == PointerEventButton.right) {
row-rect.menu-x = ShellState.pointer-x - row-rect.absolute-position.x;
row-rect.menu-y = ShellState.pointer-y - row-rect.absolute-position.y;
row-menu.show();
}
}
Text {
width: 100%;
text: item.title;
color: title-ta.has-hover ? Theme.accent : Theme.text-primary;
font-size: 14px;
font-weight: Typography.medium;
overflow: elide;
vertical-alignment: center;
}
}
if item.subtitle != "": sub-ta := TouchArea {
height: 16px;
mouse-cursor: (item.artist-id != "" && (item.kind == "track" || item.kind == "album")) ? pointer : default;
clicked => {
if (item.artist-id != "" && (item.kind == "track" || item.kind == "album")) {
FavoritesActions.open-artist(item.artist-id);
}
}
pointer-event(e) => {
if (e.kind == PointerEventKind.down && e.button == PointerEventButton.right) {
row-rect.menu-x = ShellState.pointer-x - row-rect.absolute-position.x;
row-rect.menu-y = ShellState.pointer-y - row-rect.absolute-position.y;
row-menu.show();
}
}
Text {
width: 100%;
text: item.subtitle;
color: (item.artist-id != "" && (item.kind == "track" || item.kind == "album") && sub-ta.has-hover)
? Theme.accent : Theme.text-muted;
font-size: 12px;
overflow: elide;
vertical-alignment: center;
}
}
}
// Col — type (icon + label).
Rectangle {
width: 116px;
HorizontalLayout {
spacing: 6px;
alignment: start;
VerticalLayout {
alignment: center;
QbzIcon {
source: item.kind == "track" ? @image-url("../assets/icons/music.svg")
: item.kind == "playlist" ? @image-url("../assets/icons/list-music.svg")
: item.kind == "artist" ? @image-url("../assets/icons/user.svg")
: item.kind == "label" ? @image-url("../assets/icons/disc-3.svg")
: @image-url("../assets/icons/disc.svg");
width: 13px;
height: 13px;
tint: Theme.text-muted;
}
}
Text {
text: item.kind == "track" ? @tr("Track")
: item.kind == "album" ? @tr("Album")
: item.kind == "artist" ? @tr("Artist")
: item.kind == "playlist" ? @tr("Playlist")
: @tr("Label");
color: Theme.text-muted;
font-size: 10px;
font-weight: Typography.semibold;
letter-spacing: 1.2px;
vertical-alignment: center;
}
}
}
// Col — source glyph. Only when local/Plex can
// appear (show-local on); otherwise the whole feed
// is Qobuz, so the column is hidden rather than
// painting the Qobuz mark on every row.
if LibraryAllState.show-local: Rectangle {
width: 44px;
SourceGlyph {
kind: item.source;
x: 0;
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
}
}
// Col — quality (albums + tracks; the row badge).
Rectangle {
width: 150px;
if (item.kind == "album" || item.kind == "track") && item.quality-tier != "": QualityBadgeFull {
tier: item.quality-tier;
detail: item.quality-detail;
x: 0;
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
}
}
// Col — favorite (heart) / follow (person) indicator.
VerticalLayout {
alignment: center;
QbzIcon {
source: item.kind == "artist"
? @image-url("../assets/icons/user-plus.svg")
: (item.is-favorite
? @image-url("../assets/icons/heart-filled.svg")
: @image-url("../assets/icons/heart.svg"));
width: 18px;
height: 18px;
tint: Theme.accent;
}
}
// Col — context-menu button.
VerticalLayout {
alignment: center;
menu-btn := Rectangle {
width: 30px;
height: 30px;
border-radius: 6px;
background: menu-btn-ta.has-hover ? Theme.surface-elevated : transparent;
QbzIcon {
source: @image-url("../assets/icons/ellipsis.svg");
width: 16px;
height: 16px;
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
tint: Theme.text-secondary;
}
menu-btn-ta := TouchArea {
mouse-cursor: pointer;
clicked => {
// The ⋯ opener: anchor the menu by
// the button (right-click uses the
// cursor instead).
row-rect.menu-x = row-rect.width - 206px;
row-rect.menu-y = 48px;
row-menu.show();
}
}
}
}
}
}
// Per-kind context menu (⋯ button + right-click).
row-menu := PopupWindow {
x: row-rect.menu-x;
y: row-rect.menu-y;
width: 196px;
height: (item.kind == "track" ? 6
: item.kind == "album" ? 6
: item.kind == "playlist" ? 4
: 1) * 33px + 10px;
ContextMenu {
if item.kind == "track" || item.kind == "album" || item.kind == "playlist": ContextMenuItem {
icon: @image-url("../assets/icons/play.svg");
label: @tr("Play");
clicked => {
if (item.kind == "track") { FavoritesActions.track-action(item.id, "play"); }
else if (item.kind == "album") { FavoritesActions.album-action(item.id, "play"); }
else { FavoritesActions.playlist-action(item.id, "play"); }
// Per-kind context menu (⋯ button + right-click).
row-menu := PopupWindow {
x: row-rect.menu-x;
y: row-rect.menu-y;
width: 196px;
height: (item.kind == "track" ? 7
: item.kind == "album" ? 7
: item.kind == "playlist" ? 5
: 1) * 33px + 10px;
ContextMenu {
if item.kind == "track" || item.kind == "album" || item.kind == "playlist": ContextMenuItem {
icon: @image-url("../assets/icons/play.svg");
label: @tr("Play");
clicked => {
if (item.kind == "track") { FavoritesActions.track-action(item.id, "play"); }
else if (item.kind == "album") { FavoritesActions.album-action(item.id, "play"); }
else { FavoritesActions.playlist-action(item.id, "play"); }
}
}
}
if item.kind == "track" || item.kind == "album" || item.kind == "playlist": ContextMenuItem {
icon: @image-url("../assets/icons/list-plus.svg");
label: @tr("Play next");
clicked => {
if (item.kind == "track") { FavoritesActions.track-action(item.id, "play-next"); }
else if (item.kind == "album") { FavoritesActions.album-action(item.id, "play-next"); }
else { FavoritesActions.playlist-action(item.id, "play-next"); }
if item.kind == "track" || item.kind == "album" || item.kind == "playlist": ContextMenuItem {
icon: @image-url("../assets/icons/list-start.svg");
label: @tr("Play next");
clicked => {
if (item.kind == "track") { FavoritesActions.track-action(item.id, "play-next"); }
else if (item.kind == "album") { FavoritesActions.album-action(item.id, "play-next"); }
else { FavoritesActions.playlist-action(item.id, "play-next"); }
}
}
}
if item.kind == "track" || item.kind == "album" || item.kind == "playlist": ContextMenuItem {
icon: @image-url("../assets/icons/list-end.svg");
label: @tr("Add to queue");
clicked => {
if (item.kind == "track") { FavoritesActions.track-action(item.id, "queue"); }
else if (item.kind == "album") { FavoritesActions.album-action(item.id, "queue"); }
else { FavoritesActions.playlist-action(item.id, "queue"); }
if item.kind == "track" || item.kind == "album" || item.kind == "playlist": ContextMenuItem {
icon: @image-url("../assets/icons/list-plus.svg");
label: @tr("Play later");
clicked => {
if (item.kind == "track") { FavoritesActions.track-action(item.id, "play-later"); }
else if (item.kind == "album") { FavoritesActions.album-action(item.id, "play-later"); }
else { FavoritesActions.playlist-action(item.id, "play-later"); }
}
}
}
if item.kind == "album": ContextMenuItem {
icon: @image-url("../assets/icons/library-big.svg");
label: @tr("Open album");
clicked => { FavoritesActions.open-album(item.id); }
}
if item.kind == "playlist": ContextMenuItem {
icon: @image-url("../assets/icons/library-big.svg");
label: @tr("Open playlist");
clicked => { FavoritesActions.open-playlist(item.id); }
}
if (item.kind == "track" || item.kind == "album") && item.artist-id != "": ContextMenuItem {
icon: @image-url("../assets/icons/user.svg");
label: @tr("Go to artist");
clicked => { FavoritesActions.open-artist(item.artist-id); }
}
if item.kind == "track" && item.album-id != "": ContextMenuItem {
icon: @image-url("../assets/icons/disc.svg");
label: @tr("Go to album");
clicked => { FavoritesActions.open-album(item.album-id); }
}
if item.kind == "artist": ContextMenuItem {
icon: @image-url("../assets/icons/user.svg");
label: @tr("Go to artist");
clicked => { FavoritesActions.open-artist(item.id); }
}
if item.kind == "label": ContextMenuItem {
icon: @image-url("../assets/icons/disc-3.svg");
label: @tr("Go to label");
clicked => { FavoritesActions.open-label(item.id, item.title); }
}
if item.kind == "track" || item.kind == "album": ContextMenuItem {
icon: item.is-favorite
? @image-url("../assets/icons/heart-filled.svg")
: @image-url("../assets/icons/heart.svg");
label: item.is-favorite ? @tr("Remove from Library") : @tr("Add to Library");
clicked => {
if (item.kind == "track") { FavoritesActions.track-action(item.id, "favorite"); }
else { FavoritesActions.album-action(item.id, "favorite"); }
if item.kind == "track" || item.kind == "album" || item.kind == "playlist": ContextMenuItem {
icon: @image-url("../assets/icons/list-end.svg");
label: @tr("Add to queue");
clicked => {
if (item.kind == "track") { FavoritesActions.track-action(item.id, "queue"); }
else if (item.kind == "album") { FavoritesActions.album-action(item.id, "queue"); }
else { FavoritesActions.playlist-action(item.id, "queue"); }
}
}
if item.kind == "album": ContextMenuItem {
icon: @image-url("../assets/icons/library-big.svg");
label: @tr("Open album");
clicked => { FavoritesActions.open-album(item.id); }
}
if item.kind == "playlist": ContextMenuItem {
icon: @image-url("../assets/icons/library-big.svg");
label: @tr("Open playlist");
clicked => { FavoritesActions.open-playlist(item.id); }
}
if (item.kind == "track" || item.kind == "album") && item.artist-id != "": ContextMenuItem {
icon: @image-url("../assets/icons/user.svg");
label: @tr("Go to artist");
clicked => { FavoritesActions.open-artist(item.artist-id); }
}
if item.kind == "track" && item.album-id != "": ContextMenuItem {
icon: @image-url("../assets/icons/disc.svg");
label: @tr("Go to album");
clicked => { FavoritesActions.open-album(item.album-id); }
}
if item.kind == "artist": ContextMenuItem {
icon: @image-url("../assets/icons/user.svg");
label: @tr("Go to artist");
clicked => { FavoritesActions.open-artist(item.id); }
}
if item.kind == "label": ContextMenuItem {
icon: @image-url("../assets/icons/disc-3.svg");
label: @tr("Go to label");
clicked => { FavoritesActions.open-label(item.id, item.title); }
}
if item.kind == "track" || item.kind == "album": ContextMenuItem {
icon: item.is-favorite
? @image-url("../assets/icons/heart-filled.svg")
: @image-url("../assets/icons/heart.svg");
label: item.is-favorite ? @tr("Remove from Library") : @tr("Add to Library");
clicked => {
if (item.kind == "track") { FavoritesActions.track-action(item.id, "favorite"); }
else { FavoritesActions.album-action(item.id, "favorite"); }
}
}
}
}
@@ -1398,12 +1571,13 @@ export component FavoritesView inherits Rectangle {
selected-count: FavoritesState.tracks-selected-count;
actions: [
{ id: "select-all", label: @tr("Select all"), icon: @image-url("../assets/icons/square-check-big.svg"), danger: false, needs-selection: false },
{ id: "play-next", label: @tr("Play next"), icon: @image-url("../assets/icons/list-plus.svg"), danger: false, needs-selection: true },
{ id: "play-next", label: @tr("Play next"), icon: @image-url("../assets/icons/list-start.svg"), danger: false, needs-selection: true },
{ id: "play-later", label: @tr("Play later"), icon: @image-url("../assets/icons/list-plus.svg"), danger: false, needs-selection: true },
{ id: "queue", label: @tr("Add to queue"), icon: @image-url("../assets/icons/list-end.svg"), danger: false, needs-selection: true },
{ id: "add-to-playlist", label: @tr("Add to playlist"), icon: @image-url("../assets/icons/list-music.svg"), danger: false, needs-selection: true },
{ id: "add-to-mixtape", label: @tr("Add to Mixtape/Collection"), icon: @image-url("../assets/icons/cassette-tape.svg"), danger: false, needs-selection: true },
{ id: "make-offline", label: @tr("Make available offline"), icon: @image-url("../assets/icons/cloud-download.svg"), danger: false, needs-selection: true },
{ id: "remove-selected", label: @tr("Remove from Library"), icon: @image-url("../assets/icons/trash-2.svg"), danger: true, needs-selection: true },
{ id: "remove-selected", label: @tr("Remove from Library"), icon: @image-url("../assets/icons/heart-crack.svg"), danger: true, needs-selection: true },
{ id: "clear", label: @tr("Clear"), icon: @image-url("../assets/icons/x.svg"), danger: false, needs-selection: true },
];
action(id) => {
@@ -1555,6 +1729,25 @@ export component FavoritesView inherits Rectangle {
// --- Albums tab ---
if FavoritesState.active-tab == "albums": VerticalLayout {
spacing: 0px;
// Bulk action bar — visible in multi-select edit
// mode (list view only).
if FavoritesState.albums-multi-select
&& FavoritesState.albums-view-mode == "list": MultiSelectBar {
selected-count: FavoritesState.albums-selected-count;
actions: [
{ id: "select-all", label: @tr("Select all"), icon: @image-url("../assets/icons/square-check-big.svg"), danger: false, needs-selection: false },
{ id: "play-next", label: @tr("Play next"), icon: @image-url("../assets/icons/list-start.svg"), danger: false, needs-selection: true },
{ id: "play-later", label: @tr("Play later"), icon: @image-url("../assets/icons/list-plus.svg"), danger: false, needs-selection: true },
{ id: "queue", label: @tr("Add to queue"), icon: @image-url("../assets/icons/list-end.svg"), danger: false, needs-selection: true },
{ id: "remove-selected", label: @tr("Remove from Library"), icon: @image-url("../assets/icons/heart-crack.svg"), danger: true, needs-selection: true },
{ id: "clear", label: @tr("Clear"), icon: @image-url("../assets/icons/x.svg"), danger: false, needs-selection: true },
];
action(id) => {
FavoritesActions.albums-bulk-action(id);
}
}
if FavoritesState.albums-multi-select
&& FavoritesState.albums-view-mode == "list": Rectangle { height: 8px; }
if FavoritesState.albums.length == 0: Text {
text: @tr("No albums in your Library yet.");
color: Theme.text-muted;
@@ -1572,6 +1765,7 @@ export component FavoritesView inherits Rectangle {
view-mode: FavoritesState.albums-view-mode;
is-grouped: FavoritesState.albums-group-mode != "off";
show-source: false;
select-mode: FavoritesState.albums-multi-select;
// Viewport windowing (Phase 1): only cards near the
// visible band mount + get artwork dispatched. The
// offset is the page's padding-top (16px); the grid's
+8
View File
@@ -11,4 +11,12 @@
// the user is typing (mirrors the Tauri `isInputTarget` check).
export global UiFocusState {
in-out property <bool> text-input-focused: false;
// Steam Deck OSK (Steam plan PR 4): fired on every focus transition so
// Rust (steam_osk) can open/close the Steam OSK — every text field rides
// it with zero per-field wiring. Mode: 0 = single-line (all fields today).
in-out property <int> osk-mode: 0;
callback osk(bool /* focused */, int /* mode */);
changed text-input-focused => {
root.osk(root.text-input-focused, root.osk-mode);
}
}
@@ -226,7 +226,7 @@ component SuggestionCardView inherits Rectangle {
VerticalLayout {
alignment: center;
OverlayButton {
icon: @image-url("../assets/icons/list-plus.svg");
icon: @image-url("../assets/icons/list-end.svg");
clicked => { SuggestionsActions.queue-playlist(root.card.playlist-id); }
}
}
+23 -8
View File
@@ -201,9 +201,9 @@ export component LabelPageView inherits Rectangle {
root.media-action("label", LabelState.id, "shuffle");
}
}
// ⋯ Overflow — Play next / Add to queue / Share (the
// Qobuz web label menu). Share is Qobuz-link only:
// Song.link/Album.link have no label equivalent.
// ⋯ Overflow — Play next / Play later / Add to queue /
// Share (the Qobuz web label menu). Share is Qobuz-link
// only: Song.link/Album.link have no label equivalent.
Rectangle {
width: 32px;
height: 32px;
@@ -222,16 +222,23 @@ export component LabelPageView inherits Rectangle {
x: 0px;
y: 38px;
width: 224px;
height: 3 * 33px + 10px;
height: 4 * 33px + 10px;
close-policy: close-on-click;
ContextMenu {
ContextMenuItem {
icon: @image-url("../assets/icons/list-plus.svg");
icon: @image-url("../assets/icons/list-start.svg");
label: @tr("Play next");
clicked => {
root.media-action("label", "", "top-play-next");
}
}
ContextMenuItem {
icon: @image-url("../assets/icons/list-plus.svg");
label: @tr("Play later");
clicked => {
root.media-action("label", "", "top-play-later");
}
}
ContextMenuItem {
icon: @image-url("../assets/icons/list-end.svg");
label: @tr("Add to queue");
@@ -363,16 +370,23 @@ export component LabelPageView inherits Rectangle {
x: 32px - self.width;
y: 38px;
width: 224px;
height: 2 * 33px + 10px;
height: 3 * 33px + 10px;
close-policy: close-on-click;
ContextMenu {
ContextMenuItem {
icon: @image-url("../assets/icons/list-plus.svg");
icon: @image-url("../assets/icons/list-start.svg");
label: @tr("Play next");
clicked => {
root.media-action("label", "", "top-play-next");
}
}
ContextMenuItem {
icon: @image-url("../assets/icons/list-plus.svg");
label: @tr("Play later");
clicked => {
root.media-action("label", "", "top-play-later");
}
}
ContextMenuItem {
icon: @image-url("../assets/icons/list-end.svg");
label: @tr("Add to queue");
@@ -395,7 +409,8 @@ export component LabelPageView inherits Rectangle {
selected-count: LabelState.selected-count;
actions: [
{ id: "select-all", label: @tr("Select all"), icon: @image-url("../assets/icons/square-check-big.svg"), danger: false, needs-selection: false },
{ id: "play-next", label: @tr("Play next"), icon: @image-url("../assets/icons/list-plus.svg"), danger: false, needs-selection: true },
{ id: "play-next", label: @tr("Play next"), icon: @image-url("../assets/icons/list-start.svg"), danger: false, needs-selection: true },
{ id: "play-later", label: @tr("Play later"), icon: @image-url("../assets/icons/list-plus.svg"), danger: false, needs-selection: true },
{ id: "queue", label: @tr("Add to queue"), icon: @image-url("../assets/icons/list-end.svg"), danger: false, needs-selection: true },
{ id: "add-to-playlist", label: @tr("Add to playlist"), icon: @image-url("../assets/icons/list-music.svg"), danger: false, needs-selection: true },
{ id: "add-to-mixtape", label: @tr("Add to Mixtape/Collection"), icon: @image-url("../assets/icons/cassette-tape.svg"), danger: false, needs-selection: true },
@@ -1241,7 +1241,8 @@ export component LocalLibraryView inherits Rectangle {
actions: [
{ id: "select-all", label: @tr("Select all"), icon: @image-url("../assets/icons/square-check-big.svg"), danger: false, needs-selection: false },
{ id: "queue", label: @tr("Add to queue"), icon: @image-url("../assets/icons/list-end.svg"), danger: false, needs-selection: true },
{ id: "play-next", label: @tr("Play next"), icon: @image-url("../assets/icons/list-plus.svg"), danger: false, needs-selection: true },
{ id: "play-later", label: @tr("Play later"), icon: @image-url("../assets/icons/list-plus.svg"), danger: false, needs-selection: true },
{ id: "play-next", label: @tr("Play next"), icon: @image-url("../assets/icons/list-start.svg"), danger: false, needs-selection: true },
{ id: "add-to-playlist", label: @tr("Add to playlist"), icon: @image-url("../assets/icons/list-music.svg"), danger: false, needs-selection: true },
{ id: "add-to-mixtape", label: @tr("Add to Mixtape/Collection"), icon: @image-url("../assets/icons/cassette-tape.svg"), danger: false, needs-selection: true },
{ id: "clear", label: @tr("Clear"), icon: @image-url("../assets/icons/x.svg"), danger: false, needs-selection: true },
@@ -1407,7 +1408,9 @@ export component LocalLibraryView inherits Rectangle {
actions: [
{ id: "select-all", label: @tr("Select all"), icon: @image-url("../assets/icons/square-check-big.svg"), danger: false, needs-selection: false },
{ id: "queue", label: @tr("Add to queue"), icon: @image-url("../assets/icons/list-end.svg"), danger: false, needs-selection: true },
{ id: "play-next", label: @tr("Play next"), icon: @image-url("../assets/icons/list-plus.svg"), danger: false, needs-selection: true },
{ id: "play-later", label: @tr("Play later"), icon: @image-url("../assets/icons/list-plus.svg"), danger: false, needs-selection: true },
{ id: "play-next", label: @tr("Play next"), icon: @image-url("../assets/icons/list-start.svg"), danger: false, needs-selection: true },
{ id: "remove-favorites", label: @tr("Remove from favorites"), icon: @image-url("../assets/icons/heart-crack.svg"), danger: true, needs-selection: true },
{ id: "add-to-playlist", label: @tr("Add to playlist"), icon: @image-url("../assets/icons/list-music.svg"), danger: false, needs-selection: true },
{ id: "add-to-mixtape", label: @tr("Add to Mixtape/Collection"), icon: @image-url("../assets/icons/cassette-tape.svg"), danger: false, needs-selection: true },
{ id: "clear", label: @tr("Clear"), icon: @image-url("../assets/icons/x.svg"), danger: false, needs-selection: true },
@@ -1802,11 +1805,19 @@ export component LocalLibraryView inherits Rectangle {
VerticalLayout {
alignment: center;
RailBulkBtn {
icon: @image-url("../assets/icons/list-plus.svg");
icon: @image-url("../assets/icons/list-start.svg");
tooltip: @tr("Play next");
clicked => { LocalLibraryActions.folders-bulk-action("play-next"); }
}
}
VerticalLayout {
alignment: center;
RailBulkBtn {
icon: @image-url("../assets/icons/list-plus.svg");
tooltip: @tr("Play later");
clicked => { LocalLibraryActions.folders-bulk-action("play-later"); }
}
}
VerticalLayout {
alignment: center;
RailBulkBtn {
+2 -1
View File
@@ -219,7 +219,8 @@ export component MixView inherits Rectangle {
selected-count: MixState.selected-count;
actions: [
{ id: "select-all", label: @tr("Select all"), icon: @image-url("../assets/icons/square-check-big.svg"), danger: false, needs-selection: false },
{ id: "play-next", label: @tr("Play next"), icon: @image-url("../assets/icons/list-plus.svg"), danger: false, needs-selection: true },
{ id: "play-next", label: @tr("Play next"), icon: @image-url("../assets/icons/list-start.svg"), danger: false, needs-selection: true },
{ id: "play-later", label: @tr("Play later"), icon: @image-url("../assets/icons/list-plus.svg"), danger: false, needs-selection: true },
{ id: "queue", label: @tr("Add to queue"), icon: @image-url("../assets/icons/list-end.svg"), danger: false, needs-selection: true },
{ id: "add-to-playlist", label: @tr("Add to playlist"), icon: @image-url("../assets/icons/list-music.svg"), danger: false, needs-selection: true },
{ id: "add-to-mixtape", label: @tr("Add to Mixtape/Collection"), icon: @image-url("../assets/icons/cassette-tape.svg"), danger: false, needs-selection: true },
+17 -10
View File
@@ -440,9 +440,9 @@ component DetailRow inherits Rectangle {
width: 184px;
// Explicit height: without it the PopupWindow's box does not
// resolve and the in-flow rows below paint over it (the only
// PopupWindow in the codebase that omitted this). 4 items
// (32px) + 1 separator (1px) + padding (8px) + spacing (4px).
height: 145px;
// PopupWindow in the codebase that omitted this). 5 items
// (32px) + 1 separator (1px) + padding (8px) + spacing (5px).
height: 178px;
// Act-and-dismiss menu — match the app idiom (close on any
// click; items also act before the implicit close).
close-policy: PopupClosePolicy.close-on-click;
@@ -462,12 +462,17 @@ component DetailRow inherits Rectangle {
chosen => { MyQbzDetailActions.play-item(root.item.source-item-id); }
}
RowMenuItem {
icon: @image-url("../assets/icons/list-music.svg");
icon: @image-url("../assets/icons/list-start.svg");
label: @tr("Play next");
chosen => { MyQbzDetailActions.item-action(root.item.source-item-id, "play-next"); }
}
RowMenuItem {
icon: @image-url("../assets/icons/list-music.svg");
icon: @image-url("../assets/icons/list-plus.svg");
label: @tr("Play later");
chosen => { MyQbzDetailActions.item-action(root.item.source-item-id, "play-later"); }
}
RowMenuItem {
icon: @image-url("../assets/icons/list-end.svg");
label: @tr("Add to queue");
chosen => { MyQbzDetailActions.item-action(root.item.source-item-id, "add-to-queue"); }
}
@@ -1233,10 +1238,11 @@ export component MixtapeDetailView inherits Rectangle {
// Spec 12 §13: a bottom-docked bar that appears while items are selected
// (placement='bottom' default). Mirrors the local-library MultiSelectBar
// chrome (a flat [BulkAction] row — the §13.1 split-queue dropdown isn't
// expressible in MultiSelectBar, so its two actions are flattened to the
// adjacent "Add to queue" + "Play next" buttons, same idiom the Local
// Library tracks bar uses). The §13.1 action set, in order: Add to queue
// (play-later), Play next, Add to playlist, Add to Mixtape/Collection,
// expressible in MultiSelectBar, so its queue actions are flattened to
// adjacent buttons, same idiom the Local Library tracks bar uses). The
// action set, in order: Add to queue, Play later (#442 — the manual-block
// tail; §13.1's own "play-later" label mapped to plain append before
// #442 existed), Play next, Add to playlist, Add to Mixtape/Collection,
// Remove from this collection (danger), Clear (exit-select). The bar emits
// the id; the Rust side routes it.
if MyQbzDetailState.select-mode && MyQbzDetailState.selected-count > 0: Rectangle {
@@ -1251,7 +1257,8 @@ export component MixtapeDetailView inherits Rectangle {
selected-count: MyQbzDetailState.selected-count;
actions: [
{ id: "add-to-queue", label: @tr("Add to queue"), icon: @image-url("../assets/icons/list-end.svg"), danger: false, needs-selection: true },
{ id: "play-next", label: @tr("Play next"), icon: @image-url("../assets/icons/list-plus.svg"), danger: false, needs-selection: true },
{ id: "play-later", label: @tr("Play later"), icon: @image-url("../assets/icons/list-plus.svg"), danger: false, needs-selection: true },
{ id: "play-next", label: @tr("Play next"), icon: @image-url("../assets/icons/list-start.svg"), danger: false, needs-selection: true },
{ id: "add-to-playlist", label: @tr("Add to playlist"), icon: @image-url("../assets/icons/list-music.svg"), danger: false, needs-selection: true },
{ id: "add-to-mixtape", label: @tr("Add to Mixtape/Collection"), icon: @image-url("../assets/icons/cassette-tape.svg"), danger: false, needs-selection: true },
{ id: "remove-selected", label: @tr("Remove"), icon: @image-url("../assets/icons/trash-2.svg"), danger: true, needs-selection: true },
@@ -113,6 +113,9 @@ component PmSearch inherits Rectangle {
component PmMenuItem inherits Rectangle {
in property <string> label;
in property <bool> selected: false;
// Sort rows: "up" | "down" draws the direction chevron in place of the
// plain check on the active row (#657 caret).
in property <string> trailing-icon: "";
// Collapse property — `for x: if c: El` is disallowed in Slint 1.16, so
// a hidden item collapses to zero height instead (the sidebar pattern).
in property <bool> shown: true;
@@ -137,7 +140,9 @@ component PmMenuItem inherits Rectangle {
if root.selected: VerticalLayout {
alignment: center;
QbzIcon {
source: @image-url("../assets/icons/check.svg");
source: root.trailing-icon == "up" ? @image-url("../assets/icons/chevron-up.svg")
: root.trailing-icon == "down" ? @image-url("../assets/icons/chevron-down.svg")
: @image-url("../assets/icons/check.svg");
width: 13px;
height: 13px;
tint: Theme.accent;
@@ -1193,26 +1198,36 @@ export component PlaylistManagerView inherits Rectangle {
PmMenuItem {
label: @tr("Name A-Z");
selected: PlaylistManagerState.sort == "name";
trailing-icon: PlaylistManagerState.sort == "name"
? (PlaylistManagerState.sort-asc ? "up" : "down") : "";
activated => { PlaylistManagerActions.set-sort("name"); }
}
PmMenuItem {
label: @tr("Recent");
selected: PlaylistManagerState.sort == "recent";
trailing-icon: PlaylistManagerState.sort == "recent"
? (PlaylistManagerState.sort-asc ? "up" : "down") : "";
activated => { PlaylistManagerActions.set-sort("recent"); }
}
PmMenuItem {
label: @tr("Play Count");
selected: PlaylistManagerState.sort == "playcount";
trailing-icon: PlaylistManagerState.sort == "playcount"
? (PlaylistManagerState.sort-asc ? "up" : "down") : "";
activated => { PlaylistManagerActions.set-sort("playcount"); }
}
PmMenuItem {
label: @tr("Track Count");
selected: PlaylistManagerState.sort == "tracks";
trailing-icon: PlaylistManagerState.sort == "tracks"
? (PlaylistManagerState.sort-asc ? "up" : "down") : "";
activated => { PlaylistManagerActions.set-sort("tracks"); }
}
PmMenuItem {
label: @tr("Custom");
selected: PlaylistManagerState.sort == "custom";
trailing-icon: PlaylistManagerState.sort == "custom"
? (PlaylistManagerState.sort-asc ? "up" : "down") : "";
activated => { PlaylistManagerActions.set-sort("custom"); }
}
}
+8 -5
View File
@@ -310,19 +310,22 @@ export component PlaylistView inherits Rectangle {
property <bool> show-upload-confirm: false;
// Bulk-bar action sets (typed properties — a ternary over bare array
// literals can't coerce to [BulkAction]). Tauri parity (spec §1.5):
// Play next / Add to queue / Add to playlist over the selection,
// Remove owner-only — followed playlists keep the queueing actions.
// literals can't coerce to [BulkAction]). Tauri parity (spec §1.5) + #442:
// Play next / Play later / Add to queue / Add to playlist over the
// selection, Remove owner-only — followed playlists keep the queueing
// actions.
property <[BulkAction]> bulk-actions-owner: [
{ id: "select-all", label: @tr("Select all"), icon: @image-url("../assets/icons/square-check-big.svg"), danger: false, needs-selection: false },
{ id: "play-next-selected", label: @tr("Play next"), icon: @image-url("../assets/icons/list-plus.svg"), danger: false, needs-selection: true },
{ id: "play-next-selected", label: @tr("Play next"), icon: @image-url("../assets/icons/list-start.svg"), danger: false, needs-selection: true },
{ id: "play-later-selected", label: @tr("Play later"), icon: @image-url("../assets/icons/list-plus.svg"), danger: false, needs-selection: true },
{ id: "queue-selected", label: @tr("Add to queue"), icon: @image-url("../assets/icons/list-end.svg"), danger: false, needs-selection: true },
{ id: "add-selected-to-playlist", label: @tr("Add to playlist"), icon: @image-url("../assets/icons/list-music.svg"), danger: false, needs-selection: true },
{ id: "remove-selected", label: @tr("Remove"), icon: @image-url("../assets/icons/trash-2.svg"), danger: true, needs-selection: true },
];
property <[BulkAction]> bulk-actions-follower: [
{ id: "select-all", label: @tr("Select all"), icon: @image-url("../assets/icons/square-check-big.svg"), danger: false, needs-selection: false },
{ id: "play-next-selected", label: @tr("Play next"), icon: @image-url("../assets/icons/list-plus.svg"), danger: false, needs-selection: true },
{ id: "play-next-selected", label: @tr("Play next"), icon: @image-url("../assets/icons/list-start.svg"), danger: false, needs-selection: true },
{ id: "play-later-selected", label: @tr("Play later"), icon: @image-url("../assets/icons/list-plus.svg"), danger: false, needs-selection: true },
{ id: "queue-selected", label: @tr("Add to queue"), icon: @image-url("../assets/icons/list-end.svg"), danger: false, needs-selection: true },
{ id: "add-selected-to-playlist", label: @tr("Add to playlist"), icon: @image-url("../assets/icons/list-music.svg"), danger: false, needs-selection: true },
];
@@ -6,6 +6,7 @@
//
// Item -> media-action("album", <album-id>, <action>) mapping:
// Play next -> "play-next"
// Play later -> "play-later"
// Add to queue -> "queue"
// --
// Add to playlist -> "add-to-playlist"
@@ -39,11 +40,11 @@ export component AlbumContextMenu inherits PopupWindow {
callback media-action(string, string, string);
width: 224px;
// 9 rows x 33px + 4 separators x 1px + ContextMenu's 5/5 padding. The
// 10 rows x 33px + 4 separators x 1px + ContextMenu's 5/5 padding. The
// offline row swaps label/action on `fully-cached` (count constant); the
// block row swaps label/action on `is-blocked` (count constant); the pin
// row swaps label/action on `is-pinned` (count constant).
height: 9 * 33px + 4 * 1px + 10px;
height: 10 * 33px + 4 * 1px + 10px;
// Default close-policy is close-on-click, which races the row's TouchArea:
// the popup can dismiss on the press before the item's `clicked` fires, so
// some rows (e.g. Share Qobuz link) needed a second open+click to register.
@@ -53,13 +54,23 @@ export component AlbumContextMenu inherits PopupWindow {
ContextMenu {
ContextMenuItem {
icon: @image-url("../assets/icons/list-plus.svg");
icon: @image-url("../assets/icons/list-start.svg");
label: @tr("Play next");
clicked => {
root.media-action("album", root.album-id, "play-next");
root.close();
}
}
// #442 "Play later" — the END of the manual block (after every
// play-next / play-later already queued, before the source resumes).
ContextMenuItem {
icon: @image-url("../assets/icons/list-plus.svg");
label: @tr("Play later");
clicked => {
root.media-action("album", root.album-id, "play-later");
root.close();
}
}
ContextMenuItem {
icon: @image-url("../assets/icons/list-end.svg");
label: @tr("Add to queue");
+11 -4
View File
@@ -150,7 +150,7 @@ export component AlbumListRow inherits Rectangle {
callback clicked(string);
// kind ("album" | "artist"), id, action ("play" | "queue" |
// "play-next" | "favorite" | "open").
// "play-next" | "play-later" | "favorite" | "open").
callback media-action(string, string, string);
// Hover spans the row body + the art / artist / overflow affordances so
@@ -382,8 +382,8 @@ export component AlbumListRow inherits Rectangle {
x: -170px;
y: 34px;
width: 196px;
// 6 items max (Open/Play/Play-next/Queue/favorite + block).
height: 6 * 33px + 10px;
// 7 items max (Open/Play/Play-next/Play-later/Queue/favorite + block).
height: 7 * 33px + 10px;
ContextMenu {
ContextMenuItem {
icon: @image-url("../assets/icons/library-big.svg");
@@ -400,12 +400,19 @@ export component AlbumListRow inherits Rectangle {
}
}
ContextMenuItem {
icon: @image-url("../assets/icons/list-plus.svg");
icon: @image-url("../assets/icons/list-start.svg");
label: @tr("Play next");
clicked => {
root.media-action("album", root.album.id, "play-next");
}
}
ContextMenuItem {
icon: @image-url("../assets/icons/list-plus.svg");
label: @tr("Play later");
clicked => {
root.media-action("album", root.album.id, "play-later");
}
}
ContextMenuItem {
icon: @image-url("../assets/icons/list-end.svg");
label: @tr("Add to queue");
@@ -11,6 +11,7 @@
// Shuffle -> disc-action(disc, "shuffle")
// --
// Add to queue -> disc-action(disc, "queue")
// Play later -> disc-action(disc, "play-later")
// Play next -> disc-action(disc, "play-next")
import { Theme } from "../foundation/semantic-colors.slint";
@@ -22,8 +23,8 @@ export component DiscContextMenu inherits PopupWindow {
callback disc-action(int, string);
width: 200px;
// 4 rows x 33px + 1 separator x 1px + ContextMenu's 5/5 padding.
height: 4 * 33px + 1 * 1px + 10px;
// 5 rows x 33px + 1 separator x 1px + ContextMenu's 5/5 padding.
height: 5 * 33px + 1 * 1px + 10px;
ContextMenu {
ContextMenuItem {
@@ -50,6 +51,13 @@ export component DiscContextMenu inherits PopupWindow {
}
ContextMenuItem {
icon: @image-url("../assets/icons/list-plus.svg");
label: @tr("Play later");
clicked => {
root.disc-action(root.disc, "play-later");
}
}
ContextMenuItem {
icon: @image-url("../assets/icons/list-start.svg");
label: @tr("Play next");
clicked => {
root.disc-action(root.disc, "play-next");
@@ -13,7 +13,7 @@ import { DiscContextMenu } from "DiscContextMenu.slint";
export component DiscHeaderMenu inherits Rectangle {
in property <int> disc;
// (disc, action) — action: "play" | "shuffle" | "queue" | "play-next".
// (disc, action) — action: "play" | "shuffle" | "queue" | "play-later" | "play-next".
callback disc-action(int, string);
width: 32px;
@@ -110,14 +110,15 @@ export component PlaylistDuplicateConfirmModal inherits Rectangle {
}
}
// Add only new tracks — skips the duplicates
// (executeAddTracks(true)). Disabled when there is
// nothing left to add (all dupes).
// (executeAddTracks(true)). Always the primary continue
// action (#655): with all-dupes it adds zero and closes
// (the Rust arm handles the empty set), so it must not
// disable — the mixed-case primary disappearing under
// the user's finger was the bug.
QbzPrimaryButton {
label: DuplicateConfirmState.busy ? @tr("Adding...") : @tr("Add only new tracks");
height: 34px;
enabled: !DuplicateConfirmState.busy
&& DuplicateConfirmState.duplicate-count
< DuplicateConfirmState.total-count;
enabled: !DuplicateConfirmState.busy;
clicked => {
DuplicateConfirmActions.add-new-only();
}
@@ -115,7 +115,8 @@ export component PlaylistListRow inherits Rectangle {
x: -166px;
y: 32px;
width: 196px;
height: 150px;
// 5 rows x 33px + ContextMenu's 5/5 padding.
height: 5 * 33px + 10px;
ContextMenu {
ContextMenuItem {
icon: @image-url("../assets/icons/play.svg");
@@ -125,12 +126,19 @@ export component PlaylistListRow inherits Rectangle {
}
}
ContextMenuItem {
icon: @image-url("../assets/icons/list-plus.svg");
icon: @image-url("../assets/icons/list-start.svg");
label: @tr("Play next");
clicked => {
root.media-action("playlist", root.playlist.id, "play-next");
}
}
ContextMenuItem {
icon: @image-url("../assets/icons/list-plus.svg");
label: @tr("Play later");
clicked => {
root.media-action("playlist", root.playlist.id, "play-later");
}
}
ContextMenuItem {
icon: @image-url("../assets/icons/list-end.svg");
label: @tr("Add to queue");
@@ -5,6 +5,7 @@
import { ContextMenu } from "ContextMenu.slint";
import { ContextMenuItem } from "ContextMenuItem.slint";
import { QueueState } from "../state.slint";
export component TrackContextMenu inherits PopupWindow {
in property <string> track-id;
@@ -61,14 +62,15 @@ export component TrackContextMenu inherits PopupWindow {
width: 224px;
// Nav-only (blacklisted): only Go to album / Go to artist / Track info.
// Otherwise: 3 transient + optional favorite/mixtape/add-to-playlist/remove
// + optional go-to pair + the Qobuz block (4 fixed: QBZ radio, Qobuz
// radio, 2 shares; + 1-or-2 offline entries + optional track info).
// Otherwise: 4 transient (play/next/later/queue) + optional
// favorite/mixtape/add-to-playlist/remove + optional go-to pair + the
// Qobuz block (4 fixed: QBZ radio, Qobuz radio, 2 shares; + 1-or-2
// offline entries + optional track info).
height: (root.nav-only
? ((root.go-album-action ? 1 : 0)
+ (root.go-artist-action ? 1 : 0)
+ (root.show-track-info ? 1 : 0))
: (3
: (4
+ (root.favorite-action ? 1 : 0)
+ (root.mixtape-action ? 1 : 0)
+ (root.playlist-action ? 1 : 0)
@@ -88,12 +90,21 @@ export component TrackContextMenu inherits PopupWindow {
}
}
if !root.nav-only: ContextMenuItem {
icon: @image-url("../assets/icons/list-plus.svg");
icon: @image-url("../assets/icons/list-start.svg");
label: @tr("Play next");
clicked => {
root.media-action("track", root.track-id, "play-next");
}
}
// #442 "Play later" — the end of the manual block (after every
// play-next / play-later already queued, before the source resumes).
if !root.nav-only: ContextMenuItem {
icon: @image-url("../assets/icons/list-plus.svg");
label: @tr("Play later");
clicked => {
root.media-action("track", root.track-id, "play-later");
}
}
if !root.nav-only: ContextMenuItem {
icon: @image-url("../assets/icons/list-end.svg");
label: @tr("Add to queue");
+26 -10
View File
@@ -16,7 +16,7 @@
import { Theme } from "../foundation/semantic-colors.slint";
import { Typography } from "../foundation/typography.slint";
import { Radius } from "../foundation/radius.slint";
import { NowPlayingState, ShellState } from "../state.slint";
import { NowPlayingState, ShellState, AppearanceState } from "../state.slint";
import { QbzIcon } from "QbzIcon.slint";
// One equalizer bar — oscillates continuously while it is shown (a 1s
@@ -91,9 +91,21 @@ export component TrackPlayCell inherits Rectangle {
// and it clears automatically when the flag clears.
property <bool> is-loading: root.is-active && NowPlayingState.loading;
property <bool> show-overlay: root.row-hovered || root.is-active;
// The animated bars show only while playing, the row is at rest, and the
// track isn't still fetching (the spinner wins during the fetch window).
property <bool> show-bars: root.is-playing && !root.row-hovered && !root.is-loading;
// The animated bars show only while playing, the row is at rest, the
// track isn't still fetching (the spinner wins during the fetch window),
// AND the user opted into the animation (Appearance settings). With the
// pref OFF the cell falls through to the static glyph below (the owner's
// 2026-07-24 spec: rest = play inside an accent ring, hover = pause).
property <bool> show-bars: root.is-playing && !root.row-hovered && !root.is-loading
&& AppearanceState.play-indicator-animation;
// The glyph shows PAUSE only when the playing row is hovered (the click
// pauses). At rest the playing row shows PLAY — an affordance, not a
// state badge: the accent ring + the row's edge mark carry the state.
property <bool> show-pause: root.is-playing && root.row-hovered;
// Static-mode ring on the playing row (pref OFF): transparent fill,
// accent circumference + accent glyph. Anything else keeps the original
// alpha backing + text-primary glyph (legibility on every theme, #638).
property <bool> accent-ring: !AppearanceState.play-indicator-animation && root.is-playing;
width: root.show-artwork ? root.art-size : root.number-width;
height: root.show-artwork ? root.art-size : 40px;
@@ -146,7 +158,7 @@ export component TrackPlayCell inherits Rectangle {
: animation-tick() / 1ms) * 0.36) * 1deg;
}
if root.show-overlay && !root.show-bars && !root.is-loading: QbzIcon {
source: (root.is-playing && root.row-hovered)
source: root.show-pause
? @image-url("../assets/icons/pause.svg")
: @image-url("../assets/icons/play-fill.svg");
width: 20px;
@@ -191,26 +203,30 @@ export component TrackPlayCell inherits Rectangle {
// no scrim like the artwork variant has). Fill/border/shadow use the
// polarity-baked alpha ramp (black-alpha on light, white-alpha on dark)
// and the glyph uses text-primary = the theme's max-contrast color. #638.
// On the playing row with the animation pref OFF the circle switches to
// the static ring: transparent fill, accent circumference + glyph.
if root.show-overlay && !root.show-bars && !root.is-loading: Rectangle {
width: 24px;
height: 24px;
border-radius: 12px;
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
background: Theme.alpha-10;
border-width: 1px;
border-color: Theme.alpha-15;
background: root.accent-ring ? transparent : Theme.alpha-10;
// 1.5px like CircleAction's header rings — a 1px stroked circle
// aliases badly in femtovg (owner screenshot 2026-07-26).
border-width: 1.5px;
border-color: root.accent-ring ? Theme.accent : Theme.alpha-15;
drop-shadow-blur: 6px;
drop-shadow-color: Theme.card-shadow;
QbzIcon {
source: (root.is-playing && root.row-hovered)
source: root.show-pause
? @image-url("../assets/icons/pause.svg")
: @image-url("../assets/icons/play-fill.svg");
width: 16px;
height: 16px;
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
tint: Theme.text-primary;
tint: root.accent-ring ? Theme.accent : Theme.text-primary;
}
}
}
+19 -1
View File
@@ -18,7 +18,7 @@
import { Theme } from "../foundation/semantic-colors.slint";
import { Typography } from "../foundation/typography.slint";
import { Radius } from "../foundation/radius.slint";
import { TrackItem, DragActions, NowPlayingState, NavState, TooltipState, ShellState, TrackMenuState } from "../state.slint";
import { TrackItem, DragActions, NowPlayingState, NavState, TooltipState, ShellState, TrackMenuState, AppearanceState } from "../state.slint";
import { QbzIcon } from "QbzIcon.slint";
import { TrackPlayCell } from "TrackPlayCell.slint";
import { QualityBadgeFull } from "QualityBadgeFull.slint";
@@ -110,6 +110,12 @@ export component TrackRow inherits Rectangle {
height: 50px;
border-radius: 8px;
// This row IS the now-playing track (mirrors TrackPlayCell's is-active —
// offline-cache rows publish the catalog id in track-id but the local row
// id in local-track-id). Drives the static playing affordance.
property <bool> is-active: NowPlayingState.has-track
&& (NowPlayingState.track-id == root.track.id
|| (NowPlayingState.local-track-id != "" && NowPlayingState.local-track-id == root.track.id));
// Hover uses the polarity-baked alpha ramp so the hover state is visible on
// light themes too (the old #ffffff16 was invisible white-on-white there;
// near-identical on dark). Zebra kept as-is to avoid a visible dark-theme
@@ -118,6 +124,18 @@ export component TrackRow inherits Rectangle {
? Theme.alpha-8
: (Math.mod(root.index, 2) == 1 ? #ffffff07 : transparent);
// Static now-playing mark (animation pref OFF, owner mockup 2026-07-24):
// an accent pill on the row's left edge. With the pref ON the animated
// eq bars in the play cell carry the state instead.
if !AppearanceState.play-indicator-animation && root.is-active: Rectangle {
x: 2px;
y: 7px;
width: 3px;
height: parent.height - 14px;
border-radius: 1.5px;
background: Theme.accent;
}
// Background TouchArea — also the drag source. Pressing the row
// body (not the play/fav/more controls, which sit on top) and
// moving past a threshold begins a track drag onto the sidebar
@@ -616,6 +616,28 @@ export component AppearanceSettings inherits VerticalLayout {
}
}
}
SettingRow {
label: @tr("Play indicator animation");
description: @tr("Animate the now-playing row with equalizer bars. Off (default) shows a static pause icon with an accent edge mark — lighter on CPU.");
QbzToggle {
checked: AppearanceState.play-indicator-animation;
toggled(v) => {
AppearanceState.play-indicator-animation = v;
AppearanceState.appearance-bool("play-indicator-animation", v);
}
}
}
SettingRow {
label: @tr("Invert swipe navigation direction");
description: @tr("Swap the two-finger touchpad swipe: left goes back, right goes forward.");
QbzToggle {
checked: AppearanceState.invert-swipe-navigation;
toggled(v) => {
AppearanceState.invert-swipe-navigation = v;
AppearanceState.appearance-bool("invert-swipe-navigation", v);
}
}
}
Rectangle { height: 12px; }
Divider { }
@@ -9,7 +9,7 @@
import { Theme } from "../foundation/semantic-colors.slint";
import { Typography } from "../foundation/typography.slint";
import { Radius } from "../foundation/radius.slint";
import { LibraryFoldersState, LibraryManageActions, LibraryScanState, PlexSettingsState, PlexAuthActions , UiFocusState, LocalLibraryState, LocalLibraryActions } from "../state.slint";
import { LibraryFoldersState, LibraryManageActions, LibraryScanState, PlexSettingsState, PlexAuthActions , UiFocusState, LocalLibraryState, LocalLibraryActions, LibraryAllState, LibraryAllActions } from "../state.slint";
import { SettingRow } from "SettingRow.slint";
import { QbzIcon } from "../primitives/QbzIcon.slint";
import { QbzCheckbox } from "../primitives/QbzCheckbox.slint";
@@ -448,6 +448,33 @@ export component LocalLibrarySettings inherits VerticalLayout {
Rectangle { height: 22px; }
// --- Library › All feed -------------------------------------------------
// The All view's toolbar hard-drive toggle filters local items in/out;
// THIS row picks what "local" means in the feed (owner 2026-07-24):
// hearted items only, or the entire local library.
GroupHeader { text: @tr("LIBRARY › ALL"); }
SettingRow {
label: @tr("Local items in Library All");
description: @tr("Favorited items only: what you hearted. Entire local library: every folder and Plex album, artist and track.");
VerticalLayout {
alignment: center;
QbzSelect {
menu-width: 210px;
sm: true;
options: [
@tr("Favorited items only"),
@tr("Entire local library"),
];
current-index: LibraryAllState.local-scope == "all" ? 1 : 0;
selected(i) => {
LibraryAllActions.set-local-scope(i == 1 ? "all" : "favorites");
}
}
}
}
Rectangle { height: 22px; }
// --- Maintenance -----------------------------------------------------
GroupHeader { text: @tr("MAINTENANCE"); }
SettingRow {
+13 -5
View File
@@ -189,9 +189,15 @@ export component AppShell inherits Rectangle {
// ~4.5% CPU with a playlist view open on a 165 Hz monitor. When the
// playing row is not on screen nothing reads the property, so the tick
// dirties no binding and costs nothing.
//
// The playing arm is gated on AppearanceState.play-indicator-animation:
// with the pref OFF (default) the eq bars never mount, so the clock stays
// off while playing too — the static pause glyph + accent edge mark cost
// zero ticks (the CPU complaint the pref answers, 2026-07-24).
Timer {
interval: 125ms;
running: ShellState.reduce-motion || NowPlayingState.playing;
running: ShellState.reduce-motion
|| (NowPlayingState.playing && AppearanceState.play-indicator-animation);
triggered => {
ShellState.coarse-tick-ms = Math.mod(ShellState.coarse-tick-ms + 125, 86400000);
}
@@ -294,12 +300,14 @@ export component AppShell inherits Rectangle {
// libinput natural scrolling ON (GNOME default) fingers
// right yields NEGATIVE delta-x, hence the signs below.
// Under non-natural scrolling the mapping feels inverted
// — accepted trade-off (distro scroll-direction setting
// is not visible to the app).
if (swipe-accum < 0 && NavState.can-back) {
// — the AppearanceState.invert-swipe-navigation pref
// (#653 follow-up) flips the mapping for those users.
if ((AppearanceState.invert-swipe-navigation ? -swipe-accum : swipe-accum) < 0
&& NavState.can-back) {
NavState.request-back();
}
if (swipe-accum > 0 && NavState.can-forward) {
if ((AppearanceState.invert-swipe-navigation ? -swipe-accum : swipe-accum) > 0
&& NavState.can-forward) {
NavState.request-forward();
}
swipe-fired = true;
+53 -3
View File
@@ -2,9 +2,13 @@
// (same as the desktop AlbumPageView): a header (big cover + title + artist +
// quality + Play/Shuffle) and a simple track list (number + title + duration).
// Album track rows carry no artwork, so we show the track number.
//
// Keyboard/gamepad nav (PR 2): the track list is the nav model (1-column
// list, no tabs — index == track index). Enter plays the focused track. The
// header buttons (Play / Shuffle / artist link) stay touch-only in v1.
import { Theme } from "../foundation/semantic-colors.slint";
import { Radius } from "../foundation/radius.slint";
import { AlbumState, NowPlayingState } from "../state.slint";
import { AlbumState, NowPlayingState, KioskNavState } from "../state.slint";
component PillButton inherits Rectangle {
in property <string> label;
@@ -37,7 +41,32 @@ export component KioskAlbum inherits Rectangle {
property <length> pad: 22px;
Flickable {
// ---- Nav geometry publish (PR 2): no tabs, one index per track.
function publish-nav() {
KioskNavState.tabs = 0;
KioskNavState.shelves = false;
KioskNavState.columns = 1;
KioskNavState.count = AlbumState.tracks.length;
KioskNavState.index = Math.max(0, Math.min(KioskNavState.index, KioskNavState.count - 1));
}
init => {
root.publish-nav();
}
property <int> nav-len-probe: AlbumState.tracks.length;
changed nav-len-probe => {
root.publish-nav();
}
// Enter pulse → play the focused track (the row TouchArea's own action).
property <int> act-probe: KioskNavState.activate-seq;
changed act-probe => {
if (KioskNavState.nav-active && KioskNavState.zone == "content"
&& KioskNavState.index >= 0 && KioskNavState.index < AlbumState.tracks.length) {
root.media-action("track", AlbumState.tracks[KioskNavState.index].id, "play");
}
}
page-flick := Flickable {
viewport-width: self.width;
viewport-height: page.preferred-height;
@@ -122,9 +151,30 @@ export component KioskAlbum inherits Rectangle {
border-radius: Radius.sm;
// Same local-track-id alias as TrackPlayCell (offline-cache tracks).
property <bool> current: NowPlayingState.track-id == track.id || (NowPlayingState.local-track-id != "" && NowPlayingState.local-track-id == track.id);
background: trow.has-hover
// PR 2 — focus ring + scroll-into-view. Page coords are
// fixed arithmetic: 22px pad + ~180px header + 22px
// spacing, then a 54px pitch (52px row + 2px spacing).
// viewport-y is written from a callback on a settled
// layout ("Recursion detected" class).
property <bool> row-focused: KioskNavState.nav-active
&& KioskNavState.zone == "content" && KioskNavState.index == i;
property <int> nav-index-probe: KioskNavState.index;
changed nav-index-probe => {
if (self.row-focused) {
if (224px + i * 54px < -page-flick.viewport-y) {
page-flick.viewport-y = -(224px + i * 54px - 8px);
} else if (224px + i * 54px + 52px > -page-flick.viewport-y + page-flick.height) {
page-flick.viewport-y = -(224px + i * 54px + 52px - page-flick.height + 8px);
}
}
}
background: self.row-focused
? Theme.accent.with-alpha(0.18)
: trow.has-hover
? Theme.alpha-6
: self.current ? Theme.accent.with-alpha(0.10) : transparent;
border-width: self.row-focused ? 2px : 0px;
border-color: Theme.accent;
HorizontalLayout {
padding-left: 12px;
padding-right: 12px;
+36 -1
View File
@@ -8,7 +8,7 @@
// plain full grid mounted every card at once and spiked CPU on entry — a Pi
// can't afford that. Binds `[AlbumCardItem]` so artwork pushes land on the cards.
import { Theme } from "../foundation/semantic-colors.slint";
import { AlbumCardItem } from "../state.slint";
import { AlbumCardItem, KioskNavState } from "../state.slint";
import { KioskCard } from "KioskCard.slint";
export component KioskAlbumGrid inherits Flickable {
@@ -25,6 +25,40 @@ export component KioskAlbumGrid inherits Flickable {
viewport-width: self.width;
viewport-height: root.rows * (root.card-h + root.gap) + root.pad;
// ---- Keyboard/gamepad nav (PR 2). The HOSTING VIEW publishes the grid
// geometry (tabs / columns / count) into KioskNavState; the grid only
// mirrors the focus index (ring + scroll-into-view) and answers the
// Enter pulse. The content index space skips `tabs` leading tab entries.
property <int> focused-item: KioskNavState.index - KioskNavState.tabs;
property <bool> item-focused: KioskNavState.nav-active
&& KioskNavState.zone == "content"
&& root.focused-item >= 0
&& root.focused-item < root.albums.length;
// Scroll the focused card's row into view. viewport-y is WRITTEN from
// this callback (plain imperative assignment on a settled layout), never
// read by a binding that feeds layout — the AlbumCollectionView
// "Recursion detected" panic class.
property <length> focus-top: root.pad + Math.floor(root.focused-item / root.columns) * (root.card-h + root.gap);
property <int> nav-index-probe: KioskNavState.index;
changed nav-index-probe => {
if (root.item-focused) {
if (root.focus-top < -self.viewport-y) {
self.viewport-y = -(root.focus-top - 8px);
} else if (root.focus-top + root.card-h > -self.viewport-y + self.height) {
self.viewport-y = -(root.focus-top + root.card-h - self.height + 8px);
}
}
}
// Enter pulse → open the focused album.
property <int> act-probe: KioskNavState.activate-seq;
changed act-probe => {
if (root.item-focused) {
root.open(root.albums[root.focused-item].id);
}
}
Rectangle {
for album[i] in root.albums: slot := Rectangle {
x: root.pad + Math.mod(i, root.columns) * (root.card-w + root.gap);
@@ -39,6 +73,7 @@ export component KioskAlbumGrid inherits Flickable {
width: root.card-w;
art-size: root.card-w;
album: album;
nav-focused: root.item-focused && root.focused-item == i;
clicked(id) => {
root.open(id);
}
+109 -10
View File
@@ -2,9 +2,15 @@
// ArtistState (same as the desktop ArtistPageView): a header (round portrait +
// name + Play/Radio) and the discography as simple KioskCard shelves per release
// section (the junk "other" bucket is skipped).
//
// Keyboard/gamepad nav (PR 2): SHELF mode, same model as KioskDiscover — one
// nav index per shelf (columns == 1, no tabs), Left/Right pulses `hmove`
// consumed by the focused shelf's local cursor, skipped shelves ("other"
// bucket / empty) are stepped over in the last travel direction. The header
// buttons (Play / Radio) stay touch-only in v1.
import { Theme } from "../foundation/semantic-colors.slint";
import { Radius } from "../foundation/radius.slint";
import { ArtistState } from "../state.slint";
import { ArtistState, KioskNavState } from "../state.slint";
import { KioskCard } from "KioskCard.slint";
export component KioskArtist inherits Rectangle {
@@ -13,7 +19,23 @@ export component KioskArtist inherits Rectangle {
property <length> pad: 22px;
Flickable {
// ---- Nav geometry publish (PR 2): no tab strip, one index per shelf.
function publish-nav() {
KioskNavState.tabs = 0;
KioskNavState.shelves = true;
KioskNavState.columns = 1;
KioskNavState.count = ArtistState.release-sections.length;
KioskNavState.index = Math.max(0, Math.min(KioskNavState.index, KioskNavState.count - 1));
}
init => {
root.publish-nav();
}
property <int> nav-count-probe: ArtistState.release-sections.length;
changed nav-count-probe => {
root.publish-nav();
}
page-flick := Flickable {
viewport-width: self.width;
viewport-height: page.preferred-height;
@@ -93,24 +115,101 @@ export component KioskArtist inherits Rectangle {
}
}
for section in ArtistState.release-sections: VerticalLayout {
for section[s] in ArtistState.release-sections: shelf-vl := VerticalLayout {
// PR 2 — per-shelf nav state (same idiom as KioskDiscover).
property <int> cursor: 0;
property <bool> shelf-focused: KioskNavState.nav-active
&& KioskNavState.zone == "content"
&& KioskNavState.index - KioskNavState.tabs == s;
// Skip the shelves this view doesn't render ("other" bucket,
// empty sections) in the last travel direction.
property <int> nav-skip-probe: KioskNavState.index;
changed nav-skip-probe => {
if (KioskNavState.zone == "content" && KioskNavState.nav-active
&& KioskNavState.index - KioskNavState.tabs == s
&& (section.release-type == "other" || section.albums.length == 0)
&& KioskNavState.dir != 0) {
KioskNavState.index = Math.max(KioskNavState.tabs,
Math.min(KioskNavState.count - 1, KioskNavState.index + KioskNavState.dir));
}
}
// Left/Right shelf move: consume the shell's `hmove` pulse.
property <int> hmove-probe: KioskNavState.hmove;
changed hmove-probe => {
if (self.shelf-focused && KioskNavState.hmove != 0
&& section.albums.length > 0) {
self.cursor = Math.clamp(self.cursor + KioskNavState.hmove,
0, section.albums.length - 1);
KioskNavState.hmove = 0;
}
}
// Enter pulse → open the cursor album.
property <int> act-probe: KioskNavState.activate-seq;
changed act-probe => {
if (self.shelf-focused && section.albums.length > 0
&& self.cursor < section.albums.length) {
root.open-album(section.albums[self.cursor].id);
}
}
// Keep the focused shelf's strip inside the page viewport.
// Fixed page arithmetic: 22px pad + 140px header + 22px
// spacing, then a 240px pitch per shelf block (24px title +
// 12px + 182px strip + 22px spacing). viewport-y is written
// from a callback on a settled layout ("Recursion detected"
// class — never a layout-feeding binding).
property <int> nav-index-probe: KioskNavState.index;
changed nav-index-probe => {
if (self.shelf-focused && section.albums.length > 0) {
if (184px + s * 240px < -page-flick.viewport-y) {
page-flick.viewport-y = -(184px + s * 240px - 8px);
} else if (184px + s * 240px + 218px > -page-flick.viewport-y + page-flick.height) {
page-flick.viewport-y = -(184px + s * 240px + 218px - page-flick.height + 8px);
}
}
}
if section.release-type != "other" && section.albums.length > 0: VerticalLayout {
spacing: 12px;
Text {
text: section.title;
color: Theme.text-primary;
font-size: 18px;
font-weight: 600;
// Fixed-height title wrapper — keeps the shelf block a
// constant 218px for the scroll arithmetic above.
Rectangle {
height: 24px;
Text {
text: section.title;
color: Theme.text-primary;
font-size: 18px;
font-weight: 600;
vertical-alignment: center;
}
}
Flickable {
strip-flick := Flickable {
height: 182px;
viewport-width: strip.preferred-width;
viewport-height: self.height;
// Keep the cursor card inside the strip's horizontal
// viewport (card pitch = 128px art + 14px spacing).
property <int> cursor-probe: shelf-vl.cursor;
changed cursor-probe => {
if (shelf-vl.shelf-focused) {
if (shelf-vl.cursor * 142px < -self.viewport-x) {
self.viewport-x = -(shelf-vl.cursor * 142px - 8px);
} else if (shelf-vl.cursor * 142px + 128px > -self.viewport-x + self.width) {
self.viewport-x = -(shelf-vl.cursor * 142px + 128px - self.width + 8px);
}
}
}
strip := HorizontalLayout {
spacing: 14px;
alignment: start;
for album in section.albums: KioskCard {
for album[i] in section.albums: KioskCard {
album: album;
nav-focused: shelf-vl.shelf-focused && i == shelf-vl.cursor;
clicked(id) => {
root.open-album(id);
}
@@ -3,15 +3,22 @@
// SearchState.artists (SlimItem: id/title/artwork) and FavoritesState.artists
// (FavoriteArtistItem: id/name/image).
import { Theme } from "../foundation/semantic-colors.slint";
import { Radius } from "../foundation/radius.slint";
export component KioskArtistCard inherits Rectangle {
in property <string> id;
in property <string> name;
in property <image> avatar;
in property <length> size: 120px;
// Keyboard/gamepad nav (PR 2) — same ring idiom as KioskCard.
in property <bool> nav-focused: false;
callback clicked(string);
width: root.size;
border-width: root.nav-focused ? 3px : 0px;
border-color: Theme.accent;
border-radius: Radius.sm;
background: root.nav-focused ? Theme.accent.with-alpha(0.12) : transparent;
VerticalLayout {
alignment: start;
+8
View File
@@ -13,9 +13,17 @@ export component KioskCard inherits Rectangle {
in property <AlbumCardItem> album;
in property <length> art-size: 128px;
in property <bool> round: false;
// Keyboard/gamepad nav (PR 2): the hosting view sets this when the shell
// focus index lands on this card. A 3px accent ring + tint, readable at
// 10-foot distance.
in property <bool> nav-focused: false;
callback clicked(string);
width: root.art-size;
border-width: root.nav-focused ? 3px : 0px;
border-color: Theme.accent;
border-radius: Radius.sm;
background: root.nav-focused ? Theme.accent.with-alpha(0.12) : transparent;
VerticalLayout {
alignment: start;
+149 -11
View File
@@ -6,19 +6,32 @@
// stack — lighter for a Pi. v1 covers the album-carousel rows (newReleases,
// qobuzissimes, editorPicks, pressAwards, idealDiscography, mostStreamed); the
// non-album rows (playlists, mixes, slim rails) are skipped for now.
//
// Keyboard/gamepad nav (PR 2): SHELF mode. The nav index space is the 3 tab
// pills (indices 0-2) followed by one index per shelf (columns == 1, so
// Up/Down walk shelves). Left/Right can't move the global index (that would
// jump shelves), so the shell emits an `hmove` pulse that the focused shelf
// consumes into its LOCAL card cursor. Sections with no albums render
// nothing (the playlist/mix/slim rows above) — the focused shelf skips over
// them in the last travel direction (`KioskNavState.dir`), since Slint has
// no cross-loop accumulator to pre-compact the index space.
import { Theme } from "../foundation/semantic-colors.slint";
import { Radius } from "../foundation/radius.slint";
import { DiscoverState, HomeState, HomeActions } from "../state.slint";
import { DiscoverState, HomeState, HomeActions, KioskNavState } from "../state.slint";
import { KioskCard } from "KioskCard.slint";
component TabPill inherits Rectangle {
in property <string> label;
in property <bool> active;
// Keyboard/gamepad nav (PR 2): this pill holds the shell focus ring.
in property <bool> nav-focused: false;
callback clicked;
horizontal-stretch: 0;
height: 40px;
border-radius: Radius.sm;
background: root.active ? Theme.accent : ta.has-hover ? Theme.alpha-6 : transparent;
border-width: root.nav-focused ? 2px : 0px;
border-color: Theme.accent;
HorizontalLayout {
padding-left: 16px;
padding-right: 16px;
@@ -43,7 +56,48 @@ export component KioskDiscover inherits Rectangle {
callback media-action(string, string, string);
callback discover-view-all(string, string);
Flickable {
// ---- Nav geometry publish (PR 2): 3 tab pills + one index per shelf.
// Re-published on tab switch and whenever the section set changes.
property <int> nav-section-count: (DiscoverState.active-tab == "editorPicks"
? DiscoverState.editor-sections
: DiscoverState.active-tab == "forYou"
? DiscoverState.foryou-sections
: DiscoverState.home-sections).length;
function publish-nav() {
KioskNavState.tabs = 3;
KioskNavState.shelves = true;
KioskNavState.columns = 1;
KioskNavState.count = 3 + root.nav-section-count;
KioskNavState.index = Math.max(0, Math.min(KioskNavState.index, KioskNavState.count - 1));
}
init => {
root.publish-nav();
}
property <int> nav-count-probe: root.nav-section-count;
changed nav-count-probe => {
root.publish-nav();
}
property <string> nav-tab-probe: DiscoverState.active-tab;
changed nav-tab-probe => {
root.publish-nav();
}
// Enter on a tab pill → the same tab switch the pills' TouchAreas drive.
property <int> act-probe: KioskNavState.activate-seq;
changed act-probe => {
if (KioskNavState.nav-active && KioskNavState.zone == "content"
&& KioskNavState.index < KioskNavState.tabs) {
if (KioskNavState.index == 0) {
HomeActions.select-tab("home");
} else if (KioskNavState.index == 1) {
HomeActions.select-tab("editorPicks");
} else if (KioskNavState.index == 2) {
HomeActions.select-tab("forYou");
}
}
}
page-flick := Flickable {
viewport-width: self.width;
viewport-height: page.preferred-height;
@@ -68,16 +122,19 @@ export component KioskDiscover inherits Rectangle {
TabPill {
label: @tr("Home");
active: DiscoverState.active-tab == "home";
nav-focused: KioskNavState.nav-active && KioskNavState.zone == "content" && KioskNavState.index == 0;
clicked => { HomeActions.select-tab("home"); }
}
TabPill {
label: @tr("Editor's Picks");
active: DiscoverState.active-tab == "editorPicks";
nav-focused: KioskNavState.nav-active && KioskNavState.zone == "content" && KioskNavState.index == 1;
clicked => { HomeActions.select-tab("editorPicks"); }
}
TabPill {
label: @tr("For You");
active: DiscoverState.active-tab == "forYou";
nav-focused: KioskNavState.nav-active && KioskNavState.zone == "content" && KioskNavState.index == 2;
clicked => { HomeActions.select-tab("forYou"); }
}
}
@@ -86,30 +143,111 @@ export component KioskDiscover inherits Rectangle {
// Album shelves for the active tab. Only rows carrying album data
// render (length > 0); playlist/mix/slim rows are skipped in v1.
for desc in (DiscoverState.active-tab == "editorPicks"
for desc[s] in (DiscoverState.active-tab == "editorPicks"
? DiscoverState.editor-sections
: DiscoverState.active-tab == "forYou"
? DiscoverState.foryou-sections
: DiscoverState.home-sections): VerticalLayout {
: DiscoverState.home-sections): shelf-vl := VerticalLayout {
// PR 2 — per-shelf nav state. `cursor` is the focused card
// inside this shelf; `shelf-focused` mirrors the global index.
property <int> cursor: 0;
property <bool> shelf-focused: KioskNavState.nav-active
&& KioskNavState.zone == "content"
&& KioskNavState.index - KioskNavState.tabs == s;
// Skip over shelves that render nothing (empty / non-album
// sections): nudge the index one more step in the last travel
// direction. The write re-fires the probes of the next shelf,
// so the cascade walks contiguous gaps and converges.
property <int> nav-skip-probe: KioskNavState.index;
changed nav-skip-probe => {
if (KioskNavState.zone == "content" && KioskNavState.nav-active
&& KioskNavState.index - KioskNavState.tabs == s
&& desc.section.albums.length == 0 && KioskNavState.dir != 0) {
KioskNavState.index = Math.max(KioskNavState.tabs,
Math.min(KioskNavState.count - 1, KioskNavState.index + KioskNavState.dir));
}
}
// Left/Right shelf move: the shell pulses `hmove`; the focused
// shelf consumes it into its cursor (and resets the pulse).
property <int> hmove-probe: KioskNavState.hmove;
changed hmove-probe => {
if (self.shelf-focused && KioskNavState.hmove != 0
&& desc.section.albums.length > 0) {
self.cursor = Math.clamp(self.cursor + KioskNavState.hmove,
0, desc.section.albums.length - 1);
KioskNavState.hmove = 0;
}
}
// Enter pulse → open the cursor album.
property <int> act-probe: KioskNavState.activate-seq;
changed act-probe => {
if (self.shelf-focused && desc.section.albums.length > 0
&& self.cursor < desc.section.albums.length) {
root.album-clicked(desc.section.albums[self.cursor].id);
}
}
// Keep the focused shelf's strip inside the page viewport.
// Page coords are fixed arithmetic: 16px pad + 50px tab row +
// 22px spacing, then a 240px pitch per shelf block (24px
// title + 12px + 182px strip + 22px spacing). viewport-y is
// WRITTEN from this callback on a settled layout, never read
// by a layout-feeding binding ("Recursion detected" class).
property <int> nav-index-probe: KioskNavState.index;
changed nav-index-probe => {
if (self.shelf-focused && desc.section.albums.length > 0) {
if (88px + s * 240px < -page-flick.viewport-y) {
page-flick.viewport-y = -(88px + s * 240px - 8px);
} else if (88px + s * 240px + 218px > -page-flick.viewport-y + page-flick.height) {
page-flick.viewport-y = -(88px + s * 240px + 218px - page-flick.height + 8px);
}
}
}
if desc.section.albums.length > 0: VerticalLayout {
spacing: 12px;
Text {
text: desc.section.title;
color: Theme.text-primary;
font-size: 18px;
font-weight: 600;
// Fixed-height title wrapper (was a bare Text): makes the
// shelf block a constant 218px so the scroll-into-view
// arithmetic above stays exact.
Rectangle {
height: 24px;
Text {
text: desc.section.title;
color: Theme.text-primary;
font-size: 18px;
font-weight: 600;
vertical-alignment: center;
}
}
Flickable {
strip-flick := Flickable {
height: 182px;
viewport-width: strip.preferred-width;
viewport-height: self.height;
// Keep the cursor card inside the strip's horizontal
// viewport (card pitch = 128px art + 14px spacing).
property <int> cursor-probe: shelf-vl.cursor;
changed cursor-probe => {
if (shelf-vl.shelf-focused) {
if (shelf-vl.cursor * 142px < -self.viewport-x) {
self.viewport-x = -(shelf-vl.cursor * 142px - 8px);
} else if (shelf-vl.cursor * 142px + 128px > -self.viewport-x + self.width) {
self.viewport-x = -(shelf-vl.cursor * 142px + 128px - self.width + 8px);
}
}
}
strip := HorizontalLayout {
spacing: 14px;
alignment: start;
for album in desc.section.albums: KioskCard {
for album[i] in desc.section.albums: KioskCard {
album: album;
nav-focused: shelf-vl.shelf-focused && i == shelf-vl.cursor;
clicked(id) => {
root.album-clicked(id);
}
+186 -8
View File
@@ -5,10 +5,18 @@
// FavoritesActions.select-tab (Rust lazy-loads each tab). Covers are push-only
// except album covers (windowed — v1 shows the first ~60; the window hook is a
// follow-up).
//
// Keyboard/gamepad nav (PR 2): the nav index space is the 5 tabs (indices
// 0-4) followed by the active tab's items as a uniform grid (tracks = a
// 1-column list). The view publishes that geometry into KioskNavState; each
// container mirrors the focus index (ring + scroll-into-view) and answers
// the Enter pulse. viewport-y is only ever WRITTEN from callbacks on a
// settled layout, never read by layout-feeding bindings (the
// AlbumCollectionView "Recursion detected" panic class).
import { ListView } from "std-widgets.slint";
import { Theme } from "../foundation/semantic-colors.slint";
import { Radius } from "../foundation/radius.slint";
import { FavoritesState, FavoritesActions } from "../state.slint";
import { FavoritesState, FavoritesActions, KioskNavState } from "../state.slint";
import { KioskAlbumGrid } from "KioskAlbumGrid.slint";
import { KioskArtistCard } from "KioskArtistCard.slint";
import { KioskTrackRow } from "KioskTrackRow.slint";
@@ -16,9 +24,14 @@ import { KioskTrackRow } from "KioskTrackRow.slint";
component LibTab inherits Rectangle {
in property <string> label;
in property <string> id;
// Keyboard/gamepad nav (PR 2): this tab holds the shell focus ring.
in property <bool> nav-focused: false;
callback picked(string);
property <bool> active: FavoritesState.active-tab == root.id;
horizontal-stretch: 0;
border-width: root.nav-focused ? 2px : 0px;
border-color: Theme.accent;
border-radius: Radius.sm;
HorizontalLayout {
padding-left: 2px;
padding-right: 2px;
@@ -50,6 +63,64 @@ export component KioskLibrary inherits Rectangle {
property <length> pad: 16px;
// ---- Nav geometry publish (PR 2): 5 tabs + the active tab's items.
function publish-nav() {
KioskNavState.tabs = 5;
KioskNavState.shelves = false;
if (FavoritesState.active-tab == "tracks") {
KioskNavState.columns = 1;
KioskNavState.count = 5 + FavoritesState.tracks-visible.length;
} else if (FavoritesState.active-tab == "albums") {
KioskNavState.columns = 6;
KioskNavState.count = 5 + FavoritesState.albums-visible.length;
} else if (FavoritesState.active-tab == "artists") {
KioskNavState.columns = 7;
KioskNavState.count = 5 + FavoritesState.artists-visible.length;
} else if (FavoritesState.active-tab == "playlists") {
KioskNavState.columns = 6;
KioskNavState.count = 5 + FavoritesState.playlists-visible.length;
} else {
KioskNavState.columns = 7;
KioskNavState.count = 5 + FavoritesState.labels-visible.length;
}
KioskNavState.index = Math.max(0, Math.min(KioskNavState.index, KioskNavState.count - 1));
}
init => {
root.publish-nav();
}
property <string> nav-tab-probe: FavoritesState.active-tab;
changed nav-tab-probe => {
root.publish-nav();
}
property <int> nav-len-probe: FavoritesState.tracks-visible.length
+ FavoritesState.albums-visible.length
+ FavoritesState.artists-visible.length
+ FavoritesState.playlists-visible.length
+ FavoritesState.labels-visible.length;
changed nav-len-probe => {
root.publish-nav();
}
// Enter on a tab → the same switch the tab TouchAreas drive. Items are
// activated by their own container (each watches the same pulse).
property <int> act-probe: KioskNavState.activate-seq;
changed act-probe => {
if (KioskNavState.nav-active && KioskNavState.zone == "content"
&& KioskNavState.index < KioskNavState.tabs) {
if (KioskNavState.index == 0) {
FavoritesActions.select-tab("tracks");
} else if (KioskNavState.index == 1) {
FavoritesActions.select-tab("albums");
} else if (KioskNavState.index == 2) {
FavoritesActions.select-tab("artists");
} else if (KioskNavState.index == 3) {
FavoritesActions.select-tab("playlists");
} else if (KioskNavState.index == 4) {
FavoritesActions.select-tab("labels");
}
}
}
VerticalLayout {
spacing: 0px;
@@ -61,11 +132,11 @@ export component KioskLibrary inherits Rectangle {
padding-right: root.pad;
spacing: 22px;
alignment: start;
LibTab { label: @tr("Tracks"); id: "tracks"; picked(t) => { FavoritesActions.select-tab(t); } }
LibTab { label: @tr("Albums"); id: "albums"; picked(t) => { FavoritesActions.select-tab(t); } }
LibTab { label: @tr("Artists"); id: "artists"; picked(t) => { FavoritesActions.select-tab(t); } }
LibTab { label: @tr("Playlists"); id: "playlists"; picked(t) => { FavoritesActions.select-tab(t); } }
LibTab { label: @tr("Labels"); id: "labels"; picked(t) => { FavoritesActions.select-tab(t); } }
LibTab { label: @tr("Tracks"); id: "tracks"; nav-focused: KioskNavState.nav-active && KioskNavState.zone == "content" && KioskNavState.index == 0; picked(t) => { FavoritesActions.select-tab(t); } }
LibTab { label: @tr("Albums"); id: "albums"; nav-focused: KioskNavState.nav-active && KioskNavState.zone == "content" && KioskNavState.index == 1; picked(t) => { FavoritesActions.select-tab(t); } }
LibTab { label: @tr("Artists"); id: "artists"; nav-focused: KioskNavState.nav-active && KioskNavState.zone == "content" && KioskNavState.index == 2; picked(t) => { FavoritesActions.select-tab(t); } }
LibTab { label: @tr("Playlists"); id: "playlists"; nav-focused: KioskNavState.nav-active && KioskNavState.zone == "content" && KioskNavState.index == 3; picked(t) => { FavoritesActions.select-tab(t); } }
LibTab { label: @tr("Labels"); id: "labels"; nav-focused: KioskNavState.nav-active && KioskNavState.zone == "content" && KioskNavState.index == 4; picked(t) => { FavoritesActions.select-tab(t); } }
}
Rectangle {
y: parent.height - 1px;
@@ -81,9 +152,33 @@ export component KioskLibrary inherits Rectangle {
// ListView windows the rows (only the visible band is mounted) — a
// plain VerticalLayout of every track spiked CPU on entry, which a Pi
// can't afford.
if FavoritesState.active-tab == "tracks": ListView {
for track in FavoritesState.tracks-visible: KioskTrackRow {
if FavoritesState.active-tab == "tracks": tracks-list := ListView {
// PR 2 — focus ring + Enter + scroll-into-view (62px rows).
property <int> focused-item: KioskNavState.index - KioskNavState.tabs;
property <bool> item-focused: KioskNavState.nav-active
&& KioskNavState.zone == "content"
&& self.focused-item >= 0
&& self.focused-item < FavoritesState.tracks-visible.length;
property <int> nav-index-probe: KioskNavState.index;
changed nav-index-probe => {
if (self.item-focused) {
if (self.focused-item * 62px < -self.viewport-y) {
self.viewport-y = -(self.focused-item * 62px - 8px);
} else if (self.focused-item * 62px + 62px > -self.viewport-y + self.height) {
self.viewport-y = -(self.focused-item * 62px + 62px - self.height + 8px);
}
}
}
property <int> act-probe: KioskNavState.activate-seq;
changed act-probe => {
if (self.item-focused) {
FavoritesActions.play-track(FavoritesState.tracks-visible[self.focused-item].id);
}
}
for track[i] in FavoritesState.tracks-visible: KioskTrackRow {
track: track;
nav-focused: tracks-list.item-focused && tracks-list.focused-item == i;
play(id) => {
FavoritesActions.play-track(id);
}
@@ -103,6 +198,31 @@ export component KioskLibrary inherits Rectangle {
property <length> cell: (self.width - 2 * root.pad - (self.cols - 1) * self.gap) / self.cols;
viewport-width: self.width;
viewport-height: Math.ceil(FavoritesState.artists-visible.length / self.cols) * (self.cell + 26px + self.gap) + root.pad;
// PR 2 — focus ring + Enter + scroll-into-view.
property <int> focused-item: KioskNavState.index - KioskNavState.tabs;
property <bool> item-focused: KioskNavState.nav-active
&& KioskNavState.zone == "content"
&& self.focused-item >= 0
&& self.focused-item < FavoritesState.artists-visible.length;
property <length> focus-top: root.pad + Math.floor(self.focused-item / self.cols) * (self.cell + 26px + self.gap);
property <int> nav-index-probe: KioskNavState.index;
changed nav-index-probe => {
if (self.item-focused) {
if (self.focus-top < -self.viewport-y) {
self.viewport-y = -(self.focus-top - 8px);
} else if (self.focus-top + self.cell + 26px > -self.viewport-y + self.height) {
self.viewport-y = -(self.focus-top + self.cell + 26px - self.height + 8px);
}
}
}
property <int> act-probe: KioskNavState.activate-seq;
changed act-probe => {
if (self.item-focused) {
root.open-artist(FavoritesState.artists-visible[self.focused-item].id);
}
}
Rectangle {
for artist[i] in FavoritesState.artists-visible: KioskArtistCard {
x: root.pad + Math.mod(i, arts.cols) * (arts.cell + arts.gap);
@@ -111,6 +231,7 @@ export component KioskLibrary inherits Rectangle {
id: artist.id;
name: artist.name;
avatar: artist.image;
nav-focused: arts.item-focused && arts.focused-item == i;
clicked(id) => {
root.open-artist(id);
}
@@ -124,6 +245,33 @@ export component KioskLibrary inherits Rectangle {
property <length> cell: (self.width - 2 * root.pad - (self.cols - 1) * self.gap) / self.cols;
viewport-width: self.width;
viewport-height: Math.ceil(FavoritesState.labels-visible.length / self.cols) * (self.cell + 26px + self.gap) + root.pad;
// PR 2 — focus ring + Enter + scroll-into-view.
property <int> focused-item: KioskNavState.index - KioskNavState.tabs;
property <bool> item-focused: KioskNavState.nav-active
&& KioskNavState.zone == "content"
&& self.focused-item >= 0
&& self.focused-item < FavoritesState.labels-visible.length;
property <length> focus-top: root.pad + Math.floor(self.focused-item / self.cols) * (self.cell + 26px + self.gap);
property <int> nav-index-probe: KioskNavState.index;
changed nav-index-probe => {
if (self.item-focused) {
if (self.focus-top < -self.viewport-y) {
self.viewport-y = -(self.focus-top - 8px);
} else if (self.focus-top + self.cell + 26px > -self.viewport-y + self.height) {
self.viewport-y = -(self.focus-top + self.cell + 26px - self.height + 8px);
}
}
}
property <int> act-probe: KioskNavState.activate-seq;
changed act-probe => {
if (self.item-focused) {
FavoritesActions.open-label(
FavoritesState.labels-visible[self.focused-item].id,
FavoritesState.labels-visible[self.focused-item].name);
}
}
Rectangle {
for label[i] in FavoritesState.labels-visible: KioskArtistCard {
x: root.pad + Math.mod(i, labs.cols) * (labs.cell + labs.gap);
@@ -132,6 +280,7 @@ export component KioskLibrary inherits Rectangle {
id: label.id;
name: label.name;
avatar: label.image;
nav-focused: labs.item-focused && labs.focused-item == i;
clicked(id) => {
FavoritesActions.open-label(label.id, label.name);
}
@@ -145,11 +294,40 @@ export component KioskLibrary inherits Rectangle {
property <length> cell: (self.width - 2 * root.pad - (self.cols - 1) * self.gap) / self.cols;
viewport-width: self.width;
viewport-height: Math.ceil(FavoritesState.playlists-visible.length / self.cols) * (self.cell + 42px + self.gap) + root.pad;
// PR 2 — focus ring + Enter + scroll-into-view.
property <int> focused-item: KioskNavState.index - KioskNavState.tabs;
property <bool> item-focused: KioskNavState.nav-active
&& KioskNavState.zone == "content"
&& self.focused-item >= 0
&& self.focused-item < FavoritesState.playlists-visible.length;
property <length> focus-top: root.pad + Math.floor(self.focused-item / self.cols) * (self.cell + 42px + self.gap);
property <int> nav-index-probe: KioskNavState.index;
changed nav-index-probe => {
if (self.item-focused) {
if (self.focus-top < -self.viewport-y) {
self.viewport-y = -(self.focus-top - 8px);
} else if (self.focus-top + self.cell + 42px > -self.viewport-y + self.height) {
self.viewport-y = -(self.focus-top + self.cell + 42px - self.height + 8px);
}
}
}
property <int> act-probe: KioskNavState.activate-seq;
changed act-probe => {
if (self.item-focused) {
FavoritesActions.open-playlist(FavoritesState.playlists-visible[self.focused-item].id);
}
}
Rectangle {
for pl[i] in FavoritesState.playlists-visible: Rectangle {
x: root.pad + Math.mod(i, pls.cols) * (pls.cell + pls.gap);
y: root.pad + Math.floor(i / pls.cols) * (pls.cell + 42px + pls.gap);
width: pls.cell;
border-width: pls.item-focused && pls.focused-item == i ? 3px : 0px;
border-color: Theme.accent;
border-radius: Radius.sm;
background: pls.item-focused && pls.focused-item == i ? Theme.accent.with-alpha(0.12) : transparent;
VerticalLayout {
alignment: start;
spacing: 7px;
+119 -7
View File
@@ -5,10 +5,15 @@
// (open-album routes to ContentView.local-album in Rust). v1 simplifications:
// no album-window / tracks-load-more hooks yet (first band loads), folders as a
// flat grid.
//
// Keyboard/gamepad nav (PR 2): same model as KioskLibrary — the index space
// is the 4 tabs followed by the active tab's items as a uniform grid
// (tracks = a 1-column list). The view publishes the geometry; containers
// mirror the focus index and answer the Enter pulse.
import { ListView } from "std-widgets.slint";
import { Theme } from "../foundation/semantic-colors.slint";
import { Radius } from "../foundation/radius.slint";
import { LocalLibraryState, LocalLibraryActions } from "../state.slint";
import { LocalLibraryState, LocalLibraryActions, KioskNavState } from "../state.slint";
import { KioskAlbumGrid } from "KioskAlbumGrid.slint";
import { KioskArtistCard } from "KioskArtistCard.slint";
import { KioskTrackRow } from "KioskTrackRow.slint";
@@ -16,9 +21,14 @@ import { KioskTrackRow } from "KioskTrackRow.slint";
component LLTab inherits Rectangle {
in property <string> label;
in property <string> id;
// Keyboard/gamepad nav (PR 2): this tab holds the shell focus ring.
in property <bool> nav-focused: false;
callback picked(string);
property <bool> active: LocalLibraryState.active-tab == root.id;
horizontal-stretch: 0;
border-width: root.nav-focused ? 2px : 0px;
border-color: Theme.accent;
border-radius: Radius.sm;
HorizontalLayout {
padding-left: 2px;
padding-right: 2px;
@@ -47,6 +57,57 @@ component LLTab inherits Rectangle {
export component KioskLocalLibrary inherits Rectangle {
property <length> pad: 16px;
// ---- Nav geometry publish (PR 2): 4 tabs + the active tab's items.
function publish-nav() {
KioskNavState.tabs = 4;
KioskNavState.shelves = false;
if (LocalLibraryState.active-tab == "albums") {
KioskNavState.columns = 6;
KioskNavState.count = 4 + LocalLibraryState.albums-visible.length;
} else if (LocalLibraryState.active-tab == "artists") {
KioskNavState.columns = 7;
KioskNavState.count = 4 + LocalLibraryState.artists.length;
} else if (LocalLibraryState.active-tab == "folders") {
KioskNavState.columns = 6;
KioskNavState.count = 4 + LocalLibraryState.folders-visible.length;
} else {
KioskNavState.columns = 1;
KioskNavState.count = 4 + LocalLibraryState.tracks-visible.length;
}
KioskNavState.index = Math.max(0, Math.min(KioskNavState.index, KioskNavState.count - 1));
}
init => {
root.publish-nav();
}
property <string> nav-tab-probe: LocalLibraryState.active-tab;
changed nav-tab-probe => {
root.publish-nav();
}
property <int> nav-len-probe: LocalLibraryState.albums-visible.length
+ LocalLibraryState.artists.length
+ LocalLibraryState.folders-visible.length
+ LocalLibraryState.tracks-visible.length;
changed nav-len-probe => {
root.publish-nav();
}
// Enter on a tab → the same switch the tab TouchAreas drive.
property <int> act-probe: KioskNavState.activate-seq;
changed act-probe => {
if (KioskNavState.nav-active && KioskNavState.zone == "content"
&& KioskNavState.index < KioskNavState.tabs) {
if (KioskNavState.index == 0) {
LocalLibraryActions.select-tab("albums");
} else if (KioskNavState.index == 1) {
LocalLibraryActions.select-tab("artists");
} else if (KioskNavState.index == 2) {
LocalLibraryActions.select-tab("folders");
} else if (KioskNavState.index == 3) {
LocalLibraryActions.select-tab("tracks");
}
}
}
VerticalLayout {
spacing: 0px;
@@ -58,10 +119,10 @@ export component KioskLocalLibrary inherits Rectangle {
padding-right: root.pad;
spacing: 22px;
alignment: start;
LLTab { label: @tr("Albums"); id: "albums"; picked(t) => { LocalLibraryActions.select-tab(t); } }
LLTab { label: @tr("Artists"); id: "artists"; picked(t) => { LocalLibraryActions.select-tab(t); } }
LLTab { label: @tr("Folders"); id: "folders"; picked(t) => { LocalLibraryActions.select-tab(t); } }
LLTab { label: @tr("Tracks"); id: "tracks"; picked(t) => { LocalLibraryActions.select-tab(t); } }
LLTab { label: @tr("Albums"); id: "albums"; nav-focused: KioskNavState.nav-active && KioskNavState.zone == "content" && KioskNavState.index == 0; picked(t) => { LocalLibraryActions.select-tab(t); } }
LLTab { label: @tr("Artists"); id: "artists"; nav-focused: KioskNavState.nav-active && KioskNavState.zone == "content" && KioskNavState.index == 1; picked(t) => { LocalLibraryActions.select-tab(t); } }
LLTab { label: @tr("Folders"); id: "folders"; nav-focused: KioskNavState.nav-active && KioskNavState.zone == "content" && KioskNavState.index == 2; picked(t) => { LocalLibraryActions.select-tab(t); } }
LLTab { label: @tr("Tracks"); id: "tracks"; nav-focused: KioskNavState.nav-active && KioskNavState.zone == "content" && KioskNavState.index == 3; picked(t) => { LocalLibraryActions.select-tab(t); } }
}
Rectangle {
y: parent.height - 1px;
@@ -94,6 +155,32 @@ export component KioskLocalLibrary inherits Rectangle {
property <length> cell: (self.width - 2 * root.pad - (self.cols - 1) * self.gap) / self.cols;
viewport-width: self.width;
viewport-height: Math.ceil(LocalLibraryState.artists.length / self.cols) * (self.cell + 26px + self.gap) + root.pad;
// PR 2 — focus ring + Enter + scroll-into-view.
property <int> focused-item: KioskNavState.index - KioskNavState.tabs;
property <bool> item-focused: KioskNavState.nav-active
&& KioskNavState.zone == "content"
&& self.focused-item >= 0
&& self.focused-item < LocalLibraryState.artists.length;
property <length> focus-top: root.pad + Math.floor(self.focused-item / self.cols) * (self.cell + 26px + self.gap);
property <int> nav-index-probe: KioskNavState.index;
changed nav-index-probe => {
if (self.item-focused) {
if (self.focus-top < -self.viewport-y) {
self.viewport-y = -(self.focus-top - 8px);
} else if (self.focus-top + self.cell + 26px > -self.viewport-y + self.height) {
self.viewport-y = -(self.focus-top + self.cell + 26px - self.height + 8px);
}
}
}
property <int> act-probe: KioskNavState.activate-seq;
changed act-probe => {
if (self.item-focused) {
// The TouchArea path passes the artist NAME as id.
LocalLibraryActions.open-artist(LocalLibraryState.artists[self.focused-item].name);
}
}
Rectangle {
for artist[i] in LocalLibraryState.artists: KioskArtistCard {
x: root.pad + Math.mod(i, arts.cols) * (arts.cell + arts.gap);
@@ -102,6 +189,7 @@ export component KioskLocalLibrary inherits Rectangle {
id: artist.name;
name: artist.display-name;
avatar: artist.image;
nav-focused: arts.item-focused && arts.focused-item == i;
clicked(id) => {
LocalLibraryActions.open-artist(id);
}
@@ -109,9 +197,33 @@ export component KioskLocalLibrary inherits Rectangle {
}
}
if LocalLibraryState.active-tab == "tracks": ListView {
for track in LocalLibraryState.tracks-visible: KioskTrackRow {
if LocalLibraryState.active-tab == "tracks": tracks-list := ListView {
// PR 2 — focus ring + Enter + scroll-into-view (62px rows).
property <int> focused-item: KioskNavState.index - KioskNavState.tabs;
property <bool> item-focused: KioskNavState.nav-active
&& KioskNavState.zone == "content"
&& self.focused-item >= 0
&& self.focused-item < LocalLibraryState.tracks-visible.length;
property <int> nav-index-probe: KioskNavState.index;
changed nav-index-probe => {
if (self.item-focused) {
if (self.focused-item * 62px < -self.viewport-y) {
self.viewport-y = -(self.focused-item * 62px - 8px);
} else if (self.focused-item * 62px + 62px > -self.viewport-y + self.height) {
self.viewport-y = -(self.focused-item * 62px + 62px - self.height + 8px);
}
}
}
property <int> act-probe: KioskNavState.activate-seq;
changed act-probe => {
if (self.item-focused) {
LocalLibraryActions.track-action(LocalLibraryState.tracks-visible[self.focused-item].id, "play");
}
}
for track[i] in LocalLibraryState.tracks-visible: KioskTrackRow {
track: track;
nav-focused: tracks-list.item-focused && tracks-list.focused-item == i;
play(id) => {
LocalLibraryActions.track-action(id, "play");
}
+81 -1
View File
@@ -3,15 +3,24 @@
// Collections (separate ContentView routes); simple cover cards, tap opens the
// detail. Covers are push-only. Mounted for both ContentView.mixtapes and
// ContentView.collections.
//
// Keyboard/gamepad nav (PR 2): the index space is the 2 tabs followed by the
// active list as a 6-column grid. Enter on a tab routes through the same
// header-menu navigation the tab TouchAreas use.
import { Theme } from "../foundation/semantic-colors.slint";
import { Radius } from "../foundation/radius.slint";
import { MyQbzState, MyQbzActions, MixtapeCardItem, NavState, ContentView } from "../state.slint";
import { MyQbzState, MyQbzActions, MixtapeCardItem, NavState, ContentView, KioskNavState } from "../state.slint";
component MyQbzTab inherits Rectangle {
in property <string> label;
in property <bool> active;
// Keyboard/gamepad nav (PR 2): this tab holds the shell focus ring.
in property <bool> nav-focused: false;
callback picked;
horizontal-stretch: 0;
border-width: root.nav-focused ? 2px : 0px;
border-color: Theme.accent;
border-radius: Radius.sm;
HorizontalLayout {
padding-left: 2px;
padding-right: 2px;
@@ -40,8 +49,14 @@ component MyQbzTab inherits Rectangle {
component MyQbzCardLite inherits Rectangle {
in property <MixtapeCardItem> item;
in property <length> size: 150px;
// Keyboard/gamepad nav (PR 2) — same ring idiom as KioskCard.
in property <bool> nav-focused: false;
callback clicked(string);
width: root.size;
border-width: root.nav-focused ? 3px : 0px;
border-color: Theme.accent;
border-radius: Radius.sm;
background: root.nav-focused ? Theme.accent.with-alpha(0.12) : transparent;
VerticalLayout {
alignment: start;
spacing: 7px;
@@ -94,6 +109,37 @@ export component KioskMyQBZ inherits Rectangle {
property <length> pad: 16px;
property <bool> on-collections: NavState.view == ContentView.collections;
// ---- Nav geometry publish (PR 2): 2 tabs + the active list.
function publish-nav() {
KioskNavState.tabs = 2;
KioskNavState.shelves = false;
KioskNavState.columns = 6;
KioskNavState.count = 2 + (root.on-collections
? MyQbzState.collections.length
: MyQbzState.mixtapes.length);
KioskNavState.index = Math.max(0, Math.min(KioskNavState.index, KioskNavState.count - 1));
}
init => {
root.publish-nav();
}
property <int> nav-len-probe: MyQbzState.collections.length + MyQbzState.mixtapes.length;
changed nav-len-probe => {
root.publish-nav();
}
// Enter on a tab → the same route the tab TouchAreas navigate to.
property <int> act-probe: KioskNavState.activate-seq;
changed act-probe => {
if (KioskNavState.nav-active && KioskNavState.zone == "content"
&& KioskNavState.index < KioskNavState.tabs) {
if (KioskNavState.index == 0) {
root.navigate("myqbz-mixtapes");
} else if (KioskNavState.index == 1) {
root.navigate("myqbz-collections");
}
}
}
VerticalLayout {
spacing: 0px;
@@ -108,6 +154,7 @@ export component KioskMyQBZ inherits Rectangle {
MyQbzTab {
label: @tr("Mixtapes");
active: !root.on-collections;
nav-focused: KioskNavState.nav-active && KioskNavState.zone == "content" && KioskNavState.index == 0;
picked => {
root.navigate("myqbz-mixtapes");
}
@@ -115,6 +162,7 @@ export component KioskMyQBZ inherits Rectangle {
MyQbzTab {
label: @tr("Collections");
active: root.on-collections;
nav-focused: KioskNavState.nav-active && KioskNavState.zone == "content" && KioskNavState.index == 1;
picked => {
root.navigate("myqbz-collections");
}
@@ -136,12 +184,44 @@ export component KioskMyQBZ inherits Rectangle {
property <length> cell: (self.width - 2 * root.pad - (self.cols - 1) * self.gap) / self.cols;
viewport-width: self.width;
viewport-height: Math.ceil((root.on-collections ? MyQbzState.collections.length : MyQbzState.mixtapes.length) / self.cols) * (self.cell + 44px + self.gap) + root.pad;
// PR 2 — focus ring + Enter + scroll-into-view.
property <int> focused-item: KioskNavState.index - KioskNavState.tabs;
property <bool> item-focused: KioskNavState.nav-active
&& KioskNavState.zone == "content"
&& self.focused-item >= 0
&& self.focused-item < (root.on-collections
? MyQbzState.collections.length
: MyQbzState.mixtapes.length);
property <length> focus-top: root.pad + Math.floor(self.focused-item / self.cols) * (self.cell + 44px + self.gap);
property <int> nav-index-probe: KioskNavState.index;
changed nav-index-probe => {
if (self.item-focused) {
if (self.focus-top < -self.viewport-y) {
self.viewport-y = -(self.focus-top - 8px);
} else if (self.focus-top + self.cell + 44px > -self.viewport-y + self.height) {
self.viewport-y = -(self.focus-top + self.cell + 44px - self.height + 8px);
}
}
}
property <int> act-probe: KioskNavState.activate-seq;
changed act-probe => {
if (self.item-focused) {
if (root.on-collections) {
MyQbzActions.open-card(MyQbzState.collections[self.focused-item].id);
} else {
MyQbzActions.open-card(MyQbzState.mixtapes[self.focused-item].id);
}
}
}
Rectangle {
for item[i] in (root.on-collections ? MyQbzState.collections : MyQbzState.mixtapes): MyQbzCardLite {
x: root.pad + Math.mod(i, grid.cols) * (grid.cell + grid.gap);
y: root.pad + Math.floor(i / grid.cols) * (grid.cell + 44px + grid.gap);
size: grid.cell;
item: item;
nav-focused: grid.item-focused && grid.focused-item == i;
clicked(id) => {
MyQbzActions.open-card(id);
}
+104 -4
View File
@@ -7,15 +7,19 @@
// QueueState.panel-opened() on init so the paginated queue slice is fresh.
import { Theme } from "../foundation/semantic-colors.slint";
import { Radius } from "../foundation/radius.slint";
import { NowPlayingState, QueueState, QueueItem, LyricsState, ShellState } from "../state.slint";
import { NowPlayingState, QueueState, QueueItem, LyricsState, ShellState, KioskNavState } from "../state.slint";
import { LyricsLinesView } from "LyricsLinesView.slint";
component QueueRowLite inherits Rectangle {
in property <QueueItem> item;
// Keyboard/gamepad nav (PR 2) — same ring idiom as KioskTrackRow.
in property <bool> nav-focused: false;
callback play;
height: 58px;
border-radius: Radius.sm;
background: qrow.has-hover ? Theme.alpha-6 : transparent;
background: root.nav-focused ? Theme.accent.with-alpha(0.18) : qrow.has-hover ? Theme.alpha-6 : transparent;
border-width: root.nav-focused ? 2px : 0px;
border-color: Theme.accent;
HorizontalLayout {
padding-left: 8px;
padding-right: 12px;
@@ -74,8 +78,15 @@ component QueueRowLite inherits Rectangle {
component QueueTab inherits Rectangle {
in property <string> label;
in property <bool> active;
// Keyboard/gamepad nav (PR 2): this tab holds the shell focus ring.
// Only the right-panel Queue/History pair joins the nav model; the
// left Cover/Lyrics pair leaves this false.
in property <bool> nav-focused: false;
callback picked;
horizontal-stretch: 0;
border-width: root.nav-focused ? 2px : 0px;
border-color: Theme.accent;
border-radius: Radius.sm;
HorizontalLayout {
padding-left: 2px;
padding-right: 2px;
@@ -115,11 +126,52 @@ export component KioskNowPlaying inherits Rectangle {
// lightweight lyrics surface (no immersive visualizer needed, Pi-friendly).
property <bool> show-lyrics: false;
// ---- Keyboard/gamepad nav (PR 2). The nav model is the right panel:
// 2 queue tabs (Up Next / History) followed by the active tab's rows as
// a 1-column list. Enter on a row plays it; Enter on a tab switches.
// The left panel (cover/lyrics toggle, artist link, seek) stays
// touch-only in v1; the transport row joins the "player" zone (ring
// below; Enter = play/pause handled by the shell).
function publish-nav() {
KioskNavState.tabs = 2;
KioskNavState.shelves = false;
KioskNavState.columns = 1;
KioskNavState.count = 2 + (QueueState.tab == 0
? QueueState.upcoming-page.length
: QueueState.history.length);
KioskNavState.index = Math.max(0, Math.min(KioskNavState.index, KioskNavState.count - 1));
}
property <int> nav-tab-probe: QueueState.tab;
changed nav-tab-probe => {
root.publish-nav();
}
property <int> nav-len-probe: QueueState.upcoming-page.length + QueueState.history.length;
changed nav-len-probe => {
root.publish-nav();
}
// Enter pulse → queue tab switch / play the focused queue row.
property <int> act-probe: KioskNavState.activate-seq;
changed act-probe => {
if (KioskNavState.nav-active && KioskNavState.zone == "content") {
if (KioskNavState.index < KioskNavState.tabs) {
QueueState.set-tab(KioskNavState.index);
} else {
if (QueueState.tab == 0 && KioskNavState.index - 2 < QueueState.upcoming-page.length) {
QueueState.play-upcoming(KioskNavState.index - 2);
} else if (QueueState.tab == 1 && KioskNavState.index - 2 < QueueState.history.length) {
QueueState.play-history(KioskNavState.index - 2);
}
}
}
}
init => {
QueueState.panel-opened();
// Mount in Cover mode — make sure a stale follow flag from a prior visit
// doesn't keep the sync engine running.
ShellState.kiosk-lyrics-follow = false;
root.publish-nav();
}
HorizontalLayout {
@@ -278,6 +330,12 @@ export component KioskNowPlaying inherits Rectangle {
// space on short windows).
Rectangle {
height: 46px;
// PR 2 — the "player" zone ring (PlayerBarSmall is hidden
// on this view, so the in-page transport carries it).
border-width: KioskNavState.nav-active && KioskNavState.zone == "player" ? 3px : 0px;
border-color: Theme.accent;
border-radius: Radius.sm;
background: KioskNavState.nav-active && KioskNavState.zone == "player" ? Theme.accent.with-alpha(0.08) : transparent;
HorizontalLayout {
alignment: center;
TransportControls {
@@ -321,6 +379,7 @@ export component KioskNowPlaying inherits Rectangle {
QueueTab {
label: @tr("Up Next");
active: QueueState.tab == 0;
nav-focused: KioskNavState.nav-active && KioskNavState.zone == "content" && KioskNavState.index == 0;
picked => {
QueueState.set-tab(0);
}
@@ -328,6 +387,7 @@ export component KioskNowPlaying inherits Rectangle {
QueueTab {
label: @tr("History");
active: QueueState.tab == 1;
nav-focused: KioskNavState.nav-active && KioskNavState.zone == "content" && KioskNavState.index == 1;
picked => {
QueueState.set-tab(1);
}
@@ -341,26 +401,66 @@ export component KioskNowPlaying inherits Rectangle {
// the page is cheap.)
Rectangle {
vertical-stretch: 1;
if QueueState.tab == 0: Flickable {
if QueueState.tab == 0: up-flick := Flickable {
viewport-width: self.width;
viewport-height: up-body.preferred-height;
// PR 2 — scroll the focused row into view (60px
// pitch: 58px row + 2px spacing). Written from a
// callback on a settled layout.
property <int> focused-item: KioskNavState.index - KioskNavState.tabs;
property <bool> item-focused: KioskNavState.nav-active
&& KioskNavState.zone == "content"
&& self.focused-item >= 0
&& self.focused-item < QueueState.upcoming-page.length;
property <int> nav-index-probe: KioskNavState.index;
changed nav-index-probe => {
if (self.item-focused) {
if (self.focused-item * 60px < -self.viewport-y) {
self.viewport-y = -(self.focused-item * 60px - 8px);
} else if (self.focused-item * 60px + 58px > -self.viewport-y + self.height) {
self.viewport-y = -(self.focused-item * 60px + 58px - self.height + 8px);
}
}
}
up-body := VerticalLayout {
spacing: 2px;
for item[i] in QueueState.upcoming-page: QueueRowLite {
item: item;
nav-focused: up-flick.item-focused && up-flick.focused-item == i;
play => {
QueueState.play-upcoming(i);
}
}
}
}
if QueueState.tab == 1: Flickable {
if QueueState.tab == 1: hist-flick := Flickable {
viewport-width: self.width;
viewport-height: hist-body.preferred-height;
// PR 2 — same scroll-into-view as the Up Next list.
property <int> focused-item: KioskNavState.index - KioskNavState.tabs;
property <bool> item-focused: KioskNavState.nav-active
&& KioskNavState.zone == "content"
&& self.focused-item >= 0
&& self.focused-item < QueueState.history.length;
property <int> nav-index-probe: KioskNavState.index;
changed nav-index-probe => {
if (self.item-focused) {
if (self.focused-item * 60px < -self.viewport-y) {
self.viewport-y = -(self.focused-item * 60px - 8px);
} else if (self.focused-item * 60px + 58px > -self.viewport-y + self.height) {
self.viewport-y = -(self.focused-item * 60px + 58px - self.height + 8px);
}
}
}
hist-body := VerticalLayout {
spacing: 2px;
for item[i] in QueueState.history: QueueRowLite {
item: item;
nav-focused: hist-flick.item-focused && hist-flick.focused-item == i;
play => {
QueueState.play-history(i);
}
+141 -8
View File
@@ -8,7 +8,7 @@
import { ListView } from "std-widgets.slint";
import { Theme } from "../foundation/semantic-colors.slint";
import { Radius } from "../foundation/radius.slint";
import { SearchState, SearchActions } from "../state.slint";
import { SearchState, SearchActions, KioskNavState } from "../state.slint";
import { KioskAlbumGrid } from "KioskAlbumGrid.slint";
import { KioskArtistCard } from "KioskArtistCard.slint";
import { KioskTrackRow } from "KioskTrackRow.slint";
@@ -17,9 +17,14 @@ import { KioskCard } from "KioskCard.slint";
component SearchTab inherits Rectangle {
in property <string> label;
in property <int> index;
// Keyboard/gamepad nav (PR 2): this tab holds the shell focus ring.
in property <bool> nav-focused: false;
callback picked(int);
property <bool> active: SearchState.tab == root.index;
horizontal-stretch: 0;
border-width: root.nav-focused ? 2px : 0px;
border-color: Theme.accent;
border-radius: Radius.sm;
HorizontalLayout {
padding-left: 2px;
padding-right: 2px;
@@ -89,6 +94,55 @@ export component KioskSearch inherits Rectangle {
property <length> pad: 16px;
// ---- Keyboard/gamepad nav (PR 2). The index space is the 5 tabs
// (All/Albums/Tracks/Artists/Playlists) followed by the active tab's
// items as a uniform grid. The All dashboard publishes NO items in v1
// (tabs only — its hero/preview mosaic isn't a uniform grid); the tab
// strips remain fully reachable. Containers mirror the focus index and
// answer the Enter pulse; the albums grid wires itself (KioskAlbumGrid).
function publish-nav() {
KioskNavState.tabs = 5;
KioskNavState.shelves = false;
if (SearchState.tab == 1) {
KioskNavState.columns = 6;
KioskNavState.count = 5 + SearchState.albums.length;
} else if (SearchState.tab == 2) {
KioskNavState.columns = 1;
KioskNavState.count = 5 + SearchState.tracks.length;
} else if (SearchState.tab == 3) {
KioskNavState.columns = 7;
KioskNavState.count = 5 + SearchState.artists.length;
} else if (SearchState.tab == 4) {
KioskNavState.columns = 6;
KioskNavState.count = 5 + SearchState.playlists.length;
} else {
KioskNavState.columns = 1;
KioskNavState.count = 5;
}
KioskNavState.index = Math.max(0, Math.min(KioskNavState.index, KioskNavState.count - 1));
}
init => {
root.publish-nav();
}
property <int> nav-tab-probe: SearchState.tab;
changed nav-tab-probe => {
root.publish-nav();
}
property <int> nav-len-probe: SearchState.albums.length + SearchState.tracks.length
+ SearchState.artists.length + SearchState.playlists.length;
changed nav-len-probe => {
root.publish-nav();
}
// Enter on a tab → the same switch the tab TouchAreas drive.
property <int> act-probe: KioskNavState.activate-seq;
changed act-probe => {
if (KioskNavState.nav-active && KioskNavState.zone == "content"
&& KioskNavState.index < KioskNavState.tabs) {
SearchActions.tab-changed(KioskNavState.index);
}
}
VerticalLayout {
spacing: 0px;
@@ -101,11 +155,11 @@ export component KioskSearch inherits Rectangle {
padding-right: root.pad;
spacing: 22px;
alignment: start;
SearchTab { label: @tr("All"); index: 0; picked(i) => { SearchActions.tab-changed(i); } }
SearchTab { label: @tr("Albums"); index: 1; picked(i) => { SearchActions.tab-changed(i); } }
SearchTab { label: @tr("Tracks"); index: 2; picked(i) => { SearchActions.tab-changed(i); } }
SearchTab { label: @tr("Artists"); index: 3; picked(i) => { SearchActions.tab-changed(i); } }
SearchTab { label: @tr("Playlists"); index: 4; picked(i) => { SearchActions.tab-changed(i); } }
SearchTab { label: @tr("All"); index: 0; nav-focused: KioskNavState.nav-active && KioskNavState.zone == "content" && KioskNavState.index == 0; picked(i) => { SearchActions.tab-changed(i); } }
SearchTab { label: @tr("Albums"); index: 1; nav-focused: KioskNavState.nav-active && KioskNavState.zone == "content" && KioskNavState.index == 1; picked(i) => { SearchActions.tab-changed(i); } }
SearchTab { label: @tr("Tracks"); index: 2; nav-focused: KioskNavState.nav-active && KioskNavState.zone == "content" && KioskNavState.index == 2; picked(i) => { SearchActions.tab-changed(i); } }
SearchTab { label: @tr("Artists"); index: 3; nav-focused: KioskNavState.nav-active && KioskNavState.zone == "content" && KioskNavState.index == 3; picked(i) => { SearchActions.tab-changed(i); } }
SearchTab { label: @tr("Playlists"); index: 4; nav-focused: KioskNavState.nav-active && KioskNavState.zone == "content" && KioskNavState.index == 4; picked(i) => { SearchActions.tab-changed(i); } }
}
Rectangle {
y: parent.height - 1px;
@@ -365,9 +419,33 @@ export component KioskSearch inherits Rectangle {
}
// Tracks — windowed list (ListView mounts only the visible band).
if SearchState.tab == 2: ListView {
for track in SearchState.tracks: KioskTrackRow {
if SearchState.tab == 2: tracks-list := ListView {
// PR 2 — focus ring + Enter + scroll-into-view (62px rows).
property <int> focused-item: KioskNavState.index - KioskNavState.tabs;
property <bool> item-focused: KioskNavState.nav-active
&& KioskNavState.zone == "content"
&& self.focused-item >= 0
&& self.focused-item < SearchState.tracks.length;
property <int> nav-index-probe: KioskNavState.index;
changed nav-index-probe => {
if (self.item-focused) {
if (self.focused-item * 62px < -self.viewport-y) {
self.viewport-y = -(self.focused-item * 62px - 8px);
} else if (self.focused-item * 62px + 62px > -self.viewport-y + self.height) {
self.viewport-y = -(self.focused-item * 62px + 62px - self.height + 8px);
}
}
}
property <int> act-probe: KioskNavState.activate-seq;
changed act-probe => {
if (self.item-focused) {
root.media-action("track", SearchState.tracks[self.focused-item].id, "play");
}
}
for track[i] in SearchState.tracks: KioskTrackRow {
track: track;
nav-focused: tracks-list.item-focused && tracks-list.focused-item == i;
play(id) => {
root.media-action("track", id, "play");
}
@@ -381,6 +459,31 @@ export component KioskSearch inherits Rectangle {
property <length> cell: (self.width - 2 * root.pad - (self.cols - 1) * self.gap) / self.cols;
viewport-width: self.width;
viewport-height: Math.ceil(SearchState.artists.length / self.cols) * (self.cell + 26px + self.gap) + root.pad;
// PR 2 — focus ring + Enter + scroll-into-view.
property <int> focused-item: KioskNavState.index - KioskNavState.tabs;
property <bool> item-focused: KioskNavState.nav-active
&& KioskNavState.zone == "content"
&& self.focused-item >= 0
&& self.focused-item < SearchState.artists.length;
property <length> focus-top: root.pad + Math.floor(self.focused-item / self.cols) * (self.cell + 26px + self.gap);
property <int> nav-index-probe: KioskNavState.index;
changed nav-index-probe => {
if (self.item-focused) {
if (self.focus-top < -self.viewport-y) {
self.viewport-y = -(self.focus-top - 8px);
} else if (self.focus-top + self.cell + 26px > -self.viewport-y + self.height) {
self.viewport-y = -(self.focus-top + self.cell + 26px - self.height + 8px);
}
}
}
property <int> act-probe: KioskNavState.activate-seq;
changed act-probe => {
if (self.item-focused) {
root.open-artist(SearchState.artists[self.focused-item].id);
}
}
Rectangle {
for artist[i] in SearchState.artists: KioskArtistCard {
x: root.pad + Math.mod(i, agrid.cols) * (agrid.cell + agrid.gap);
@@ -389,6 +492,7 @@ export component KioskSearch inherits Rectangle {
id: artist.id;
name: artist.title;
avatar: artist.artwork;
nav-focused: agrid.item-focused && agrid.focused-item == i;
clicked(id) => {
root.open-artist(id);
}
@@ -403,11 +507,40 @@ export component KioskSearch inherits Rectangle {
property <length> cell: (self.width - 2 * root.pad - (self.cols - 1) * self.gap) / self.cols;
viewport-width: self.width;
viewport-height: Math.ceil(SearchState.playlists.length / self.cols) * (self.cell + 42px + self.gap) + root.pad;
// PR 2 — focus ring + Enter + scroll-into-view.
property <int> focused-item: KioskNavState.index - KioskNavState.tabs;
property <bool> item-focused: KioskNavState.nav-active
&& KioskNavState.zone == "content"
&& self.focused-item >= 0
&& self.focused-item < SearchState.playlists.length;
property <length> focus-top: root.pad + Math.floor(self.focused-item / self.cols) * (self.cell + 42px + self.gap);
property <int> nav-index-probe: KioskNavState.index;
changed nav-index-probe => {
if (self.item-focused) {
if (self.focus-top < -self.viewport-y) {
self.viewport-y = -(self.focus-top - 8px);
} else if (self.focus-top + self.cell + 42px > -self.viewport-y + self.height) {
self.viewport-y = -(self.focus-top + self.cell + 42px - self.height + 8px);
}
}
}
property <int> act-probe: KioskNavState.activate-seq;
changed act-probe => {
if (self.item-focused) {
root.media-action("playlist", SearchState.playlists[self.focused-item].id, "open");
}
}
Rectangle {
for pl[i] in SearchState.playlists: Rectangle {
x: root.pad + Math.mod(i, pgrid.cols) * (pgrid.cell + pgrid.gap);
y: root.pad + Math.floor(i / pgrid.cols) * (pgrid.cell + 42px + pgrid.gap);
width: pgrid.cell;
border-width: pgrid.item-focused && pgrid.focused-item == i ? 3px : 0px;
border-color: Theme.accent;
border-radius: Radius.sm;
background: pgrid.item-focused && pgrid.focused-item == i ? Theme.accent.with-alpha(0.12) : transparent;
VerticalLayout {
alignment: start;
spacing: 7px;
+211 -3
View File
@@ -19,7 +19,7 @@ import { LineEdit } from "std-widgets.slint";
import { Theme } from "../foundation/semantic-colors.slint";
import { Layout } from "../foundation/layout.slint";
import { Radius } from "../foundation/radius.slint";
import { NavState, ContentView, OfflineState, PlaylistState, PurchaseDetailState, SearchActions, UiFocusState, AppearanceState, ImmersiveState } from "../state.slint";
import { NavState, ContentView, OfflineState, PlaylistState, PurchaseDetailState, SearchActions, UiFocusState, AppearanceState, ImmersiveState, NowPlayingState, KioskNavState } from "../state.slint";
import { ImmersiveView } from "../immersive/ImmersiveView.slint";
import { PlayerBarSmall } from "PlayerBarSmall.slint";
import { NavRail } from "NavRail.slint";
@@ -73,6 +73,131 @@ export component KioskShell inherits Rectangle {
callback logout();
callback close-app();
callback recovery-login();
// Keyboard/gamepad nav (PR 2): emitted by the root FocusScope on Enter.
// The internal dispatch runs through nav-activate() below; this public
// seam exists so an outer layer (or a future gamepad driver) can observe
// or trigger the same "activate the focused item" request.
callback activate-focused();
// ---- Kiosk keyboard navigation (Steam Deck Game Mode plan, PR 2) ----
// One root FocusScope drives the whole kiosk: arrows move a focus ring
// through three zones — rail (bottom NavRail) → content (the mounted
// view's items) → player (the transport bar) — Enter activates the
// focused item, Esc goes back. The zone model lives in KioskNavState
// (state.slint); the mounted content view publishes its geometry there
// (tabs / columns / count / shelves) and mirrors `index` onto its focus
// rings. Pure Slint: PR 3's gamepad feeds this same path by injecting
// synthetic key events, so nothing here is keyboard-specific.
//
// Zone cycle (Down): rail → content → player → rail; Up reverses. The
// content index space is `tabs` leading tab-strip entries followed by
// the item rows (a `columns`-wide grid; columns == 1 = list/shelves).
function nav-left() {
KioskNavState.nav-active = true;
KioskNavState.dir = 0;
if (KioskNavState.zone == "rail") {
KioskNavState.index = Math.max(0, KioskNavState.index - 1);
} else if (KioskNavState.zone == "content") {
if (KioskNavState.index < KioskNavState.tabs) {
KioskNavState.index = Math.max(0, KioskNavState.index - 1);
} else if (KioskNavState.shelves) {
KioskNavState.hmove = -1;
} else if (Math.mod(KioskNavState.index - KioskNavState.tabs, KioskNavState.columns) > 0) {
KioskNavState.index = KioskNavState.index - 1;
}
}
}
function nav-right() {
KioskNavState.nav-active = true;
KioskNavState.dir = 0;
if (KioskNavState.zone == "rail") {
KioskNavState.index = Math.min(rail.tile-count - 1, KioskNavState.index + 1);
} else if (KioskNavState.zone == "content") {
if (KioskNavState.index < KioskNavState.tabs) {
KioskNavState.index = Math.min(KioskNavState.tabs - 1, KioskNavState.index + 1);
} else if (KioskNavState.shelves) {
KioskNavState.hmove = 1;
} else if (Math.mod(KioskNavState.index - KioskNavState.tabs, KioskNavState.columns) < KioskNavState.columns - 1
&& KioskNavState.index + 1 < KioskNavState.count) {
KioskNavState.index = KioskNavState.index + 1;
}
}
}
function nav-up() {
KioskNavState.nav-active = true;
KioskNavState.dir = -1;
if (KioskNavState.zone == "content") {
if (KioskNavState.index >= KioskNavState.tabs + KioskNavState.columns) {
KioskNavState.index = KioskNavState.index - KioskNavState.columns;
} else if (KioskNavState.index >= KioskNavState.tabs) {
// Grid row 0 → the tab strip above it (column → tab).
KioskNavState.index = Math.min(KioskNavState.tabs - 1, KioskNavState.index - KioskNavState.tabs);
} else {
// Tab strip (top of the cycle) → wrap down to the rail.
KioskNavState.zone = "rail";
KioskNavState.index = 0;
}
} else if (KioskNavState.zone == "rail") {
KioskNavState.zone = "player";
KioskNavState.index = 0;
} else {
KioskNavState.zone = "content";
KioskNavState.index = 0;
}
}
function nav-down() {
KioskNavState.nav-active = true;
KioskNavState.dir = 1;
if (KioskNavState.zone == "content") {
if (KioskNavState.index < KioskNavState.tabs) {
// Tab strip → the first item row (or the player bar when the
// view carries no items).
if (KioskNavState.count > KioskNavState.tabs) {
KioskNavState.index = KioskNavState.tabs;
} else {
KioskNavState.zone = "player";
KioskNavState.index = 0;
}
} else if (KioskNavState.index + KioskNavState.columns < KioskNavState.count) {
KioskNavState.index = KioskNavState.index + KioskNavState.columns;
} else {
KioskNavState.zone = "player";
KioskNavState.index = 0;
}
} else if (KioskNavState.zone == "player") {
KioskNavState.zone = "rail";
KioskNavState.index = 0;
} else {
KioskNavState.zone = "content";
KioskNavState.index = 0;
}
}
function nav-activate() {
KioskNavState.nav-active = true;
if (KioskNavState.zone == "player") {
// The transport is ONE focusable unit: Enter = play/pause.
if (NowPlayingState.has-track) {
NowPlayingState.toggle-play();
}
} else if (KioskNavState.zone == "rail") {
rail.activate-index(KioskNavState.index);
} else {
// Content views own their activation. They are conditionally
// mounted (can't be addressed from here), so the request goes
// out as a pulse the mounted view answers via a changed probe.
KioskNavState.activate-seq = KioskNavState.activate-seq + 1;
}
}
// Reset the nav model on every view switch: focus lands on the new
// view's content, parked at the first item.
property <ContentView> nav-view-probe: NavState.view;
changed nav-view-probe => {
KioskNavState.zone = "content";
KioskNavState.index = 0;
KioskNavState.dir = 0;
KioskNavState.hmove = 0;
}
// Kiosk chrome heights. The back bar + transport are one header-row tall
// each; the NavRail is a fat touch target.
@@ -180,6 +305,9 @@ export component KioskShell inherits Rectangle {
// on-screen keyboard (squeekboard) closes. Otherwise the
// search field keeps focus forever and the OSK never hides.
search-field.clear-focus();
// Hand keyboard focus back to the nav scope so arrows
// keep working after a search (PR 2).
keys.focus();
}
}
}
@@ -203,7 +331,11 @@ export component KioskShell inherits Rectangle {
// BEHIND the views (which handle their own taps); it only catches
// empty-area taps.
dismiss-osk-ta := TouchArea {
clicked => { search-field.clear-focus(); }
clicked => {
search-field.clear-focus();
// Same hand-back as the accepted path (PR 2).
keys.focus();
}
}
VerticalLayout {
vertical-stretch: 1;
@@ -500,7 +632,7 @@ export component KioskShell inherits Rectangle {
// --- Bottom NavRail (net-new). Each tile maps to the real route the
// Sidebar/Rust dispatcher already uses. ------------------------------
NavRail {
rail := NavRail {
height: root.navrail-height;
discover => {
root.header-menu-navigate("discover-home");
@@ -526,6 +658,82 @@ export component KioskShell inherits Rectangle {
}
}
// --- Player-zone focus ring (PR 2). The transport bar is ONE focusable
// unit (Enter toggles play/pause, handled in nav-activate). Drawn as an
// overlay on the bar's exact slot (above the NavRail); the Now Playing
// view hides PlayerBarSmall and draws its own ring on its in-page
// transport row instead.
if NavState.view != ContentView.now-playing
&& KioskNavState.nav-active
&& KioskNavState.zone == "player": Rectangle {
x: 0;
y: root.height - root.navrail-height - Layout.header-height;
width: root.width;
height: Layout.header-height;
border-width: 3px;
border-color: Theme.accent;
border-radius: Radius.sm;
background: Theme.accent.with-alpha(0.08);
}
// --- Root nav FocusScope (PR 2). Holds keyboard focus for the whole
// kiosk; TouchAreas never take focus, so it survives card/tile taps and
// only yields to the search LineEdit (which then sets
// UiFocusState.text-input-focused and every key is rejected here — typing
// must never navigate). Rejected keys keep their normal path.
keys := FocusScope {
key-pressed(event) => {
if (UiFocusState.text-input-focused) {
return reject;
}
// The immersive visualizer overlay owns its own keys while open.
if (ImmersiveState.open) {
return reject;
}
if (event.text == Key.LeftArrow) {
root.nav-left();
return accept;
}
if (event.text == Key.RightArrow) {
root.nav-right();
return accept;
}
if (event.text == Key.UpArrow) {
root.nav-up();
return accept;
}
if (event.text == Key.DownArrow) {
root.nav-down();
return accept;
}
if (event.text == Key.Return) {
root.activate-focused();
root.nav-activate();
return accept;
}
if (event.text == Key.Escape) {
if (NavState.can-back) {
NavState.request-back();
return accept;
}
return reject;
}
return reject;
}
}
// Deferred mount focus — synchronous focus during the initial layout
// races it; a one-shot timer grabs it a tick later (the desktop modal
// idiom, e.g. KeyboardShortcutsModal).
focus-timer := Timer {
interval: 30ms;
running: true;
triggered => {
self.running = false;
keys.focus();
}
}
// Immersive / visualizer overlay — the same app-level view the desktop
// mounts, but the desktop hangs it inside AppShell, so the kiosk shell must
// mount its own copy. Reached from the NavRail visualizer tile (which fires
+5 -1
View File
@@ -8,11 +8,15 @@ import { TrackItem } from "../state.slint";
export component KioskTrackRow inherits Rectangle {
in property <TrackItem> track;
// Keyboard/gamepad nav (PR 2) — same ring idiom as KioskCard.
in property <bool> nav-focused: false;
callback play(string);
height: 62px;
border-radius: Radius.sm;
background: ta.has-hover ? Theme.alpha-6 : transparent;
background: root.nav-focused ? Theme.accent.with-alpha(0.18) : ta.has-hover ? Theme.alpha-6 : transparent;
border-width: root.nav-focused ? 2px : 0px;
border-color: Theme.accent;
HorizontalLayout {
padding-left: 8px;
+29 -4
View File
@@ -8,20 +8,24 @@
// panel).
import { Theme } from "../foundation/semantic-colors.slint";
import { Radius } from "../foundation/radius.slint";
import { NavState, ContentView, ImmersiveState, MyQbzBrandingState } from "../state.slint";
import { NavState, ContentView, ImmersiveState, MyQbzBrandingState, KioskNavState } from "../state.slint";
component NavTile inherits Rectangle {
in property <image> icon;
in property <string> label;
in property <bool> active;
// Keyboard/gamepad nav (PR 2): this tile holds the shell focus ring.
in property <bool> nav-focused: false;
callback clicked;
border-radius: Radius.sm;
background: root.active
background: root.nav-focused
? Theme.accent.with-alpha(0.22)
: root.active
? Theme.accent.with-alpha(0.12)
: ta.has-hover ? Theme.alpha-6 : transparent;
border-width: root.active ? 1px : 0px;
border-color: Theme.accent.with-alpha(0.30);
border-width: root.nav-focused ? 2px : root.active ? 1px : 0px;
border-color: root.nav-focused ? Theme.accent : Theme.accent.with-alpha(0.30);
VerticalLayout {
alignment: center;
@@ -65,6 +69,20 @@ export component NavRail inherits Rectangle {
background: Theme.surface-main;
// Keyboard/gamepad nav (PR 2): tile count for the shell's rail zone and
// the activation seam for Enter (the rail is always mounted, so the
// shell calls this directly instead of using the activate-seq pulse).
out property <int> tile-count: 7;
public function activate-index(i: int) {
if (i == 0) { root.discover(); }
else if (i == 1) { root.library(); }
else if (i == 2) { root.local-library(); }
else if (i == 3) { root.myqbz(); }
else if (i == 4) { root.playing(); }
else if (i == 5) { root.visualizer(); }
else if (i == 6) { root.menu(); }
}
// 7 tiles, 8px outer padding, 6px gaps (6 gaps). Search is intentionally
// absent — the back-bar carries a persistent search field, so a footer
// Search tile is redundant.
@@ -87,6 +105,7 @@ export component NavRail inherits Rectangle {
icon: @image-url("../assets/icons/compass.svg");
label: @tr("Discover");
active: NavState.view == ContentView.home;
nav-focused: KioskNavState.nav-active && KioskNavState.zone == "rail" && KioskNavState.index == 0;
clicked => {
root.discover();
}
@@ -99,6 +118,7 @@ export component NavRail inherits Rectangle {
icon: @image-url("../assets/icons/library-big.svg");
label: @tr("Library");
active: NavState.view == ContentView.favorites;
nav-focused: KioskNavState.nav-active && KioskNavState.zone == "rail" && KioskNavState.index == 1;
clicked => {
root.library();
}
@@ -111,6 +131,7 @@ export component NavRail inherits Rectangle {
icon: @image-url("../assets/icons/hard-drive.svg");
label: @tr("Local Library");
active: NavState.view == ContentView.local-library || NavState.view == ContentView.local-album;
nav-focused: KioskNavState.nav-active && KioskNavState.zone == "rail" && KioskNavState.index == 2;
clicked => {
root.local-library();
}
@@ -125,6 +146,7 @@ export component NavRail inherits Rectangle {
active: NavState.view == ContentView.mixtapes
|| NavState.view == ContentView.collections
|| NavState.view == ContentView.mixtape-detail;
nav-focused: KioskNavState.nav-active && KioskNavState.zone == "rail" && KioskNavState.index == 3;
clicked => {
root.myqbz();
}
@@ -137,6 +159,7 @@ export component NavRail inherits Rectangle {
icon: @image-url("../assets/icons/play-fill.svg");
label: @tr("Now Playing");
active: NavState.view == ContentView.now-playing;
nav-focused: KioskNavState.nav-active && KioskNavState.zone == "rail" && KioskNavState.index == 4;
clicked => {
root.playing();
}
@@ -149,6 +172,7 @@ export component NavRail inherits Rectangle {
icon: @image-url("../assets/icons/sliders-horizontal.svg");
label: @tr("Visualizer");
active: false;
nav-focused: KioskNavState.nav-active && KioskNavState.zone == "rail" && KioskNavState.index == 5;
clicked => {
root.visualizer();
}
@@ -161,6 +185,7 @@ export component NavRail inherits Rectangle {
icon: @image-url("../assets/icons/settings.svg");
label: @tr("Settings");
active: NavState.view == ContentView.settings;
nav-focused: KioskNavState.nav-active && KioskNavState.zone == "rail" && KioskNavState.index == 6;
clicked => {
root.menu();
}
+54 -8
View File
@@ -382,9 +382,21 @@ export component QueueSidebar inherits Rectangle {
// Pointer window-Y -> insertion slot [0..N]. Rows are a fixed 44px stacked
// with 0 spacing; the +0.5 row rounds the boundary to row centers (top half
// of row k -> slot k, bottom half -> slot k+1).
// #442: section headers (22px) above row/slot index i — every drag
// computation below adds them to the uniform 44px strides.
pure function headers-above(i: int) -> int {
return (QueueState.page-manual-header && i >= 1 ? 1 : 0)
+ (QueueState.page-manual-pos >= 0 && i > QueueState.page-manual-pos ? 1 : 0);
}
pure function slot-from-pointer(win-y: length) -> int {
// One header-adjustment pass: with at most 2 headers and 44px rows,
// adjusting by the headers below the RAW slot lands on the right gap
// (boundary cases round to a row gap either way).
return Math.max(0, Math.min(QueueState.upcoming-page.length,
Math.floor((win-y - root.reorder-list-top) / 44px + 0.5)));
Math.floor((win-y - root.reorder-list-top
- root.headers-above(
Math.floor((win-y - root.reorder-list-top) / 44px + 0.5)) * 22px)
/ 44px + 0.5)));
}
function commit-reorder() {
root.reorder-active = false;
@@ -597,14 +609,45 @@ export component QueueSidebar inherits Rectangle {
letter-spacing: 0.5px;
}
// Page of upcoming rows + drop-indicator overlay. Explicit
// height (rows are a fixed 44px, spacing 0) so the wrapping
// Rectangle does not collapse — a VerticalLayout does not
// auto-size its parent Rectangle.
// height (rows are a fixed 44px, spacing 0; #442 section
// headers are 22px each) so the wrapping Rectangle does
// not collapse — a VerticalLayout does not auto-size its
// parent Rectangle.
Rectangle {
height: QueueState.upcoming-page.length * 44px;
height: QueueState.upcoming-page.length * 44px
+ QueueState.page-section-count * 22px;
VerticalLayout {
spacing: 0px;
for item[idx] in QueueState.upcoming-page: QueueRow {
for item[idx] in QueueState.upcoming-page: VerticalLayout {
spacing: 0px;
// #442 section header above the row (only on
// marked rows; "" everywhere else). The
// indent icon marks the manual block's
// insertion point (list-indent-increase).
if item.section != "": HorizontalLayout {
spacing: 6px;
height: 22px;
if item.section == "next-in-queue": VerticalLayout {
alignment: center;
QbzIcon {
source: @image-url("../assets/icons/list-indent-increase.svg");
width: 13px;
height: 13px;
tint: Theme.accent;
}
}
Text {
text: item.section == "next-in-queue"
? @tr("Next in queue")
: @tr("Next up");
color: Theme.text-muted;
font-size: 11px;
font-weight: Typography.semibold;
letter-spacing: 0.5px;
vertical-alignment: center;
}
}
QueueRow {
item: item;
index: QueueState.page-start - 1 + idx;
page-local-index: idx;
@@ -627,7 +670,8 @@ export component QueueSidebar inherits Rectangle {
root.reorder-active = true;
root.reorder-from = local-idx;
root.reorder-over = local-idx;
root.reorder-list-top = row-top - local-idx * 44px;
root.reorder-list-top = row-top
- (local-idx * 44px + root.headers-above(local-idx) * 22px);
}
drag-update(pointer-y) => {
root.reorder-over = root.slot-from-pointer(pointer-y);
@@ -638,13 +682,15 @@ export component QueueSidebar inherits Rectangle {
}
}
}
}
// Drop indicator: a 2px accent line at the insertion
// slot, hidden for the two no-op slots (from, from+1).
if root.reorder-active
&& root.reorder-over != root.reorder-from
&& root.reorder-over != root.reorder-from + 1: Rectangle {
x: 0;
y: root.reorder-over * 44px - 1px;
y: root.reorder-over * 44px
+ root.headers-above(root.reorder-over) * 22px - 1px;
width: parent.width;
height: 2px;
background: Theme.accent;
+17 -2
View File
@@ -26,6 +26,9 @@ component MenuItem inherits Rectangle {
in property <bool> selected: false;
in property <image> icon;
in property <bool> has-icon: false;
// Sort rows: "up" | "down" draws the direction chevron in place of the
// plain check on the active row (#657 caret).
in property <string> trailing-icon: "";
// When false the row is muted and ignores clicks (Import while
// offline). Same pattern as ContextMenuItem's enabled.
in property <bool> enabled: true;
@@ -56,11 +59,13 @@ component MenuItem inherits Rectangle {
horizontal-stretch: 1;
overflow: elide;
}
// Selected check (sort options).
// Selected check — or the direction chevron on a sort row.
if root.selected: VerticalLayout {
alignment: center;
QbzIcon {
source: @image-url("../assets/icons/check.svg");
source: root.trailing-icon == "up" ? @image-url("../assets/icons/chevron-up.svg")
: root.trailing-icon == "down" ? @image-url("../assets/icons/chevron-down.svg")
: @image-url("../assets/icons/check.svg");
width: 13px;
height: 13px;
tint: Theme.accent;
@@ -946,26 +951,36 @@ export component Sidebar inherits Rectangle {
MenuItem {
label: @tr("Name (A-Z)");
selected: SidebarState.sort-option == "name";
trailing-icon: SidebarState.sort-option == "name"
? (SidebarState.sort-asc ? "up" : "down") : "";
activated => { SidebarActions.set-sort("name"); }
}
MenuItem {
label: @tr("Recent");
selected: SidebarState.sort-option == "recent";
trailing-icon: SidebarState.sort-option == "recent"
? (SidebarState.sort-asc ? "up" : "down") : "";
activated => { SidebarActions.set-sort("recent"); }
}
MenuItem {
label: @tr("# of tracks");
selected: SidebarState.sort-option == "tracks";
trailing-icon: SidebarState.sort-option == "tracks"
? (SidebarState.sort-asc ? "up" : "down") : "";
activated => { SidebarActions.set-sort("tracks"); }
}
MenuItem {
label: @tr("Play Count");
selected: SidebarState.sort-option == "playcount";
trailing-icon: SidebarState.sort-option == "playcount"
? (SidebarState.sort-asc ? "up" : "down") : "";
activated => { SidebarActions.set-sort("playcount"); }
}
MenuItem {
label: @tr("Custom");
selected: SidebarState.sort-option == "custom";
trailing-icon: SidebarState.sort-option == "custom"
? (SidebarState.sort-asc ? "up" : "down") : "";
activated => { SidebarActions.set-sort("custom"); }
}
Rectangle {
@@ -129,9 +129,9 @@ export component TransportControls inherits HorizontalLayout {
}
add-menu := PopupWindow {
x: 0px;
y: -222px;
y: -255px;
width: 232px;
height: 214px;
height: 247px;
close-policy: close-on-click;
ContextMenu {
ContextMenuItem {
@@ -157,6 +157,13 @@ export component TransportControls inherits HorizontalLayout {
}
ContextMenuItem {
icon: @image-url("../assets/icons/list-plus.svg");
label: @tr("Play later");
clicked => {
root.media-action("track", NowPlayingState.track-id, "play-later");
}
}
ContextMenuItem {
icon: @image-url("../assets/icons/list-start.svg");
label: @tr("Play next");
clicked => {
root.media-action("track", NowPlayingState.track-id, "play-next");
+119 -18
View File
@@ -1029,12 +1029,12 @@ export global AlbumActions {
// Toggle multi-select (track selection) mode on the album track list.
callback toggle-multi-select();
// Bulk-bar action over the selected rows (id = "select-all" | "play-next"
// | "queue" | "add-to-playlist" | "add-to-favorites" | "make-offline" |
// "clear").
// | "play-later" | "queue" | "add-to-playlist" | "add-to-favorites" |
// "make-offline" | "clear").
callback bulk-action(string);
// Per-disc context-menu action on a multi-disc album's "Disc N" header —
// scoped to that disc's tracks only (action = "play" | "shuffle" | "queue"
// | "play-next"). The whole-album header buttons are unaffected.
// | "play-later" | "play-next"). The whole-album header buttons are unaffected.
callback disc-action(int /* disc number */, string /* action */);
// Open an external link (Last.fm / Discogs / MusicBrainz) in the system
// browser. The url is prebuilt by the controller (AlbumState.*-url).
@@ -1365,13 +1365,13 @@ export global LocalLibraryActions {
callback open-album(string /* album id */);
// Open an artist (lands with the Artists slice).
callback open-artist(string /* artist id */);
// Per-card action (play / queue / play-next / toggle-select). The Albums
// grid honors the shared AlbumCollectionView contract.
// Per-card action (play / queue / play-next / play-later / toggle-select).
// The Albums grid honors the shared AlbumCollectionView contract.
callback album-action(string /* album id */, string /* action */);
// Toggle the Albums-grid multi-select mode.
callback albums-toggle-multi-select();
// Albums bulk bar action: select-all | clear | queue | play-next |
// add-to-playlist | add-to-mixtape.
// play-later | add-to-playlist | add-to-mixtape.
callback albums-bulk-action(string /* action */);
// Windowed grid: the visible row band changed (item indices into
// albums-visible, prefetch margin included) — dispatch artwork for it.
@@ -1381,7 +1381,7 @@ export global LocalLibraryActions {
callback tracks-search(string /* query */);
callback tracks-load-more();
callback tracks-retry();
// Per-row play/queue/toggle-select (action: play|play-next|queue|toggle-select).
// Per-row play/queue/toggle-select (action: play|play-next|play-later|queue|toggle-select).
callback track-action(string /* track id */, string /* action */);
// Group-by mode (off|album|artist|name), persisted.
callback tracks-set-group(string /* mode */);
@@ -1389,7 +1389,7 @@ export global LocalLibraryActions {
callback tracks-set-sort(string /* key */);
// Toggle the multi-select checkbox column.
callback tracks-toggle-multi-select();
// Bulk bar action: select-all | clear | queue | play-next | add-to-playlist.
// Bulk bar action: select-all | clear | queue | play-next | play-later | add-to-playlist.
callback tracks-bulk-action(string /* action */);
// --- Folders tab ---
@@ -1416,7 +1416,7 @@ export global LocalLibraryActions {
callback folders-toggle-select-mode();
callback folders-toggle-folder-select(string /* folder path */);
callback folders-toggle-track-select(string /* track path */);
callback folders-bulk-action(string /* select-all|clear|queue|play-next|add-to-playlist */);
callback folders-bulk-action(string /* select-all|clear|queue|play-next|play-later|add-to-playlist */);
// --- Ephemeral folder (ad-hoc play) ---
// Open the native folder picker → scan → show the ephemeral pane.
@@ -1501,8 +1501,8 @@ export global LocalAlbumActions {
callback add-to-playlist();
callback add-to-mixtape();
callback play-track(string /* track id */);
// Per-row context-menu actions beyond "play" (play-next / queue /
// add-to-playlist) — resolved against the open version's track cache.
// Per-row context-menu actions beyond "play" (play-next / play-later /
// queue / add-to-playlist) — resolved against the open version's track cache.
callback track-menu-action(string /* track id */, string /* action */);
callback select-version(int);
// Client-side track filter (mirrors AlbumActions.search), applied over the
@@ -1510,7 +1510,8 @@ export global LocalAlbumActions {
callback search(string /* query */);
// Per-disc context-menu action on a multi-disc local album's "Disc N"
// header — scoped to that disc's tracks only (action = "play" | "shuffle" |
// "queue" | "play-next"). The whole-album header buttons are unaffected.
// "queue" | "play-later" | "play-next"). The whole-album header buttons are
// unaffected.
callback disc-action(int /* disc number */, string /* action */);
}
@@ -1933,6 +1934,10 @@ export global SidebarState {
// Active sort option: "name" | "recent" | "tracks" | "playcount"
// | "custom" (mirrors Tauri's SortOption). Persisted by crate::sidebar.
in-out property <string> sort-option: "name";
// Direction of the active sort (re-selecting the option in the menu
// flips it). true = the option's natural direction (name A→Z, recent
// newest-first, tracks most-first, custom position asc).
in-out property <bool> sort-asc: true;
// Micro-collage of track covers on playlist rows. Opt-out (ON by
// default) to match Tauri's `sidebarPlaylistCollage`. The Settings >
// Appearance toggle wires into this in a later step.
@@ -2315,6 +2320,10 @@ export global PlaylistManagerState {
in-out property <string> filter: "all";
// "name" | "recent" | "playcount" | "tracks" | "custom".
in-out property <string> sort: "name";
// Direction of the active sort (re-selecting the menu option flips it).
// true = natural direction (name A→Z, recent newest-first, playcount /
// tracks most-first, custom position asc).
in-out property <bool> sort-asc: true;
// "grid" | "list" | "tree".
in-out property <string> view-mode: "grid";
// Folder mode on (folders shown + folder filtering) vs flat.
@@ -3236,6 +3245,12 @@ export global AppearanceState {
in-out property <bool> intelligent-search: true;
// Player volume +/- stepper buttons — opt-in, hidden by default.
in-out property <bool> show-volume-steppers: false;
// Animate the now-playing row's equalizer bars. Default OFF — a static
// pause glyph + accent left-edge mark instead (CPU on some machines).
in-out property <bool> play-indicator-animation: false;
// Invert the two-finger touchpad swipe navigation (#653). Default OFF =
// natural-scrolling convention (fingers right = back, left = forward).
in-out property <bool> invert-swipe-navigation: false;
// --- NOTIFICATIONS -----------------------------------------------------
in-out property <bool> in-app-toasts: true;
@@ -3476,6 +3491,50 @@ export global NavState {
in-out property <length> scroll-restore: 0;
}
// Kiosk keyboard/gamepad navigation model (Steam Deck Game Mode plan, PR 2 —
// feature/internal/kiosk-key-nav). Pure Slint: the root FocusScope in
// KioskShell drives these properties from arrow/Enter/Esc keys; PR 3's
// gamepad layer injects the same synthetic key events, so nothing here is
// keyboard-specific. Additive with safe defaults — the desktop AppShell never
// touches this global.
//
// Zone flow: "rail" (bottom NavRail tiles) → "content" (the mounted view's
// navigable items) → "player" (the transport bar, one focusable unit).
// Down advances rail→content→player→rail; Up reverses. `index` is the
// focused item inside the zone. For content, the index space is
// `tabs` leading tab-strip entries followed by the item rows laid out as a
// `columns`-wide grid (`columns == 1` = vertical list or card shelves);
// the mounted view publishes `tabs` / `columns` / `count` / `shelves`
// whenever its tab or data changes. `nav-active` latches true on the first
// nav key and gates every focus-ring rendering (touch-only sessions never
// show a ring).
export global KioskNavState {
in-out property <string> zone: "content"; // "rail" | "content" | "player"
in-out property <int> index: 0;
in-out property <int> columns: 1;
in-out property <int> count: 0; // tabs + item count of the mounted content view
in-out property <bool> nav-active: false;
// Leading tab-strip entries inside the content index space (0 = the view
// has no tabs). Owned by the mounted view, never by shared grids.
in-out property <int> tabs: 0;
// Shelf mode (Discover / Artist): each content row is a horizontal card
// strip with its own cursor, so Left/Right can't move `index` (that
// would jump shelves). The shell emits an `hmove` pulse instead and the
// focused shelf consumes it into its local cursor.
in-out property <bool> shelves: false;
in-out property <int> hmove: 0; // pending shelf move: -1 | +1 (consumer resets to 0)
// Last vertical move direction (-1 up / +1 down / 0 none). Shelf views
// skip non-navigable rows (e.g. playlist/mix sections that render no
// album strip) by nudging `index` one more step this way.
in-out property <int> dir: 0;
// Enter pulse: the shell increments this instead of calling into views
// (conditionally-mounted children can't be addressed). The mounted zone
// owner (view, grid or list) reacts via a `changed` probe, checks the
// zone, and activates `index`. NavRail is always mounted, so the shell
// calls its `activate-index()` directly instead.
in-out property <int> activate-seq: 0;
}
// Artist detail screen state.
// One label row in the artist network sidebar's Labels section.
export struct LabelEntry {
@@ -3601,8 +3660,8 @@ export global ArtistActions {
// Toggle Popular Tracks multi-select mode.
callback toggle-top-tracks-select();
// Bulk-bar action over the selected Popular Tracks (id = "select-all" |
// "play-next" | "queue" | "add-to-playlist" | "add-to-favorites" |
// "clear").
// "play-next" | "play-later" | "queue" | "add-to-playlist" |
// "add-to-favorites" | "clear").
callback top-tracks-bulk-action(string);
// Popular Tracks section "more" menu (id = "next-all" | "queue-all" |
// "shuffle-all" | "playlist-all").
@@ -4145,6 +4204,9 @@ export struct QueueItem {
explicit: bool,
// From an ephemeral folder — gates persistence actions in the row menu.
is-ephemeral: bool,
// #442 section header drawn ABOVE this row: "" | "next-in-queue" (first
// hand-queued row) | "next-up" (first row after the manual block).
section: string,
}
// ImmersiveView audio visualizers. Driven at ~30fps by the Rust drain
@@ -4772,6 +4834,19 @@ export global QueueState {
in property <int> page-start: 0;
// Index of the last upcoming entry shown, 1-based, for the counter.
in property <int> page-end: 0;
// #442 section headers present in the current page (0-2: "Next in queue"
// and/or "Next up") — the sidebar adds their height to the rows block.
in property <int> page-section-count: 0;
// #442 drag-math aids: the "Next in queue" header sits above the page's
// first row, and the page-local index of the "Next up" row (-1 = not in
// this page).
in property <bool> page-manual-header: false;
in property <int> page-manual-pos: -1;
// #442 menu-hint numbers (TrackContextMenu "where does it land" suffix):
// total queue length, current index (-1 = none), manual-block size.
in property <int> queue-length: 0;
in property <int> queue-current-index: -1;
in property <int> manual-count: 0;
// --- History tab -----------------------------------------------------
in property <[QueueItem]> history: [];
@@ -5072,6 +5147,11 @@ export global FavoritesState {
in property <[AlbumCardItem]> albums-visible: []; // after search + sort (flat render)
in property <[DiscoverSection]> albums-grouped: []; // group-by sections
in-out property <string> albums-view-mode: "grid"; // "grid" | "list"
// Albums LIST-mode multi-select (owner 2026-07-24): checkbox column on
// the rows + the bulk bar (un-favorite / play-next / play-later / queue).
// List only — switching to grid forces it off.
in-out property <bool> albums-multi-select: false;
in property <int> albums-selected-count: 0;
in-out property <string> albums-search: "";
in-out property <string> albums-sort-by: "default"; // default|title-asc|title-desc|artist-asc
in-out property <string> albums-group-mode: "off"; // off|alpha|artist
@@ -5132,7 +5212,15 @@ export global FavoritesActions {
callback shuffle-tracks();
callback toggle-multi-select();
callback bulk-action(string /* select-all | remove-selected | clear */);
// Albums list-mode multi-select: toggle the edit mode + the bulk bar
// actions (select-all | play-next | play-later | queue | remove-selected | clear).
callback albums-toggle-multi-select();
callback albums-bulk-action(string);
callback album-action(string /* album id */, string /* action */);
// Generic artist action route (the All feed's ArtistGridCard follow
// button) — forwarded to the shared media-action ("artist", id, action)
// arm, which owns the optimistic flip + rollback.
callback artist-action(string /* artist id */, string /* action */);
callback albums-search(string /* query */);
callback search-labels(string /* query */);
callback playlists-set-sub-tab(string /* favorites | following */);
@@ -5140,7 +5228,7 @@ export global FavoritesActions {
callback labels-random();
callback search-playlists(string /* query */);
callback playlists-set-view(string /* grid | list */);
callback playlist-action(string /* playlist id */, string /* play | play-next | queue | share | favorite | follow | unfollow | copy */);
callback playlist-action(string /* playlist id */, string /* play | play-next | play-later | queue | share | favorite | follow | unfollow | copy */);
callback search-artists(string /* query */);
callback artists-set-view(string /* grid | sidepanel */);
callback artists-set-group(string /* off | alpha */);
@@ -5209,9 +5297,14 @@ export global LibraryAllState {
in-out property <bool> show-purchases: true;
in-out property <bool> show-favorites: true;
in-out property <bool> show-following: true;
// "show local" — includes local files + Plex (Phase 2). Off until the
// local-favorites layer lands.
in-out property <bool> show-local: false;
// "show local" — includes local files + Plex (the local-favorites layer).
// ON by default (users expect their local items in All); toggled from
// Settings > Local Library, persisted in favorites_ui.json.
in-out property <bool> show-local: true;
// What "local" means in the feed: "favorites" (hearted items only) |
// "all" (the whole local library). Display state for the Settings
// selector; the loader reads the persisted pref directly.
in-out property <string> local-scope: "favorites";
in property <int> total: 0; // badge count (deduped union)
in property <bool> loading: false;
in property <string> load-error: "";
@@ -5223,6 +5316,14 @@ export global LibraryAllActions {
callback set-sort(string /* date | title | artist */);
callback set-view(string /* grid | list */);
callback toggle-source(string /* purchases | favorites | following | local */);
// Settings > Local Library: what "local" means in the feed —
// "favorites" (hearted items only) | "all" (the whole local library).
// Reloads the feed.
callback set-local-scope(string);
// Viewport windowing: the grid/list report the mounted row band (item
// indices, prefetch margin included) so Rust dispatches covers only for
// the visible window and evicts far outside it.
callback window-changed(int /* first index */, int /* last index */);
callback open-item(string /* kind */, string /* id */);
callback media-action(string /* kind */, string /* id */, string /* action */);
callback retry();
+8 -1
View File
@@ -58,7 +58,14 @@ reqwest = { workspace = true }
image = { version = "0.25", default-features = false, features = ["jpeg", "png"] }
urlencoding = "2"
open = "5"
arboard = "3"
# Steam Deck plan PR 3: native gamepad nav (D-pad/stick/A/B → synthetic Slint
# keys). Pure Rust, evdev — no C deps.
gilrs = "0.11.2"
# wayland-data-control: Wayland-native clipboard via the data-control
# protocols (GNOME/KDE/sway/niri/Hyprland), falling back to X11 when Wayland
# is absent (#514 — without it arboard is X11-only and every copy fails on
# Wayland-only sessions).
arboard = { version = "3", features = ["wayland-data-control"] }
# async-std (rfd's name for the async-io/ashpd backend), NOT tokio: zbus 5's
# "tokio" feature unifies graph-wide and breaks accesskit/atspi/ksni threads
# (see the ashpd comment in qbz-credentials/Cargo.toml).
+14 -8
View File
@@ -137,9 +137,11 @@ pub enum ArtworkTarget {
/// main-column Playlists carousel). Single cover (slot 0), like
/// `LabelPlaylistCover`.
ArtistPlaylistCover { index: usize },
/// A card in the Library "All" mixed feed (`LibraryAllState.items-visible[index]`).
/// Dispatched against the VISIBLE model, re-dispatched on each derive.
LibraryAllCover { index: usize },
/// A card in the Library "All" mixed feed, keyed by `kind:id` (Qobuz
/// numeric ids overlap across entity types). Id-keyed + generation-guarded
/// so a derive re-sort between dispatch and apply cannot land a cover on
/// the wrong card; applied onto BOTH `items` and `items-visible`.
LibraryAllById { key: String, gen: u64 },
/// A row in `ArtistState.library-tracks[index]` — the ArtistPage
/// "In library" track list (library_by_artist seed). Row thumbnail.
ArtistLibraryTrack { index: usize },
@@ -1264,12 +1266,16 @@ fn apply_artwork(
model.set_row_data(index, item);
}
}
ArtworkTarget::LibraryAllCover { index } => {
let model = window.global::<crate::LibraryAllState>().get_items_visible();
if let Some(mut item) = model.row_data(index) {
item.image = image;
model.set_row_data(index, item);
ArtworkTarget::LibraryAllById { key, gen } => {
// The job is done either way — free its in-flight slot so the
// window dispatcher can re-request it after an eviction.
crate::library_all::artwork_job_done(&key);
// Drop the cover if a reload superseded the set it belongs to.
if !crate::library_all::library_all_gen_current(gen) {
return;
}
// Set by key onto the full set + visible rows.
crate::library_all::set_library_all_artwork(window, &key, image);
}
ArtworkTarget::ArtistLibraryTrack { index } => {
let model = window.global::<ArtistState>().get_library_tracks();
+121
View File
@@ -1611,6 +1611,127 @@ pub fn selected_tracks(window: &AppWindow) -> Vec<Track> {
.unwrap_or_default()
}
// ---- Albums LIST-mode multi-select (mirrors the tracks section) ---------
// Selection lives on AlbumCardItem.selected in `albums-visible` (flat) and
// each `albums-grouped` section model (grouped list renders the same rows).
/// Enter/leave albums multi-select; leaving clears the selection.
pub fn set_albums_multi_select(window: &AppWindow, on: bool) {
window.global::<FavoritesState>().set_albums_multi_select(on);
crate::selection::clear_anchor();
if !on {
clear_albums_selection(window);
}
}
/// Flip one album's `selected` across the flat + grouped models.
pub fn toggle_select_album(window: &AppWindow, id: &str) {
let st = window.global::<FavoritesState>();
let flip = |model: &slint::ModelRc<AlbumCardItem>| {
for i in 0..model.row_count() {
if let Some(mut item) = model.row_data(i) {
if item.id.as_str() == id {
item.selected = !item.selected;
model.set_row_data(i, item);
break;
}
}
}
};
flip(&st.get_albums_visible());
let grouped = st.get_albums_grouped();
for s in 0..grouped.row_count() {
if let Some(section) = grouped.row_data(s) {
flip(&section.albums);
}
}
recount_albums_selected(window);
}
/// Recount selected album rows into `albums-selected-count`.
pub fn recount_albums_selected(window: &AppWindow) {
let st = window.global::<FavoritesState>();
let visible = st.get_albums_visible();
let mut count = (0..visible.row_count())
.filter(|&i| visible.row_data(i).map(|a| a.selected).unwrap_or(false))
.count();
let grouped = st.get_albums_grouped();
for s in 0..grouped.row_count() {
if let Some(section) = grouped.row_data(s) {
let albums = section.albums;
count += (0..albums.row_count())
.filter(|&i| albums.row_data(i).map(|a| a.selected).unwrap_or(false))
.count();
}
}
st.set_albums_selected_count(count as i32);
}
/// Select-all toggle on the flat visible set (or clear when all selected).
pub fn select_all_albums(window: &AppWindow) {
let model = window.global::<FavoritesState>().get_albums_visible();
let total = model.row_count();
let selected = (0..total)
.filter(|&i| model.row_data(i).map(|a| a.selected).unwrap_or(false))
.count();
let target = selected != total;
for i in 0..total {
if let Some(mut item) = model.row_data(i) {
if item.selected != target {
item.selected = target;
model.set_row_data(i, item);
}
}
}
recount_albums_selected(window);
}
/// Deselect every album row (flat + grouped).
pub fn clear_albums_selection(window: &AppWindow) {
let st = window.global::<FavoritesState>();
let clear = |model: &slint::ModelRc<AlbumCardItem>| {
for i in 0..model.row_count() {
if let Some(mut item) = model.row_data(i) {
if item.selected {
item.selected = false;
model.set_row_data(i, item);
}
}
}
};
clear(&st.get_albums_visible());
let grouped = st.get_albums_grouped();
for s in 0..grouped.row_count() {
if let Some(section) = grouped.row_data(s) {
clear(&section.albums);
}
}
st.set_albums_selected_count(0);
}
/// The ids of the currently-selected album rows (flat + grouped).
pub fn selected_album_ids(window: &AppWindow) -> Vec<String> {
let st = window.global::<FavoritesState>();
let mut ids: Vec<String> = (0..st.get_albums_visible().row_count())
.filter_map(|i| st.get_albums_visible().row_data(i))
.filter(|a| a.selected)
.map(|a| a.id.to_string())
.collect();
let grouped = st.get_albums_grouped();
for s in 0..grouped.row_count() {
if let Some(section) = grouped.row_data(s) {
ids.extend(
(0..section.albums.row_count())
.filter_map(|i| section.albums.row_data(i))
.filter(|a| a.selected)
.map(|a| a.id.to_string()),
);
}
}
ids
}
pub fn reset_loading(window: &AppWindow) {
let state = window.global::<FavoritesState>();
state.set_loading(true);
+51 -2
View File
@@ -8,7 +8,7 @@ use std::path::PathBuf;
use serde::{Deserialize, Serialize};
use slint::ComponentHandle;
use crate::{AppWindow, FavoritesState};
use crate::{AppWindow, FavoritesState, LibraryAllState};
#[derive(Serialize, Deserialize)]
struct Prefs {
@@ -26,6 +26,17 @@ struct Prefs {
artists_group: bool,
#[serde(default = "d_grid")]
artists_view: String,
// Library "All": include local files + Plex in the feed (the toolbar's
// hard-drive filter). Default ON — users expect their local items there
// (issuers' report).
#[serde(default = "d_true")]
all_show_local: bool,
// Library "All": what "local" means in the feed — "favorites" (only
// hearted local items, webplayer parity) or "all" (the entire local
// library: every folder + Plex album/artist/track). Settings > Local
// Library selector (owner 2026-07-24).
#[serde(default = "d_favorites")]
all_local_scope: String,
}
impl Default for Prefs {
@@ -38,6 +49,8 @@ impl Default for Prefs {
playlists_view: d_grid(),
artists_group: false,
artists_view: d_grid(),
all_show_local: true,
all_local_scope: d_favorites(),
}
}
}
@@ -51,6 +64,12 @@ fn d_default() -> String {
fn d_off() -> String {
"off".to_string()
}
fn d_true() -> bool {
true
}
fn d_favorites() -> String {
"favorites".to_string()
}
fn store_path() -> Option<PathBuf> {
Some(dirs::data_dir()?.join("qbz").join("favorites_ui.json"))
@@ -77,9 +96,17 @@ pub fn load(window: &AppWindow) {
st.set_playlists_view_mode(p.playlists_view.into());
st.set_artists_group_enabled(p.artists_group);
st.set_artists_view_mode(p.artists_view.into());
window
.global::<LibraryAllState>()
.set_show_local(p.all_show_local);
window
.global::<LibraryAllState>()
.set_local_scope(p.all_local_scope.clone().into());
}
/// Persist the current toolbar choices read from `FavoritesState`.
/// Persist the current toolbar choices read from `FavoritesState`. Preserves
/// the Library-All local SCOPE (read-modify-write — it lives only here, not
/// in any Slint global).
pub fn save(window: &AppWindow) {
let Some(path) = store_path() else {
return;
@@ -93,6 +120,28 @@ pub fn save(window: &AppWindow) {
playlists_view: st.get_playlists_view_mode().into(),
artists_group: st.get_artists_group_enabled(),
artists_view: st.get_artists_view_mode().into(),
all_show_local: window.global::<LibraryAllState>().get_show_local(),
all_local_scope: read().all_local_scope,
};
if let Some(parent) = path.parent() {
let _ = std::fs::create_dir_all(parent);
}
if let Ok(json) = serde_json::to_vec_pretty(&p) {
let _ = std::fs::write(&path, json);
}
}
/// The persisted Library-All local scope: "favorites" (default) | "all".
pub fn local_scope() -> String {
read().all_local_scope
}
/// Persist a new Library-All local scope (read-modify-write).
pub fn set_local_scope(scope: &str) {
let mut p = read();
p.all_local_scope = scope.to_string();
let Some(path) = store_path() else {
return;
};
if let Some(parent) = path.parent() {
let _ = std::fs::create_dir_all(parent);
+190
View File
@@ -0,0 +1,190 @@
//! Native gamepad input — Steam Deck plan PR 3
//! (qbz-nix-docs/steam-deck/STEAM_DECK_GAME_MODE_PLAN.md §5).
//!
//! Steam Input's default template for non-Steam shortcuts exposes a VIRTUAL
//! Xbox 360 pad (uinput), not keyboard events — and QBZ reads neither by
//! default. `gilrs` (pure Rust, evdev) reads that pad and translates
//! D-pad / left stick / A / B into synthetic Slint key events through the
//! same `dispatch_event` pipeline as the winit synthetic-key hook
//! (main.rs install_browser_mouse_nav). PR 2's kiosk FocusScope consumes
//! them as ordinary arrow/Enter/Escape presses; any USB/BT pad on a HTPC
//! gets the same ride.
//!
//! The thread is spawned only when the kiosk profile is active or
//! `QBZ_GAMEPAD=1` forces it (see the call site in main.rs).
use std::time::{Duration, Instant};
use gilrs::{Axis, Button, EventType, Gilrs};
use slint::platform::{Key, WindowEvent};
use slint::ComponentHandle;
use crate::AppWindow;
/// Axis→direction hysteresis: engage when |axis| crosses the high threshold,
/// release below the low one (classic 10-foot anti-jitter; stick drift near
/// center must cause zero ghost navigation).
const AXIS_ENGAGE: f32 = 0.6;
const AXIS_RELEASE: f32 = 0.4;
/// Key repeat while a direction is held: initial delay, then interval.
const REPEAT_DELAY: Duration = Duration::from_millis(250);
const REPEAT_INTERVAL: Duration = Duration::from_millis(80);
/// Double-input guard for a user-selected keyboard-EMULATING Steam layout:
/// the same nav action can then arrive from Steam's kbd hook AND from gilrs —
/// collapse repeats of the same key inside this window.
const DEBOUNCE: Duration = Duration::from_millis(30);
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
enum Dir {
Up,
Down,
Left,
Right,
}
impl Dir {
fn key(self) -> Key {
match self {
Dir::Up => Key::UpArrow,
Dir::Down => Key::DownArrow,
Dir::Left => Key::LeftArrow,
Dir::Right => Key::RightArrow,
}
}
}
/// Spawn the gamepad listener thread (no-op log on failure — gamepad input
/// is a convenience, never fatal).
pub fn spawn(weak: slint::Weak<AppWindow>) {
if let Err(e) = std::thread::Builder::new()
.name("qbz-gamepad".into())
.spawn(move || run(weak))
{
log::warn!("[gamepad] could not spawn thread: {e}");
}
}
/// Press+release `key` as one synthetic tap on the UI thread, debounced.
/// A Return/Escape tap is a single action; arrows are also emitted by the
/// repeat engine. While a text field is focused, Escape is SUPPRESSED (B
/// on the pad must close Steam's OSK without also navigating back — plan
/// §4.5) and Return passes through (the OSK's own accept).
fn emit_tap(weak: &slint::Weak<AppWindow>, key: Key, last: &mut Option<(Key, Instant)>) {
let now = Instant::now();
if let Some((prev, at)) = last {
if *prev == key && now.duration_since(*at) < DEBOUNCE {
return;
}
}
*last = Some((key, now));
let _ = weak.upgrade_in_event_loop(move |w| {
if key == Key::Escape
&& w.global::<crate::UiFocusState>().get_text_input_focused()
{
return;
}
let text: slint::SharedString = key.into();
w.window().dispatch_event(WindowEvent::KeyPressed { text: text.clone() });
w.window().dispatch_event(WindowEvent::KeyReleased { text });
});
}
fn run(weak: slint::Weak<AppWindow>) {
let mut gilrs = match Gilrs::new() {
Ok(g) => g,
Err(e) => {
log::warn!("[gamepad] gilrs init failed (no evdev access?): {e}");
return;
}
};
let pads = gilrs.gamepads().count();
log::info!("[gamepad] listening ({pads} pad(s) connected)");
// Held direction for repeat: (dir, engaged_at, last_emit). One at a
// time — the newest direction wins, like any 10-foot UI.
let mut held: Option<(Dir, Instant, Instant)> = None;
let mut last_emit: Option<(Key, Instant)> = None;
loop {
// Block until the next pad event, waking at most by the repeat tick
// so a held direction keeps firing with no pad traffic.
let wake = held.map(|(_, _, last_emit_at)| {
REPEAT_INTERVAL.saturating_sub(last_emit_at.elapsed())
});
let event = match wake {
Some(t) => gilrs.next_event_blocking(Some(t)),
None => gilrs.next_event_blocking(None),
};
let now = Instant::now();
match event.map(|e| e.event) {
Some(EventType::ButtonPressed(Button::South, _)) => {
emit_tap(&weak, Key::Return, &mut last_emit);
}
Some(EventType::ButtonPressed(Button::East, _)) => {
emit_tap(&weak, Key::Escape, &mut last_emit);
}
Some(EventType::ButtonPressed(btn, _)) => {
if let Some(dir) = dpad_dir(btn) {
emit_tap(&weak, dir.key(), &mut last_emit);
held = Some((dir, now, now));
}
}
Some(EventType::ButtonReleased(btn, _)) => {
if let Some(dir) = dpad_dir(btn) {
if held.map(|(d, _, _)| d) == Some(dir) {
held = None;
}
}
}
Some(EventType::AxisChanged(axis, value, _)) => {
let (neg, pos) = match axis {
Axis::LeftStickX => (Dir::Left, Dir::Right),
Axis::LeftStickY => (Dir::Up, Dir::Down),
_ => {
continue;
}
};
if value.abs() >= AXIS_ENGAGE {
let dir = if value < 0.0 { neg } else { pos };
if held.map(|(d, _, _)| d) != Some(dir) {
emit_tap(&weak, dir.key(), &mut last_emit);
held = Some((dir, now, now));
}
} else if value.abs() < AXIS_RELEASE {
// Only disengage when the held direction lives on THIS axis.
if let Some((d, _, _)) = held {
if d == neg || d == pos {
held = None;
}
}
}
}
Some(_) => {}
// Timeout wake (or a spurious None): fire the repeat engine.
None => {}
}
// Repeat: a held direction re-emits after the initial delay, then on
// every interval.
if let Some((dir, engaged_at, last_emit_at)) = held.as_mut() {
if engaged_at.elapsed() >= REPEAT_DELAY
&& last_emit_at.elapsed() >= REPEAT_INTERVAL
{
let key = dir.key();
emit_tap(&weak, key, &mut last_emit);
*last_emit_at = Instant::now();
}
}
}
}
fn dpad_dir(btn: Button) -> Option<Dir> {
match btn {
Button::DPadUp => Some(Dir::Up),
Button::DPadDown => Some(Dir::Down),
Button::DPadLeft => Some(Dir::Left),
Button::DPadRight => Some(Dir::Right),
_ => None,
}
}
+5
View File
@@ -545,6 +545,11 @@ fn toggle_immersive(window: &AppWindow) {
}
fn toggle_miniplayer() {
// Suppressed under gamescope (Steam Deck Game Mode PR 1): an extra
// mapped window can steal the compositor's focused-window pick.
if crate::in_gamescope() {
return;
}
if crate::miniplayer::is_open() {
crate::miniplayer::exit();
} else {
+464 -16
View File
@@ -7,13 +7,14 @@
//! into `LibraryAllState`. Search / sort / source-switch filtering all run in Rust
//! (`derive`) — Slint renders the pre-computed `items-visible`.
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Arc;
use qbz_app::shell::AppRuntime;
use slint::{ComponentHandle, Model, ModelRc, VecModel};
use crate::adapter::SlintAdapter;
use crate::artwork::{ArtworkJob, ArtworkTarget};
use crate::artwork::{ArtworkJob, ArtworkTarget, ImageCache};
use crate::favorites::{self, FavData, FavTab};
use crate::{AppWindow, LibraryAllState, LibraryFeedItem};
@@ -295,9 +296,16 @@ pub async fn load_library_all(runtime: &Runtime) -> Result<Vec<Feed>, String> {
}
}
// --- Local + Plex favorites (source "local"/"plex"; gated by show-local
// in derive). group "local" — bypasses the Qobuz source switches. ---
{
// --- Local + Plex (source "local"/"plex"; gated by show-local in
// derive). group "local" — bypasses the Qobuz source switches. The
// Settings scope picks the CONTENT: "favorites" (hearted items only,
// webplayer parity) or "all" (the entire local library). ---
if crate::favorites_prefs::local_scope() == "all" {
match tokio::task::spawn_blocking(all_local_blocking).await {
Ok(items) => feed.extend(items),
Err(e) => log::error!("[qbz-slint] all-local feed load failed: {e}"),
}
} else {
let locals = crate::local_favorites::list();
let n = locals.len();
for (i, lf) in locals.into_iter().enumerate() {
@@ -331,6 +339,184 @@ pub async fn load_library_all(runtime: &Runtime) -> Result<Vec<Feed>, String> {
Ok(feed)
}
/// The "all" local scope (Settings > Local Library): every local-library
/// album + artist + track, mirroring the Local Library tabs' own queries so
/// both surfaces agree (same Plex union, same network-folder gate, same
/// album-identity mode, offline-cache copies excluded — they must never
/// duplicate Qobuz rows in this feed). Runs on a blocking thread — rusqlite
/// is sync. Off-thread companion of the local arm of `load_library_all`.
fn all_local_blocking() -> Vec<Feed> {
let mut out: Vec<Feed> = Vec::new();
let exclude_network = crate::local_library::exclude_network_folders_now();
let group_mode = qbz_library::album_grouping::AlbumGroupMode::from_pref(
&crate::locallibrary_prefs::albums_id_mode(),
);
let plex_path = crate::local_library::plex_cache_db_path();
let plex_enabled = crate::plex_settings::get().enabled;
// Plex tracks ride TWO sections (artist names + the track list) — fetch
// the bounded set once (mirrors the Tracks tab's page-1 merge).
let plex_tracks: Vec<qbz_library::LocalTrack> = if plex_enabled {
qbz_plex::plex_cache_search_tracks(String::new(), None)
.map(|rows| {
rows.into_iter()
.map(crate::local_library::map_plex_cached_to_local_track)
.collect()
})
.unwrap_or_default()
} else {
Vec::new()
};
// --- Albums — the Albums tab's own full-load page (Plex union folded in
// by the query when enabled). ---
let albums = crate::library_db::with_db(|db| {
db.get_albums_metadata_page(
0,
crate::local_library::ALBUMS_FULL_LOAD_LIMIT,
None,
"artist",
"asc",
true,
exclude_network,
plex_path.as_deref(),
group_mode,
)
.map(|p| p.albums)
})
.unwrap_or_default();
let n = albums.len();
for (i, a) in albums.into_iter().enumerate() {
let source = if a.id.starts_with("plex:") {
"plex"
} else {
"local"
};
let (tier, detail, _) =
crate::quality::badge(&a.format.to_string(), a.bit_depth, Some(a.sample_rate));
out.push(Feed {
kind: "album".into(),
group: "local".into(),
source: source.into(),
subtitle: a.artist.clone(),
artist: a.artist,
artist_id: String::new(),
album: String::new(),
album_id: String::new(),
image_url: a.artwork_path.unwrap_or_default(),
quality_tier: tier.into(),
quality_detail: detail.into(),
is_favorite: crate::local_favorites::is_favorite("album", &a.id),
genre: String::new(),
added_rank: rank(i, n),
id: a.id,
title: a.title,
..Default::default()
});
}
// --- Artists — local DB names + names that only exist on Plex tracks
// (a local + Plex artist of the same name counts once). ---
let local_artist_names: Vec<String> = crate::library_db::with_db(|db| db.get_artists())
.unwrap_or_default()
.into_iter()
.map(|a| a.name)
.filter(|n| !n.trim().is_empty())
.collect();
let local_keys: std::collections::HashSet<String> = local_artist_names
.iter()
.map(|n| n.trim().to_lowercase())
.collect();
let mut artist_names = local_artist_names;
{
let mut seen = local_keys.clone();
for t in &plex_tracks {
let name = t.artist.trim();
if !name.is_empty() && seen.insert(name.to_lowercase()) {
artist_names.push(name.to_string());
}
}
}
artist_names.sort_by_key(|n| n.to_lowercase());
let n = artist_names.len();
for (i, name) in artist_names.into_iter().enumerate() {
let source = if local_keys.contains(&name.trim().to_lowercase()) {
"local"
} else {
"plex"
};
out.push(Feed {
kind: "artist".into(),
group: "local".into(),
source: source.into(),
subtitle: String::new(),
artist: String::new(),
artist_id: String::new(),
album: String::new(),
album_id: String::new(),
image_url: String::new(),
quality_tier: String::new(),
quality_detail: String::new(),
is_favorite: crate::local_favorites::is_favorite("artist", &name),
genre: String::new(),
added_rank: rank(i, n),
id: name.clone(),
title: name,
..Default::default()
});
}
// --- Tracks — local pages (offline-cache copies excluded in SQL) + the
// Plex set fetched above. ---
let mut tracks: Vec<qbz_library::LocalTrack> = Vec::new();
const PAGE: u64 = 200;
let mut offset = 0u64;
loop {
let rows = crate::library_db::with_db(|db| {
db.search_with_filter_page("", offset, PAGE, false, exclude_network, "default")
});
let Some(rows) = rows else { break };
let full = rows.len() as u64 == PAGE;
tracks.extend(rows);
if !full || tracks.len() >= 200_000 {
break;
}
offset += PAGE;
}
tracks.extend(plex_tracks);
let n = tracks.len();
for (i, t) in tracks.into_iter().enumerate() {
let (id, source) = match t.source.as_deref() {
Some("plex") => (format!("plex:{}", t.file_path), "plex"),
Some("qobuz_download") => continue, // belt: the SQL already excludes
_ => (t.file_path.clone(), "local"),
};
let (tier, detail, _) =
crate::quality::badge(&t.format.to_string(), t.bit_depth, Some(t.sample_rate));
out.push(Feed {
kind: "track".into(),
group: "local".into(),
source: source.into(),
subtitle: t.artist.clone(),
artist: t.artist,
artist_id: String::new(),
album: t.album,
album_id: t.album_group_key,
image_url: t.artwork_path.unwrap_or_default(),
quality_tier: tier.into(),
quality_detail: detail.into(),
is_favorite: crate::local_favorites::is_favorite("track", &id),
genre: t.genre.unwrap_or_default(),
added_rank: rank(i, n),
id,
title: t.title,
..Default::default()
});
}
out
}
fn to_item(f: &Feed) -> LibraryFeedItem {
LibraryFeedItem {
kind: f.kind.clone().into(),
@@ -477,24 +663,286 @@ pub fn derive(window: &AppWindow) {
}
st.set_items_visible(ModelRc::new(VecModel::from(out)));
// The grid/list only fire window-changed when the BAND changes, not when
// the rows under it change — re-dispatch covers for the current band over
// the fresh visible set (no-op where covers already ride the full model).
dispatch_library_all_window(window);
}
/// Build cover-download jobs for the CURRENT visible feed. Call after apply and
/// after every derive (the ImageCache dedups already-decoded covers, so
/// re-dispatching on filter/sort is cheap). Indices target `items-visible`.
pub fn artwork_jobs(window: &AppWindow) -> Vec<ArtworkJob> {
let visible = window.global::<LibraryAllState>().get_items_visible();
// ---- Windowed artwork (mirrors favorites' albums grid, Phase 1 pattern) ---
/// Generation guard, bumped on every Library-All (re)load. A stale in-flight
/// cover fetch (an older load's job) is discarded on apply so it can't land
/// on the replacement set.
static LIB_ALL_GEN: AtomicU64 = AtomicU64::new(0);
/// True if `gen` is still the current Library-All generation. The artwork
/// pipeline calls this before applying a decoded cover so an in-flight job
/// from a superseded load doesn't paint the new model.
pub fn library_all_gen_current(gen: u64) -> bool {
LIB_ALL_GEN.load(Ordering::SeqCst) == gen
}
/// Last row band reported by the windowed grid/list (item indices into
/// `items-visible`, prefetch margin already included by the view).
static LIB_ALL_WINDOW: std::sync::Mutex<(usize, usize)> = std::sync::Mutex::new((0, 59));
/// Cover keys currently in the artwork pipeline (dedupe during fast scroll).
/// Freed on apply; cleared on reloads.
fn lib_all_inflight() -> &'static std::sync::Mutex<std::collections::HashSet<String>> {
static S: std::sync::OnceLock<std::sync::Mutex<std::collections::HashSet<String>>> =
std::sync::OnceLock::new();
S.get_or_init(|| std::sync::Mutex::new(std::collections::HashSet::new()))
}
/// Image-cache handle captured at load time so the window dispatcher can
/// spawn artwork jobs outside the load path. Plex params are re-read at
/// dispatch time (`plex_settings::get`), so only the cache rides along here.
fn lib_all_dispatch_ctx() -> &'static std::sync::Mutex<Option<ImageCache>> {
static S: std::sync::OnceLock<std::sync::Mutex<Option<ImageCache>>> =
std::sync::OnceLock::new();
S.get_or_init(|| std::sync::Mutex::new(None))
}
/// A windowed artwork job finished (applied or dropped) — free its slot so
/// the dispatcher can request the key again after an eviction.
pub fn artwork_job_done(key: &str) {
lib_all_inflight().lock().unwrap().remove(key);
}
/// Reset the windowed artwork pipeline for a fresh Library-All load: bump the
/// generation (orphans every in-flight job — dropped on apply), free their
/// dedupe slots and stash the image-cache handle the dispatchers spawn jobs
/// with. Runs on the UI thread BEFORE `apply_library_all`.
pub fn begin_library_all_artwork(image_cache: ImageCache) {
LIB_ALL_GEN.fetch_add(1, Ordering::SeqCst);
lib_all_inflight().lock().unwrap().clear();
*lib_all_dispatch_ctx().lock().unwrap() = Some(image_cache);
}
/// Dispatch throttle for the view's band reports (leading + trailing edge,
/// UI thread) — same rationale as the favorites albums grid: coalesce fling
/// crossings to one dispatch per interval.
const LIB_ALL_DISPATCH_THROTTLE_MS: u64 = 180;
thread_local! {
static LIB_ALL_BAND: crate::viewport::BandDispatcher =
crate::viewport::BandDispatcher::new(LIB_ALL_DISPATCH_THROTTLE_MS);
}
/// The windowed grid/list reported a new visible row band. The band is stored
/// immediately (model rebuilds re-read it); the artwork dispatch is throttled
/// through the BandDispatcher.
pub fn window_changed(window: &AppWindow, first: i32, last: i32) {
let first = first.max(0) as usize;
let last = last.max(first as i32) as usize;
*LIB_ALL_WINDOW.lock().unwrap() = (first, last);
let gen = LIB_ALL_GEN.load(Ordering::SeqCst);
let weak = window.as_weak();
LIB_ALL_BAND.with(|d| {
d.report(Box::new(move || {
if !library_all_gen_current(gen) {
return;
}
if let Some(w) = weak.upgrade() {
dispatch_library_all_window(&w);
}
}));
});
}
/// Dispatch covers for the current window (over `items-visible`) and evict
/// decoded covers far outside it back to the placeholder, so cover RAM scales
/// with the viewport instead of the library. Delivery is key-keyed
/// (`LibraryAllById`), so a derive re-sort between dispatch and apply cannot
/// land a cover on the wrong card; a reload bumps `LIB_ALL_GEN` and the apply
/// arm drops the stale image.
pub fn dispatch_library_all_window(window: &AppWindow) {
let (first, last) = *LIB_ALL_WINDOW.lock().unwrap();
let Some(image_cache) = lib_all_dispatch_ctx().lock().unwrap().clone() else {
return;
};
let gen = LIB_ALL_GEN.load(Ordering::SeqCst);
let state = window.global::<LibraryAllState>();
let visible = state.get_items_visible();
let len = visible.row_count();
if len == 0 {
return;
}
let last = last.min(len - 1);
if first > last {
return;
}
// Retention = the window plus one window-span on each side. Beyond it,
// covers return to the placeholder; re-entry is cheap (byte-budgeted
// decoded cache, else a bounded re-decode through the disk cache).
let span = last - first + 1;
let keep_lo = first.saturating_sub(span);
let keep_hi = (last + span).min(len - 1);
let mut jobs = Vec::new();
for i in 0..visible.row_count() {
if let Some(item) = visible.row_data(i) {
let url = item.image_url.to_string();
if !url.is_empty() {
{
let mut inflight = lib_all_inflight().lock().unwrap();
for vi in first..=last {
let Some(item) = visible.row_data(vi) else { continue };
if item.image.size().width > 0 || item.image_url.is_empty() {
continue;
}
let key = feed_key(item.kind.as_str(), item.id.as_str());
if inflight.insert(key.clone()) {
jobs.push(ArtworkJob {
target: ArtworkTarget::LibraryAllCover { index: i },
url,
target: ArtworkTarget::LibraryAllById { key, gen },
url: item.image_url.to_string(),
});
}
}
}
jobs
for vi in (0..keep_lo).chain(keep_hi + 1..len) {
let Some(item) = visible.row_data(vi) else { continue };
if item.image.size().width > 0 {
set_library_all_artwork(
window,
&feed_key(item.kind.as_str(), item.id.as_str()),
slint::Image::default(),
);
}
}
if !jobs.is_empty() {
// Mixed payload (Qobuz http / local fs / Plex /library/) — route each
// cover by scheme so local/Plex covers decode.
let plex = crate::plex_settings::get();
crate::artwork::spawn_search_loads(
jobs,
plex.base_url,
plex.token,
window.as_weak(),
image_cache,
);
}
}
/// Stable artwork key for a feed row. Qobuz numeric ids overlap across entity
/// types (a track id can equal an artist id), so the kind prefixes the key.
fn feed_key(kind: &str, id: &str) -> String {
format!("{kind}:{id}")
}
/// Set a freshly-decoded (or evicted) cover BY KEY on the full `items` model
/// AND the rendered `items-visible`. Writing the full model too is what keeps
/// covers across derives: `derive` clones rows out of `items`, so a cover
/// that only ever landed on the visible copy was blanked by every search
/// keystroke (and re-fought by the whole dispatch pipeline each time).
pub fn set_library_all_artwork(window: &AppWindow, key: &str, image: slint::Image) {
let st = window.global::<LibraryAllState>();
for model in [st.get_items(), st.get_items_visible()] {
for i in 0..model.row_count() {
let Some(mut item) = model.row_data(i) else { continue };
if feed_key(item.kind.as_str(), item.id.as_str()) == key {
item.image = image.clone();
model.set_row_data(i, item);
break;
}
}
}
}
/// Flip the favorite/follow flag BY KEY on the full `items` model AND the
/// rendered `items-visible` — the Library-All leg of the app-wide optimistic
/// favorite flips (`set_row_favorite` / `set_album_row_favorite` /
/// `search::mark_artist_followed` don't know this surface). Called for both
/// the optimistic flip and the failure rollback.
pub fn set_feed_favorite(window: &AppWindow, kind: &str, id: &str, value: bool) {
let key = feed_key(kind, id);
let st = window.global::<LibraryAllState>();
for model in [st.get_items(), st.get_items_visible()] {
for i in 0..model.row_count() {
let Some(mut item) = model.row_data(i) else { continue };
if feed_key(item.kind.as_str(), item.id.as_str()) == key {
if item.is_favorite != value {
item.is_favorite = value;
model.set_row_data(i, item);
}
break;
}
}
}
}
/// True when a feed id belongs to the LOCAL/Plex world, not Qobuz. Local
/// tracks are file paths (`/music/x.flac` or `plex:<path>` — Qobuz track ids
/// are numeric), local albums are group keys (`plex:…` or containing `|`/`/`),
/// local artists are plain names (Qobuz artist ids are numeric).
fn is_local_feed_id(kind: &str, id: &str) -> bool {
match kind {
"track" | "artist" => id.parse::<u64>().is_err(),
"album" => id.starts_with("plex:") || id.contains('|') || id.contains('/'),
_ => false,
}
}
/// Toggle a LOCAL/Plex feed row's heart against the local-favorites store.
/// The All-feed media-action arms receive only `(kind, id, action)` and would
/// otherwise fire the Qobuz favorite API at a file path / group key / artist
/// name — always an error toast (owner report 2026-07-24). Returns
/// `Some(new_state)` when the id was local and the toggle was handled here
/// (the caller must NOT continue into its Qobuz path); `None` for Qobuz ids.
///
/// Every local row in the feed is favorited by construction (the store feeds
/// the feed), so the common click is an UN-favorite; the re-favorite path
/// rebuilds the store snapshot from the feed row itself (title / subtitle /
/// artwork / source all ride the model). Works offline — the store is local.
pub fn toggle_local_feed_favorite(window: &AppWindow, kind: &str, id: &str) -> Option<bool> {
if !is_local_feed_id(kind, id) {
return None;
}
let new_state = if crate::local_favorites::is_favorite(kind, id) {
if let Err(e) = crate::local_favorites::unfavorite(kind, id) {
log::error!("[qbz-slint] local unfavorite ({kind}:{id}) failed: {e}");
return Some(true); // state unchanged — keep the heart filled
}
false
} else {
// Re-favorite: rebuild the snapshot from the feed row.
let st = window.global::<LibraryAllState>();
let items = st.get_items();
let mut snap = None;
for i in 0..items.row_count() {
let Some(item) = items.row_data(i) else { continue };
if feed_key(item.kind.as_str(), item.id.as_str()) == feed_key(kind, id) {
snap = Some(crate::local_favorites::LocalFavItem {
kind: kind.to_string(),
id: id.to_string(),
title: item.title.to_string(),
subtitle: item.subtitle.to_string(),
artwork_url: item.image_url.to_string(),
artist: item.artist.to_string(),
source: item.source.to_string(),
favorited_at: 0, // the service stamps `now` itself
});
break;
}
}
match snap {
Some(s) if s.source == "local" || s.source == "plex" => {
if let Err(e) = crate::local_favorites::favorite(&s) {
log::error!("[qbz-slint] local favorite ({kind}:{id}) failed: {e}");
return Some(false); // state unchanged — keep the heart hollow
}
true
}
// Not in the feed (or a malformed source) — nothing to snapshot;
// treat as handled-but-unchanged rather than falling into Qobuz.
_ => return Some(false),
}
};
set_feed_favorite(window, kind, id, new_state);
// Same toast wording as the Qobuz album/track favorite arms.
crate::toast::success(
window,
if new_state {
"Added to favorites"
} else {
"Removed from favorites"
},
);
Some(new_state)
}
+2 -2
View File
@@ -677,7 +677,7 @@ pub fn clear_album_filter(window: &AppWindow) {
/// disabled OR the data dir is unavailable, so the Albums union degrades to
/// local-only (identical behaviour to before Plex existed). Mirrors the Tauri
/// command's resolution (`commands_v2/library.rs`).
fn plex_cache_db_path() -> Option<std::path::PathBuf> {
pub(crate) fn plex_cache_db_path() -> Option<std::path::PathBuf> {
if !crate::plex_settings::get().enabled {
return None;
}
@@ -737,7 +737,7 @@ pub fn invalidate_artists(window: &AppWindow) {
/// one shot (search/sort/filter/group are all derived client-side over the
/// cached set in `derive_albums`), so we request a single large page rather
/// than truly paginating. `total` from the page is informational here.
const ALBUMS_FULL_LOAD_LIMIT: u64 = 1_000_000;
pub(crate) const ALBUMS_FULL_LOAD_LIMIT: u64 = 1_000_000;
/// The user's album-identity mode (Albums tab dropdown / Settings, persisted
/// in locallibrary_ui.json). Read on the UI thread and captured into the
+53 -1
View File
@@ -20,7 +20,9 @@ use slint::{ComponentHandle, Model};
use crate::adapter::SlintAdapter;
use crate::artwork::{self, ArtworkJob, ArtworkTarget, ImageCache};
use crate::playback::{after_track_change, refresh_sidebar};
use crate::playback::{
after_track_change, batch_all_qconnect_castable, refresh_sidebar, sync_qconnect_after_add,
};
use crate::{AppWindow, ContentView, NavState, PlaylistState, TrackItem};
type Runtime = Arc<AppRuntime<SlintAdapter>>;
@@ -1648,6 +1650,56 @@ pub fn enqueue_by_id(
});
}
/// Play-later variant of `enqueue_by_id` (#442): the playlist's rows land at
/// the END of the manual block in NATURAL order (each `add_track_later`
/// appends — no reverse). Rides the playback.rs QConnect pattern (batch
/// routing to the peer's manual-block tail + post-add sync; local/plex rows
/// are refused by the router's all-or-nothing admission, exactly like the
/// other source-typed batch paths). The OFFLINE-ONLY stamp rule is unchanged
/// (D8: nothing from it ever reaches the cloud push).
pub fn enqueue_later_by_id(
runtime: Runtime,
weak: slint::Weak<AppWindow>,
handle: tokio::runtime::Handle,
playlist_id: String,
) {
handle.spawn(async move {
let Some(data) = load(&runtime, &playlist_id).await else {
crate::toast::error_weak(&weak, qbz_i18n::t("Couldn't load this playlist"));
return;
};
let tracks: Vec<QueueTrack> = data
.rows
.iter()
.filter_map(|r| row_queue_track(&r.item))
.collect();
if tracks.is_empty() {
crate::toast::error_weak(&weak, qbz_i18n::t("Nothing playable in this playlist right now"));
return;
}
// QConnect CONTROLLER mode: route the whole playlist to the peer's
// manual-block tail; false when no peer is active, so the local loop
// below runs unchanged.
if let Some(svc) = crate::qconnect_service::service() {
let routed: Vec<(u64, Option<String>)> =
tracks.iter().map(|qt| (qt.id, qt.source.clone())).collect();
if svc.play_later_batch_on_peer_if_active(&routed).await {
return;
}
}
let added_castable = batch_all_qconnect_castable(&tracks);
for track in tracks {
runtime.core().add_track_later(track).await;
}
sync_qconnect_after_add(added_castable).await;
if data.offline_only {
runtime.core().set_queue_offline_only(true);
}
refresh_sidebar(false);
crate::toast::success_weak(&weak, qbz_i18n::t("Added to play later"));
});
}
// ──────────────────────── reorder (B2) ────────────────────────
/// Move the clicked row one slot up/down within the open LOCAL playlist's
+6
View File
@@ -100,6 +100,12 @@ pub fn ephemeral_path() -> Option<String> {
read().ephemeral_folder.filter(|s| !s.is_empty())
}
/// The persisted album-identity mode ("folder" | "metadata") — the feed
/// loader reads it without a window handle (Settings/Library-All scope).
pub fn albums_id_mode() -> String {
read().albums_id_mode
}
/// Persist (or clear) the ephemeral-folder path (read-modify-write).
pub fn save_ephemeral_path(path: Option<&str>) {
let mut p = read();
+838 -52
View File
File diff suppressed because it is too large. Load diff
+100 -26
View File
@@ -1,9 +1,10 @@
//! My QBZ — Collection / Mixtape DETAIL **playback** (Phase-2 Slice 5).
//!
//! Wires the detail view's hero Play / Shuffle CTAs, the per-row Play action,
//! and the per-row context menu (play / play-next / add-to-queue) to the
//! shared `qbz-mixtape` ENQUEUE resolver, then drives the already-shared
//! `qbz-core` queue + `qbz-app` `RuntimeManager` queue-source stamp.
//! and the per-row context menu (play / play-next / play-later /
//! add-to-queue) to the shared `qbz-mixtape` ENQUEUE resolver, then drives
//! the already-shared `qbz-core` queue + `qbz-app` `RuntimeManager`
//! queue-source stamp.
//!
//! Behavior is 1:1 with Tauri's `v2_enqueue_collection` /
//! `v2_enqueue_collection_item` (spec 40 §5/§6, gotchas §9):
@@ -47,6 +48,9 @@ enum RowMode {
Play,
/// Insert the item's resolved tracks immediately after the current track.
PlayNext,
/// Insert the item's resolved tracks at the END of the manual block
/// (#442 "Play later") — NATURAL order, each insert appends to the block.
PlayLater,
/// Append the item's resolved tracks at the end of the queue.
AddToQueue,
}
@@ -56,6 +60,7 @@ impl RowMode {
match action {
"play" => Some(Self::Play),
"play-next" | "play_next" => Some(Self::PlayNext),
"play-later" | "play_later" => Some(Self::PlayLater),
"add-to-queue" | "add_to_queue" | "append" => Some(Self::AddToQueue),
_ => None,
}
@@ -381,7 +386,8 @@ pub fn play_item(
}
/// Per-row context-menu action (`on_item_action`): play / play-next /
/// add-to-queue for the single item identified by `source_item_id`.
/// play-later / add-to-queue for the single item identified by
/// `source_item_id`.
pub fn item_action(
runtime: Runtime,
weak: slint::Weak<AppWindow>,
@@ -435,6 +441,16 @@ pub fn item_action(
refresh_sidebar(false);
crate::toast::success_weak(&weak, qbz_i18n::t("Playing next"));
}
RowMode::PlayLater => {
// #442: NATURAL order — each insert appends to the END of the
// manual block (after every play-next/play-later already
// queued, before the source resumes).
for track in tracks {
runtime.core().add_track_later(track).await;
}
refresh_sidebar(false);
crate::toast::success_weak(&weak, qbz_i18n::t("Added to play later"));
}
RowMode::AddToQueue => {
runtime.core().add_tracks(tracks).await;
refresh_sidebar(false);
@@ -444,24 +460,70 @@ pub fn item_action(
});
}
/// The bulk-bar queue placement (spec 12 §13.1 + #442 "Play later").
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum BulkEnqueueMode {
/// Append the batch at the end of the queue.
Append,
/// Insert the whole batch immediately after the current track, in REVERSE
/// so the first resolved track lands first (same rule as per-row PlayNext).
Next,
/// Insert the batch at the END of the manual block (#442 "Play later") —
/// NATURAL order, each `add_track_later` appends to the block.
Later,
}
/// **Bulk** enqueue for the detail select-mode bulk bar (spec 12 §13.1 Add to
/// queue / Play next). Resolves EACH selected `MixtapeCollectionItem` through
/// the same `ProdItemResolver` the per-row path uses (so Qobuz albums/tracks/
/// playlists + local/Plex all resolve), flattens them in selection order, then:
/// - **play_next = true**: insert the whole batch immediately after the current
/// track, in REVERSE so the first resolved track lands first (same rule as the
/// per-row `PlayNext`).
/// - **play_next = false**: append the batch at the end of the queue.
///
/// Never replaces the queue and never stamps the queue-source collection
/// (append/play-next preserve context, mirroring the per-row contract). Items
/// that resolve to nothing are logged + skipped; an all-empty batch toasts.
/// queue / Play next): `play_next` selects [`BulkEnqueueMode::Next`] over
/// [`BulkEnqueueMode::Append`]. See `bulk_enqueue_inner` for the contract.
pub fn bulk_enqueue(
runtime: Runtime,
weak: slint::Weak<AppWindow>,
handle: tokio::runtime::Handle,
items: Vec<MixtapeCollectionItem>,
play_next: bool,
) {
let mode = if play_next {
BulkEnqueueMode::Next
} else {
BulkEnqueueMode::Append
};
bulk_enqueue_inner(runtime, weak, handle, items, mode);
}
/// #442 "Play later" bulk enqueue (spec 12 §13.1 bar entry): same resolve +
/// flatten as `bulk_enqueue`, placed at the END of the manual block.
pub fn bulk_enqueue_later(
runtime: Runtime,
weak: slint::Weak<AppWindow>,
handle: tokio::runtime::Handle,
items: Vec<MixtapeCollectionItem>,
) {
bulk_enqueue_inner(runtime, weak, handle, items, BulkEnqueueMode::Later);
}
/// **Bulk** enqueue for the detail select-mode bulk bar (spec 12 §13.1 Add to
/// queue / Play next / Play later). Resolves EACH selected
/// `MixtapeCollectionItem` through the same `ProdItemResolver` the per-row
/// path uses (so Qobuz albums/tracks/playlists + local/Plex all resolve),
/// flattens them in selection order, then places the batch per `mode`:
/// - **Next**: insert the whole batch immediately after the current track, in
/// REVERSE so the first resolved track lands first (same rule as the per-row
/// `PlayNext`).
/// - **Later**: insert at the END of the manual block in NATURAL order (each
/// `add_track_later` appends — #442).
/// - **Append**: append the batch at the end of the queue.
///
/// Never replaces the queue and never stamps the queue-source collection
/// (append/play-next/play-later preserve context, mirroring the per-row
/// contract). Items that resolve to nothing are logged + skipped; an
/// all-empty batch toasts.
fn bulk_enqueue_inner(
runtime: Runtime,
weak: slint::Weak<AppWindow>,
handle: tokio::runtime::Handle,
items: Vec<MixtapeCollectionItem>,
mode: BulkEnqueueMode,
) {
if items.is_empty() {
return;
@@ -510,18 +572,30 @@ pub fn bulk_enqueue(
return;
}
if play_next {
// REVERSE so the first resolved track lands immediately after the
// current track (spec §9.8).
for track in tracks.into_iter().rev() {
runtime.core().add_track_next(track).await;
match mode {
BulkEnqueueMode::Next => {
// REVERSE so the first resolved track lands immediately after the
// current track (spec §9.8).
for track in tracks.into_iter().rev() {
runtime.core().add_track_next(track).await;
}
refresh_sidebar(false);
crate::toast::success_weak(&weak, qbz_i18n::t("Playing next"));
}
BulkEnqueueMode::Later => {
// #442: NATURAL order — each insert appends to the END of the
// manual block.
for track in tracks {
runtime.core().add_track_later(track).await;
}
refresh_sidebar(false);
crate::toast::success_weak(&weak, qbz_i18n::t("Added to play later"));
}
BulkEnqueueMode::Append => {
runtime.core().add_tracks(tracks).await;
refresh_sidebar(false);
crate::toast::success_weak(&weak, qbz_i18n::t("Added to queue"));
}
refresh_sidebar(false);
crate::toast::success_weak(&weak, qbz_i18n::t("Playing next"));
} else {
runtime.core().add_tracks(tracks).await;
refresh_sidebar(false);
crate::toast::success_weak(&weak, qbz_i18n::t("Added to queue"));
}
});
}
+411 -4
View File
@@ -2213,6 +2213,7 @@ pub(crate) async fn refresh_now_playing_meta(runtime: &Runtime, weak: &slint::We
album: album_display.clone(),
duration: (duration > 0).then(|| std::time::Duration::from_secs(duration as u64)),
art_url: mpris_art,
url: qbz_media_controls::xesam_url_for(track.source.as_deref(), track.id),
});
}
mc.set_playback(
@@ -3020,6 +3021,7 @@ pub fn enqueue_artist_top_selected(
return;
}
}
let added_castable = batch_all_qconnect_castable(&tracks);
if next {
for track in tracks.into_iter().rev() {
runtime.core().add_track_next(track).await;
@@ -3027,11 +3029,54 @@ pub fn enqueue_artist_top_selected(
} else {
runtime.core().add_tracks(tracks).await;
}
sync_qconnect_after_add(added_castable).await;
refresh_sidebar(false);
crate::toast::success_weak(&weak, if next { qbz_i18n::t("Playing next") } else { qbz_i18n::t("Added to queue") });
});
}
/// Play-later variant of `enqueue_artist_top_selected` (#442): same
/// re-fetch + id filter + page order, but the selection lands at the END of
/// the manual block — NATURAL order (each `add_track_later` appends, no
/// reverse) and the QConnect batch rides `play_later_batch_on_peer_if_active`.
pub fn enqueue_artist_top_later(
runtime: Runtime,
weak: slint::Weak<AppWindow>,
handle: tokio::runtime::Handle,
artist_id: String,
ids: Vec<String>,
) {
if ids.is_empty() {
return;
}
handle.spawn(async move {
let Some(all) = fetch_artist_top_for_play(&runtime, &weak, &artist_id).await else {
return;
};
let want: std::collections::HashSet<u64> =
ids.iter().filter_map(|s| s.parse::<u64>().ok()).collect();
let tracks: Vec<QueueTrack> =
all.into_iter().filter(|qt| want.contains(&qt.id)).collect();
if tracks.is_empty() {
return;
}
if let Some(svc) = crate::qconnect_service::service() {
let routed: Vec<(u64, Option<String>)> =
tracks.iter().map(|qt| (qt.id, qt.source.clone())).collect();
if svc.play_later_batch_on_peer_if_active(&routed).await {
return;
}
}
let added_castable = batch_all_qconnect_castable(&tracks);
for track in tracks {
runtime.core().add_track_later(track).await;
}
sync_qconnect_after_add(added_castable).await;
refresh_sidebar(false);
crate::toast::success_weak(&weak, qbz_i18n::t("Added to play later"));
});
}
/// Shuffle-play ALL of the artist's Popular tracks (section "more" menu).
/// Re-fetches, xorshift-shuffles (same seedless mix as `play_album_shuffled`),
/// and replaces the queue.
@@ -3858,7 +3903,9 @@ pub fn enqueue_album(runtime: Runtime, weak: slint::Weak<AppWindow>, handle: tok
return;
}
}
let added_castable = batch_all_qconnect_castable(&tracks);
runtime.core().add_tracks(tracks).await;
sync_qconnect_after_add(added_castable).await;
refresh_sidebar(false);
crate::toast::success_weak(&weak, qbz_i18n::t("Added to queue"));
});
@@ -3962,14 +4009,107 @@ pub fn enqueue_album_next(
}
}
// Insert in reverse so the tracks end up in the correct order.
let added_castable = batch_all_qconnect_castable(&tracks);
for track in tracks.into_iter().rev() {
runtime.core().add_track_next(track).await;
}
sync_qconnect_after_add(added_castable).await;
refresh_sidebar(false);
crate::toast::success_weak(&weak, qbz_i18n::t("Playing next"));
});
}
/// Insert an album's tracks at the END of the manual block ("Play later",
/// #442): after every play-next / play-later already queued, before the
/// source resumes. Unlike play-next there is NO reverse — each
/// `add_track_later` appends to the block, so the natural order is preserved.
pub fn enqueue_album_later(
runtime: Runtime,
weak: slint::Weak<AppWindow>,
handle: tokio::runtime::Handle,
album_id: String,
) {
handle.spawn(async move {
let album = match runtime.core().get_album(&album_id).await {
Ok(album) => album,
Err(e) => {
log::error!("[qbz-slint] playback: play-later get_album {album_id} failed: {e}");
return;
}
};
let album_title = album.title.clone();
let album_artist = album.artist.name.clone();
let album_artwork = album.image.best().cloned().unwrap_or_default();
crate::recently::remember_album_meta(&album.id, album_card_meta(&album));
// Drop blacklisted tracks (composer-aware, album-primary fallback)
// before play-later — same predicate as album play-all (D-FIX-b).
let album_primary = Some(album.artist.id);
let tracks: Vec<QueueTrack> = album
.tracks
.as_ref()
.map(|container| container.items.as_slice())
.unwrap_or_default()
.iter()
.filter(|track| !track_is_blacklisted_full(track, album_primary))
.map(|track| {
make_queue_track(track, &album.id, &album_title, &album_artist, &album_artwork, album.version.as_deref())
})
.collect();
if tracks.is_empty() {
return;
}
// QConnect CONTROLLER mode: route the whole album to the peer's
// manual-block tail. All-or-nothing admission inside the router;
// false when no peer is active, so the local loop runs unchanged.
if let Some(svc) = crate::qconnect_service::service() {
let routed: Vec<(u64, Option<String>)> =
tracks.iter().map(|qt| (qt.id, qt.source.clone())).collect();
if svc.play_later_batch_on_peer_if_active(&routed).await {
return;
}
}
// NATURAL order — each insert appends to the manual block.
let added_castable = batch_all_qconnect_castable(&tracks);
for track in tracks {
runtime.core().add_track_later(track).await;
}
sync_qconnect_after_add(added_castable).await;
refresh_sidebar(false);
crate::toast::success_weak(&weak, qbz_i18n::t("Added to play later"));
});
}
/// QConnect sync-on-add predicate (#442): true when EVERY track of an added
/// batch is Qobuz-castable (mirrors `sync_local_queue_if_changed`'s
/// all-or-nothing admission — `local` / `plex` refused, offline
/// `qobuz_download` eligible). A non-castable batch skips the post-add push so
/// a local/plex add never trips the refusal toast on every click; the next
/// track tick already surfaces that state.
pub(crate) fn batch_all_qconnect_castable(tracks: &[QueueTrack]) -> bool {
tracks.iter().all(|qt| {
qt.id > 0
&& !matches!(
qt.source.as_deref().map(|s| s.to_ascii_lowercase()).as_deref(),
Some("local") | Some("plex")
)
})
}
/// QConnect sync-on-add (#442): after a local queue ADD (play-next / play-later
/// / add-to-queue), push the updated queue to the session immediately so a
/// connected controller sees the new item AT its position — previously it only
/// re-synced on the next track transition. `sync_local_queue_if_changed`
/// self-gates (no-op when not connected, when a peer owns playback, or when the
/// queue is unchanged), so this is cheap in every non-renderer situation.
pub(crate) async fn sync_qconnect_after_add(added_castable: bool) {
if !added_castable {
return;
}
if let Some(svc) = crate::qconnect_service::service() {
svc.sync_local_queue_if_changed().await;
}
}
/// Enqueue a single track at the end of the current queue.
pub fn enqueue_track(runtime: Runtime, weak: slint::Weak<AppWindow>, handle: tokio::runtime::Handle, track_id: u64) {
handle.spawn(async move {
@@ -4010,6 +4150,8 @@ pub fn enqueue_track(runtime: Runtime, weak: slint::Weak<AppWindow>, handle: tok
let queue_track =
make_queue_track(&track, &album_id, &album_title, &album_artist, &album_artwork, None);
runtime.core().add_track(queue_track).await;
// QConnect sync-on-add (#442): single catalog track — always castable.
sync_qconnect_after_add(true).await;
refresh_sidebar(false);
crate::toast::success_weak(&weak, qbz_i18n::t("Added to queue"));
});
@@ -4060,11 +4202,68 @@ pub fn play_track_next(
let queue_track =
make_queue_track(&track, &album_id, &album_title, &album_artist, &album_artwork, None);
runtime.core().add_track_next(queue_track).await;
// QConnect sync-on-add (#442): single catalog track — always castable.
sync_qconnect_after_add(true).await;
refresh_sidebar(false);
crate::toast::success_weak(&weak, qbz_i18n::t("Playing next"));
});
}
/// Insert a single track at the END of the manual block ("Play later", #442):
/// after every play-next / play-later already queued, before the source
/// resumes. In QConnect CONTROLLER mode it routes to the peer's manual-block
/// tail (`play_later_on_peer_if_active` steers `insert_after`; the protocol
/// has no "later" — best-effort positionally, the cloud stays authoritative).
pub fn play_track_later(
runtime: Runtime,
weak: slint::Weak<AppWindow>,
handle: tokio::runtime::Handle,
track_id: u64,
) {
handle.spawn(async move {
if let Some(svc) = crate::qconnect_service::service() {
// Single-track play-later always builds a Qobuz catalog track
// (`make_queue_track` source = "qobuz"), so it is always castable.
if svc
.play_later_on_peer_if_active(track_id, Some("qobuz"))
.await
{
return;
}
}
let track = match runtime.core().get_track(track_id).await {
Ok(track) => track,
Err(e) => {
log::error!("[qbz-slint] playback: play-later get_track {track_id} failed: {e}");
return;
}
};
let (album_id, album_title, album_artwork) = match track.album.as_ref() {
Some(album) => (
album.id.clone(),
album.title.clone(),
album.image.best().cloned().unwrap_or_default(),
),
None => (String::new(), String::new(), String::new()),
};
let album_artist = track
.performer
.as_ref()
.map(|p| p.name.clone())
.unwrap_or_default();
let queue_track =
make_queue_track(&track, &album_id, &album_title, &album_artist, &album_artwork, None);
runtime.core().add_track_later(queue_track).await;
// QConnect sync-on-add (#442): push the updated queue to the session
// immediately so controllers see the new item AT its manual-block
// position — previously they only re-synced on the next track
// transition. Single catalog track — always castable.
sync_qconnect_after_add(true).await;
refresh_sidebar(false);
crate::toast::success_weak(&weak, qbz_i18n::t("Added to play later"));
});
}
/// Play a whole playlist (by id) NOW — replace the queue with the playlist's
/// tracks and start at the first one. Fetches the tracks fresh, so it works
/// from any playlist CARD (Discover / Search / Label carousels) without a
@@ -4178,6 +4377,7 @@ pub fn enqueue_playlist(
return;
}
}
let added_castable = batch_all_qconnect_castable(&tracks);
if next {
// Reverse so the inserted block keeps the playlist's order.
for track in tracks.into_iter().rev() {
@@ -4186,11 +4386,76 @@ pub fn enqueue_playlist(
} else {
runtime.core().add_tracks(tracks).await;
}
sync_qconnect_after_add(added_castable).await;
refresh_sidebar(false);
crate::toast::success_weak(&weak, if next { qbz_i18n::t("Playing next") } else { qbz_i18n::t("Added to queue") });
});
}
/// Insert a whole playlist (by id) at the END of the manual block ("Play
/// later", #442). Mirrors `enqueue_playlist` — same fetch + mixed-sidecar
/// interleave + blacklist filter + QConnect batch routing — but the local
/// insert loops `add_track_later` in NATURAL order (each insert appends to
/// the block; successive later-inserts keep FIFO order, no reverse).
pub fn enqueue_playlist_later(
runtime: Runtime,
weak: slint::Weak<AppWindow>,
handle: tokio::runtime::Handle,
playlist_id: String,
) {
let Ok(pid) = playlist_id.parse::<u64>() else {
return;
};
handle.spawn(async move {
let playlist = match runtime.core().get_playlist(pid).await {
Ok(playlist) => playlist,
Err(e) => {
log::error!("[qbz-slint] playback: play-later get_playlist {pid} failed: {e}");
return;
}
};
let qobuz_tracks: Vec<Track> = playlist.tracks.map(|c| c.items).unwrap_or_default();
// Same mixed-playlist merge as `enqueue_playlist` (see its MIXED
// comment): interleave the local/Plex sidecar rows at their stored
// slots so every row keeps its source. Pure-Qobuz playlists read an
// empty sidecar.
let qobuz_count = qobuz_tracks.len() as u32;
let sidecar = tokio::task::spawn_blocking(move || {
crate::local_playlist::read_sidecar_rows_blocking(pid, qobuz_count, true)
})
.await
.unwrap_or_default();
let rows = crate::playlist::interleave_rows(qobuz_tracks, sidecar);
// Drop blacklisted Qobuz rows (performer; local/Plex rows kept by the
// source guard). Silent early-return when nothing playable remains.
let tracks: Vec<QueueTrack> = filter_blacklisted_queue(
rows.iter()
.filter_map(|row| crate::local_playlist::row_queue_track(&row.item))
.collect(),
);
if tracks.is_empty() {
return;
}
// QConnect CONTROLLER mode: route the whole playlist to the peer's
// manual-block tail. All-or-nothing admission inside the router;
// false when no peer is active, so the local loop runs unchanged.
if let Some(svc) = crate::qconnect_service::service() {
let routed: Vec<(u64, Option<String>)> =
tracks.iter().map(|qt| (qt.id, qt.source.clone())).collect();
if svc.play_later_batch_on_peer_if_active(&routed).await {
return;
}
}
let added_castable = batch_all_qconnect_castable(&tracks);
for track in tracks {
runtime.core().add_track_later(track).await;
}
sync_qconnect_after_add(added_castable).await;
refresh_sidebar(false);
crate::toast::success_weak(&weak, qbz_i18n::t("Added to play later"));
});
}
/// Append (or insert-next) a batch of already-fetched tracks to the queue
/// without re-fetching them. Used by the favorites bulk bar.
/// Resolve a list of Qobuz track ids (order-preserving) and enqueue them at the
@@ -4273,6 +4538,7 @@ pub fn enqueue_tracks(
} else {
tracks
};
let mut added: Vec<QueueTrack> = Vec::with_capacity(ordered.len());
for track in ordered {
let (album_id, album_title, album_artwork) = track
.album
@@ -4293,11 +4559,72 @@ pub fn enqueue_tracks(
let qt =
make_queue_track(&track, &album_id, &album_title, &album_artist, &album_artwork, None);
if next {
runtime.core().add_track_next(qt).await;
runtime.core().add_track_next(qt.clone()).await;
} else {
runtime.core().add_track(qt).await;
runtime.core().add_track(qt.clone()).await;
}
added.push(qt);
}
sync_qconnect_after_add(batch_all_qconnect_castable(&added)).await;
refresh_sidebar(false);
});
}
/// Play-later variant of `enqueue_tracks` (#442): the catalog Track batch
/// lands at the END of the manual block in NATURAL order (each
/// `add_track_later` appends to the block — no reverse, unlike play-next).
/// Same blacklist filter + QConnect controller routing (play_later batch).
pub fn enqueue_tracks_later(
runtime: Runtime,
handle: tokio::runtime::Handle,
tracks: Vec<qbz_models::Track>,
) {
if tracks.is_empty() {
return;
}
// Drop blacklisted tracks (performer OR composer — D-FEAT) from the bulk
// batch before routing/enqueueing. Silent early-return when 0 remain.
let tracks: Vec<qbz_models::Track> = tracks
.into_iter()
.filter(|track| !track_is_blacklisted_full(track, None))
.collect();
if tracks.is_empty() {
return;
}
handle.spawn(async move {
// QConnect CONTROLLER mode: same all-or-nothing batch admission as
// `enqueue_tracks`, routed to the peer's manual-block tail.
if let Some(svc) = crate::qconnect_service::service() {
let routed: Vec<(u64, Option<String>)> =
tracks.iter().map(|track| (track.id, None)).collect();
if svc.play_later_batch_on_peer_if_active(&routed).await {
return;
}
}
let mut added: Vec<QueueTrack> = Vec::with_capacity(tracks.len());
for track in tracks {
let (album_id, album_title, album_artwork) = track
.album
.as_ref()
.map(|a| {
(
a.id.clone(),
a.title.clone(),
a.image.best().cloned().unwrap_or_default(),
)
})
.unwrap_or_default();
let album_artist = track
.performer
.as_ref()
.map(|p| p.name.clone())
.unwrap_or_default();
let qt =
make_queue_track(&track, &album_id, &album_title, &album_artist, &album_artwork, None);
runtime.core().add_track_later(qt.clone()).await;
added.push(qt);
}
sync_qconnect_after_add(batch_all_qconnect_castable(&added)).await;
refresh_sidebar(false);
});
}
@@ -4339,6 +4666,7 @@ pub fn enqueue_queue_tracks(
return;
}
}
let added_castable = batch_all_qconnect_castable(&tracks);
if next {
// Reverse so the inserted block keeps the selection's order.
for track in tracks.into_iter().rev() {
@@ -4347,11 +4675,48 @@ pub fn enqueue_queue_tracks(
} else {
runtime.core().add_tracks(tracks).await;
}
sync_qconnect_after_add(added_castable).await;
refresh_sidebar(false);
crate::toast::success_weak(&weak, if next { qbz_i18n::t("Playing next") } else { qbz_i18n::t("Added to queue") });
});
}
/// Insert a batch of source-aware QueueTracks at the END of the manual block
/// (#442 "Play later"): selection order is preserved (each insert appends to
/// the block, no reverse needed). QConnect CONTROLLER mode routes the whole
/// batch to the peer's manual-block tail (`play_later_batch_on_peer_if_active`
/// — the protocol has no "later"; `insert_after` is steered best-effort).
pub fn enqueue_queue_tracks_later(
runtime: Runtime,
weak: slint::Weak<AppWindow>,
handle: tokio::runtime::Handle,
tracks: Vec<QueueTrack>,
) {
if tracks.is_empty() {
return;
}
let tracks = filter_blacklisted_queue(tracks);
if tracks.is_empty() {
return;
}
handle.spawn(async move {
if let Some(svc) = crate::qconnect_service::service() {
let routed: Vec<(u64, Option<String>)> =
tracks.iter().map(|qt| (qt.id, qt.source.clone())).collect();
if svc.play_later_batch_on_peer_if_active(&routed).await {
return;
}
}
let added_castable = batch_all_qconnect_castable(&tracks);
for track in tracks {
runtime.core().add_track_later(track).await;
}
sync_qconnect_after_add(added_castable).await;
refresh_sidebar(false);
crate::toast::success_weak(&weak, qbz_i18n::t("Added to play later"));
});
}
/// Append (or insert-next) a batch of already-loaded LocalLibrary rows to the
/// queue. Mirrors `enqueue_tracks` but for `LocalTrack`: `local_queue_track`
/// builds source-aware QueueTracks (is_local=true; "local"/"qobuz_download")
@@ -4373,14 +4738,42 @@ pub fn enqueue_local_tracks(
} else {
tracks
};
for track in &ordered {
let qt = local_queue_track(track);
// qobuz_download rows ARE Qobuz-castable (the sync pushes their real
// Qobuz id); plain `local` rows skip the post-add push (#442).
let qts: Vec<QueueTrack> = ordered.iter().map(local_queue_track).collect();
let added_castable = batch_all_qconnect_castable(&qts);
for qt in qts {
if next {
runtime.core().add_track_next(qt).await;
} else {
runtime.core().add_track(qt).await;
}
}
sync_qconnect_after_add(added_castable).await;
refresh_sidebar(false);
});
}
/// Play-later variant of `enqueue_local_tracks` (#442): the LocalLibrary
/// batch lands at the END of the manual block in NATURAL order (each
/// `add_track_later` appends — no reverse). Same `local_queue_track`
/// source-aware build; qobuz_download rows stay Qobuz-castable for the
/// post-add sync, plain `local` rows skip it.
pub fn enqueue_local_tracks_later(
runtime: Runtime,
handle: tokio::runtime::Handle,
tracks: Vec<qbz_library::LocalTrack>,
) {
if tracks.is_empty() {
return;
}
handle.spawn(async move {
let qts: Vec<QueueTrack> = tracks.iter().map(local_queue_track).collect();
let added_castable = batch_all_qconnect_castable(&qts);
for qt in qts {
runtime.core().add_track_later(qt).await;
}
sync_qconnect_after_add(added_castable).await;
refresh_sidebar(false);
});
}
@@ -4415,6 +4808,20 @@ pub fn toggle_play_pause(
crate::session_persist::capture_and_save(&runtime).await;
return;
}
// Not playing — but a LOAD may be in flight: the engine reports
// is_playing=false until the stream starts, so a pause issued during
// the load window used to fall into the resume branch below and get
// swallowed (the track started anyway; the sleep inhibitor then
// stayed held with no successful pause to release it — #661).
// Cancelling the in-flight play is the only sane "pause" there.
let pending = PENDING_PLAY_ID.load(std::sync::atomic::Ordering::Relaxed);
if pending != 0 {
if let Err(e) = runtime.core().stop() {
log::error!("[qbz-slint] playback: stop-during-load failed: {e}");
}
clear_loading(&weak, pending);
return;
}
// Not playing: resume an existing stream, or cold-start the current
// queue track when nothing is loaded.
if runtime.core().player().has_loaded_audio() {
+19 -9
View File
@@ -320,8 +320,10 @@ pub fn reset_session(_window: &AppWindow) {
/// Order playlists by the active sort (mirrors `applySortToList`):
/// name (locale-ish), playcount desc, tracks (remote+local) desc, custom
/// (position asc); `recent` keeps the API order.
fn sort_playlists(list: &mut Vec<PmPlaylist>, sort: &str) {
/// (position asc); `recent` keeps the API order (which IS newest-first —
/// #657). Every option's `asc == true` is its natural direction; `false`
/// reverses it (re-selecting the menu option flips the direction).
fn sort_playlists(list: &mut Vec<PmPlaylist>, sort: &str, asc: bool) {
match sort {
"name" => list.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase())),
"playcount" => list.sort_by(|a, b| b.play_count.cmp(&a.play_count)),
@@ -330,6 +332,9 @@ fn sort_playlists(list: &mut Vec<PmPlaylist>, sort: &str) {
// "recent" — keep insertion (API) order.
_ => {}
}
if !asc {
list.reverse();
}
}
/// Display-stage union of a Qobuz playlist and a LOCAL (library.db)
@@ -390,8 +395,9 @@ impl PmEntry<'_> {
}
/// `sort_playlists`, over the merged Qobuz + local display set (same
/// comparators; see `PmEntry` for the missing-stat rules).
fn sort_entries(list: &mut [PmEntry], sort: &str) {
/// comparators; see `PmEntry` for the missing-stat rules). `asc == false`
/// reverses the option's natural direction (#657 caret).
fn sort_entries(list: &mut [PmEntry], sort: &str, asc: bool) {
match sort {
"name" => list.sort_by_key(|e| e.name_lower()),
"playcount" => list.sort_by(|a, b| b.play_count().cmp(&a.play_count())),
@@ -400,6 +406,9 @@ fn sort_entries(list: &mut [PmEntry], sort: &str) {
// "recent" — Qobuz keeps API order, locals stay after it.
_ => {}
}
if !asc {
list.reverse();
}
}
/// The LOCAL playlists that pass the toolbar filters, name-sorted (their
@@ -576,6 +585,7 @@ pub fn rebuild(window: &AppWindow) {
let query = state.get_search_query().trim().to_lowercase();
let filter = state.get_filter().to_string();
let sort = state.get_sort().to_string();
let sort_asc = state.get_sort_asc();
let view_mode = state.get_view_mode().to_string();
let folder_mode = state.get_folder_mode();
@@ -608,14 +618,14 @@ pub fn rebuild(window: &AppWindow) {
// Qobuz set (B4) — see `PmEntry` for the missing-stat sort rules.
let mut entries: Vec<PmEntry> = filtered.iter().map(PmEntry::Qobuz).collect();
entries.extend(local_entries(&data, &query, &filter).into_iter().map(PmEntry::Local));
sort_entries(&mut entries, &sort);
sort_entries(&mut entries, &sort, sort_asc);
let playlist_items: Vec<PmPlaylistItem> = entries.iter().map(|e| e.item()).collect();
let visible_count = playlist_items.len();
// Tree rows (folder headers + nested + root playlists). Built only
// when the tree view is active; otherwise an empty model.
let tree = if folder_mode && view_mode == "tree" {
build_tree(&data, &query, &filter, &sort)
build_tree(&data, &query, &filter, &sort, sort_asc)
} else {
Vec::new()
};
@@ -667,7 +677,7 @@ pub fn search_menu_folders(window: &AppWindow, query: &str) {
/// Flatten folders + their (expanded) playlists + root playlists into the
/// tree model. Auto-expands all folders the first time the tree opens.
fn build_tree(data: &PmData, query: &str, filter: &str, sort: &str) -> Vec<PmTreeRow> {
fn build_tree(data: &PmData, query: &str, filter: &str, sort: &str, sort_asc: bool) -> Vec<PmTreeRow> {
// Auto-expand on first tree open (Tauri's treeInitialized).
{
let mut init = TREE_INIT.lock().unwrap_or_else(|e| e.into_inner());
@@ -714,7 +724,7 @@ fn build_tree(data: &PmData, query: &str, filter: &str, sort: &str) -> Vec<PmTre
if (searching || offline) && members.is_empty() {
continue;
}
sort_playlists(&mut members, sort);
sort_playlists(&mut members, sort, sort_asc);
let is_exp = searching || expanded.contains(&f.id);
rows.push(PmTreeRow {
kind: "folder".into(),
@@ -747,7 +757,7 @@ fn build_tree(data: &PmData, query: &str, filter: &str, sort: &str) -> Vec<PmTre
.collect();
let mut entries: Vec<PmEntry> = root.iter().map(PmEntry::Qobuz).collect();
entries.extend(local_entries(data, query, filter).into_iter().map(PmEntry::Local));
sort_entries(&mut entries, sort);
sort_entries(&mut entries, sort, sort_asc);
for e in &entries {
rows.push(PmTreeRow {
kind: "playlist".into(),
+302 -1
View File
@@ -305,6 +305,146 @@ pub struct SlintQconnectService {
/// echo `QueueUpdated` lands (which would otherwise double-push on the next
/// track tick). Cleared on disconnect.
last_pushed_queue_ids: Mutex<Option<Vec<u64>>>,
/// Controller-mode mirror of the local queue's manual block (#442 "Play
/// later"): steers `insert_after` for play-later routing. See the struct
/// docs on `ControllerManualBlock`.
controller_manual: Mutex<ControllerManualBlock>,
}
/// Controller-mode mirror of the local queue's manual block (#442 "Play later").
///
/// The QConnect protocol has no "later" concept and the peer's queue carries no
/// manual-block metadata; worse, every cloud echo re-materializes the LOCAL core
/// queue via `materialize_remote_queue`, resetting its `manual_next_count` — so
/// the block tail is tracked HERE instead. Every play-next / play-later routed
/// to the peer is recorded as a PENDING insert (anchor + track ids); the next
/// `reconcile` confirms it against the fresh snapshot once the echo lands (the
/// inserted ids show up after the anchor we used). The tail is dropped when the
/// renderer's current track advances at/past it, and everything resets when the
/// queue's major version changes (a wholesale replace wipes the block).
/// Best-effort by design: it only steers `insert_after`; the cloud remains the
/// source of truth.
#[derive(Default)]
struct ControllerManualBlock {
/// queue_item_id of the deepest confirmed manual item (the block tail).
tail_qid: Option<u64>,
/// Renderer current-track qid at the last reconcile (advance detection).
current_qid: Option<u64>,
/// Queue major version at the last reconcile (replace detection).
queue_major: Option<u64>,
/// Sent-but-unconfirmed inserts: (insert_after anchor we used, track ids).
pending: Vec<(u64, Vec<u64>)>,
}
impl ControllerManualBlock {
/// Reconcile with the latest peer snapshot BEFORE computing a new anchor.
fn reconcile(&mut self, renderer: &QConnectRendererState, queue: &QConnectQueueState) {
let items = &queue.queue_items;
// Queue replaced wholesale → the block is gone.
if self.queue_major != Some(queue.version.major) {
self.queue_major = Some(queue.version.major);
self.current_qid = renderer.current_track.as_ref().map(|i| i.queue_item_id);
self.tail_qid = None;
self.pending.clear();
return;
}
// Advance detection: the current track moving consumes manual items.
// The tail only survives while it sits strictly AFTER the current track.
let new_current = renderer.current_track.as_ref().map(|i| i.queue_item_id);
if new_current != self.current_qid {
if let (Some(tail), Some(cur)) = (self.tail_qid, new_current) {
let tail_pos = items.iter().position(|i| i.queue_item_id == tail);
let cur_pos = items.iter().position(|i| i.queue_item_id == cur);
let tail_still_ahead = matches!((tail_pos, cur_pos), (Some(t), Some(c)) if t > c);
if !tail_still_ahead {
self.tail_qid = None;
}
} else {
self.tail_qid = None;
}
self.current_qid = new_current;
}
// Confirm pendings against the snapshot: the echo has landed once the
// inserted ids show up shortly after the anchor we used. The small scan
// window tolerates a play-next interleaving between two play-laters
// (which pushes an earlier play-later one position deeper).
let mut confirmed: Vec<u64> = Vec::new();
self.pending.retain(|(anchor, ids)| {
let Some(anchor_pos) = items.iter().position(|i| i.queue_item_id == *anchor) else {
return false; // anchor gone — the queue moved on; drop.
};
let window_end = (anchor_pos + 1 + ids.len() + 2).min(items.len());
let window = &items[anchor_pos + 1..window_end];
match ids.last() {
Some(last_id) => match window.iter().find(|i| i.track_id == *last_id) {
Some(item) => {
confirmed.push(item.queue_item_id);
false // confirmed → drop from pending
}
None => true, // echo not here yet
},
None => false,
}
});
// Raise the tail to the DEEPEST confirmed item — never lower it (two
// rapid inserts confirm in send order, but the second anchor sits
// shallower when its echo landed first).
let deepest = confirmed
.into_iter()
.filter_map(|qid| {
items
.iter()
.position(|i| i.queue_item_id == qid)
.map(|pos| (pos, qid))
})
.max_by_key(|(pos, _)| *pos);
if let Some((pos, qid)) = deepest {
let raises = self
.tail_qid
.and_then(|tail| items.iter().position(|i| i.queue_item_id == tail))
.map(|tail_pos| pos > tail_pos)
.unwrap_or(true);
if raises {
self.tail_qid = Some(qid);
}
}
}
/// queue_item_id AFTER which a play-later insert lands: the confirmed block
/// tail while it is still ahead of the current track, else the current
/// track itself (degrades to play-next — also under shuffle, mirroring the
/// local `add_track_later` shuffle fallback).
fn later_anchor(
&self,
renderer: &QConnectRendererState,
queue: &QConnectQueueState,
) -> Option<u64> {
let current = renderer.current_track.as_ref()?.queue_item_id;
if queue.shuffle_mode {
return Some(current);
}
let items = &queue.queue_items;
if let Some(tail) = self.tail_qid {
let cur_pos = items.iter().position(|i| i.queue_item_id == current);
let tail_pos = items.iter().position(|i| i.queue_item_id == tail);
if let (Some(c), Some(t)) = (cur_pos, tail_pos) {
if t > c {
return Some(tail);
}
}
}
Some(current)
}
/// Record a just-sent insert so the next reconcile can confirm its echo.
fn note_sent(&mut self, anchor: Option<u64>, ids: Vec<u64>) {
if let (Some(anchor), false) = (anchor, ids.is_empty()) {
self.pending.push((anchor, ids));
}
}
}
/// Slint-local mirror of the Tauri `QconnectVisibleQueueProjection` reduced to
@@ -450,6 +590,7 @@ impl SlintQconnectService {
window,
custom_device_name: Arc::new(tokio::sync::RwLock::new(saved_name)),
last_pushed_queue_ids: Mutex::new(None),
controller_manual: Mutex::new(ControllerManualBlock::default()),
}
}
@@ -1155,6 +1296,7 @@ impl SlintQconnectService {
&self.window,
format!("-> play_next QueueInsertTracks {track_id} after={insert_after:?}"),
);
self.note_controller_insert(insert_after, &[track_id]).await;
}
Err(err) => {
log::warn!("[QConnect] play_next_on_peer: insert failed: {err}");
@@ -1330,7 +1472,8 @@ impl SlintQconnectService {
.and_then(|item| i64::try_from(item.queue_item_id).ok())
});
let ids: Vec<i64> = tracks.iter().map(|(id, _)| *id as i64).collect();
let ids_u64: Vec<u64> = tracks.iter().map(|(id, _)| *id).collect();
let ids: Vec<i64> = ids_u64.iter().map(|id| *id as i64).collect();
let count = ids.len();
let mut payload = json!({
"track_ids": ids,
@@ -1354,6 +1497,7 @@ impl SlintQconnectService {
&self.window,
format!("-> play_next QueueInsertTracks {count} tracks after={insert_after:?}"),
);
self.note_controller_insert(insert_after, &ids_u64).await;
}
Err(err) => {
log::warn!("[QConnect] play_next_batch_on_peer: insert failed: {err}");
@@ -1362,6 +1506,163 @@ impl SlintQconnectService {
true
}
/// Resolve the `insert_after` anchor for a controller-mode PLAY-LATER: the
/// manual-block tail while it is still ahead of the peer's current track,
/// else the current track itself (degrades to play-next). Reconciles the
/// `ControllerManualBlock` against the freshest snapshot first. `None` when
/// no snapshot is available (the command then omits `insert_after`, same as
/// the play-next path).
async fn controller_later_anchor(&self) -> Option<i64> {
let (renderer, queue, _session) = self
.effective_remote_renderer_snapshot()
.await
.ok()
.flatten()?;
let mut guard = self.controller_manual.lock().await;
guard.reconcile(&renderer, &queue);
guard
.later_anchor(&renderer, &queue)
.and_then(|qid| i64::try_from(qid).ok())
}
/// Record a just-sent controller-mode insert (play-next or play-later) so
/// the next `controller_later_anchor` can confirm its echo and keep the
/// manual-block tail past it.
async fn note_controller_insert(&self, anchor: Option<i64>, ids: &[u64]) {
let anchor = anchor.and_then(|v| u64::try_from(v).ok());
let mut guard = self.controller_manual.lock().await;
guard.note_sent(anchor, ids.to_vec());
}
/// Controller play-LATER routing (#442): when QBZ is CONTROLLING a peer
/// renderer, insert the track at the END of the peer's manual block (after
/// every play-next / play-later already routed, before the source resumes)
/// instead of right after the current track. The protocol has no "later" —
/// the position is steered via `insert_after` against the tracked block
/// tail (`ControllerManualBlock`); best-effort, the cloud stays the source
/// of truth. Same admission + echo contract as `play_next_on_peer_if_active`.
pub async fn play_later_on_peer_if_active(&self, track_id: u64, source: Option<&str>) -> bool {
let peer_active = self.is_peer_renderer_active().await;
if !peer_active {
return false;
}
if !self.is_track_castable(track_id, source) {
log::info!(
"[QConnect] play_later_on_peer: track {track_id} not Qobuz-castable; refusing"
);
crate::toast::error_weak(&self.window, qbz_i18n::t("Track not castable to Qobuz Connect"));
dev_push_event(
&self.window,
format!("-> play_later REFUSED (non-Qobuz track {track_id})"),
);
return true;
}
let insert_after = self.controller_later_anchor().await;
let mut payload = json!({
"track_ids": [track_id as i64],
"context_uuid": Uuid::new_v4().to_string(),
"autoplay_reset": false,
"autoplay_loading": false,
});
if let Some(insert_after) = insert_after {
payload["insert_after"] = json!(insert_after);
}
match self
.send_command(QueueCommandType::CtrlSrvrQueueInsertTracks, payload)
.await
{
Ok(_) => {
log::info!(
"[QConnect] play_later_on_peer: inserted track {track_id} (after={insert_after:?})"
);
dev_push_event(
&self.window,
format!("-> play_later QueueInsertTracks {track_id} after={insert_after:?}"),
);
self.note_controller_insert(insert_after, &[track_id]).await;
}
Err(err) => {
log::warn!("[QConnect] play_later_on_peer: insert failed: {err}");
// Still handled: a peer owns playback; never fall back to local.
}
}
true
}
/// Controller play-LATER routing for a MULTI-track batch (#442). Same
/// all-or-nothing admission as `play_next_batch_on_peer_if_active`; the
/// whole block lands after the manual-block tail in NATURAL order (the
/// server preserves list order), mirroring the local
/// `enqueue_queue_tracks_later` semantics.
pub async fn play_later_batch_on_peer_if_active(
&self,
tracks: &[(u64, Option<String>)],
) -> bool {
if !self.is_peer_renderer_active().await {
return false;
}
if tracks.is_empty() {
return false;
}
if let Some((bad_id, _)) = tracks
.iter()
.find(|(id, source)| !self.is_track_castable(*id, source.as_deref()))
{
log::info!(
"[QConnect] play_later_batch_on_peer: track {bad_id} not Qobuz-castable; refusing whole batch"
);
crate::toast::error_weak(&self.window, qbz_i18n::t("Some tracks can't be cast to Qobuz Connect"));
dev_push_event(
&self.window,
format!(
"-> play_later REFUSED (non-Qobuz track {bad_id} in batch of {})",
tracks.len()
),
);
return true;
}
let insert_after = self.controller_later_anchor().await;
let ids_u64: Vec<u64> = tracks.iter().map(|(id, _)| *id).collect();
let ids: Vec<i64> = ids_u64.iter().map(|id| *id as i64).collect();
let count = ids.len();
let mut payload = json!({
"track_ids": ids,
"context_uuid": Uuid::new_v4().to_string(),
"autoplay_reset": false,
"autoplay_loading": false,
});
if let Some(insert_after) = insert_after {
payload["insert_after"] = json!(insert_after);
}
match self
.send_command(QueueCommandType::CtrlSrvrQueueInsertTracks, payload)
.await
{
Ok(_) => {
log::info!(
"[QConnect] play_later_batch_on_peer: inserted {count} tracks (after={insert_after:?})"
);
dev_push_event(
&self.window,
format!("-> play_later QueueInsertTracks {count} tracks after={insert_after:?}"),
);
self.note_controller_insert(insert_after, &ids_u64).await;
}
Err(err) => {
log::warn!("[QConnect] play_later_batch_on_peer: insert failed: {err}");
}
}
true
}
/// True when a PEER renderer currently owns playback (controller mode). Reads
/// the session under the sync-state lock. Shared by the play-next /
/// add-to-queue routing entry points.
+39 -1
View File
@@ -89,6 +89,9 @@ struct RowData {
artwork_url: String,
playing: bool,
is_ephemeral: bool,
// #442 section header drawn ABOVE this row: "" | "next-in-queue" (first
// manual row) | "next-up" (first row after the manual block).
section: String,
}
/// `M:SS` duration string.
@@ -144,6 +147,7 @@ fn row_from(track: &QueueTrack, playing: bool) -> RowData {
playing,
is_ephemeral: track.source.as_deref() == Some("ephemeral")
|| crate::ephemeral::is_ephemeral_id(track.id as i64),
section: String::new(),
}
}
@@ -258,7 +262,21 @@ impl QueueController {
let page_rows: Vec<RowData> = filtered[start..end]
.iter()
.map(|t| row_from(t, false))
.enumerate()
.map(|(i, t)| {
let mut r = row_from(t, false);
// #442 section markers — only on the unfiltered list (a
// search result has no meaningful sections).
if query.is_empty() {
let g = start + i;
if state.manual_next_count > 0 && g == 0 {
r.section = "next-in-queue".into();
} else if g == state.manual_next_count {
r.section = "next-up".into();
}
}
r
})
.collect();
// page-start / page-end are 1-based for the human-readable counter.
@@ -436,6 +454,25 @@ impl QueueController {
qs.set_page_count(page_count as i32);
qs.set_page_start(page_start as i32);
qs.set_page_end(page_end as i32);
qs.set_page_section_count(
page_rows.iter().filter(|r| !r.section.is_empty()).count() as i32
);
// #442 drag-math aids (see QueueSidebar's headers-above).
qs.set_page_manual_header(
query.is_empty() && state.manual_next_count > 0 && start == 0
);
qs.set_page_manual_pos(if query.is_empty()
&& state.manual_next_count >= start
&& state.manual_next_count < end
{
(state.manual_next_count - start) as i32
} else {
-1
});
// #442 menu-hint numbers (TrackContextMenu suffix).
qs.set_queue_length(state.total_tracks as i32);
qs.set_queue_current_index(state.current_index.map(|i| i as i32).unwrap_or(-1));
qs.set_manual_count(state.manual_next_count as i32);
let history_items: Vec<QueueItem> =
history_rows.iter().map(|r| to_item_reuse(r, &prior_all)).collect();
@@ -982,6 +1019,7 @@ fn to_item_reuse(
duration: row.duration.clone().into(),
explicit: row.explicit,
is_ephemeral: row.is_ephemeral,
section: row.section.clone().into(),
}
}
+60 -15
View File
@@ -93,10 +93,11 @@ pub struct SidebarData {
static EXPANDED: LazyLock<Mutex<HashSet<String>>> = LazyLock::new(|| Mutex::new(HashSet::new()));
/// Last loaded data, so expand/move rebuild without a refetch.
static CACHE: LazyLock<Mutex<SidebarData>> = LazyLock::new(|| Mutex::new(SidebarData::default()));
/// Active sort option, mirrored from `SidebarState.sort-option`. Session
/// scope (Tauri persists in localStorage; we have no equivalent store
/// here yet, so this matches the in-session behavior).
static SORT: LazyLock<Mutex<String>> = LazyLock::new(|| Mutex::new("name".to_string()));
/// Active sort option + direction, mirrored from `SidebarState.sort-option` /
/// `.sort-asc`. Session scope (Tauri persists in localStorage; we have no
/// equivalent store here yet, so this matches the in-session behavior).
static SORT: LazyLock<Mutex<(String, bool)>> =
LazyLock::new(|| Mutex::new(("name".to_string(), true)));
/// Active playlist-name search query (lowercased), mirrored from
/// `SidebarState.search-query`. Filters the rebuilt list recursively.
static SEARCH: LazyLock<Mutex<String>> = LazyLock::new(|| Mutex::new(String::new()));
@@ -111,16 +112,28 @@ pub fn set_loading(window: &AppWindow, loading: bool) {
}
/// Update the active sort option and re-render (matches Tauri's five
/// options). Unknown values fall back to "name".
/// options). Unknown values fall back to "name". PlaylistView-style toggle:
/// re-selecting the ACTIVE option flips its direction (#657); a new option
/// starts at its natural direction (`asc == true` everywhere: name A→Z,
/// recent newest-first, tracks most-first, custom position asc).
pub fn set_sort(window: &AppWindow, option: &str) {
let opt = match option {
"name" | "recent" | "tracks" | "playcount" | "custom" => option,
_ => "name",
};
let mut asc = true;
if let Ok(mut s) = SORT.lock() {
*s = opt.to_string();
if s.0 == opt {
s.1 = !s.1;
} else {
s.0 = opt.to_string();
s.1 = true;
}
asc = s.1;
}
window.global::<SidebarState>().set_sort_option(opt.into());
let st = window.global::<SidebarState>();
st.set_sort_option(opt.into());
st.set_sort_asc(asc);
rebuild(window);
}
@@ -133,20 +146,28 @@ pub fn set_search(window: &AppWindow, query: &str) {
rebuild(window);
}
/// Order playlists by the active sort option, mirroring Tauri's
/// comparators. `recent` keeps reverse insertion order (most-recently
/// added first); `playcount` has no per-playlist count source here, so it
/// stays stable like Tauri does when `play_count` is absent (0).
/// Order playlists by the active sort option + direction, mirroring Tauri's
/// comparators. `recent` keeps the API order, which IS newest-first (#657 —
/// the old unconditional `.reverse()` was written when the list arrived
/// oldest-first and had been flipping it to oldest-first ever since);
/// `playcount` has no per-playlist count source here, so it also keeps the
/// API order like Tauri does when `play_count` is absent. Every option's
/// `asc == true` is its natural direction; `false` reverses it.
fn sort_playlists(playlists: &[SidebarPlaylist]) -> Vec<SidebarPlaylist> {
let sort = SORT.lock().map(|s| s.clone()).unwrap_or_else(|_| "name".into());
let (sort, asc) = SORT
.lock()
.map(|s| s.clone())
.unwrap_or_else(|_| ("name".into(), true));
let mut out: Vec<SidebarPlaylist> = playlists.to_vec();
match sort.as_str() {
"name" => out.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase())),
"recent" => out.reverse(),
"tracks" => out.sort_by(|a, b| b.tracks_count.cmp(&a.tracks_count)),
"playcount" => { /* no play_count source — stable, like Tauri's absent field */ }
"custom" => out.sort_by(|a, b| a.position.cmp(&b.position)),
_ => out.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase())),
// recent / playcount — API order (newest first).
_ => {}
}
if !asc {
out.reverse();
}
out
}
@@ -408,6 +429,30 @@ pub fn rename_entry(window: &AppWindow, id: &str, name: &str) {
rebuild(window);
}
/// Optimistically INSERT a freshly-created Qobuz playlist row (import or
/// duplicate-create) so the sidebar shows it instantly — the authoritative
/// `getUserPlaylists` read lags the create by seconds (the import flow
/// reconciles with a bounded retry, mirroring `reconcile_sidebar_after_rename`).
/// Idempotent by id.
pub fn insert_qobuz_entry(window: &AppWindow, id: u64, name: &str, tracks_count: u32) {
if let Ok(mut cache) = CACHE.lock() {
if !cache.playlists.iter().any(|p| p.id == id) {
cache.playlists.insert(
0,
SidebarPlaylist {
id,
name: name.to_string(),
description: String::new(),
tracks_count,
cover_urls: Vec::new(),
position: 0,
},
);
}
}
rebuild(window);
}
/// Build a playlist `SidebarEntry` (with its cover URLs for the
/// micro-collage). The decoded `cover*` images stay default here and are
/// filled asynchronously by the artwork pipeline (see `artwork_jobs`).
+55
View File
@@ -0,0 +1,55 @@
//! Steam on-screen keyboard via the deeplink — Steam Deck plan PR 4
//! (qbz-nix-docs/steam-deck/STEAM_DECK_GAME_MODE_PLAN.md §5).
//!
//! The Deck OSK only opens when something asks the Steam client for it. For
//! a non-Steam shortcut the only programmatic path is the deeplink
//! `steam://open/keyboard?...&Mode=N` (and `steam://close/keyboard`) — the
//! same call Valve's own SDL fires on the Deck, proven for non-Steam apps.
//! Entered text arrives as ordinary key events, so no extra plumbing: a text
//! field gains focus → we fire open; it loses focus → we fire close. Mode 0
//! (single-line) covers every field in the app today (login is browser-OAuth,
//! so there is no in-app email field to flag Mode 2 for).
//!
//! Activation gate: `SteamDeck=1` env (set by SteamOS Game Mode) or
//! `SDL_ENABLE_STEAM_SCREEN_KEYBOARD=1`, with a manual override
//! `QBZ_STEAM_OSK=0/1` (0 wins over everything — diagnostics).
/// True when the Steam OSK integration should fire.
fn enabled() -> bool {
match std::env::var("QBZ_STEAM_OSK") {
Ok(v) if v == "1" => return true,
Ok(v) if v == "0" => return false,
_ => {}
}
std::env::var("SteamDeck").map(|v| v == "1").unwrap_or(false)
|| std::env::var_os("SDL_ENABLE_STEAM_SCREEN_KEYBOARD").is_some()
}
/// A text field's focus flipped (wired to `UiFocusState.osk` in main.rs).
/// Fire-and-forget: the deeplink has no dismissal callback, so ALWAYS send
/// close on blur — a stranded OSK is worse than a redundant close.
pub fn focus_changed(focused: bool, mode: i32) {
if !enabled() {
return;
}
let url = if focused {
format!(
"steam://open/keyboard?XPosition=0&YPosition=0&Width=0&Height=0&Mode={mode}"
)
} else {
"steam://close/keyboard".to_string()
};
// xdg-open routes the steam:// scheme to the running Steam client; the
// fallback pokes Steam directly (only if already running).
if std::process::Command::new("xdg-open")
.arg(&url)
.spawn()
.is_err()
{
let _ = std::process::Command::new("steam")
.arg("-ifrunning")
.arg(&url)
.spawn();
}
log::info!("[steam-osk] {} (mode {mode})", if focused { "open" } else { "close" });
}
+12
View File
@@ -406,6 +406,16 @@ pub struct UiPrefs {
/// per-machine app preference — your Discord client is per-machine.)
#[serde(default)]
pub discord_rpc_enabled: bool,
/// Animate the now-playing row's equalizer bars. Default OFF — a static
/// pause glyph + accent edge mark instead; the animated bars hold
/// measurable CPU on some machines (owner call 2026-07-24).
#[serde(default)]
pub play_indicator_animation: bool,
/// Invert the two-finger touchpad swipe navigation direction (#653).
/// Default OFF = the natural-scrolling convention (fingers right = back,
/// fingers left = forward).
#[serde(default)]
pub invert_swipe_navigation: bool,
/// Show the opt-in "Purchases" navigation entry (sidebar / header). Default
/// OFF — the whole DRM-free Purchases feature is hidden until the user
/// enables it (mirrors Tauri `showPurchases`, default `false`). Re-seeded
@@ -772,6 +782,8 @@ impl Default for UiPrefs {
system_notifications: default_system_notifications(),
musicbrainz_enabled: default_musicbrainz_enabled(),
discord_rpc_enabled: false,
play_indicator_animation: false,
invert_swipe_navigation: false,
show_purchases: false,
nav_tb_purchases: false,
use_system_title_bar: default_use_system_title_bar(),
+25
View File
@@ -42,6 +42,20 @@ pub struct BootedRuntime {
pub async fn run(roots: ProfileRoots, cfg: QbzdConfig, warns: Vec<String>) -> Result<i32, String> {
// 1. argv parse happened in main(). 2. logging:
qbz_log::install(&cfg.log.level);
// 2b. Host memory profile (issue #660: qbzd OOM-killed on a 1 GB Pi 3B):
// one-shot Normal/LowMemory detection, pushed into the player as the
// initial-buffer cap, the L1 cache budget and the low-memory class
// gate. Must run BEFORE boot() builds the AppRuntime (and with it the
// Player + its L1 cache); after the logger so the profile's own info
// line lands in the log.
{
let profile = qbz_core::system_capabilities::memory_profile();
qbz_player::player::apply_memory_tuning(
profile.class == qbz_core::system_capabilities::MemoryClass::LowMemory,
profile.audio_cache_l1_max_bytes,
profile.max_initial_buffer_bytes,
);
}
// 3. config: surface unknown-key warnings (they never abort — D14).
for w in &warns {
log::warn!("[config] unknown key: {w}");
@@ -76,6 +90,12 @@ pub async fn run(roots: ProfileRoots, cfg: QbzdConfig, warns: Vec<String>) -> Re
// 6.-9. compose stores + runtime + restore credentials + restore session.
let mut booted = boot(&roots, &cfg, warns.len()).await?;
// 9b. Memory-pressure watchdog (issue #660): 15 s loop that evicts the
// L1 audio cache and halts prefetch under critical pressure. Holds an
// Arc<Player> (not Arc<AppRuntime>), so it sits outside the #521/§8.2
// ordering — aborted alongside the scrobbler for a clean shutdown.
let memwatch = qbz_app::memory_watchdog::spawn(booted.runtime.core().player());
// 10. playback driver (T4). Spawn the 450 ms headless orchestrator on the
// booted runtime + shared state. It runs safely regardless of auth: with
// no session the queue is empty and each tick is a near-no-op. The
@@ -211,6 +231,11 @@ pub async fn run(roots: ProfileRoots, cfg: QbzdConfig, warns: Vec<String>) -> Re
// Stop the scrobble-on-play subscriber (holds no Arc<AppRuntime>; order-free).
scrobbler.abort();
let _ = scrobbler.await;
// Stop the memory-pressure watchdog and JOIN it: it holds an Arc<Player>
// clone, which must drop before `drop(booted)` can release the audio
// device — the same ordering constraint as the driver (§8.2).
memwatch.abort();
let _ = memwatch.await;
// Tear down MPRIS: abort its updater and drop the D-Bus handle. Its inbound
// callback held only a Weak<AppRuntime>, so this is order-free too.
if let Some(mpris) = mpris {
+1
View File
@@ -225,6 +225,7 @@ fn track_meta(t: &QueueTrack) -> TrackMeta {
album: t.album.clone(),
duration: (t.duration_secs > 0).then(|| Duration::from_secs(t.duration_secs)),
art_url: t.artwork_url.clone(),
url: qbz_media_controls::xesam_url_for(t.source.as_deref(), t.id),
}
}
+20
View File
@@ -67,6 +67,26 @@ impl core::fmt::Display for FrameSkipped {
impl std::error::Error for FrameSkipped {}
/// QBZ vendor patch (issue #558): presented-frames counter feeding the
/// render-liveness watchdog in the qbz crate. A backend can be ALIVE yet
/// wedged — the #558 Outdated-surface skip loop above skips every frame
/// forever while the process looks healthy, and the crash-chain ladder only
/// escalates on DEATH, so a silent wedge never degrades. The watchdog reads
/// this a few seconds after startup: zero presented frames with a visible
/// window = escalate the degradation ladder and restart on the next rung.
static FRAMES_PRESENTED: core::sync::atomic::AtomicU64 = core::sync::atomic::AtomicU64::new(0);
/// Total frames successfully presented by the active backend since process
/// start. Read by the qbz render-liveness watchdog (#558).
pub fn frames_presented() -> u64 {
FRAMES_PRESENTED.load(core::sync::atomic::Ordering::Relaxed)
}
/// Watchdog hook — each backend's `present_surface` calls this on success.
pub(crate) fn note_frame_presented() {
FRAMES_PRESENTED.fetch_add(1, core::sync::atomic::Ordering::Relaxed);
}
pub trait GraphicsBackend {
type Renderer: femtovg::Renderer + TextureImporter;
type WindowSurface: WindowSurface<Self::Renderer>;
+7 -1
View File
@@ -196,7 +196,13 @@ impl GraphicsBackend for OpenGLBackend {
&self,
_surface: GLWindowSurface,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
self.opengl_context.borrow().swap_buffers()
let result = self.opengl_context.borrow().swap_buffers();
if result.is_ok() {
// QBZ vendor patch (#558): count presented frames for the
// render-liveness watchdog.
crate::note_frame_presented();
}
result
}
#[cfg(not(target_family = "wasm"))]
+3
View File
@@ -209,6 +209,9 @@ impl GraphicsBackend for WGPUBackend {
surface: Self::WindowSurface,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
surface.surface_texture.present();
// QBZ vendor patch (#558): count presented frames for the
// render-liveness watchdog.
crate::note_frame_presented();
Ok(())
}