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:
110 files changed
+8082
-687
No files matched your search
Generated
+144
-3
@@ -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,4 +1,5 @@
|
||||
pub mod diagnostics;
|
||||
pub mod memory_watchdog;
|
||||
pub mod offline_mode;
|
||||
pub mod graphics_autoconfig;
|
||||
pub mod playback_context;
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,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}");
|
||||
}
|
||||
}
|
||||
@@ -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};
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 "Удаление {} треков из избранного…"
|
||||
@@ -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 },
|
||||
|
||||
@@ -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 |
@@ -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");
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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); }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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"); }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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();
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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(§ion.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(§ion.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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
File diff suppressed because it is too large.
Load diff
+100
-26
@@ -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
@@ -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() {
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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
@@ -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
@@ -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`).
|
||||
|
||||
@@ -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" });
|
||||
}
|
||||
@@ -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(),
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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"))]
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user