fix: stabilize playback memory, local library sync and Qt settings
Add shared playback memory profiles, bounded disk-backed playback and storage selection for desktop and daemon. Preserve gapless during streaming-only playback and provide explicit cache eviction with playback confirmation. Stop watcher read events and unchanged scans from rebuilding the local catalog. Restore playlist/sidebar updates, folder availability feedback and player layout. Add appearance presets and a radio-based cache settings panel with translated trade-offs, plus consistent clipboard menus across visible Qt text editors. Validate with Rust regressions, QML interaction tests, isolated release startup and idle-library checks; extend the shared local and CI gates. cx
No files matched your search
@@ -1,5 +1,7 @@
|
||||
# CI for the crates/ workspace on the integration branch.
|
||||
#
|
||||
# Playback memory presets: shared persistence/migration, live budgets and growth;
|
||||
# Qt profile descriptions/selection are covered by the shared QML runtime gate.
|
||||
# Three jobs, three economies:
|
||||
# test — validate all eight gettext catalogs, then `cargo test` on
|
||||
# every crate that needs no Qt (scripts/cargo-test.sh:
|
||||
@@ -38,8 +40,19 @@
|
||||
# and preference preservation; qt-runtime-gate.sh covers the update UI.
|
||||
# Shared cargo-test.sh also gates Orbit HTTP host isolation, library profiles
|
||||
# and the SSE public-identity projection. No separate test command to drift.
|
||||
# Qt runtime gate includes Library source/count regressions and the Rust
|
||||
# playlist-deletion/creation/offline-gapless and maintenance retry regressions.
|
||||
# The shared library service gate preserves manual scan feedback while keeping
|
||||
# background completions silent, including coalesced work and unavailable roots.
|
||||
# Watcher read-feedback and no-op content invalidation are in cargo-test.sh;
|
||||
# Library folders status/action layout and tooltips are in qt-runtime-gate.sh.
|
||||
name: test-crates
|
||||
|
||||
# Shared playback-cache gate covers #774 sealed buffers, Hi-Res decode continuity,
|
||||
# memory policy admission/pressure, persistence and qbzd settings round trips.
|
||||
# Purchases navigation placement matrix; missing-root watcher retry/recovery.
|
||||
# Disk-backed streaming, 24/192 seek, cache reader leases and cancelled prefetch.
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
# pre-release is the real base; main is listed so a PR opened against main
|
||||
@@ -50,6 +63,8 @@ on:
|
||||
- "scripts/cargo-test.sh"
|
||||
- "scripts/install.sh"
|
||||
- "scripts/test-installer.py"
|
||||
- "scripts/test_qt_library.mjs"
|
||||
- "scripts/test_qt_purchases_navigation.mjs"
|
||||
- "scripts/qt-*.py"
|
||||
- "scripts/qt-*.sh"
|
||||
- "scripts/test_qt_build_gates.py"
|
||||
@@ -62,6 +77,10 @@ on:
|
||||
- "scripts/test-search-local-qml.sh"
|
||||
- "scripts/test-orbit-qml.sh"
|
||||
- "scripts/test-updates-qml.sh"
|
||||
- "scripts/test-playback-cache-qml.sh"
|
||||
- "scripts/test-text-edit-menu-qml.sh"
|
||||
- "scripts/test-compact-seek-qml.sh"
|
||||
- "scripts/test-library-folders-qml.sh"
|
||||
- "scripts/test-orbit-daemon.py"
|
||||
- ".github/workflows/test-crates.yml"
|
||||
push:
|
||||
@@ -72,6 +91,8 @@ on:
|
||||
- "scripts/cargo-test.sh"
|
||||
- "scripts/install.sh"
|
||||
- "scripts/test-installer.py"
|
||||
- "scripts/test_qt_library.mjs"
|
||||
- "scripts/test_qt_purchases_navigation.mjs"
|
||||
- "scripts/qt-*.py"
|
||||
- "scripts/qt-*.sh"
|
||||
- "scripts/test_qt_build_gates.py"
|
||||
@@ -84,6 +105,10 @@ on:
|
||||
- "scripts/test-search-local-qml.sh"
|
||||
- "scripts/test-orbit-qml.sh"
|
||||
- "scripts/test-updates-qml.sh"
|
||||
- "scripts/test-playback-cache-qml.sh"
|
||||
- "scripts/test-text-edit-menu-qml.sh"
|
||||
- "scripts/test-compact-seek-qml.sh"
|
||||
- "scripts/test-library-folders-qml.sh"
|
||||
- "scripts/test-orbit-daemon.py"
|
||||
- ".github/workflows/test-crates.yml"
|
||||
workflow_dispatch:
|
||||
|
||||
@@ -493,6 +493,51 @@ Multi-device playback control using Qobuz's real-time streaming protocol. Full
|
||||
- **Offline mode** usable without ever logging into Qobuz, with fully offline
|
||||
playlists and automatic reconnection
|
||||
|
||||
### Playback memory cache
|
||||
|
||||
In **Settings → Playback → Playback cache**, choose a playback memory profile:
|
||||
|
||||
| Profile | Cache and prefetch behavior |
|
||||
| --- | --- |
|
||||
| **Auto — recommended** | Fixed 400 MiB on normal-memory hosts or 50 MiB below 2 GiB RAM. |
|
||||
| **High — dynamic** | Starts at 400 MiB and can grow to 1600 MiB when RAM is available. |
|
||||
| **Desktop** | Fixed 400 MiB with normal prefetch. |
|
||||
| **Low — Pi style** | Fixed 50 MiB, smaller initial buffers and reduced prefetch; skips speculative Hi-Res warm-up. |
|
||||
| **Custom** | Manual base/maximum and dynamic growth; prefetch follows the detected host class. |
|
||||
|
||||
Editing a preset's limits or growth toggle switches to Custom. Existing manual
|
||||
cache settings are preserved as Custom. Restore defaults returns to Auto.
|
||||
Profiles are shared by qbz and qbzd and preserve the requested audio quality.
|
||||
|
||||
Dynamic growth requires measured memory headroom. The cache shrinks after two
|
||||
minutes without new demand and can fall below its base under memory pressure.
|
||||
These budgets cover cached audio, not reserved RAM or the whole application's
|
||||
memory. New buffers follow the selected profile; an existing track continues
|
||||
with its current storage. Changing the profile does not restart the DAC.
|
||||
|
||||
L2 disk cache remains enabled unless **Streaming only** is selected. Tracks
|
||||
outside the memory budget stream through temporary files; L2 playback and
|
||||
catalog gapless read files without loading entire tracks into RAM. Keep the
|
||||
cache directory on real storage when saving RAM: tmpfs consumes RAM too.
|
||||
Offline downloads retain their separate settings. Streaming only keeps gapless
|
||||
available, uses less memory and skips cache-based protection against network
|
||||
interruptions and instant replay.
|
||||
|
||||
```bash
|
||||
qbzd settings set audio.playback_memory_profile high
|
||||
qbzd status --json
|
||||
qbzd settings set audio.playback_memory_profile low
|
||||
qbzd settings set audio.playback_memory_profile auto
|
||||
```
|
||||
|
||||
The CLI also accepts `desktop` and `custom`. Advanced keys remain
|
||||
`audio.playback_cache_dynamic`, `audio.playback_cache_min_mib` and
|
||||
`audio.playback_cache_max_mib`; editing them selects Custom. Use `none` for an
|
||||
automatic limit. Custom dynamic mode defaults to a ceiling four times the base,
|
||||
capped at 16384 MiB. `status.playback_cache` reports the active profile, usage,
|
||||
and effective/base/maximum budgets in bytes.
|
||||
|
||||
|
||||
## Headless daemon (qbzd)
|
||||
|
||||
<p align="center">
|
||||
|
||||
@@ -3997,8 +3997,11 @@ dependencies = [
|
||||
"filetime",
|
||||
"log",
|
||||
"md-5",
|
||||
"qbz-models",
|
||||
"rusqlite",
|
||||
"serde",
|
||||
"tempfile",
|
||||
"windows 0.61.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4359,6 +4362,7 @@ dependencies = [
|
||||
"rodio",
|
||||
"serde",
|
||||
"symphonia",
|
||||
"tempfile",
|
||||
"thiserror 2.0.20",
|
||||
"tokio",
|
||||
"tokio-stream",
|
||||
|
||||
@@ -803,7 +803,6 @@ pub async fn run_driver<A: FrontendAdapter + Send + Sync + 'static>(
|
||||
log::info!(
|
||||
"[qbzd] driver: fetching gapless track {successor_id} after {predecessor_id}"
|
||||
);
|
||||
let cached = player.is_track_cached(successor_id);
|
||||
gapless_fetches.spawn(async move {
|
||||
let Some(_permit) = task_readmit_owner_action(&task_owner_token) else {
|
||||
return (
|
||||
@@ -817,37 +816,21 @@ pub async fn run_driver<A: FrontendAdapter + Send + Sync + 'static>(
|
||||
);
|
||||
};
|
||||
let core = task_runtime.core();
|
||||
// COLD successor: append its initial CMAF buffer as an
|
||||
// incremental source (the desktop's streaming
|
||||
// hand-off) instead of materializing the whole file —
|
||||
// a Hi-Res track cannot reliably download inside the
|
||||
// 10 s window on a Pi, and the whole-file `Vec` is
|
||||
// exactly what a 1 GB box cannot afford. Cached
|
||||
// successors keep the byte path (no network at all).
|
||||
if !cached {
|
||||
// L2 successors keep their open file; cold successors
|
||||
// use bounded disk spools when they exceed the RAM budget.
|
||||
// A failed setup must not fall back to a full-file Vec.
|
||||
if task_owner_actions_allowed() {
|
||||
match core.queue_gapless_streaming(successor_id, quality).await {
|
||||
Ok(()) => {
|
||||
return (
|
||||
task_owner_token,
|
||||
GaplessFetchResult {
|
||||
predecessor_id,
|
||||
successor_id,
|
||||
bytes: None,
|
||||
streamed: true,
|
||||
},
|
||||
)
|
||||
}
|
||||
Ok(()) => return (
|
||||
task_owner_token,
|
||||
GaplessFetchResult { predecessor_id, successor_id, bytes: None, streamed: true },
|
||||
),
|
||||
Err(error) => log::warn!(
|
||||
"[qbzd] driver: gapless stream setup for {successor_id} failed: {error}; trying byte fallback"
|
||||
"[qbzd] driver: gapless setup for {successor_id} failed: {error}; normal advance remains available"
|
||||
),
|
||||
}
|
||||
}
|
||||
let bytes = if task_owner_actions_allowed() {
|
||||
core.fetch_for_gapless_resolved(successor_id, quality, None, None)
|
||||
.await
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let bytes = None;
|
||||
(
|
||||
task_owner_token,
|
||||
GaplessFetchResult {
|
||||
@@ -1037,15 +1020,15 @@ async fn prefetch_successors<A: FrontendAdapter + Send + Sync + 'static>(
|
||||
log::debug!("[qbzd] driver: prefetch skipped — memory-pressure halt active");
|
||||
return;
|
||||
}
|
||||
let profile = qbz_core::system_capabilities::memory_profile();
|
||||
let core = runtime.core();
|
||||
let profile = core.player().playback_prefetch_policy();
|
||||
// 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 depth = 1usize.min(profile.lookahead);
|
||||
let upcoming = core.peek_upcoming(depth).await;
|
||||
let Some(next) = upcoming.into_iter().next() else {
|
||||
return;
|
||||
@@ -1070,8 +1053,13 @@ async fn prefetch_successors<A: FrontendAdapter + Send + Sync + 'static>(
|
||||
// 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();
|
||||
profile.allow_hires && !crate::memory_watchdog::hires_prefetch_paused();
|
||||
let prefetch_quality = cap_prefetch_quality(quality, allow_hires);
|
||||
if prefetch_quality != quality {
|
||||
// Do not warm a CD copy that will be rejected at a Hi-Res transition.
|
||||
// The on-demand gapless spool retains the requested format.
|
||||
return;
|
||||
}
|
||||
if !owner_actions_allowed() {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -678,6 +678,7 @@ const AUDIO_PORTABLE: &[&str] = &[
|
||||
"stream_first_track",
|
||||
"stream_buffer_seconds",
|
||||
"streaming_only",
|
||||
"playback_cache",
|
||||
"normalization_enabled",
|
||||
"normalization_target_lufs",
|
||||
"gapless_enabled",
|
||||
@@ -1195,6 +1196,10 @@ fn apply_audio_writes(data_root: &Path, writes: &[(&str, &Value)]) -> Result<(),
|
||||
"stream_buffer_seconds" => {
|
||||
store.set_stream_buffer_seconds(value.as_u64().unwrap_or(2) as u8)?
|
||||
}
|
||||
"playback_cache" => {
|
||||
let policy = serde_json::from_value((*value).clone()).map_err(|e| format!("playback_cache: {e}"))?;
|
||||
store.set_playback_cache(&policy)?;
|
||||
}
|
||||
"streaming_only" => store.set_streaming_only(as_bool(value))?,
|
||||
"limit_quality_to_device" => store.set_limit_quality_to_device(as_bool(value))?,
|
||||
"preferred_sample_rate" => {
|
||||
|
||||
@@ -76,6 +76,12 @@ impl<A: FrontendAdapter + Send + Sync + 'static> AppRuntime<A> {
|
||||
audio_settings: AudioSettings,
|
||||
visualizer_tap: Option<VisualizerTap>,
|
||||
) -> Self {
|
||||
let host = qbz_core::system_capabilities::memory_profile();
|
||||
qbz_player::player::apply_memory_tuning(
|
||||
host.class == qbz_core::system_capabilities::MemoryClass::LowMemory,
|
||||
host.audio_cache_l1_max_bytes,
|
||||
host.max_initial_buffer_bytes,
|
||||
);
|
||||
let diagnostic = AudioDiagnostic::new();
|
||||
let player = Player::new(device_name, audio_settings, visualizer_tap, diagnostic);
|
||||
let core = QbzCore::new(adapter, player);
|
||||
|
||||
@@ -32,6 +32,8 @@ pub struct AudioSettings {
|
||||
pub stream_buffer_seconds: u8,
|
||||
/// When true, skip L1+L2 cache writes (streaming-only mode). Offline cache still works.
|
||||
pub streaming_only: bool,
|
||||
#[serde(default)]
|
||||
pub playback_cache: qbz_models::playback_cache::PlaybackCacheSettings,
|
||||
/// When true, cap the REQUESTED streaming quality tier at the local output
|
||||
/// device's detected ceiling (#638 fix 3; consumed by the desktop's
|
||||
/// request-time resolution, never by the audio backends). Applies to local
|
||||
@@ -128,6 +130,7 @@ impl Default for AudioSettings {
|
||||
alsa_hardware_volume_controls: HashMap::new(),
|
||||
stream_first_track: true, // On by default (opt-out)
|
||||
stream_buffer_seconds: 2, // 2 seconds initial buffer
|
||||
playback_cache: Default::default(),
|
||||
streaming_only: false, // Disabled by default (cache tracks for instant replay)
|
||||
limit_quality_to_device: false, // Opt-in. Off since 1.1.9 (#45); wired to the read-only probe in #638 fix 3
|
||||
device_max_sample_rate: None, // Set when device is selected
|
||||
@@ -201,6 +204,7 @@ impl AudioSettingsStore {
|
||||
"ALTER TABLE audio_settings ADD COLUMN stream_buffer_seconds INTEGER DEFAULT 2",
|
||||
[],
|
||||
);
|
||||
let _ = conn.execute("ALTER TABLE audio_settings ADD COLUMN playback_cache TEXT NOT NULL DEFAULT '{}'", []);
|
||||
let _ = conn.execute(
|
||||
"ALTER TABLE audio_settings ADD COLUMN streaming_only INTEGER DEFAULT 0",
|
||||
[],
|
||||
@@ -322,7 +326,7 @@ impl AudioSettingsStore {
|
||||
pub fn get_settings(&self) -> Result<AudioSettings, String> {
|
||||
self.conn
|
||||
.query_row(
|
||||
"SELECT output_device, exclusive_mode, dac_passthrough, preferred_sample_rate, backend_type, alsa_plugin, alsa_hardware_volume, stream_first_track, stream_buffer_seconds, streaming_only, limit_quality_to_device, device_max_sample_rate, normalization_enabled, normalization_target_lufs, gapless_enabled, device_sample_rate_limits, pw_force_bitperfect, sync_audio_on_startup, quality_fallback_behavior, skip_sink_switch, allow_quality_fallback, reserve_dac_while_running, dsd_mode, alsa_hardware_volume_controls FROM audio_settings WHERE id = 1",
|
||||
"SELECT output_device, exclusive_mode, dac_passthrough, preferred_sample_rate, backend_type, alsa_plugin, alsa_hardware_volume, stream_first_track, stream_buffer_seconds, streaming_only, limit_quality_to_device, device_max_sample_rate, normalization_enabled, normalization_target_lufs, gapless_enabled, device_sample_rate_limits, pw_force_bitperfect, sync_audio_on_startup, quality_fallback_behavior, skip_sink_switch, allow_quality_fallback, reserve_dac_while_running, dsd_mode, alsa_hardware_volume_controls, playback_cache FROM audio_settings WHERE id = 1",
|
||||
[],
|
||||
|row| {
|
||||
// Parse backend_type from JSON string
|
||||
@@ -360,6 +364,7 @@ impl AudioSettingsStore {
|
||||
alsa_hardware_volume_controls,
|
||||
stream_first_track: row.get::<_, Option<i64>>(7)?.unwrap_or(0) != 0,
|
||||
stream_buffer_seconds: row.get::<_, Option<i64>>(8)?.unwrap_or(3) as u8,
|
||||
playback_cache: row.get::<_, Option<String>>(24)?.and_then(|s| serde_json::from_str::<qbz_models::playback_cache::PlaybackCacheSettings>(&s).ok()).filter(|p| p.validate().is_ok()).unwrap_or_default(),
|
||||
streaming_only: row.get::<_, Option<i64>>(9)?.unwrap_or(0) != 0,
|
||||
limit_quality_to_device: row.get::<_, Option<i64>>(10)?.unwrap_or(0) != 0,
|
||||
device_max_sample_rate: row.get::<_, Option<i64>>(11)?.map(|r| r as u32),
|
||||
@@ -586,6 +591,14 @@ impl AudioSettingsStore {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn set_playback_cache(&self, policy: &qbz_models::playback_cache::PlaybackCacheSettings) -> Result<(), String> {
|
||||
policy.validate()?;
|
||||
let json = serde_json::to_string(policy).map_err(|e| e.to_string())?;
|
||||
self.conn.execute("UPDATE audio_settings SET playback_cache = ?1 WHERE id = 1", params![json])
|
||||
.map_err(|e| format!("Failed to save playback cache settings: {e}"))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn set_streaming_only(&self, enabled: bool) -> Result<(), String> {
|
||||
self.conn
|
||||
.execute(
|
||||
@@ -839,7 +852,8 @@ impl AudioSettingsStore {
|
||||
skip_sink_switch = ?19,
|
||||
allow_quality_fallback = ?20,
|
||||
reserve_dac_while_running = ?21,
|
||||
alsa_hardware_volume_controls = ?22
|
||||
alsa_hardware_volume_controls = ?22,
|
||||
playback_cache = '{}'
|
||||
WHERE id = 1",
|
||||
params![
|
||||
defaults.output_device,
|
||||
@@ -949,6 +963,28 @@ mod tests {
|
||||
(dir, store)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn playback_cache_persists_validates_and_resets_without_losing_other_settings() {
|
||||
use qbz_models::playback_cache::PlaybackCacheSettings;
|
||||
let (dir, store) = fresh_store("playback-cache");
|
||||
let policy = PlaybackCacheSettings { dynamic: true, min_mib: Some(400), max_mib: Some(1600), ..Default::default() };
|
||||
store.set_playback_cache(&policy).unwrap();
|
||||
store.set_gapless_enabled(true).unwrap();
|
||||
drop(store);
|
||||
let store = AudioSettingsStore::new_at(&dir).unwrap();
|
||||
assert_eq!(store.get_settings().unwrap().playback_cache, policy);
|
||||
let invalid = PlaybackCacheSettings { max_mib: Some(100), ..policy.clone() };
|
||||
assert!(store.set_playback_cache(&invalid).is_err());
|
||||
assert_eq!(store.get_settings().unwrap().playback_cache, policy);
|
||||
store.set_quality_fallback_behavior("always_skip").unwrap();
|
||||
store.reset_all().unwrap();
|
||||
let fresh = store.get_settings().unwrap();
|
||||
assert_eq!(fresh.playback_cache, PlaybackCacheSettings::default());
|
||||
assert_eq!(fresh.quality_fallback_behavior, "always_skip");
|
||||
drop(store);
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn audio_settings_default_values_are_stable() {
|
||||
let settings = AudioSettings::default();
|
||||
|
||||
@@ -6,6 +6,7 @@ description = "Audio caching system for QBZ - L1 memory + L2 disk caches"
|
||||
authors = ["blitzkriegfc"]
|
||||
|
||||
[dependencies]
|
||||
qbz-models = { path = "../qbz-models" }
|
||||
# Logging
|
||||
log = "0.4"
|
||||
|
||||
@@ -15,7 +16,11 @@ serde = { version = "1", features = ["derive"] }
|
||||
# File system utilities
|
||||
dirs = "6"
|
||||
filetime = "0.2"
|
||||
tempfile = "3"
|
||||
|
||||
# Image cache index + URL hashing
|
||||
rusqlite = { version = "0.40", features = ["bundled"] }
|
||||
md-5 = "0.10"
|
||||
|
||||
[target.'cfg(target_os = "windows")'.dependencies]
|
||||
windows = { version = "0.61", features = ["Win32_System_SystemInformation"] }
|
||||
@@ -4,10 +4,11 @@
|
||||
//! Evicted tracks can optionally spill to L2 disk cache.
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::sync::{Arc, Mutex, OnceLock};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use crate::PlaybackCache;
|
||||
use crate::{memory::MemoryHeadroom, PlaybackCache};
|
||||
use qbz_models::playback_cache::PlaybackCacheSettings;
|
||||
|
||||
/// Cached audio data for a track
|
||||
#[derive(Clone)]
|
||||
@@ -17,10 +18,31 @@ pub struct CachedTrack {
|
||||
pub size_bytes: usize,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct SharedTrack {
|
||||
track_id: u64,
|
||||
data: Arc<Vec<u8>>,
|
||||
size_bytes: usize,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum CacheAdmission {
|
||||
Memory,
|
||||
Disk,
|
||||
Skipped,
|
||||
}
|
||||
|
||||
/// Internal cache state - all in one struct to avoid deadlocks
|
||||
struct CacheState {
|
||||
/// Cached tracks keyed by track ID
|
||||
tracks: HashMap<u64, CachedTrack>,
|
||||
tracks: HashMap<u64, SharedTrack>,
|
||||
policy: PlaybackCacheSettings,
|
||||
base: usize,
|
||||
ceiling: usize,
|
||||
effective: usize,
|
||||
headroom: Option<(MemoryHeadroom, Instant)>,
|
||||
growth_since_sample: usize,
|
||||
last_demand: Instant,
|
||||
/// Order of access for LRU eviction (most recent at back)
|
||||
access_order: Vec<u64>,
|
||||
/// Current cache size in bytes
|
||||
@@ -41,6 +63,7 @@ struct CacheState {
|
||||
/// cache (if configured) for later retrieval.
|
||||
pub struct AudioCache {
|
||||
state: Mutex<CacheState>,
|
||||
maintenance_thread: OnceLock<std::thread::Thread>,
|
||||
/// Maximum cache size in bytes
|
||||
max_size_bytes: usize,
|
||||
/// Optional disk-based L2 cache for evicted tracks
|
||||
@@ -57,8 +80,16 @@ impl AudioCache {
|
||||
/// Create a new cache with specified max size in bytes
|
||||
pub fn new(max_size_bytes: usize) -> Self {
|
||||
Self {
|
||||
maintenance_thread: OnceLock::new(),
|
||||
state: Mutex::new(CacheState {
|
||||
tracks: HashMap::new(),
|
||||
policy: PlaybackCacheSettings::default(),
|
||||
base: max_size_bytes,
|
||||
ceiling: max_size_bytes,
|
||||
effective: max_size_bytes,
|
||||
headroom: None,
|
||||
growth_since_sample: 0,
|
||||
last_demand: Instant::now(),
|
||||
access_order: Vec::new(),
|
||||
current_size: 0,
|
||||
fetching: HashSet::new(),
|
||||
@@ -72,8 +103,16 @@ impl AudioCache {
|
||||
/// Create cache with disk spillover enabled
|
||||
pub fn with_playback_cache(max_size_bytes: usize, playback_cache: Arc<PlaybackCache>) -> Self {
|
||||
Self {
|
||||
maintenance_thread: OnceLock::new(),
|
||||
state: Mutex::new(CacheState {
|
||||
tracks: HashMap::new(),
|
||||
policy: PlaybackCacheSettings::default(),
|
||||
base: max_size_bytes,
|
||||
ceiling: max_size_bytes,
|
||||
effective: max_size_bytes,
|
||||
headroom: None,
|
||||
growth_since_sample: 0,
|
||||
last_demand: Instant::now(),
|
||||
access_order: Vec::new(),
|
||||
current_size: 0,
|
||||
fetching: HashSet::new(),
|
||||
@@ -109,7 +148,13 @@ impl AudioCache {
|
||||
log::debug!("Cache miss for track {}", track_id);
|
||||
}
|
||||
|
||||
track
|
||||
drop(state);
|
||||
// Legacy Vec consumers copy after releasing the cache lock.
|
||||
track.map(|t| CachedTrack {
|
||||
track_id: t.track_id,
|
||||
data: (*t.data).clone(),
|
||||
size_bytes: t.size_bytes,
|
||||
})
|
||||
}
|
||||
|
||||
/// Check if a track is in cache without updating access order
|
||||
@@ -161,77 +206,195 @@ impl AudioCache {
|
||||
self.state.lock().unwrap().failed.remove(&track_id);
|
||||
}
|
||||
|
||||
/// Insert a track into cache, evicting old entries to disk if needed
|
||||
pub fn insert(&self, track_id: u64, data: Vec<u8>) {
|
||||
let size = data.len();
|
||||
|
||||
// Don't cache if track is larger than max cache size
|
||||
if size > self.max_size_bytes {
|
||||
log::warn!(
|
||||
"Track {} ({} bytes) too large for cache (max {} bytes)",
|
||||
track_id,
|
||||
size,
|
||||
self.max_size_bytes
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Collect tracks to evict (to avoid holding lock while writing to disk)
|
||||
let mut tracks_to_spill: Vec<CachedTrack> = Vec::new();
|
||||
|
||||
{
|
||||
/// Apply a validated persisted policy without restarting the audio device.
|
||||
pub fn configure(&self, policy: PlaybackCacheSettings) -> Result<(), String> {
|
||||
policy.validate()?;
|
||||
let retired = {
|
||||
let mut state = self.state.lock().unwrap();
|
||||
if state.policy == policy {
|
||||
return Ok(());
|
||||
}
|
||||
let (base, ceiling) = policy.budgets(self.max_size_bytes);
|
||||
state.policy = policy;
|
||||
state.base = base;
|
||||
state.ceiling = ceiling;
|
||||
state.effective = base;
|
||||
state.last_demand = Instant::now();
|
||||
Self::trim(&mut state)
|
||||
};
|
||||
if let Some(worker) = self.maintenance_thread.get() {
|
||||
worker.unpark();
|
||||
}
|
||||
drop(retired); // Destruction of large buffers never holds the cache mutex.
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// Evict old entries to make room
|
||||
while state.current_size + size > self.max_size_bytes && !state.access_order.is_empty()
|
||||
{
|
||||
let oldest_id = state.access_order.remove(0);
|
||||
if let Some(track) = state.tracks.remove(&oldest_id) {
|
||||
state.current_size = state.current_size.saturating_sub(track.size_bytes);
|
||||
log::debug!(
|
||||
"Evicting track {} ({} bytes) from memory cache",
|
||||
oldest_id,
|
||||
track.size_bytes
|
||||
);
|
||||
tracks_to_spill.push(track);
|
||||
/// Weak ownership: maintenance cannot keep a player/cache alive after shutdown.
|
||||
pub fn start_maintenance(cache: &Arc<Self>) {
|
||||
let weak = Arc::downgrade(cache);
|
||||
let worker = std::thread::spawn(move || loop {
|
||||
let Some(cache) = weak.upgrade() else { break };
|
||||
let headroom = if cache.stats().dynamic {
|
||||
crate::memory::read()
|
||||
} else {
|
||||
None
|
||||
};
|
||||
cache.maintain(headroom, Instant::now());
|
||||
drop(cache);
|
||||
std::thread::park_timeout(Duration::from_secs(15));
|
||||
});
|
||||
let _ = cache.maintenance_thread.set(worker.thread().clone());
|
||||
}
|
||||
|
||||
fn reserve(memory: MemoryHeadroom) -> u64 {
|
||||
(256 * 1024 * 1024).max(memory.total / 4)
|
||||
}
|
||||
|
||||
fn critical(memory: MemoryHeadroom) -> bool {
|
||||
memory.available < (128 * 1024 * 1024).max(memory.total / 100)
|
||||
}
|
||||
|
||||
fn maintain(&self, headroom: Option<MemoryHeadroom>, now: Instant) {
|
||||
let retired = {
|
||||
let mut state = self.state.lock().unwrap();
|
||||
state.headroom = headroom.map(|m| (m, now));
|
||||
state.growth_since_sample = 0;
|
||||
if state.policy.dynamic {
|
||||
if headroom.is_some_and(|m| m.available < Self::reserve(m)) {
|
||||
// Relinquish growth first; critical pressure also overrides the base.
|
||||
state.effective = if headroom.is_some_and(Self::critical) {
|
||||
0
|
||||
} else {
|
||||
state.base
|
||||
};
|
||||
} else if now.saturating_duration_since(state.last_demand)
|
||||
>= Duration::from_secs(120)
|
||||
{
|
||||
state.effective = state.base;
|
||||
}
|
||||
}
|
||||
Self::trim(&mut state)
|
||||
};
|
||||
drop(retired);
|
||||
}
|
||||
|
||||
fn trim(state: &mut CacheState) -> Vec<SharedTrack> {
|
||||
let mut retired = Vec::new();
|
||||
while state.current_size > state.effective && !state.access_order.is_empty() {
|
||||
let oldest = state.access_order.remove(0);
|
||||
if let Some(track) = state.tracks.remove(&oldest) {
|
||||
state.current_size = state.current_size.saturating_sub(track.size_bytes);
|
||||
retired.push(track);
|
||||
}
|
||||
}
|
||||
retired
|
||||
}
|
||||
|
||||
pub fn insert(&self, track_id: u64, data: Vec<u8>) -> CacheAdmission {
|
||||
self.insert_shared(track_id, Arc::new(data))
|
||||
}
|
||||
|
||||
/// Admit a sealed streaming buffer without allocating a second track-sized copy.
|
||||
/// Reservation, eviction and insertion are atomic with respect to other writers.
|
||||
pub fn insert_shared(&self, track_id: u64, data: Arc<Vec<u8>>) -> CacheAdmission {
|
||||
self.admit(track_id, data, true)
|
||||
}
|
||||
|
||||
/// L2 reads may warm L1, but an oversized hit must not rewrite the same file.
|
||||
pub fn promote_from_disk(&self, track_id: u64, data: Vec<u8>) -> CacheAdmission {
|
||||
self.admit(track_id, Arc::new(data), false)
|
||||
}
|
||||
|
||||
/// Decide storage before a download allocates its payload. Dynamic growth
|
||||
/// uses the same fresh-headroom allowance as L1 admission, not just max MiB.
|
||||
pub fn can_buffer_in_memory(&self, size: usize) -> bool {
|
||||
let mut state = self.state.lock().unwrap();
|
||||
Self::budget_for_incoming(&mut state, size);
|
||||
let fits = size <= state.effective;
|
||||
let retired = Self::trim(&mut state);
|
||||
drop(state);
|
||||
drop(retired);
|
||||
fits
|
||||
}
|
||||
|
||||
fn budget_for_incoming(state: &mut CacheState, size: usize) {
|
||||
let now = Instant::now();
|
||||
state.last_demand = now;
|
||||
if state.policy.dynamic {
|
||||
if let Some((memory, _sampled)) = state.headroom.filter(|(_, sampled)| {
|
||||
now.saturating_duration_since(*sampled) <= Duration::from_secs(30)
|
||||
}) {
|
||||
let reserve = Self::reserve(memory);
|
||||
if memory.available >= reserve {
|
||||
let desired = state.current_size.saturating_add(size).min(state.ceiling);
|
||||
let spare = memory
|
||||
.available
|
||||
.saturating_sub(reserve)
|
||||
.min(usize::MAX as u64) as usize;
|
||||
// Spend at most half the current headroom; decoding/prefetch need room too.
|
||||
let permitted = state
|
||||
.effective
|
||||
.saturating_add((spare / 2).saturating_sub(state.growth_since_sample));
|
||||
let next = state.effective.max(desired.min(permitted));
|
||||
state.growth_since_sample += next.saturating_sub(state.effective);
|
||||
state.effective = next;
|
||||
} else {
|
||||
state.effective = if Self::critical(memory) {
|
||||
0
|
||||
} else {
|
||||
state.base
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Spill evicted tracks to disk cache (outside of lock)
|
||||
if let Some(playback_cache) = &self.playback_cache {
|
||||
for track in tracks_to_spill {
|
||||
playback_cache.insert(track.track_id, &track.data);
|
||||
fn admit(&self, track_id: u64, data: Arc<Vec<u8>>, spill_rejected: bool) -> CacheAdmission {
|
||||
let size = data.len();
|
||||
let mut retired = Vec::new();
|
||||
let admitted = {
|
||||
let mut state = self.state.lock().unwrap();
|
||||
Self::budget_for_incoming(&mut state, size);
|
||||
if size > state.effective {
|
||||
retired.extend(Self::trim(&mut state));
|
||||
false
|
||||
} else {
|
||||
if let Some(old) = state.tracks.remove(&track_id) {
|
||||
state.current_size = state.current_size.saturating_sub(old.size_bytes);
|
||||
retired.push(old);
|
||||
}
|
||||
state.access_order.retain(|&id| id != track_id);
|
||||
state.current_size = state.current_size.saturating_add(size);
|
||||
retired.extend(Self::trim(&mut state));
|
||||
state.tracks.insert(
|
||||
track_id,
|
||||
SharedTrack {
|
||||
track_id,
|
||||
data: data.clone(),
|
||||
size_bytes: size,
|
||||
},
|
||||
);
|
||||
state.access_order.push(track_id);
|
||||
true
|
||||
}
|
||||
};
|
||||
if let Some(disk) = &self.playback_cache {
|
||||
for track in retired {
|
||||
disk.insert(track.track_id, &track.data);
|
||||
}
|
||||
}
|
||||
|
||||
let mut state = self.state.lock().unwrap();
|
||||
|
||||
// If track already exists, update size tracking
|
||||
if let Some(existing) = state.tracks.get(&track_id) {
|
||||
state.current_size = state.current_size.saturating_sub(existing.size_bytes);
|
||||
if admitted {
|
||||
CacheAdmission::Memory
|
||||
} else if spill_rejected
|
||||
&& self
|
||||
.playback_cache
|
||||
.as_ref()
|
||||
.is_some_and(|disk| disk.insert(track_id, &data))
|
||||
{
|
||||
CacheAdmission::Disk
|
||||
} else {
|
||||
CacheAdmission::Skipped
|
||||
}
|
||||
|
||||
let cached = CachedTrack {
|
||||
track_id,
|
||||
data,
|
||||
size_bytes: size,
|
||||
};
|
||||
|
||||
state.tracks.insert(track_id, cached);
|
||||
state.current_size += size;
|
||||
|
||||
// Update access order
|
||||
state.access_order.retain(|&id| id != track_id);
|
||||
state.access_order.push(track_id);
|
||||
|
||||
log::info!(
|
||||
"Cached track {} ({} bytes). Cache size: {}/{} bytes",
|
||||
track_id,
|
||||
size,
|
||||
state.current_size,
|
||||
self.max_size_bytes
|
||||
);
|
||||
}
|
||||
|
||||
/// Clear all cached data (both L1 memory and L2 disk caches)
|
||||
@@ -268,9 +431,14 @@ impl AudioCache {
|
||||
pub fn stats(&self) -> CacheStats {
|
||||
let state = self.state.lock().unwrap();
|
||||
CacheStats {
|
||||
profile: state.policy.selected_profile().as_str().into(),
|
||||
cached_tracks: state.tracks.len(),
|
||||
current_size_bytes: state.current_size,
|
||||
max_size_bytes: self.max_size_bytes,
|
||||
max_size_bytes: state.effective,
|
||||
base_size_bytes: state.base,
|
||||
ceiling_size_bytes: state.ceiling,
|
||||
recommended_size_bytes: self.max_size_bytes,
|
||||
dynamic: state.policy.dynamic,
|
||||
fetching_count: state.fetching.len(),
|
||||
}
|
||||
}
|
||||
@@ -279,8 +447,218 @@ impl AudioCache {
|
||||
/// Cache statistics
|
||||
#[derive(Debug, Clone, serde::Serialize)]
|
||||
pub struct CacheStats {
|
||||
pub profile: String,
|
||||
pub cached_tracks: usize,
|
||||
pub current_size_bytes: usize,
|
||||
pub max_size_bytes: usize,
|
||||
pub fetching_count: usize,
|
||||
pub base_size_bytes: usize,
|
||||
pub ceiling_size_bytes: usize,
|
||||
pub recommended_size_bytes: usize,
|
||||
pub dynamic: bool,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod playback_policy_tests {
|
||||
use super::*;
|
||||
const MIB: usize = 1024 * 1024;
|
||||
fn dynamic() -> PlaybackCacheSettings {
|
||||
PlaybackCacheSettings {
|
||||
dynamic: true,
|
||||
min_mib: Some(16),
|
||||
max_mib: Some(64),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
fn roomy() -> MemoryHeadroom {
|
||||
MemoryHeadroom {
|
||||
total: 4 << 30,
|
||||
available: 3 << 30,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn defaults_and_unknown_headroom_do_not_grow() {
|
||||
let cache = AudioCache::new(16 * MIB);
|
||||
cache.configure(dynamic()).unwrap();
|
||||
assert_eq!(cache.insert(1, vec![0; 20 * MIB]), CacheAdmission::Skipped);
|
||||
assert_eq!(cache.stats().max_size_bytes, 16 * MIB);
|
||||
cache.configure(PlaybackCacheSettings::default()).unwrap();
|
||||
cache.maintain(Some(roomy()), Instant::now());
|
||||
assert_eq!(cache.insert(1, vec![0; 20 * MIB]), CacheAdmission::Skipped);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn on_demand_growth_shares_bytes_obeys_ceiling_and_shrinks_with_hysteresis() {
|
||||
let cache = AudioCache::new(16 * MIB);
|
||||
cache.configure(dynamic()).unwrap();
|
||||
let now = Instant::now();
|
||||
cache.maintain(Some(roomy()), now);
|
||||
let bytes = Arc::new(vec![7; 24 * MIB]);
|
||||
assert_eq!(
|
||||
cache.insert_shared(1, bytes.clone()),
|
||||
CacheAdmission::Memory
|
||||
);
|
||||
assert!(Arc::ptr_eq(
|
||||
&bytes,
|
||||
&cache.state.lock().unwrap().tracks[&1].data
|
||||
));
|
||||
assert_eq!(cache.stats().max_size_bytes, 24 * MIB);
|
||||
cache.maintain(Some(roomy()), now + Duration::from_secs(60));
|
||||
assert_eq!(cache.stats().cached_tracks, 1);
|
||||
cache.maintain(Some(roomy()), now + Duration::from_secs(121));
|
||||
assert_eq!(cache.stats().max_size_bytes, 16 * MIB);
|
||||
assert!(!cache.contains(1));
|
||||
assert_eq!(bytes[0], 7); // eviction cannot truncate the active source
|
||||
cache.maintain(Some(roomy()), Instant::now());
|
||||
assert_eq!(cache.insert(2, vec![0; 65 * MIB]), CacheAdmission::Skipped);
|
||||
assert!(cache.stats().max_size_bytes <= 64 * MIB);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pressure_overrides_base_and_stale_metrics_cannot_grant_growth() {
|
||||
let cache = AudioCache::new(16 * MIB);
|
||||
cache.configure(dynamic()).unwrap();
|
||||
cache.maintain(Some(roomy()), Instant::now() - Duration::from_secs(31));
|
||||
assert_eq!(cache.insert(1, vec![0; 24 * MIB]), CacheAdmission::Skipped);
|
||||
cache.maintain(Some(roomy()), Instant::now());
|
||||
cache.insert(1, vec![0; 24 * MIB]);
|
||||
cache.maintain(
|
||||
Some(MemoryHeadroom {
|
||||
total: 4 << 30,
|
||||
available: 64 * MIB as u64,
|
||||
}),
|
||||
Instant::now(),
|
||||
);
|
||||
assert_eq!(cache.stats().max_size_bytes, 0);
|
||||
assert_eq!(cache.stats().current_size_bytes, 0);
|
||||
assert_eq!(cache.insert(2, vec![0; 8]), CacheAdmission::Skipped);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn limited_headroom_releases_growth_but_keeps_the_base() {
|
||||
let cache = AudioCache::new(16 * MIB);
|
||||
cache.configure(dynamic()).unwrap();
|
||||
cache.maintain(Some(roomy()), Instant::now());
|
||||
cache.insert(1, vec![0; 24 * MIB]);
|
||||
cache.maintain(
|
||||
Some(MemoryHeadroom {
|
||||
total: 4 << 30,
|
||||
available: 512 << 20,
|
||||
}),
|
||||
Instant::now(),
|
||||
);
|
||||
assert_eq!(cache.stats().max_size_bytes, 16 * MIB);
|
||||
assert_eq!(cache.insert(2, vec![1; 8 * MIB]), CacheAdmission::Memory);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn disk_promotion_does_not_write_rejected_bytes_back_to_disk() {
|
||||
let path =
|
||||
std::env::temp_dir().join(format!("qbz-774-promote-disk-{}", std::process::id()));
|
||||
let disk = Arc::new(PlaybackCache::with_path(path.clone(), 1024).unwrap());
|
||||
let cache = AudioCache::with_playback_cache(16, disk.clone());
|
||||
assert_eq!(
|
||||
cache.promote_from_disk(1, vec![7; 32]),
|
||||
CacheAdmission::Skipped
|
||||
);
|
||||
assert!(!disk.contains(1));
|
||||
assert_eq!(
|
||||
cache.promote_from_disk(2, vec![7; 8]),
|
||||
CacheAdmission::Memory
|
||||
);
|
||||
drop(cache);
|
||||
drop(disk);
|
||||
std::fs::remove_dir_all(path).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn concurrent_oversized_streams_respect_the_disk_budget() {
|
||||
let path =
|
||||
std::env::temp_dir().join(format!("qbz-774-parallel-disk-{}", std::process::id()));
|
||||
let disk = Arc::new(PlaybackCache::with_path(path.clone(), 1024).unwrap());
|
||||
let cache = Arc::new(AudioCache::with_playback_cache(16, disk.clone()));
|
||||
let handles: Vec<_> = (0..8)
|
||||
.map(|id| {
|
||||
let cache = cache.clone();
|
||||
std::thread::spawn(move || {
|
||||
assert_eq!(cache.insert(id, vec![id as u8; 600]), CacheAdmission::Disk);
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
for handle in handles {
|
||||
handle.join().unwrap();
|
||||
}
|
||||
assert!(disk.stats().current_size_bytes <= 1024);
|
||||
let data = (0..8).find_map(|id| disk.get(id)).unwrap();
|
||||
assert_eq!(data.len(), 600);
|
||||
assert!(data.iter().all(|b| *b == data[0]));
|
||||
drop(cache);
|
||||
drop(disk);
|
||||
std::fs::remove_dir_all(path).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn concurrent_writers_and_replacement_never_exceed_budget() {
|
||||
let cache = Arc::new(AudioCache::new(128));
|
||||
let handles: Vec<_> = (0..8)
|
||||
.map(|id| {
|
||||
let cache = cache.clone();
|
||||
std::thread::spawn(move || {
|
||||
for _ in 0..50 {
|
||||
cache.insert(id, vec![id as u8; 32]);
|
||||
let stats = cache.stats();
|
||||
assert!(stats.current_size_bytes <= stats.max_size_bytes);
|
||||
}
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
for handle in handles {
|
||||
handle.join().unwrap();
|
||||
}
|
||||
let state = cache.state.lock().unwrap();
|
||||
assert_eq!(
|
||||
state.current_size,
|
||||
state.tracks.values().map(|t| t.size_bytes).sum::<usize>()
|
||||
);
|
||||
assert_eq!(state.access_order.len(), state.tracks.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oversized_l1_falls_back_to_disk_and_reports_rejection_truthfully() {
|
||||
let path = std::env::temp_dir().join(format!("qbz-774-disk-{}", std::process::id()));
|
||||
let disk = Arc::new(PlaybackCache::with_path(path.clone(), 1024).unwrap());
|
||||
let cache = AudioCache::with_playback_cache(16, disk.clone());
|
||||
assert_eq!(cache.insert(1, vec![7; 32]), CacheAdmission::Disk);
|
||||
assert_eq!(disk.get(1).unwrap(), vec![7; 32]);
|
||||
assert!(!cache.contains(1));
|
||||
assert_eq!(cache.insert(2, vec![8; 2048]), CacheAdmission::Skipped);
|
||||
assert!(!disk.contains(2));
|
||||
drop(cache);
|
||||
drop(disk);
|
||||
std::fs::remove_dir_all(path).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod memory_profile_tests {
|
||||
use super::*;
|
||||
use qbz_models::playback_cache::PlaybackMemoryProfile;
|
||||
#[test]
|
||||
fn memory_profile_switches_live_budget_and_high_growth_requires_headroom() {
|
||||
let cache=AudioCache::new(400<<20);
|
||||
let mut policy=PlaybackCacheSettings::default();policy.select_profile(PlaybackMemoryProfile::High);
|
||||
cache.configure(policy.clone()).unwrap();
|
||||
assert!(!cache.can_buffer_in_memory(681736251));
|
||||
cache.maintain(Some(MemoryHeadroom { total:4<<30, available:3<<30 }),Instant::now());
|
||||
assert!(cache.can_buffer_in_memory(681736251));
|
||||
assert_eq!(cache.stats().ceiling_size_bytes,1600<<20);
|
||||
assert!(cache.stats().max_size_bytes>400<<20);
|
||||
policy.select_profile(PlaybackMemoryProfile::Low);cache.configure(policy.clone()).unwrap();
|
||||
assert_eq!(cache.stats().max_size_bytes,50<<20);
|
||||
assert!(!cache.can_buffer_in_memory(681736251));
|
||||
assert_eq!(cache.stats().profile,"low");
|
||||
policy.dynamic=true;assert!(cache.configure(policy).is_err());
|
||||
assert_eq!(cache.stats().profile,"low");
|
||||
}
|
||||
}
|
||||
@@ -56,9 +56,10 @@
|
||||
//! ```
|
||||
|
||||
mod audio_cache;
|
||||
mod memory;
|
||||
mod image_cache;
|
||||
mod playback_cache;
|
||||
|
||||
pub use audio_cache::{AudioCache, CacheStats, CachedTrack};
|
||||
pub use audio_cache::{AudioCache, CacheStats, CachedTrack, CacheAdmission};
|
||||
pub use image_cache::{ImageCacheService, ImageCacheStats};
|
||||
pub use playback_cache::{PlaybackCache, PlaybackCacheStats};
|
||||
@@ -0,0 +1,138 @@
|
||||
//! Memory headroom sampled by the cache worker, never by an audio callback.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub(crate) struct MemoryHeadroom {
|
||||
pub total: u64,
|
||||
pub available: u64,
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
pub(crate) fn read() -> Option<MemoryHeadroom> {
|
||||
let text = std::fs::read_to_string("/proc/meminfo").ok()?;
|
||||
let kib = |key: &str| -> Option<u64> {
|
||||
text.lines()
|
||||
.find_map(|line| line.strip_prefix(key))?
|
||||
.split_whitespace()
|
||||
.next()?
|
||||
.parse::<u64>()
|
||||
.ok()?
|
||||
.checked_mul(1024)
|
||||
};
|
||||
let mut memory = MemoryHeadroom {
|
||||
total: kib("MemTotal:")?,
|
||||
available: kib("MemAvailable:")?,
|
||||
};
|
||||
// Respect all enclosing cgroup-v2 limits, including a container's parent.
|
||||
if let Ok(groups) = std::fs::read_to_string("/proc/self/cgroup") {
|
||||
if let Some(group) = groups.lines().find_map(|line| line.strip_prefix("0::")) {
|
||||
let root = std::path::Path::new("/sys/fs/cgroup");
|
||||
let mut path = root.join(group.trim_start_matches('/'));
|
||||
while path.starts_with(root) {
|
||||
let read_number = |name| {
|
||||
std::fs::read_to_string(path.join(name))
|
||||
.ok()?
|
||||
.trim()
|
||||
.parse::<u64>()
|
||||
.ok()
|
||||
};
|
||||
if let (Some(limit), Some(used)) =
|
||||
(read_number("memory.max"), read_number("memory.current"))
|
||||
{
|
||||
memory.total = memory.total.min(limit);
|
||||
memory.available = memory.available.min(limit.saturating_sub(used));
|
||||
}
|
||||
if !path.pop() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(memory)
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
pub(crate) fn read() -> Option<MemoryHeadroom> {
|
||||
let total = std::process::Command::new("/usr/sbin/sysctl")
|
||||
.args(["-n", "hw.memsize", "kern.memorystatus_vm_pressure_level"])
|
||||
.output()
|
||||
.ok()?;
|
||||
let pages = std::process::Command::new("/usr/bin/vm_stat")
|
||||
.output()
|
||||
.ok()?;
|
||||
if !total.status.success() || !pages.status.success() {
|
||||
return None;
|
||||
}
|
||||
let info = std::str::from_utf8(&total.stdout).ok()?;
|
||||
let mut info = info.lines();
|
||||
parse_macos(
|
||||
info.next()?.trim().parse().ok()?,
|
||||
std::str::from_utf8(&pages.stdout).ok()?,
|
||||
info.next()?.trim().parse().ok()?,
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(any(target_os = "macos", test))]
|
||||
fn parse_macos(total: u64, text: &str, pressure: u32) -> Option<MemoryHeadroom> {
|
||||
let page_size = text
|
||||
.lines()
|
||||
.next()?
|
||||
.split("page size of ")
|
||||
.nth(1)?
|
||||
.split_whitespace()
|
||||
.next()?
|
||||
.parse::<u64>()
|
||||
.ok()?;
|
||||
let pages = |key| {
|
||||
text.lines()
|
||||
.find_map(|line| line.strip_prefix(key))?
|
||||
.trim()
|
||||
.trim_end_matches('.')
|
||||
.parse::<u64>()
|
||||
.ok()
|
||||
};
|
||||
// sysctl exports dispatch flags, not XNU's internal 0..4 enum:
|
||||
// 1 = normal, 2 = warning, 4 = critical. Unknown/missing metrics grant nothing.
|
||||
// File-backed pages are reclaimable; do not count anonymous inactive pages
|
||||
// or compressed memory. Counting only free pages misclassifies healthy Macs.
|
||||
let available = match pressure {
|
||||
1 => pages("Pages free:")?
|
||||
.checked_add(pages("File-backed pages:")?)?
|
||||
.checked_mul(page_size)?
|
||||
.min(total),
|
||||
2 | 4 => 0,
|
||||
_ => return None,
|
||||
};
|
||||
Some(MemoryHeadroom { total, available })
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
pub(crate) fn read() -> Option<MemoryHeadroom> {
|
||||
use windows::Win32::System::SystemInformation::{GlobalMemoryStatusEx, MEMORYSTATUSEX};
|
||||
let mut status = MEMORYSTATUSEX {
|
||||
dwLength: std::mem::size_of::<MEMORYSTATUSEX>() as u32,
|
||||
..Default::default()
|
||||
};
|
||||
unsafe { GlobalMemoryStatusEx(&mut status) }.ok()?;
|
||||
Some(MemoryHeadroom {
|
||||
total: status.ullTotalPhys,
|
||||
available: status.ullAvailPhys,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
|
||||
pub(crate) fn read() -> Option<MemoryHeadroom> {
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn macos_counts_reclaimable_files_but_honors_real_pressure() {
|
||||
let text = "Mach Virtual Memory Statistics: (page size of 16384 bytes)\nPages free: 4058.\nPages inactive: 161060.\nFile-backed pages: 119431.\nPages occupied by compressor: 97493.\n";
|
||||
let m = super::parse_macos(8 << 30, text, 1).unwrap();
|
||||
assert_eq!(m.available, (4058 + 119431) * 16384);
|
||||
assert_eq!(super::parse_macos(8 << 30, text, 2).unwrap().available, 0);
|
||||
assert_eq!(super::parse_macos(8 << 30, text, 4).unwrap().available, 0);
|
||||
assert!(super::parse_macos(8 << 30, text, 0).is_none());
|
||||
assert!(super::parse_macos(8 << 30, "unavailable", 1).is_none());
|
||||
}
|
||||
}
|
||||
@@ -33,6 +33,8 @@ struct PlaybackCacheState {
|
||||
/// Files are named `{track_id}.audio` in the cache directory.
|
||||
pub struct PlaybackCache {
|
||||
state: Mutex<PlaybackCacheState>,
|
||||
// Serialize admission with eviction so parallel oversized streams respect L2.
|
||||
writes: Mutex<()>,
|
||||
/// Cache directory path
|
||||
cache_dir: PathBuf,
|
||||
/// Maximum cache size in bytes
|
||||
@@ -59,6 +61,7 @@ impl PlaybackCache {
|
||||
.map_err(|e| format!("Failed to create playback cache directory: {}", e))?;
|
||||
|
||||
let cache = Self {
|
||||
writes: Mutex::new(()),
|
||||
state: Mutex::new(PlaybackCacheState {
|
||||
entries: HashMap::new(),
|
||||
current_size: 0,
|
||||
@@ -131,6 +134,23 @@ impl PlaybackCache {
|
||||
self.state.lock().unwrap().entries.contains_key(&track_id)
|
||||
}
|
||||
|
||||
/// Open an immutable cache entry without allocating its contents. Cache
|
||||
/// replacement is atomic so active decoder handles keep the original file.
|
||||
pub fn open(&self, track_id: u64) -> Option<fs::File> {
|
||||
let file = fs::File::open(self.track_path(track_id)).ok()?;
|
||||
let mut state = self.state.lock().ok()?;
|
||||
if let Some(entry) = state.entries.get_mut(&track_id) {
|
||||
entry.last_accessed = SystemTime::now();
|
||||
}
|
||||
Some(file)
|
||||
}
|
||||
|
||||
/// Temporary playback spools live on the cache disk, never the system /tmp
|
||||
/// (commonly tmpfs on streamers). They disappear with their last file handle.
|
||||
pub fn create_spool(&self) -> std::io::Result<fs::File> {
|
||||
tempfile::tempfile_in(&self.cache_dir)
|
||||
}
|
||||
|
||||
/// Get a track from the cache
|
||||
pub fn get(&self, track_id: u64) -> Option<Vec<u8>> {
|
||||
let path = self.track_path(track_id);
|
||||
@@ -181,7 +201,8 @@ impl PlaybackCache {
|
||||
}
|
||||
|
||||
/// Insert a track into the cache (called when evicting from memory cache)
|
||||
pub fn insert(&self, track_id: u64, data: &[u8]) {
|
||||
pub fn insert(&self, track_id: u64, data: &[u8]) -> bool {
|
||||
let _write = self.writes.lock().unwrap();
|
||||
let size = data.len() as u64;
|
||||
|
||||
// Don't cache if larger than max size
|
||||
@@ -192,18 +213,27 @@ impl PlaybackCache {
|
||||
size / (1024 * 1024),
|
||||
self.max_size_bytes / (1024 * 1024)
|
||||
);
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
|
||||
// Evict old entries if needed
|
||||
self.evict_if_needed(size);
|
||||
|
||||
let path = self.track_path(track_id);
|
||||
let pending = path.with_extension(format!("audio.{}.tmp", std::process::id()));
|
||||
|
||||
// Write file
|
||||
match fs::File::create(&path) {
|
||||
match fs::File::create(&pending) {
|
||||
Ok(mut file) => {
|
||||
if file.write_all(data).is_ok() {
|
||||
drop(file);
|
||||
if let Err(error) = fs::rename(&pending, &path) {
|
||||
log::warn!(
|
||||
"Failed to publish playback cache file for track {track_id}: {error}"
|
||||
);
|
||||
let _ = fs::remove_file(&pending);
|
||||
return false;
|
||||
}
|
||||
let mut state = self.state.lock().unwrap();
|
||||
|
||||
// Remove old entry if exists
|
||||
@@ -229,9 +259,10 @@ impl PlaybackCache {
|
||||
state.current_size / (1024 * 1024),
|
||||
self.max_size_bytes / (1024 * 1024)
|
||||
);
|
||||
return true;
|
||||
} else {
|
||||
log::warn!("Failed to write playback cache file for track {}", track_id);
|
||||
let _ = fs::remove_file(&path);
|
||||
let _ = fs::remove_file(&pending);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
@@ -242,6 +273,7 @@ impl PlaybackCache {
|
||||
);
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Insert a track whose bytes are streamed in by `fill` rather than
|
||||
@@ -253,70 +285,54 @@ impl PlaybackCache {
|
||||
/// `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)
|
||||
pub fn insert_from<F>(&self, track_id: u64, size_hint: u64, fill: F) -> bool
|
||||
where
|
||||
F: FnOnce(&mut fs::File) -> std::io::Result<usize>,
|
||||
{
|
||||
// Don't cache if larger than max size
|
||||
let _write = self.writes.lock().unwrap();
|
||||
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;
|
||||
return false;
|
||||
}
|
||||
|
||||
// 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)
|
||||
);
|
||||
// Build a separate inode. Replacing a cached id must not truncate the
|
||||
// open file of an active decoder, and failed writes must not publish.
|
||||
let pending = self
|
||||
.cache_dir
|
||||
.join(format!("{track_id}.stream.{}.tmp", std::process::id()));
|
||||
let result = (|| -> std::io::Result<u64> {
|
||||
let mut file = fs::File::create(&pending)?;
|
||||
let written = fill(&mut file)? as u64;
|
||||
if written > self.max_size_bytes || file.metadata()?.len() != written {
|
||||
return Err(std::io::Error::other(
|
||||
"invalid playback cache streamed size",
|
||||
));
|
||||
}
|
||||
drop(file);
|
||||
self.evict_if_needed(written);
|
||||
fs::rename(&pending, self.track_path(track_id))?;
|
||||
Ok(written)
|
||||
})();
|
||||
match result {
|
||||
Ok(size) => {
|
||||
let mut state = self.state.lock().unwrap();
|
||||
if let Some(old) = state.entries.remove(&track_id) {
|
||||
state.current_size = state.current_size.saturating_sub(old.size_bytes);
|
||||
}
|
||||
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 {}: {}",
|
||||
state.entries.insert(
|
||||
track_id,
|
||||
e
|
||||
CacheEntry {
|
||||
track_id,
|
||||
size_bytes: size,
|
||||
last_accessed: SystemTime::now(),
|
||||
},
|
||||
);
|
||||
state.current_size += size;
|
||||
log::info!("Saved track {track_id} to playback disk cache ({size} bytes)");
|
||||
true
|
||||
}
|
||||
Err(error) => {
|
||||
let _ = fs::remove_file(&pending);
|
||||
log::warn!("Failed to stream playback cache track {track_id}: {error}");
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -357,6 +373,7 @@ impl PlaybackCache {
|
||||
|
||||
/// Clear the entire cache
|
||||
pub fn clear(&self) {
|
||||
let _write = self.writes.lock().unwrap();
|
||||
let mut state = self.state.lock().unwrap();
|
||||
|
||||
for track_id in state.entries.keys() {
|
||||
@@ -393,3 +410,50 @@ pub struct PlaybackCacheStats {
|
||||
pub current_size_bytes: u64,
|
||||
pub max_size_bytes: u64,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod disk_reader_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn open_reader_survives_replacement_and_eviction() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let cache = PlaybackCache::with_path(temp.path().into(), 1024).unwrap();
|
||||
assert!(cache.insert(1, b"original bytes"));
|
||||
let mut original = cache.open(1).unwrap();
|
||||
assert!(cache.insert_from(1, 11, |file| {
|
||||
file.write_all(b"replacement")?;
|
||||
Ok(11)
|
||||
}));
|
||||
assert_eq!(cache.get(1).unwrap(), b"replacement");
|
||||
cache.clear();
|
||||
let mut bytes = Vec::new();
|
||||
original.read_to_end(&mut bytes).unwrap();
|
||||
assert_eq!(bytes, b"original bytes");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_stream_write_does_not_publish_or_damage_existing_track() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let cache = PlaybackCache::with_path(temp.path().into(), 1024).unwrap();
|
||||
assert!(cache.insert(1, b"original"));
|
||||
assert!(!cache.insert_from(1, 10, |file| {
|
||||
file.write_all(b"partial")?;
|
||||
Err(std::io::Error::other("fixture disk failure"))
|
||||
}));
|
||||
assert_eq!(cache.get(1).unwrap(), b"original");
|
||||
assert_eq!(fs::read_dir(temp.path()).unwrap().count(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn understated_stream_size_cannot_exceed_disk_budget() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let cache = PlaybackCache::with_path(temp.path().into(), 8).unwrap();
|
||||
assert!(!cache.insert_from(1, 1, |file| {
|
||||
file.write_all(&[0; 9])?;
|
||||
Ok(9)
|
||||
}));
|
||||
assert!(cache.open(1).is_none());
|
||||
assert_eq!(fs::read_dir(temp.path()).unwrap().count(), 0);
|
||||
}
|
||||
}
|
||||
@@ -7249,3 +7249,241 @@ msgstr "System / manuell"
|
||||
#: qbz-qt/qml/shell/UpdatesModal.qml
|
||||
msgid "Update ready. Close QBZ to finish installation. It will reopen automatically."
|
||||
msgstr "Update bereit. Schließe QBZ, um die Installation abzuschließen. QBZ wird automatisch wieder geöffnet."
|
||||
|
||||
#. Playback memory cache policy (#774).
|
||||
msgid "PLAYBACK CACHE"
|
||||
msgstr "WIEDERGABECACHE"
|
||||
|
||||
#. Playback memory cache policy (#774).
|
||||
msgid "Grow cache when memory is available"
|
||||
msgstr "Cache bei verfügbarem Speicher vergrößern"
|
||||
|
||||
#. Playback memory cache policy (#774).
|
||||
msgid "Optional; recommended for long Hi-Res tracks. The fixed budget remains the recommended default."
|
||||
msgstr "Optional; für lange Hi-Res-Titel empfohlen. Das feste Budget bleibt die empfohlene Standardeinstellung."
|
||||
|
||||
#. Playback memory cache policy (#774).
|
||||
msgid "Minimum cache budget (MiB)"
|
||||
msgstr "Minimales Cache-Budget (MiB)"
|
||||
|
||||
#. Playback memory cache policy (#774).
|
||||
msgid "Base budget, not reserved RAM. Memory pressure can reduce it. Leave blank for the recommended value."
|
||||
msgstr "Basisbudget ohne RAM-Reservierung. Bei Speicherdruck kann es sinken. Leer lassen für den empfohlenen Wert."
|
||||
|
||||
#. Playback memory cache policy (#774).
|
||||
msgid "Maximum cache budget (MiB)"
|
||||
msgstr "Maximales Cache-Budget (MiB)"
|
||||
|
||||
#. Playback memory cache policy (#774).
|
||||
msgid "Growth ceiling; available memory may impose a lower limit. Leave blank for automatic."
|
||||
msgstr "Wachstumsgrenze; verfügbarer Speicher kann sie weiter begrenzen. Leer lassen für Automatik."
|
||||
|
||||
#. Playback memory cache policy (#774).
|
||||
msgid "Memory cache usage"
|
||||
msgstr "Speichercache-Nutzung"
|
||||
|
||||
#. Playback memory cache policy (#774).
|
||||
msgid "Used / current budget: %1 / %2 MiB. This excludes decoder memory and offline downloads."
|
||||
msgstr "Belegt / aktuelles Budget: %1 / %2 MiB. Ohne Decoder-Speicher und Offline-Downloads."
|
||||
|
||||
#. Playback memory cache policy (#774).
|
||||
msgid "Recommended cache defaults"
|
||||
msgstr "Empfohlene Cache-Standardwerte"
|
||||
|
||||
#. Playback memory cache policy (#774).
|
||||
msgid "Restore defaults"
|
||||
msgstr "Standardwerte wiederherstellen"
|
||||
|
||||
#. Playback memory cache policy (#774).
|
||||
msgid "Playback cache limits must be between 16 and 16384 MiB"
|
||||
msgstr "Die Wiedergabecache-Limits müssen zwischen 16 und 16384 MiB liegen"
|
||||
|
||||
#. Playback memory cache policy (#774).
|
||||
msgid "Minimum playback cache budget must not exceed its maximum"
|
||||
msgstr "Das minimale Wiedergabecache-Budget darf das Maximum nicht überschreiten"
|
||||
|
||||
#: qbz-qt/qml/settings/PlaybackSettings.qml
|
||||
msgid "Playback memory profile"
|
||||
msgstr "Speicherprofil für die Wiedergabe"
|
||||
|
||||
#: qbz-qt/qml/settings/PlaybackSettings.qml
|
||||
msgid "Auto — recommended"
|
||||
msgstr "Auto — empfohlen"
|
||||
|
||||
#: qbz-qt/qml/settings/PlaybackSettings.qml
|
||||
msgid "High — dynamic"
|
||||
msgstr "High — dynamisch"
|
||||
|
||||
#: qbz-qt/qml/settings/PlaybackSettings.qml
|
||||
msgid "Desktop"
|
||||
msgstr "Desktop"
|
||||
|
||||
#: qbz-qt/qml/settings/PlaybackSettings.qml
|
||||
msgid "Low — Pi style"
|
||||
msgstr "Low — Pi-Stil"
|
||||
|
||||
#: qbz-qt/qml/settings/PlaybackSettings.qml
|
||||
msgid "Auto selects Desktop or Low from the computer's RAM. Fixed cache budget; dynamic growth is off. Recommended default."
|
||||
msgstr "Auto wählt Desktop oder Low anhand des Arbeitsspeichers. Festes Cache-Budget ohne dynamisches Wachstum. Empfohlene Standardeinstellung."
|
||||
|
||||
#: qbz-qt/qml/settings/PlaybackSettings.qml
|
||||
msgid "High starts with a 400 MiB cache budget and grows up to 1600 MiB when memory is available. It shrinks after inactivity or memory pressure. For long Hi-Res tracks and computers with spare RAM."
|
||||
msgstr "High beginnt mit einem Cache-Budget von 400 MiB und wächst bei verfügbarem Speicher auf bis zu 1600 MiB. Bei Inaktivität oder Speicherdruck wird es reduziert. Für lange Hi-Res-Titel und Rechner mit freiem RAM."
|
||||
|
||||
#: qbz-qt/qml/settings/PlaybackSettings.qml
|
||||
msgid "Desktop uses a fixed 400 MiB cache budget and normal prefetch. Larger tracks use disk-backed buffers."
|
||||
msgstr "Desktop verwendet ein festes Cache-Budget von 400 MiB und normales Vorladen. Größere Titel verwenden dateigestützte Puffer."
|
||||
|
||||
#: qbz-qt/qml/settings/PlaybackSettings.qml
|
||||
msgid "Low uses a 50 MiB cache budget, smaller startup buffers and reduced prefetch. Large tracks use disk; speculative Hi-Res prefetch is skipped. Useful on Raspberry Pi or when saving RAM."
|
||||
msgstr "Low verwendet ein Cache-Budget von 50 MiB, kleinere Startpuffer und weniger Vorladen. Große Titel nutzen die Festplatte; vorausschauendes Hi-Res-Vorladen entfällt. Für Raspberry Pi oder zum Sparen von RAM."
|
||||
|
||||
#: qbz-qt/qml/settings/PlaybackSettings.qml
|
||||
msgid "Custom uses your cache limits and growth preference below. Prefetch follows the computer's detected memory class. Editing a preset switches to Custom."
|
||||
msgstr "Benutzerdefiniert verwendet die unten eingestellten Cache-Grenzen und Wachstumsoptionen. Das Vorladen folgt der erkannten Speicherklasse. Änderungen an einer Vorgabe wechseln zu Benutzerdefiniert."
|
||||
|
||||
#: qbz-qt/qml/settings/PlaybackSettings.qml
|
||||
msgid "Same audio quality in every profile"
|
||||
msgstr "Gleiche Audioqualität in jedem Profil"
|
||||
|
||||
#: qbz-qt/qml/settings/PlaybackSettings.qml
|
||||
msgid "Profiles control playback memory, not audio quality or the whole app's RAM. Disk cache stays enabled unless Streaming only is on. Changes apply to new buffers; the current track continues."
|
||||
msgstr "Profile steuern den Wiedergabespeicher, nicht die Audioqualität oder den gesamten RAM der App. Der Festplatten-Cache bleibt außer bei Nur Streaming aktiv. Änderungen gelten für neue Puffer; der aktuelle Titel läuft weiter."
|
||||
|
||||
#: qbz-qt/qml/settings/PlaybackSettings.qml
|
||||
msgid "Allow the cache to grow within your maximum when RAM is available. High enables this automatically. Editing this option switches to Custom."
|
||||
msgstr "Der Cache darf bei freiem RAM bis zum festgelegten Maximum wachsen. High aktiviert dies automatisch. Änderungen wechseln zu Benutzerdefiniert."
|
||||
|
||||
#: qbz-qt/qml/settings/PlaybackSettings.qml
|
||||
msgid "Unknown playback memory profile"
|
||||
msgstr "Unbekanntes Wiedergabespeicherprofil"
|
||||
|
||||
#: qbz-qt/qml/settings/PlaybackSettings.qml
|
||||
msgid "Playback memory preset conflicts with custom cache limits"
|
||||
msgstr "Das Wiedergabespeicherprofil widerspricht den benutzerdefinierten Cache-Grenzen"
|
||||
|
||||
msgid "Apply playback memory profile?"
|
||||
msgstr "Wiedergabe-Speicherprofil anwenden?"
|
||||
|
||||
msgid "Playback buffers will be released. If playing, the current track will stop and restart from the beginning. If paused, it will stay stopped at 0:00. Your queue and disk cache will be kept."
|
||||
msgstr "Die Wiedergabepuffer werden freigegeben. Bei laufender Wiedergabe wird der aktuelle Titel gestoppt und von Anfang an neu gestartet. Bei pausierter Wiedergabe bleibt er bei 0:00 gestoppt. Warteschlange und Festplatten-Cache bleiben erhalten."
|
||||
|
||||
msgid "Apply & free memory"
|
||||
msgstr "Anwenden & Speicher freigeben"
|
||||
|
||||
msgid "Rebuild playback buffers"
|
||||
msgstr "Wiedergabepuffer neu aufbauen"
|
||||
|
||||
msgid "The profile already applies to new buffers. Apply now to release existing playback buffers and the memory cache. Memory use can grow again as playback continues."
|
||||
msgstr "Das Profil gilt bereits für neue Puffer. Jetzt anwenden, um bestehende Wiedergabepuffer und den Speicher-Cache freizugeben. Während der weiteren Wiedergabe kann die Speichernutzung wieder steigen."
|
||||
|
||||
msgid "Playback memory can only be applied to local playback."
|
||||
msgstr "Das Speicherprofil kann nur auf die Wiedergabe auf diesem Gerät angewendet werden."
|
||||
|
||||
msgid "Playback is not ready yet."
|
||||
msgstr "Die Wiedergabe ist noch nicht bereit."
|
||||
|
||||
msgid "Playback is busy. Try applying the memory profile again."
|
||||
msgstr "Die Wiedergabe ist beschäftigt. Versuche erneut, das Speicherprofil anzuwenden."
|
||||
|
||||
msgid "Playback changed. Apply again to confirm the current track."
|
||||
msgstr "Die Wiedergabe hat sich geändert. Erneut anwenden, um den aktuellen Titel zu bestätigen."
|
||||
|
||||
msgid "Visual resource profile"
|
||||
msgstr "Profil für Grafikressourcen"
|
||||
|
||||
msgid "Low uses the Small player, compact headers, no dynamic background, header gradient, spectrum, animated play indicator, sidebar collages or optional track artwork. Default restores these visual defaults. Editing a managed option selects Custom. Audio quality and playback memory are independent."
|
||||
msgstr "Low nutzt den kleinen Player und kompakte Kopfbereiche; dynamischer Hintergrund, Kopfverlauf, Spektrum, animierte Wiedergabeanzeige, Collagen und optionale Titelbilder sind aus. Default stellt diese Standardwerte wieder her. Änderungen wählen Custom. Audioqualität und Wiedergabespeicher sind unabhängig."
|
||||
|
||||
msgid "Compare memory profiles"
|
||||
msgstr "Speicherprofile vergleichen"
|
||||
|
||||
msgid "Auto: fixed 400 MiB or 50 MiB according to the computer's RAM; disk handles larger tracks."
|
||||
msgstr "Auto: feste 400 MiB oder 50 MiB je nach RAM; größere Titel nutzen die Festplatte."
|
||||
|
||||
msgid "High: dynamic 400–1600 MiB; more room in RAM for large tracks, with less need for disk-backed buffers."
|
||||
msgstr "High: dynamische 400–1600 MiB; mehr RAM für große Titel, weniger Bedarf an dateibasierten Puffern."
|
||||
|
||||
msgid "Desktop: fixed 400 MiB; balances RAM use with disk-backed playback for larger tracks."
|
||||
msgstr "Desktop: feste 400 MiB; gleicht RAM-Nutzung und dateibasierte Wiedergabe größerer Titel aus."
|
||||
|
||||
msgid "Low: fixed 50 MiB and reduced prefetch; lower RAM use, with more disk reads and writes for large tracks."
|
||||
msgstr "Low: feste 50 MiB und weniger Vorladen; weniger RAM, dafür mehr Festplattenzugriffe bei großen Titeln."
|
||||
|
||||
msgid "Custom: your RAM limits and growth preference determine when playback needs disk storage."
|
||||
msgstr "Custom: deine RAM-Grenzen und Wachstumseinstellung bestimmen, wann Festplattenspeicher benötigt wird."
|
||||
|
||||
msgid "RAM and storage trade-off"
|
||||
msgstr "RAM und Speicher abwägen"
|
||||
|
||||
msgid "All profiles preserve audio quality and can use disk cache. Lower memory budgets rely more on storage speed and writes; more RAM can reduce that work. Cached disk tracks are read from disk even in High. Choose storage appropriate for your listening workload."
|
||||
msgstr "Alle Profile behalten die Audioqualität und können den Festplatten-Cache nutzen. Weniger RAM bedeutet mehr Abhängigkeit von Speichergeschwindigkeit und Schreibzugriffen; mehr RAM kann diese verringern. Bereits auf Festplatte gespeicherte Titel werden auch in High von dort gelesen. Wähle einen passenden Datenträger."
|
||||
|
||||
msgid "Playback storage folder"
|
||||
msgstr "Ordner für Wiedergabespeicher"
|
||||
|
||||
msgid "Choose an existing folder on the drive you want to use. QBZ stores its playback cache and temporary track buffers in a qbz-playback subfolder. Offline downloads are separate. Leave blank for the default location."
|
||||
msgstr "Wähle einen vorhandenen Ordner auf dem gewünschten Laufwerk. QBZ speichert Wiedergabe-Cache und temporäre Titelpuffer im Unterordner qbz-playback. Offline-Downloads sind getrennt. Leer lassen für den Standardpfad."
|
||||
|
||||
msgid "Current playback storage"
|
||||
msgstr "Aktueller Wiedergabespeicher"
|
||||
|
||||
msgid "Changing the folder takes effect after restarting QBZ. Existing cache files stay in the old location; they are not moved or deleted automatically."
|
||||
msgstr "Der neue Ordner wird nach einem Neustart von QBZ verwendet. Vorhandene Cache-Dateien bleiben am alten Ort; sie werden nicht automatisch verschoben oder gelöscht."
|
||||
|
||||
msgid "Could not use this playback storage folder:"
|
||||
msgstr "Dieser Wiedergabeordner konnte nicht verwendet werden:"
|
||||
|
||||
msgid "Folder access"
|
||||
msgstr "Ordnerzugriff"
|
||||
|
||||
msgid "If the sandbox blocks this folder, run the command below in a host terminal, restart QBZ, then select the folder again. Only grant access to a folder you intend to use."
|
||||
msgstr "Falls die Sandbox den Ordner blockiert, führe den folgenden Befehl in einem Terminal des Hostsystems aus, starte QBZ neu und wähle den Ordner erneut. Erlaube nur Zugriff auf den gewünschten Ordner."
|
||||
|
||||
msgid "Snap can access supported home folders and removable storage under /mnt, /media or /run/media. For removable storage, connect the interface below and retry. Snap cannot grant arbitrary folder access with a Flatpak-style override."
|
||||
msgstr "Snap kann auf unterstützte persönliche Ordner und Wechselmedien unter /mnt, /media oder /run/media zugreifen. Verbinde dafür die unten angegebene Schnittstelle und versuche es erneut. Beliebige Ordner lassen sich nicht wie bei Flatpak per Override freigeben."
|
||||
|
||||
msgid "Choose an absolute folder path for playback storage."
|
||||
msgstr "Wähle einen absoluten Ordnerpfad für den Wiedergabespeicher."
|
||||
|
||||
msgid "The folder is accessible and visible in the library."
|
||||
msgstr "Der Ordner ist zugänglich und in der Bibliothek sichtbar."
|
||||
|
||||
msgid "The folder is accessible but hidden by your preference."
|
||||
msgstr "Der Ordner ist zugänglich, wurde aber von dir ausgeblendet."
|
||||
|
||||
msgid "The folder path no longer exists. Reconnect it, change its path or remove it from the library."
|
||||
msgstr "Der Ordnerpfad existiert nicht mehr. Verbinde ihn erneut, ändere den Pfad oder entferne ihn aus der Bibliothek."
|
||||
|
||||
msgid "A connection failure prevents access to this storage."
|
||||
msgstr "Ein Verbindungsfehler verhindert den Zugriff auf diesen Speicher."
|
||||
|
||||
msgid "QBZ does not have permission to access this folder."
|
||||
msgstr "QBZ hat keine Berechtigung, auf diesen Ordner zuzugreifen."
|
||||
|
||||
msgid "The folder could not be accessed. The check timed out or returned another access error."
|
||||
msgstr "Auf den Ordner konnte nicht zugegriffen werden. Die Prüfung dauerte zu lange oder meldete einen anderen Zugriffsfehler."
|
||||
|
||||
msgid "Folder access is being checked or has not been checked yet."
|
||||
msgstr "Der Ordnerzugriff wird geprüft oder wurde noch nicht geprüft."
|
||||
|
||||
msgid "Local folder"
|
||||
msgstr "Lokaler Ordner"
|
||||
|
||||
msgid "Access denied"
|
||||
msgstr "Zugriff verweigert"
|
||||
|
||||
msgid "Missing"
|
||||
msgstr "Fehlend"
|
||||
|
||||
msgid "Actions"
|
||||
msgstr "Aktionen"
|
||||
|
||||
msgid "Low"
|
||||
msgstr "Niedrig"
|
||||
|
||||
msgid "These options do not apply while Streaming only is enabled. Turn it off to choose a playback memory profile and apply changes."
|
||||
msgstr "Diese Optionen gelten nicht, solange Nur Streaming aktiviert ist. Deaktiviere es, um ein Wiedergabespeicherprofil auszuwählen und Änderungen anzuwenden."
|
||||
|
||||
msgid "Mimics official clients’ streaming behavior with lower memory usage. Skips the playback cache and its protection against network interruptions: QBZ’s cache keeps upcoming audio ready for more reliable playback and instant replay."
|
||||
msgstr "Entspricht dem Streaming-Verhalten der offiziellen Clients bei geringerem Speicherbedarf. Umgeht den Wiedergabe-Cache und seinen Schutz vor Netzwerkunterbrechungen: Der QBZ-Cache hält kommende Audiodaten für zuverlässigere Wiedergabe und sofortiges erneutes Abspielen bereit."
|
||||
@@ -2849,3 +2849,267 @@ msgstr "System / manual"
|
||||
#: qbz-qt/qml/shell/UpdatesModal.qml
|
||||
msgid "Update ready. Close QBZ to finish installation. It will reopen automatically."
|
||||
msgstr "Update ready. Close QBZ to finish installation. It will reopen automatically."
|
||||
|
||||
#. Playback memory cache policy (#774).
|
||||
msgid "PLAYBACK CACHE"
|
||||
msgstr "PLAYBACK CACHE"
|
||||
|
||||
#. Playback memory cache policy (#774).
|
||||
msgid "Grow cache when memory is available"
|
||||
msgstr "Grow cache when memory is available"
|
||||
|
||||
#. Playback memory cache policy (#774).
|
||||
msgid "Optional; recommended for long Hi-Res tracks. The fixed budget remains the recommended default."
|
||||
msgstr "Optional; recommended for long Hi-Res tracks. The fixed budget remains the recommended default."
|
||||
|
||||
#. Playback memory cache policy (#774).
|
||||
msgid "Minimum cache budget (MiB)"
|
||||
msgstr "Minimum cache budget (MiB)"
|
||||
|
||||
#. Playback memory cache policy (#774).
|
||||
msgid "Base budget, not reserved RAM. Memory pressure can reduce it. Leave blank for the recommended value."
|
||||
msgstr "Base budget, not reserved RAM. Memory pressure can reduce it. Leave blank for the recommended value."
|
||||
|
||||
#. Playback memory cache policy (#774).
|
||||
msgid "Maximum cache budget (MiB)"
|
||||
msgstr "Maximum cache budget (MiB)"
|
||||
|
||||
#. Playback memory cache policy (#774).
|
||||
msgid "Growth ceiling; available memory may impose a lower limit. Leave blank for automatic."
|
||||
msgstr "Growth ceiling; available memory may impose a lower limit. Leave blank for automatic."
|
||||
|
||||
#. Playback memory cache policy (#774).
|
||||
msgid "Memory cache usage"
|
||||
msgstr "Memory cache usage"
|
||||
|
||||
#. Playback memory cache policy (#774).
|
||||
msgid "Used / current budget: %1 / %2 MiB. This excludes decoder memory and offline downloads."
|
||||
msgstr "Used / current budget: %1 / %2 MiB. This excludes decoder memory and offline downloads."
|
||||
|
||||
#. Playback memory cache policy (#774).
|
||||
msgid "Recommended cache defaults"
|
||||
msgstr "Recommended cache defaults"
|
||||
|
||||
#. Playback memory cache policy (#774).
|
||||
msgid "Restore defaults"
|
||||
msgstr "Restore defaults"
|
||||
|
||||
#. Playback memory cache policy (#774).
|
||||
msgid "Refresh"
|
||||
msgstr "Refresh"
|
||||
|
||||
#. Playback memory cache policy (#774).
|
||||
msgid "Playback cache limits must be between 16 and 16384 MiB"
|
||||
msgstr "Playback cache limits must be between 16 and 16384 MiB"
|
||||
|
||||
#. Playback memory cache policy (#774).
|
||||
msgid "Minimum playback cache budget must not exceed its maximum"
|
||||
msgstr "Minimum playback cache budget must not exceed its maximum"
|
||||
|
||||
#: qbz-qt/qml/settings/PlaybackSettings.qml
|
||||
msgid "Playback memory profile"
|
||||
msgstr "Playback memory profile"
|
||||
|
||||
#: qbz-qt/qml/settings/PlaybackSettings.qml
|
||||
msgid "Auto — recommended"
|
||||
msgstr "Auto — recommended"
|
||||
|
||||
#: qbz-qt/qml/settings/PlaybackSettings.qml
|
||||
msgid "High — dynamic"
|
||||
msgstr "High — dynamic"
|
||||
|
||||
#: qbz-qt/qml/settings/PlaybackSettings.qml
|
||||
msgid "Desktop"
|
||||
msgstr "Desktop"
|
||||
|
||||
#: qbz-qt/qml/settings/PlaybackSettings.qml
|
||||
msgid "Low — Pi style"
|
||||
msgstr "Low — Pi style"
|
||||
|
||||
#: qbz-qt/qml/settings/PlaybackSettings.qml
|
||||
msgid "Custom"
|
||||
msgstr "Custom"
|
||||
|
||||
#: qbz-qt/qml/settings/PlaybackSettings.qml
|
||||
msgid "Auto selects Desktop or Low from the computer's RAM. Fixed cache budget; dynamic growth is off. Recommended default."
|
||||
msgstr "Auto selects Desktop or Low from the computer's RAM. Fixed cache budget; dynamic growth is off. Recommended default."
|
||||
|
||||
#: qbz-qt/qml/settings/PlaybackSettings.qml
|
||||
msgid "High starts with a 400 MiB cache budget and grows up to 1600 MiB when memory is available. It shrinks after inactivity or memory pressure. For long Hi-Res tracks and computers with spare RAM."
|
||||
msgstr "High starts with a 400 MiB cache budget and grows up to 1600 MiB when memory is available. It shrinks after inactivity or memory pressure. For long Hi-Res tracks and computers with spare RAM."
|
||||
|
||||
#: qbz-qt/qml/settings/PlaybackSettings.qml
|
||||
msgid "Desktop uses a fixed 400 MiB cache budget and normal prefetch. Larger tracks use disk-backed buffers."
|
||||
msgstr "Desktop uses a fixed 400 MiB cache budget and normal prefetch. Larger tracks use disk-backed buffers."
|
||||
|
||||
#: qbz-qt/qml/settings/PlaybackSettings.qml
|
||||
msgid "Low uses a 50 MiB cache budget, smaller startup buffers and reduced prefetch. Large tracks use disk; speculative Hi-Res prefetch is skipped. Useful on Raspberry Pi or when saving RAM."
|
||||
msgstr "Low uses a 50 MiB cache budget, smaller startup buffers and reduced prefetch. Large tracks use disk; speculative Hi-Res prefetch is skipped. Useful on Raspberry Pi or when saving RAM."
|
||||
|
||||
#: qbz-qt/qml/settings/PlaybackSettings.qml
|
||||
msgid "Custom uses your cache limits and growth preference below. Prefetch follows the computer's detected memory class. Editing a preset switches to Custom."
|
||||
msgstr "Custom uses your cache limits and growth preference below. Prefetch follows the computer's detected memory class. Editing a preset switches to Custom."
|
||||
|
||||
#: qbz-qt/qml/settings/PlaybackSettings.qml
|
||||
msgid "Same audio quality in every profile"
|
||||
msgstr "Same audio quality in every profile"
|
||||
|
||||
#: qbz-qt/qml/settings/PlaybackSettings.qml
|
||||
msgid "Profiles control playback memory, not audio quality or the whole app's RAM. Disk cache stays enabled unless Streaming only is on. Changes apply to new buffers; the current track continues."
|
||||
msgstr "Profiles control playback memory, not audio quality or the whole app's RAM. Disk cache stays enabled unless Streaming only is on. Changes apply to new buffers; the current track continues."
|
||||
|
||||
#: qbz-qt/qml/settings/PlaybackSettings.qml
|
||||
msgid "Allow the cache to grow within your maximum when RAM is available. High enables this automatically. Editing this option switches to Custom."
|
||||
msgstr "Allow the cache to grow within your maximum when RAM is available. High enables this automatically. Editing this option switches to Custom."
|
||||
|
||||
#: qbz-qt/qml/settings/PlaybackSettings.qml
|
||||
msgid "Unknown playback memory profile"
|
||||
msgstr "Unknown playback memory profile"
|
||||
|
||||
#: qbz-qt/qml/settings/PlaybackSettings.qml
|
||||
msgid "Playback memory preset conflicts with custom cache limits"
|
||||
msgstr "Playback memory preset conflicts with custom cache limits"
|
||||
|
||||
msgid "Apply playback memory profile?"
|
||||
msgstr "Apply playback memory profile?"
|
||||
|
||||
msgid "Playback buffers will be released. If playing, the current track will stop and restart from the beginning. If paused, it will stay stopped at 0:00. Your queue and disk cache will be kept."
|
||||
msgstr "Playback buffers will be released. If playing, the current track will stop and restart from the beginning. If paused, it will stay stopped at 0:00. Your queue and disk cache will be kept."
|
||||
|
||||
msgid "Apply & free memory"
|
||||
msgstr "Apply & free memory"
|
||||
|
||||
msgid "Rebuild playback buffers"
|
||||
msgstr "Rebuild playback buffers"
|
||||
|
||||
msgid "The profile already applies to new buffers. Apply now to release existing playback buffers and the memory cache. Memory use can grow again as playback continues."
|
||||
msgstr "The profile already applies to new buffers. Apply now to release existing playback buffers and the memory cache. Memory use can grow again as playback continues."
|
||||
|
||||
msgid "Playback memory can only be applied to local playback."
|
||||
msgstr "Playback memory can only be applied to local playback."
|
||||
|
||||
msgid "Playback is not ready yet."
|
||||
msgstr "Playback is not ready yet."
|
||||
|
||||
msgid "Playback is busy. Try applying the memory profile again."
|
||||
msgstr "Playback is busy. Try applying the memory profile again."
|
||||
|
||||
msgid "Playback changed. Apply again to confirm the current track."
|
||||
msgstr "Playback changed. Apply again to confirm the current track."
|
||||
|
||||
msgid "Visual resource profile"
|
||||
msgstr "Visual resource profile"
|
||||
|
||||
msgid "Low uses the Small player, compact headers, no dynamic background, header gradient, spectrum, animated play indicator, sidebar collages or optional track artwork. Default restores these visual defaults. Editing a managed option selects Custom. Audio quality and playback memory are independent."
|
||||
msgstr "Low uses the Small player, compact headers, no dynamic background, header gradient, spectrum, animated play indicator, sidebar collages or optional track artwork. Default restores these visual defaults. Editing a managed option selects Custom. Audio quality and playback memory are independent."
|
||||
|
||||
msgid "Compare memory profiles"
|
||||
msgstr "Compare memory profiles"
|
||||
|
||||
msgid "Auto: fixed 400 MiB or 50 MiB according to the computer's RAM; disk handles larger tracks."
|
||||
msgstr "Auto: fixed 400 MiB or 50 MiB according to the computer's RAM; disk handles larger tracks."
|
||||
|
||||
msgid "High: dynamic 400–1600 MiB; more room in RAM for large tracks, with less need for disk-backed buffers."
|
||||
msgstr "High: dynamic 400–1600 MiB; more room in RAM for large tracks, with less need for disk-backed buffers."
|
||||
|
||||
msgid "Desktop: fixed 400 MiB; balances RAM use with disk-backed playback for larger tracks."
|
||||
msgstr "Desktop: fixed 400 MiB; balances RAM use with disk-backed playback for larger tracks."
|
||||
|
||||
msgid "Low: fixed 50 MiB and reduced prefetch; lower RAM use, with more disk reads and writes for large tracks."
|
||||
msgstr "Low: fixed 50 MiB and reduced prefetch; lower RAM use, with more disk reads and writes for large tracks."
|
||||
|
||||
msgid "Custom: your RAM limits and growth preference determine when playback needs disk storage."
|
||||
msgstr "Custom: your RAM limits and growth preference determine when playback needs disk storage."
|
||||
|
||||
msgid "RAM and storage trade-off"
|
||||
msgstr "RAM and storage trade-off"
|
||||
|
||||
msgid "All profiles preserve audio quality and can use disk cache. Lower memory budgets rely more on storage speed and writes; more RAM can reduce that work. Cached disk tracks are read from disk even in High. Choose storage appropriate for your listening workload."
|
||||
msgstr "All profiles preserve audio quality and can use disk cache. Lower memory budgets rely more on storage speed and writes; more RAM can reduce that work. Cached disk tracks are read from disk even in High. Choose storage appropriate for your listening workload."
|
||||
|
||||
msgid "Playback storage folder"
|
||||
msgstr "Playback storage folder"
|
||||
|
||||
msgid "Choose an existing folder on the drive you want to use. QBZ stores its playback cache and temporary track buffers in a qbz-playback subfolder. Offline downloads are separate. Leave blank for the default location."
|
||||
msgstr "Choose an existing folder on the drive you want to use. QBZ stores its playback cache and temporary track buffers in a qbz-playback subfolder. Offline downloads are separate. Leave blank for the default location."
|
||||
|
||||
msgid "Current playback storage"
|
||||
msgstr "Current playback storage"
|
||||
|
||||
msgid "Changing the folder takes effect after restarting QBZ. Existing cache files stay in the old location; they are not moved or deleted automatically."
|
||||
msgstr "Changing the folder takes effect after restarting QBZ. Existing cache files stay in the old location; they are not moved or deleted automatically."
|
||||
|
||||
msgid "Could not use this playback storage folder:"
|
||||
msgstr "Could not use this playback storage folder:"
|
||||
|
||||
msgid "Folder access"
|
||||
msgstr "Folder access"
|
||||
|
||||
msgid "If the sandbox blocks this folder, run the command below in a host terminal, restart QBZ, then select the folder again. Only grant access to a folder you intend to use."
|
||||
msgstr "If the sandbox blocks this folder, run the command below in a host terminal, restart QBZ, then select the folder again. Only grant access to a folder you intend to use."
|
||||
|
||||
msgid "Snap can access supported home folders and removable storage under /mnt, /media or /run/media. For removable storage, connect the interface below and retry. Snap cannot grant arbitrary folder access with a Flatpak-style override."
|
||||
msgstr "Snap can access supported home folders and removable storage under /mnt, /media or /run/media. For removable storage, connect the interface below and retry. Snap cannot grant arbitrary folder access with a Flatpak-style override."
|
||||
|
||||
msgid "Choose an absolute folder path for playback storage."
|
||||
msgstr "Choose an absolute folder path for playback storage."
|
||||
|
||||
msgid "The folder is accessible and visible in the library."
|
||||
msgstr "The folder is accessible and visible in the library."
|
||||
|
||||
msgid "The folder is accessible but hidden by your preference."
|
||||
msgstr "The folder is accessible but hidden by your preference."
|
||||
|
||||
msgid "The folder path no longer exists. Reconnect it, change its path or remove it from the library."
|
||||
msgstr "The folder path no longer exists. Reconnect it, change its path or remove it from the library."
|
||||
|
||||
msgid "A connection failure prevents access to this storage."
|
||||
msgstr "A connection failure prevents access to this storage."
|
||||
|
||||
msgid "QBZ does not have permission to access this folder."
|
||||
msgstr "QBZ does not have permission to access this folder."
|
||||
|
||||
msgid "The folder could not be accessed. The check timed out or returned another access error."
|
||||
msgstr "The folder could not be accessed. The check timed out or returned another access error."
|
||||
|
||||
msgid "Folder access is being checked or has not been checked yet."
|
||||
msgstr "Folder access is being checked or has not been checked yet."
|
||||
|
||||
msgid "Local folder"
|
||||
msgstr "Local folder"
|
||||
|
||||
msgid "Network folder"
|
||||
msgstr "Network folder"
|
||||
|
||||
msgid "Access denied"
|
||||
msgstr "Access denied"
|
||||
|
||||
msgid "Missing"
|
||||
msgstr "Missing"
|
||||
|
||||
msgid "Disconnected"
|
||||
msgstr "Disconnected"
|
||||
|
||||
msgid "Hidden"
|
||||
msgstr "Hidden"
|
||||
|
||||
msgid "Active"
|
||||
msgstr "Active"
|
||||
|
||||
msgid "Unavailable"
|
||||
msgstr "Unavailable"
|
||||
|
||||
msgid "Checking..."
|
||||
msgstr "Checking..."
|
||||
|
||||
msgid "Actions"
|
||||
msgstr "Actions"
|
||||
|
||||
msgid "Low"
|
||||
msgstr "Low"
|
||||
|
||||
msgid "These options do not apply while Streaming only is enabled. Turn it off to choose a playback memory profile and apply changes."
|
||||
msgstr "These options do not apply while Streaming only is enabled. Turn it off to choose a playback memory profile and apply changes."
|
||||
|
||||
msgid "Mimics official clients’ streaming behavior with lower memory usage. Skips the playback cache and its protection against network interruptions: QBZ’s cache keeps upcoming audio ready for more reliable playback and instant replay."
|
||||
msgstr "Mimics official clients’ streaming behavior with lower memory usage. Skips the playback cache and its protection against network interruptions: QBZ’s cache keeps upcoming audio ready for more reliable playback and instant replay."
|
||||
@@ -7255,3 +7255,241 @@ msgstr "Sistema / manual"
|
||||
#: qbz-qt/qml/shell/UpdatesModal.qml
|
||||
msgid "Update ready. Close QBZ to finish installation. It will reopen automatically."
|
||||
msgstr "Actualización lista. Cierra QBZ para terminar la instalación. Se abrirá de nuevo automáticamente."
|
||||
|
||||
#. Playback memory cache policy (#774).
|
||||
msgid "PLAYBACK CACHE"
|
||||
msgstr "CACHÉ DE REPRODUCCIÓN"
|
||||
|
||||
#. Playback memory cache policy (#774).
|
||||
msgid "Grow cache when memory is available"
|
||||
msgstr "Ampliar la caché cuando haya memoria disponible"
|
||||
|
||||
#. Playback memory cache policy (#774).
|
||||
msgid "Optional; recommended for long Hi-Res tracks. The fixed budget remains the recommended default."
|
||||
msgstr "Opcional; recomendado para pistas largas en Hi-Res. El presupuesto fijo sigue siendo el valor predeterminado recomendado."
|
||||
|
||||
#. Playback memory cache policy (#774).
|
||||
msgid "Minimum cache budget (MiB)"
|
||||
msgstr "Presupuesto mínimo de caché (MiB)"
|
||||
|
||||
#. Playback memory cache policy (#774).
|
||||
msgid "Base budget, not reserved RAM. Memory pressure can reduce it. Leave blank for the recommended value."
|
||||
msgstr "Presupuesto base, sin reservar RAM. Puede reducirse si falta memoria. Deja vacío para usar el valor recomendado."
|
||||
|
||||
#. Playback memory cache policy (#774).
|
||||
msgid "Maximum cache budget (MiB)"
|
||||
msgstr "Presupuesto máximo de caché (MiB)"
|
||||
|
||||
#. Playback memory cache policy (#774).
|
||||
msgid "Growth ceiling; available memory may impose a lower limit. Leave blank for automatic."
|
||||
msgstr "Tope de crecimiento; la memoria disponible puede imponer un límite menor. Deja vacío para usar el valor automático."
|
||||
|
||||
#. Playback memory cache policy (#774).
|
||||
msgid "Memory cache usage"
|
||||
msgstr "Uso de caché en memoria"
|
||||
|
||||
#. Playback memory cache policy (#774).
|
||||
msgid "Used / current budget: %1 / %2 MiB. This excludes decoder memory and offline downloads."
|
||||
msgstr "Uso / presupuesto actual: %1 / %2 MiB. Excluye la memoria del decodificador y las descargas offline."
|
||||
|
||||
#. Playback memory cache policy (#774).
|
||||
msgid "Recommended cache defaults"
|
||||
msgstr "Valores recomendados de caché"
|
||||
|
||||
#. Playback memory cache policy (#774).
|
||||
msgid "Restore defaults"
|
||||
msgstr "Restaurar valores predeterminados"
|
||||
|
||||
#. Playback memory cache policy (#774).
|
||||
msgid "Playback cache limits must be between 16 and 16384 MiB"
|
||||
msgstr "Los límites de caché de reproducción deben estar entre 16 y 16384 MiB"
|
||||
|
||||
#. Playback memory cache policy (#774).
|
||||
msgid "Minimum playback cache budget must not exceed its maximum"
|
||||
msgstr "El presupuesto mínimo de caché de reproducción no puede superar el máximo"
|
||||
|
||||
#: qbz-qt/qml/settings/PlaybackSettings.qml
|
||||
msgid "Playback memory profile"
|
||||
msgstr "Perfil de memoria de reproducción"
|
||||
|
||||
#: qbz-qt/qml/settings/PlaybackSettings.qml
|
||||
msgid "Auto — recommended"
|
||||
msgstr "Auto — recomendado"
|
||||
|
||||
#: qbz-qt/qml/settings/PlaybackSettings.qml
|
||||
msgid "High — dynamic"
|
||||
msgstr "High — dinámico"
|
||||
|
||||
#: qbz-qt/qml/settings/PlaybackSettings.qml
|
||||
msgid "Desktop"
|
||||
msgstr "Desktop"
|
||||
|
||||
#: qbz-qt/qml/settings/PlaybackSettings.qml
|
||||
msgid "Low — Pi style"
|
||||
msgstr "Low — estilo Pi"
|
||||
|
||||
#: qbz-qt/qml/settings/PlaybackSettings.qml
|
||||
msgid "Auto selects Desktop or Low from the computer's RAM. Fixed cache budget; dynamic growth is off. Recommended default."
|
||||
msgstr "Auto selecciona Desktop o Low según la RAM del equipo. El límite de caché es fijo y el crecimiento dinámico está desactivado. Es la opción predeterminada recomendada."
|
||||
|
||||
#: qbz-qt/qml/settings/PlaybackSettings.qml
|
||||
msgid "High starts with a 400 MiB cache budget and grows up to 1600 MiB when memory is available. It shrinks after inactivity or memory pressure. For long Hi-Res tracks and computers with spare RAM."
|
||||
msgstr "High parte de un límite de caché de 400 MiB y puede crecer hasta 1600 MiB cuando hay memoria disponible. Se reduce tras inactividad o presión de memoria. Para pistas Hi-Res largas y equipos con RAM de sobra."
|
||||
|
||||
#: qbz-qt/qml/settings/PlaybackSettings.qml
|
||||
msgid "Desktop uses a fixed 400 MiB cache budget and normal prefetch. Larger tracks use disk-backed buffers."
|
||||
msgstr "Desktop usa un límite fijo de caché de 400 MiB y precarga normal. Las pistas más grandes usan buffers respaldados en disco."
|
||||
|
||||
#: qbz-qt/qml/settings/PlaybackSettings.qml
|
||||
msgid "Low uses a 50 MiB cache budget, smaller startup buffers and reduced prefetch. Large tracks use disk; speculative Hi-Res prefetch is skipped. Useful on Raspberry Pi or when saving RAM."
|
||||
msgstr "Low usa un límite de caché de 50 MiB, buffers iniciales más pequeños y menos precarga. Las pistas grandes usan disco; se omite la precarga especulativa Hi-Res. Útil en Raspberry Pi o para ahorrar RAM."
|
||||
|
||||
#: qbz-qt/qml/settings/PlaybackSettings.qml
|
||||
msgid "Custom uses your cache limits and growth preference below. Prefetch follows the computer's detected memory class. Editing a preset switches to Custom."
|
||||
msgstr "Personalizado usa los límites de caché y la preferencia de crecimiento indicados abajo. La precarga sigue la memoria detectada del equipo. Editar un perfil cambia a Personalizado."
|
||||
|
||||
#: qbz-qt/qml/settings/PlaybackSettings.qml
|
||||
msgid "Same audio quality in every profile"
|
||||
msgstr "La misma calidad de audio en todos los perfiles"
|
||||
|
||||
#: qbz-qt/qml/settings/PlaybackSettings.qml
|
||||
msgid "Profiles control playback memory, not audio quality or the whole app's RAM. Disk cache stays enabled unless Streaming only is on. Changes apply to new buffers; the current track continues."
|
||||
msgstr "Los perfiles controlan la memoria de reproducción, no la calidad de audio ni toda la RAM de la aplicación. La caché de disco permanece activa salvo en Solo streaming. Los cambios se aplican a buffers nuevos; la pista actual continúa."
|
||||
|
||||
#: qbz-qt/qml/settings/PlaybackSettings.qml
|
||||
msgid "Allow the cache to grow within your maximum when RAM is available. High enables this automatically. Editing this option switches to Custom."
|
||||
msgstr "Permite que la caché crezca hasta tu máximo cuando hay RAM disponible. High lo activa automáticamente. Editar esta opción cambia a Personalizado."
|
||||
|
||||
#: qbz-qt/qml/settings/PlaybackSettings.qml
|
||||
msgid "Unknown playback memory profile"
|
||||
msgstr "Perfil de memoria de reproducción desconocido"
|
||||
|
||||
#: qbz-qt/qml/settings/PlaybackSettings.qml
|
||||
msgid "Playback memory preset conflicts with custom cache limits"
|
||||
msgstr "El perfil de memoria de reproducción entra en conflicto con los límites de caché personalizados"
|
||||
|
||||
msgid "Apply playback memory profile?"
|
||||
msgstr "¿Aplicar el perfil de memoria de reproducción?"
|
||||
|
||||
msgid "Playback buffers will be released. If playing, the current track will stop and restart from the beginning. If paused, it will stay stopped at 0:00. Your queue and disk cache will be kept."
|
||||
msgstr "Se liberarán los buffers de reproducción. Si hay reproducción activa, el track actual se detendrá y comenzará desde el principio. Si está pausado, quedará detenido en 0:00. Se conservarán la cola y la caché en disco."
|
||||
|
||||
msgid "Apply & free memory"
|
||||
msgstr "Aplicar y liberar memoria"
|
||||
|
||||
msgid "Rebuild playback buffers"
|
||||
msgstr "Reconstruir buffers de reproducción"
|
||||
|
||||
msgid "The profile already applies to new buffers. Apply now to release existing playback buffers and the memory cache. Memory use can grow again as playback continues."
|
||||
msgstr "El perfil ya se aplica a los buffers nuevos. Aplica ahora para liberar los buffers existentes y la caché en memoria. El uso de memoria puede volver a crecer al continuar la reproducción."
|
||||
|
||||
msgid "Playback memory can only be applied to local playback."
|
||||
msgstr "El perfil de memoria solo se puede aplicar a la reproducción en este equipo."
|
||||
|
||||
msgid "Playback is not ready yet."
|
||||
msgstr "La reproducción aún no está lista."
|
||||
|
||||
msgid "Playback is busy. Try applying the memory profile again."
|
||||
msgstr "La reproducción está ocupada. Intenta aplicar el perfil de memoria de nuevo."
|
||||
|
||||
msgid "Playback changed. Apply again to confirm the current track."
|
||||
msgstr "La reproducción cambió. Vuelve a aplicar para confirmar el track actual."
|
||||
|
||||
msgid "Visual resource profile"
|
||||
msgstr "Perfil de recursos visuales"
|
||||
|
||||
msgid "Low uses the Small player, compact headers, no dynamic background, header gradient, spectrum, animated play indicator, sidebar collages or optional track artwork. Default restores these visual defaults. Editing a managed option selects Custom. Audio quality and playback memory are independent."
|
||||
msgstr "Low usa NPB Small y encabezados compactos; desactiva fondo dinámico, gradiente, espectro, indicador animado, collages y carátulas opcionales por track. Default restaura estos valores visuales. Editarlos cambia a Custom. La calidad de audio y la memoria de reproducción son independientes."
|
||||
|
||||
msgid "Compare memory profiles"
|
||||
msgstr "Comparar perfiles de memoria"
|
||||
|
||||
msgid "Auto: fixed 400 MiB or 50 MiB according to the computer's RAM; disk handles larger tracks."
|
||||
msgstr "Auto: 400 MiB o 50 MiB fijos según la RAM del equipo; las pistas mayores usan disco."
|
||||
|
||||
msgid "High: dynamic 400–1600 MiB; more room in RAM for large tracks, with less need for disk-backed buffers."
|
||||
msgstr "High: 400–1600 MiB dinámicos; más espacio en RAM para pistas grandes y menor necesidad de buffers en disco."
|
||||
|
||||
msgid "Desktop: fixed 400 MiB; balances RAM use with disk-backed playback for larger tracks."
|
||||
msgstr "Desktop: 400 MiB fijos; equilibra el uso de RAM con reproducción desde disco para pistas mayores."
|
||||
|
||||
msgid "Low: fixed 50 MiB and reduced prefetch; lower RAM use, with more disk reads and writes for large tracks."
|
||||
msgstr "Low: 50 MiB fijos y menos precarga; menor uso de RAM, con más lecturas y escrituras en disco para pistas grandes."
|
||||
|
||||
msgid "Custom: your RAM limits and growth preference determine when playback needs disk storage."
|
||||
msgstr "Custom: tus límites de RAM y el crecimiento elegido determinan cuándo se necesita almacenamiento en disco."
|
||||
|
||||
msgid "RAM and storage trade-off"
|
||||
msgstr "Equilibrio entre RAM y almacenamiento"
|
||||
|
||||
msgid "All profiles preserve audio quality and can use disk cache. Lower memory budgets rely more on storage speed and writes; more RAM can reduce that work. Cached disk tracks are read from disk even in High. Choose storage appropriate for your listening workload."
|
||||
msgstr "Todos los perfiles conservan la calidad y pueden usar caché en disco. Menos RAM implica mayor dependencia de la velocidad y las escrituras del almacenamiento; más RAM puede reducir ese trabajo. Las pistas ya cacheadas en disco se leen desde ahí incluso en High. Elige una unidad adecuada para tu uso."
|
||||
|
||||
msgid "Playback storage folder"
|
||||
msgstr "Carpeta de almacenamiento de reproducción"
|
||||
|
||||
msgid "Choose an existing folder on the drive you want to use. QBZ stores its playback cache and temporary track buffers in a qbz-playback subfolder. Offline downloads are separate. Leave blank for the default location."
|
||||
msgstr "Elige una carpeta existente en la unidad que quieras usar. QBZ guardará la caché de reproducción y los buffers temporales en la subcarpeta qbz-playback. Las descargas offline son independientes. Déjalo vacío para usar la ubicación predeterminada."
|
||||
|
||||
msgid "Current playback storage"
|
||||
msgstr "Almacenamiento de reproducción actual"
|
||||
|
||||
msgid "Changing the folder takes effect after restarting QBZ. Existing cache files stay in the old location; they are not moved or deleted automatically."
|
||||
msgstr "La nueva carpeta se usará después de reiniciar QBZ. Los archivos de caché existentes permanecerán en la ubicación anterior; no se moverán ni eliminarán automáticamente."
|
||||
|
||||
msgid "Could not use this playback storage folder:"
|
||||
msgstr "No se pudo usar esta carpeta de reproducción:"
|
||||
|
||||
msgid "Folder access"
|
||||
msgstr "Acceso a la carpeta"
|
||||
|
||||
msgid "If the sandbox blocks this folder, run the command below in a host terminal, restart QBZ, then select the folder again. Only grant access to a folder you intend to use."
|
||||
msgstr "Si el sandbox bloquea la carpeta, ejecuta el comando de abajo en una terminal del sistema, reinicia QBZ y vuelve a seleccionarla. Da acceso solo a la carpeta que quieras usar."
|
||||
|
||||
msgid "Snap can access supported home folders and removable storage under /mnt, /media or /run/media. For removable storage, connect the interface below and retry. Snap cannot grant arbitrary folder access with a Flatpak-style override."
|
||||
msgstr "Snap puede acceder a carpetas compatibles del directorio personal y unidades bajo /mnt, /media o /run/media. Para estas unidades, conecta la interfaz indicada y reintenta. Snap no permite dar acceso a cualquier carpeta mediante un override como Flatpak."
|
||||
|
||||
msgid "Choose an absolute folder path for playback storage."
|
||||
msgstr "Elige una ruta absoluta para el almacenamiento de reproducción."
|
||||
|
||||
msgid "The folder is accessible and visible in the library."
|
||||
msgstr "La carpeta es accesible y visible en la biblioteca."
|
||||
|
||||
msgid "The folder is accessible but hidden by your preference."
|
||||
msgstr "La carpeta es accesible, pero está oculta por tu configuración."
|
||||
|
||||
msgid "The folder path no longer exists. Reconnect it, change its path or remove it from the library."
|
||||
msgstr "La ruta ya no existe. Reconecta la carpeta, cambia su ruta o elimínala de la biblioteca."
|
||||
|
||||
msgid "A connection failure prevents access to this storage."
|
||||
msgstr "Un fallo de conexión impide acceder a este almacenamiento."
|
||||
|
||||
msgid "QBZ does not have permission to access this folder."
|
||||
msgstr "QBZ no tiene permisos para acceder a esta carpeta."
|
||||
|
||||
msgid "The folder could not be accessed. The check timed out or returned another access error."
|
||||
msgstr "No se pudo acceder a la carpeta. La comprobación agotó el tiempo o devolvió otro error de acceso."
|
||||
|
||||
msgid "Folder access is being checked or has not been checked yet."
|
||||
msgstr "Se está comprobando el acceso o la comprobación aún está pendiente."
|
||||
|
||||
msgid "Local folder"
|
||||
msgstr "Carpeta local"
|
||||
|
||||
msgid "Access denied"
|
||||
msgstr "Acceso denegado"
|
||||
|
||||
msgid "Missing"
|
||||
msgstr "Ausente"
|
||||
|
||||
msgid "Actions"
|
||||
msgstr "Acciones"
|
||||
|
||||
msgid "Low"
|
||||
msgstr "Bajo"
|
||||
|
||||
msgid "These options do not apply while Streaming only is enabled. Turn it off to choose a playback memory profile and apply changes."
|
||||
msgstr "Estas opciones no se aplican mientras Solo streaming esté activado. Desactívalo para elegir un perfil de memoria de reproducción y aplicar los cambios."
|
||||
|
||||
msgid "Mimics official clients’ streaming behavior with lower memory usage. Skips the playback cache and its protection against network interruptions: QBZ’s cache keeps upcoming audio ready for more reliable playback and instant replay."
|
||||
msgstr "Imita el comportamiento de streaming de los clientes oficiales y usa menos memoria. Omite la caché de reproducción y su protección ante interrupciones de red: la caché de QBZ prepara el audio siguiente para una reproducción más fiable y permite volver a escuchar al instante."
|
||||
@@ -7247,3 +7247,241 @@ msgstr "Système / manuelle"
|
||||
#: qbz-qt/qml/shell/UpdatesModal.qml
|
||||
msgid "Update ready. Close QBZ to finish installation. It will reopen automatically."
|
||||
msgstr "Mise à jour prête. Fermez QBZ pour terminer l’installation. Il se rouvrira automatiquement."
|
||||
|
||||
#. Playback memory cache policy (#774).
|
||||
msgid "PLAYBACK CACHE"
|
||||
msgstr "CACHE DE LECTURE"
|
||||
|
||||
#. Playback memory cache policy (#774).
|
||||
msgid "Grow cache when memory is available"
|
||||
msgstr "Agrandir le cache si la mémoire le permet"
|
||||
|
||||
#. Playback memory cache policy (#774).
|
||||
msgid "Optional; recommended for long Hi-Res tracks. The fixed budget remains the recommended default."
|
||||
msgstr "Facultatif ; recommandé pour les longs titres Hi-Res. Le budget fixe reste le réglage par défaut recommandé."
|
||||
|
||||
#. Playback memory cache policy (#774).
|
||||
msgid "Minimum cache budget (MiB)"
|
||||
msgstr "Budget minimal du cache (MiB)"
|
||||
|
||||
#. Playback memory cache policy (#774).
|
||||
msgid "Base budget, not reserved RAM. Memory pressure can reduce it. Leave blank for the recommended value."
|
||||
msgstr "Budget de base, sans réservation de RAM. Il peut diminuer si la mémoire manque. Laisser vide pour la valeur recommandée."
|
||||
|
||||
#. Playback memory cache policy (#774).
|
||||
msgid "Maximum cache budget (MiB)"
|
||||
msgstr "Budget maximal du cache (MiB)"
|
||||
|
||||
#. Playback memory cache policy (#774).
|
||||
msgid "Growth ceiling; available memory may impose a lower limit. Leave blank for automatic."
|
||||
msgstr "Plafond de croissance ; la mémoire disponible peut imposer une limite inférieure. Laisser vide pour le mode automatique."
|
||||
|
||||
#. Playback memory cache policy (#774).
|
||||
msgid "Memory cache usage"
|
||||
msgstr "Utilisation du cache mémoire"
|
||||
|
||||
#. Playback memory cache policy (#774).
|
||||
msgid "Used / current budget: %1 / %2 MiB. This excludes decoder memory and offline downloads."
|
||||
msgstr "Utilisé / budget actuel : %1 / %2 MiB. Hors mémoire du décodeur et téléchargements hors ligne."
|
||||
|
||||
#. Playback memory cache policy (#774).
|
||||
msgid "Recommended cache defaults"
|
||||
msgstr "Valeurs par défaut recommandées du cache"
|
||||
|
||||
#. Playback memory cache policy (#774).
|
||||
msgid "Restore defaults"
|
||||
msgstr "Rétablir les valeurs par défaut"
|
||||
|
||||
#. Playback memory cache policy (#774).
|
||||
msgid "Playback cache limits must be between 16 and 16384 MiB"
|
||||
msgstr "Les limites du cache de lecture doivent être comprises entre 16 et 16384 MiB"
|
||||
|
||||
#. Playback memory cache policy (#774).
|
||||
msgid "Minimum playback cache budget must not exceed its maximum"
|
||||
msgstr "Le budget minimal du cache de lecture ne doit pas dépasser le maximum"
|
||||
|
||||
#: qbz-qt/qml/settings/PlaybackSettings.qml
|
||||
msgid "Playback memory profile"
|
||||
msgstr "Profil mémoire de lecture"
|
||||
|
||||
#: qbz-qt/qml/settings/PlaybackSettings.qml
|
||||
msgid "Auto — recommended"
|
||||
msgstr "Auto — recommandé"
|
||||
|
||||
#: qbz-qt/qml/settings/PlaybackSettings.qml
|
||||
msgid "High — dynamic"
|
||||
msgstr "High — dynamique"
|
||||
|
||||
#: qbz-qt/qml/settings/PlaybackSettings.qml
|
||||
msgid "Desktop"
|
||||
msgstr "Desktop"
|
||||
|
||||
#: qbz-qt/qml/settings/PlaybackSettings.qml
|
||||
msgid "Low — Pi style"
|
||||
msgstr "Low — style Pi"
|
||||
|
||||
#: qbz-qt/qml/settings/PlaybackSettings.qml
|
||||
msgid "Auto selects Desktop or Low from the computer's RAM. Fixed cache budget; dynamic growth is off. Recommended default."
|
||||
msgstr "Auto choisit Desktop ou Low selon la RAM de l’ordinateur. Budget de cache fixe, sans croissance dynamique. Réglage par défaut recommandé."
|
||||
|
||||
#: qbz-qt/qml/settings/PlaybackSettings.qml
|
||||
msgid "High starts with a 400 MiB cache budget and grows up to 1600 MiB when memory is available. It shrinks after inactivity or memory pressure. For long Hi-Res tracks and computers with spare RAM."
|
||||
msgstr "High démarre avec un budget de cache de 400 MiB et peut atteindre 1600 MiB si la mémoire est disponible. Il diminue après une période d’inactivité ou sous pression mémoire. Pour les longs morceaux Hi-Res et les ordinateurs disposant de RAM libre."
|
||||
|
||||
#: qbz-qt/qml/settings/PlaybackSettings.qml
|
||||
msgid "Desktop uses a fixed 400 MiB cache budget and normal prefetch. Larger tracks use disk-backed buffers."
|
||||
msgstr "Desktop utilise un budget de cache fixe de 400 MiB et un préchargement normal. Les morceaux plus volumineux utilisent des tampons sur disque."
|
||||
|
||||
#: qbz-qt/qml/settings/PlaybackSettings.qml
|
||||
msgid "Low uses a 50 MiB cache budget, smaller startup buffers and reduced prefetch. Large tracks use disk; speculative Hi-Res prefetch is skipped. Useful on Raspberry Pi or when saving RAM."
|
||||
msgstr "Low utilise un budget de cache de 50 MiB, des tampons de démarrage plus petits et un préchargement réduit. Les gros morceaux utilisent le disque ; le préchargement spéculatif Hi-Res est désactivé. Utile sur Raspberry Pi ou pour économiser la RAM."
|
||||
|
||||
#: qbz-qt/qml/settings/PlaybackSettings.qml
|
||||
msgid "Custom uses your cache limits and growth preference below. Prefetch follows the computer's detected memory class. Editing a preset switches to Custom."
|
||||
msgstr "Personnalisé utilise les limites et la préférence de croissance ci-dessous. Le préchargement suit la classe mémoire détectée. Modifier un préréglage sélectionne Personnalisé."
|
||||
|
||||
#: qbz-qt/qml/settings/PlaybackSettings.qml
|
||||
msgid "Same audio quality in every profile"
|
||||
msgstr "Même qualité audio dans tous les profils"
|
||||
|
||||
#: qbz-qt/qml/settings/PlaybackSettings.qml
|
||||
msgid "Profiles control playback memory, not audio quality or the whole app's RAM. Disk cache stays enabled unless Streaming only is on. Changes apply to new buffers; the current track continues."
|
||||
msgstr "Les profils contrôlent la mémoire de lecture, pas la qualité audio ni toute la RAM de l’application. Le cache disque reste actif sauf en mode Streaming uniquement. Les changements concernent les nouveaux tampons ; le morceau actuel continue."
|
||||
|
||||
#: qbz-qt/qml/settings/PlaybackSettings.qml
|
||||
msgid "Allow the cache to grow within your maximum when RAM is available. High enables this automatically. Editing this option switches to Custom."
|
||||
msgstr "Autorise la croissance du cache jusqu’au maximum choisi lorsque de la RAM est disponible. High l’active automatiquement. Modifier cette option sélectionne Personnalisé."
|
||||
|
||||
#: qbz-qt/qml/settings/PlaybackSettings.qml
|
||||
msgid "Unknown playback memory profile"
|
||||
msgstr "Profil mémoire de lecture inconnu"
|
||||
|
||||
#: qbz-qt/qml/settings/PlaybackSettings.qml
|
||||
msgid "Playback memory preset conflicts with custom cache limits"
|
||||
msgstr "Le profil mémoire de lecture entre en conflit avec les limites de cache personnalisées"
|
||||
|
||||
msgid "Apply playback memory profile?"
|
||||
msgstr "Appliquer le profil mémoire de lecture ?"
|
||||
|
||||
msgid "Playback buffers will be released. If playing, the current track will stop and restart from the beginning. If paused, it will stay stopped at 0:00. Your queue and disk cache will be kept."
|
||||
msgstr "Les tampons de lecture seront libérés. Si la lecture est en cours, le titre sera arrêté puis relancé depuis le début. En pause, il restera arrêté à 0:00. La file et le cache disque seront conservés."
|
||||
|
||||
msgid "Apply & free memory"
|
||||
msgstr "Appliquer et libérer la mémoire"
|
||||
|
||||
msgid "Rebuild playback buffers"
|
||||
msgstr "Reconstruire les tampons de lecture"
|
||||
|
||||
msgid "The profile already applies to new buffers. Apply now to release existing playback buffers and the memory cache. Memory use can grow again as playback continues."
|
||||
msgstr "Le profil s’applique déjà aux nouveaux tampons. Appliquez-le maintenant pour libérer les tampons existants et le cache mémoire. L’utilisation de mémoire peut augmenter à nouveau pendant la lecture."
|
||||
|
||||
msgid "Playback memory can only be applied to local playback."
|
||||
msgstr "Le profil mémoire ne peut être appliqué qu’à la lecture sur cet appareil."
|
||||
|
||||
msgid "Playback is not ready yet."
|
||||
msgstr "La lecture n’est pas encore prête."
|
||||
|
||||
msgid "Playback is busy. Try applying the memory profile again."
|
||||
msgstr "La lecture est occupée. Réessayez d’appliquer le profil mémoire."
|
||||
|
||||
msgid "Playback changed. Apply again to confirm the current track."
|
||||
msgstr "La lecture a changé. Appliquez à nouveau pour confirmer le titre actuel."
|
||||
|
||||
msgid "Visual resource profile"
|
||||
msgstr "Profil de ressources visuelles"
|
||||
|
||||
msgid "Low uses the Small player, compact headers, no dynamic background, header gradient, spectrum, animated play indicator, sidebar collages or optional track artwork. Default restores these visual defaults. Editing a managed option selects Custom. Audio quality and playback memory are independent."
|
||||
msgstr "Low utilise le petit lecteur et des en-têtes compacts, sans fond dynamique, dégradé, spectre, indicateur animé, collages ni pochettes facultatives des titres. Default rétablit ces réglages visuels. Les modifier active Custom. Qualité audio et mémoire de lecture restent indépendantes."
|
||||
|
||||
msgid "Compare memory profiles"
|
||||
msgstr "Comparer les profils mémoire"
|
||||
|
||||
msgid "Auto: fixed 400 MiB or 50 MiB according to the computer's RAM; disk handles larger tracks."
|
||||
msgstr "Auto : 400 MiB ou 50 MiB fixes selon la RAM ; les titres plus volumineux utilisent le disque."
|
||||
|
||||
msgid "High: dynamic 400–1600 MiB; more room in RAM for large tracks, with less need for disk-backed buffers."
|
||||
msgstr "High : 400–1600 MiB dynamiques ; plus de RAM pour les gros titres, moins de recours aux tampons sur disque."
|
||||
|
||||
msgid "Desktop: fixed 400 MiB; balances RAM use with disk-backed playback for larger tracks."
|
||||
msgstr "Desktop : 400 MiB fixes ; équilibre RAM et lecture sur disque des titres plus volumineux."
|
||||
|
||||
msgid "Low: fixed 50 MiB and reduced prefetch; lower RAM use, with more disk reads and writes for large tracks."
|
||||
msgstr "Low : 50 MiB fixes et préchargement réduit ; moins de RAM, davantage de lectures et écritures disque pour les gros titres."
|
||||
|
||||
msgid "Custom: your RAM limits and growth preference determine when playback needs disk storage."
|
||||
msgstr "Custom : vos limites de RAM et de croissance déterminent quand la lecture utilise le disque."
|
||||
|
||||
msgid "RAM and storage trade-off"
|
||||
msgstr "Équilibre entre RAM et stockage"
|
||||
|
||||
msgid "All profiles preserve audio quality and can use disk cache. Lower memory budgets rely more on storage speed and writes; more RAM can reduce that work. Cached disk tracks are read from disk even in High. Choose storage appropriate for your listening workload."
|
||||
msgstr "Tous les profils préservent la qualité audio et peuvent utiliser le cache disque. Moins de RAM implique davantage de dépendance à la vitesse et aux écritures du stockage ; plus de RAM peut les réduire. Les titres déjà sur disque y sont lus même en High. Choisissez un stockage adapté à votre usage."
|
||||
|
||||
msgid "Playback storage folder"
|
||||
msgstr "Dossier de stockage de lecture"
|
||||
|
||||
msgid "Choose an existing folder on the drive you want to use. QBZ stores its playback cache and temporary track buffers in a qbz-playback subfolder. Offline downloads are separate. Leave blank for the default location."
|
||||
msgstr "Choisissez un dossier existant sur le disque souhaité. QBZ stocke le cache de lecture et les tampons temporaires dans le sous-dossier qbz-playback. Les téléchargements hors ligne sont séparés. Laissez vide pour le dossier par défaut."
|
||||
|
||||
msgid "Current playback storage"
|
||||
msgstr "Stockage de lecture actuel"
|
||||
|
||||
msgid "Changing the folder takes effect after restarting QBZ. Existing cache files stay in the old location; they are not moved or deleted automatically."
|
||||
msgstr "Le nouveau dossier sera utilisé après le redémarrage de QBZ. Les fichiers du cache restent à leur ancien emplacement ; ils ne sont ni déplacés ni supprimés automatiquement."
|
||||
|
||||
msgid "Could not use this playback storage folder:"
|
||||
msgstr "Impossible d’utiliser ce dossier de lecture :"
|
||||
|
||||
msgid "Folder access"
|
||||
msgstr "Accès au dossier"
|
||||
|
||||
msgid "If the sandbox blocks this folder, run the command below in a host terminal, restart QBZ, then select the folder again. Only grant access to a folder you intend to use."
|
||||
msgstr "Si le bac à sable bloque ce dossier, exécutez la commande ci-dessous dans un terminal du système hôte, redémarrez QBZ et sélectionnez-le à nouveau. N’autorisez que le dossier souhaité."
|
||||
|
||||
msgid "Snap can access supported home folders and removable storage under /mnt, /media or /run/media. For removable storage, connect the interface below and retry. Snap cannot grant arbitrary folder access with a Flatpak-style override."
|
||||
msgstr "Snap peut accéder aux dossiers personnels pris en charge et aux supports sous /mnt, /media ou /run/media. Connectez l’interface indiquée puis réessayez. Snap ne permet pas d’autoriser n’importe quel dossier avec un override comme Flatpak."
|
||||
|
||||
msgid "Choose an absolute folder path for playback storage."
|
||||
msgstr "Choisissez un chemin de dossier absolu pour le stockage de lecture."
|
||||
|
||||
msgid "The folder is accessible and visible in the library."
|
||||
msgstr "Le dossier est accessible et visible dans la bibliothèque."
|
||||
|
||||
msgid "The folder is accessible but hidden by your preference."
|
||||
msgstr "Le dossier est accessible, mais masqué selon votre préférence."
|
||||
|
||||
msgid "The folder path no longer exists. Reconnect it, change its path or remove it from the library."
|
||||
msgstr "Le chemin n’existe plus. Reconnectez le dossier, changez son chemin ou retirez-le de la bibliothèque."
|
||||
|
||||
msgid "A connection failure prevents access to this storage."
|
||||
msgstr "Un échec de connexion empêche l’accès à ce stockage."
|
||||
|
||||
msgid "QBZ does not have permission to access this folder."
|
||||
msgstr "QBZ n’a pas l’autorisation d’accéder à ce dossier."
|
||||
|
||||
msgid "The folder could not be accessed. The check timed out or returned another access error."
|
||||
msgstr "Le dossier est inaccessible. La vérification a expiré ou a renvoyé une autre erreur d’accès."
|
||||
|
||||
msgid "Folder access is being checked or has not been checked yet."
|
||||
msgstr "L’accès est en cours de vérification ou n’a pas encore été vérifié."
|
||||
|
||||
msgid "Local folder"
|
||||
msgstr "Dossier local"
|
||||
|
||||
msgid "Access denied"
|
||||
msgstr "Accès refusé"
|
||||
|
||||
msgid "Missing"
|
||||
msgstr "Absent"
|
||||
|
||||
msgid "Actions"
|
||||
msgstr "Actions"
|
||||
|
||||
msgid "Low"
|
||||
msgstr "Faible"
|
||||
|
||||
msgid "These options do not apply while Streaming only is enabled. Turn it off to choose a playback memory profile and apply changes."
|
||||
msgstr "Ces options ne s’appliquent pas lorsque Streaming uniquement est activé. Désactivez cette option pour choisir un profil mémoire de lecture et appliquer les modifications."
|
||||
|
||||
msgid "Mimics official clients’ streaming behavior with lower memory usage. Skips the playback cache and its protection against network interruptions: QBZ’s cache keeps upcoming audio ready for more reliable playback and instant replay."
|
||||
msgstr "Reproduit le comportement de streaming des clients officiels en utilisant moins de mémoire. Désactive le cache de lecture et sa protection contre les interruptions réseau : le cache de QBZ prépare les prochains contenus audio pour une lecture plus fiable et une réécoute immédiate."
|
||||
@@ -7230,3 +7230,241 @@ msgstr "システム / 手動"
|
||||
#: qbz-qt/qml/shell/UpdatesModal.qml
|
||||
msgid "Update ready. Close QBZ to finish installation. It will reopen automatically."
|
||||
msgstr "更新の準備ができました。QBZ を終了するとインストールが完了し、自動的に再起動します。"
|
||||
|
||||
#. Playback memory cache policy (#774).
|
||||
msgid "PLAYBACK CACHE"
|
||||
msgstr "再生キャッシュ"
|
||||
|
||||
#. Playback memory cache policy (#774).
|
||||
msgid "Grow cache when memory is available"
|
||||
msgstr "メモリに余裕がある場合にキャッシュを拡張"
|
||||
|
||||
#. Playback memory cache policy (#774).
|
||||
msgid "Optional; recommended for long Hi-Res tracks. The fixed budget remains the recommended default."
|
||||
msgstr "任意設定です。長いハイレゾ曲におすすめします。通常は既定の固定容量を推奨します。"
|
||||
|
||||
#. Playback memory cache policy (#774).
|
||||
msgid "Minimum cache budget (MiB)"
|
||||
msgstr "キャッシュの最小容量(MiB)"
|
||||
|
||||
#. Playback memory cache policy (#774).
|
||||
msgid "Base budget, not reserved RAM. Memory pressure can reduce it. Leave blank for the recommended value."
|
||||
msgstr "RAMを予約する設定ではありません。メモリ不足時は縮小されます。空欄で推奨値を使用します。"
|
||||
|
||||
#. Playback memory cache policy (#774).
|
||||
msgid "Maximum cache budget (MiB)"
|
||||
msgstr "キャッシュの最大容量(MiB)"
|
||||
|
||||
#. Playback memory cache policy (#774).
|
||||
msgid "Growth ceiling; available memory may impose a lower limit. Leave blank for automatic."
|
||||
msgstr "拡張の上限です。空きメモリによってさらに制限される場合があります。空欄で自動設定になります。"
|
||||
|
||||
#. Playback memory cache policy (#774).
|
||||
msgid "Memory cache usage"
|
||||
msgstr "メモリキャッシュの使用量"
|
||||
|
||||
#. Playback memory cache policy (#774).
|
||||
msgid "Used / current budget: %1 / %2 MiB. This excludes decoder memory and offline downloads."
|
||||
msgstr "使用量 / 現在の容量:%1 / %2 MiB。デコーダーのメモリとオフラインダウンロードは含みません。"
|
||||
|
||||
#. Playback memory cache policy (#774).
|
||||
msgid "Recommended cache defaults"
|
||||
msgstr "キャッシュの推奨設定"
|
||||
|
||||
#. Playback memory cache policy (#774).
|
||||
msgid "Restore defaults"
|
||||
msgstr "既定値に戻す"
|
||||
|
||||
#. Playback memory cache policy (#774).
|
||||
msgid "Playback cache limits must be between 16 and 16384 MiB"
|
||||
msgstr "再生キャッシュの容量は16〜16384 MiBで指定してください"
|
||||
|
||||
#. Playback memory cache policy (#774).
|
||||
msgid "Minimum playback cache budget must not exceed its maximum"
|
||||
msgstr "再生キャッシュの最小容量は最大容量以下にしてください"
|
||||
|
||||
#: qbz-qt/qml/settings/PlaybackSettings.qml
|
||||
msgid "Playback memory profile"
|
||||
msgstr "再生メモリプロファイル"
|
||||
|
||||
#: qbz-qt/qml/settings/PlaybackSettings.qml
|
||||
msgid "Auto — recommended"
|
||||
msgstr "Auto — 推奨"
|
||||
|
||||
#: qbz-qt/qml/settings/PlaybackSettings.qml
|
||||
msgid "High — dynamic"
|
||||
msgstr "High — 動的"
|
||||
|
||||
#: qbz-qt/qml/settings/PlaybackSettings.qml
|
||||
msgid "Desktop"
|
||||
msgstr "Desktop"
|
||||
|
||||
#: qbz-qt/qml/settings/PlaybackSettings.qml
|
||||
msgid "Low — Pi style"
|
||||
msgstr "Low — Pi向け"
|
||||
|
||||
#: qbz-qt/qml/settings/PlaybackSettings.qml
|
||||
msgid "Auto selects Desktop or Low from the computer's RAM. Fixed cache budget; dynamic growth is off. Recommended default."
|
||||
msgstr "AutoはコンピューターのRAMに応じてDesktopまたはLowを選択します。キャッシュ容量は固定で、動的拡張は無効です。推奨の初期設定です。"
|
||||
|
||||
#: qbz-qt/qml/settings/PlaybackSettings.qml
|
||||
msgid "High starts with a 400 MiB cache budget and grows up to 1600 MiB when memory is available. It shrinks after inactivity or memory pressure. For long Hi-Res tracks and computers with spare RAM."
|
||||
msgstr "Highは400 MiBのキャッシュ容量から開始し、空きメモリがある場合に最大1600 MiBまで拡張します。非アクティブ時やメモリ不足時には縮小します。長いHi-Res曲やRAMに余裕のあるコンピューター向けです。"
|
||||
|
||||
#: qbz-qt/qml/settings/PlaybackSettings.qml
|
||||
msgid "Desktop uses a fixed 400 MiB cache budget and normal prefetch. Larger tracks use disk-backed buffers."
|
||||
msgstr "Desktopは400 MiBの固定キャッシュ容量と通常の先読みを使用します。大きな曲にはディスク上のバッファを使用します。"
|
||||
|
||||
#: qbz-qt/qml/settings/PlaybackSettings.qml
|
||||
msgid "Low uses a 50 MiB cache budget, smaller startup buffers and reduced prefetch. Large tracks use disk; speculative Hi-Res prefetch is skipped. Useful on Raspberry Pi or when saving RAM."
|
||||
msgstr "Lowは50 MiBのキャッシュ容量、小さな開始バッファ、少ない先読みを使用します。大きな曲にはディスクを使用し、Hi-Resの事前読み込みは省略します。Raspberry PiやRAMの節約に適しています。"
|
||||
|
||||
#: qbz-qt/qml/settings/PlaybackSettings.qml
|
||||
msgid "Custom uses your cache limits and growth preference below. Prefetch follows the computer's detected memory class. Editing a preset switches to Custom."
|
||||
msgstr "カスタムは以下のキャッシュ上限と拡張設定を使用します。先読みは検出されたメモリクラスに従います。プリセットを編集するとカスタムに切り替わります。"
|
||||
|
||||
#: qbz-qt/qml/settings/PlaybackSettings.qml
|
||||
msgid "Same audio quality in every profile"
|
||||
msgstr "すべてのプロファイルで同じ音質"
|
||||
|
||||
#: qbz-qt/qml/settings/PlaybackSettings.qml
|
||||
msgid "Profiles control playback memory, not audio quality or the whole app's RAM. Disk cache stays enabled unless Streaming only is on. Changes apply to new buffers; the current track continues."
|
||||
msgstr "プロファイルは再生メモリを制御し、音質やアプリ全体のRAMは変更しません。ストリーミングのみのモードを除き、ディスクキャッシュは有効です。変更は新しいバッファに適用され、現在の曲は再生を続けます。"
|
||||
|
||||
#: qbz-qt/qml/settings/PlaybackSettings.qml
|
||||
msgid "Allow the cache to grow within your maximum when RAM is available. High enables this automatically. Editing this option switches to Custom."
|
||||
msgstr "RAMに余裕がある場合、指定した上限までキャッシュを拡張します。Highでは自動的に有効になります。この設定を編集するとカスタムに切り替わります。"
|
||||
|
||||
#: qbz-qt/qml/settings/PlaybackSettings.qml
|
||||
msgid "Unknown playback memory profile"
|
||||
msgstr "不明な再生メモリプロファイルです"
|
||||
|
||||
#: qbz-qt/qml/settings/PlaybackSettings.qml
|
||||
msgid "Playback memory preset conflicts with custom cache limits"
|
||||
msgstr "再生メモリプリセットとカスタムキャッシュ上限が矛盾しています"
|
||||
|
||||
msgid "Apply playback memory profile?"
|
||||
msgstr "再生メモリプロファイルを適用しますか?"
|
||||
|
||||
msgid "Playback buffers will be released. If playing, the current track will stop and restart from the beginning. If paused, it will stay stopped at 0:00. Your queue and disk cache will be kept."
|
||||
msgstr "再生バッファを解放します。再生中の曲は停止し、最初から再生し直します。一時停止中の場合は、0:00で停止したままになります。キューとディスクキャッシュは保持されます。"
|
||||
|
||||
msgid "Apply & free memory"
|
||||
msgstr "適用してメモリを解放"
|
||||
|
||||
msgid "Rebuild playback buffers"
|
||||
msgstr "再生バッファを再構築"
|
||||
|
||||
msgid "The profile already applies to new buffers. Apply now to release existing playback buffers and the memory cache. Memory use can grow again as playback continues."
|
||||
msgstr "プロファイルはすでに新しいバッファに適用されています。今すぐ適用すると、既存の再生バッファとメモリキャッシュを解放します。再生を続けると、メモリ使用量が再び増えることがあります。"
|
||||
|
||||
msgid "Playback memory can only be applied to local playback."
|
||||
msgstr "メモリプロファイルは、このデバイスでの再生にのみ適用できます。"
|
||||
|
||||
msgid "Playback is not ready yet."
|
||||
msgstr "再生の準備がまだできていません。"
|
||||
|
||||
msgid "Playback is busy. Try applying the memory profile again."
|
||||
msgstr "再生処理が進行中です。メモリプロファイルの適用をもう一度お試しください。"
|
||||
|
||||
msgid "Playback changed. Apply again to confirm the current track."
|
||||
msgstr "再生内容が変わりました。現在の曲を確認して、もう一度適用してください。"
|
||||
|
||||
msgid "Visual resource profile"
|
||||
msgstr "表示リソースプロファイル"
|
||||
|
||||
msgid "Low uses the Small player, compact headers, no dynamic background, header gradient, spectrum, animated play indicator, sidebar collages or optional track artwork. Default restores these visual defaults. Editing a managed option selects Custom. Audio quality and playback memory are independent."
|
||||
msgstr "Lowは小型プレーヤーとコンパクトなヘッダーを使い、動的背景、グラデーション、スペクトラム、再生アニメーション、コラージュ、任意の曲の画像を無効にします。Defaultで標準値に戻り、対象設定を変更するとCustomになります。音質と再生メモリは独立しています。"
|
||||
|
||||
msgid "Compare memory profiles"
|
||||
msgstr "メモリプロファイルを比較"
|
||||
|
||||
msgid "Auto: fixed 400 MiB or 50 MiB according to the computer's RAM; disk handles larger tracks."
|
||||
msgstr "Auto:RAM容量に応じて400 MiBまたは50 MiBに固定。大きな曲はディスクを使用します。"
|
||||
|
||||
msgid "High: dynamic 400–1600 MiB; more room in RAM for large tracks, with less need for disk-backed buffers."
|
||||
msgstr "High:400~1600 MiBで可変。大きな曲をRAMに保持しやすくなり、ディスクバッファの必要性が減ります。"
|
||||
|
||||
msgid "Desktop: fixed 400 MiB; balances RAM use with disk-backed playback for larger tracks."
|
||||
msgstr "Desktop:400 MiB固定。RAMを使い、大きな曲はディスクで再生します。"
|
||||
|
||||
msgid "Low: fixed 50 MiB and reduced prefetch; lower RAM use, with more disk reads and writes for large tracks."
|
||||
msgstr "Low:50 MiB固定で先読みを抑制。RAM使用量は減りますが、大きな曲ではディスクの読み書きが増えます。"
|
||||
|
||||
msgid "Custom: your RAM limits and growth preference determine when playback needs disk storage."
|
||||
msgstr "Custom:RAMの上限と拡張設定に応じてディスクを使用します。"
|
||||
|
||||
msgid "RAM and storage trade-off"
|
||||
msgstr "RAMとストレージのバランス"
|
||||
|
||||
msgid "All profiles preserve audio quality and can use disk cache. Lower memory budgets rely more on storage speed and writes; more RAM can reduce that work. Cached disk tracks are read from disk even in High. Choose storage appropriate for your listening workload."
|
||||
msgstr "どのプロファイルも音質を維持し、ディスクキャッシュを使用できます。RAMが少ないほどストレージ速度や書き込みへの依存が増え、RAMを増やすと負荷を減らせます。ディスクにキャッシュ済みの曲はHighでもディスクから読みます。用途に合うストレージを選んでください。"
|
||||
|
||||
msgid "Playback storage folder"
|
||||
msgstr "再生ストレージのフォルダー"
|
||||
|
||||
msgid "Choose an existing folder on the drive you want to use. QBZ stores its playback cache and temporary track buffers in a qbz-playback subfolder. Offline downloads are separate. Leave blank for the default location."
|
||||
msgstr "使用するドライブの既存フォルダーを選びます。QBZはqbz-playbackサブフォルダーに再生キャッシュと一時バッファを保存します。オフラインダウンロードは別です。空欄なら標準の場所を使います。"
|
||||
|
||||
msgid "Current playback storage"
|
||||
msgstr "現在の再生ストレージ"
|
||||
|
||||
msgid "Changing the folder takes effect after restarting QBZ. Existing cache files stay in the old location; they are not moved or deleted automatically."
|
||||
msgstr "フォルダーの変更はQBZの再起動後に反映されます。既存のキャッシュは元の場所に残り、自動で移動や削除はされません。"
|
||||
|
||||
msgid "Could not use this playback storage folder:"
|
||||
msgstr "この再生ストレージフォルダーを使用できませんでした:"
|
||||
|
||||
msgid "Folder access"
|
||||
msgstr "フォルダーへのアクセス"
|
||||
|
||||
msgid "If the sandbox blocks this folder, run the command below in a host terminal, restart QBZ, then select the folder again. Only grant access to a folder you intend to use."
|
||||
msgstr "サンドボックスがフォルダーをブロックする場合、ホストの端末で下のコマンドを実行し、QBZを再起動して再度選んでください。使用するフォルダーだけにアクセスを許可してください。"
|
||||
|
||||
msgid "Snap can access supported home folders and removable storage under /mnt, /media or /run/media. For removable storage, connect the interface below and retry. Snap cannot grant arbitrary folder access with a Flatpak-style override."
|
||||
msgstr "Snapは対応するホームフォルダーと/mnt、/media、/run/media配下の外部ストレージにアクセスできます。外部ストレージでは下のインターフェースを接続して再試行してください。Flatpakのようなoverrideで任意のフォルダーを許可することはできません。"
|
||||
|
||||
msgid "Choose an absolute folder path for playback storage."
|
||||
msgstr "再生ストレージの絶対パスを指定してください。"
|
||||
|
||||
msgid "The folder is accessible and visible in the library."
|
||||
msgstr "フォルダーにアクセスでき、ライブラリに表示されます。"
|
||||
|
||||
msgid "The folder is accessible but hidden by your preference."
|
||||
msgstr "フォルダーにアクセスできますが、設定により非表示です。"
|
||||
|
||||
msgid "The folder path no longer exists. Reconnect it, change its path or remove it from the library."
|
||||
msgstr "フォルダーのパスが存在しません。再接続するか、パスを変更するか、ライブラリから削除してください。"
|
||||
|
||||
msgid "A connection failure prevents access to this storage."
|
||||
msgstr "接続エラーにより、このストレージにアクセスできません。"
|
||||
|
||||
msgid "QBZ does not have permission to access this folder."
|
||||
msgstr "QBZにはこのフォルダーへのアクセス権がありません。"
|
||||
|
||||
msgid "The folder could not be accessed. The check timed out or returned another access error."
|
||||
msgstr "フォルダーにアクセスできませんでした。確認がタイムアウトしたか、別のアクセスエラーが発生しました。"
|
||||
|
||||
msgid "Folder access is being checked or has not been checked yet."
|
||||
msgstr "フォルダーへのアクセスを確認中、またはまだ未確認です。"
|
||||
|
||||
msgid "Local folder"
|
||||
msgstr "ローカルフォルダー"
|
||||
|
||||
msgid "Access denied"
|
||||
msgstr "アクセス拒否"
|
||||
|
||||
msgid "Missing"
|
||||
msgstr "見つかりません"
|
||||
|
||||
msgid "Actions"
|
||||
msgstr "操作"
|
||||
|
||||
msgid "Low"
|
||||
msgstr "低"
|
||||
|
||||
msgid "These options do not apply while Streaming only is enabled. Turn it off to choose a playback memory profile and apply changes."
|
||||
msgstr "「ストリーミングのみ」が有効な間、これらの設定は適用されません。無効にすると、再生メモリプロファイルを選択して変更を適用できます。"
|
||||
|
||||
msgid "Mimics official clients’ streaming behavior with lower memory usage. Skips the playback cache and its protection against network interruptions: QBZ’s cache keeps upcoming audio ready for more reliable playback and instant replay."
|
||||
msgstr "公式クライアントと同様のストリーミング動作でメモリ使用量を抑えます。再生キャッシュとそのネットワーク中断への保護を無効にします。QBZのキャッシュは次の音声を事前に用意し、より安定した再生と即座のリプレイを可能にします。"
|
||||
@@ -7237,3 +7237,241 @@ msgstr "Systeem / handmatig"
|
||||
#: qbz-qt/qml/shell/UpdatesModal.qml
|
||||
msgid "Update ready. Close QBZ to finish installation. It will reopen automatically."
|
||||
msgstr "Update gereed. Sluit QBZ om de installatie te voltooien. Het wordt automatisch opnieuw geopend."
|
||||
|
||||
#. Playback memory cache policy (#774).
|
||||
msgid "PLAYBACK CACHE"
|
||||
msgstr "AFSPEELCACHE"
|
||||
|
||||
#. Playback memory cache policy (#774).
|
||||
msgid "Grow cache when memory is available"
|
||||
msgstr "Cache vergroten bij beschikbaar geheugen"
|
||||
|
||||
#. Playback memory cache policy (#774).
|
||||
msgid "Optional; recommended for long Hi-Res tracks. The fixed budget remains the recommended default."
|
||||
msgstr "Optioneel; aanbevolen voor lange Hi-Res-nummers. Het vaste budget blijft de aanbevolen standaard."
|
||||
|
||||
#. Playback memory cache policy (#774).
|
||||
msgid "Minimum cache budget (MiB)"
|
||||
msgstr "Minimaal cachebudget (MiB)"
|
||||
|
||||
#. Playback memory cache policy (#774).
|
||||
msgid "Base budget, not reserved RAM. Memory pressure can reduce it. Leave blank for the recommended value."
|
||||
msgstr "Basisbudget zonder RAM te reserveren. Kan dalen bij geheugendruk. Laat leeg voor de aanbevolen waarde."
|
||||
|
||||
#. Playback memory cache policy (#774).
|
||||
msgid "Maximum cache budget (MiB)"
|
||||
msgstr "Maximaal cachebudget (MiB)"
|
||||
|
||||
#. Playback memory cache policy (#774).
|
||||
msgid "Growth ceiling; available memory may impose a lower limit. Leave blank for automatic."
|
||||
msgstr "Groeiplimiet; beschikbaar geheugen kan een lagere grens opleggen. Laat leeg voor automatisch."
|
||||
|
||||
#. Playback memory cache policy (#774).
|
||||
msgid "Memory cache usage"
|
||||
msgstr "Gebruik van geheugencache"
|
||||
|
||||
#. Playback memory cache policy (#774).
|
||||
msgid "Used / current budget: %1 / %2 MiB. This excludes decoder memory and offline downloads."
|
||||
msgstr "Gebruikt / huidig budget: %1 / %2 MiB. Exclusief decodergeheugen en offline downloads."
|
||||
|
||||
#. Playback memory cache policy (#774).
|
||||
msgid "Recommended cache defaults"
|
||||
msgstr "Aanbevolen cachestandaarden"
|
||||
|
||||
#. Playback memory cache policy (#774).
|
||||
msgid "Restore defaults"
|
||||
msgstr "Standaardwaarden herstellen"
|
||||
|
||||
#. Playback memory cache policy (#774).
|
||||
msgid "Playback cache limits must be between 16 and 16384 MiB"
|
||||
msgstr "De afspeelcachelimieten moeten tussen 16 en 16384 MiB liggen"
|
||||
|
||||
#. Playback memory cache policy (#774).
|
||||
msgid "Minimum playback cache budget must not exceed its maximum"
|
||||
msgstr "Het minimale afspeelcachebudget mag het maximum niet overschrijden"
|
||||
|
||||
#: qbz-qt/qml/settings/PlaybackSettings.qml
|
||||
msgid "Playback memory profile"
|
||||
msgstr "Geheugenprofiel voor afspelen"
|
||||
|
||||
#: qbz-qt/qml/settings/PlaybackSettings.qml
|
||||
msgid "Auto — recommended"
|
||||
msgstr "Auto — aanbevolen"
|
||||
|
||||
#: qbz-qt/qml/settings/PlaybackSettings.qml
|
||||
msgid "High — dynamic"
|
||||
msgstr "High — dynamisch"
|
||||
|
||||
#: qbz-qt/qml/settings/PlaybackSettings.qml
|
||||
msgid "Desktop"
|
||||
msgstr "Desktop"
|
||||
|
||||
#: qbz-qt/qml/settings/PlaybackSettings.qml
|
||||
msgid "Low — Pi style"
|
||||
msgstr "Low — Pi-stijl"
|
||||
|
||||
#: qbz-qt/qml/settings/PlaybackSettings.qml
|
||||
msgid "Auto selects Desktop or Low from the computer's RAM. Fixed cache budget; dynamic growth is off. Recommended default."
|
||||
msgstr "Auto kiest Desktop of Low op basis van het RAM. Vast cachebudget zonder dynamische groei. Aanbevolen standaardinstelling."
|
||||
|
||||
#: qbz-qt/qml/settings/PlaybackSettings.qml
|
||||
msgid "High starts with a 400 MiB cache budget and grows up to 1600 MiB when memory is available. It shrinks after inactivity or memory pressure. For long Hi-Res tracks and computers with spare RAM."
|
||||
msgstr "High begint met een cachebudget van 400 MiB en groeit tot 1600 MiB als geheugen beschikbaar is. Het krimpt na inactiviteit of bij geheugendruk. Voor lange Hi-Res-nummers en computers met vrij RAM."
|
||||
|
||||
#: qbz-qt/qml/settings/PlaybackSettings.qml
|
||||
msgid "Desktop uses a fixed 400 MiB cache budget and normal prefetch. Larger tracks use disk-backed buffers."
|
||||
msgstr "Desktop gebruikt een vast cachebudget van 400 MiB en normaal vooraf laden. Grotere nummers gebruiken buffers op schijf."
|
||||
|
||||
#: qbz-qt/qml/settings/PlaybackSettings.qml
|
||||
msgid "Low uses a 50 MiB cache budget, smaller startup buffers and reduced prefetch. Large tracks use disk; speculative Hi-Res prefetch is skipped. Useful on Raspberry Pi or when saving RAM."
|
||||
msgstr "Low gebruikt een cachebudget van 50 MiB, kleinere startbuffers en minder vooraf laden. Grote nummers gebruiken de schijf; speculatief vooraf laden van Hi-Res wordt overgeslagen. Handig op Raspberry Pi of om RAM te besparen."
|
||||
|
||||
#: qbz-qt/qml/settings/PlaybackSettings.qml
|
||||
msgid "Custom uses your cache limits and growth preference below. Prefetch follows the computer's detected memory class. Editing a preset switches to Custom."
|
||||
msgstr "Aangepast gebruikt de onderstaande cachegrenzen en groeivoorkeur. Vooraf laden volgt de gedetecteerde geheugenklasse. Een voorinstelling bewerken schakelt naar Aangepast."
|
||||
|
||||
#: qbz-qt/qml/settings/PlaybackSettings.qml
|
||||
msgid "Same audio quality in every profile"
|
||||
msgstr "Dezelfde audiokwaliteit in elk profiel"
|
||||
|
||||
#: qbz-qt/qml/settings/PlaybackSettings.qml
|
||||
msgid "Profiles control playback memory, not audio quality or the whole app's RAM. Disk cache stays enabled unless Streaming only is on. Changes apply to new buffers; the current track continues."
|
||||
msgstr "Profielen regelen het afspeelgeheugen, niet de audiokwaliteit of het totale RAM van de app. De schijfcache blijft actief behalve bij Alleen streamen. Wijzigingen gelden voor nieuwe buffers; het huidige nummer speelt door."
|
||||
|
||||
#: qbz-qt/qml/settings/PlaybackSettings.qml
|
||||
msgid "Allow the cache to grow within your maximum when RAM is available. High enables this automatically. Editing this option switches to Custom."
|
||||
msgstr "Laat de cache tot het ingestelde maximum groeien wanneer RAM beschikbaar is. High schakelt dit automatisch in. Deze optie bewerken schakelt naar Aangepast."
|
||||
|
||||
#: qbz-qt/qml/settings/PlaybackSettings.qml
|
||||
msgid "Unknown playback memory profile"
|
||||
msgstr "Onbekend afspeelgeheugenprofiel"
|
||||
|
||||
#: qbz-qt/qml/settings/PlaybackSettings.qml
|
||||
msgid "Playback memory preset conflicts with custom cache limits"
|
||||
msgstr "Het afspeelgeheugenprofiel conflicteert met aangepaste cachegrenzen"
|
||||
|
||||
msgid "Apply playback memory profile?"
|
||||
msgstr "Geheugenprofiel voor afspelen toepassen?"
|
||||
|
||||
msgid "Playback buffers will be released. If playing, the current track will stop and restart from the beginning. If paused, it will stay stopped at 0:00. Your queue and disk cache will be kept."
|
||||
msgstr "De afspeelbuffers worden vrijgegeven. Tijdens het afspelen stopt het huidige nummer en begint het opnieuw vanaf het begin. Bij pauze blijft het gestopt op 0:00. Je wachtrij en schijfcache blijven behouden."
|
||||
|
||||
msgid "Apply & free memory"
|
||||
msgstr "Toepassen en geheugen vrijmaken"
|
||||
|
||||
msgid "Rebuild playback buffers"
|
||||
msgstr "Afspeelbuffers opnieuw opbouwen"
|
||||
|
||||
msgid "The profile already applies to new buffers. Apply now to release existing playback buffers and the memory cache. Memory use can grow again as playback continues."
|
||||
msgstr "Het profiel geldt al voor nieuwe buffers. Pas het nu toe om bestaande afspeelbuffers en de geheugencache vrij te geven. Het geheugengebruik kan weer toenemen tijdens het afspelen."
|
||||
|
||||
msgid "Playback memory can only be applied to local playback."
|
||||
msgstr "Het geheugenprofiel kan alleen worden toegepast op afspelen op dit apparaat."
|
||||
|
||||
msgid "Playback is not ready yet."
|
||||
msgstr "Afspelen is nog niet gereed."
|
||||
|
||||
msgid "Playback is busy. Try applying the memory profile again."
|
||||
msgstr "Afspelen is bezig. Probeer het geheugenprofiel opnieuw toe te passen."
|
||||
|
||||
msgid "Playback changed. Apply again to confirm the current track."
|
||||
msgstr "Het afspelen is gewijzigd. Pas opnieuw toe om het huidige nummer te bevestigen."
|
||||
|
||||
msgid "Visual resource profile"
|
||||
msgstr "Profiel voor visuele bronnen"
|
||||
|
||||
msgid "Low uses the Small player, compact headers, no dynamic background, header gradient, spectrum, animated play indicator, sidebar collages or optional track artwork. Default restores these visual defaults. Editing a managed option selects Custom. Audio quality and playback memory are independent."
|
||||
msgstr "Low gebruikt de kleine speler en compacte koppen, zonder dynamische achtergrond, kleurverloop, spectrum, bewegende afspeelindicator, collages of optionele hoesjes per nummer. Default herstelt deze standaardwaarden. Wijzigingen kiezen Custom. Audiokwaliteit en afspeelgeheugen staan hier los van."
|
||||
|
||||
msgid "Compare memory profiles"
|
||||
msgstr "Geheugenprofielen vergelijken"
|
||||
|
||||
msgid "Auto: fixed 400 MiB or 50 MiB according to the computer's RAM; disk handles larger tracks."
|
||||
msgstr "Auto: vast 400 MiB of 50 MiB afhankelijk van het RAM; grotere nummers gebruiken schijfopslag."
|
||||
|
||||
msgid "High: dynamic 400–1600 MiB; more room in RAM for large tracks, with less need for disk-backed buffers."
|
||||
msgstr "High: dynamisch 400–1600 MiB; meer RAM voor grote nummers en minder behoefte aan schijfgebufferde weergave."
|
||||
|
||||
msgid "Desktop: fixed 400 MiB; balances RAM use with disk-backed playback for larger tracks."
|
||||
msgstr "Desktop: vast 400 MiB; verdeelt het gebruik tussen RAM en schijf voor grotere nummers."
|
||||
|
||||
msgid "Low: fixed 50 MiB and reduced prefetch; lower RAM use, with more disk reads and writes for large tracks."
|
||||
msgstr "Low: vast 50 MiB en minder vooraf laden; minder RAM, meer lezen en schrijven op schijf bij grote nummers."
|
||||
|
||||
msgid "Custom: your RAM limits and growth preference determine when playback needs disk storage."
|
||||
msgstr "Custom: je RAM-limieten en groei-instelling bepalen wanneer schijfopslag nodig is."
|
||||
|
||||
msgid "RAM and storage trade-off"
|
||||
msgstr "Afweging tussen RAM en opslag"
|
||||
|
||||
msgid "All profiles preserve audio quality and can use disk cache. Lower memory budgets rely more on storage speed and writes; more RAM can reduce that work. Cached disk tracks are read from disk even in High. Choose storage appropriate for your listening workload."
|
||||
msgstr "Alle profielen behouden de audiokwaliteit en kunnen schijfcache gebruiken. Minder RAM vraagt meer van opslagsnelheid en schrijfwerk; meer RAM kan dit verminderen. Nummers in de schijfcache worden ook bij High van schijf gelezen. Kies opslag die bij je gebruik past."
|
||||
|
||||
msgid "Playback storage folder"
|
||||
msgstr "Opslagmap voor afspelen"
|
||||
|
||||
msgid "Choose an existing folder on the drive you want to use. QBZ stores its playback cache and temporary track buffers in a qbz-playback subfolder. Offline downloads are separate. Leave blank for the default location."
|
||||
msgstr "Kies een bestaande map op het gewenste station. QBZ bewaart afspeelcache en tijdelijke buffers in de submap qbz-playback. Offline downloads staan hier los van. Laat leeg voor de standaardlocatie."
|
||||
|
||||
msgid "Current playback storage"
|
||||
msgstr "Huidige afspeelopslag"
|
||||
|
||||
msgid "Changing the folder takes effect after restarting QBZ. Existing cache files stay in the old location; they are not moved or deleted automatically."
|
||||
msgstr "De nieuwe map wordt na een herstart van QBZ gebruikt. Bestaande cachebestanden blijven op de oude locatie; ze worden niet automatisch verplaatst of verwijderd."
|
||||
|
||||
msgid "Could not use this playback storage folder:"
|
||||
msgstr "Deze afspeelopslagmap kon niet worden gebruikt:"
|
||||
|
||||
msgid "Folder access"
|
||||
msgstr "Maptoegang"
|
||||
|
||||
msgid "If the sandbox blocks this folder, run the command below in a host terminal, restart QBZ, then select the folder again. Only grant access to a folder you intend to use."
|
||||
msgstr "Als de sandbox deze map blokkeert, voer je de onderstaande opdracht uit in een hostterminal, herstart je QBZ en kies je de map opnieuw. Verleen alleen toegang tot de gewenste map."
|
||||
|
||||
msgid "Snap can access supported home folders and removable storage under /mnt, /media or /run/media. For removable storage, connect the interface below and retry. Snap cannot grant arbitrary folder access with a Flatpak-style override."
|
||||
msgstr "Snap heeft toegang tot ondersteunde persoonlijke mappen en verwisselbare opslag onder /mnt, /media of /run/media. Verbind hiervoor de onderstaande interface en probeer opnieuw. Snap kan willekeurige mappen niet via een Flatpak-achtige override toestaan."
|
||||
|
||||
msgid "Choose an absolute folder path for playback storage."
|
||||
msgstr "Kies een absoluut mappad voor afspeelopslag."
|
||||
|
||||
msgid "The folder is accessible and visible in the library."
|
||||
msgstr "De map is toegankelijk en zichtbaar in de bibliotheek."
|
||||
|
||||
msgid "The folder is accessible but hidden by your preference."
|
||||
msgstr "De map is toegankelijk, maar door je instelling verborgen."
|
||||
|
||||
msgid "The folder path no longer exists. Reconnect it, change its path or remove it from the library."
|
||||
msgstr "Het pad bestaat niet meer. Verbind de map opnieuw, wijzig het pad of verwijder de map uit de bibliotheek."
|
||||
|
||||
msgid "A connection failure prevents access to this storage."
|
||||
msgstr "Een verbindingsfout verhindert toegang tot deze opslag."
|
||||
|
||||
msgid "QBZ does not have permission to access this folder."
|
||||
msgstr "QBZ heeft geen toestemming om deze map te openen."
|
||||
|
||||
msgid "The folder could not be accessed. The check timed out or returned another access error."
|
||||
msgstr "De map kon niet worden geopend. De controle duurde te lang of gaf een andere toegangsfout."
|
||||
|
||||
msgid "Folder access is being checked or has not been checked yet."
|
||||
msgstr "De toegang wordt gecontroleerd of is nog niet gecontroleerd."
|
||||
|
||||
msgid "Local folder"
|
||||
msgstr "Lokale map"
|
||||
|
||||
msgid "Access denied"
|
||||
msgstr "Toegang geweigerd"
|
||||
|
||||
msgid "Missing"
|
||||
msgstr "Ontbreekt"
|
||||
|
||||
msgid "Actions"
|
||||
msgstr "Acties"
|
||||
|
||||
msgid "Low"
|
||||
msgstr "Laag"
|
||||
|
||||
msgid "These options do not apply while Streaming only is enabled. Turn it off to choose a playback memory profile and apply changes."
|
||||
msgstr "Deze opties zijn niet van toepassing zolang Alleen streamen is ingeschakeld. Schakel dit uit om een geheugenprofiel voor afspelen te kiezen en wijzigingen toe te passen."
|
||||
|
||||
msgid "Mimics official clients’ streaming behavior with lower memory usage. Skips the playback cache and its protection against network interruptions: QBZ’s cache keeps upcoming audio ready for more reliable playback and instant replay."
|
||||
msgstr "Bootst het streaminggedrag van de officiële clients na met minder geheugengebruik. Slaat de afspeelcache en de bescherming tegen netwerkonderbrekingen over: de cache van QBZ houdt komende audio gereed voor betrouwbaarder afspelen en direct opnieuw afspelen."
|
||||
@@ -7247,3 +7247,241 @@ msgstr "Sistema / manual"
|
||||
#: qbz-qt/qml/shell/UpdatesModal.qml
|
||||
msgid "Update ready. Close QBZ to finish installation. It will reopen automatically."
|
||||
msgstr "Atualização pronta. Feche o QBZ para concluir a instalação. Ele será reaberto automaticamente."
|
||||
|
||||
#. Playback memory cache policy (#774).
|
||||
msgid "PLAYBACK CACHE"
|
||||
msgstr "CACHE DE REPRODUÇÃO"
|
||||
|
||||
#. Playback memory cache policy (#774).
|
||||
msgid "Grow cache when memory is available"
|
||||
msgstr "Expandir o cache quando houver memória disponível"
|
||||
|
||||
#. Playback memory cache policy (#774).
|
||||
msgid "Optional; recommended for long Hi-Res tracks. The fixed budget remains the recommended default."
|
||||
msgstr "Opcional; recomendado para faixas longas em Hi-Res. O limite fixo continua sendo o padrão recomendado."
|
||||
|
||||
#. Playback memory cache policy (#774).
|
||||
msgid "Minimum cache budget (MiB)"
|
||||
msgstr "Limite mínimo do cache (MiB)"
|
||||
|
||||
#. Playback memory cache policy (#774).
|
||||
msgid "Base budget, not reserved RAM. Memory pressure can reduce it. Leave blank for the recommended value."
|
||||
msgstr "Limite base, sem reservar RAM. Pode diminuir se faltar memória. Deixe em branco para usar o valor recomendado."
|
||||
|
||||
#. Playback memory cache policy (#774).
|
||||
msgid "Maximum cache budget (MiB)"
|
||||
msgstr "Limite máximo do cache (MiB)"
|
||||
|
||||
#. Playback memory cache policy (#774).
|
||||
msgid "Growth ceiling; available memory may impose a lower limit. Leave blank for automatic."
|
||||
msgstr "Teto de expansão; a memória disponível pode impor um limite menor. Deixe em branco para automático."
|
||||
|
||||
#. Playback memory cache policy (#774).
|
||||
msgid "Memory cache usage"
|
||||
msgstr "Uso do cache em memória"
|
||||
|
||||
#. Playback memory cache policy (#774).
|
||||
msgid "Used / current budget: %1 / %2 MiB. This excludes decoder memory and offline downloads."
|
||||
msgstr "Uso / limite atual: %1 / %2 MiB. Exclui a memória do decodificador e os downloads offline."
|
||||
|
||||
#. Playback memory cache policy (#774).
|
||||
msgid "Recommended cache defaults"
|
||||
msgstr "Valores padrão recomendados do cache"
|
||||
|
||||
#. Playback memory cache policy (#774).
|
||||
msgid "Restore defaults"
|
||||
msgstr "Restaurar padrões"
|
||||
|
||||
#. Playback memory cache policy (#774).
|
||||
msgid "Playback cache limits must be between 16 and 16384 MiB"
|
||||
msgstr "Os limites do cache de reprodução devem estar entre 16 e 16384 MiB"
|
||||
|
||||
#. Playback memory cache policy (#774).
|
||||
msgid "Minimum playback cache budget must not exceed its maximum"
|
||||
msgstr "O limite mínimo do cache de reprodução não pode exceder o máximo"
|
||||
|
||||
#: qbz-qt/qml/settings/PlaybackSettings.qml
|
||||
msgid "Playback memory profile"
|
||||
msgstr "Perfil de memória de reprodução"
|
||||
|
||||
#: qbz-qt/qml/settings/PlaybackSettings.qml
|
||||
msgid "Auto — recommended"
|
||||
msgstr "Auto — recomendado"
|
||||
|
||||
#: qbz-qt/qml/settings/PlaybackSettings.qml
|
||||
msgid "High — dynamic"
|
||||
msgstr "High — dinâmico"
|
||||
|
||||
#: qbz-qt/qml/settings/PlaybackSettings.qml
|
||||
msgid "Desktop"
|
||||
msgstr "Desktop"
|
||||
|
||||
#: qbz-qt/qml/settings/PlaybackSettings.qml
|
||||
msgid "Low — Pi style"
|
||||
msgstr "Low — estilo Pi"
|
||||
|
||||
#: qbz-qt/qml/settings/PlaybackSettings.qml
|
||||
msgid "Auto selects Desktop or Low from the computer's RAM. Fixed cache budget; dynamic growth is off. Recommended default."
|
||||
msgstr "Auto seleciona Desktop ou Low conforme a RAM do computador. Limite de cache fixo, sem crescimento dinâmico. Padrão recomendado."
|
||||
|
||||
#: qbz-qt/qml/settings/PlaybackSettings.qml
|
||||
msgid "High starts with a 400 MiB cache budget and grows up to 1600 MiB when memory is available. It shrinks after inactivity or memory pressure. For long Hi-Res tracks and computers with spare RAM."
|
||||
msgstr "High começa com um limite de cache de 400 MiB e cresce até 1600 MiB quando há memória disponível. Diminui após inatividade ou pressão de memória. Para faixas Hi-Res longas e computadores com RAM livre."
|
||||
|
||||
#: qbz-qt/qml/settings/PlaybackSettings.qml
|
||||
msgid "Desktop uses a fixed 400 MiB cache budget and normal prefetch. Larger tracks use disk-backed buffers."
|
||||
msgstr "Desktop usa um limite fixo de cache de 400 MiB e pré-carregamento normal. Faixas maiores usam buffers em disco."
|
||||
|
||||
#: qbz-qt/qml/settings/PlaybackSettings.qml
|
||||
msgid "Low uses a 50 MiB cache budget, smaller startup buffers and reduced prefetch. Large tracks use disk; speculative Hi-Res prefetch is skipped. Useful on Raspberry Pi or when saving RAM."
|
||||
msgstr "Low usa um limite de cache de 50 MiB, buffers iniciais menores e menos pré-carregamento. Faixas grandes usam disco; o pré-carregamento especulativo Hi-Res é ignorado. Útil no Raspberry Pi ou para economizar RAM."
|
||||
|
||||
#: qbz-qt/qml/settings/PlaybackSettings.qml
|
||||
msgid "Custom uses your cache limits and growth preference below. Prefetch follows the computer's detected memory class. Editing a preset switches to Custom."
|
||||
msgstr "Personalizado usa os limites de cache e a preferência de crescimento abaixo. O pré-carregamento segue a classe de memória detectada. Editar um perfil muda para Personalizado."
|
||||
|
||||
#: qbz-qt/qml/settings/PlaybackSettings.qml
|
||||
msgid "Same audio quality in every profile"
|
||||
msgstr "A mesma qualidade de áudio em todos os perfis"
|
||||
|
||||
#: qbz-qt/qml/settings/PlaybackSettings.qml
|
||||
msgid "Profiles control playback memory, not audio quality or the whole app's RAM. Disk cache stays enabled unless Streaming only is on. Changes apply to new buffers; the current track continues."
|
||||
msgstr "Os perfis controlam a memória de reprodução, não a qualidade de áudio nem toda a RAM do aplicativo. O cache em disco continua ativo, exceto em Somente streaming. As alterações se aplicam a novos buffers; a faixa atual continua."
|
||||
|
||||
#: qbz-qt/qml/settings/PlaybackSettings.qml
|
||||
msgid "Allow the cache to grow within your maximum when RAM is available. High enables this automatically. Editing this option switches to Custom."
|
||||
msgstr "Permite que o cache cresça até o máximo definido quando há RAM disponível. High ativa isso automaticamente. Editar esta opção muda para Personalizado."
|
||||
|
||||
#: qbz-qt/qml/settings/PlaybackSettings.qml
|
||||
msgid "Unknown playback memory profile"
|
||||
msgstr "Perfil de memória de reprodução desconhecido"
|
||||
|
||||
#: qbz-qt/qml/settings/PlaybackSettings.qml
|
||||
msgid "Playback memory preset conflicts with custom cache limits"
|
||||
msgstr "O perfil de memória de reprodução conflita com os limites de cache personalizados"
|
||||
|
||||
msgid "Apply playback memory profile?"
|
||||
msgstr "Aplicar o perfil de memória de reprodução?"
|
||||
|
||||
msgid "Playback buffers will be released. If playing, the current track will stop and restart from the beginning. If paused, it will stay stopped at 0:00. Your queue and disk cache will be kept."
|
||||
msgstr "Os buffers de reprodução serão liberados. Se estiver tocando, a faixa atual será interrompida e reiniciada do começo. Se estiver pausada, ficará parada em 0:00. A fila e o cache em disco serão mantidos."
|
||||
|
||||
msgid "Apply & free memory"
|
||||
msgstr "Aplicar e liberar memória"
|
||||
|
||||
msgid "Rebuild playback buffers"
|
||||
msgstr "Reconstruir buffers de reprodução"
|
||||
|
||||
msgid "The profile already applies to new buffers. Apply now to release existing playback buffers and the memory cache. Memory use can grow again as playback continues."
|
||||
msgstr "O perfil já se aplica aos novos buffers. Aplique agora para liberar os buffers existentes e o cache em memória. O uso de memória pode voltar a crescer conforme a reprodução continua."
|
||||
|
||||
msgid "Playback memory can only be applied to local playback."
|
||||
msgstr "O perfil de memória só pode ser aplicado à reprodução neste dispositivo."
|
||||
|
||||
msgid "Playback is not ready yet."
|
||||
msgstr "A reprodução ainda não está pronta."
|
||||
|
||||
msgid "Playback is busy. Try applying the memory profile again."
|
||||
msgstr "A reprodução está ocupada. Tente aplicar o perfil de memória novamente."
|
||||
|
||||
msgid "Playback changed. Apply again to confirm the current track."
|
||||
msgstr "A reprodução mudou. Aplique novamente para confirmar a faixa atual."
|
||||
|
||||
msgid "Visual resource profile"
|
||||
msgstr "Perfil de recursos visuais"
|
||||
|
||||
msgid "Low uses the Small player, compact headers, no dynamic background, header gradient, spectrum, animated play indicator, sidebar collages or optional track artwork. Default restores these visual defaults. Editing a managed option selects Custom. Audio quality and playback memory are independent."
|
||||
msgstr "Low usa o player Small e cabeçalhos compactos, sem fundo dinâmico, gradiente, espectro, indicador animado, colagens ou capas opcionais nas faixas. Default restaura esses padrões visuais. Alterações ativam Custom. Qualidade de áudio e memória de reprodução são independentes."
|
||||
|
||||
msgid "Compare memory profiles"
|
||||
msgstr "Comparar perfis de memória"
|
||||
|
||||
msgid "Auto: fixed 400 MiB or 50 MiB according to the computer's RAM; disk handles larger tracks."
|
||||
msgstr "Auto: 400 MiB ou 50 MiB fixos conforme a RAM; faixas maiores usam o disco."
|
||||
|
||||
msgid "High: dynamic 400–1600 MiB; more room in RAM for large tracks, with less need for disk-backed buffers."
|
||||
msgstr "High: 400–1600 MiB dinâmicos; mais RAM para faixas grandes e menor necessidade de buffers em disco."
|
||||
|
||||
msgid "Desktop: fixed 400 MiB; balances RAM use with disk-backed playback for larger tracks."
|
||||
msgstr "Desktop: 400 MiB fixos; equilibra RAM e reprodução em disco para faixas maiores."
|
||||
|
||||
msgid "Low: fixed 50 MiB and reduced prefetch; lower RAM use, with more disk reads and writes for large tracks."
|
||||
msgstr "Low: 50 MiB fixos e menos pré-carregamento; menos RAM, com mais leituras e gravações em disco para faixas grandes."
|
||||
|
||||
msgid "Custom: your RAM limits and growth preference determine when playback needs disk storage."
|
||||
msgstr "Custom: seus limites de RAM e preferência de crescimento determinam quando a reprodução usa o disco."
|
||||
|
||||
msgid "RAM and storage trade-off"
|
||||
msgstr "Equilíbrio entre RAM e armazenamento"
|
||||
|
||||
msgid "All profiles preserve audio quality and can use disk cache. Lower memory budgets rely more on storage speed and writes; more RAM can reduce that work. Cached disk tracks are read from disk even in High. Choose storage appropriate for your listening workload."
|
||||
msgstr "Todos os perfis preservam a qualidade e podem usar cache em disco. Menos RAM implica maior dependência da velocidade e das gravações no armazenamento; mais RAM pode reduzir esse trabalho. Faixas já no cache em disco são lidas dele mesmo em High. Escolha uma unidade adequada ao seu uso."
|
||||
|
||||
msgid "Playback storage folder"
|
||||
msgstr "Pasta de armazenamento de reprodução"
|
||||
|
||||
msgid "Choose an existing folder on the drive you want to use. QBZ stores its playback cache and temporary track buffers in a qbz-playback subfolder. Offline downloads are separate. Leave blank for the default location."
|
||||
msgstr "Escolha uma pasta existente na unidade desejada. QBZ armazena o cache de reprodução e buffers temporários na subpasta qbz-playback. Downloads offline são separados. Deixe vazio para usar o local padrão."
|
||||
|
||||
msgid "Current playback storage"
|
||||
msgstr "Armazenamento atual de reprodução"
|
||||
|
||||
msgid "Changing the folder takes effect after restarting QBZ. Existing cache files stay in the old location; they are not moved or deleted automatically."
|
||||
msgstr "A nova pasta será usada após reiniciar QBZ. Arquivos de cache existentes ficam no local anterior; não são movidos nem excluídos automaticamente."
|
||||
|
||||
msgid "Could not use this playback storage folder:"
|
||||
msgstr "Não foi possível usar esta pasta de reprodução:"
|
||||
|
||||
msgid "Folder access"
|
||||
msgstr "Acesso à pasta"
|
||||
|
||||
msgid "If the sandbox blocks this folder, run the command below in a host terminal, restart QBZ, then select the folder again. Only grant access to a folder you intend to use."
|
||||
msgstr "Se o sandbox bloquear a pasta, execute o comando abaixo em um terminal do sistema, reinicie QBZ e selecione a pasta novamente. Dê acesso apenas à pasta desejada."
|
||||
|
||||
msgid "Snap can access supported home folders and removable storage under /mnt, /media or /run/media. For removable storage, connect the interface below and retry. Snap cannot grant arbitrary folder access with a Flatpak-style override."
|
||||
msgstr "Snap acessa pastas pessoais compatíveis e mídias em /mnt, /media ou /run/media. Para essas mídias, conecte a interface abaixo e tente novamente. Snap não libera pastas arbitrárias com override como Flatpak."
|
||||
|
||||
msgid "Choose an absolute folder path for playback storage."
|
||||
msgstr "Escolha um caminho absoluto para o armazenamento de reprodução."
|
||||
|
||||
msgid "The folder is accessible and visible in the library."
|
||||
msgstr "A pasta está acessível e visível na biblioteca."
|
||||
|
||||
msgid "The folder is accessible but hidden by your preference."
|
||||
msgstr "A pasta está acessível, mas oculta pela sua preferência."
|
||||
|
||||
msgid "The folder path no longer exists. Reconnect it, change its path or remove it from the library."
|
||||
msgstr "O caminho não existe mais. Reconecte a pasta, altere o caminho ou remova-a da biblioteca."
|
||||
|
||||
msgid "A connection failure prevents access to this storage."
|
||||
msgstr "Uma falha de conexão impede o acesso a este armazenamento."
|
||||
|
||||
msgid "QBZ does not have permission to access this folder."
|
||||
msgstr "QBZ não tem permissão para acessar esta pasta."
|
||||
|
||||
msgid "The folder could not be accessed. The check timed out or returned another access error."
|
||||
msgstr "Não foi possível acessar a pasta. A verificação expirou ou retornou outro erro de acesso."
|
||||
|
||||
msgid "Folder access is being checked or has not been checked yet."
|
||||
msgstr "O acesso está sendo verificado ou ainda está pendente."
|
||||
|
||||
msgid "Local folder"
|
||||
msgstr "Pasta local"
|
||||
|
||||
msgid "Access denied"
|
||||
msgstr "Acesso negado"
|
||||
|
||||
msgid "Missing"
|
||||
msgstr "Ausente"
|
||||
|
||||
msgid "Actions"
|
||||
msgstr "Ações"
|
||||
|
||||
msgid "Low"
|
||||
msgstr "Baixo"
|
||||
|
||||
msgid "These options do not apply while Streaming only is enabled. Turn it off to choose a playback memory profile and apply changes."
|
||||
msgstr "Estas opções não se aplicam enquanto Apenas streaming estiver ativado. Desative-o para escolher um perfil de memória de reprodução e aplicar as alterações."
|
||||
|
||||
msgid "Mimics official clients’ streaming behavior with lower memory usage. Skips the playback cache and its protection against network interruptions: QBZ’s cache keeps upcoming audio ready for more reliable playback and instant replay."
|
||||
msgstr "Reproduz o comportamento de streaming dos clientes oficiais com menor uso de memória. Ignora o cache de reprodução e sua proteção contra interrupções de rede: o cache do QBZ prepara o próximo áudio para uma reprodução mais confiável e repetição imediata."
|
||||
@@ -7268,3 +7268,241 @@ msgstr "Системная / ручная"
|
||||
#: qbz-qt/qml/shell/UpdatesModal.qml
|
||||
msgid "Update ready. Close QBZ to finish installation. It will reopen automatically."
|
||||
msgstr "Обновление готово. Закройте QBZ для завершения установки. Приложение откроется автоматически."
|
||||
|
||||
#. Playback memory cache policy (#774).
|
||||
msgid "PLAYBACK CACHE"
|
||||
msgstr "КЭШ ВОСПРОИЗВЕДЕНИЯ"
|
||||
|
||||
#. Playback memory cache policy (#774).
|
||||
msgid "Grow cache when memory is available"
|
||||
msgstr "Увеличивать кэш при наличии свободной памяти"
|
||||
|
||||
#. Playback memory cache policy (#774).
|
||||
msgid "Optional; recommended for long Hi-Res tracks. The fixed budget remains the recommended default."
|
||||
msgstr "Необязательно; рекомендуется для длинных треков Hi-Res. Фиксированный лимит остаётся рекомендуемым значением по умолчанию."
|
||||
|
||||
#. Playback memory cache policy (#774).
|
||||
msgid "Minimum cache budget (MiB)"
|
||||
msgstr "Минимальный лимит кэша (МиБ)"
|
||||
|
||||
#. Playback memory cache policy (#774).
|
||||
msgid "Base budget, not reserved RAM. Memory pressure can reduce it. Leave blank for the recommended value."
|
||||
msgstr "Базовый лимит без резервирования ОЗУ. Может снижаться при нехватке памяти. Оставьте пустым для рекомендуемого значения."
|
||||
|
||||
#. Playback memory cache policy (#774).
|
||||
msgid "Maximum cache budget (MiB)"
|
||||
msgstr "Максимальный лимит кэша (МиБ)"
|
||||
|
||||
#. Playback memory cache policy (#774).
|
||||
msgid "Growth ceiling; available memory may impose a lower limit. Leave blank for automatic."
|
||||
msgstr "Предел роста; доступная память может ограничить его сильнее. Оставьте пустым для автоматического выбора."
|
||||
|
||||
#. Playback memory cache policy (#774).
|
||||
msgid "Memory cache usage"
|
||||
msgstr "Использование кэша в памяти"
|
||||
|
||||
#. Playback memory cache policy (#774).
|
||||
msgid "Used / current budget: %1 / %2 MiB. This excludes decoder memory and offline downloads."
|
||||
msgstr "Использовано / текущий лимит: %1 / %2 МиБ. Без памяти декодера и офлайн-загрузок."
|
||||
|
||||
#. Playback memory cache policy (#774).
|
||||
msgid "Recommended cache defaults"
|
||||
msgstr "Рекомендуемые настройки кэша"
|
||||
|
||||
#. Playback memory cache policy (#774).
|
||||
msgid "Restore defaults"
|
||||
msgstr "Восстановить значения по умолчанию"
|
||||
|
||||
#. Playback memory cache policy (#774).
|
||||
msgid "Playback cache limits must be between 16 and 16384 MiB"
|
||||
msgstr "Лимиты кэша воспроизведения должны быть от 16 до 16384 МиБ"
|
||||
|
||||
#. Playback memory cache policy (#774).
|
||||
msgid "Minimum playback cache budget must not exceed its maximum"
|
||||
msgstr "Минимальный лимит кэша воспроизведения не может превышать максимальный"
|
||||
|
||||
#: qbz-qt/qml/settings/PlaybackSettings.qml
|
||||
msgid "Playback memory profile"
|
||||
msgstr "Профиль памяти воспроизведения"
|
||||
|
||||
#: qbz-qt/qml/settings/PlaybackSettings.qml
|
||||
msgid "Auto — recommended"
|
||||
msgstr "Auto — рекомендуется"
|
||||
|
||||
#: qbz-qt/qml/settings/PlaybackSettings.qml
|
||||
msgid "High — dynamic"
|
||||
msgstr "High — динамический"
|
||||
|
||||
#: qbz-qt/qml/settings/PlaybackSettings.qml
|
||||
msgid "Desktop"
|
||||
msgstr "Desktop"
|
||||
|
||||
#: qbz-qt/qml/settings/PlaybackSettings.qml
|
||||
msgid "Low — Pi style"
|
||||
msgstr "Low — режим Pi"
|
||||
|
||||
#: qbz-qt/qml/settings/PlaybackSettings.qml
|
||||
msgid "Auto selects Desktop or Low from the computer's RAM. Fixed cache budget; dynamic growth is off. Recommended default."
|
||||
msgstr "Auto выбирает Desktop или Low по объёму ОЗУ компьютера. Фиксированный бюджет кэша без динамического роста. Рекомендуемая настройка по умолчанию."
|
||||
|
||||
#: qbz-qt/qml/settings/PlaybackSettings.qml
|
||||
msgid "High starts with a 400 MiB cache budget and grows up to 1600 MiB when memory is available. It shrinks after inactivity or memory pressure. For long Hi-Res tracks and computers with spare RAM."
|
||||
msgstr "High начинает с бюджета кэша 400 МиБ и увеличивает его до 1600 МиБ при наличии свободной памяти. После простоя или при нехватке памяти бюджет уменьшается. Для длинных треков Hi-Res и компьютеров со свободной ОЗУ."
|
||||
|
||||
#: qbz-qt/qml/settings/PlaybackSettings.qml
|
||||
msgid "Desktop uses a fixed 400 MiB cache budget and normal prefetch. Larger tracks use disk-backed buffers."
|
||||
msgstr "Desktop использует фиксированный бюджет кэша 400 МиБ и обычную предзагрузку. Более крупные треки используют дисковые буферы."
|
||||
|
||||
#: qbz-qt/qml/settings/PlaybackSettings.qml
|
||||
msgid "Low uses a 50 MiB cache budget, smaller startup buffers and reduced prefetch. Large tracks use disk; speculative Hi-Res prefetch is skipped. Useful on Raspberry Pi or when saving RAM."
|
||||
msgstr "Low использует бюджет кэша 50 МиБ, меньшие стартовые буферы и сокращённую предзагрузку. Большие треки используют диск; упреждающая загрузка Hi-Res пропускается. Полезно на Raspberry Pi или для экономии ОЗУ."
|
||||
|
||||
#: qbz-qt/qml/settings/PlaybackSettings.qml
|
||||
msgid "Custom uses your cache limits and growth preference below. Prefetch follows the computer's detected memory class. Editing a preset switches to Custom."
|
||||
msgstr "Свой профиль использует указанные ниже лимиты кэша и настройку роста. Предзагрузка зависит от определённого класса памяти компьютера. Изменение готового профиля включает свой профиль."
|
||||
|
||||
#: qbz-qt/qml/settings/PlaybackSettings.qml
|
||||
msgid "Same audio quality in every profile"
|
||||
msgstr "Одинаковое качество звука во всех профилях"
|
||||
|
||||
#: qbz-qt/qml/settings/PlaybackSettings.qml
|
||||
msgid "Profiles control playback memory, not audio quality or the whole app's RAM. Disk cache stays enabled unless Streaming only is on. Changes apply to new buffers; the current track continues."
|
||||
msgstr "Профили управляют памятью воспроизведения, а не качеством звука или всей ОЗУ приложения. Дисковый кэш работает, кроме режима Только потоковая передача. Изменения действуют на новые буферы; текущий трек продолжает играть."
|
||||
|
||||
#: qbz-qt/qml/settings/PlaybackSettings.qml
|
||||
msgid "Allow the cache to grow within your maximum when RAM is available. High enables this automatically. Editing this option switches to Custom."
|
||||
msgstr "Разрешить рост кэша до заданного максимума при наличии свободной ОЗУ. High включает это автоматически. Изменение этой настройки включает свой профиль."
|
||||
|
||||
#: qbz-qt/qml/settings/PlaybackSettings.qml
|
||||
msgid "Unknown playback memory profile"
|
||||
msgstr "Неизвестный профиль памяти воспроизведения"
|
||||
|
||||
#: qbz-qt/qml/settings/PlaybackSettings.qml
|
||||
msgid "Playback memory preset conflicts with custom cache limits"
|
||||
msgstr "Профиль памяти воспроизведения несовместим с заданными лимитами кэша"
|
||||
|
||||
msgid "Apply playback memory profile?"
|
||||
msgstr "Применить профиль памяти воспроизведения?"
|
||||
|
||||
msgid "Playback buffers will be released. If playing, the current track will stop and restart from the beginning. If paused, it will stay stopped at 0:00. Your queue and disk cache will be kept."
|
||||
msgstr "Буферы воспроизведения будут освобождены. Если трек воспроизводится, он остановится и начнётся сначала. Если он на паузе, он останется остановленным на 0:00. Очередь и дисковый кэш сохранятся."
|
||||
|
||||
msgid "Apply & free memory"
|
||||
msgstr "Применить и освободить память"
|
||||
|
||||
msgid "Rebuild playback buffers"
|
||||
msgstr "Пересоздать буферы воспроизведения"
|
||||
|
||||
msgid "The profile already applies to new buffers. Apply now to release existing playback buffers and the memory cache. Memory use can grow again as playback continues."
|
||||
msgstr "Профиль уже применяется к новым буферам. Примените его сейчас, чтобы освободить существующие буферы и кэш в памяти. При продолжении воспроизведения расход памяти может снова вырасти."
|
||||
|
||||
msgid "Playback memory can only be applied to local playback."
|
||||
msgstr "Профиль памяти можно применить только к воспроизведению на этом устройстве."
|
||||
|
||||
msgid "Playback is not ready yet."
|
||||
msgstr "Воспроизведение ещё не готово."
|
||||
|
||||
msgid "Playback is busy. Try applying the memory profile again."
|
||||
msgstr "Воспроизведение занято. Попробуйте применить профиль памяти ещё раз."
|
||||
|
||||
msgid "Playback changed. Apply again to confirm the current track."
|
||||
msgstr "Воспроизведение изменилось. Примените снова, чтобы подтвердить текущий трек."
|
||||
|
||||
msgid "Visual resource profile"
|
||||
msgstr "Профиль визуальных ресурсов"
|
||||
|
||||
msgid "Low uses the Small player, compact headers, no dynamic background, header gradient, spectrum, animated play indicator, sidebar collages or optional track artwork. Default restores these visual defaults. Editing a managed option selects Custom. Audio quality and playback memory are independent."
|
||||
msgstr "Low использует маленький плеер и компактные заголовки, отключает динамический фон, градиент, спектр, анимацию воспроизведения, коллажи и дополнительные обложки треков. Default восстанавливает эти настройки. Их изменение выбирает Custom. Качество звука и память воспроизведения независимы."
|
||||
|
||||
msgid "Compare memory profiles"
|
||||
msgstr "Сравнение профилей памяти"
|
||||
|
||||
msgid "Auto: fixed 400 MiB or 50 MiB according to the computer's RAM; disk handles larger tracks."
|
||||
msgstr "Auto: фиксированные 400 MiB или 50 MiB в зависимости от ОЗУ; крупные треки используют диск."
|
||||
|
||||
msgid "High: dynamic 400–1600 MiB; more room in RAM for large tracks, with less need for disk-backed buffers."
|
||||
msgstr "High: динамические 400–1600 MiB; больше ОЗУ для крупных треков, меньше потребность в дисковых буферах."
|
||||
|
||||
msgid "Desktop: fixed 400 MiB; balances RAM use with disk-backed playback for larger tracks."
|
||||
msgstr "Desktop: фиксированные 400 MiB; баланс ОЗУ и дискового воспроизведения крупных треков."
|
||||
|
||||
msgid "Low: fixed 50 MiB and reduced prefetch; lower RAM use, with more disk reads and writes for large tracks."
|
||||
msgstr "Low: фиксированные 50 MiB и сокращённая предзагрузка; меньше ОЗУ, больше чтения и записи на диск для крупных треков."
|
||||
|
||||
msgid "Custom: your RAM limits and growth preference determine when playback needs disk storage."
|
||||
msgstr "Custom: ваши пределы ОЗУ и настройки роста определяют, когда нужен диск."
|
||||
|
||||
msgid "RAM and storage trade-off"
|
||||
msgstr "Баланс ОЗУ и накопителя"
|
||||
|
||||
msgid "All profiles preserve audio quality and can use disk cache. Lower memory budgets rely more on storage speed and writes; more RAM can reduce that work. Cached disk tracks are read from disk even in High. Choose storage appropriate for your listening workload."
|
||||
msgstr "Все профили сохраняют качество звука и могут использовать дисковый кэш. Меньше ОЗУ означает большую зависимость от скорости накопителя и больше записей; дополнительная ОЗУ может снизить эту нагрузку. Треки в дисковом кэше читаются с диска даже в High. Выбирайте накопитель с учётом своего использования."
|
||||
|
||||
msgid "Playback storage folder"
|
||||
msgstr "Папка хранения воспроизведения"
|
||||
|
||||
msgid "Choose an existing folder on the drive you want to use. QBZ stores its playback cache and temporary track buffers in a qbz-playback subfolder. Offline downloads are separate. Leave blank for the default location."
|
||||
msgstr "Выберите существующую папку на нужном диске. QBZ хранит кэш и временные буферы в подпапке qbz-playback. Офлайн-загрузки хранятся отдельно. Оставьте поле пустым для стандартного пути."
|
||||
|
||||
msgid "Current playback storage"
|
||||
msgstr "Текущее хранилище воспроизведения"
|
||||
|
||||
msgid "Changing the folder takes effect after restarting QBZ. Existing cache files stay in the old location; they are not moved or deleted automatically."
|
||||
msgstr "Новая папка будет использоваться после перезапуска QBZ. Старые файлы кэша останутся на прежнем месте; они не перемещаются и не удаляются автоматически."
|
||||
|
||||
msgid "Could not use this playback storage folder:"
|
||||
msgstr "Не удалось использовать эту папку воспроизведения:"
|
||||
|
||||
msgid "Folder access"
|
||||
msgstr "Доступ к папке"
|
||||
|
||||
msgid "If the sandbox blocks this folder, run the command below in a host terminal, restart QBZ, then select the folder again. Only grant access to a folder you intend to use."
|
||||
msgstr "Если песочница блокирует папку, выполните команду ниже в терминале основной системы, перезапустите QBZ и выберите папку снова. Разрешайте доступ только к нужной папке."
|
||||
|
||||
msgid "Snap can access supported home folders and removable storage under /mnt, /media or /run/media. For removable storage, connect the interface below and retry. Snap cannot grant arbitrary folder access with a Flatpak-style override."
|
||||
msgstr "Snap может обращаться к поддерживаемым домашним папкам и накопителям в /mnt, /media или /run/media. Для накопителей подключите указанный интерфейс и повторите попытку. Snap не разрешает произвольные папки через override, как Flatpak."
|
||||
|
||||
msgid "Choose an absolute folder path for playback storage."
|
||||
msgstr "Укажите абсолютный путь к папке воспроизведения."
|
||||
|
||||
msgid "The folder is accessible and visible in the library."
|
||||
msgstr "Папка доступна и видна в библиотеке."
|
||||
|
||||
msgid "The folder is accessible but hidden by your preference."
|
||||
msgstr "Папка доступна, но скрыта по вашей настройке."
|
||||
|
||||
msgid "The folder path no longer exists. Reconnect it, change its path or remove it from the library."
|
||||
msgstr "Путь больше не существует. Подключите папку, измените путь или удалите её из библиотеки."
|
||||
|
||||
msgid "A connection failure prevents access to this storage."
|
||||
msgstr "Ошибка соединения препятствует доступу к накопителю."
|
||||
|
||||
msgid "QBZ does not have permission to access this folder."
|
||||
msgstr "У QBZ нет разрешения на доступ к этой папке."
|
||||
|
||||
msgid "The folder could not be accessed. The check timed out or returned another access error."
|
||||
msgstr "Не удалось обратиться к папке. Проверка превысила время ожидания или вернула другую ошибку доступа."
|
||||
|
||||
msgid "Folder access is being checked or has not been checked yet."
|
||||
msgstr "Доступ проверяется или ещё не был проверен."
|
||||
|
||||
msgid "Local folder"
|
||||
msgstr "Локальная папка"
|
||||
|
||||
msgid "Access denied"
|
||||
msgstr "Доступ запрещён"
|
||||
|
||||
msgid "Missing"
|
||||
msgstr "Отсутствует"
|
||||
|
||||
msgid "Actions"
|
||||
msgstr "Действия"
|
||||
|
||||
msgid "Low"
|
||||
msgstr "Низкий"
|
||||
|
||||
msgid "These options do not apply while Streaming only is enabled. Turn it off to choose a playback memory profile and apply changes."
|
||||
msgstr "Эти параметры не применяются, пока включён режим «Только потоковое воспроизведение». Отключите его, чтобы выбрать профиль памяти воспроизведения и применить изменения."
|
||||
|
||||
msgid "Mimics official clients’ streaming behavior with lower memory usage. Skips the playback cache and its protection against network interruptions: QBZ’s cache keeps upcoming audio ready for more reliable playback and instant replay."
|
||||
msgstr "Повторяет поведение потокового воспроизведения официальных клиентов, используя меньше памяти. Отключает кэш воспроизведения и его защиту от перебоев сети: кэш QBZ заранее подготавливает аудио для более надёжного воспроизведения и мгновенного повтора."
|
||||
@@ -46,6 +46,7 @@ mod remote_tag_sidecar;
|
||||
mod sacd;
|
||||
pub mod sacd_scan;
|
||||
mod scan;
|
||||
mod scan_changes;
|
||||
mod scanner;
|
||||
pub mod search;
|
||||
pub mod service;
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
//! Connection-local change tracking for a scan. Scan checkpoints, generations
|
||||
//! and last-scan timestamps are not changes to the browsable catalog.
|
||||
//!
|
||||
//! TEMP triggers observe committed track writes, including CUE/SACD imports and
|
||||
//! partial scans. The flag rolls back with a failed batch. No persistent schema
|
||||
//! changes, full-library copies or hashes are needed for an unchanged scan.
|
||||
|
||||
use rusqlite::Connection;
|
||||
|
||||
pub(crate) fn begin(connection: &Connection) -> rusqlite::Result<()> {
|
||||
let mut columns = connection.prepare("PRAGMA main.table_info(local_tracks)")?;
|
||||
let changed_columns = columns
|
||||
.query_map([], |row| row.get::<_, String>(1))?
|
||||
.map(|column| {
|
||||
column.map(|name| {
|
||||
let quoted = name.replace('"', "\"\"");
|
||||
format!("OLD.\"{quoted}\" IS NOT NEW.\"{quoted}\"")
|
||||
})
|
||||
})
|
||||
.collect::<rusqlite::Result<Vec<_>>>()?
|
||||
.join(" OR ");
|
||||
connection.execute_batch(&format!(
|
||||
"CREATE TEMP TABLE qbz_scan_changes (changed INTEGER NOT NULL);
|
||||
INSERT INTO qbz_scan_changes VALUES (0);
|
||||
CREATE TEMP TRIGGER qbz_scan_insert AFTER INSERT ON main.local_tracks
|
||||
BEGIN UPDATE qbz_scan_changes SET changed=1 WHERE changed=0; END;
|
||||
CREATE TEMP TRIGGER qbz_scan_delete AFTER DELETE ON main.local_tracks
|
||||
BEGIN UPDATE qbz_scan_changes SET changed=1 WHERE changed=0; END;
|
||||
CREATE TEMP TRIGGER qbz_scan_update AFTER UPDATE ON main.local_tracks
|
||||
WHEN {changed_columns}
|
||||
BEGIN UPDATE qbz_scan_changes SET changed=1 WHERE changed=0; END;"
|
||||
))
|
||||
}
|
||||
|
||||
pub(crate) fn changed(connection: &Connection) -> rusqlite::Result<bool> {
|
||||
connection.query_row("SELECT changed FROM temp.qbz_scan_changes", [], |row| row.get(0))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn bookkeeping_and_rolled_back_batches_do_not_invalidate_content() {
|
||||
let conn = Connection::open_in_memory().unwrap();
|
||||
conn.execute_batch(
|
||||
"CREATE TABLE local_tracks(id INTEGER PRIMARY KEY, title TEXT, artwork_path TEXT);
|
||||
CREATE TABLE local_scan_roots(generation INTEGER);
|
||||
INSERT INTO local_tracks VALUES(1,'Dogs',NULL);"
|
||||
).unwrap();
|
||||
begin(&conn).unwrap();
|
||||
conn.execute_batch(
|
||||
"INSERT INTO local_scan_roots VALUES(104);
|
||||
UPDATE local_scan_roots SET generation=105;
|
||||
UPDATE local_tracks SET title=title, artwork_path=NULL;
|
||||
BEGIN; DELETE FROM local_tracks; ROLLBACK;"
|
||||
).unwrap();
|
||||
assert!(!changed(&conn).unwrap());
|
||||
conn.execute_batch("UPDATE local_tracks SET artwork_path='cover.jpg'").unwrap();
|
||||
assert!(changed(&conn).unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inserts_deletes_and_committed_partial_imports_invalidate_content() {
|
||||
for mutation in [
|
||||
"INSERT INTO local_tracks VALUES(2,'Sheep')",
|
||||
"DELETE FROM local_tracks",
|
||||
"UPDATE local_tracks SET title='Dogs (Remix)'",
|
||||
] {
|
||||
let conn = Connection::open_in_memory().unwrap();
|
||||
conn.execute_batch(
|
||||
"CREATE TABLE local_tracks(id INTEGER PRIMARY KEY, title TEXT);
|
||||
INSERT INTO local_tracks VALUES(1,'Dogs');"
|
||||
).unwrap();
|
||||
begin(&conn).unwrap();
|
||||
conn.execute_batch(mutation).unwrap();
|
||||
conn.execute_batch("BEGIN; DELETE FROM local_tracks; ROLLBACK;").unwrap();
|
||||
assert!(changed(&conn).unwrap(), "{mutation}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,7 @@
|
||||
//! is cooperative: call `close` immediately, then `shutdown` on a blocking thread
|
||||
//! to join (filesystem calls on an unavailable mount may take time to return).
|
||||
|
||||
use std::collections::BTreeSet;
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::{Arc, Condvar, Mutex};
|
||||
@@ -42,6 +42,7 @@ pub enum ScanOutcome {
|
||||
#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize)]
|
||||
pub struct ScanCompletion {
|
||||
pub job: u64,
|
||||
pub background: bool,
|
||||
pub outcome: ScanOutcome,
|
||||
pub skipped: usize,
|
||||
}
|
||||
@@ -50,44 +51,62 @@ pub struct ScanCompletion {
|
||||
pub struct LibrarySnapshot {
|
||||
pub progress: ScanProgress,
|
||||
pub last_scan: Option<ScanCompletion>,
|
||||
/// Keep a manual result available even if background work finishes between UI polls.
|
||||
#[serde(skip_serializing)]
|
||||
pub last_requested_scan: Option<ScanCompletion>,
|
||||
/// Changes after each scan, even a partial/cancelled one: committed batches
|
||||
/// remain authoritative. This is an invalidation signal, not a DB cursor.
|
||||
pub revision: u64,
|
||||
/// Changes only when scans write catalog rows (or a folder is registered).
|
||||
/// Completion/progress still advances for an unchanged or unavailable root.
|
||||
pub content_revision: u64,
|
||||
pub queued: usize,
|
||||
pub closed: bool,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct PendingScans {
|
||||
all: bool,
|
||||
folders: BTreeSet<i64>,
|
||||
all: Option<bool>,
|
||||
folders: BTreeMap<i64, bool>,
|
||||
}
|
||||
|
||||
impl PendingScans {
|
||||
fn push(&mut self, id: Option<i64>) {
|
||||
self.push_with_background(id, false);
|
||||
}
|
||||
|
||||
fn push_with_background(&mut self, id: Option<i64>, background: bool) {
|
||||
match id {
|
||||
None => {
|
||||
self.all = true;
|
||||
self.all = Some(
|
||||
background
|
||||
&& self.all.unwrap_or(true)
|
||||
&& self.folders.values().all(|background| *background),
|
||||
);
|
||||
self.folders.clear();
|
||||
}
|
||||
Some(id) if !self.all => {
|
||||
self.folders.insert(id);
|
||||
Some(id) if self.all.is_none() => {
|
||||
self.folders
|
||||
.entry(id)
|
||||
.and_modify(|old| *old &= background)
|
||||
.or_insert(background);
|
||||
}
|
||||
Some(_) => {}
|
||||
Some(_) => *self.all.as_mut().unwrap() &= background,
|
||||
}
|
||||
}
|
||||
|
||||
fn pop(&mut self) -> Option<Option<i64>> {
|
||||
if self.all {
|
||||
self.all = false;
|
||||
Some(None)
|
||||
fn pop(&mut self) -> Option<(Option<i64>, bool)> {
|
||||
if let Some(background) = self.all.take() {
|
||||
Some((None, background))
|
||||
} else {
|
||||
self.folders.pop_first().map(Some)
|
||||
self.folders
|
||||
.pop_first()
|
||||
.map(|(id, background)| (Some(id), background))
|
||||
}
|
||||
}
|
||||
|
||||
fn len(&self) -> usize {
|
||||
usize::from(self.all) + self.folders.len()
|
||||
usize::from(self.all.is_some()) + self.folders.len()
|
||||
}
|
||||
fn clear(&mut self) {
|
||||
*self = Self::default();
|
||||
@@ -99,6 +118,16 @@ struct State {
|
||||
snapshot: LibrarySnapshot,
|
||||
pending: PendingScans,
|
||||
source_base: u32,
|
||||
background: bool,
|
||||
}
|
||||
|
||||
impl State {
|
||||
fn record_completion(&mut self, completion: ScanCompletion) {
|
||||
if !completion.background {
|
||||
self.snapshot.last_requested_scan = Some(completion.clone());
|
||||
}
|
||||
self.snapshot.last_scan = Some(completion);
|
||||
}
|
||||
}
|
||||
|
||||
struct Shared {
|
||||
@@ -171,12 +200,30 @@ impl LibraryService {
|
||||
/// Returns false after close. The queue and running flag change under the
|
||||
/// same lock, so a request arriving at worker completion cannot be lost.
|
||||
pub fn scan(&self, folder: Option<i64>) -> Result<bool, LibraryError> {
|
||||
self.scan_with_background(folder, false)
|
||||
}
|
||||
|
||||
/// Maintenance keeps the same progress and invalidation contract, without
|
||||
/// presenting its completion as a scan requested by the user.
|
||||
pub fn scan_background(&self, folder: Option<i64>) -> Result<bool, LibraryError> {
|
||||
self.scan_with_background(folder, true)
|
||||
}
|
||||
|
||||
fn scan_with_background(
|
||||
&self,
|
||||
folder: Option<i64>,
|
||||
background: bool,
|
||||
) -> Result<bool, LibraryError> {
|
||||
let mut worker = self.worker.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let mut state = self.shared.state.lock().unwrap_or_else(|e| e.into_inner());
|
||||
if state.snapshot.closed {
|
||||
return Ok(false);
|
||||
}
|
||||
state.pending.push(folder);
|
||||
if background {
|
||||
state.pending.push_with_background(folder, true);
|
||||
} else {
|
||||
state.pending.push(folder);
|
||||
}
|
||||
if state.snapshot.progress.running {
|
||||
return Ok(true);
|
||||
}
|
||||
@@ -195,12 +242,16 @@ impl LibraryService {
|
||||
if result.is_err() {
|
||||
let mut state = shared.state.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let job = state.snapshot.progress.job;
|
||||
state.snapshot.last_scan = Some(ScanCompletion {
|
||||
let background = state.background;
|
||||
state.record_completion(ScanCompletion {
|
||||
job,
|
||||
background,
|
||||
outcome: ScanOutcome::Failed,
|
||||
skipped: 0,
|
||||
});
|
||||
state.snapshot.revision += 1;
|
||||
// A panic may follow an already committed batch.
|
||||
state.snapshot.content_revision += 1;
|
||||
state.snapshot.progress.running = false;
|
||||
state.pending.clear();
|
||||
shared.idle.notify_all();
|
||||
@@ -268,12 +319,12 @@ impl LibraryService {
|
||||
.get_folder_by_id(folder_id)?
|
||||
.ok_or_else(|| LibraryError::Other("registered folder missing".into()))?;
|
||||
if added {
|
||||
self.shared
|
||||
let mut state = self.shared
|
||||
.state
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner())
|
||||
.snapshot
|
||||
.revision += 1;
|
||||
.unwrap_or_else(|e| e.into_inner());
|
||||
state.snapshot.revision += 1;
|
||||
state.snapshot.content_revision += 1;
|
||||
}
|
||||
Ok(folder)
|
||||
}
|
||||
@@ -344,7 +395,7 @@ fn run(shared: &Shared) {
|
||||
} else {
|
||||
state.pending.pop()
|
||||
};
|
||||
let Some(next) = next else {
|
||||
let Some((next, background)) = next else {
|
||||
state.snapshot.progress.running = false;
|
||||
state.snapshot.progress.file.clear();
|
||||
shared.idle.notify_all();
|
||||
@@ -357,11 +408,13 @@ fn run(shared: &Shared) {
|
||||
..Default::default()
|
||||
};
|
||||
state.source_base = 0;
|
||||
state.background = background;
|
||||
// Same lock as cancellation: no request can reset a cancellation
|
||||
// intended for the already-started job.
|
||||
shared.cancel.store(false, Ordering::Release);
|
||||
next
|
||||
};
|
||||
let mut content_changed = true;
|
||||
let result = shared.store.open_or_create().and_then(|db| {
|
||||
let ids = folder.map(|id| vec![id]);
|
||||
let count = db
|
||||
@@ -377,25 +430,35 @@ fn run(shared: &Shared) {
|
||||
.progress
|
||||
.source_count = count.min(u32::MAX as usize) as u32;
|
||||
std::fs::create_dir_all(&shared.artwork)?;
|
||||
crate::scan_with_progress(
|
||||
db.with_connection(crate::scan_changes::begin)
|
||||
.map_err(|error| LibraryError::Database(error.to_string()))?;
|
||||
let result = crate::scan_with_progress(
|
||||
&db,
|
||||
ids.as_deref(),
|
||||
&shared.artwork,
|
||||
&shared.cancel,
|
||||
&|event| on_event(shared, event),
|
||||
)
|
||||
);
|
||||
content_changed = db.with_connection(crate::scan_changes::changed)
|
||||
.map_err(|error| LibraryError::Database(error.to_string()))?;
|
||||
result
|
||||
});
|
||||
let mut state = shared.state.lock().unwrap_or_else(|e| e.into_inner());
|
||||
if let Err(error) = result {
|
||||
log::error!("library scan failed: {error}");
|
||||
let job = state.snapshot.progress.job;
|
||||
state.snapshot.last_scan = Some(ScanCompletion {
|
||||
let background = state.background;
|
||||
state.record_completion(ScanCompletion {
|
||||
job,
|
||||
background,
|
||||
outcome: ScanOutcome::Failed,
|
||||
skipped: 0,
|
||||
});
|
||||
}
|
||||
state.snapshot.revision += 1;
|
||||
if content_changed {
|
||||
state.snapshot.content_revision += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -454,8 +517,10 @@ fn on_event(shared: &Shared, event: ScanEvent) {
|
||||
progress.cleaning = false;
|
||||
progress.file.clear();
|
||||
let job = progress.job;
|
||||
state.snapshot.last_scan = Some(ScanCompletion {
|
||||
let background = state.background;
|
||||
state.record_completion(ScanCompletion {
|
||||
job,
|
||||
background,
|
||||
outcome: match status {
|
||||
ScanStatus::Complete => ScanOutcome::Complete,
|
||||
ScanStatus::Cancelled => ScanOutcome::Cancelled,
|
||||
@@ -479,6 +544,47 @@ mod tests {
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unchanged_and_missing_scans_complete_without_catalog_invalidation() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let music = temp.path().join("music");
|
||||
std::fs::create_dir(&music).unwrap();
|
||||
let track = music.join("silence.wav");
|
||||
let mut wav = Vec::new();
|
||||
wav.extend_from_slice(b"RIFF");
|
||||
wav.extend_from_slice(&918_u32.to_le_bytes());
|
||||
wav.extend_from_slice(b"WAVEfmt ");
|
||||
wav.extend_from_slice(&16_u32.to_le_bytes());
|
||||
for value in [1_u16, 1] { wav.extend_from_slice(&value.to_le_bytes()); }
|
||||
for value in [44_100_u32, 88_200] { wav.extend_from_slice(&value.to_le_bytes()); }
|
||||
for value in [2_u16, 16] { wav.extend_from_slice(&value.to_le_bytes()); }
|
||||
wav.extend_from_slice(b"data");
|
||||
wav.extend_from_slice(&882_u32.to_le_bytes());
|
||||
wav.resize(926, 0);
|
||||
std::fs::write(&track, wav).unwrap();
|
||||
let service = host(&temp.path().join("profile"));
|
||||
let folder = service.register_folder(&music, 0).unwrap();
|
||||
let scan = || {
|
||||
assert!(service.scan_background(Some(folder.id)).unwrap());
|
||||
assert!(service.wait_idle(Duration::from_secs(10)));
|
||||
service.snapshot()
|
||||
};
|
||||
let imported = scan();
|
||||
assert_eq!(imported.content_revision, 2); // Registration + imported track.
|
||||
let reused = scan();
|
||||
assert_eq!(reused.content_revision, imported.content_revision);
|
||||
assert!(reused.revision > imported.revision);
|
||||
assert_eq!(reused.last_scan.unwrap().outcome, ScanOutcome::Complete);
|
||||
std::fs::remove_file(track).unwrap();
|
||||
let removed = scan();
|
||||
assert_eq!(removed.content_revision, imported.content_revision + 1);
|
||||
std::fs::remove_dir(music).unwrap();
|
||||
let missing = scan();
|
||||
assert_eq!(missing.content_revision, removed.content_revision);
|
||||
assert!(missing.last_scan.unwrap().skipped > 0);
|
||||
service.shutdown();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn folder_registration_is_revisioned_idempotent_and_profile_owned() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
@@ -549,15 +655,60 @@ mod tests {
|
||||
for id in [9, 4, 9] {
|
||||
queue.push(Some(id));
|
||||
}
|
||||
assert_eq!(queue.pop(), Some(Some(4)));
|
||||
assert_eq!(queue.pop(), Some(Some(9)));
|
||||
assert_eq!(queue.pop(), Some((Some(4), false)));
|
||||
assert_eq!(queue.pop(), Some((Some(9), false)));
|
||||
queue.push(Some(4));
|
||||
queue.push(None);
|
||||
queue.push(Some(9));
|
||||
assert_eq!(queue.pop(), Some(None));
|
||||
assert_eq!(queue.pop(), Some((None, false)));
|
||||
assert_eq!(queue.pop(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn background_scans_keep_manual_intent_when_requests_coalesce() {
|
||||
let mut queue = PendingScans::default();
|
||||
queue.push_with_background(Some(1), true);
|
||||
queue.push(Some(1));
|
||||
queue.push_with_background(Some(2), true);
|
||||
assert_eq!(queue.pop(), Some((Some(1), false)));
|
||||
assert_eq!(queue.pop(), Some((Some(2), true)));
|
||||
queue.push(Some(1));
|
||||
queue.push_with_background(None, true);
|
||||
assert_eq!(queue.pop(), Some((None, false)));
|
||||
queue.push_with_background(None, true);
|
||||
queue.push(Some(2));
|
||||
assert_eq!(queue.pop(), Some((None, false)));
|
||||
queue.push_with_background(None, true);
|
||||
assert_eq!(queue.pop(), Some((None, true)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn background_completion_remains_silent_and_next_manual_scan_is_reportable() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let service = host(temp.path());
|
||||
// A missing root reproduces the periodic "one skipped" completion.
|
||||
service
|
||||
.store()
|
||||
.open_or_create()
|
||||
.unwrap()
|
||||
.add_folder(temp.path().join("missing").to_str().unwrap())
|
||||
.unwrap();
|
||||
for background in [true, true, false, true] {
|
||||
assert!(service.scan_with_background(None, background).unwrap());
|
||||
assert!(service.wait_idle(Duration::from_secs(10)));
|
||||
let result = service.snapshot().last_scan.unwrap();
|
||||
assert_eq!(result.background, background);
|
||||
assert_eq!(result.skipped, 1);
|
||||
assert_eq!(result.outcome, ScanOutcome::Complete);
|
||||
if result.job >= 3 {
|
||||
assert_eq!(service.snapshot().last_requested_scan.unwrap().job, 3);
|
||||
} else {
|
||||
assert!(service.snapshot().last_requested_scan.is_none());
|
||||
}
|
||||
}
|
||||
service.shutdown();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn separate_hosts_scan_real_roots_and_keep_cancellation_local() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
@@ -607,6 +758,7 @@ mod tests {
|
||||
assert_eq!(
|
||||
snapshot.last_scan.unwrap(),
|
||||
ScanCompletion {
|
||||
background: false,
|
||||
job,
|
||||
outcome: ScanOutcome::Failed,
|
||||
skipped: 0
|
||||
@@ -686,6 +838,7 @@ mod tests {
|
||||
assert_eq!(
|
||||
snapshot.last_scan.unwrap(),
|
||||
ScanCompletion {
|
||||
background: false,
|
||||
job: 2,
|
||||
outcome: ScanOutcome::Complete,
|
||||
skipped: 0,
|
||||
|
||||
@@ -10,7 +10,8 @@ use std::path::PathBuf;
|
||||
use std::sync::mpsc::{self, Receiver, RecvTimeoutError};
|
||||
use std::time::Duration;
|
||||
|
||||
use notify::{Event, RecommendedWatcher, RecursiveMode, Watcher};
|
||||
use notify::event::{AccessKind, AccessMode, MetadataKind, ModifyKind};
|
||||
use notify::{Event, EventKind, RecommendedWatcher, RecursiveMode, Watcher};
|
||||
|
||||
use crate::{LibraryError, LibraryFolder};
|
||||
|
||||
@@ -26,19 +27,25 @@ pub struct LocalRootWatcher {
|
||||
watcher: RecommendedWatcher,
|
||||
receiver: Receiver<notify::Result<Event>>,
|
||||
roots: BTreeMap<i64, PathBuf>,
|
||||
failures: BTreeMap<i64, (PathBuf, String)>,
|
||||
}
|
||||
|
||||
impl LocalRootWatcher {
|
||||
pub fn new(folders: &[LibraryFolder]) -> Result<Self, LibraryError> {
|
||||
let (sender, receiver) = mpsc::channel();
|
||||
let watcher = notify::recommended_watcher(move |event| {
|
||||
let _ = sender.send(event);
|
||||
let watcher = notify::recommended_watcher(move |event: notify::Result<Event>| {
|
||||
// Filter before enqueueing: our own directory walks and playback
|
||||
// reads otherwise schedule another scan indefinitely.
|
||||
if event.as_ref().map_or(true, changes_library) {
|
||||
let _ = sender.send(event);
|
||||
}
|
||||
})
|
||||
.map_err(|error| LibraryError::Other(format!("local root watcher: {error}")))?;
|
||||
let mut result = Self {
|
||||
watcher,
|
||||
receiver,
|
||||
roots: BTreeMap::new(),
|
||||
failures: BTreeMap::new(),
|
||||
};
|
||||
result.rebuild(folders)?;
|
||||
Ok(result)
|
||||
@@ -58,30 +65,57 @@ impl LocalRootWatcher {
|
||||
let _ = self.watcher.unwatch(&path);
|
||||
self.roots.remove(&root_id);
|
||||
}
|
||||
let mut watch_failures = 0_usize;
|
||||
self.failures.retain(|id, (path, _)| desired.get(id) == Some(path));
|
||||
for (root_id, path) in &desired {
|
||||
if self.roots.get(root_id) == Some(path) {
|
||||
continue;
|
||||
}
|
||||
// A missing/newly-unmounted root remains covered by periodic
|
||||
// reconciliation. Failing to install its hint is not fatal.
|
||||
if self.watcher.watch(path, RecursiveMode::Recursive).is_ok() {
|
||||
self.roots.insert(*root_id, path.clone());
|
||||
} else {
|
||||
watch_failures += 1;
|
||||
match self.watcher.watch(path, RecursiveMode::Recursive) {
|
||||
Ok(()) => {
|
||||
self.roots.insert(*root_id, path.clone());
|
||||
if self.failures.remove(root_id).is_some() {
|
||||
log::info!(
|
||||
"[local-scan] watcher restored root_id={root_id} path={}",
|
||||
path.display()
|
||||
);
|
||||
}
|
||||
}
|
||||
Err(error) => {
|
||||
let error = error.to_string();
|
||||
if self.record_failure(*root_id, path.clone(), error.clone()) {
|
||||
log::warn!(
|
||||
"[local-scan] watcher unavailable root_id={root_id} path={} error={error}; periodic reconciliation remains active",
|
||||
path.display()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if watch_failures > 0 {
|
||||
log::warn!(
|
||||
"[local-scan] watcher unavailable for {watch_failures} local root(s); periodic reconciliation remains active"
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// Report a failure once per path/cause, then again after recovery. Keep
|
||||
// retrying registration so removable folders recover without a restart.
|
||||
fn record_failure(&mut self, id: i64, path: PathBuf, error: String) -> bool {
|
||||
let failure = (path, error);
|
||||
if self.failures.get(&id) == Some(&failure) {
|
||||
return false;
|
||||
}
|
||||
self.failures.insert(id, failure);
|
||||
true
|
||||
}
|
||||
|
||||
pub fn recv_timeout(&self, timeout: Duration) -> RootWatchEvent {
|
||||
match self.receiver.recv_timeout(timeout) {
|
||||
Ok(Ok(event)) if event.need_rescan() => {
|
||||
RootWatchEvent::Changed(self.roots.keys().copied().collect())
|
||||
}
|
||||
Ok(Ok(event)) => RootWatchEvent::Changed(self.roots_for_paths(&event.paths)),
|
||||
Ok(Err(error)) if error.paths.is_empty() => {
|
||||
RootWatchEvent::Error(self.roots.keys().copied().collect())
|
||||
}
|
||||
Ok(Err(error)) => RootWatchEvent::Error(self.roots_for_paths(&error.paths)),
|
||||
Err(RecvTimeoutError::Timeout) => RootWatchEvent::Timeout,
|
||||
Err(RecvTimeoutError::Disconnected) => RootWatchEvent::Disconnected,
|
||||
@@ -106,10 +140,63 @@ impl LocalRootWatcher {
|
||||
}
|
||||
}
|
||||
|
||||
fn changes_library(event: &Event) -> bool {
|
||||
if event.need_rescan() {
|
||||
return true;
|
||||
}
|
||||
match event.kind {
|
||||
// Preserve the completed-write hint: a preceding Modify can arrive
|
||||
// while an external writer still has an incomplete audio file open.
|
||||
EventKind::Access(AccessKind::Close(AccessMode::Write)) => true,
|
||||
EventKind::Access(_) => false,
|
||||
EventKind::Modify(ModifyKind::Metadata(MetadataKind::AccessTime)) => false,
|
||||
_ => true,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn reads_do_not_schedule_scans_but_content_changes_and_overflow_do() {
|
||||
use notify::event::{CreateKind, DataChange, Flag, RemoveKind, RenameMode};
|
||||
for kind in [
|
||||
EventKind::Access(AccessKind::Read),
|
||||
EventKind::Access(AccessKind::Open(AccessMode::Any)),
|
||||
EventKind::Access(AccessKind::Close(AccessMode::Read)),
|
||||
EventKind::Modify(ModifyKind::Metadata(MetadataKind::AccessTime)),
|
||||
] {
|
||||
assert!(!changes_library(&Event::new(kind)), "{kind:?}");
|
||||
assert!(changes_library(&Event::new(kind).set_flag(Flag::Rescan)));
|
||||
}
|
||||
for kind in [
|
||||
EventKind::Create(CreateKind::File),
|
||||
EventKind::Remove(RemoveKind::Folder),
|
||||
EventKind::Modify(ModifyKind::Data(DataChange::Any)),
|
||||
EventKind::Modify(ModifyKind::Name(RenameMode::Both)),
|
||||
EventKind::Modify(ModifyKind::Metadata(MetadataKind::Permissions)),
|
||||
EventKind::Access(AccessKind::Close(AccessMode::Write)),
|
||||
] {
|
||||
assert!(changes_library(&Event::new(kind)), "{kind:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn directory_reads_do_not_feed_back_into_native_watcher() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let path = temp.path().join("track.flac");
|
||||
std::fs::write(&path, b"fixture").unwrap();
|
||||
let watcher = LocalRootWatcher::new(&[folder(4, temp.path(), false)]).unwrap();
|
||||
for _ in 0..3 {
|
||||
let _ = std::fs::read_dir(temp.path()).unwrap().collect::<Vec<_>>();
|
||||
assert_eq!(std::fs::read(&path).unwrap(), b"fixture");
|
||||
}
|
||||
assert_eq!(watcher.recv_timeout(Duration::from_millis(300)), RootWatchEvent::Timeout);
|
||||
std::fs::write(&path, b"changed").unwrap();
|
||||
assert_eq!(watcher.recv_timeout(Duration::from_secs(5)), RootWatchEvent::Changed(vec![4]));
|
||||
}
|
||||
|
||||
fn folder(id: i64, path: &std::path::Path, network: bool) -> LibraryFolder {
|
||||
LibraryFolder {
|
||||
id,
|
||||
@@ -123,6 +210,31 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_root_retries_recovers_and_forgets_removed_failures() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let path = temp.path().join("removable");
|
||||
let folders = [folder(7, &path, false)];
|
||||
let mut watcher = LocalRootWatcher::new(&folders).unwrap();
|
||||
assert_eq!(watcher.watched_roots(), 0);
|
||||
assert_eq!(watcher.failures.len(), 1);
|
||||
let failure = watcher.failures[&7].clone();
|
||||
watcher.rebuild(&folders).unwrap();
|
||||
assert_eq!(watcher.failures[&7], failure);
|
||||
assert!(!watcher.record_failure(7, failure.0, failure.1));
|
||||
assert!(watcher.record_failure(7, path.clone(), "changed cause".into()));
|
||||
std::fs::create_dir(&path).unwrap();
|
||||
watcher.rebuild(&folders).unwrap();
|
||||
assert_eq!(watcher.watched_roots(), 1);
|
||||
assert!(watcher.failures.is_empty());
|
||||
watcher.rebuild(&[]).unwrap();
|
||||
std::fs::remove_dir(&path).unwrap();
|
||||
watcher.rebuild(&folders).unwrap();
|
||||
assert_eq!(watcher.failures.len(), 1);
|
||||
watcher.rebuild(&[]).unwrap();
|
||||
assert!(watcher.failures.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_local_roots_are_watched_and_nested_path_queues_every_owner() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
|
||||
@@ -35,6 +35,7 @@ pub mod fs_url;
|
||||
pub mod lenient;
|
||||
pub mod mixtape;
|
||||
pub mod playback;
|
||||
pub mod playback_cache;
|
||||
pub mod purchase_serde;
|
||||
pub mod source;
|
||||
pub mod traits;
|
||||
|
||||
@@ -0,0 +1,264 @@
|
||||
//! Persisted playback-memory policy shared by GUI and headless hosts.
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum PlaybackMemoryProfile {
|
||||
Auto,
|
||||
High,
|
||||
Desktop,
|
||||
Low,
|
||||
Custom,
|
||||
}
|
||||
|
||||
impl PlaybackMemoryProfile {
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Auto => "auto",
|
||||
Self::High => "high",
|
||||
Self::Desktop => "desktop",
|
||||
Self::Low => "low",
|
||||
Self::Custom => "custom",
|
||||
}
|
||||
}
|
||||
pub fn parse(value: &str) -> Result<Self, String> {
|
||||
match value {
|
||||
"auto" => Ok(Self::Auto),
|
||||
"high" => Ok(Self::High),
|
||||
"desktop" => Ok(Self::Desktop),
|
||||
"low" => Ok(Self::Low),
|
||||
"custom" => Ok(Self::Custom),
|
||||
_ => Err("Unknown playback memory profile".into()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct PlaybackPrefetchPolicy {
|
||||
pub lookahead: usize,
|
||||
pub concurrency: usize,
|
||||
pub allow_hires: bool,
|
||||
pub initial_buffer_cap: usize,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(default, deny_unknown_fields)]
|
||||
pub struct PlaybackCacheSettings {
|
||||
/// Missing in older settings: default limits map to Auto, edited limits to Custom.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub profile: Option<PlaybackMemoryProfile>,
|
||||
pub dynamic: bool,
|
||||
/// None keeps the host's recommended base (400 MiB, or 50 MiB low-memory).
|
||||
pub min_mib: Option<u32>,
|
||||
/// None uses the base in fixed mode, or a bounded automatic ceiling.
|
||||
pub max_mib: Option<u32>,
|
||||
/// Optional parent directory; QBZ owns only its qbz-playback child.
|
||||
/// Separate from the memory preset and applied when Player is constructed.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub disk_directory: Option<String>,
|
||||
}
|
||||
|
||||
impl PlaybackCacheSettings {
|
||||
pub fn selected_profile(&self) -> PlaybackMemoryProfile {
|
||||
self.profile.unwrap_or_else(|| {
|
||||
if self.dynamic || self.min_mib.is_some() || self.max_mib.is_some() {
|
||||
PlaybackMemoryProfile::Custom
|
||||
} else {
|
||||
PlaybackMemoryProfile::Auto
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub fn select_profile(&mut self, profile: PlaybackMemoryProfile) {
|
||||
if profile == PlaybackMemoryProfile::Custom {
|
||||
self.profile = Some(profile);
|
||||
return;
|
||||
}
|
||||
let directory = self.disk_directory.take();
|
||||
*self = Self { disk_directory: directory, ..Self::default() };
|
||||
match profile {
|
||||
PlaybackMemoryProfile::High => {
|
||||
self.dynamic = true;
|
||||
self.min_mib = Some(400);
|
||||
self.max_mib = Some(1600);
|
||||
}
|
||||
PlaybackMemoryProfile::Desktop => {
|
||||
self.min_mib = Some(400);
|
||||
self.max_mib = None;
|
||||
}
|
||||
PlaybackMemoryProfile::Low => {
|
||||
self.min_mib = Some(50);
|
||||
self.max_mib = None;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
if profile != PlaybackMemoryProfile::Auto {
|
||||
self.profile = Some(profile);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn make_custom(&mut self) {
|
||||
self.profile = Some(PlaybackMemoryProfile::Custom);
|
||||
}
|
||||
|
||||
pub fn prefetch_policy(&self, host_recommended_bytes: usize) -> PlaybackPrefetchPolicy {
|
||||
let low = match self.selected_profile() {
|
||||
PlaybackMemoryProfile::Low => true,
|
||||
PlaybackMemoryProfile::High | PlaybackMemoryProfile::Desktop => false,
|
||||
_ => host_recommended_bytes <= 50 * 1024 * 1024,
|
||||
};
|
||||
PlaybackPrefetchPolicy {
|
||||
lookahead: if low { 1 } else { 5 },
|
||||
concurrency: if low { 1 } else { 2 },
|
||||
allow_hires: !low,
|
||||
initial_buffer_cap: if low { 256 * 1024 } else { 2 * 1024 * 1024 },
|
||||
}
|
||||
}
|
||||
|
||||
pub fn validate(&self) -> Result<(), String> {
|
||||
if let Some(path) = &self.disk_directory {
|
||||
if !std::path::Path::new(path).is_absolute() || path.contains('\0') {
|
||||
return Err("Choose an absolute folder path for playback storage.".into());
|
||||
}
|
||||
}
|
||||
if let Some(profile) = self.profile.filter(|p| *p != PlaybackMemoryProfile::Custom) {
|
||||
let mut canonical = Self::default();
|
||||
canonical.select_profile(profile);
|
||||
if (self.dynamic, self.min_mib, self.max_mib)
|
||||
!= (canonical.dynamic, canonical.min_mib, canonical.max_mib)
|
||||
{
|
||||
return Err("Playback memory preset conflicts with custom cache limits".into());
|
||||
}
|
||||
}
|
||||
for value in [self.min_mib, self.max_mib].into_iter().flatten() {
|
||||
if !(16..=16384).contains(&value) {
|
||||
return Err("Playback cache limits must be between 16 and 16384 MiB".into());
|
||||
}
|
||||
}
|
||||
if matches!((self.min_mib, self.max_mib), (Some(min), Some(max)) if min > max) {
|
||||
return Err("Minimum playback cache budget must not exceed its maximum".into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn budgets(&self, recommended_bytes: usize) -> (usize, usize) {
|
||||
const MIB: usize = 1024 * 1024;
|
||||
let base = self
|
||||
.min_mib
|
||||
.map(|m| (m as u64 * MIB as u64).min(usize::MAX as u64) as usize)
|
||||
.unwrap_or(recommended_bytes);
|
||||
let ceiling = self
|
||||
.max_mib
|
||||
.map(|m| (m as u64 * MIB as u64).min(usize::MAX as u64) as usize)
|
||||
.unwrap_or_else(|| {
|
||||
if self.dynamic {
|
||||
base.saturating_mul(4)
|
||||
.min((16384u64 * MIB as u64).min(usize::MAX as u64) as usize)
|
||||
} else {
|
||||
base
|
||||
}
|
||||
});
|
||||
(base.min(ceiling), ceiling)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn switching_memory_profiles_preserves_playback_storage() {
|
||||
let mut policy = PlaybackCacheSettings::default();
|
||||
let path = std::env::temp_dir().join("playback-drive").to_string_lossy().into_owned();
|
||||
policy.disk_directory = Some(path.clone());
|
||||
for profile in [PlaybackMemoryProfile::High, PlaybackMemoryProfile::Low,
|
||||
PlaybackMemoryProfile::Desktop, PlaybackMemoryProfile::Custom, PlaybackMemoryProfile::Auto] {
|
||||
policy.select_profile(profile);
|
||||
assert_eq!(policy.disk_directory.as_deref(), Some(path.as_str()));
|
||||
policy.validate().unwrap();
|
||||
let restored: PlaybackCacheSettings = serde_json::from_str(&serde_json::to_string(&policy).unwrap()).unwrap();
|
||||
assert_eq!(restored.disk_directory, policy.disk_directory);
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn legacy_settings_keep_recommended_fixed_budget() {
|
||||
let policy: PlaybackCacheSettings = serde_json::from_str("{}").unwrap();
|
||||
assert_eq!(policy, PlaybackCacheSettings::default());
|
||||
assert_eq!(policy.budgets(400 << 20), (400 << 20, 400 << 20));
|
||||
assert_eq!(policy.budgets(50 << 20), (50 << 20, 50 << 20));
|
||||
}
|
||||
#[test]
|
||||
fn invalid_bounds_are_rejected_and_dynamic_ceiling_is_bounded() {
|
||||
let mut p = PlaybackCacheSettings {
|
||||
dynamic: true,
|
||||
min_mib: Some(400),
|
||||
max_mib: None,
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(p.budgets(50 << 20), (400 << 20, 1600 << 20));
|
||||
for max in [0, 15, 399, 16385, u32::MAX] {
|
||||
p.max_mib = Some(max);
|
||||
assert!(p.validate().is_err());
|
||||
}
|
||||
p.max_mib = Some(1600);
|
||||
assert!(p.validate().is_ok());
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod profile_tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn memory_profiles_roundtrip_and_apply_on_both_host_classes() {
|
||||
for (profile, base, max, dynamic) in [
|
||||
(PlaybackMemoryProfile::High, 400, 1600, true),
|
||||
(PlaybackMemoryProfile::Desktop, 400, 400, false),
|
||||
(PlaybackMemoryProfile::Low, 50, 50, false),
|
||||
] {
|
||||
let mut p = PlaybackCacheSettings::default();
|
||||
p.select_profile(profile);
|
||||
p.validate().unwrap();
|
||||
for host in [50, 400] {
|
||||
assert_eq!(p.budgets(host << 20), (base << 20, max << 20));
|
||||
}
|
||||
assert_eq!(p.dynamic, dynamic);
|
||||
assert_eq!(
|
||||
serde_json::from_str::<PlaybackCacheSettings>(&serde_json::to_string(&p).unwrap())
|
||||
.unwrap(),
|
||||
p
|
||||
);
|
||||
assert_eq!(
|
||||
p.prefetch_policy(400 << 20).allow_hires,
|
||||
profile != PlaybackMemoryProfile::Low
|
||||
);
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn memory_profiles_preserve_legacy_custom_and_reset_to_host_auto() {
|
||||
let mut p: PlaybackCacheSettings =
|
||||
serde_json::from_str(r#"{"dynamic":true,"min_mib":512,"max_mib":2048}"#).unwrap();
|
||||
assert_eq!(p.selected_profile(), PlaybackMemoryProfile::Custom);
|
||||
assert_eq!(p.budgets(50 << 20), (512 << 20, 2048 << 20));
|
||||
p.select_profile(PlaybackMemoryProfile::Low);
|
||||
p.make_custom();
|
||||
p.min_mib = Some(32);
|
||||
p.validate().unwrap();
|
||||
assert_eq!(p.selected_profile(), PlaybackMemoryProfile::Custom);
|
||||
p.select_profile(PlaybackMemoryProfile::Auto);
|
||||
assert_eq!(p, PlaybackCacheSettings::default());
|
||||
assert_eq!(p.budgets(50 << 20), (50 << 20, 50 << 20));
|
||||
assert_eq!(p.prefetch_policy(50 << 20).concurrency, 1);
|
||||
assert_eq!(p.prefetch_policy(400 << 20).concurrency, 2);
|
||||
}
|
||||
#[test]
|
||||
fn memory_profiles_reject_conflicting_presets_and_unknown_names() {
|
||||
let mut p = PlaybackCacheSettings::default();
|
||||
p.select_profile(PlaybackMemoryProfile::High);
|
||||
p.dynamic = false;
|
||||
assert!(p.validate().is_err());
|
||||
assert!(PlaybackMemoryProfile::parse("unlimited").is_err());
|
||||
assert!(
|
||||
serde_json::from_str::<PlaybackCacheSettings>(r#"{"profile":"unlimited"}"#).is_err()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -39,3 +39,6 @@ rodio = { version = "0.22", features = ["symphonia-all"] }
|
||||
|
||||
# HTTP client for streaming
|
||||
reqwest = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3"
|
||||
@@ -0,0 +1,215 @@
|
||||
//! Disk-backed playback for tracks that do not fit the live RAM budget.
|
||||
//! Audio bytes and format are unchanged; only their storage and reader change.
|
||||
use super::*;
|
||||
|
||||
pub(super) struct PrefetchGuard {
|
||||
cache: Arc<qbz_cache::AudioCache>,
|
||||
track_id: u64,
|
||||
}
|
||||
impl PrefetchGuard {
|
||||
pub(super) fn new(cache: Arc<qbz_cache::AudioCache>, track_id: u64) -> Self {
|
||||
cache.mark_fetching(track_id);
|
||||
Self { cache, track_id }
|
||||
}
|
||||
}
|
||||
impl Drop for PrefetchGuard {
|
||||
fn drop(&mut self) {
|
||||
self.cache.unmark_fetching(self.track_id);
|
||||
}
|
||||
}
|
||||
|
||||
impl Player {
|
||||
pub(super) fn streaming_buffer(
|
||||
&self,
|
||||
config: StreamingConfig,
|
||||
total_size: Option<u64>,
|
||||
) -> Result<(BufferedMediaSource, BufferWriter), String> {
|
||||
let total_size = total_size.filter(|size| *size > 0);
|
||||
let streaming_only = self
|
||||
.audio_settings
|
||||
.lock()
|
||||
.map(|s| s.streaming_only)
|
||||
.unwrap_or(false);
|
||||
let fits = total_size
|
||||
.and_then(|size| usize::try_from(size).ok())
|
||||
.is_some_and(|size| self.audio_cache.can_buffer_in_memory(size));
|
||||
if streaming_only || fits {
|
||||
return Ok(BufferedMediaSource::new(config, total_size));
|
||||
}
|
||||
let cache = self
|
||||
.audio_cache
|
||||
.get_playback_cache()
|
||||
.ok_or("Track exceeds the memory budget and playback disk cache is unavailable")?;
|
||||
let file = cache
|
||||
.create_spool()
|
||||
.map_err(|error| format!("Cannot create playback spool: {error}"))?;
|
||||
log::info!("[STREAM-STORAGE] disk-backed buffer, expected_bytes={total_size:?}");
|
||||
Ok(BufferedMediaSource::new_on_disk(config, total_size, file))
|
||||
}
|
||||
|
||||
pub(super) async fn persist_stream(
|
||||
cache: Arc<qbz_cache::AudioCache>,
|
||||
writer: BufferWriter,
|
||||
track_id: u64,
|
||||
) -> Result<(), String> {
|
||||
tokio::task::spawn_blocking(move || -> Result<(), String> {
|
||||
let size = writer.buffer_size();
|
||||
let admission = if let Some(data) = writer.complete_data_shared() {
|
||||
cache.insert_shared(track_id, data)
|
||||
} else {
|
||||
// The spool is copied in bounded chunks; never materialize an
|
||||
// oversized file just to pass it to the L2 writer.
|
||||
let saved = cache.get_playback_cache().is_some_and(|disk| {
|
||||
disk.insert_from(track_id, size as u64, |file| writer.write_buffered_to(file))
|
||||
});
|
||||
if saved {
|
||||
qbz_cache::CacheAdmission::Disk
|
||||
} else {
|
||||
qbz_cache::CacheAdmission::Skipped
|
||||
}
|
||||
};
|
||||
log::info!("[PLAYBACK-CACHE] Track {track_id} admission: {admission:?} ({size} bytes)");
|
||||
Ok(())
|
||||
})
|
||||
.await
|
||||
.map_err(|error| format!("Playback cache task failed: {error}"))?
|
||||
}
|
||||
|
||||
pub(super) fn cached_disk_source(
|
||||
&self,
|
||||
track_id: u64,
|
||||
quality: Quality,
|
||||
) -> Result<Option<(Arc<BufferedMediaSource>, AudioMetadata, u64)>, String> {
|
||||
let Some(file) = self
|
||||
.audio_cache
|
||||
.get_playback_cache()
|
||||
.and_then(|cache| cache.open(track_id))
|
||||
else {
|
||||
return Ok(None);
|
||||
};
|
||||
let source = Arc::new(BufferedMediaSource::from_file(file).map_err(|e| e.to_string())?);
|
||||
let (meta, duration) = source_metadata(&source)?;
|
||||
let below = match quality {
|
||||
Quality::UltraHiRes => meta.bit_depth.unwrap_or(16) < 24 || meta.sample_rate <= 96_000,
|
||||
Quality::HiRes => meta.bit_depth.unwrap_or(16) < 24,
|
||||
_ => false,
|
||||
};
|
||||
if below {
|
||||
log::info!("[CACHE] Disk track {track_id} below requested {quality:?}; re-fetching");
|
||||
return Ok(None);
|
||||
}
|
||||
log::info!(
|
||||
"[CACHE HIT] Track {track_id} from DISK, {} bytes, {}Hz; no full-file RAM read",
|
||||
source.buffer_size(),
|
||||
meta.sample_rate
|
||||
);
|
||||
Ok(Some((source, meta, duration)))
|
||||
}
|
||||
|
||||
pub(super) fn apply_completed_source(
|
||||
&self,
|
||||
source: Arc<BufferedMediaSource>,
|
||||
meta: AudioMetadata,
|
||||
duration_secs: u64,
|
||||
track_id: u64,
|
||||
start_position_secs: u64,
|
||||
) -> Result<(), String> {
|
||||
let play_gen = self.state.current_play_generation();
|
||||
self.state
|
||||
.set_stream_quality(meta.sample_rate, meta.bit_depth.unwrap_or(16));
|
||||
self.state.begin_buffering(track_id, play_gen);
|
||||
self.tx
|
||||
.send(AudioCommand::PlayStreaming {
|
||||
content_length: source.buffer_size() as u64,
|
||||
source,
|
||||
track_id,
|
||||
sample_rate: meta.sample_rate,
|
||||
channels: meta.channels,
|
||||
duration_secs,
|
||||
start_position_secs,
|
||||
play_gen,
|
||||
})
|
||||
.map_err(|error| format!("Failed to send cached stream: {error}"))?;
|
||||
self.state.seal_stream_feeder();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Legacy CDN fallback, consuming each HTTP chunk directly into its chosen
|
||||
/// storage. Kept separate from the Vec API used by external renderers.
|
||||
pub(super) async fn download_buffered(
|
||||
&self,
|
||||
url: &str,
|
||||
) -> Result<(Arc<BufferedMediaSource>, BufferWriter), String> {
|
||||
let client = reqwest::Client::builder()
|
||||
.connect_timeout(Duration::from_secs(10))
|
||||
.timeout(Duration::from_secs(120))
|
||||
.build()
|
||||
.map_err(|e| e.to_string())?;
|
||||
let mut response = client
|
||||
.get(url)
|
||||
.header("User-Agent", "Mozilla/5.0")
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| crate::remote_stream::describe_reqwest_error(&e))?;
|
||||
if !response.status().is_success() {
|
||||
return Err(format!("HTTP error: {}", response.status()));
|
||||
}
|
||||
let (source, writer) =
|
||||
self.streaming_buffer(StreamingConfig::fast_start(), response.content_length())?;
|
||||
while let Some(chunk) = response
|
||||
.chunk()
|
||||
.await
|
||||
.map_err(|e| crate::remote_stream::describe_reqwest_error(&e))?
|
||||
{
|
||||
writer.push_chunk(&chunk)?;
|
||||
}
|
||||
writer.complete()?;
|
||||
Ok((Arc::new(source), writer))
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn source_metadata(
|
||||
source: &BufferedMediaSource,
|
||||
) -> Result<(AudioMetadata, u64), String> {
|
||||
let stream = MediaSourceStream::new(Box::new(source.create_reader()), Default::default());
|
||||
let format = get_probe()
|
||||
.format(
|
||||
&Hint::new(),
|
||||
stream,
|
||||
&FormatOptions {
|
||||
enable_gapless: true,
|
||||
..Default::default()
|
||||
},
|
||||
&MetadataOptions::default(),
|
||||
)
|
||||
.map_err(|error| format!("Cached audio probe failed: {error}"))?;
|
||||
let track = format
|
||||
.format
|
||||
.default_track()
|
||||
.ok_or("No supported track in cached file")?;
|
||||
let meta = audio_metadata_from_codec_params(&track.codec_params)?;
|
||||
let duration = track.codec_params.n_frames.unwrap_or(0) / u64::from(meta.sample_rate.max(1));
|
||||
Ok((meta, duration))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[tokio::test]
|
||||
async fn cancelled_prefetch_releases_the_fetch_marker() {
|
||||
let cache = Arc::new(qbz_cache::AudioCache::new(1024));
|
||||
let entered = Arc::new(tokio::sync::Notify::new());
|
||||
let task_cache = cache.clone();
|
||||
let task_entered = entered.clone();
|
||||
let task = tokio::spawn(async move {
|
||||
let _guard = PrefetchGuard::new(task_cache, 42);
|
||||
task_entered.notify_one();
|
||||
std::future::pending::<()>().await;
|
||||
});
|
||||
entered.notified().await;
|
||||
assert!(cache.is_fetching(42));
|
||||
task.abort();
|
||||
assert!(task.await.unwrap_err().is_cancelled());
|
||||
assert!(!cache.is_fetching(42));
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@
|
||||
|
||||
mod playback_engine;
|
||||
mod streaming_source;
|
||||
mod disk_playback;
|
||||
|
||||
pub mod memory_tuning;
|
||||
|
||||
@@ -92,7 +93,7 @@ enum AudioCommand {
|
||||
/// Resume playback
|
||||
Resume,
|
||||
/// Stop playback
|
||||
Stop,
|
||||
Stop { completed: Option<tokio::sync::oneshot::Sender<()>> },
|
||||
/// Set volume (0.0 - 1.0)
|
||||
SetVolume(f32),
|
||||
/// Seek to position in seconds
|
||||
@@ -150,7 +151,7 @@ enum AudioCommand {
|
||||
|
||||
/// Pending gapless track data (queued for seamless transition)
|
||||
enum GaplessMedia {
|
||||
Buffered(Vec<u8>),
|
||||
Buffered(Arc<Vec<u8>>),
|
||||
Streaming(Arc<BufferedMediaSource>),
|
||||
Direct(DirectDsdMedia),
|
||||
}
|
||||
@@ -2333,7 +2334,7 @@ impl Player {
|
||||
|
||||
let mut current_engine: Option<PlaybackEngine> = None;
|
||||
// Store audio data for seeking (we need to re-decode from the beginning)
|
||||
let mut current_audio_data: Option<Vec<u8>> = None;
|
||||
let mut current_audio_data: Option<Arc<Vec<u8>>> = None;
|
||||
// Store streaming source for resume (when download completes, we can get the data)
|
||||
let mut current_streaming_source: Option<Arc<BufferedMediaSource>> = None;
|
||||
// Direct DSD bypasses rodio and therefore has no cached PCM/audio
|
||||
@@ -2348,11 +2349,6 @@ impl Player {
|
||||
let mut last_empty_check = Instant::now();
|
||||
// #660: when the last wedged-stream rebuild happened (backoff input).
|
||||
let mut last_wedge_rebuild: Option<Instant> = None;
|
||||
// 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)
|
||||
@@ -2368,7 +2364,7 @@ impl Player {
|
||||
let handle_command =
|
||||
|command: AudioCommand,
|
||||
current_engine: &mut Option<PlaybackEngine>,
|
||||
current_audio_data: &mut Option<Vec<u8>>,
|
||||
current_audio_data: &mut Option<Arc<Vec<u8>>>,
|
||||
current_streaming_source: &mut Option<Arc<BufferedMediaSource>>,
|
||||
current_direct_dsd: &mut Option<DirectDsdMedia>,
|
||||
stream_opt: &mut Option<StreamType>,
|
||||
@@ -2733,7 +2729,7 @@ impl Player {
|
||||
}
|
||||
thread_state.set_hardware_volume_active(false);
|
||||
|
||||
*current_audio_data = Some(data.clone());
|
||||
*current_audio_data = Some(Arc::new(data.clone()));
|
||||
*current_streaming_source = None; // Clear streaming source for non-streaming playback
|
||||
thread_state.set_loaded_audio(true);
|
||||
|
||||
@@ -3394,7 +3390,7 @@ impl Player {
|
||||
|
||||
// Create incremental streaming source - this starts playback IMMEDIATELY
|
||||
// while continuing to decode/download in background
|
||||
let incremental_source =
|
||||
let mut incremental_source =
|
||||
match IncrementalStreamingSource::new_for_play(
|
||||
source.clone(),
|
||||
PlaybackBufferReporter::new(
|
||||
@@ -3476,6 +3472,15 @@ impl Player {
|
||||
*current_gain_atomic = gain_atomic.clone();
|
||||
thread_state.set_normalization_gain(normalization);
|
||||
|
||||
let disk_resume = source.is_file_backed() && source.is_complete()
|
||||
&& start_position_secs > 0;
|
||||
if disk_resume {
|
||||
if let Err(error) = incremental_source.seek_to(Duration::from_secs(start_position_secs)) {
|
||||
log::error!("Cached disk resume seek failed: {error}");
|
||||
thread_state.record_stream_error("Cached disk resume seek failed");
|
||||
return;
|
||||
}
|
||||
}
|
||||
// Box the incremental source to match the expected type
|
||||
let mut source_to_play: Box<dyn Source<Item = f32> + Send> =
|
||||
Box::new(incremental_source);
|
||||
@@ -3486,7 +3491,7 @@ impl Player {
|
||||
// would underrun the audio device for multi-second
|
||||
// offsets. The buffer wait above guarantees
|
||||
// there's enough downloaded data to feed this loop.
|
||||
if start_position_secs > 0 {
|
||||
if start_position_secs > 0 && !disk_resume {
|
||||
let target_samples: u64 = (start_position_secs)
|
||||
.saturating_mul(actual_sr as u64)
|
||||
.saturating_mul(actual_ch as u64);
|
||||
@@ -3942,14 +3947,19 @@ impl Player {
|
||||
*pause_suspend_deadline = None;
|
||||
if current_engine.is_none() {
|
||||
// Try to get audio data from regular storage or streaming source
|
||||
let audio_data: Vec<u8> = if let Some(ref data) =
|
||||
let disk_source = current_streaming_source.as_ref()
|
||||
.filter(|source| source.is_complete() && source.is_file_backed())
|
||||
.cloned();
|
||||
let audio_data: Arc<Vec<u8>> = if disk_source.is_some() {
|
||||
Arc::new(Vec::new())
|
||||
} else if let Some(ref data) =
|
||||
*current_audio_data
|
||||
{
|
||||
data.clone()
|
||||
} else if let Some(ref streaming_src) = *current_streaming_source {
|
||||
// Try to get complete data from streaming source
|
||||
if streaming_src.is_complete() {
|
||||
match streaming_src.take_complete_data() {
|
||||
match streaming_src.complete_data_shared() {
|
||||
Some(data) => {
|
||||
log::info!("Resume: using complete streaming data ({} bytes)", data.len());
|
||||
// Store it in current_audio_data for future use
|
||||
@@ -4029,12 +4039,19 @@ impl Player {
|
||||
f32::from_bits(thread_state.volume.load(Ordering::SeqCst));
|
||||
apply_engine_volume(&stream_opt, &engine, volume);
|
||||
|
||||
let source = match decode_with_fallback(&audio_data) {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
log::error!("Failed to decode audio for resume: {}", e);
|
||||
return;
|
||||
}
|
||||
let resume_pos = thread_state.position.load(Ordering::SeqCst);
|
||||
let source: Result<Box<dyn Source<Item = f32> + Send>, String> =
|
||||
if let Some(disk) = disk_source.as_ref() {
|
||||
IncrementalStreamingSource::new(disk.clone()).and_then(|mut source| {
|
||||
if resume_pos > 0 { source.seek_to(Duration::from_secs(resume_pos))?; }
|
||||
Ok(Box::new(source) as _)
|
||||
})
|
||||
} else {
|
||||
decode_with_fallback(&audio_data)
|
||||
};
|
||||
let source = match source {
|
||||
Ok(source) => source,
|
||||
Err(error) => { log::error!("Failed to decode audio for resume: {error}"); return; }
|
||||
};
|
||||
|
||||
// A Resume that rebuilds from completed streaming data
|
||||
@@ -4052,9 +4069,8 @@ impl Player {
|
||||
}
|
||||
}
|
||||
|
||||
let resume_pos = thread_state.position.load(Ordering::SeqCst);
|
||||
let skipped_source: Box<dyn Source<Item = f32> + Send> =
|
||||
if resume_pos > 0 {
|
||||
if resume_pos > 0 && disk_source.is_none() {
|
||||
Box::new(
|
||||
source.skip_duration(Duration::from_secs(resume_pos)),
|
||||
)
|
||||
@@ -4094,7 +4110,7 @@ impl Player {
|
||||
log::info!("Audio thread: resumed");
|
||||
}
|
||||
}
|
||||
AudioCommand::Stop => {
|
||||
AudioCommand::Stop { completed } => {
|
||||
if let Some(engine) = current_engine.take() {
|
||||
engine.stop();
|
||||
}
|
||||
@@ -4142,6 +4158,9 @@ impl Player {
|
||||
#[cfg(target_os = "linux")]
|
||||
qbz_audio::pipewire_backend::PipeWireBackend::reset_pipewire_clock();
|
||||
log::info!("Audio thread: stopped");
|
||||
if let Some(completed) = completed {
|
||||
let _ = completed.send(());
|
||||
}
|
||||
}
|
||||
AudioCommand::SetVolume(volume) => {
|
||||
let reported_volume = reported_volume_after_command(
|
||||
@@ -4429,7 +4448,7 @@ impl Player {
|
||||
let audio_data = current_audio_data
|
||||
.as_ref()
|
||||
.expect("current_audio_data was checked Some above");
|
||||
match InMemorySource::new(audio_data.clone()) {
|
||||
match InMemorySource::from_shared(audio_data.clone()) {
|
||||
Ok(mut s) => match s.seek_to(skip_duration) {
|
||||
Ok(()) => Box::new(s),
|
||||
Err(e) => {
|
||||
@@ -4745,7 +4764,7 @@ impl Player {
|
||||
track_id,
|
||||
play_generation: thread_state.current_play_generation(),
|
||||
duration_secs: actual_duration,
|
||||
media: GaplessMedia::Buffered(data),
|
||||
media: GaplessMedia::Buffered(Arc::new(data)),
|
||||
sample_rate,
|
||||
channels,
|
||||
bit_depth,
|
||||
@@ -5029,59 +5048,21 @@ impl Player {
|
||||
if let Some(streaming_src) = current_streaming_source.as_ref() {
|
||||
if streaming_src.is_complete() {
|
||||
if current_audio_data.is_none() {
|
||||
// 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);
|
||||
}
|
||||
// The sealed source shares its allocation with the
|
||||
// active decoder. Promotion is constant-time even
|
||||
// for a track larger than the entire L1 budget.
|
||||
if let Some(full_data) = streaming_src.complete_data_shared() {
|
||||
log::info!(
|
||||
"Streaming promotion: sharing full track ({} bytes), enabling cached transition path",
|
||||
full_data.len()
|
||||
);
|
||||
current_audio_data = Some(full_data);
|
||||
clear_streaming_source = true;
|
||||
}
|
||||
} else {
|
||||
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;
|
||||
@@ -5453,7 +5434,17 @@ impl Player {
|
||||
// 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) {
|
||||
let storage_directory = settings.lock().ok()
|
||||
.and_then(|s| s.playback_cache.disk_directory.clone());
|
||||
let disk_cache = match storage_directory {
|
||||
Some(parent) => std::fs::read_dir(&parent)
|
||||
.map_err(|e| format!("Selected playback storage is unavailable: {e}"))
|
||||
.and_then(|_| qbz_cache::PlaybackCache::with_path(
|
||||
std::path::PathBuf::from(parent).join("qbz-playback"), 800 * 1024 * 1024,
|
||||
)),
|
||||
None => qbz_cache::PlaybackCache::new(800 * 1024 * 1024),
|
||||
};
|
||||
let audio_cache = match disk_cache {
|
||||
Ok(pc) => Arc::new(qbz_cache::AudioCache::with_playback_cache(
|
||||
l1_max_bytes,
|
||||
Arc::new(pc),
|
||||
@@ -5464,6 +5455,14 @@ impl Player {
|
||||
}
|
||||
};
|
||||
|
||||
if let Ok(config) = settings.lock() {
|
||||
set_max_initial_buffer_bytes(config.playback_cache.prefetch_policy(l1_max_bytes).initial_buffer_cap);
|
||||
if let Err(error) = audio_cache.configure(config.playback_cache.clone()) {
|
||||
log::warn!("Invalid playback cache policy, using defaults: {error}");
|
||||
}
|
||||
}
|
||||
qbz_cache::AudioCache::start_maintenance(&audio_cache);
|
||||
|
||||
Self {
|
||||
tx,
|
||||
state,
|
||||
@@ -5557,6 +5556,13 @@ impl Player {
|
||||
}
|
||||
}
|
||||
|
||||
if let Some((source, meta, duration)) = self.cached_disk_source(track_id, quality)? {
|
||||
if self.is_current_play(gen) {
|
||||
return self.apply_completed_source(source, meta, duration, track_id, start_position_secs);
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// `streaming_only` suppresses writing the track into the cache.
|
||||
let skip_cache = self
|
||||
.audio_settings
|
||||
@@ -5720,32 +5726,14 @@ impl Player {
|
||||
|
||||
log::info!("Player: Got stream URL (format: {})", stream_url.mime_type);
|
||||
|
||||
// Download the audio data
|
||||
log::info!("Player: Starting audio caching...");
|
||||
let audio_data = self.download_audio(&stream_url.url).await.map_err(|e| {
|
||||
log::error!("Player: Caching failed: {}", e);
|
||||
e
|
||||
})?;
|
||||
log::info!("Player: Cached {} bytes of audio data", audio_data.len());
|
||||
|
||||
if !self.is_current_play(gen) {
|
||||
log::info!(
|
||||
"Player: legacy play for track {track_id} superseded after download (gen {gen})"
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Store the legacy download in the cache for instant replay.
|
||||
let (source, writer) = self.download_buffered(&stream_url.url).await?;
|
||||
if !self.is_current_play(gen) { return Ok(()); }
|
||||
if !skip_cache {
|
||||
self.audio_cache.insert(track_id, audio_data.clone());
|
||||
Self::persist_stream(self.audio_cache.clone(), writer, track_id).await?;
|
||||
}
|
||||
|
||||
// Send to audio thread (do not re-bump generation)
|
||||
let r = self.apply_play_data(audio_data, track_id);
|
||||
if r.is_ok() && start_position_secs > 0 && self.is_current_play(gen) {
|
||||
let _ = self.seek(start_position_secs);
|
||||
}
|
||||
r
|
||||
if !self.is_current_play(gen) { return Ok(()); }
|
||||
let (meta, duration) = disk_playback::source_metadata(&source)?;
|
||||
self.apply_completed_source(source, meta, duration, track_id, start_position_secs)
|
||||
}
|
||||
|
||||
/// Queue a cold Qobuz successor as an incremental source. This is the
|
||||
@@ -5759,7 +5747,36 @@ impl Player {
|
||||
quality: Quality,
|
||||
) -> Result<(), String> {
|
||||
let gen = self.state.current_play_generation();
|
||||
let cmaf_info = qbz_qobuz::cmaf::setup_streaming(client, track_id, quality).await?;
|
||||
if let Some((source, meta, duration_secs)) = self.cached_disk_source(track_id, quality)? {
|
||||
self.tx.send(AudioCommand::PlayNextStreaming {
|
||||
source, track_id, sample_rate: meta.sample_rate, channels: meta.channels,
|
||||
bit_depth: meta.bit_depth.unwrap_or(16), duration_secs,
|
||||
}).map_err(|error| format!("failed to queue cached disk source: {error}"))?;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if let Some(cached) = self.audio_cache.get(track_id) {
|
||||
if !cached_quality_below_requested(&cached.data, quality) {
|
||||
return self.play_next(cached.data, track_id);
|
||||
}
|
||||
}
|
||||
let cmaf_info = match qbz_qobuz::cmaf::setup_streaming(client, track_id, quality).await {
|
||||
Ok(info) => info,
|
||||
Err(error) => {
|
||||
log::warn!("[GAPLESS-STREAM] CMAF setup failed: {error}; trying buffered legacy source");
|
||||
let url = client.get_stream_url_with_fallback(track_id, quality).await.map_err(|e| e.to_string())?;
|
||||
let (source, writer) = self.download_buffered(&url.url).await?;
|
||||
if !self.is_current_play(gen) { return Err("gapless legacy fetch superseded".into()); }
|
||||
let skip_cache = self.audio_settings.lock().map(|s| s.streaming_only).unwrap_or(false);
|
||||
if !skip_cache { Self::persist_stream(self.audio_cache.clone(), writer, track_id).await?; }
|
||||
if !self.is_current_play(gen) { return Err("gapless legacy fetch superseded".into()); }
|
||||
let (meta, duration_secs) = disk_playback::source_metadata(&source)?;
|
||||
return self.tx.send(AudioCommand::PlayNextStreaming {
|
||||
source, track_id, sample_rate: meta.sample_rate, channels: meta.channels,
|
||||
bit_depth: meta.bit_depth.unwrap_or(16), duration_secs,
|
||||
}).map_err(|e| e.to_string());
|
||||
}
|
||||
};
|
||||
if !self.is_current_play(gen) {
|
||||
return Err("current track changed during gapless stream setup".to_string());
|
||||
}
|
||||
@@ -5799,7 +5816,7 @@ impl Player {
|
||||
.max(user_floor)
|
||||
.clamp(256 * 1024, 8 * 1024 * 1024);
|
||||
|
||||
let (source, writer) = BufferedMediaSource::new(config, Some(total_flac_size));
|
||||
let (source, writer) = self.streaming_buffer(config, Some(total_flac_size))?;
|
||||
let source = Arc::new(source);
|
||||
let (cancel_tx, cancel_rx) = tokio::sync::watch::channel(false);
|
||||
if !self.state.register_gapless_stream_feeder(
|
||||
@@ -5816,6 +5833,7 @@ impl Player {
|
||||
let flac_header = cmaf_info.flac_header;
|
||||
let n_segments = cmaf_info.n_segments;
|
||||
let cache = self.audio_cache.clone();
|
||||
let skip_cache = self.audio_settings.lock().map(|settings| settings.streaming_only).unwrap_or(false);
|
||||
tokio::spawn(async move {
|
||||
match Self::cmaf_stream_segments(
|
||||
&url_template,
|
||||
@@ -5825,7 +5843,7 @@ impl Player {
|
||||
writer,
|
||||
track_id,
|
||||
cache,
|
||||
true,
|
||||
skip_cache,
|
||||
total_flac_size,
|
||||
cancel_rx,
|
||||
)
|
||||
@@ -5891,8 +5909,8 @@ impl Player {
|
||||
/// successor after its initial buffer. This method remains the prefetch
|
||||
/// primitive the controller drives for the next 1-2 queue tracks.
|
||||
///
|
||||
/// Mirrors the Tauri V2 prefetch download: CMAF `download_full` first
|
||||
/// (Akamai CDN), legacy `/track/getFileUrl` full download as fallback.
|
||||
/// CMAF segments and legacy HTTP bodies stream into the selected storage;
|
||||
/// oversized tracks never materialize a whole-file Vec for prefetch.
|
||||
/// No-ops when `streaming_only` is set, when the track is already
|
||||
/// cached, or when another fetch for the same id is in flight.
|
||||
pub async fn prefetch_into_cache(
|
||||
@@ -5937,41 +5955,42 @@ impl Player {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
self.audio_cache.mark_fetching(track_id);
|
||||
if self.audio_cache.get_playback_cache().is_some_and(|cache| cache.contains(track_id)) {
|
||||
return Ok(());
|
||||
}
|
||||
let _fetching = disk_playback::PrefetchGuard::new(self.audio_cache.clone(), track_id);
|
||||
log::info!("[PREFETCH] Prefetching track {track_id} at {quality:?}");
|
||||
|
||||
// Try CMAF full download first (Akamai CDN), legacy full download
|
||||
// as fallback (nginx CDN).
|
||||
let result = match qbz_qobuz::cmaf::download_full(client, track_id, quality).await {
|
||||
Ok(data) => Ok(data),
|
||||
Err(e) => {
|
||||
log::warn!(
|
||||
"[PREFETCH] CMAF failed for track {track_id}: {e}, trying legacy"
|
||||
);
|
||||
match client.get_stream_url_with_fallback(track_id, quality).await {
|
||||
Ok(stream_url) => self.download_audio(&stream_url.url).await,
|
||||
Err(e) => Err(format!("Failed to get stream URL: {e}")),
|
||||
}
|
||||
let cmaf_result = async {
|
||||
let info = qbz_qobuz::cmaf::setup_streaming(client, track_id, quality).await?;
|
||||
let size = info.flac_header.len() as u64 + info.segment_table.iter()
|
||||
.map(|segment| segment.byte_len as u64).sum::<u64>();
|
||||
let (_source, writer) = self.streaming_buffer(StreamingConfig::fast_start(), Some(size))?;
|
||||
let (_cancel_tx, cancel_rx) = tokio::sync::watch::channel(false);
|
||||
Self::cmaf_stream_segments(&info.url_template, info.n_segments, info.content_key,
|
||||
info.flac_header, writer, track_id, self.audio_cache.clone(), false, size, cancel_rx).await?;
|
||||
Ok::<(), String>(())
|
||||
}.await;
|
||||
let result = match cmaf_result {
|
||||
Ok(()) => Ok(()),
|
||||
Err(error) => {
|
||||
log::warn!("[PREFETCH] CMAF failed for {track_id}: {error}; trying legacy");
|
||||
async {
|
||||
let url = client.get_stream_url_with_fallback(track_id, quality).await.map_err(|e| e.to_string())?;
|
||||
let (_source, writer) = self.download_buffered(&url.url).await?;
|
||||
Self::persist_stream(self.audio_cache.clone(), writer, track_id).await
|
||||
}.await
|
||||
}
|
||||
};
|
||||
|
||||
match result {
|
||||
Ok(data) => {
|
||||
// Brief delay before the cache write to avoid racing the
|
||||
// audio thread, matching the Tauri prefetch path.
|
||||
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||
let len = data.len();
|
||||
self.audio_cache.insert(track_id, data);
|
||||
self.audio_cache.unmark_fetching(track_id);
|
||||
Ok(()) => {
|
||||
self.audio_cache.clear_failed(track_id);
|
||||
log::info!("[PREFETCH] Complete for track {track_id} ({len} bytes)");
|
||||
log::info!("[PREFETCH] Complete for track {track_id}");
|
||||
Ok(())
|
||||
}
|
||||
Err(e) => {
|
||||
self.audio_cache.unmark_fetching(track_id);
|
||||
Err(error) => {
|
||||
self.audio_cache.mark_failed(track_id);
|
||||
log::warn!("[PREFETCH] Failed for track {track_id}: {e}");
|
||||
Err(e)
|
||||
Err(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6028,7 +6047,7 @@ impl Player {
|
||||
"[GAPLESS] Track {track_id} from DISK cache ({} bytes)",
|
||||
audio_data.len()
|
||||
);
|
||||
self.audio_cache.insert(track_id, audio_data.clone());
|
||||
self.audio_cache.promote_from_disk(track_id, audio_data.clone());
|
||||
return Some(audio_data);
|
||||
}
|
||||
}
|
||||
@@ -6198,7 +6217,7 @@ impl Player {
|
||||
"[CAST-FETCH] Track {track_id} from DISK cache ({} bytes)",
|
||||
audio_data.len()
|
||||
);
|
||||
self.audio_cache.insert(track_id, audio_data.clone());
|
||||
self.audio_cache.promote_from_disk(track_id, audio_data.clone());
|
||||
return Some(ExternalStreamAsset {
|
||||
bytes: audio_data,
|
||||
content_type: "audio/flac".to_string(),
|
||||
@@ -6282,12 +6301,8 @@ impl Player {
|
||||
/// 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).
|
||||
/// Completed RAM buffers are shared; disk spools are persisted in bounded
|
||||
/// chunks outside the decoder lock, without a parallel RAM accumulator.
|
||||
///
|
||||
/// `cancel_rx` flips to `true` when a newer play intent supersedes this
|
||||
/// stream (see `SharedState::cancel_stream_feeder`). Returns `Ok(true)`
|
||||
@@ -6301,7 +6316,7 @@ impl Player {
|
||||
track_id: u64,
|
||||
cache: Arc<qbz_cache::AudioCache>,
|
||||
skip_cache: bool,
|
||||
expected_total_bytes: u64,
|
||||
_expected_total_bytes: u64,
|
||||
mut cancel_rx: tokio::sync::watch::Receiver<bool>,
|
||||
) -> Result<bool, String> {
|
||||
struct FailGuard {
|
||||
@@ -6335,24 +6350,6 @@ 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 — 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();
|
||||
|
||||
for seg_idx in 1..=n_segments {
|
||||
@@ -6403,9 +6400,6 @@ impl Player {
|
||||
let _ = writer.error(msg.clone());
|
||||
return Err(msg);
|
||||
}
|
||||
if accumulate_cache {
|
||||
cache_data.extend_from_slice(&frame);
|
||||
}
|
||||
total_written += frame.len() as u64;
|
||||
data_pos = frame_end;
|
||||
}
|
||||
@@ -6418,9 +6412,6 @@ impl Player {
|
||||
let _ = writer.error(msg.clone());
|
||||
return Err(msg);
|
||||
}
|
||||
if accumulate_cache {
|
||||
cache_data.extend_from_slice(trailing);
|
||||
}
|
||||
total_written += trailing.len() as u64;
|
||||
}
|
||||
|
||||
@@ -6462,34 +6453,8 @@ impl Player {
|
||||
n_segments - 1
|
||||
);
|
||||
|
||||
// Cache the assembled FLAC (header + decrypted frames) for instant
|
||||
// replay on the next play of this track.
|
||||
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
|
||||
);
|
||||
}
|
||||
if !skip_cache {
|
||||
Self::persist_stream(cache, writer.clone(), track_id).await?;
|
||||
}
|
||||
|
||||
Ok(true)
|
||||
@@ -6889,7 +6854,7 @@ impl Player {
|
||||
// Use StreamingConfig::from_seconds for proper buffer sizing
|
||||
let config = StreamingConfig::from_seconds(buffer_seconds);
|
||||
|
||||
let (source, writer) = BufferedMediaSource::new(config, Some(content_length));
|
||||
let (source, writer) = self.streaming_buffer(config, Some(content_length))?;
|
||||
let source = Arc::new(source);
|
||||
let play_gen = self.state.current_play_generation();
|
||||
self.state.begin_buffering(track_id, play_gen);
|
||||
@@ -7012,7 +6977,7 @@ impl Player {
|
||||
);
|
||||
}
|
||||
|
||||
let (source, writer) = BufferedMediaSource::new(config, Some(content_length));
|
||||
let (source, writer) = self.streaming_buffer(config, Some(content_length))?;
|
||||
let source = Arc::new(source);
|
||||
let play_gen = self.state.current_play_generation();
|
||||
self.state.begin_buffering(track_id, play_gen);
|
||||
@@ -7113,10 +7078,26 @@ impl Player {
|
||||
// seal it so a reader parked on its condvar wakes to EOF.
|
||||
self.state.seal_stream_feeder();
|
||||
self.tx
|
||||
.send(AudioCommand::Stop)
|
||||
.send(AudioCommand::Stop { completed: None })
|
||||
.map_err(|e| format!("Failed to send stop command: {}", e))
|
||||
}
|
||||
|
||||
/// Stop and wait until the audio thread has dropped current/gapless
|
||||
/// readers before evicting L1. Callers first cancel speculative downloads.
|
||||
/// Keep L2 and the selected memory policy; the next start builds new buffers.
|
||||
pub async fn stop_and_evict_playback_memory(&self) -> Result<(), String> {
|
||||
let _ = self.begin_play();
|
||||
self.state.seal_stream_feeder();
|
||||
let (completed, receiver) = tokio::sync::oneshot::channel();
|
||||
self.tx.send(AudioCommand::Stop { completed: Some(completed) })
|
||||
.map_err(|e| format!("Failed to send stop command: {e}"))?;
|
||||
tokio::time::timeout(Duration::from_secs(10), receiver).await
|
||||
.map_err(|_| "Timed out waiting for playback memory release".to_string())?
|
||||
.map_err(|_| "Audio thread closed before playback memory release".to_string())?;
|
||||
self.audio_cache.evict_all_memory();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Set volume (0.0 - 1.0)
|
||||
pub fn set_volume(&self, volume: f32) -> Result<(), String> {
|
||||
let clamped = volume.clamp(0.0, 1.0);
|
||||
@@ -7189,7 +7170,34 @@ impl Player {
|
||||
|
||||
/// Reload audio settings from fresh config (e.g., after database update)
|
||||
/// Call this before reinit_device() to ensure Player uses latest settings
|
||||
pub fn playback_cache_stats(&self) -> qbz_cache::CacheStats {
|
||||
self.audio_cache.stats()
|
||||
}
|
||||
|
||||
pub fn playback_storage_directory(&self) -> Option<std::path::PathBuf> {
|
||||
self.audio_cache.get_playback_cache().map(|cache| cache.cache_dir().clone())
|
||||
}
|
||||
|
||||
pub fn playback_prefetch_policy(&self) -> qbz_models::playback_cache::PlaybackPrefetchPolicy {
|
||||
let policy = self.audio_settings.lock().map(|s| s.playback_cache.clone()).unwrap_or_default();
|
||||
policy.prefetch_policy(self.audio_cache.stats().recommended_size_bytes)
|
||||
}
|
||||
|
||||
/// Update only memory policy; never apply an unrelated pending output/DSD setting.
|
||||
pub fn configure_playback_cache(
|
||||
&self,
|
||||
policy: qbz_models::playback_cache::PlaybackCacheSettings,
|
||||
) -> Result<(), String> {
|
||||
let mut settings = self.audio_settings.lock().map_err(|_| "Failed to lock audio settings")?;
|
||||
self.audio_cache.configure(policy.clone())?;
|
||||
set_max_initial_buffer_bytes(policy.prefetch_policy(self.audio_cache.stats().recommended_size_bytes).initial_buffer_cap);
|
||||
settings.playback_cache = policy;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn reload_settings(&self, settings: AudioSettings) -> Result<(), String> {
|
||||
self.audio_cache.configure(settings.playback_cache.clone())?;
|
||||
set_max_initial_buffer_bytes(settings.playback_cache.prefetch_policy(self.audio_cache.stats().recommended_size_bytes).initial_buffer_cap);
|
||||
if let Ok(mut current_settings) = self.audio_settings.lock() {
|
||||
*current_settings = settings;
|
||||
Ok(())
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
//! Communication uses `Mutex` + `Condvar` for blocking synchronization.
|
||||
|
||||
use std::collections::VecDeque;
|
||||
use std::fs::File;
|
||||
use std::io::{Cursor, Error as IoError, ErrorKind, Read, Result as IoResult, Seek, SeekFrom};
|
||||
use std::sync::{Arc, Condvar, Mutex};
|
||||
use std::time::Duration;
|
||||
@@ -191,7 +192,9 @@ fn prealloc_capacity(total_size: Option<u64>) -> Option<usize> {
|
||||
/// Internal state shared between reader and writer
|
||||
struct BufferState {
|
||||
/// Accumulated data from HTTP response
|
||||
data: Vec<u8>,
|
||||
data: Arc<Vec<u8>>,
|
||||
file: Option<Arc<File>>,
|
||||
written: usize,
|
||||
/// True when HTTP download is complete
|
||||
download_complete: bool,
|
||||
/// Error from download, if any
|
||||
@@ -200,6 +203,61 @@ struct BufferState {
|
||||
total_size: Option<u64>,
|
||||
}
|
||||
|
||||
impl BufferState {
|
||||
fn len(&self) -> usize {
|
||||
if self.file.is_some() {
|
||||
self.written
|
||||
} else {
|
||||
self.data.len()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Positional I/O keeps cloned decoder readers independent and never holds the
|
||||
// buffer-state mutex across disk operations. Writers publish only committed bytes.
|
||||
fn file_read_at(file: &File, out: &mut [u8], offset: u64) -> IoResult<usize> {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::FileExt;
|
||||
file.read_at(out, offset)
|
||||
}
|
||||
#[cfg(windows)]
|
||||
{
|
||||
use std::os::windows::fs::FileExt;
|
||||
file.seek_read(out, offset)
|
||||
}
|
||||
}
|
||||
|
||||
fn file_write_all_at(file: &File, mut bytes: &[u8], mut offset: u64) -> IoResult<()> {
|
||||
while !bytes.is_empty() {
|
||||
#[cfg(unix)]
|
||||
let result = {
|
||||
use std::os::unix::fs::FileExt;
|
||||
file.write_at(bytes, offset)
|
||||
};
|
||||
#[cfg(windows)]
|
||||
let result = {
|
||||
use std::os::windows::fs::FileExt;
|
||||
file.seek_write(bytes, offset)
|
||||
};
|
||||
match result {
|
||||
Ok(0) => {
|
||||
return Err(IoError::new(
|
||||
ErrorKind::WriteZero,
|
||||
"stream spool write returned zero",
|
||||
))
|
||||
}
|
||||
Ok(n) => {
|
||||
bytes = &bytes[n..];
|
||||
offset += n as u64;
|
||||
}
|
||||
Err(error) if error.kind() == ErrorKind::Interrupted => continue,
|
||||
Err(error) => return Err(error),
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A media source that buffers from an async HTTP stream.
|
||||
///
|
||||
/// Provides `Read + Seek` interface for decoders while data is still downloading.
|
||||
@@ -218,30 +276,80 @@ 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) {
|
||||
Self::new_storage(config, total_size, None)
|
||||
}
|
||||
|
||||
/// Use a temporary disk file as the growing stream, retaining only bounded
|
||||
/// decoder buffers in RAM. The caller owns the choice of disk and policy.
|
||||
pub fn new_on_disk(
|
||||
config: StreamingConfig,
|
||||
total_size: Option<u64>,
|
||||
file: File,
|
||||
) -> (Self, BufferWriter) {
|
||||
Self::new_storage(config, total_size, Some(file))
|
||||
}
|
||||
|
||||
/// A completed L2 cache file uses the same decoder, seeking and resume path
|
||||
/// as an in-flight stream. The open handle survives cache eviction.
|
||||
pub fn from_file(file: File) -> IoResult<Self> {
|
||||
let size = usize::try_from(file.metadata()?.len()).map_err(|_| {
|
||||
IoError::new(
|
||||
ErrorKind::InvalidData,
|
||||
"audio file exceeds addressable stream size",
|
||||
)
|
||||
})?;
|
||||
let (source, _) = Self::new_on_disk(StreamingConfig::fast_start(), Some(size as u64), file);
|
||||
{
|
||||
let mut state = source.state.0.lock().unwrap();
|
||||
state.written = size;
|
||||
state.download_complete = true;
|
||||
}
|
||||
Ok(source)
|
||||
}
|
||||
|
||||
pub fn is_file_backed(&self) -> bool {
|
||||
self.state
|
||||
.0
|
||||
.lock()
|
||||
.map(|state| state.file.is_some())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
fn new_storage(
|
||||
config: StreamingConfig,
|
||||
total_size: Option<u64>,
|
||||
file: Option<File>,
|
||||
) -> (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!(
|
||||
let data = if file.is_some() {
|
||||
Vec::new()
|
||||
} else {
|
||||
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 = Vec::with_capacity(config.initial_buffer_bytes);
|
||||
}
|
||||
v
|
||||
}
|
||||
v
|
||||
None => Vec::with_capacity(config.initial_buffer_bytes),
|
||||
}
|
||||
None => Vec::with_capacity(config.initial_buffer_bytes),
|
||||
};
|
||||
|
||||
let state = Arc::new((
|
||||
Mutex::new(BufferState {
|
||||
data,
|
||||
data: Arc::new(data),
|
||||
file: file.map(Arc::new),
|
||||
written: 0,
|
||||
download_complete: false,
|
||||
download_error: None,
|
||||
total_size,
|
||||
@@ -255,7 +363,10 @@ impl BufferedMediaSource {
|
||||
read_pos: std::sync::atomic::AtomicU64::new(0),
|
||||
};
|
||||
|
||||
let writer = BufferWriter { state };
|
||||
let writer = BufferWriter {
|
||||
state,
|
||||
append: Arc::new(Mutex::new(())),
|
||||
};
|
||||
|
||||
(source, writer)
|
||||
}
|
||||
@@ -282,7 +393,7 @@ impl BufferedMediaSource {
|
||||
.lock()
|
||||
.map_err(|_| IoError::new(ErrorKind::Other, "Failed to acquire buffer lock"))?;
|
||||
|
||||
while state.data.len() < self.config.initial_buffer_bytes
|
||||
while state.len() < self.config.initial_buffer_bytes
|
||||
&& !state.download_complete
|
||||
&& state.download_error.is_none()
|
||||
{
|
||||
@@ -312,7 +423,7 @@ impl BufferedMediaSource {
|
||||
pub fn buffer_size(&self) -> usize {
|
||||
let (lock, _) = &*self.state;
|
||||
if let Ok(state) = lock.lock() {
|
||||
state.data.len()
|
||||
state.len()
|
||||
} else {
|
||||
0
|
||||
}
|
||||
@@ -323,19 +434,21 @@ impl BufferedMediaSource {
|
||||
/// Used to store in cache after streaming playback completes.
|
||||
/// Returns None if download is not complete or failed.
|
||||
///
|
||||
/// IMPORTANT: This clones the buffer rather than moving it. Earlier
|
||||
/// attempts to use `mem::take` here regressed playback — the
|
||||
/// `Source` impl is still actively reading from `state.data` when
|
||||
/// the promotion path calls this, and zeroing the buffer out from
|
||||
/// under the reader caused immediate EOF (tracks ending 10s into a
|
||||
/// 104s file). Cloning is the safe choice; the audible hiccup at
|
||||
/// promotion that the move attempted to fix needs a different
|
||||
/// approach (shared `Arc<Vec<u8>>` ownership, or off-thread copy).
|
||||
/// Legacy copying interface. The completed allocation is shared under
|
||||
/// the mutex; the copy happens after releasing it. Promotion uses
|
||||
/// `complete_data_shared` directly. Never take the active reader's bytes.
|
||||
pub fn take_complete_data(&self) -> Option<Vec<u8>> {
|
||||
self.complete_data_shared().map(|data| (*data).clone())
|
||||
}
|
||||
|
||||
/// Completed buffers are immutable and can be shared without copying or
|
||||
/// taking bytes away from the active decoder. The reader lock is held only
|
||||
/// long enough to clone an Arc, regardless of track size.
|
||||
pub fn complete_data_shared(&self) -> Option<Arc<Vec<u8>>> {
|
||||
let (lock, _) = &*self.state;
|
||||
if let Ok(state) = lock.lock() {
|
||||
if state.download_complete && state.download_error.is_none() {
|
||||
Some(state.data.clone())
|
||||
if state.download_complete && state.download_error.is_none() && state.file.is_none() {
|
||||
Some(Arc::clone(&state.data))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
@@ -350,15 +463,27 @@ impl BufferedMediaSource {
|
||||
/// Useful for extracting file-level metadata (e.g., ReplayGain tags)
|
||||
/// which are typically in the first few KB of the file.
|
||||
pub fn get_buffered_data(&self) -> Option<Vec<u8>> {
|
||||
let (lock, _) = &*self.state;
|
||||
if let Ok(state) = lock.lock() {
|
||||
if state.data.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(state.data.clone())
|
||||
// File-level tags live in the prefix, not in hundreds of MiB of frames.
|
||||
const METADATA_LIMIT: usize = 1024 * 1024;
|
||||
let state = self.state.0.lock().ok()?;
|
||||
let count = state.len().min(METADATA_LIMIT);
|
||||
if count == 0 {
|
||||
return None;
|
||||
}
|
||||
if let Some(file) = state.file.clone() {
|
||||
drop(state);
|
||||
let mut prefix = vec![0; count];
|
||||
let mut offset = 0;
|
||||
while offset < count {
|
||||
let n = file_read_at(&file, &mut prefix[offset..], offset as u64).ok()?;
|
||||
if n == 0 {
|
||||
return None;
|
||||
}
|
||||
offset += n;
|
||||
}
|
||||
Some(prefix)
|
||||
} else {
|
||||
None
|
||||
Some(state.data[..count].to_vec())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -372,7 +497,7 @@ impl BufferedMediaSource {
|
||||
if total == 0 {
|
||||
1.0
|
||||
} else {
|
||||
state.data.len() as f32 / total as f32
|
||||
state.len() as f32 / total as f32
|
||||
}
|
||||
})
|
||||
} else {
|
||||
@@ -387,7 +512,7 @@ impl BufferedMediaSource {
|
||||
pub fn has_min_buffer(&self) -> bool {
|
||||
let (lock, _) = &*self.state;
|
||||
if let Ok(state) = lock.lock() {
|
||||
state.data.len() >= self.config.initial_buffer_bytes || state.download_complete
|
||||
state.len() >= self.config.initial_buffer_bytes || state.download_complete
|
||||
} else {
|
||||
false
|
||||
}
|
||||
@@ -414,9 +539,7 @@ impl Read for BufferedMediaSource {
|
||||
let read_pos = self.read_pos.load(Ordering::SeqCst) as usize;
|
||||
|
||||
// Wait for data if we're ahead of buffer
|
||||
while read_pos >= state.data.len()
|
||||
&& !state.download_complete
|
||||
&& state.download_error.is_none()
|
||||
while read_pos >= state.len() && !state.download_complete && state.download_error.is_none()
|
||||
{
|
||||
state = cvar
|
||||
.wait(state)
|
||||
@@ -429,14 +552,20 @@ impl Read for BufferedMediaSource {
|
||||
}
|
||||
|
||||
// EOF if at end and download complete
|
||||
if read_pos >= state.data.len() && state.download_complete {
|
||||
if read_pos >= state.len() && state.download_complete {
|
||||
return Ok(0);
|
||||
}
|
||||
|
||||
// Read available data
|
||||
let available = state.data.len() - read_pos;
|
||||
let available = state.len() - read_pos;
|
||||
let to_read = buf.len().min(available);
|
||||
buf[..to_read].copy_from_slice(&state.data[read_pos..read_pos + to_read]);
|
||||
let to_read = if let Some(file) = state.file.clone() {
|
||||
drop(state);
|
||||
file_read_at(&file, &mut buf[..to_read], read_pos as u64)?
|
||||
} else {
|
||||
buf[..to_read].copy_from_slice(&state.data[read_pos..read_pos + to_read]);
|
||||
to_read
|
||||
};
|
||||
self.read_pos
|
||||
.store((read_pos + to_read) as u64, Ordering::SeqCst);
|
||||
|
||||
@@ -463,7 +592,7 @@ impl Seek for BufferedMediaSource {
|
||||
if let Some(total) = state.total_size {
|
||||
total as i64 + offset
|
||||
} else if state.download_complete {
|
||||
state.data.len() as i64 + offset
|
||||
state.len() as i64 + offset
|
||||
} else {
|
||||
// Can't seek from end without knowing size
|
||||
return Err(IoError::new(
|
||||
@@ -484,7 +613,7 @@ impl Seek for BufferedMediaSource {
|
||||
let new_pos_usize = new_pos as usize;
|
||||
|
||||
// If seeking forward beyond buffer, wait for data
|
||||
while new_pos_usize > state.data.len()
|
||||
while new_pos_usize > state.len()
|
||||
&& !state.download_complete
|
||||
&& state.download_error.is_none()
|
||||
{
|
||||
@@ -498,7 +627,7 @@ impl Seek for BufferedMediaSource {
|
||||
}
|
||||
|
||||
// After download complete, check bounds
|
||||
if state.download_complete && new_pos_usize > state.data.len() {
|
||||
if state.download_complete && new_pos_usize > state.len() {
|
||||
return Err(IoError::new(
|
||||
ErrorKind::InvalidInput,
|
||||
"Seek position beyond end of stream",
|
||||
@@ -534,19 +663,50 @@ impl MediaSource for BufferedMediaSource {
|
||||
#[derive(Clone)]
|
||||
pub struct BufferWriter {
|
||||
state: Arc<(Mutex<BufferState>, Condvar)>,
|
||||
append: Arc<Mutex<()>>,
|
||||
}
|
||||
|
||||
impl BufferWriter {
|
||||
pub fn complete_data_shared(&self) -> Option<Arc<Vec<u8>>> {
|
||||
let state = self.state.0.lock().ok()?;
|
||||
(state.download_complete && state.download_error.is_none() && state.file.is_none())
|
||||
.then(|| Arc::clone(&state.data))
|
||||
}
|
||||
|
||||
/// Push a chunk of downloaded data
|
||||
///
|
||||
/// This wakes up any readers waiting for data.
|
||||
pub fn push_chunk(&self, chunk: &[u8]) -> Result<(), String> {
|
||||
let _append = self
|
||||
.append
|
||||
.lock()
|
||||
.map_err(|_| "Failed to acquire stream append lock")?;
|
||||
let (lock, cvar) = &*self.state;
|
||||
let mut state = lock.lock().map_err(|_| "Failed to acquire buffer lock")?;
|
||||
|
||||
state.data.extend_from_slice(chunk);
|
||||
if state.download_complete || state.download_error.is_some() {
|
||||
return Err("Cannot append to a completed or failed stream".into());
|
||||
}
|
||||
if let Some(file) = state.file.clone() {
|
||||
let offset = state.written;
|
||||
drop(state);
|
||||
if let Err(error) = file_write_all_at(&file, chunk, offset as u64) {
|
||||
let message = format!("stream spool write failed: {error}");
|
||||
let _ = self.error(message.clone());
|
||||
return Err(message);
|
||||
}
|
||||
state = lock.lock().map_err(|_| "Failed to acquire buffer lock")?;
|
||||
if state.download_complete || state.download_error.is_some() {
|
||||
return Err("Stream was sealed during disk append".into());
|
||||
}
|
||||
state.written = offset
|
||||
.checked_add(chunk.len())
|
||||
.ok_or("Stream size overflow")?;
|
||||
} else {
|
||||
Arc::get_mut(&mut state.data)
|
||||
.ok_or("Streaming buffer shared before completion")?
|
||||
.extend_from_slice(chunk);
|
||||
}
|
||||
cvar.notify_all();
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -585,7 +745,7 @@ impl BufferWriter {
|
||||
pub fn buffer_size(&self) -> usize {
|
||||
let (lock, _) = &*self.state;
|
||||
if let Ok(state) = lock.lock() {
|
||||
state.data.len()
|
||||
state.len()
|
||||
} else {
|
||||
0
|
||||
}
|
||||
@@ -598,6 +758,38 @@ impl BufferWriter {
|
||||
/// 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> {
|
||||
if let Some(data) = self.complete_data_shared() {
|
||||
out.write_all(&data)?;
|
||||
return Ok(data.len());
|
||||
}
|
||||
let snapshot = {
|
||||
let state = self
|
||||
.state
|
||||
.0
|
||||
.lock()
|
||||
.map_err(|_| IoError::other("buffer lock poisoned"))?;
|
||||
if let Some(error) = state.download_error.as_ref() {
|
||||
return Err(IoError::other(error.clone()));
|
||||
}
|
||||
state.file.clone().map(|file| (file, state.len()))
|
||||
};
|
||||
if let Some((file, size)) = snapshot {
|
||||
let mut chunk = vec![0; 256 * 1024];
|
||||
let mut offset = 0;
|
||||
while offset < size {
|
||||
let count = chunk.len().min(size - offset);
|
||||
let n = file_read_at(&file, &mut chunk[..count], offset as u64)?;
|
||||
if n == 0 {
|
||||
return Err(IoError::new(
|
||||
ErrorKind::UnexpectedEof,
|
||||
"stream spool truncated",
|
||||
));
|
||||
}
|
||||
out.write_all(&chunk[..n])?;
|
||||
offset += n;
|
||||
}
|
||||
return Ok(offset);
|
||||
}
|
||||
const CHUNK: usize = 1024 * 1024;
|
||||
let (lock, _) = &*self.state;
|
||||
let mut offset = 0usize;
|
||||
@@ -605,10 +797,10 @@ impl BufferWriter {
|
||||
let state = lock
|
||||
.lock()
|
||||
.map_err(|_| IoError::new(ErrorKind::Other, "Failed to acquire buffer lock"))?;
|
||||
if offset >= state.data.len() {
|
||||
if offset >= state.len() {
|
||||
break;
|
||||
}
|
||||
let end = (offset + CHUNK).min(state.data.len());
|
||||
let end = (offset + CHUNK).min(state.len());
|
||||
out.write_all(&state.data[offset..end])?;
|
||||
offset = end;
|
||||
}
|
||||
@@ -1159,6 +1351,269 @@ mod tests {
|
||||
use std::thread;
|
||||
use std::time::Duration;
|
||||
|
||||
fn hires_wav() -> Vec<u8> {
|
||||
// A real PCM WAV container: stereo, signed 24-bit, 192 kHz, two seconds.
|
||||
let frames = 384000u32;
|
||||
let size = frames * 6;
|
||||
let mut wav = Vec::with_capacity(size as usize + 44);
|
||||
wav.extend_from_slice(b"RIFF");
|
||||
wav.extend_from_slice(&(size + 36).to_le_bytes());
|
||||
wav.extend_from_slice(b"WAVEfmt ");
|
||||
wav.extend_from_slice(&16u32.to_le_bytes());
|
||||
wav.extend_from_slice(&1u16.to_le_bytes());
|
||||
wav.extend_from_slice(&2u16.to_le_bytes());
|
||||
wav.extend_from_slice(&192000u32.to_le_bytes());
|
||||
wav.extend_from_slice(&1152000u32.to_le_bytes());
|
||||
wav.extend_from_slice(&6u16.to_le_bytes());
|
||||
wav.extend_from_slice(&24u16.to_le_bytes());
|
||||
wav.extend_from_slice(b"data");
|
||||
wav.extend_from_slice(&size.to_le_bytes());
|
||||
for frame in 0..frames {
|
||||
let value = (frame % 100000) as i32 - 50000;
|
||||
for sample in [value, -value] {
|
||||
wav.extend_from_slice(&sample.to_le_bytes()[..3]);
|
||||
}
|
||||
}
|
||||
wav
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn disk_24bit_192khz_decode_and_seek_match_memory_source() {
|
||||
let wav = hires_wav();
|
||||
let (source, writer) = BufferedMediaSource::new_on_disk(
|
||||
StreamingConfig::fast_start(),
|
||||
Some(wav.len() as u64),
|
||||
tempfile::tempfile().unwrap(),
|
||||
);
|
||||
writer.push_chunk(&wav[..wav.len() / 2]).unwrap();
|
||||
let source = Arc::new(source);
|
||||
let mut disk = IncrementalStreamingSource::new(source.clone()).unwrap();
|
||||
let mut memory = InMemorySource::new(wav.clone()).unwrap();
|
||||
assert_eq!(disk.sample_rate().get(), 192000);
|
||||
assert_eq!(disk.channels().get(), 2);
|
||||
for _ in 0..1000 {
|
||||
assert_eq!(disk.next(), memory.next());
|
||||
}
|
||||
writer.push_chunk(&wav[wav.len() / 2..]).unwrap();
|
||||
writer.complete().unwrap();
|
||||
for _ in 1000..384000 * 2 {
|
||||
assert_eq!(disk.next(), memory.next());
|
||||
}
|
||||
assert_eq!(disk.next(), None);
|
||||
for seconds in [1, 0, 1] {
|
||||
disk.seek_to(Duration::from_secs(seconds)).unwrap();
|
||||
memory.seek_to(Duration::from_secs(seconds)).unwrap();
|
||||
for _ in 0..1000 {
|
||||
assert_eq!(disk.next(), memory.next());
|
||||
}
|
||||
}
|
||||
assert_eq!(source.state.0.lock().unwrap().data.capacity(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn real_disk_write_failure_is_published_to_readers() {
|
||||
let temp = tempfile::NamedTempFile::new().unwrap();
|
||||
let file = File::open(temp.path()).unwrap(); // read-only descriptor
|
||||
let (mut source, writer) =
|
||||
BufferedMediaSource::new_on_disk(StreamingConfig::fast_start(), None, file);
|
||||
assert!(writer
|
||||
.push_chunk(b"cannot write")
|
||||
.unwrap_err()
|
||||
.contains("spool write failed"));
|
||||
assert!(source
|
||||
.download_error()
|
||||
.unwrap()
|
||||
.contains("spool write failed"));
|
||||
assert!(source.read(&mut [0; 1]).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn disk_buffer_streams_concurrently_without_retaining_payload_in_ram() {
|
||||
let temp = tempfile::tempfile().unwrap();
|
||||
let count = 256;
|
||||
let chunk = vec![73; 16 * 1024];
|
||||
let size = count * chunk.len();
|
||||
let (mut source, writer) = BufferedMediaSource::new_on_disk(
|
||||
StreamingConfig::fast_start(),
|
||||
Some(size as u64),
|
||||
temp,
|
||||
);
|
||||
let feeder = thread::spawn(move || {
|
||||
for _ in 0..count {
|
||||
writer.push_chunk(&chunk).unwrap();
|
||||
}
|
||||
writer.complete().unwrap();
|
||||
assert!(writer.push_chunk(b"late").is_err());
|
||||
writer
|
||||
});
|
||||
let mut consumed = 0;
|
||||
let mut read = [0; 4096];
|
||||
loop {
|
||||
let n = source.read(&mut read).unwrap();
|
||||
if n == 0 {
|
||||
break;
|
||||
}
|
||||
assert!(read[..n].iter().all(|byte| *byte == 73));
|
||||
consumed += n;
|
||||
}
|
||||
let writer = feeder.join().unwrap();
|
||||
assert_eq!(consumed, size);
|
||||
assert_eq!(source.state.0.lock().unwrap().data.capacity(), 0);
|
||||
assert!(source.complete_data_shared().is_none());
|
||||
let mut reader2 = source.create_reader();
|
||||
source.seek(SeekFrom::Start(123)).unwrap();
|
||||
source.read_exact(&mut read[..1]).unwrap();
|
||||
assert_eq!(
|
||||
reader2.read_pos.load(std::sync::atomic::Ordering::SeqCst),
|
||||
0
|
||||
);
|
||||
reader2.seek(SeekFrom::End(-1)).unwrap();
|
||||
reader2.read_exact(&mut read[..1]).unwrap();
|
||||
assert_eq!(read[0], 73);
|
||||
let mut copied = Vec::new();
|
||||
assert_eq!(writer.write_buffered_to(&mut copied).unwrap(), size);
|
||||
assert!(copied.iter().all(|byte| *byte == 73));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_disk_buffer_wakes_reader_and_cannot_be_cached() {
|
||||
let (mut source, writer) = BufferedMediaSource::new_on_disk(
|
||||
StreamingConfig::fast_start(),
|
||||
None,
|
||||
tempfile::tempfile().unwrap(),
|
||||
);
|
||||
let reader = thread::spawn(move || source.read(&mut [0; 8]));
|
||||
writer.error("fixture disk failure".into()).unwrap();
|
||||
assert!(reader.join().unwrap().is_err());
|
||||
assert!(writer.write_buffered_to(&mut Vec::new()).is_err());
|
||||
assert!(writer.push_chunk(b"late").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sealed_buffer_shares_allocation_without_truncating_active_reader() {
|
||||
let (mut source, writer) =
|
||||
BufferedMediaSource::new(StreamingConfig::from_seconds(1), Some(10));
|
||||
writer.push_chunk(b"0123456789").unwrap();
|
||||
assert!(source.complete_data_shared().is_none());
|
||||
let pointer = source.state.0.lock().unwrap().data.as_ptr();
|
||||
let mut prefix = [0; 3];
|
||||
source.read_exact(&mut prefix).unwrap();
|
||||
writer.complete().unwrap();
|
||||
let shared = source.complete_data_shared().unwrap();
|
||||
assert_eq!(pointer, shared.as_ptr());
|
||||
assert!(Arc::ptr_eq(
|
||||
&shared,
|
||||
&writer.complete_data_shared().unwrap()
|
||||
));
|
||||
assert!(writer.push_chunk(b"late bytes").is_err());
|
||||
let mut tail = Vec::new();
|
||||
source.read_to_end(&mut tail).unwrap();
|
||||
assert_eq!(tail, b"3456789");
|
||||
source.seek(SeekFrom::Start(0)).unwrap();
|
||||
source.read_exact(&mut prefix).unwrap();
|
||||
assert_eq!(prefix, *b"012");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn promotion_keeps_24bit_192khz_samples_continuous_and_seekable() {
|
||||
let frames = 384000u32;
|
||||
let size = frames * 6;
|
||||
let wav = hires_wav();
|
||||
let (buffer, writer) =
|
||||
BufferedMediaSource::new(StreamingConfig::from_seconds(1), Some(wav.len() as u64));
|
||||
let split = 44 + size as usize / 2;
|
||||
writer.push_chunk(&wav[..split]).unwrap();
|
||||
let buffer = Arc::new(buffer);
|
||||
let mut streaming = IncrementalStreamingSource::new(buffer.clone()).unwrap();
|
||||
assert_eq!(streaming.get_sample_rate(), 192000);
|
||||
let expected = |index: usize| {
|
||||
let value = ((index / 2) % 100000) as i32 - 50000;
|
||||
(if index % 2 == 0 { value } else { -value }) as f32 / 8388608.0
|
||||
};
|
||||
for index in 0..1000 {
|
||||
assert_eq!(streaming.next().unwrap(), expected(index));
|
||||
}
|
||||
writer.push_chunk(&wav[split..]).unwrap();
|
||||
writer.complete().unwrap();
|
||||
let shared = buffer.complete_data_shared().unwrap();
|
||||
for index in 1000..frames as usize * 2 {
|
||||
assert_eq!(streaming.next().unwrap(), expected(index));
|
||||
}
|
||||
assert!(streaming.next().is_none());
|
||||
let mut replay = InMemorySource::from_shared(shared).unwrap();
|
||||
assert_eq!(replay.sample_rate(), 192000);
|
||||
assert_eq!(replay.channels(), 2);
|
||||
// Seek remains packet-aligned, matching the pre-existing Vec-backed path.
|
||||
let mut baseline = InMemorySource::new(wav).unwrap();
|
||||
baseline.seek_to(Duration::from_secs(1)).unwrap();
|
||||
replay.seek_to(Duration::from_secs(1)).unwrap();
|
||||
for _ in 0..1000 {
|
||||
assert_eq!(replay.next().unwrap(), baseline.next().unwrap());
|
||||
}
|
||||
replay.seek_to(Duration::ZERO).unwrap();
|
||||
for index in 0..1000 {
|
||||
assert_eq!(replay.next().unwrap(), expected(index));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires QBZ_HIRES_FIXTURE pointing to a real 24/192 FLAC file"]
|
||||
fn real_hires_file_streaming_promotion_matches_uninterrupted_decode() {
|
||||
let path = std::env::var("QBZ_HIRES_FIXTURE").expect("set QBZ_HIRES_FIXTURE");
|
||||
let bytes = std::fs::read(path).unwrap();
|
||||
let (buffer, writer) =
|
||||
BufferedMediaSource::new(StreamingConfig::from_seconds(1), Some(bytes.len() as u64));
|
||||
writer.push_chunk(&bytes).unwrap();
|
||||
drop(bytes);
|
||||
let buffer = Arc::new(buffer);
|
||||
let mut streaming = IncrementalStreamingSource::new(buffer.clone()).unwrap();
|
||||
assert_eq!(streaming.get_sample_rate(), 192000);
|
||||
let first: Vec<_> = streaming.by_ref().take(1000).collect();
|
||||
writer.complete().unwrap();
|
||||
let shared = buffer.complete_data_shared().unwrap();
|
||||
let mut reference = InMemorySource::from_shared(shared).unwrap();
|
||||
assert_eq!(reference.sample_rate(), 192000);
|
||||
for sample in first {
|
||||
assert_eq!(Some(sample), reference.next());
|
||||
}
|
||||
let mut count = 1000usize;
|
||||
for sample in streaming {
|
||||
assert_eq!(Some(sample), reference.next());
|
||||
count += 1;
|
||||
}
|
||||
assert!(reference.next().is_none());
|
||||
assert!(count > 384000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires QBZ_HIRES_FIXTURE pointing to a real 24/192 FLAC file"]
|
||||
fn real_hires_disk_file_matches_memory_decode_and_seeks() {
|
||||
let path = std::env::var("QBZ_HIRES_FIXTURE").expect("set QBZ_HIRES_FIXTURE");
|
||||
let buffer = Arc::new(BufferedMediaSource::from_file(std::fs::File::open(&path).unwrap()).unwrap());
|
||||
let mut disk = IncrementalStreamingSource::new(buffer.clone()).unwrap();
|
||||
let mut reference = InMemorySource::new(std::fs::read(path).unwrap()).unwrap();
|
||||
assert_eq!(disk.get_sample_rate(), 192000);
|
||||
assert_eq!(reference.sample_rate(), 192000);
|
||||
let mut count = 0usize;
|
||||
for sample in disk.by_ref() {
|
||||
assert_eq!(Some(sample), reference.next(), "sample {count}");
|
||||
count += 1;
|
||||
}
|
||||
assert!(reference.next().is_none());
|
||||
assert!(count > 384000);
|
||||
for seconds in [500, 10, 0, 600] {
|
||||
let position = Duration::from_secs(seconds);
|
||||
disk.seek_to(position).unwrap();
|
||||
reference.seek_to(position).unwrap();
|
||||
for index in 0..384000 {
|
||||
assert_eq!(disk.next(), reference.next(), "seek {seconds}s sample {index}");
|
||||
}
|
||||
}
|
||||
assert!(buffer.complete_data_shared().is_none());
|
||||
assert_eq!(buffer.state.0.lock().unwrap().data.capacity(), 0);
|
||||
eprintln!("real disk FLAC: {count} samples matched, four seeks matched");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ready_reports_only_on_first_decode_and_stall_recovery() {
|
||||
assert!(should_report_ready(0, false));
|
||||
|
||||
@@ -580,6 +580,7 @@ fn main() {
|
||||
"qml/controls/QbzEmptyState.qml",
|
||||
"qml/controls/QbzIconButton.qml",
|
||||
"qml/controls/QbzLineEdit.qml",
|
||||
"qml/controls/QbzTextEditMenu.qml",
|
||||
"qml/controls/InputFocusDismiss.qml",
|
||||
"qml/controls/QbzLoadingDots.qml",
|
||||
"qml/controls/QbzLoadMore.qml",
|
||||
@@ -605,6 +606,7 @@ fn main() {
|
||||
"qml/controls/QbzTooltip.qml",
|
||||
"qml/controls/PanelResizeHandle.qml",
|
||||
"qml/controls/QbzProgressRing.qml",
|
||||
"qml/controls/QbzCompactSeekBar.qml",
|
||||
// Applied-filters tooltip: the trigger a filter control mounts
|
||||
// beside itself (it writes the shell channel QbzTooltip reads).
|
||||
"qml/controls/QbzFilterTip.qml",
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="#4285f4"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<rect width="20" height="8" x="2" y="2" rx="2" ry="2" />
|
||||
<rect width="20" height="8" x="2" y="14" rx="2" ry="2" />
|
||||
<line x1="6" x2="6.01" y1="6" y2="6" />
|
||||
<line x1="6" x2="6.01" y1="18" y2="18" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 408 B |
@@ -0,0 +1,16 @@
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="#000000"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<rect width="20" height="8" x="2" y="2" rx="2" ry="2" />
|
||||
<rect width="20" height="8" x="2" y="14" rx="2" ry="2" />
|
||||
<line x1="6" x2="6.01" y1="6" y2="6" />
|
||||
<line x1="6" x2="6.01" y1="18" y2="18" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 408 B |
@@ -0,0 +1,16 @@
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="#ef4444"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<rect width="20" height="8" x="2" y="2" rx="2" ry="2" />
|
||||
<rect width="20" height="8" x="2" y="14" rx="2" ry="2" />
|
||||
<line x1="6" x2="6.01" y1="6" y2="6" />
|
||||
<line x1="6" x2="6.01" y1="18" y2="18" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 408 B |
@@ -0,0 +1,16 @@
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="#888888"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<rect width="20" height="8" x="2" y="2" rx="2" ry="2" />
|
||||
<rect width="20" height="8" x="2" y="14" rx="2" ry="2" />
|
||||
<line x1="6" x2="6.01" y1="6" y2="6" />
|
||||
<line x1="6" x2="6.01" y1="18" y2="18" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 408 B |
@@ -0,0 +1,16 @@
|
||||
<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"
|
||||
>
|
||||
<rect width="20" height="8" x="2" y="2" rx="2" ry="2" />
|
||||
<rect width="20" height="8" x="2" y="14" rx="2" ry="2" />
|
||||
<line x1="6" x2="6.01" y1="6" y2="6" />
|
||||
<line x1="6" x2="6.01" y1="18" y2="18" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 408 B |
@@ -0,0 +1,16 @@
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="#cccccc"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<rect width="20" height="8" x="2" y="2" rx="2" ry="2" />
|
||||
<rect width="20" height="8" x="2" y="14" rx="2" ry="2" />
|
||||
<line x1="6" x2="6.01" y1="6" y2="6" />
|
||||
<line x1="6" x2="6.01" y1="18" y2="18" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 408 B |
@@ -0,0 +1,16 @@
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="#fbbf24"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<rect width="20" height="8" x="2" y="2" rx="2" ry="2" />
|
||||
<rect width="20" height="8" x="2" y="14" rx="2" ry="2" />
|
||||
<line x1="6" x2="6.01" y1="6" y2="6" />
|
||||
<line x1="6" x2="6.01" y1="18" y2="18" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 408 B |
@@ -230,6 +230,8 @@ Item {
|
||||
border.color: searchInput.activeFocus ? theme.accent : theme.borderSubtle
|
||||
|
||||
TextInput {
|
||||
|
||||
QbzTextEditMenu { }
|
||||
id: searchInput
|
||||
anchors.fill: parent
|
||||
anchors.leftMargin: 12
|
||||
@@ -413,6 +415,8 @@ Item {
|
||||
border.color: nameInput.activeFocus ? theme.accent : theme.borderSubtle
|
||||
|
||||
TextInput {
|
||||
|
||||
QbzTextEditMenu { }
|
||||
id: nameInput
|
||||
anchors.fill: parent
|
||||
anchors.leftMargin: 12
|
||||
|
||||
@@ -197,6 +197,8 @@ Item {
|
||||
border.color: queryInput.activeFocus ? theme.accent : theme.borderSubtle
|
||||
|
||||
TextInput {
|
||||
|
||||
QbzTextEditMenu { }
|
||||
id: queryInput
|
||||
anchors.fill: parent
|
||||
anchors.leftMargin: 12
|
||||
|
||||
@@ -243,6 +243,7 @@ Item {
|
||||
tintName: "muted"
|
||||
}
|
||||
TextInput {
|
||||
QbzTextEditMenu { }
|
||||
id: searchInput
|
||||
width: parent.width - 22
|
||||
height: parent.height
|
||||
|
||||
@@ -208,6 +208,7 @@ Popup {
|
||||
font.pixelSize: theme.fontLegal
|
||||
}
|
||||
TextEdit {
|
||||
QbzTextEditMenu { }
|
||||
id: factValue
|
||||
x: 154
|
||||
width: parent.width - x
|
||||
@@ -256,6 +257,7 @@ Popup {
|
||||
font.weight: theme.weightSemibold
|
||||
}
|
||||
TextEdit {
|
||||
QbzTextEditMenu { }
|
||||
x: 12
|
||||
y: 28
|
||||
width: parent.width - 92
|
||||
|
||||
@@ -339,6 +339,8 @@ Item {
|
||||
opacity: root.loading ? 0.5 : 1.0
|
||||
|
||||
TextInput {
|
||||
|
||||
QbzTextEditMenu { }
|
||||
id: urlInput
|
||||
anchors.fill: parent
|
||||
anchors.leftMargin: 12
|
||||
@@ -542,6 +544,8 @@ Item {
|
||||
opacity: root.loading ? 0.5 : 1.0
|
||||
|
||||
TextInput {
|
||||
|
||||
QbzTextEditMenu { }
|
||||
id: serviceInput
|
||||
anchors.fill: parent
|
||||
anchors.leftMargin: 12
|
||||
@@ -667,6 +671,8 @@ Item {
|
||||
opacity: nameInput.enabled ? 1.0 : 0.5
|
||||
|
||||
TextInput {
|
||||
|
||||
QbzTextEditMenu { }
|
||||
id: nameInput
|
||||
anchors.fill: parent
|
||||
anchors.leftMargin: 12
|
||||
|
||||
@@ -258,6 +258,8 @@ Item {
|
||||
border.color: searchInput.activeFocus ? theme.accent : theme.borderSubtle
|
||||
|
||||
TextInput {
|
||||
|
||||
QbzTextEditMenu { }
|
||||
id: searchInput
|
||||
anchors.fill: parent
|
||||
anchors.leftMargin: 12
|
||||
@@ -359,6 +361,7 @@ Item {
|
||||
border.width: 1
|
||||
border.color: nameInput.activeFocus ? theme.accent : theme.borderSubtle
|
||||
TextInput {
|
||||
QbzTextEditMenu { }
|
||||
id: nameInput
|
||||
anchors.fill: parent
|
||||
anchors.leftMargin: 12
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
// Thin transport rail. The thumb extends outside the 3px layout slot; the
|
||||
// shell stacks its transport above the content and the time bubble uses Overlay.
|
||||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
import "../theme"
|
||||
|
||||
Item {
|
||||
id: root
|
||||
height: 3
|
||||
property bool hasTrack: false
|
||||
property real progress: 0
|
||||
property real cacheProgress: 0
|
||||
property real seekableMax: 1
|
||||
property real durationSecs: 0
|
||||
signal seekRequested(real fraction)
|
||||
function clamp01(value) { return Math.min(Math.max(value, 0), 1) }
|
||||
function fmt(secs) {
|
||||
var whole = Math.floor(Math.max(0, secs))
|
||||
return Math.floor(whole / 60) + ":" + (whole % 60 < 10 ? "0" : "") + whole % 60
|
||||
}
|
||||
QbzTheme { id: theme }
|
||||
Rectangle {
|
||||
id: track
|
||||
anchors.fill: parent
|
||||
radius: 2
|
||||
color: theme.surfaceElevated
|
||||
Rectangle {
|
||||
width: parent.width * root.clamp01(root.cacheProgress)
|
||||
height: parent.height
|
||||
radius: 2
|
||||
color: Qt.rgba(theme.textMuted.r, theme.textMuted.g, theme.textMuted.b, 0.35)
|
||||
}
|
||||
Rectangle {
|
||||
width: parent.width * root.clamp01(root.progress)
|
||||
height: parent.height
|
||||
radius: 2
|
||||
color: theme.accent
|
||||
}
|
||||
Rectangle {
|
||||
objectName: "compactSeekThumb"
|
||||
width: 12; height: 12; radius: 6
|
||||
color: theme.textPrimary
|
||||
x: parent.width * root.clamp01(root.progress) - width / 2
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
visible: area.containsMouse && root.hasTrack
|
||||
}
|
||||
}
|
||||
MouseArea {
|
||||
id: area
|
||||
objectName: "compactSeekArea"
|
||||
width: parent.width
|
||||
height: 18
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
hoverEnabled: true
|
||||
enabled: root.hasTrack
|
||||
cursorShape: root.clamp01(mouseX / width) > root.seekableMax
|
||||
? Qt.ForbiddenCursor : Qt.PointingHandCursor
|
||||
onClicked: root.seekRequested(Math.min(root.clamp01(mouseX / width), root.seekableMax))
|
||||
}
|
||||
Item {
|
||||
id: tip
|
||||
objectName: "compactSeekTip"
|
||||
parent: Overlay.overlay
|
||||
z: 10000
|
||||
visible: root.visible && root.hasTrack && area.containsMouse
|
||||
readonly property real fraction: root.clamp01(area.mouseX / Math.max(1, area.width))
|
||||
// Explicit geometry dependencies also reposition a stationary tooltip
|
||||
// when the window resizes; mapping alone does not notify QML bindings.
|
||||
readonly property point anchor: {
|
||||
var geometry = root.x + root.y + root.width + root.height
|
||||
var windowHeight = parent ? parent.height : 0
|
||||
return parent ? root.mapToItem(parent, root.width * fraction, 0) : Qt.point(0, 0)
|
||||
}
|
||||
width: bubble.width
|
||||
height: bubble.height + 9
|
||||
x: Math.max(4, Math.min((parent ? parent.width : width) - width - 4, anchor.x - width / 2))
|
||||
y: anchor.y - height + 3
|
||||
Rectangle {
|
||||
id: bubble
|
||||
width: label.implicitWidth + 14
|
||||
height: 18
|
||||
radius: 4
|
||||
color: theme.surfaceElevated
|
||||
border.width: 1
|
||||
border.color: theme.borderMuted
|
||||
Text {
|
||||
id: label
|
||||
objectName: "compactSeekTime"
|
||||
anchors.centerIn: parent
|
||||
text: root.fmt(tip.fraction * root.durationSecs)
|
||||
color: theme.textPrimary
|
||||
font.pixelSize: 11
|
||||
}
|
||||
}
|
||||
Rectangle {
|
||||
width: 9; height: 9
|
||||
rotation: 45
|
||||
color: theme.surfaceElevated
|
||||
anchors.horizontalCenter: bubble.horizontalCenter
|
||||
y: bubble.height - height / 2
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -224,6 +224,7 @@ Rectangle {
|
||||
// No clip: TextInput scissors itself (:207) and the
|
||||
// placeholder elides. A redundant second scissor.
|
||||
TextInput {
|
||||
QbzTextEditMenu { }
|
||||
id: input
|
||||
anchors.fill: parent
|
||||
color: theme.textPrimary
|
||||
|
||||
@@ -22,26 +22,38 @@ Item {
|
||||
id: root
|
||||
|
||||
property string label: ""
|
||||
property string description: ""
|
||||
property bool kioskHost: false
|
||||
property bool selected: false
|
||||
signal clicked()
|
||||
|
||||
QbzTheme { id: theme }
|
||||
|
||||
implicitWidth: row.implicitWidth
|
||||
implicitHeight: Math.max(18, lbl.implicitHeight)
|
||||
implicitWidth: lbl.implicitWidth + 24
|
||||
implicitHeight: Math.max(root.kioskHost ? 44 : 18, row.implicitHeight)
|
||||
activeFocusOnTab: enabled
|
||||
Accessible.role: Accessible.RadioButton
|
||||
Accessible.name: label
|
||||
Accessible.description: description
|
||||
Accessible.checked: selected
|
||||
Accessible.onPressAction: if (enabled) clicked()
|
||||
Keys.onSpacePressed: clicked()
|
||||
Keys.onReturnPressed: clicked()
|
||||
Keys.onEnterPressed: clicked()
|
||||
|
||||
Row {
|
||||
id: row
|
||||
width: parent.width
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
spacing: 6
|
||||
|
||||
Rectangle {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
y: Math.max(0, (lbl.height - height) / 2)
|
||||
width: 18
|
||||
height: 18
|
||||
radius: 9
|
||||
border.width: root.selected ? 0 : 1.5
|
||||
border.color: theme.textMuted
|
||||
border.width: root.selected && !root.activeFocus ? 0 : 1.5
|
||||
border.color: root.activeFocus ? theme.accent : theme.textMuted
|
||||
color: root.selected ? theme.accent : "transparent"
|
||||
Rectangle {
|
||||
visible: root.selected
|
||||
@@ -52,19 +64,32 @@ Item {
|
||||
color: theme.accentGlyphColor
|
||||
}
|
||||
}
|
||||
Text {
|
||||
id: lbl
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: root.label
|
||||
color: theme.textPrimary
|
||||
font.pixelSize: theme.fontBody
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
Column {
|
||||
width: parent.width - 24
|
||||
spacing: 4
|
||||
Text {
|
||||
id: lbl
|
||||
width: parent.width
|
||||
text: root.label
|
||||
color: theme.textPrimary
|
||||
font.pixelSize: root.kioskHost ? theme.fontBody * 1.2 : theme.fontBody
|
||||
wrapMode: Text.WordWrap
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
}
|
||||
Text {
|
||||
visible: root.description !== ""
|
||||
width: parent.width
|
||||
text: root.description
|
||||
color: theme.textMuted
|
||||
font.pixelSize: root.kioskHost ? theme.fontLegal * 1.2 : theme.fontLegal
|
||||
wrapMode: Text.WordWrap
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
anchors.fill: parent
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: root.clicked()
|
||||
onClicked: { root.forceActiveFocus(); root.clicked() }
|
||||
}
|
||||
}
|
||||
@@ -337,6 +337,7 @@ Rectangle {
|
||||
width: parent.width - 14 - 8
|
||||
height: parent.height
|
||||
TextInput {
|
||||
QbzTextEditMenu { }
|
||||
id: searchInput
|
||||
anchors.fill: parent
|
||||
color: theme.textPrimary
|
||||
|
||||
@@ -50,6 +50,8 @@ Rectangle {
|
||||
boundsBehavior: Flickable.StopAtBounds
|
||||
|
||||
TextEdit {
|
||||
|
||||
QbzTextEditMenu { }
|
||||
id: input
|
||||
width: flick.width
|
||||
text: root.text
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
// Pointer editing for both TextInput and TextEdit, including native TextFields.
|
||||
// Keep focus on the editor so form bindings and focus-loss commits stay intact.
|
||||
import QtQuick
|
||||
import com.blitzfc.qbz
|
||||
|
||||
Item {
|
||||
id: root
|
||||
anchors.fill: parent
|
||||
readonly property var editor: parent
|
||||
readonly property bool secret: editor.echoMode !== undefined && editor.echoMode !== TextInput.Normal
|
||||
readonly property bool writable: editor.enabled && !editor.readOnly
|
||||
Component.onCompleted: editor.selectByMouse = true
|
||||
property int editRevision: 0
|
||||
Connections {
|
||||
target: root.editor
|
||||
ignoreUnknownSignals: true
|
||||
function onTextEdited() { root.editRevision += 1 }
|
||||
}
|
||||
|
||||
function perform(action) {
|
||||
editor.forceActiveFocus()
|
||||
const before = editor.text
|
||||
const revision = editRevision
|
||||
if (action === "copy" && !secret) editor.copy()
|
||||
else if (action === "cut" && writable && !secret) editor.cut()
|
||||
else if (action === "paste" && writable) editor.paste()
|
||||
else if (action === "select-all") editor.selectAll()
|
||||
// Drive the same callback as typing if this Qt version did not emit
|
||||
// textEdited for cut/paste. Avoid double requests on versions that do.
|
||||
// TextEdit already notifies its consumers via textChanged.
|
||||
if (editor.text !== before && editor instanceof TextInput && editRevision === revision)
|
||||
editor.textEdited()
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
anchors.fill: parent
|
||||
acceptedButtons: Qt.RightButton
|
||||
onClicked: function(mouse) {
|
||||
root.editor.forceActiveFocus()
|
||||
menuLoader.active = true
|
||||
menuLoader.item.openAtCursor(root.editor, mouse.x, mouse.y)
|
||||
}
|
||||
}
|
||||
// Allocate menu rows/icons only when this field is first right-clicked.
|
||||
Loader {
|
||||
id: menuLoader
|
||||
active: false
|
||||
sourceComponent: CardMenu {
|
||||
id: menu
|
||||
objectName: "textEditContextMenu"
|
||||
menuWidth: 180
|
||||
focus: false
|
||||
entries: [
|
||||
{label: QbzSession.tr("Cut", QbzSession.trRev), icon: "scissors", action: "cut",
|
||||
enabled: root.writable && !root.secret && root.editor.selectedText !== ""},
|
||||
{label: QbzSession.tr("Copy", QbzSession.trRev), icon: "copy", action: "copy",
|
||||
enabled: !root.secret && root.editor.selectedText !== ""},
|
||||
{label: QbzSession.tr("Paste", QbzSession.trRev), icon: "clipboard", action: "paste",
|
||||
enabled: root.writable && root.editor.canPaste},
|
||||
{label: QbzSession.tr("Select all", QbzSession.trRev), icon: "square-check-big", action: "select-all",
|
||||
enabled: root.editor.text !== ""}
|
||||
]
|
||||
onPicked: function(action) { root.perform(action) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -14,12 +14,14 @@ Item {
|
||||
property string label: ""
|
||||
property string description: ""
|
||||
property bool rowEnabled: true
|
||||
property bool fitDescription: false
|
||||
default property alias control: controlHost.data
|
||||
|
||||
QbzTheme { id: theme }
|
||||
|
||||
width: parent ? parent.width : 0
|
||||
height: kioskHost ? labelColumn.height + controlHost.height + 24 : (description === "" ? 52 : 64)
|
||||
height: kioskHost ? labelColumn.height + controlHost.height + 24
|
||||
: Math.max(description === "" ? 52 : 64, fitDescription ? labelColumn.height + 16 : 0)
|
||||
|
||||
Column {
|
||||
id: labelColumn
|
||||
|
||||
@@ -220,6 +220,7 @@ Item {
|
||||
border.width: input.activeFocus ? 1 : 0
|
||||
border.color: theme.focusRing
|
||||
TextInput {
|
||||
QbzTextEditMenu { }
|
||||
id: input
|
||||
anchors.fill: parent
|
||||
leftPadding: 9
|
||||
|
||||
@@ -190,6 +190,8 @@ Item {
|
||||
border.color: queryInput.activeFocus ? theme.accent : theme.borderSubtle
|
||||
|
||||
TextInput {
|
||||
|
||||
QbzTextEditMenu { }
|
||||
id: queryInput
|
||||
anchors.fill: parent
|
||||
anchors.leftMargin: 12
|
||||
|
||||
@@ -83,6 +83,7 @@ Rectangle {
|
||||
KioskTab { text: "+"; onClicked: QbzMyQbz.createOpen(root.kind) }
|
||||
}
|
||||
TextField {
|
||||
QbzTextEditMenu { }
|
||||
id: search
|
||||
anchors.top: root.compactHeader ? root.top : tabs.bottom
|
||||
anchors.left: root.compactHeader ? tabs.right : parent.left
|
||||
|
||||
@@ -154,6 +154,7 @@ Rectangle {
|
||||
TouchButton { text: "⋯" + (root.doc.selectedCount > 0 ? " " + root.doc.selectedCount : ""); onClicked: root.showMenu() }
|
||||
}
|
||||
TextField {
|
||||
QbzTextEditMenu { }
|
||||
y: root.compactHeader ? 70 : 124
|
||||
width: parent.width
|
||||
height: 48
|
||||
|
||||
@@ -854,7 +854,7 @@ Rectangle {
|
||||
// per-row batch-root cost as the quality cell above.
|
||||
RoundedImage {
|
||||
anchors.fill: parent
|
||||
source: root.item.artPath || ""
|
||||
source: root.showArtwork ? (root.item.artPath || "") : ""
|
||||
radius: 4
|
||||
}
|
||||
// Per-item cover placeholder — clears when THIS row's cover lands,
|
||||
|
||||
@@ -56,6 +56,17 @@ Column {
|
||||
spacing: 4
|
||||
|
||||
// --- Theme row state (theme_qt.rs via the bridge) --------------------
|
||||
SettingRow { kioskHost: root.kioskHost;
|
||||
fitDescription: true
|
||||
label: QbzSession.tr("Visual resource profile", QbzSession.trRev)
|
||||
description: QbzSession.tr("Low uses the Small player, compact headers, no dynamic background, header gradient, spectrum, animated play indicator, sidebar collages or optional track artwork. Default restores these visual defaults. Editing a managed option selects Custom. Audio quality and playback memory are independent.", QbzSession.trRev)
|
||||
QbzSelect { kioskHost: root.kioskHost;
|
||||
menuWidth: 240
|
||||
options: [QbzSession.tr("Low", QbzSession.trRev), QbzSession.tr("Default", QbzSession.trRev), QbzSession.tr("Custom", QbzSession.trRev)]
|
||||
currentIndex: Math.max(0, ["low", "default", "custom"].indexOf(root.doc.appearanceProfile || "default"))
|
||||
onSelected: function(i) { QbzBridge.settingsString("appearance-profile", ["low", "default", "custom"][i]) }
|
||||
}
|
||||
}
|
||||
readonly property var themeEntries: {
|
||||
try {
|
||||
return JSON.parse(QbzShell.themeListJson)
|
||||
|
||||
@@ -668,6 +668,8 @@ Item {
|
||||
border.color: manualInput.activeFocus ? theme.accent : theme.borderSubtle
|
||||
|
||||
TextInput {
|
||||
|
||||
QbzTextEditMenu { }
|
||||
id: manualInput
|
||||
anchors.fill: parent
|
||||
anchors.leftMargin: 12
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
// (settings_qt/library.rs).
|
||||
|
||||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
import com.blitzfc.qbz
|
||||
import "../controls"
|
||||
import "../theme"
|
||||
@@ -51,11 +52,43 @@ Column {
|
||||
if (!ts) return QbzSession.tr("Never", QbzSession.trRev)
|
||||
return Qt.formatDateTime(new Date(ts * 1000), "yyyy-MM-dd hh:mm")
|
||||
}
|
||||
// Shared column geometry (header + rows MUST agree):
|
||||
// checkbox 20 · folder (rest) · last-scan 120 · status 84 · actions 110.
|
||||
readonly property int actionsW: root.kioskHost ? 200 : 110
|
||||
readonly property var statusLabels: ({
|
||||
active: QbzSession.tr("Active", QbzSession.trRev),
|
||||
hidden: QbzSession.tr("Hidden", QbzSession.trRev),
|
||||
missing: QbzSession.tr("Missing", QbzSession.trRev),
|
||||
disconnected: QbzSession.tr("Disconnected", QbzSession.trRev),
|
||||
denied: QbzSession.tr("Access denied", QbzSession.trRev),
|
||||
unavailable: QbzSession.tr("Unavailable", QbzSession.trRev),
|
||||
checking: QbzSession.tr("Checking...", QbzSession.trRev)
|
||||
})
|
||||
readonly property var statusDescriptions: ({
|
||||
active: QbzSession.tr("The folder is accessible and visible in the library.", QbzSession.trRev),
|
||||
hidden: QbzSession.tr("The folder is accessible but hidden by your preference.", QbzSession.trRev),
|
||||
missing: QbzSession.tr("The folder path no longer exists. Reconnect it, change its path or remove it from the library.", QbzSession.trRev),
|
||||
disconnected: QbzSession.tr("A connection failure prevents access to this storage.", QbzSession.trRev),
|
||||
denied: QbzSession.tr("QBZ does not have permission to access this folder.", QbzSession.trRev),
|
||||
unavailable: QbzSession.tr("The folder could not be accessed. The check timed out or returned another access error.", QbzSession.trRev),
|
||||
checking: QbzSession.tr("Folder access is being checked or has not been checked yet.", QbzSession.trRev)
|
||||
})
|
||||
FontMetrics {
|
||||
id: statusMetrics
|
||||
font.pixelSize: root.kioskHost ? theme.fontLegal * 1.2 : theme.fontLegal
|
||||
font.weight: theme.weightMedium
|
||||
}
|
||||
readonly property int statusW: Math.ceil(Math.max(110,
|
||||
...Object.values(root.statusLabels).map(function(s) { return statusMetrics.advanceWidth(s) + 20 })))
|
||||
readonly property int actionButtonW: root.kioskHost ? 44 : 40
|
||||
readonly property int actionsW: 4 * actionButtonW + 3 * 4
|
||||
function folderColW(rowWidth) {
|
||||
return Math.max(60, rowWidth - (root.kioskHost ? 44 : 20) - 120 - 84 - actionsW - 4 * 12)
|
||||
return Math.max(100, rowWidth - (root.kioskHost ? 44 : 20) - 120 - statusW - actionsW - 4 * 12)
|
||||
}
|
||||
property string lastPickedPath: ""
|
||||
onLibChanged: {
|
||||
const picked = root.lib.picked_path || ""
|
||||
if (picked !== lastPickedPath) {
|
||||
lastPickedPath = picked
|
||||
if (picked !== "") { root.pendingPath = picked; pathField.text = picked }
|
||||
}
|
||||
}
|
||||
|
||||
Item {
|
||||
@@ -103,10 +136,7 @@ Column {
|
||||
onEdited: function (v) { root.pendingPath = v }
|
||||
onCommitted: function (v) { root.pendingPath = v }
|
||||
}
|
||||
// The native chooser (settings_qt/library.rs pick_and_add_folder).
|
||||
// It adds the folder itself, so there is nothing to clear here —
|
||||
// the typed field is the OTHER route to the same insert, kept
|
||||
// because pasting a path is faster for network mounts.
|
||||
// Browse fills the same path field as typing; Add is the single commit.
|
||||
SettingsButton { kioskHost: root.kioskHost;
|
||||
iconName: "folder"
|
||||
text: QbzSession.tr("Browse...", QbzSession.trRev)
|
||||
@@ -160,6 +190,20 @@ Column {
|
||||
wrapMode: Text.WordWrap
|
||||
}
|
||||
|
||||
// Keep every column reachable on narrow windows; never paint actions over status.
|
||||
Flickable {
|
||||
id: folderTable
|
||||
width: root.width
|
||||
height: tableRows.height
|
||||
contentWidth: Math.max(width, 100 + (root.kioskHost ? 44 : 20) + 120 + root.statusW + root.actionsW + 70)
|
||||
contentHeight: height
|
||||
clip: true
|
||||
flickableDirection: Flickable.HorizontalFlick
|
||||
boundsBehavior: Flickable.StopAtBounds
|
||||
Column {
|
||||
id: tableRows
|
||||
width: folderTable.contentWidth
|
||||
spacing: 4
|
||||
// Table header (columns MUST match the rows below).
|
||||
Item {
|
||||
visible: root.folders.length > 0
|
||||
@@ -190,7 +234,15 @@ Column {
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
}
|
||||
Text {
|
||||
width: 84
|
||||
width: root.actionsW
|
||||
height: parent.height
|
||||
text: QbzSession.tr("Actions", QbzSession.trRev)
|
||||
color: theme.textMuted
|
||||
font.pixelSize: root.kioskHost ? theme.fontLegal * 1.2 : theme.fontLegal
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
}
|
||||
Text {
|
||||
width: root.statusW
|
||||
height: parent.height
|
||||
text: QbzSession.tr("STATUS", QbzSession.trRev)
|
||||
color: theme.textMuted
|
||||
@@ -198,7 +250,7 @@ Column {
|
||||
font.letterSpacing: 0.5
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
}
|
||||
Item { width: root.actionsW; height: 1 }
|
||||
|
||||
}
|
||||
}
|
||||
Rectangle {
|
||||
@@ -214,7 +266,7 @@ Column {
|
||||
id: folderRow
|
||||
required property var modelData
|
||||
|
||||
width: root.width
|
||||
width: folderTable.contentWidth
|
||||
height: root.kioskHost ? 64 : 40
|
||||
radius: theme.radiusSm
|
||||
color: root.isSelected(modelData.id) ? theme.surfaceElevated
|
||||
@@ -249,14 +301,22 @@ Column {
|
||||
spacing: 8
|
||||
QbzIcon {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
// POC-NOTE: no `network` glyph in this icon set — a
|
||||
// network folder uses `link`, a local one hard-drive.
|
||||
name: folderRow.modelData.isNetwork ? "link" : "hard-drive"
|
||||
objectName: "folderType-" + folderRow.modelData.id
|
||||
name: folderRow.modelData.isNetwork ? "server" : "hard-drive"
|
||||
ToolTip.visible: typeHover.containsMouse
|
||||
ToolTip.delay: 400
|
||||
ToolTip.text: folderRow.modelData.isNetwork
|
||||
? QbzSession.tr("Network folder", QbzSession.trRev)
|
||||
: QbzSession.tr("Local folder", QbzSession.trRev)
|
||||
MouseArea {
|
||||
id: typeHover
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
acceptedButtons: Qt.NoButton
|
||||
}
|
||||
width: 16
|
||||
height: 16
|
||||
tintName: folderRow.modelData.isNetwork
|
||||
? (folderRow.modelData.accessible ? "accent" : "favorite")
|
||||
: "secondary"
|
||||
tintName: folderRow.modelData.isNetwork ? "accent" : "secondary"
|
||||
}
|
||||
Text {
|
||||
width: parent.width - 24
|
||||
@@ -277,38 +337,25 @@ Column {
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
// STATUS (read-only, like the Slint).
|
||||
Text {
|
||||
width: 84
|
||||
height: parent.height
|
||||
text: !folderRow.modelData.enabled
|
||||
? QbzSession.tr("Disabled", QbzSession.trRev)
|
||||
: (folderRow.modelData.isNetwork && !folderRow.modelData.accessible)
|
||||
? QbzSession.tr("Unavailable", QbzSession.trRev)
|
||||
: QbzSession.tr("Active", QbzSession.trRev)
|
||||
color: (folderRow.modelData.isNetwork && !folderRow.modelData.accessible
|
||||
&& folderRow.modelData.enabled) ? theme.danger : theme.textMuted
|
||||
font.pixelSize: root.kioskHost ? (theme.fontLegal) * 1.2 : (theme.fontLegal)
|
||||
font.weight: theme.weightMedium
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
// ACTIONS: settings · enable/disable · scan this folder ·
|
||||
// remove it. The eye button duplicates the edit modal's
|
||||
// "Enabled" toggle on purpose — it predates the modal and is
|
||||
// one click instead of three for the thing people do most.
|
||||
Row {
|
||||
objectName: "folderActions-" + folderRow.modelData.id
|
||||
width: root.actionsW
|
||||
height: parent.height
|
||||
spacing: 4
|
||||
layoutDirection: Qt.RightToLeft
|
||||
SettingsButton { kioskHost: root.kioskHost;
|
||||
width: root.actionButtonW
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
iconName: "trash-2"
|
||||
onClicked: QbzBridge.settingsString("library-remove-folders",
|
||||
JSON.stringify([folderRow.modelData.id]))
|
||||
}
|
||||
SettingsButton { kioskHost: root.kioskHost;
|
||||
width: root.actionButtonW
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
iconName: "refresh-cw"
|
||||
enabled: folderRow.modelData.enabled && root.lib.scanning !== true
|
||||
@@ -316,6 +363,7 @@ Column {
|
||||
String(folderRow.modelData.id))
|
||||
}
|
||||
SettingsButton { kioskHost: root.kioskHost;
|
||||
width: root.actionButtonW
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
iconName: folderRow.modelData.enabled ? "eye" : "eye-off"
|
||||
onClicked: QbzBridge.settingsString("library-folder-enabled",
|
||||
@@ -326,16 +374,41 @@ Column {
|
||||
// the same place; this port had no affordance at all
|
||||
// until the modal landed.
|
||||
SettingsButton { kioskHost: root.kioskHost;
|
||||
width: root.actionButtonW
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
iconName: "settings-2"
|
||||
onClicked: QbzBridge.settingsString("library-folder-edit-open",
|
||||
String(folderRow.modelData.id))
|
||||
}
|
||||
}
|
||||
Text {
|
||||
objectName: "folderStatus-" + folderRow.modelData.id
|
||||
width: root.statusW
|
||||
height: parent.height
|
||||
text: root.statusLabels[folderRow.modelData.status || "checking"] || root.statusLabels.unavailable
|
||||
color: ["missing", "disconnected", "denied", "unavailable"].indexOf(folderRow.modelData.status) >= 0
|
||||
? theme.danger : theme.textMuted
|
||||
font.pixelSize: root.kioskHost ? theme.fontLegal * 1.2 : theme.fontLegal
|
||||
font.weight: theme.weightMedium
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
ToolTip.visible: statusHover.containsMouse
|
||||
ToolTip.delay: 400
|
||||
ToolTip.text: root.statusDescriptions[folderRow.modelData.status || "checking"] || root.statusDescriptions.unavailable
|
||||
MouseArea {
|
||||
id: statusHover
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
acceptedButtons: Qt.NoButton
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
// --------------------------- scan progress ---------------------------
|
||||
Item {
|
||||
visible: root.lib.scanning === true
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
// every control rides the settingsJson document and the settings invokables.
|
||||
|
||||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
import com.blitzfc.qbz
|
||||
import "../controls"
|
||||
import "../theme"
|
||||
@@ -13,6 +14,35 @@ Column {
|
||||
id: root
|
||||
|
||||
property var doc: ({})
|
||||
property var confirmHost: null
|
||||
|
||||
function applyMemory() {
|
||||
if (root.doc.playbackMemoryApplyBusy === true) return
|
||||
const track = QbzPlayer.npHasTrack ? QbzPlayer.npTrackId : ""
|
||||
if (QbzPlayer.npPlaying || QbzPlayer.npLoading) {
|
||||
if (!root.confirmHost) return
|
||||
root.confirmHost.ask(
|
||||
QbzSession.tr("Apply playback memory profile?", QbzSession.trRev),
|
||||
QbzSession.tr("Playback buffers will be released. If playing, the current track will stop and restart from the beginning. If paused, it will stay stopped at 0:00. Your queue and disk cache will be kept.", QbzSession.trRev),
|
||||
QbzSession.tr("Apply & free memory", QbzSession.trRev),
|
||||
function () { QbzBridge.settingsString("playback-cache-apply", "restart:" + track) })
|
||||
} else {
|
||||
QbzBridge.settingsString("playback-cache-apply", "idle:" + track)
|
||||
}
|
||||
}
|
||||
readonly property var cachePolicy: doc.playbackCache || ({})
|
||||
readonly property var cacheUsage: doc.playbackCacheUsage || ({})
|
||||
readonly property var storage: doc.playbackStorage || ({})
|
||||
readonly property var memoryProfileIds: ["auto", "high", "desktop", "low", "custom"]
|
||||
readonly property string memoryProfile: doc.playbackMemoryProfile ||
|
||||
(cachePolicy.profile || ((cachePolicy.dynamic || cachePolicy.min_mib != null || cachePolicy.max_mib != null) ? "custom" : "auto"))
|
||||
readonly property var memoryProfileDescriptions: [
|
||||
QbzSession.tr("Auto selects Desktop or Low from the computer's RAM. Fixed cache budget; dynamic growth is off. Recommended default.", QbzSession.trRev),
|
||||
QbzSession.tr("High starts with a 400 MiB cache budget and grows up to 1600 MiB when memory is available. It shrinks after inactivity or memory pressure. For long Hi-Res tracks and computers with spare RAM.", QbzSession.trRev),
|
||||
QbzSession.tr("Desktop uses a fixed 400 MiB cache budget and normal prefetch. Larger tracks use disk-backed buffers.", QbzSession.trRev),
|
||||
QbzSession.tr("Low uses a 50 MiB cache budget, smaller startup buffers and reduced prefetch. Large tracks use disk; speculative Hi-Res prefetch is skipped. Useful on Raspberry Pi or when saving RAM.", QbzSession.trRev),
|
||||
QbzSession.tr("Custom uses your cache limits and growth preference below. Prefetch follows the computer's detected memory class. Editing a preset switches to Custom.", QbzSession.trRev)
|
||||
]
|
||||
|
||||
QbzTheme { id: theme }
|
||||
|
||||
@@ -39,11 +69,8 @@ Column {
|
||||
SettingRow { kioskHost: root.kioskHost;
|
||||
label: QbzSession.tr("Gapless playback", QbzSession.trRev)
|
||||
description: QbzSession.tr("Play consecutive same-format tracks without a gap.", QbzSession.trRev)
|
||||
// Disabled while Streaming only is on — gapless needs the local cache.
|
||||
rowEnabled: root.doc.streamingOnly !== true
|
||||
QbzToggle { kioskHost: root.kioskHost;
|
||||
checked: root.doc.gapless === true
|
||||
enabled: root.doc.streamingOnly !== true
|
||||
onToggled: function (v) { QbzBridge.settingsBool("gapless", v) }
|
||||
}
|
||||
}
|
||||
@@ -143,7 +170,8 @@ Column {
|
||||
}
|
||||
SettingRow { kioskHost: root.kioskHost;
|
||||
label: QbzSession.tr("Streaming only", QbzSession.trRev)
|
||||
description: QbzSession.tr("Skip writing tracks to the local cache while streaming.", QbzSession.trRev)
|
||||
fitDescription: true
|
||||
description: QbzSession.tr("Mimics official clients’ streaming behavior with lower memory usage. Skips the playback cache and its protection against network interruptions: QBZ’s cache keeps upcoming audio ready for more reliable playback and instant replay.", QbzSession.trRev)
|
||||
QbzToggle { kioskHost: root.kioskHost;
|
||||
checked: root.doc.streamingOnly === true
|
||||
onToggled: function (v) { QbzBridge.settingsBool("streaming-only", v) }
|
||||
@@ -159,4 +187,245 @@ Column {
|
||||
onSelected: function (i) { QbzBridge.settingsSelect("retry-behavior", i) }
|
||||
}
|
||||
}
|
||||
|
||||
SettingsSpacer { }
|
||||
SettingsDivider { }
|
||||
SettingsSpacer { }
|
||||
GroupHeader { kioskHost: root.kioskHost; text: QbzSession.tr("PLAYBACK CACHE", QbzSession.trRev) }
|
||||
GroupBox {
|
||||
id: memoryProfiles
|
||||
objectName: "playbackMemoryFieldset"
|
||||
width: root.width
|
||||
padding: 16
|
||||
topPadding: label.implicitHeight + 28
|
||||
title: QbzSession.tr("Playback memory profile", QbzSession.trRev)
|
||||
Accessible.role: Accessible.Grouping
|
||||
Accessible.name: title
|
||||
label: Text {
|
||||
x: memoryProfiles.leftPadding
|
||||
y: 12
|
||||
text: memoryProfiles.title
|
||||
color: theme.textPrimary
|
||||
font.pixelSize: root.kioskHost ? theme.fontBody * 1.2 : theme.fontBody
|
||||
font.weight: theme.weightMedium
|
||||
}
|
||||
background: Rectangle {
|
||||
color: "transparent"
|
||||
border.color: theme.borderSubtle
|
||||
radius: 6
|
||||
}
|
||||
contentItem: Column {
|
||||
spacing: 16
|
||||
Text {
|
||||
objectName: "playbackMemoryStreamingNotice"
|
||||
width: parent.width
|
||||
visible: root.doc.streamingOnly === true
|
||||
text: QbzSession.tr("These options do not apply while Streaming only is enabled. Turn it off to choose a playback memory profile and apply changes.", QbzSession.trRev)
|
||||
color: theme.textSecondary
|
||||
font.pixelSize: root.kioskHost ? theme.fontLegal * 1.2 : theme.fontLegal
|
||||
wrapMode: Text.WordWrap
|
||||
}
|
||||
Column {
|
||||
width: parent.width
|
||||
spacing: 16
|
||||
Repeater {
|
||||
id: profileOptions
|
||||
model: root.memoryProfileIds
|
||||
delegate: QbzRadioOption {
|
||||
required property int index
|
||||
required property string modelData
|
||||
objectName: "memoryProfile-" + modelData
|
||||
width: parent.width
|
||||
kioskHost: root.kioskHost
|
||||
label: [QbzSession.tr("Auto — recommended", QbzSession.trRev),
|
||||
QbzSession.tr("High — dynamic", QbzSession.trRev),
|
||||
QbzSession.tr("Desktop", QbzSession.trRev),
|
||||
QbzSession.tr("Low — Pi style", QbzSession.trRev),
|
||||
QbzSession.tr("Custom", QbzSession.trRev)][index]
|
||||
description: root.memoryProfileDescriptions[index]
|
||||
selected: root.memoryProfile === modelData
|
||||
enabled: root.doc.streamingOnly !== true
|
||||
opacity: enabled ? 1 : 0.45
|
||||
onClicked: QbzBridge.settingsString("playback-memory-profile", modelData)
|
||||
function step(delta) {
|
||||
const next = profileOptions.itemAt((index + delta + 5) % 5)
|
||||
next.forceActiveFocus()
|
||||
next.clicked()
|
||||
}
|
||||
Keys.onUpPressed: step(-1)
|
||||
Keys.onDownPressed: step(1)
|
||||
Keys.onLeftPressed: step(-1)
|
||||
Keys.onRightPressed: step(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
Column {
|
||||
objectName: "playbackMemoryCustomOptions"
|
||||
width: parent.width
|
||||
visible: root.memoryProfile === "custom"
|
||||
spacing: 4
|
||||
SettingRow { kioskHost: root.kioskHost;
|
||||
fitDescription: true
|
||||
label: QbzSession.tr("Grow cache when memory is available", QbzSession.trRev)
|
||||
description: QbzSession.tr("Allow the cache to grow within your maximum when RAM is available. High enables this automatically. Editing this option switches to Custom.", QbzSession.trRev)
|
||||
rowEnabled: root.doc.streamingOnly !== true
|
||||
QbzToggle { kioskHost: root.kioskHost;
|
||||
checked: root.cachePolicy.dynamic === true
|
||||
enabled: root.doc.streamingOnly !== true
|
||||
onToggled: function (v) { QbzBridge.settingsBool("playback-cache-dynamic", v) }
|
||||
}
|
||||
}
|
||||
SettingRow { kioskHost: root.kioskHost;
|
||||
fitDescription: true
|
||||
label: QbzSession.tr("Minimum cache budget (MiB)", QbzSession.trRev)
|
||||
description: QbzSession.tr("Base budget, not reserved RAM. Memory pressure can reduce it. Leave blank for the recommended value.", QbzSession.trRev)
|
||||
rowEnabled: root.doc.streamingOnly !== true
|
||||
QbzLineEdit { kioskHost: root.kioskHost;
|
||||
width: 240
|
||||
enabled: root.doc.streamingOnly !== true
|
||||
text: root.cachePolicy.min_mib == null ? "" : String(root.cachePolicy.min_mib)
|
||||
placeholder: String(Math.round((root.cacheUsage.base_size_bytes || root.cacheUsage.recommended_size_bytes || 419430400) / 1048576))
|
||||
onCommitted: function (s) { QbzBridge.settingsString("playback-cache-min", s) }
|
||||
}
|
||||
}
|
||||
SettingRow { kioskHost: root.kioskHost;
|
||||
fitDescription: true
|
||||
label: QbzSession.tr("Maximum cache budget (MiB)", QbzSession.trRev)
|
||||
description: QbzSession.tr("Growth ceiling; available memory may impose a lower limit. Leave blank for automatic.", QbzSession.trRev)
|
||||
rowEnabled: root.doc.streamingOnly !== true && root.cachePolicy.dynamic === true
|
||||
QbzLineEdit { kioskHost: root.kioskHost;
|
||||
width: 240
|
||||
enabled: root.doc.streamingOnly !== true && root.cachePolicy.dynamic === true
|
||||
text: root.cachePolicy.max_mib == null ? "" : String(root.cachePolicy.max_mib)
|
||||
placeholder: String(Math.round((root.cacheUsage.ceiling_size_bytes || 419430400) / 1048576))
|
||||
onCommitted: function (s) { QbzBridge.settingsString("playback-cache-max", s) }
|
||||
}
|
||||
}
|
||||
}
|
||||
Column {
|
||||
objectName: "playbackMemoryTradeoff"
|
||||
width: parent.width
|
||||
spacing: 4
|
||||
Text {
|
||||
width: parent.width
|
||||
text: QbzSession.tr("RAM and storage trade-off", QbzSession.trRev)
|
||||
color: theme.textPrimary
|
||||
font.pixelSize: root.kioskHost ? theme.fontBody * 1.2 : theme.fontBody
|
||||
font.weight: theme.weightMedium
|
||||
wrapMode: Text.WordWrap
|
||||
}
|
||||
Text {
|
||||
width: parent.width
|
||||
text: QbzSession.tr("All profiles preserve audio quality and can use disk cache. Lower memory budgets rely more on storage speed and writes; more RAM can reduce that work. Cached disk tracks are read from disk even in High. Choose storage appropriate for your listening workload.", QbzSession.trRev)
|
||||
color: theme.textMuted
|
||||
font.pixelSize: root.kioskHost ? theme.fontLegal * 1.2 : theme.fontLegal
|
||||
wrapMode: Text.WordWrap
|
||||
}
|
||||
}
|
||||
Text {
|
||||
width: parent.width
|
||||
text: QbzSession.tr("The profile already applies to new buffers. Apply now to release existing playback buffers and the memory cache. Memory use can grow again as playback continues.", QbzSession.trRev)
|
||||
color: theme.textMuted
|
||||
font.pixelSize: root.kioskHost ? theme.fontLegal * 1.2 : theme.fontLegal
|
||||
wrapMode: Text.WordWrap
|
||||
}
|
||||
Row {
|
||||
objectName: "playbackMemoryActions"
|
||||
anchors.right: parent.right
|
||||
spacing: 8
|
||||
SettingsButton {
|
||||
objectName: "playbackMemoryReset"
|
||||
kioskHost: root.kioskHost
|
||||
text: QbzSession.tr("Restore defaults", QbzSession.trRev)
|
||||
enabled: root.doc.streamingOnly !== true && root.doc.playbackMemoryApplyBusy !== true
|
||||
onClicked: QbzBridge.settingsString("playback-cache-reset", "")
|
||||
}
|
||||
SettingsButton {
|
||||
objectName: "playbackMemoryApply"
|
||||
kioskHost: root.kioskHost
|
||||
text: QbzSession.tr("Apply & free memory", QbzSession.trRev)
|
||||
busy: root.doc.playbackMemoryApplyBusy === true
|
||||
enabled: root.doc.streamingOnly !== true && !busy
|
||||
onClicked: root.applyMemory()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
SettingRow { kioskHost: root.kioskHost;
|
||||
label: QbzSession.tr("Memory cache usage", QbzSession.trRev)
|
||||
description: QbzSession.tr("Used / current budget: %1 / %2 MiB. This excludes decoder memory and offline downloads.", QbzSession.trRev)
|
||||
.arg(Math.round((root.cacheUsage.current_size_bytes || 0) / 1048576))
|
||||
.arg(Math.round((root.cacheUsage.max_size_bytes || 0) / 1048576))
|
||||
SettingsButton { kioskHost: root.kioskHost;
|
||||
text: QbzSession.tr("Refresh", QbzSession.trRev)
|
||||
onClicked: QbzBridge.settingsString("playback-cache-refresh", "")
|
||||
}
|
||||
}
|
||||
Column {
|
||||
objectName: "playbackStorageSetting"
|
||||
width: root.width
|
||||
spacing: 8
|
||||
Text {
|
||||
width: parent.width
|
||||
text: QbzSession.tr("Playback storage folder", QbzSession.trRev)
|
||||
color: theme.textPrimary
|
||||
font.pixelSize: root.kioskHost ? theme.fontBody * 1.2 : theme.fontBody
|
||||
font.weight: theme.weightMedium
|
||||
}
|
||||
Text {
|
||||
width: parent.width
|
||||
text: QbzSession.tr("Choose an existing folder on the drive you want to use. QBZ stores its playback cache and temporary track buffers in a qbz-playback subfolder. Offline downloads are separate. Leave blank for the default location.", QbzSession.trRev)
|
||||
color: theme.textMuted
|
||||
font.pixelSize: root.kioskHost ? theme.fontLegal * 1.2 : theme.fontLegal
|
||||
wrapMode: Text.WordWrap
|
||||
}
|
||||
Row {
|
||||
width: parent.width
|
||||
spacing: 8
|
||||
QbzLineEdit {
|
||||
objectName: "playbackStoragePath"
|
||||
kioskHost: root.kioskHost
|
||||
width: Math.max(0, Math.min(storageBrowse.width * 2, parent.width - storageBrowse.width - parent.spacing))
|
||||
text: root.storage.candidate || ""
|
||||
onCommitted: function(path) { QbzBridge.settingsString("playback-storage-folder", path) }
|
||||
}
|
||||
SettingsButton {
|
||||
id: storageBrowse
|
||||
objectName: "playbackStorageBrowse"
|
||||
kioskHost: root.kioskHost
|
||||
text: QbzSession.tr("Browse...", QbzSession.trRev)
|
||||
onClicked: QbzBridge.settingsString("playback-storage-browse", "")
|
||||
}
|
||||
}
|
||||
Text {
|
||||
objectName: "playbackStorageActiveHint"
|
||||
width: parent.width
|
||||
text: QbzSession.tr("Current playback storage", QbzSession.trRev) + ": " + (root.storage.active || "—")
|
||||
+ "\n" + QbzSession.tr("Changing the folder takes effect after restarting QBZ. Existing cache files stay in the old location; they are not moved or deleted automatically.", QbzSession.trRev)
|
||||
color: theme.textMuted
|
||||
font.pixelSize: root.kioskHost ? theme.fontLegal * 1.2 : theme.fontLegal
|
||||
wrapMode: Text.Wrap
|
||||
}
|
||||
}
|
||||
Text {
|
||||
width: root.width
|
||||
visible: (root.storage.error || "") !== ""
|
||||
text: QbzSession.tr("Could not use this playback storage folder:", QbzSession.trRev) + " " + (root.storage.error || "")
|
||||
wrapMode: Text.Wrap
|
||||
color: theme.danger
|
||||
font.pixelSize: theme.fontLegal
|
||||
}
|
||||
SettingRow { kioskHost: root.kioskHost;
|
||||
visible: (root.storage.sandbox || "") !== ""
|
||||
fitDescription: true
|
||||
label: (root.storage.sandbox || "") + " — " + QbzSession.tr("Folder access", QbzSession.trRev)
|
||||
description: root.storage.sandbox === "Flatpak"
|
||||
? QbzSession.tr("If the sandbox blocks this folder, run the command below in a host terminal, restart QBZ, then select the folder again. Only grant access to a folder you intend to use.", QbzSession.trRev)
|
||||
: QbzSession.tr("Snap can access supported home folders and removable storage under /mnt, /media or /run/media. For removable storage, connect the interface below and retry. Snap cannot grant arbitrary folder access with a Flatpak-style override.", QbzSession.trRev)
|
||||
}
|
||||
CommandBlock {
|
||||
visible: (root.storage.command || "") !== ""
|
||||
width: root.width
|
||||
command: root.storage.command || ""
|
||||
}
|
||||
}
|
||||
@@ -446,6 +446,7 @@ Item {
|
||||
PlaybackSettings { kioskHost: root.kioskHost;
|
||||
width: parent.width
|
||||
doc: root.doc
|
||||
confirmHost: settingsConfirmHost
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -254,6 +254,10 @@ Rectangle {
|
||||
|
||||
NowPlayingBar {
|
||||
id: npb
|
||||
// The small seek thumb/hit area extends above the bar by a few pixels.
|
||||
// Only Small needs this overlap. Large's cover dock is a later
|
||||
// sibling and must paint over the full-width player background.
|
||||
z: QbzShell.npbMode === 2 ? 1 : 0
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
anchors.bottom: parent.bottom
|
||||
|
||||
@@ -175,23 +175,15 @@ Rectangle {
|
||||
try { return JSON.parse(QbzBridge.settingsJson) } catch (e) { return ({}) }
|
||||
}
|
||||
|
||||
/// Purchases in the TITLE BAR — the third row of the §7.1 truth table.
|
||||
///
|
||||
/// This is the EXACT COMPLEMENT of `Sidebar.qml`'s `purchasesVisible`, and
|
||||
/// it has to be, because that property WITHDRAWS the sidebar row for this
|
||||
/// configuration. Until this existed the entry was withdrawn from one host
|
||||
/// and offered by none: with `show_purchases` and `nav_tb_purchases` both on
|
||||
/// and a custom title bar, Purchases simply had no way in.
|
||||
///
|
||||
/// `nav_tb_purchases` only RELOCATES; `show_purchases` is the master gate.
|
||||
/// Under system chrome or with no title bar there is nowhere to relocate TO,
|
||||
/// so the sidebar keeps it and this stays false.
|
||||
// Follow navigation into the header, including the closed-sidebar fallback.
|
||||
// The separate relocation preference also works while navigation stays in
|
||||
// the sidebar; only that explicit title-bar placement needs custom chrome.
|
||||
readonly property bool purchasesInHeader:
|
||||
root.settingsDoc.showPurchases === true
|
||||
&& !QbzSession.offline
|
||||
&& root.settingsDoc.navTbPurchases === true
|
||||
&& !QbzShell.systemTitleBar
|
||||
&& !QbzShell.hideTitleBar
|
||||
&& (root.headerTabsOn || root.headerCompactOn
|
||||
|| (root.settingsDoc.navTbPurchases === true
|
||||
&& !QbzShell.systemTitleBar && !QbzShell.hideTitleBar))
|
||||
|
||||
// Highlighted section — derived from the live view (see NavFlyout), OR'd
|
||||
// in the triggers with "my menu is open" (Slint highlights off
|
||||
@@ -256,17 +248,17 @@ Rectangle {
|
||||
visible: purchaseTab.showIcon
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
name: "shopping-bag"
|
||||
width: 16
|
||||
height: 16
|
||||
tintName: purchaseTab.isActive ? "primary" : "muted"
|
||||
width: purchaseTab.compact ? 16 : 14
|
||||
height: width
|
||||
tintName: purchaseTab.isActive ? "textPrimary" : "secondary"
|
||||
}
|
||||
Text {
|
||||
visible: !purchaseTab.compact
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: QbzSession.tr("Purchases", QbzSession.trRev)
|
||||
color: purchaseTab.isActive ? theme.textPrimary : theme.textMuted
|
||||
font.pixelSize: theme.fontLegal
|
||||
font.weight: theme.weightMedium
|
||||
color: theme.textPrimary
|
||||
font.pixelSize: 11
|
||||
font.weight: purchaseTab.isActive ? theme.weightSemibold : theme.weightRegular
|
||||
}
|
||||
}
|
||||
|
||||
@@ -460,6 +452,13 @@ Rectangle {
|
||||
}
|
||||
}
|
||||
|
||||
// Explicit Purchases relocation with the other navigation in the sidebar.
|
||||
PurchaseTab {
|
||||
visible: root.purchasesInHeader && !root.headerTabsOn && !root.headerCompactOn
|
||||
showIcon: root.width >= 1140
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
}
|
||||
|
||||
// Compact section nav — while the sidebar is fully closed (so the
|
||||
// sections stay reachable), or always when the nav is in the header
|
||||
// and "Compact header navigation" is ON (HeaderBar.slint:974).
|
||||
@@ -618,6 +617,7 @@ Rectangle {
|
||||
border.width: 1
|
||||
border.color: theme.borderSubtle
|
||||
TextInput {
|
||||
QbzTextEditMenu { }
|
||||
id: plSearch
|
||||
anchors.fill: parent
|
||||
anchors.leftMargin: 6
|
||||
@@ -880,6 +880,7 @@ Rectangle {
|
||||
tintName: "muted"
|
||||
}
|
||||
TextInput {
|
||||
QbzTextEditMenu { }
|
||||
id: searchInput
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
@@ -898,10 +899,8 @@ Rectangle {
|
||||
// the reference opens on the FIRST keystroke >= 2 chars and
|
||||
// debounces only the LOAD. Rust's version guard is what discards
|
||||
// the superseded loads.
|
||||
// Shared by keyboard edits (onTextEdited) and the edit menu's
|
||||
// cut/paste, which are PROGRAMMATIC changes — TextInput only
|
||||
// emits textEdited for user input, so a menu paste would
|
||||
// otherwise change the text without ever driving the cortinilla.
|
||||
// The shared edit menu dispatches the same textEdited callback
|
||||
// as typing, once, including on Qt versions where paste does not.
|
||||
function applyLiveQuery() {
|
||||
if (text.trim().length < 2) {
|
||||
QbzSearch.cortinillaDismiss()
|
||||
@@ -911,38 +910,6 @@ Rectangle {
|
||||
}
|
||||
onTextEdited: applyLiveQuery()
|
||||
|
||||
// QoL round: clipboard access by pointer, not only Ctrl+C/X/V —
|
||||
// QML TextInput has no system edit menu of its own. RightButton
|
||||
// only, so left clicks keep placing the cursor.
|
||||
MouseArea {
|
||||
anchors.fill: parent
|
||||
acceptedButtons: Qt.RightButton
|
||||
onClicked: function (mouse) {
|
||||
searchEditMenu.openAtCursor(searchInput, mouse.x, mouse.y)
|
||||
}
|
||||
}
|
||||
CardMenu {
|
||||
id: searchEditMenu
|
||||
menuWidth: 160
|
||||
entries: {
|
||||
var t = QbzSession.tr
|
||||
var r = QbzSession.trRev
|
||||
var hasSel = searchInput.selectedText !== ""
|
||||
return [
|
||||
{ "label": t("Cut", r), "icon": "scissors", "action": "cut", "enabled": hasSel },
|
||||
{ "label": t("Copy", r), "icon": "copy", "action": "copy", "enabled": hasSel },
|
||||
{ "label": t("Paste", r), "icon": "clipboard", "action": "paste", "enabled": searchInput.canPaste },
|
||||
{ "label": t("Select all", r), "icon": "square-check-big", "action": "select-all", "enabled": searchInput.text !== "" },
|
||||
]
|
||||
}
|
||||
onPicked: function (a) {
|
||||
if (a === "cut") { searchInput.cut(); searchInput.applyLiveQuery() }
|
||||
else if (a === "copy") searchInput.copy()
|
||||
else if (a === "paste") { searchInput.paste(); searchInput.applyLiveQuery() }
|
||||
else if (a === "select-all") searchInput.selectAll()
|
||||
}
|
||||
}
|
||||
|
||||
// The Enter rule (HeaderBar.slint on-enter): cortinilla open +
|
||||
// a keyboard selection -> activate the row; open + none -> full
|
||||
// search; closed -> plain submit (also Search > All).
|
||||
|
||||
@@ -261,6 +261,7 @@ Item {
|
||||
tintName: "muted"
|
||||
}
|
||||
TextInput {
|
||||
QbzTextEditMenu { }
|
||||
id: searchInput
|
||||
anchors.left: searchGlyph.right
|
||||
anchors.leftMargin: 8
|
||||
@@ -488,6 +489,7 @@ Item {
|
||||
tintName: "accent"
|
||||
}
|
||||
TextInput {
|
||||
QbzTextEditMenu { }
|
||||
anchors.left: linkGlyph.right
|
||||
anchors.leftMargin: 8
|
||||
anchors.right: urlCopy.left
|
||||
|
||||
@@ -44,9 +44,6 @@ Rectangle {
|
||||
// PlayerBarSmall.slint).
|
||||
color: ambientOn ? theme.surfaceCardA50 : theme.surfaceCard
|
||||
readonly property bool ambientOn: theme.ambientOn
|
||||
// Feature parked for a later release. Keep the compact renderer in place
|
||||
// but use the classic seekbar consistently with the other NPB modes.
|
||||
readonly property bool waveformVisible: false
|
||||
/// AppShell's one shared hover-tooltip overlay.
|
||||
property Item tooltip: null
|
||||
|
||||
@@ -142,27 +139,6 @@ Rectangle {
|
||||
return m + ":" + (s < 10 ? "0" : "") + s
|
||||
}
|
||||
|
||||
// --- Seek clamp (closes PARITY-DEBT #15, QML half) -------------------
|
||||
// Same SeekBar the full bar mounts (PlayerBarSmall.slint:186 passes
|
||||
// seekable-max: NowPlayingState.seekable-max into SeekBar.slint), so the
|
||||
// behaviour is identical here: while a track is still downloading the
|
||||
// seek target is LOCKED to the furthest fraction that has arrived and the
|
||||
// cursor turns not-allowed past it. Source: state.slint:4402, fed by
|
||||
// playback.rs:5304 `buffer_progress.clamp(0,1)`, published as
|
||||
// QbzPlayer.npSeekableMax. A fully-available track reports 1.0, so the
|
||||
// Math.min() is a no-op and local/cached seeking stays free.
|
||||
function clamp01(v) {
|
||||
return Math.min(Math.max(v, 0), 1)
|
||||
}
|
||||
// SeekBar.slint:98 — Math.min(clamp01(mouse-x / width), seekable-max).
|
||||
function seekTarget(fraction) {
|
||||
return Math.min(root.clamp01(fraction), QbzPlayer.npSeekableMax)
|
||||
}
|
||||
// SeekBar.slint:93 — clamp01(mouse-x / width) > seekable-max.
|
||||
function beyondSeekable(fraction) {
|
||||
return root.clamp01(fraction) > QbzPlayer.npSeekableMax
|
||||
}
|
||||
|
||||
// --- Track Info (album/TrackInfoModal.slint) -------------------------
|
||||
// The (i) button and the song-card title open the MODAL (scrim + centered
|
||||
// card) — PlayerBarSmall.slint fires media-action("track", id,
|
||||
@@ -214,81 +190,14 @@ Rectangle {
|
||||
spacing: 0
|
||||
|
||||
// === A. TOP full-width seekbar (the content/bar divider) ========
|
||||
Item {
|
||||
QbzCompactSeekBar {
|
||||
width: parent.width
|
||||
height: 3
|
||||
|
||||
Rectangle {
|
||||
id: seekTrack
|
||||
width: parent.width
|
||||
height: root.waveformVisible ? 13 : 3
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
radius: 2
|
||||
color: root.waveformVisible ? "transparent" : theme.surfaceElevated
|
||||
|
||||
SeekWaveformItem {
|
||||
anchors.fill: parent
|
||||
visible: root.waveformVisible
|
||||
values: QbzPlayer.npSeekWaveform
|
||||
playedProgress: QbzPlayer.npProgress
|
||||
cacheProgress: QbzPlayer.npCacheProgress
|
||||
baseColor: theme.surfaceElevated
|
||||
cacheColor: Qt.rgba(theme.textMuted.r, theme.textMuted.g,
|
||||
theme.textMuted.b, 0.35)
|
||||
playedColor: theme.accent
|
||||
renderMode: 1
|
||||
}
|
||||
|
||||
// Buffered / cache line.
|
||||
Rectangle {
|
||||
visible: !root.waveformVisible
|
||||
width: parent.width * Math.min(Math.max(QbzPlayer.npCacheProgress, 0), 1)
|
||||
height: parent.height
|
||||
radius: 2
|
||||
color: Qt.rgba(theme.textMuted.r, theme.textMuted.g,
|
||||
theme.textMuted.b, 0.35)
|
||||
// Alpha in the material, not a container `opacity`: an
|
||||
// always-on opacity node pins this quad in its own batch,
|
||||
// and the three seek quads use compatible materials
|
||||
// (QSGSmoothColorMaterial::compare() returns 0), so folding
|
||||
// it lets rail + cache + progress merge.
|
||||
}
|
||||
// Playback progress line.
|
||||
Rectangle {
|
||||
visible: !root.waveformVisible
|
||||
width: parent.width * Math.min(Math.max(QbzPlayer.npProgress, 0), 1)
|
||||
height: parent.height
|
||||
radius: 2
|
||||
color: theme.accent
|
||||
}
|
||||
// Hover thumb.
|
||||
Rectangle {
|
||||
width: 12
|
||||
height: 12
|
||||
radius: 6
|
||||
color: theme.textPrimary
|
||||
x: parent.width * Math.min(Math.max(QbzPlayer.npProgress, 0), 1) - width / 2
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
opacity: seekArea.containsMouse ? 1.0 : 0.0
|
||||
Behavior on opacity { NumberAnimation { duration: 100 } }
|
||||
}
|
||||
}
|
||||
// Tall, easy-to-grab hit area over the thin line.
|
||||
MouseArea {
|
||||
id: seekArea
|
||||
width: parent.width
|
||||
height: 18
|
||||
anchors.verticalCenter: seekTrack.verticalCenter
|
||||
hoverEnabled: true
|
||||
// No-drop cursor over the not-yet-downloaded region
|
||||
// (SeekBar.slint:93-95).
|
||||
cursorShape: root.beyondSeekable(mouseX / width) ? Qt.ForbiddenCursor
|
||||
: Qt.PointingHandCursor
|
||||
enabled: QbzPlayer.npHasTrack
|
||||
// Lock the seek target to what has downloaded while streaming
|
||||
// (SeekBar.slint:96-99).
|
||||
onClicked: QbzPlayer.seek(root.seekTarget(mouseX / width))
|
||||
}
|
||||
hasTrack: QbzPlayer.npHasTrack
|
||||
progress: QbzPlayer.npProgress
|
||||
cacheProgress: QbzPlayer.npCacheProgress
|
||||
seekableMax: QbzPlayer.npSeekableMax
|
||||
durationSecs: QbzPlayer.npDurationSecs
|
||||
onSeekRequested: function (fraction) { QbzPlayer.seek(fraction) }
|
||||
}
|
||||
|
||||
// === B. CONTROLS ROW — symmetric 3 columns =======================
|
||||
|
||||
@@ -144,6 +144,7 @@ Popup {
|
||||
boundsBehavior: Flickable.StopAtBounds
|
||||
clip: true
|
||||
TextEdit {
|
||||
QbzTextEditMenu { }
|
||||
id: statusText
|
||||
width: statusFlick.width
|
||||
readOnly: true
|
||||
@@ -214,6 +215,7 @@ Popup {
|
||||
boundsBehavior: Flickable.StopAtBounds
|
||||
clip: true
|
||||
TextEdit {
|
||||
QbzTextEditMenu { }
|
||||
id: logText
|
||||
width: logFlick.width
|
||||
readOnly: true
|
||||
|
||||
@@ -434,7 +434,7 @@ Rectangle {
|
||||
// visible queue row.
|
||||
RoundedImage {
|
||||
anchors.fill: parent
|
||||
source: root.coverMap[row.artUrl] || ""
|
||||
source: qrRoot.artworkLeading ? (root.coverMap[row.artUrl] || "") : ""
|
||||
radius: 4
|
||||
}
|
||||
// Per-item: this thumbnail clears when ITS cover lands, so
|
||||
@@ -1358,6 +1358,7 @@ Rectangle {
|
||||
border.color: customInput.activeFocus ? theme.accent : theme.borderSubtle
|
||||
color: theme.surfaceElevated
|
||||
TextInput {
|
||||
QbzTextEditMenu { }
|
||||
id: customInput
|
||||
anchors.fill: parent
|
||||
anchors.leftMargin: 9
|
||||
|
||||
@@ -1016,6 +1016,7 @@ Rectangle {
|
||||
border.width: 1
|
||||
border.color: theme.borderSubtle
|
||||
TextInput {
|
||||
QbzTextEditMenu { }
|
||||
id: myqbzSearchEdit
|
||||
anchors.fill: parent
|
||||
anchors.leftMargin: 6
|
||||
@@ -1292,6 +1293,7 @@ Rectangle {
|
||||
border.width: 1
|
||||
border.color: theme.borderSubtle
|
||||
TextInput {
|
||||
QbzTextEditMenu { }
|
||||
id: searchEdit
|
||||
anchors.fill: parent
|
||||
anchors.leftMargin: 6
|
||||
|
||||
@@ -121,7 +121,7 @@ Item {
|
||||
RoundedImage {
|
||||
visible: QbzPlayer.npHasTrack
|
||||
anchors.fill: parent
|
||||
source: QbzPlayer.npArtworkPath
|
||||
source: root.visible && QbzPlayer.npHasTrack ? QbzPlayer.npArtworkPath : ""
|
||||
radius: theme.radiusMd
|
||||
}
|
||||
QbzIcon {
|
||||
|
||||
@@ -66,7 +66,8 @@ Rectangle {
|
||||
readonly property bool pulled: root.item.qobuzUnavailable === true
|
||||
readonly property int cacheStatus: root.item.cacheStatus !== undefined
|
||||
? root.item.cacheStatus : 0
|
||||
readonly property bool pulledDead: root.pulled && root.cacheStatus !== 3
|
||||
readonly property bool pulledDead: (root.pulled && root.cacheStatus !== 3)
|
||||
|| root.item.sourceUnavailable === true
|
||||
/// `modifiers` rides straight off the mouse event: Shift is what turns
|
||||
/// a click into a range (controls/SelectionModel.qml).
|
||||
signal toggleSelect(int modifiers)
|
||||
@@ -123,7 +124,7 @@ Rectangle {
|
||||
/// below all resolve a catalog id.
|
||||
readonly property bool catalogRow: {
|
||||
var src = root.item.source || ""
|
||||
return src !== "local" && src !== "plex"
|
||||
return src === "" || src === "qobuz"
|
||||
}
|
||||
|
||||
function menuEntries() {
|
||||
|
||||
@@ -1484,6 +1484,7 @@ Rectangle {
|
||||
tintName: "muted"
|
||||
}
|
||||
TextInput {
|
||||
QbzTextEditMenu { }
|
||||
width: parent.width - 21
|
||||
height: parent.height
|
||||
color: theme.textPrimary
|
||||
|
||||
@@ -148,10 +148,12 @@ Rectangle {
|
||||
it._albumLc = String(it.album || "").toLowerCase()
|
||||
it._genreLc = String(it.genre || "").toLowerCase()
|
||||
if (it.kind === "track" || it.kind === "album") {
|
||||
if (it.group === "favorites" || it.group === "purchases") {
|
||||
var tabKey = it.kind + ":" + it.id
|
||||
if (it.group === "favorites" || it.group === "purchases"
|
||||
|| (it.kind === "album" && isLocalFeedItem(it))) {
|
||||
var tabKey = membershipKey
|
||||
if (it.group === "purchases") tabPurchased[tabKey] = true
|
||||
else tabFavorites[tabKey] = true
|
||||
else if (it.group === "favorites" || it.isFavorite === true)
|
||||
tabFavorites[tabKey] = true
|
||||
if (!tabSeen[tabKey]) {
|
||||
tabSeen[tabKey] = true
|
||||
totals[it.kind + "s"]++
|
||||
@@ -674,13 +676,15 @@ Rectangle {
|
||||
var x = feed[i]
|
||||
var keep = false
|
||||
if (sourceTab && x.kind === sourceKind) {
|
||||
var sourceKey = sourceKind + ":" + x.id
|
||||
var sourceKey = x._membershipKey
|
||||
var isPurchased = purchased[sourceKey] === true
|
||||
var isFavorite = favorites[sourceKey] === true
|
||||
var included = matchesSources(isPurchased, isFavorite, false)
|
||||
var localRelease = sourceKind === "album" && isLocalFeedItem(x)
|
||||
if (localRelease && !showLocal && hideableLocalAlbum(x)) included = false
|
||||
// One representative row per included entity. A purchase row
|
||||
// wins even in Favorites-only so its quality badge survives.
|
||||
keep = included && ((isPurchased && x.group === "purchases")
|
||||
keep = included && (localRelease || (isPurchased && x.group === "purchases")
|
||||
|| (!isPurchased && x.group === "favorites"))
|
||||
if (keep) {
|
||||
if (tabSeen[sourceKey]) keep = false
|
||||
|
||||
@@ -556,6 +556,7 @@ Rectangle {
|
||||
height: parent.height
|
||||
clip: true
|
||||
TextInput {
|
||||
QbzTextEditMenu { }
|
||||
id: searchInput
|
||||
anchors.fill: parent
|
||||
color: theme.textPrimary
|
||||
|
||||
@@ -122,6 +122,7 @@ Rectangle {
|
||||
height: parent.height
|
||||
clip: true
|
||||
TextInput {
|
||||
QbzTextEditMenu { }
|
||||
id: mpSearch
|
||||
anchors.fill: parent
|
||||
color: theme.textPrimary
|
||||
|
||||
@@ -737,6 +737,7 @@ Rectangle {
|
||||
spacing: 6
|
||||
QbzIcon { name: "search"; width: 13; height: 13; anchors.verticalCenter: parent.verticalCenter; tintName: "muted" }
|
||||
TextInput {
|
||||
QbzTextEditMenu { }
|
||||
width: parent.width - 19
|
||||
height: parent.height
|
||||
color: theme.textPrimary
|
||||
|
||||
@@ -692,6 +692,7 @@ Rectangle {
|
||||
border.color: customMinutes.activeFocus
|
||||
? theme.accent : theme.borderSubtle
|
||||
TextInput {
|
||||
QbzTextEditMenu { }
|
||||
id: customMinutes
|
||||
anchors.fill: parent
|
||||
anchors.leftMargin: 9
|
||||
|
||||
@@ -114,6 +114,19 @@ Item {
|
||||
// may additionally show the catalog mark in the mixed feed.
|
||||
source: cell.item.source
|
||||
sources: cell.item.sources || []
|
||||
catalogAffordances: !cell.view.isLocalFeedItem(cell.item)
|
||||
localMode: cell.view.isLocalFeedItem(cell.item)
|
||||
localPlaylistAffordance: localMode
|
||||
hostFavorite: localMode && badgeSources.some(function (source) {
|
||||
return ["local", "plex", "jellyfin", "subsonic", "navidrome",
|
||||
"gonic", "airsonic", "astiga"].indexOf(source) >= 0
|
||||
})
|
||||
onFavoriteRequested: QbzLocal.albumToggleFavorite(cell.item.id,
|
||||
cell.item.title || "", cell.item.artist || "", artworkUrl,
|
||||
JSON.stringify(badgeSources))
|
||||
onOpenRequested: QbzAlbum.openAlbum(cell.item.id)
|
||||
onPlayRequested: QbzPlayer.playAlbum(cell.item.id)
|
||||
onEnqueueRequested: function (mode) { QbzPlayer.enqueueAlbum(cell.item.id, mode) }
|
||||
showSourceBadge: cell.view.showSourceBadges
|
||||
}
|
||||
}
|
||||
|
||||
@@ -259,6 +259,7 @@ Rectangle {
|
||||
border.color: nameField.activeFocus
|
||||
? theme.accent : theme.surfaceElevated
|
||||
TextInput {
|
||||
QbzTextEditMenu { }
|
||||
id: nameField
|
||||
anchors.fill: parent
|
||||
anchors.leftMargin: 12
|
||||
|
||||
@@ -53,6 +53,7 @@
|
||||
// consumer.
|
||||
|
||||
import QtQuick
|
||||
import "../../controls"
|
||||
import com.blitzfc.qbz
|
||||
import "../../theme"
|
||||
|
||||
@@ -267,6 +268,7 @@ Item {
|
||||
tintName: "muted"
|
||||
}
|
||||
TextInput {
|
||||
QbzTextEditMenu { }
|
||||
id: searchInput
|
||||
width: parent.width - 22 - (clearBtn.visible ? clearBtn.width + 8 : 0)
|
||||
height: 21
|
||||
|
||||
@@ -274,6 +274,7 @@ const MASTERS: &[(&str, &str)] = &[
|
||||
("search", include_str!("../qml/assets/icons/primary/search.svg")),
|
||||
// The GEAR. Distinct from settings-2 (two sliders) — the kiosk NavRail's
|
||||
// seventh tile uses this one (`shell/NavRail.slint:185`).
|
||||
("server", include_str!("../qml/assets/icons/primary/server.svg")),
|
||||
("settings", include_str!("../qml/assets/icons/primary/settings.svg")),
|
||||
("settings-2", include_str!("../qml/assets/icons/primary/settings-2.svg")),
|
||||
("shopping-bag", include_str!("../qml/assets/icons/primary/shopping-bag.svg")),
|
||||
|
||||
@@ -167,12 +167,20 @@ fn albums_action(ids: Vec<String>, action: &str) {
|
||||
crate::spawn(async move {
|
||||
let mut queue: Vec<QueueTrack> = Vec::new();
|
||||
for id in &ids {
|
||||
// A local / Plex group key is not a catalog id — the album
|
||||
// bar only ever holds Qobuz favourites (the Albums tab
|
||||
// keeps `group === "favorites"`), but the guard is what
|
||||
// stops a widened filter from 404-ing later.
|
||||
// Releases includes the same local/download/server groups
|
||||
// as All. Resolve those keys locally before the catalog arm.
|
||||
if library_qt::is_local_album_key(id) {
|
||||
log::info!("[qbz-qt] library bulk albums: skipping local key {id}");
|
||||
let key = id.clone();
|
||||
let tracks = tokio::task::spawn_blocking(move || {
|
||||
let mut rows = crate::local_albums::fetch_album_tracks_blocking(&key);
|
||||
crate::local_playback::fill_missing_covers(&mut rows);
|
||||
rows.iter()
|
||||
.map(crate::local_playback::local_queue_track)
|
||||
.collect()
|
||||
})
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
queue.extend(playback_qt::stamped(tracks, PlayContext::album(id)));
|
||||
continue;
|
||||
}
|
||||
match playback_qt::fetch_album_queue(&runtime, id).await {
|
||||
@@ -239,8 +247,15 @@ fn remove_favorites(kind: &str, ids: Vec<String>) {
|
||||
let runtime = crate::app();
|
||||
crate::spawn(async move {
|
||||
for id in &ids {
|
||||
if library_qt::is_local_feed_id(&kind, id) {
|
||||
if library_qt::is_favorite(&kind, id) {
|
||||
library_qt::toggle_favorite(&runtime, &kind, id).await;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if let Err(e) = runtime.core().remove_favorite(&kind, id).await {
|
||||
log::error!("[qbz-qt] library bulk remove {kind}:{id} failed: {e}");
|
||||
continue;
|
||||
}
|
||||
crate::fav_cache_qt::set(&kind, id, false);
|
||||
crate::library_qt::set_feed_favorite(&kind, id, false);
|
||||
|
||||
@@ -243,6 +243,48 @@ pub struct LibraryData {
|
||||
|
||||
static LIBRARY: Mutex<Option<LibraryData>> = Mutex::new(None);
|
||||
|
||||
// Successful owned deletions are authoritative for this session, even when an
|
||||
// already running list request returns the old playlist afterwards. Lock order:
|
||||
// deleted IDs -> Library/sidebar/manager cache; never the reverse.
|
||||
static DELETED_PLAYLISTS: std::sync::LazyLock<Mutex<std::collections::HashSet<String>>> =
|
||||
std::sync::LazyLock::new(|| Mutex::new(std::collections::HashSet::new()));
|
||||
static PLAYLIST_DELETION_REVISION: std::sync::atomic::AtomicU64 =
|
||||
std::sync::atomic::AtomicU64::new(0);
|
||||
|
||||
pub(crate) fn playlist_deletion_revision() -> u64 {
|
||||
PLAYLIST_DELETION_REVISION.load(std::sync::atomic::Ordering::SeqCst)
|
||||
}
|
||||
|
||||
pub(crate) fn with_deleted_playlists<R>(
|
||||
f: impl FnOnce(&std::collections::HashSet<String>) -> R,
|
||||
) -> R {
|
||||
f(&DELETED_PLAYLISTS.lock().unwrap())
|
||||
}
|
||||
|
||||
fn exclude_deleted_playlists(data: &mut LibraryData, deleted: &std::collections::HashSet<String>) {
|
||||
let before = data.feed.len();
|
||||
let mut removed_favorites = std::collections::HashSet::new();
|
||||
data.feed.retain(|row| {
|
||||
let remove = row.kind == "playlist" && deleted.contains(&row.id);
|
||||
if remove && (row.playlist_owned || row.is_favorite) {
|
||||
removed_favorites.insert(row.id.clone());
|
||||
}
|
||||
!remove
|
||||
});
|
||||
data.counts.all = (data.counts.all - (before - data.feed.len()) as i64).max(0);
|
||||
data.counts.playlists = (data.counts.playlists - removed_favorites.len() as i64).max(0);
|
||||
}
|
||||
|
||||
pub(crate) fn playlist_deleted(id: &str) {
|
||||
let mut deleted = DELETED_PLAYLISTS.lock().unwrap();
|
||||
deleted.insert(id.to_string());
|
||||
let mut library = LIBRARY.lock().unwrap();
|
||||
if let Some(data) = library.as_mut() {
|
||||
exclude_deleted_playlists(data, &deleted);
|
||||
}
|
||||
PLAYLIST_DELETION_REVISION.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
|
||||
}
|
||||
|
||||
/// The RAW local rows behind the feed's `source: "local" | "plex"` tracks,
|
||||
/// keyed by the same row id the feed row carries.
|
||||
///
|
||||
@@ -1064,8 +1106,11 @@ pub async fn load_library(runtime: &Arc<AppRuntime<LoggingAdapter>>) -> Result<u
|
||||
// refresh the mirror while it is in hand — the session seed was taken at
|
||||
// activation and a heart set from another surface since then would
|
||||
// otherwise only reach it through that surface's own write-through.
|
||||
let fav_pl_ids = crate::library_db_qt::favorite_playlist_ids();
|
||||
crate::fav_cache_qt::set_all_playlists(fav_pl_ids.iter().copied().collect());
|
||||
let mut fav_pl_ids = crate::library_db_qt::favorite_playlist_ids();
|
||||
with_deleted_playlists(|deleted| {
|
||||
fav_pl_ids.retain(|id| !deleted.contains(&id.to_string()));
|
||||
crate::fav_cache_qt::set_all_playlists(fav_pl_ids.iter().copied().collect());
|
||||
});
|
||||
for fid in fav_pl_ids {
|
||||
if !seen.insert(fid) {
|
||||
continue;
|
||||
@@ -1282,7 +1327,11 @@ pub async fn load_library(runtime: &Arc<AppRuntime<LoggingAdapter>>) -> Result<u
|
||||
labels: labels_total as i64,
|
||||
all: all_total,
|
||||
};
|
||||
*LIBRARY.lock().unwrap() = Some(LibraryData { feed, counts });
|
||||
with_deleted_playlists(|deleted| {
|
||||
let mut data = LibraryData { feed, counts };
|
||||
exclude_deleted_playlists(&mut data, deleted);
|
||||
*LIBRARY.lock().unwrap() = Some(data);
|
||||
});
|
||||
Ok(all_total as usize)
|
||||
}
|
||||
|
||||
@@ -1405,6 +1454,10 @@ fn all_local_feed_blocking() -> Vec<FeedItem> {
|
||||
quality_detail: crate::local_rows::detail_of(&a.format, a.bit_depth, a.sample_rate),
|
||||
bit_depth: a.bit_depth,
|
||||
sample_rate: Some(khz(a.sample_rate)),
|
||||
genre: a.genres.join(", "),
|
||||
track_count: Some(a.track_count),
|
||||
duration_secs: a.total_duration_secs.min(u32::MAX as u64) as u32,
|
||||
duration: mmss(a.total_duration_secs.min(u32::MAX as u64) as u32),
|
||||
is_favorite: hearted("album", &a.id),
|
||||
release_sort_key: a
|
||||
.year
|
||||
@@ -1650,7 +1703,10 @@ async fn fetch_purchases(
|
||||
genre: t.album.as_ref().and_then(|a| a.genre.as_ref())
|
||||
.map(|genre| genre.name.clone()).unwrap_or_default(),
|
||||
release_sort_key: qbz_text_utils::dates::release_sort_key(
|
||||
t.album.as_ref().and_then(|a| a.release_date_original.as_deref())),
|
||||
t.album
|
||||
.as_ref()
|
||||
.and_then(|a| a.release_date_original.as_deref()),
|
||||
),
|
||||
album: alb,
|
||||
album_id: aid,
|
||||
album_artist_id,
|
||||
@@ -1960,6 +2016,8 @@ pub fn teardown() {
|
||||
if let Ok(mut service) = LOCAL_FAVS.lock() {
|
||||
*service = None;
|
||||
}
|
||||
DELETED_PLAYLISTS.lock().unwrap().clear();
|
||||
PLAYLIST_DELETION_REVISION.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
|
||||
*LIBRARY.lock().unwrap() = None;
|
||||
LOCAL_RAW.lock().unwrap().clear();
|
||||
log::info!("[qbz-qt] library stores cleared (logout)");
|
||||
@@ -3281,3 +3339,70 @@ mod tests {
|
||||
assert!(!rows[1].is_favorite);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod playlist_deletion_regressions {
|
||||
use super::*;
|
||||
|
||||
fn snapshot() -> LibraryData {
|
||||
let row = |id: &str, kind: &str, favorite: bool| FeedItem {
|
||||
id: id.into(),
|
||||
kind: kind.into(),
|
||||
source: "qobuz".into(),
|
||||
playlist_owned: kind == "playlist",
|
||||
is_favorite: favorite,
|
||||
..Default::default()
|
||||
};
|
||||
LibraryData {
|
||||
feed: vec![
|
||||
row("42", "playlist", true),
|
||||
row("42", "playlist", true),
|
||||
row("43", "playlist", false),
|
||||
row("42", "album", true),
|
||||
],
|
||||
counts: LibraryCounts {
|
||||
all: 4,
|
||||
playlists: 2,
|
||||
albums: 1,
|
||||
tracks: 0,
|
||||
artists: 0,
|
||||
labels: 0,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn owned_delete_removes_all_copies_even_if_hearted() {
|
||||
let mut data = snapshot();
|
||||
let deleted = HashSet::from(["42".to_string()]);
|
||||
exclude_deleted_playlists(&mut data, &deleted);
|
||||
assert_eq!(data.feed.len(), 2);
|
||||
assert!(data.feed.iter().any(|r| r.kind == "album" && r.id == "42"));
|
||||
assert_eq!(data.counts.playlists, 1);
|
||||
assert_eq!(data.counts.all, 2);
|
||||
exclude_deleted_playlists(&mut data, &deleted);
|
||||
assert_eq!(
|
||||
data.counts.playlists, 1,
|
||||
"repeated reconciliation is idempotent"
|
||||
);
|
||||
assert_eq!(data.counts.all, 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn late_list_response_cannot_resurrect_deleted_playlist() {
|
||||
let deleted = HashSet::from(["42".to_string(), "43".to_string()]);
|
||||
let mut late_response = snapshot();
|
||||
exclude_deleted_playlists(&mut late_response, &deleted);
|
||||
assert_eq!(late_response.counts.playlists, 0);
|
||||
assert_eq!(late_response.counts.all, 1);
|
||||
assert_eq!(late_response.feed[0].kind, "album");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_successful_delete_keeps_snapshot_intact() {
|
||||
let mut data = snapshot();
|
||||
exclude_deleted_playlists(&mut data, &HashSet::new());
|
||||
assert_eq!(data.feed.len(), 4);
|
||||
assert_eq!(data.counts.playlists, 2);
|
||||
}
|
||||
}
|
||||
@@ -344,7 +344,7 @@ pub fn local_queue_track(t: &LocalTrack) -> QueueTrack {
|
||||
/// filesystem whether `file_path` is a directory, and that stats a path which
|
||||
/// may live on an unreachable share — the exact block the audible step's
|
||||
/// bounded probe exists to survive.
|
||||
fn belongs_to_the_offline_tier(track: &QueueTrack) -> bool {
|
||||
pub(crate) fn belongs_to_the_offline_tier(track: &QueueTrack) -> bool {
|
||||
qbz_source::RawRef::from_queue_track(track).badge == qbz_source::SourceBadge::Offline
|
||||
}
|
||||
|
||||
@@ -503,8 +503,7 @@ pub async fn play_current_if_local(runtime: &Runtime, track_id: u64) -> LocalPla
|
||||
// keep happening.
|
||||
let ours = match qbz_source::registry().claim(&raw) {
|
||||
Ok(item) => {
|
||||
item.source() != qbz_source::SourceId::QOBUZ
|
||||
&& raw.badge != qbz_source::SourceBadge::Offline
|
||||
item.source() != qbz_source::SourceId::QOBUZ && !belongs_to_the_offline_tier(&qt)
|
||||
}
|
||||
Err(_) => false,
|
||||
};
|
||||
|
||||
@@ -16,6 +16,10 @@ const ROOT_REFRESH: Duration = Duration::from_secs(60);
|
||||
const LOCAL_RECONCILE: Duration = Duration::from_secs(24 * 60 * 60);
|
||||
const NETWORK_RECONCILE: Duration = Duration::from_secs(6 * 60 * 60);
|
||||
const BUSY_RETRY: Duration = Duration::from_secs(15);
|
||||
// Failed/unavailable roots do not advance last_scan. Do not turn the one-minute
|
||||
// folder refresh into a scan loop for those roots; leave the stored success
|
||||
// timestamp intact and bound retries within this watcher session.
|
||||
const RECONCILE_RETRY: Duration = Duration::from_secs(5 * 60);
|
||||
|
||||
pub(crate) fn start() {
|
||||
static START: Once = Once::new();
|
||||
@@ -53,7 +57,14 @@ fn watch_profile(
|
||||
) {
|
||||
let mut watcher = LocalRootWatcher::new(&folders).ok();
|
||||
let mut pending = BTreeMap::<i64, Instant>::new();
|
||||
queue_periodic_due(&folders, unix_now(), Instant::now(), &mut pending);
|
||||
let mut attempted = BTreeMap::<i64, Instant>::new();
|
||||
queue_periodic_due(
|
||||
&folders,
|
||||
unix_now(),
|
||||
Instant::now(),
|
||||
&attempted,
|
||||
&mut pending,
|
||||
);
|
||||
let mut refreshed = Instant::now();
|
||||
|
||||
while crate::local_service_qt::is_current(&host) {
|
||||
@@ -95,6 +106,20 @@ fn watch_profile(
|
||||
else {
|
||||
return;
|
||||
};
|
||||
attempted.retain(|id, _| {
|
||||
current.iter().any(|new| {
|
||||
new.id == *id
|
||||
&& new.enabled
|
||||
&& folders
|
||||
.iter()
|
||||
.any(|old| old.id == *id && old.path == new.path && old.enabled)
|
||||
})
|
||||
});
|
||||
pending.retain(|id, _| {
|
||||
current
|
||||
.iter()
|
||||
.any(|folder| folder.id == *id && folder.enabled)
|
||||
});
|
||||
folders = current;
|
||||
match watcher.as_mut() {
|
||||
Some(active_watcher) => {
|
||||
@@ -104,7 +129,7 @@ fn watch_profile(
|
||||
}
|
||||
None => watcher = LocalRootWatcher::new(&folders).ok(),
|
||||
}
|
||||
queue_periodic_due(&folders, unix_now(), now, &mut pending);
|
||||
queue_periodic_due(&folders, unix_now(), now, &attempted, &mut pending);
|
||||
refreshed = now;
|
||||
}
|
||||
|
||||
@@ -115,7 +140,8 @@ fn watch_profile(
|
||||
let Some(root_id) = ready else {
|
||||
continue;
|
||||
};
|
||||
if crate::settings_qt::library::scan_for(host.clone(), Some(root_id)) {
|
||||
if crate::settings_qt::library::scan_for(host.clone(), Some(root_id), true) {
|
||||
attempted.insert(root_id, now);
|
||||
pending.remove(&root_id);
|
||||
} else {
|
||||
pending.insert(root_id, now + BUSY_RETRY);
|
||||
@@ -127,10 +153,15 @@ fn queue_periodic_due(
|
||||
folders: &[LibraryFolder],
|
||||
now_secs: i64,
|
||||
now: Instant,
|
||||
attempted: &BTreeMap<i64, Instant>,
|
||||
pending: &mut BTreeMap<i64, Instant>,
|
||||
) {
|
||||
for folder in folders.iter().filter(|folder| folder.enabled) {
|
||||
if reconciliation_due(folder, now_secs) {
|
||||
if reconciliation_due(folder, now_secs)
|
||||
&& attempted
|
||||
.get(&folder.id)
|
||||
.is_none_or(|last| now.duration_since(*last) >= RECONCILE_RETRY)
|
||||
{
|
||||
pending.entry(folder.id).or_insert(now);
|
||||
}
|
||||
}
|
||||
@@ -196,4 +227,33 @@ mod tests {
|
||||
now
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unavailable_root_does_not_rescan_on_every_folder_refresh() {
|
||||
let now = Instant::now();
|
||||
let folders = [folder(false, None)];
|
||||
let mut pending = BTreeMap::new();
|
||||
let mut attempted = BTreeMap::new();
|
||||
queue_periodic_due(&folders, 2_000_000, now, &attempted, &mut pending);
|
||||
assert!(pending.remove(&1).is_some());
|
||||
attempted.insert(1, now);
|
||||
for minute in 1..5 {
|
||||
queue_periodic_due(
|
||||
&folders,
|
||||
2_000_000 + minute * 60,
|
||||
now + Duration::from_secs(minute as u64 * 60),
|
||||
&attempted,
|
||||
&mut pending,
|
||||
);
|
||||
assert!(pending.is_empty());
|
||||
}
|
||||
queue_periodic_due(
|
||||
&folders,
|
||||
2_000_300,
|
||||
now + RECONCILE_RETRY,
|
||||
&attempted,
|
||||
&mut pending,
|
||||
);
|
||||
assert!(pending.contains_key(&1));
|
||||
}
|
||||
}
|
||||
@@ -1308,6 +1308,8 @@ pub(crate) fn reload_sidebar_including_local() {
|
||||
/// the only refresh that is correct offline AND does not cost a round trip
|
||||
/// (contract D10).
|
||||
pub(crate) fn publish_sidebar() {
|
||||
let deletion_revision = library_qt::playlist_deletion_revision();
|
||||
let insertion_revision = sidebar_qt::insertion_revision();
|
||||
let entries = sidebar_qt::rebuild();
|
||||
let json = serde_json::to_string(&entries).unwrap_or_else(|_| "[]".into());
|
||||
log::debug!(
|
||||
@@ -1317,6 +1319,11 @@ pub(crate) fn publish_sidebar() {
|
||||
);
|
||||
let (sort_by, sort_asc) = sidebar_qt::sort_state();
|
||||
shell_bridge::ui(move |mut b| {
|
||||
if deletion_revision != library_qt::playlist_deletion_revision()
|
||||
|| insertion_revision != sidebar_qt::insertion_revision()
|
||||
{
|
||||
return;
|
||||
}
|
||||
b.as_mut().set_sidebar_json(QString::from(json.as_str()));
|
||||
b.as_mut()
|
||||
.set_sidebar_sort_by(QString::from(sort_by.as_str()));
|
||||
@@ -3054,6 +3061,7 @@ pub(crate) fn reload_library() {
|
||||
let t = std::time::Instant::now();
|
||||
match library_qt::load_library(&runtime).await {
|
||||
Ok(total) => {
|
||||
let deletion_revision = library_qt::playlist_deletion_revision();
|
||||
let t_ser = std::time::Instant::now();
|
||||
let (feed_json, counts_json) = library_qt::with_library(|d| {
|
||||
(
|
||||
@@ -3072,6 +3080,10 @@ pub(crate) fn reload_library() {
|
||||
t.elapsed(),
|
||||
);
|
||||
library_bridge::ui(move |mut b| {
|
||||
if deletion_revision != library_qt::playlist_deletion_revision() {
|
||||
b.as_mut().set_library_loading(false);
|
||||
return;
|
||||
}
|
||||
b.as_mut()
|
||||
.set_library_json(QString::from(feed_json.as_str()));
|
||||
b.as_mut()
|
||||
@@ -3110,6 +3122,7 @@ pub(crate) fn reload_library() {
|
||||
///
|
||||
/// No-op before the Library has ever loaded (`with_library` -> `None`).
|
||||
pub(crate) fn publish_library_document() {
|
||||
let deletion_revision = library_qt::playlist_deletion_revision();
|
||||
let Some((feed_json, counts_json)) = library_qt::with_library(|d| {
|
||||
(
|
||||
serde_json::to_string(&d.feed).unwrap_or_else(|_| "[]".into()),
|
||||
@@ -3119,6 +3132,9 @@ pub(crate) fn publish_library_document() {
|
||||
return;
|
||||
};
|
||||
library_bridge::ui(move |mut b| {
|
||||
if deletion_revision != library_qt::playlist_deletion_revision() {
|
||||
return;
|
||||
}
|
||||
b.as_mut()
|
||||
.set_library_json(QString::from(feed_json.as_str()));
|
||||
b.as_mut()
|
||||
|
||||
@@ -284,6 +284,65 @@ const PREFETCH_LOOKAHEAD: usize = 2;
|
||||
/// after a session does not hold a listening session's worth of bytes.
|
||||
const IDLE_L1_TRIM_SECS: u64 = 300;
|
||||
|
||||
static MEMORY_APPLY_BUSY: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
pub(crate) fn memory_apply_busy() -> bool {
|
||||
MEMORY_APPLY_BUSY.load(Ordering::SeqCst)
|
||||
}
|
||||
|
||||
/// Explicit, confirmed rebuild of local playback buffers. The authority lane
|
||||
/// drains owner work and prevents the poller from repopulating gapless/L1 while
|
||||
/// Stop is acknowledged. Disk cache, queue order and audio settings survive.
|
||||
pub(crate) async fn apply_playback_memory(request: &str) -> Result<(), String> {
|
||||
let (mode, expected_track) = request.split_once(':')
|
||||
.filter(|(mode, _)| matches!(*mode, "idle" | "restart"))
|
||||
.ok_or("Playback changed. Apply again to confirm the current track.")?;
|
||||
if MEMORY_APPLY_BUSY.compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst).is_err() {
|
||||
return Ok(());
|
||||
}
|
||||
struct BusyGuard;
|
||||
impl Drop for BusyGuard {
|
||||
fn drop(&mut self) { MEMORY_APPLY_BUSY.store(false, Ordering::SeqCst); }
|
||||
}
|
||||
let _busy = BusyGuard;
|
||||
crate::settings_qt::publish_snapshot().await;
|
||||
let runtime = crate::APP.get().ok_or("Playback is not ready yet.")?;
|
||||
let service = crate::qconnect_qt::service().ok_or("Playback is not ready yet.")?;
|
||||
let (_lane, _fence) = tokio::time::timeout(
|
||||
std::time::Duration::from_secs(20), service.playback_memory_fence(),
|
||||
).await.map_err(|_| "Playback is busy. Try applying the memory profile again.".to_string())??;
|
||||
if crate::cast_qt::is_casting().await || service.is_peer_renderer_active().await {
|
||||
return Err("Playback memory can only be applied to local playback.".into());
|
||||
}
|
||||
let player = runtime.core().player();
|
||||
let event = player.get_playback_event();
|
||||
let pending = PENDING_PLAY_ID.load(Ordering::Relaxed);
|
||||
let restart = event.is_playing || pending != 0;
|
||||
let active = player.has_loaded_audio() || event.is_playing || pending != 0;
|
||||
let current = runtime.core().current_track().await;
|
||||
if (restart && mode != "restart")
|
||||
|| (active && !current.as_ref().is_some_and(|t| t.id.to_string() == expected_track)) {
|
||||
return Err("Playback changed. Apply again to confirm the current track.".into());
|
||||
}
|
||||
let _ = begin_resolved_play();
|
||||
PENDING_PLAY_ID.store(0, Ordering::Relaxed);
|
||||
player.stop_and_evict_playback_memory().await?;
|
||||
crate::now_playing::clear_loading();
|
||||
crate::now_playing::set_position(0, event.duration as i32, false, 0.0, false);
|
||||
if let Some(track) = current {
|
||||
let _ = qbz_app::session_persist::take_resume_for(track.id);
|
||||
if restart {
|
||||
crate::now_playing::begin_loading();
|
||||
let result = restore_owner_playback(runtime, track.id, 0, true).await;
|
||||
crate::now_playing::clear_loading();
|
||||
result?;
|
||||
}
|
||||
}
|
||||
refresh_now_playing(runtime).await;
|
||||
log::info!("[playback-memory] Applied profile; old playback buffers and L1 released, L2 retained");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Shared upper bound across overlapping track edges. A new current track can
|
||||
/// surface while the second successor of the previous edge is still warming;
|
||||
/// the player's own fetching marker de-duplicates the overlap, while this
|
||||
@@ -458,13 +517,30 @@ fn prefetch_quality_tag(quality: Quality) -> qbz_audio::network_throttle::Playba
|
||||
/// established byte path; only a cold network successor needs the bounded
|
||||
/// initial-buffer handoff. A zero adaptive cap protects a live stream that is
|
||||
/// already struggling instead of competing with it for the link.
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
enum GaplessSource {
|
||||
Download,
|
||||
Catalog,
|
||||
Local,
|
||||
}
|
||||
|
||||
fn gapless_source(track: &QueueTrack) -> GaplessSource {
|
||||
if crate::local_playback::belongs_to_the_offline_tier(track) {
|
||||
GaplessSource::Download
|
||||
} else if track.is_local {
|
||||
GaplessSource::Local
|
||||
} else {
|
||||
GaplessSource::Catalog
|
||||
}
|
||||
}
|
||||
|
||||
fn should_stream_gapless_successor(
|
||||
streaming_only: bool,
|
||||
_streaming_only: bool,
|
||||
player_cached: bool,
|
||||
offline_cached: bool,
|
||||
throttle_cap: usize,
|
||||
) -> bool {
|
||||
streaming_only && !player_cached && !offline_cached && throttle_cap > 0
|
||||
!offline_cached && (player_cached || throttle_cap > 0)
|
||||
}
|
||||
|
||||
/// The queue/engine edge a completed gapless fetch still belongs to.
|
||||
@@ -538,16 +614,20 @@ async fn kick_prefetch(
|
||||
.casting_prefetch_quality()
|
||||
.await
|
||||
.unwrap_or_else(|| local_playback_quality().0);
|
||||
let memory = runtime.core().player().playback_prefetch_policy();
|
||||
if qbz_app::memory_watchdog::prefetch_halted()
|
||||
|| ((!memory.allow_hires || qbz_app::memory_watchdog::hires_prefetch_paused())
|
||||
&& matches!(quality, Quality::HiRes | Quality::UltraHiRes)) { return; }
|
||||
let throttle_cap = qbz_audio::network_throttle::state().current_prefetch_cap(
|
||||
qbz_audio::network_throttle::playback_mbps_for_quality(prefetch_quality_tag(quality)),
|
||||
MAX_CONCURRENT_PREFETCH,
|
||||
MAX_CONCURRENT_PREFETCH.min(memory.concurrency),
|
||||
);
|
||||
if throttle_cap == 0 {
|
||||
log::debug!("[qbz-qt] prefetch: skipped (network throttle cap 0)");
|
||||
return;
|
||||
}
|
||||
|
||||
let upcoming = runtime.core().peek_upcoming(PREFETCH_LOOKAHEAD).await;
|
||||
let upcoming = runtime.core().peek_upcoming(PREFETCH_LOOKAHEAD.min(memory.lookahead)).await;
|
||||
let candidates: Vec<PrefetchCandidate> = upcoming.iter().map(PrefetchCandidate::from).collect();
|
||||
let track_ids = prefetch_track_ids(streaming_only, offline, throttle_cap, &candidates);
|
||||
for track_id in track_ids {
|
||||
@@ -570,6 +650,7 @@ async fn kick_prefetch(
|
||||
return;
|
||||
};
|
||||
let player = runtime.core().player();
|
||||
if !player.playback_prefetch_policy().allow_hires && matches!(quality, Quality::HiRes | Quality::UltraHiRes) { return; }
|
||||
if let Err(error) = player.prefetch_into_cache(client, track_id, quality).await {
|
||||
log::debug!("[qbz-qt] prefetch: track {track_id} failed: {error}");
|
||||
}
|
||||
@@ -4725,7 +4806,52 @@ pub fn start_poll_loop(runtime: Arc<AppRuntime<LoggingAdapter>>) {
|
||||
"[qbz-qt] [GAPLESS] upcoming {} is the track already playing; cursor off by one, successor skipped",
|
||||
next.id
|
||||
);
|
||||
} else if next.id != track_id && !next.is_local {
|
||||
} else if next.id != track_id
|
||||
&& gapless_source(&next) == GaplessSource::Download
|
||||
{
|
||||
// Local Library downloads carry a catalog play ID
|
||||
// and a local row hint, but their path is an encrypted
|
||||
// bundle. Use the same offline resolver as normal
|
||||
// playback, before the generic local-file branch.
|
||||
gapless_requested_for = track_id;
|
||||
let runtime = runtime.clone();
|
||||
let next_id = next.id;
|
||||
let task = spawn_owner_playback_task(async move {
|
||||
let Some(_owner_action) =
|
||||
begin_owner_action_exact(owner_token).await
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let Some(offline) = crate::offline_qt::get().await else {
|
||||
return;
|
||||
};
|
||||
let sink = crate::offline_cache_qt::row_sink();
|
||||
let Some(bytes) = qbz_core::offline_resolve::resolve_offline_bytes(
|
||||
next_id,
|
||||
&offline,
|
||||
Some(&sink),
|
||||
)
|
||||
.await
|
||||
else {
|
||||
return;
|
||||
};
|
||||
if !gapless_edge_is_current(&runtime, track_id, next_id).await {
|
||||
return;
|
||||
}
|
||||
if let Err(error) =
|
||||
runtime.core().player().play_next(bytes, next_id)
|
||||
{
|
||||
log::warn!(
|
||||
"[qbz-qt] [GAPLESS] offline successor {next_id}: {error}"
|
||||
);
|
||||
}
|
||||
});
|
||||
if let Some(stale) = gapless_fetch_task.replace(task) {
|
||||
stale.abort();
|
||||
}
|
||||
} else if next.id != track_id
|
||||
&& gapless_source(&next) == GaplessSource::Catalog
|
||||
{
|
||||
gapless_requested_for = track_id;
|
||||
let runtime = runtime.clone();
|
||||
let next_id = next.id;
|
||||
@@ -4819,48 +4945,22 @@ pub fn start_poll_loop(runtime: Arc<AppRuntime<LoggingAdapter>>) {
|
||||
runtime.core().player().is_track_cached(next_id);
|
||||
let offline_cached = crate::offline_qt::is_cached_id(next_id);
|
||||
|
||||
if streaming_only && !player_cached && !offline_cached {
|
||||
// A long successor cannot reliably finish a
|
||||
// whole-file download inside the fixed 10 s
|
||||
// handoff window. In Streaming only, prepare
|
||||
// just its initial CMAF buffer and append that
|
||||
// incremental source behind the current one.
|
||||
// The same adaptive throttle that protects
|
||||
// immediate prefetch keeps a struggling live
|
||||
// stream from starting this second transfer.
|
||||
if !offline_cached {
|
||||
// L1/L2 and cold catalog successors all use the
|
||||
// bounded streaming/file path. Offline downloads
|
||||
// retain the existing offline-aware resolver below.
|
||||
let throttle_cap = qbz_audio::network_throttle::state()
|
||||
.current_prefetch_cap(
|
||||
qbz_audio::network_throttle::playback_mbps_for_quality(
|
||||
prefetch_quality_tag(quality),
|
||||
),
|
||||
1,
|
||||
);
|
||||
if !should_stream_gapless_successor(
|
||||
streaming_only,
|
||||
player_cached,
|
||||
offline_cached,
|
||||
throttle_cap,
|
||||
) {
|
||||
log::info!(
|
||||
"[qbz-qt] [GAPLESS] streaming successor {next_id} skipped: network throttle cap 0"
|
||||
);
|
||||
qbz_audio::network_throttle::playback_mbps_for_quality(prefetch_quality_tag(quality)), 1);
|
||||
if !should_stream_gapless_successor(streaming_only, player_cached, offline_cached, throttle_cap) {
|
||||
log::info!("[qbz-qt] [GAPLESS] successor {next_id} skipped: network throttle cap 0");
|
||||
return;
|
||||
}
|
||||
match runtime
|
||||
.core()
|
||||
.queue_gapless_streaming(next_id, quality)
|
||||
.await
|
||||
{
|
||||
Ok(()) => {
|
||||
log::info!(
|
||||
"[qbz-qt] [GAPLESS] queued streaming track {next_id}"
|
||||
);
|
||||
return;
|
||||
match runtime.core().queue_gapless_streaming(next_id, quality).await {
|
||||
Ok(()) => log::info!("[qbz-qt] [GAPLESS] queued streaming track {next_id}"),
|
||||
Err(error) => log::warn!("[qbz-qt] [GAPLESS] streaming setup for {next_id} failed: {error}"),
|
||||
}
|
||||
Err(error) => log::warn!(
|
||||
"[qbz-qt] [GAPLESS] streaming setup for {next_id} failed: {error}; trying byte fallback"
|
||||
),
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Shared tier-walk: L1/L2 (player cache) -> OFFLINE
|
||||
@@ -4911,7 +5011,9 @@ pub fn start_poll_loop(runtime: Arc<AppRuntime<LoggingAdapter>>) {
|
||||
if let Some(stale) = gapless_fetch_task.replace(task) {
|
||||
stale.abort();
|
||||
}
|
||||
} else if next.id != track_id && next.is_local {
|
||||
} else if next.id != track_id
|
||||
&& gapless_source(&next) == GaplessSource::Local
|
||||
{
|
||||
// Source-owned gapless. `is_local` means "does not use
|
||||
// Qobuz's tier walk", NOT "has a filesystem path": it
|
||||
// also covers Plex/Jellyfin/Subsonic streams. Resolve
|
||||
@@ -5566,8 +5668,10 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cached_or_offline_successor_keeps_the_byte_handoff() {
|
||||
assert!(!should_stream_gapless_successor(true, true, false, 1));
|
||||
fn cached_successor_uses_file_handoff_and_offline_keeps_its_resolver() {
|
||||
assert!(should_stream_gapless_successor(true, true, false, 1));
|
||||
assert!(should_stream_gapless_successor(false, true, false, 0));
|
||||
assert!(should_stream_gapless_successor(false, false, false, 1));
|
||||
assert!(!should_stream_gapless_successor(true, false, true, 1));
|
||||
}
|
||||
|
||||
@@ -5974,3 +6078,99 @@ mod tests {
|
||||
assert!(qml.contains("b - qb <= near"));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod offline_gapless_regressions {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn local_library_download_routes_to_offline_bytes_by_catalog_id() {
|
||||
use qbz_offline_cache::{OfflineCacheState, OfflineCacheStatus, TrackCacheInfo};
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let offline = OfflineCacheState::new_empty();
|
||||
offline.init_at(temp.path()).await.unwrap();
|
||||
let file = temp.path().join("download.flac");
|
||||
let bytes = b"fLaC-regression-byte-source";
|
||||
std::fs::write(&file, bytes).unwrap();
|
||||
let row = crate::local_playback::local_queue_track(&qbz_library::LocalTrack {
|
||||
id: 4938,
|
||||
qobuz_track_id: Some(266725026),
|
||||
source: Some("qobuz_download".into()),
|
||||
file_path: file.display().to_string(),
|
||||
..Default::default()
|
||||
});
|
||||
assert!(row.is_local);
|
||||
assert_eq!(row.source_item_id_hint.as_deref(), Some("4938"));
|
||||
assert_eq!(gapless_source(&row), GaplessSource::Download);
|
||||
{
|
||||
let guard = offline.db.lock().await;
|
||||
let db = guard.as_ref().unwrap();
|
||||
db.insert_track(
|
||||
&TrackCacheInfo {
|
||||
track_id: row.id,
|
||||
title: "Next".into(),
|
||||
artist: "Fixture".into(),
|
||||
album: None,
|
||||
album_id: None,
|
||||
duration_secs: 30,
|
||||
quality: "hires".into(),
|
||||
bit_depth: Some(24),
|
||||
sample_rate: Some(192000.0),
|
||||
},
|
||||
file.to_str().unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
db.update_status(row.id, OfflineCacheStatus::Ready, None)
|
||||
.unwrap();
|
||||
}
|
||||
let resolved = qbz_core::offline_resolve::resolve_offline_bytes(row.id, &offline, None)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resolved, bytes);
|
||||
assert!(
|
||||
qbz_core::offline_resolve::resolve_offline_bytes(4938, &offline, None)
|
||||
.await
|
||||
.is_none()
|
||||
);
|
||||
std::fs::remove_file(file).unwrap();
|
||||
assert!(
|
||||
qbz_core::offline_resolve::resolve_offline_bytes(row.id, &offline, None)
|
||||
.await
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
|
||||
fn queue_row(source: &str, is_local: bool) -> QueueTrack {
|
||||
let mut row = crate::local_playback::local_queue_track(&qbz_library::LocalTrack {
|
||||
id: 42, ..Default::default()
|
||||
});
|
||||
row.source = Some(source.into());
|
||||
row.is_local = is_local;
|
||||
row
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn files_purchases_and_servers_keep_their_source_route() {
|
||||
for source in [
|
||||
"user",
|
||||
"local",
|
||||
"ephemeral",
|
||||
"qobuz_purchase",
|
||||
"plex",
|
||||
"jellyfin",
|
||||
"subsonic",
|
||||
"navidrome",
|
||||
] {
|
||||
let row = queue_row(source, true);
|
||||
assert_eq!(gapless_source(&row), GaplessSource::Local, "{source}");
|
||||
}
|
||||
for source in ["qobuz_download", "offline"] {
|
||||
let row = queue_row(source, true);
|
||||
assert_eq!(gapless_source(&row), GaplessSource::Download, "{source}");
|
||||
}
|
||||
assert_eq!(
|
||||
gapless_source(&queue_row("qobuz", false)),
|
||||
GaplessSource::Catalog
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -183,20 +183,14 @@ fn settle_failure(msgid: &'static str) {
|
||||
crate::toast_qt::error(qbz_i18n::t(msgid));
|
||||
}
|
||||
|
||||
fn settle_success(new_id: String, local: bool) {
|
||||
fn settle_success(new_id: String) {
|
||||
{
|
||||
let mut st = state();
|
||||
st.busy = false;
|
||||
st.open = false;
|
||||
}
|
||||
publish();
|
||||
// The offline-safe verb: `reload_sidebar` early-returns while offline, and
|
||||
// a local playlist created there would never appear in the tree.
|
||||
if local {
|
||||
crate::reload_sidebar_including_local();
|
||||
} else {
|
||||
crate::reload_sidebar();
|
||||
}
|
||||
crate::publish_sidebar();
|
||||
// Land on it, exactly as the reference does after either arm
|
||||
// (`main.rs:21554` / `:21598`). `open_playlist` routes a `local:` id to the
|
||||
// local loader and does not offline-gate it.
|
||||
@@ -242,6 +236,7 @@ pub(crate) fn submit(
|
||||
// `main.rs:21528-21530`): the flag is what "Upload to Qobuz" reads, and
|
||||
// Edit can unmark it later.
|
||||
crate::spawn(async move {
|
||||
let created_name = name.clone();
|
||||
let created = tokio::task::spawn_blocking(move || {
|
||||
let desc = if description.is_empty() {
|
||||
None
|
||||
@@ -256,7 +251,8 @@ pub(crate) fn submit(
|
||||
match created {
|
||||
Some(new_id) => {
|
||||
log::info!("[qbz-qt] local playlist created: {new_id}");
|
||||
settle_success(new_id, true);
|
||||
crate::sidebar_qt::insert_local_entry(&new_id, &created_name);
|
||||
settle_success(new_id);
|
||||
}
|
||||
None => {
|
||||
log::error!("[qbz-qt] create local playlist failed");
|
||||
@@ -294,7 +290,14 @@ pub(crate) fn submit(
|
||||
playlist.name,
|
||||
playlist.id
|
||||
);
|
||||
settle_success(new_id, false);
|
||||
crate::sidebar_qt::insert_qobuz_entry(
|
||||
playlist.id,
|
||||
&playlist.name,
|
||||
playlist.tracks_count,
|
||||
&[],
|
||||
);
|
||||
crate::sidebar_qt::move_playlist_optimistic(&new_id, &folder_id);
|
||||
settle_success(new_id);
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("[qbz-qt] create playlist failed: {e}");
|
||||
|
||||
@@ -273,8 +273,12 @@ fn build_document() -> (String, String) {
|
||||
|
||||
/// Publish the document. **Never touches `pmLoading`** (§5.4).
|
||||
pub(crate) fn publish_document() {
|
||||
let deletion_revision = crate::library_qt::playlist_deletion_revision();
|
||||
let (manager, folders) = build_document();
|
||||
crate::playlist_manager_bridge::ui(move |mut b| {
|
||||
if deletion_revision != crate::library_qt::playlist_deletion_revision() {
|
||||
return;
|
||||
}
|
||||
b.as_mut().set_manager_json(QString::from(manager.as_str()));
|
||||
b.as_mut().set_folders_json(QString::from(folders.as_str()));
|
||||
});
|
||||
@@ -283,8 +287,13 @@ pub(crate) fn publish_document() {
|
||||
/// The load's TERMINAL rebuild — the one and only place `pmLoading` is
|
||||
/// cleared.
|
||||
fn publish_loaded() {
|
||||
let deletion_revision = crate::library_qt::playlist_deletion_revision();
|
||||
let (manager, folders) = build_document();
|
||||
crate::playlist_manager_bridge::ui(move |mut b| {
|
||||
if deletion_revision != crate::library_qt::playlist_deletion_revision() {
|
||||
b.as_mut().set_pm_loading(false);
|
||||
return;
|
||||
}
|
||||
b.as_mut().set_manager_json(QString::from(manager.as_str()));
|
||||
b.as_mut().set_folders_json(QString::from(folders.as_str()));
|
||||
b.as_mut().set_pm_loading(false);
|
||||
@@ -338,8 +347,12 @@ pub(crate) fn reload() {
|
||||
});
|
||||
let runtime = crate::app();
|
||||
crate::spawn(async move {
|
||||
let data = load(&runtime).await;
|
||||
*CACHE.lock().unwrap() = Some(data);
|
||||
let mut data = load(&runtime).await;
|
||||
crate::library_qt::with_deleted_playlists(|deleted| {
|
||||
data.playlists
|
||||
.retain(|p| !deleted.contains(&p.id.to_string()));
|
||||
*CACHE.lock().unwrap() = Some(data);
|
||||
});
|
||||
publish_loaded();
|
||||
});
|
||||
}
|
||||
@@ -365,6 +378,15 @@ pub fn reload_if_loaded() {
|
||||
reload();
|
||||
}
|
||||
|
||||
pub(crate) fn playlist_deleted(id: u64) {
|
||||
{
|
||||
let mut cache = CACHE.lock().unwrap();
|
||||
let Some(data) = cache.as_mut() else { return };
|
||||
data.playlists.retain(|p| p.id != id);
|
||||
}
|
||||
publish_document();
|
||||
}
|
||||
|
||||
/// Name + description of a QOBUZ playlist out of the WARM manager cache.
|
||||
///
|
||||
/// Block 3's editor seeds from here before it falls back to
|
||||
|
||||
@@ -809,6 +809,8 @@ pub fn create_and_add(name: &str) {
|
||||
crate::toast_qt::error(qbz_i18n::t("Couldn't create the playlist"));
|
||||
return;
|
||||
};
|
||||
crate::sidebar_qt::insert_local_entry(&new_id, &name);
|
||||
crate::publish_sidebar();
|
||||
let added = tokio::task::spawn_blocking({
|
||||
let new_id = new_id.clone();
|
||||
move || match payload {
|
||||
@@ -838,6 +840,13 @@ pub fn create_and_add(name: &str) {
|
||||
match runtime.core().create_playlist(&name, None, false).await {
|
||||
Ok(playlist) => {
|
||||
let pid = playlist.id;
|
||||
crate::sidebar_qt::insert_qobuz_entry(
|
||||
pid,
|
||||
&playlist.name,
|
||||
playlist.tracks_count,
|
||||
&[],
|
||||
);
|
||||
crate::publish_sidebar();
|
||||
// Snapshot the newborn header FIRST (owned, empty, captured):
|
||||
// the incremental producer below refuses playlists it has
|
||||
// never captured, and the authoritative list will not name
|
||||
|
||||
@@ -384,7 +384,11 @@ enum SidecarRemoval {
|
||||
/// fallback that lets a dead Plex/Jellyfin/Subsonic row be removed. Returns
|
||||
/// `None` for a Qobuz row (the caller falls through to the Qobuz arm) or when
|
||||
/// no ref can be derived.
|
||||
fn sidecar_removal(source: &str, row_id: &str, queue_ref: Option<String>) -> Option<SidecarRemoval> {
|
||||
fn sidecar_removal(
|
||||
source: &str,
|
||||
row_id: &str,
|
||||
queue_ref: Option<String>,
|
||||
) -> Option<SidecarRemoval> {
|
||||
let strip = |prefix: &str| {
|
||||
queue_ref
|
||||
.as_deref()
|
||||
@@ -588,29 +592,34 @@ static FOLLOWED_PLAYLISTS: std::sync::LazyLock<Mutex<std::collections::HashSet<u
|
||||
/// `(playlist id, owner id)` pairs. Callers pass pairs rather than the models
|
||||
/// so this stays independent of which of the two fetchers happens to run.
|
||||
pub fn set_user_playlists(pairs: &[(u64, u64)]) {
|
||||
let uid = USER_ID.load(std::sync::atomic::Ordering::SeqCst);
|
||||
let mut owned = std::collections::HashSet::new();
|
||||
let mut followed = std::collections::HashSet::new();
|
||||
for &(id, owner) in pairs {
|
||||
// uid == 0 means "no session id yet" — claiming ownership on an id
|
||||
// that has not been set would mark EVERY row owned.
|
||||
if uid != 0 && owner == uid {
|
||||
owned.insert(id);
|
||||
} else {
|
||||
followed.insert(id);
|
||||
crate::library_qt::with_deleted_playlists(|deleted| {
|
||||
let uid = USER_ID.load(std::sync::atomic::Ordering::SeqCst);
|
||||
let mut owned = std::collections::HashSet::new();
|
||||
let mut followed = std::collections::HashSet::new();
|
||||
for &(id, owner) in pairs {
|
||||
if deleted.contains(&id.to_string()) {
|
||||
continue;
|
||||
}
|
||||
// uid == 0 means "no session id yet" — claiming ownership on an id
|
||||
// that has not been set would mark EVERY row owned.
|
||||
if uid != 0 && owner == uid {
|
||||
owned.insert(id);
|
||||
} else {
|
||||
followed.insert(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
log::info!(
|
||||
"[qbz-qt] playlist ownership snapshot: {} owned / {} followed",
|
||||
owned.len(),
|
||||
followed.len()
|
||||
);
|
||||
if let Ok(mut g) = OWNED_PLAYLISTS.lock() {
|
||||
*g = owned;
|
||||
}
|
||||
if let Ok(mut g) = FOLLOWED_PLAYLISTS.lock() {
|
||||
*g = followed;
|
||||
}
|
||||
log::info!(
|
||||
"[qbz-qt] playlist ownership snapshot: {} owned / {} followed",
|
||||
owned.len(),
|
||||
followed.len()
|
||||
);
|
||||
if let Ok(mut g) = OWNED_PLAYLISTS.lock() {
|
||||
*g = owned;
|
||||
}
|
||||
if let Ok(mut g) = FOLLOWED_PLAYLISTS.lock() {
|
||||
*g = followed;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Is the session user id known yet? Ownership answers are meaningless (all
|
||||
@@ -1883,16 +1892,27 @@ pub async fn delete_by_id(
|
||||
if owned { "deleted" } else { "unsubscribed" }
|
||||
);
|
||||
if owned {
|
||||
crate::library_qt::playlist_deleted(&pid.to_string());
|
||||
OWNED_PLAYLISTS.lock().unwrap().remove(&pid);
|
||||
mark_following(pid, false);
|
||||
crate::fav_cache_qt::set("playlist", &pid.to_string(), false);
|
||||
crate::sidebar_qt::remove_qobuz_entry(pid);
|
||||
crate::playlist_manager_qt::playlist_deleted(pid);
|
||||
crate::publish_sidebar();
|
||||
crate::publish_library_document();
|
||||
// Leave the membership index's target set now instead of waiting out
|
||||
// the authoritative-list retirement grace.
|
||||
let _ = tokio::task::spawn_blocking(move || {
|
||||
crate::library_db_qt::with_db(true, |db| {
|
||||
db.delete_playlist_settings(pid)?;
|
||||
Ok(db.with_connection(|conn| {
|
||||
qbz_library::qobuz_playlist_snapshot::mark_inactive(conn, pid)
|
||||
}))
|
||||
})
|
||||
})
|
||||
.await;
|
||||
} else {
|
||||
follow_settled(pid, false, None);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -39,10 +39,14 @@ pub fn authoritative_entries(
|
||||
)
|
||||
}
|
||||
|
||||
pub fn record_authoritative_detached(entries: Vec<AuthoritativeEntry>) {
|
||||
pub fn record_authoritative_detached(mut entries: Vec<AuthoritativeEntry>) {
|
||||
let write = move || {
|
||||
let result = crate::library_db_qt::with_db(true, |db| {
|
||||
Ok(db.with_connection(|conn| repo::record_authoritative_list(conn, &entries)))
|
||||
let result = crate::library_qt::with_deleted_playlists(|deleted| {
|
||||
entries.retain(|entry| !deleted.contains(&entry.qobuz_playlist_id.to_string()));
|
||||
let result = crate::library_db_qt::with_db(true, |db| {
|
||||
Ok(db.with_connection(|conn| repo::record_authoritative_list(conn, &entries)))
|
||||
});
|
||||
result
|
||||
});
|
||||
match result {
|
||||
Some(Ok(generation)) => {
|
||||
|
||||
@@ -216,6 +216,22 @@ pub struct QtDelegationHost {
|
||||
}
|
||||
|
||||
impl QtDelegationHost {
|
||||
/// Serialize local memory maintenance with authority handoffs. Retain both
|
||||
/// guards until restart completes, dropping the fence before the lane.
|
||||
pub(crate) async fn playback_memory_fence(
|
||||
&self,
|
||||
) -> Result<(OwnedMutexGuard<()>, OwnerActionFence), String> {
|
||||
let lane = Arc::clone(&self.transition_gate).lock_owned().await;
|
||||
if self.authority.current().is_some_and(|s| s.origin() != AuthorityOrigin::Owner) {
|
||||
return Err("Playback memory can only be applied to local playback.".into());
|
||||
}
|
||||
let fence = OwnerActionFence::acquire_drained(
|
||||
Arc::clone(&self.authority),
|
||||
crate::playback_qt::cancel_owner_playback_tasks,
|
||||
).await;
|
||||
Ok((lane, fence))
|
||||
}
|
||||
|
||||
pub fn new(
|
||||
runtime: Runtime,
|
||||
inner: Arc<StdMutex<QtQconnectInner>>,
|
||||
|
||||
@@ -1364,6 +1364,12 @@ impl QtQconnectService {
|
||||
self.authority.observe_owner_authority()
|
||||
}
|
||||
|
||||
pub(crate) async fn playback_memory_fence(&self) -> Result<
|
||||
(tokio::sync::OwnedMutexGuard<()>, qconnect_app::OwnerActionFence), String,
|
||||
> {
|
||||
self.delegation_host.playback_memory_fence().await
|
||||
}
|
||||
|
||||
/// Preserve already-stamped owner work across a fallible candidate fence.
|
||||
/// It returns `None` only once that exact owner generation is truly stale.
|
||||
pub async fn wait_for_exact_owner_action_permit(
|
||||
@@ -3138,7 +3144,7 @@ impl QtQconnectService {
|
||||
/// 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.
|
||||
async fn is_peer_renderer_active(&self) -> bool {
|
||||
pub(crate) async fn is_peer_renderer_active(&self) -> bool {
|
||||
let sync_state = {
|
||||
let guard = lock_inner(&self.inner);
|
||||
let Some(runtime) = guard.runtime.as_ref() else {
|
||||
|
||||
@@ -47,6 +47,8 @@ use serde::Serialize;
|
||||
pub mod devtools;
|
||||
pub mod import_export;
|
||||
pub mod library;
|
||||
mod appearance_profile;
|
||||
mod playback_storage;
|
||||
pub mod offline;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -1500,6 +1502,9 @@ pub(crate) fn read_pref(key: &str) -> Option<serde_json::Value> {
|
||||
/// atomic rename and its refusal to rebuild an unparsable document.
|
||||
pub fn save_pref(key: &str, value: serde_json::Value) {
|
||||
update_prefs(|doc| {
|
||||
if appearance_profile::managed(key) && doc.get(key) != Some(&value) {
|
||||
doc.insert("appearance_profile".into(), serde_json::json!("custom"));
|
||||
}
|
||||
doc.insert(key.to_string(), value);
|
||||
true
|
||||
});
|
||||
@@ -2089,6 +2094,18 @@ pub struct SettingsDoc {
|
||||
pub buffer_seconds: i32,
|
||||
#[serde(rename = "streamingOnly")]
|
||||
pub streaming_only: bool,
|
||||
#[serde(rename = "playbackCache")]
|
||||
pub playback_cache: qbz_models::playback_cache::PlaybackCacheSettings,
|
||||
#[serde(rename = "playbackMemoryProfile")]
|
||||
pub playback_memory_profile: String,
|
||||
#[serde(rename = "appearanceProfile")]
|
||||
pub appearance_profile: String,
|
||||
#[serde(rename = "playbackMemoryApplyBusy")]
|
||||
pub playback_memory_apply_busy: bool,
|
||||
#[serde(rename = "playbackStorage")]
|
||||
pub playback_storage: playback_storage::Snapshot,
|
||||
#[serde(rename = "playbackCacheUsage")]
|
||||
pub playback_cache_usage: Option<qbz_cache::CacheStats>,
|
||||
#[serde(rename = "retryBehaviors")]
|
||||
pub retry_behaviors: Vec<String>,
|
||||
#[serde(rename = "retryBehaviorIndex")]
|
||||
@@ -2618,6 +2635,12 @@ pub async fn publish_snapshot() {
|
||||
stream_uncached: audio_settings.stream_first_track,
|
||||
buffer_seconds: audio_settings.stream_buffer_seconds as i32,
|
||||
streaming_only: audio_settings.streaming_only,
|
||||
playback_cache: audio_settings.playback_cache.clone(),
|
||||
playback_memory_profile: audio_settings.playback_cache.selected_profile().as_str().into(),
|
||||
appearance_profile: appearance_profile::selected(),
|
||||
playback_memory_apply_busy: crate::playback_qt::memory_apply_busy(),
|
||||
playback_storage: playback_storage::snapshot(),
|
||||
playback_cache_usage: crate::APP.get().map(|r| r.core().player().playback_cache_stats()),
|
||||
retry_behaviors: RETRY_BEHAVIOR_LABELS
|
||||
.iter()
|
||||
.map(|l| qbz_i18n::t(l))
|
||||
@@ -3417,6 +3440,12 @@ pub(crate) async fn reconcile_alsa_hardware_volume(
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
pub async fn settings_bool(runtime: &Arc<AppRuntime<LoggingAdapter>>, key: &str, value: bool) {
|
||||
if key == "playback-cache-dynamic" {
|
||||
update_playback_cache(|p| { p.make_custom(); p.dynamic = value; Ok(()) });
|
||||
publish_snapshot().await;
|
||||
return;
|
||||
}
|
||||
|
||||
// Hardware-volume toggles can await a physical mixer write, seed the live
|
||||
// Player and reinitialize its output. Admit before the first persistence or
|
||||
// cascade so a rejected delegated action is a complete no-op.
|
||||
@@ -3442,11 +3471,6 @@ pub async fn settings_bool(runtime: &Arc<AppRuntime<LoggingAdapter>>, key: &str,
|
||||
cascaded = true;
|
||||
}
|
||||
}
|
||||
"streaming-only" if value => {
|
||||
if with_audio(|s| s.set_gapless_enabled(false)).is_ok() {
|
||||
cascaded = true;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
@@ -4286,12 +4310,56 @@ pub async fn settings_slider(runtime: &Arc<AppRuntime<LoggingAdapter>>, key: &st
|
||||
publish_snapshot().await;
|
||||
}
|
||||
|
||||
fn update_playback_cache(change: impl FnOnce(&mut qbz_models::playback_cache::PlaybackCacheSettings) -> Result<(), String>) {
|
||||
let result = with_audio(|store| {
|
||||
let mut settings = store.get_settings()?;
|
||||
change(&mut settings.playback_cache)?;
|
||||
store.set_playback_cache(&settings.playback_cache)?;
|
||||
if let Some(runtime) = crate::APP.get() {
|
||||
runtime.core().player().configure_playback_cache(settings.playback_cache)?;
|
||||
}
|
||||
Ok(())
|
||||
});
|
||||
if let Err(error) = result {
|
||||
log::warn!("Playback cache setting rejected: {error}");
|
||||
crate::toast_qt::error(qbz_i18n::t(&error));
|
||||
}
|
||||
}
|
||||
|
||||
/// String-payload handler. Also the ACTION channel for the sections whose
|
||||
/// affordances are buttons rather than settings (Local Library folders and
|
||||
/// scans, the caches, the developer tools): the payload is the action's
|
||||
/// argument ("" when it takes none).
|
||||
pub async fn settings_string(key: &str, value: String) {
|
||||
match key {
|
||||
"playback-memory-profile" => update_playback_cache(|policy| {
|
||||
policy.select_profile(qbz_models::playback_cache::PlaybackMemoryProfile::parse(&value)?);
|
||||
Ok(())
|
||||
}),
|
||||
"playback-cache-min" | "playback-cache-max" => {
|
||||
update_playback_cache(|policy| {
|
||||
let limit = if value.trim().is_empty() { None } else {
|
||||
Some(value.trim().parse::<u32>().map_err(|_| "Playback cache limits must be between 16 and 16384 MiB".to_string())?)
|
||||
};
|
||||
policy.make_custom();
|
||||
if key == "playback-cache-min" { policy.min_mib = limit; } else { policy.max_mib = limit; }
|
||||
Ok(())
|
||||
});
|
||||
}
|
||||
"playback-cache-reset" => update_playback_cache(|policy| {
|
||||
policy.select_profile(qbz_models::playback_cache::PlaybackMemoryProfile::Auto); Ok(())
|
||||
}),
|
||||
"playback-cache-refresh" => {},
|
||||
"appearance-profile" => appearance_profile::apply(&value),
|
||||
"playback-storage-folder" => playback_storage::set(value).await,
|
||||
"playback-storage-browse" => playback_storage::browse().await,
|
||||
"playback-cache-apply" => {
|
||||
if let Err(error) = crate::playback_qt::apply_playback_memory(&value).await {
|
||||
log::warn!("[playback-memory] Apply failed: {error}");
|
||||
crate::toast_qt::error(qbz_i18n::t(&error));
|
||||
}
|
||||
},
|
||||
|
||||
"qconnect-device-name" => {
|
||||
let trimmed = value.trim().to_string();
|
||||
let stored = (!trimmed.is_empty()).then_some(trimmed);
|
||||
@@ -4411,8 +4479,7 @@ pub async fn settings_string(key: &str, value: String) {
|
||||
crate::nav_qt::record("libraryfolders");
|
||||
}
|
||||
"library-pick-folder" => {
|
||||
// Native chooser, then the SAME add path as the typed field —
|
||||
// the picker only supplies the string.
|
||||
// Native chooser fills the field; Add confirms registration.
|
||||
library::pick_and_add_folder().await;
|
||||
}
|
||||
"library-remove-folders" => {
|
||||
@@ -4709,6 +4776,25 @@ mod local_tab_order_tests {
|
||||
mod exclusive_gate_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn memory_and_appearance_documents_use_the_qml_field_names() {
|
||||
let doc = SettingsDoc {
|
||||
playback_memory_profile: "low".into(),
|
||||
appearance_profile: "custom".into(),
|
||||
playback_memory_apply_busy: true,
|
||||
playback_storage: playback_storage::Snapshot {
|
||||
candidate: "/mnt/music".into(),
|
||||
..Default::default()
|
||||
},
|
||||
..Default::default()
|
||||
};
|
||||
let json = serde_json::to_value(doc).unwrap();
|
||||
assert_eq!(json["playbackMemoryProfile"], "low");
|
||||
assert_eq!(json["appearanceProfile"], "custom");
|
||||
assert_eq!(json["playbackMemoryApplyBusy"], true);
|
||||
assert_eq!(json["playbackStorage"]["candidate"], "/mnt/music");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn settings_doc_publishes_backend_is_coreaudio_under_the_name_qml_reads() {
|
||||
// AudioSettings.qml gates the Exclusive-mode row on
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
//! Visual presets do not change audio, renderer selection or waveform experiments.
|
||||
use serde_json::{Map, Value, json};
|
||||
|
||||
fn values(low: bool) -> Map<String, Value> {
|
||||
json!({
|
||||
"album_header_gradient": !low,
|
||||
"compact_album_header": low,
|
||||
"app_background": "off",
|
||||
"large_visualizer_on": !low,
|
||||
"sidebar_playlist_collage": !low,
|
||||
"queue_track_artwork": false,
|
||||
"library_track_artwork": false,
|
||||
"local_library_track_artwork": false,
|
||||
"play_indicator_animation": false,
|
||||
"npb_mode": if low { "small" } else { "new" }
|
||||
}).as_object().unwrap().clone()
|
||||
}
|
||||
|
||||
pub(super) fn managed(key: &str) -> bool {
|
||||
matches!(key, "album_header_gradient" | "compact_album_header" | "app_background"
|
||||
| "large_visualizer_on" | "sidebar_playlist_collage" | "queue_track_artwork"
|
||||
| "library_track_artwork" | "local_library_track_artwork" | "play_indicator_animation" | "npb_mode")
|
||||
}
|
||||
|
||||
fn classify(doc: &Map<String, Value>) -> &'static str {
|
||||
let defaults = values(false);
|
||||
let matches = |low| values(low).iter().all(|(key, expected)| {
|
||||
doc.get(key).unwrap_or(&defaults[key]) == expected
|
||||
});
|
||||
match doc.get("appearance_profile").and_then(Value::as_str) {
|
||||
Some("custom") => "custom",
|
||||
Some("low") if matches(true) => "low",
|
||||
None | Some("default") if matches(false) => "default",
|
||||
_ => "custom",
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn selected() -> String {
|
||||
super::prefs_path().and_then(|path| super::read_json_object(&path))
|
||||
.map(|doc| classify(&doc).to_string()).unwrap_or_else(|| "default".into())
|
||||
}
|
||||
|
||||
pub(super) fn apply(profile: &str) {
|
||||
if !matches!(profile, "low" | "default" | "custom") { return; }
|
||||
super::update_prefs(|doc| {
|
||||
if profile != "custom" { doc.extend(values(profile == "low")); }
|
||||
doc.insert("appearance_profile".into(), json!(profile));
|
||||
true
|
||||
});
|
||||
// Read back persisted values so a failed preferences write cannot leave
|
||||
// the live shell using a preset that was never saved.
|
||||
let mode = super::npb_mode_index();
|
||||
let visualizer = super::large_visualizer_on();
|
||||
let height = crate::shell_bridge::large_dock_height(visualizer);
|
||||
let collage = super::sidebar_playlist_collage();
|
||||
let background = super::app_background_mode();
|
||||
crate::shell_bridge::ui(move |mut shell| {
|
||||
shell.as_mut().set_npb_mode(mode);
|
||||
shell.as_mut().set_large_visualizer_on(visualizer);
|
||||
shell.as_mut().set_large_dock_height(height);
|
||||
shell.as_mut().set_sidebar_playlist_collage(collage);
|
||||
shell.as_mut().set_ambient_mode(background);
|
||||
});
|
||||
if !visualizer { crate::viz_qt::set_enabled(false); }
|
||||
crate::local_album_actions::publish_track_artwork();
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn migration_preserves_custom_values_and_ignores_unrelated_preferences() {
|
||||
let mut doc = Map::new();
|
||||
assert_eq!(classify(&doc), "default");
|
||||
doc.insert("language".into(), json!("es"));
|
||||
doc.insert("seekbar_waveform".into(), json!(true));
|
||||
assert_eq!(classify(&doc), "default");
|
||||
doc.insert("queue_track_artwork".into(), json!(true));
|
||||
assert_eq!(classify(&doc), "custom");
|
||||
doc.extend(values(true));
|
||||
doc.insert("appearance_profile".into(), json!("low"));
|
||||
assert_eq!(classify(&doc), "low");
|
||||
assert_eq!(doc["seekbar_waveform"], true);
|
||||
doc.insert("npb_mode".into(), json!("large"));
|
||||
assert_eq!(classify(&doc), "custom");
|
||||
}
|
||||
}
|
||||
@@ -42,8 +42,9 @@ pub struct FolderRow {
|
||||
pub enabled: bool,
|
||||
#[serde(rename = "isNetwork")]
|
||||
pub is_network: bool,
|
||||
/// Network folders only: whether the mount answers right now.
|
||||
pub accessible: bool,
|
||||
/// None until probed; local and network folders share the same status.
|
||||
pub accessible: Option<bool>,
|
||||
pub status: &'static str,
|
||||
}
|
||||
|
||||
#[derive(Clone, Default, Serialize)]
|
||||
@@ -116,6 +117,7 @@ pub struct MediaServerFields {
|
||||
#[derive(Clone, Default, Serialize)]
|
||||
pub struct Snapshot {
|
||||
pub folders: Vec<FolderRow>,
|
||||
pub picked_path: String,
|
||||
pub scanning: bool,
|
||||
pub processed: i32,
|
||||
pub total: i32,
|
||||
@@ -217,7 +219,7 @@ static CLEANUP_STATUS: LazyLock<Mutex<String>> = LazyLock::new(|| Mutex::new(Str
|
||||
static CLEARING: AtomicBool = AtomicBool::new(false);
|
||||
static STATUS: LazyLock<Mutex<String>> = LazyLock::new(|| Mutex::new(String::new()));
|
||||
|
||||
/// Last known accessibility per NETWORK folder id, filled by
|
||||
/// Last known accessibility per folder id and path, filled by
|
||||
/// [`spawn_accessibility_probes`].
|
||||
///
|
||||
/// It exists because the probe cannot run where it used to. `snapshot()` is
|
||||
@@ -228,9 +230,35 @@ static STATUS: LazyLock<Mutex<String>> = LazyLock::new(|| Mutex::new(String::new
|
||||
/// reference never had that: it publishes `accessible: true` optimistically
|
||||
/// and updates each row from a spawned probe with a 6 s ceiling
|
||||
/// (`crates/qbz/src/local_library_settings.rs:212-236`).
|
||||
static ACCESSIBLE: LazyLock<Mutex<std::collections::HashMap<i64, bool>>> =
|
||||
static ACCESSIBLE: LazyLock<Mutex<std::collections::HashMap<(i64, String), FolderAccess>>> =
|
||||
LazyLock::new(|| Mutex::new(std::collections::HashMap::new()));
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
enum FolderAccess { Available, Missing, Disconnected, Denied, Unavailable }
|
||||
impl FolderAccess {
|
||||
fn from_error(error: std::io::ErrorKind) -> Self {
|
||||
use std::io::ErrorKind::*;
|
||||
match error {
|
||||
NotFound | NotADirectory => Self::Missing,
|
||||
PermissionDenied => Self::Denied,
|
||||
NotConnected | ConnectionAborted | ConnectionReset | ConnectionRefused
|
||||
| NetworkUnreachable | HostUnreachable => Self::Disconnected,
|
||||
_ => Self::Unavailable,
|
||||
}
|
||||
}
|
||||
fn label(self, enabled: bool) -> &'static str {
|
||||
match self {
|
||||
Self::Available if enabled => "active",
|
||||
Self::Available => "hidden",
|
||||
Self::Missing => "missing",
|
||||
Self::Disconnected => "disconnected",
|
||||
Self::Denied => "denied",
|
||||
Self::Unavailable => "unavailable",
|
||||
}
|
||||
}
|
||||
}
|
||||
static PICKED_PATH: LazyLock<Mutex<String>> = LazyLock::new(|| Mutex::new(String::new()));
|
||||
|
||||
fn set_status(text: String) {
|
||||
*STATUS.lock().unwrap_or_else(|e| e.into_inner()) = text;
|
||||
}
|
||||
@@ -268,20 +296,10 @@ pub fn snapshot() -> Snapshot {
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.map(|f| {
|
||||
// Cache lookup only — NEVER touch the filesystem here. Unknown
|
||||
// (not probed yet) reads as accessible, so a fresh publish shows
|
||||
// the folder as fine and the probe demotes it a moment later
|
||||
// rather than flashing a false "unavailable" on every open.
|
||||
let accessible = if f.is_network {
|
||||
ACCESSIBLE
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner())
|
||||
.get(&f.id)
|
||||
.copied()
|
||||
.unwrap_or(true)
|
||||
} else {
|
||||
true
|
||||
};
|
||||
// Do not block settings publication on filesystem I/O. Unknown
|
||||
// paths remain visibly unchecked until the background probe finishes.
|
||||
let accessible = ACCESSIBLE.lock().unwrap_or_else(|e| e.into_inner())
|
||||
.get(&(f.id, f.path.clone())).copied();
|
||||
FolderRow {
|
||||
display_name: match f.alias.as_deref() {
|
||||
Some(a) if !a.is_empty() => a.to_string(),
|
||||
@@ -292,7 +310,8 @@ pub fn snapshot() -> Snapshot {
|
||||
last_scan: f.last_scan.unwrap_or(0),
|
||||
enabled: f.enabled,
|
||||
is_network: f.is_network,
|
||||
accessible,
|
||||
accessible: accessible.map(|a| a == FolderAccess::Available),
|
||||
status: accessible.map(|a| a.label(f.enabled)).unwrap_or("checking"),
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
@@ -303,6 +322,7 @@ pub fn snapshot() -> Snapshot {
|
||||
let plex_cfg = crate::local_plex::settings();
|
||||
Snapshot {
|
||||
folders,
|
||||
picked_path: PICKED_PATH.lock().unwrap_or_else(|e| e.into_inner()).clone(),
|
||||
scanning: progress.running,
|
||||
processed: progress.processed as i32,
|
||||
total: progress.total as i32,
|
||||
@@ -391,30 +411,25 @@ pub async fn open_folder_edit(id: i64) {
|
||||
if !st.open || st.folder_id != id {
|
||||
return;
|
||||
}
|
||||
st.accessible = accessible;
|
||||
st.accessible = accessible == FolderAccess::Available;
|
||||
st.checking_accessible = false;
|
||||
}
|
||||
record_accessible(id, accessible);
|
||||
record_accessible(id, &path, accessible);
|
||||
super::publish_snapshot().await;
|
||||
}
|
||||
|
||||
/// The shared probe: `exists()` first, then a `read_dir` under a 6 s ceiling,
|
||||
/// falling back to `exists()` on timeout (a slow mount that IS there must not
|
||||
/// be reported dead).
|
||||
async fn probe_path(path: &str) -> bool {
|
||||
if !std::path::Path::new(path).exists() {
|
||||
return false;
|
||||
}
|
||||
let p = path.to_string();
|
||||
let res = tokio::time::timeout(
|
||||
/// All filesystem access stays on the blocking pool, including local paths
|
||||
/// that may actually sit on a stale mount. Failure/timeout means unavailable
|
||||
/// now, never proof that a folder should be removed from the library.
|
||||
async fn probe_path(path: &str) -> FolderAccess {
|
||||
let path = path.to_owned();
|
||||
match tokio::time::timeout(
|
||||
std::time::Duration::from_secs(6),
|
||||
tokio::task::spawn_blocking(move || std::fs::read_dir(&p).is_ok()),
|
||||
)
|
||||
.await;
|
||||
match res {
|
||||
Ok(Ok(ok)) => ok,
|
||||
Ok(Err(_)) => false,
|
||||
Err(_) => std::path::Path::new(path).exists(),
|
||||
tokio::task::spawn_blocking(move || std::fs::read_dir(path).map(|_| ())),
|
||||
).await {
|
||||
Ok(Ok(Ok(()))) => FolderAccess::Available,
|
||||
Ok(Ok(Err(error))) => FolderAccess::from_error(error.kind()),
|
||||
_ => FolderAccess::Unavailable,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -516,10 +531,11 @@ pub async fn change_folder_path(id: i64) {
|
||||
|
||||
let path = EDIT.lock().unwrap_or_else(|e| e.into_inner()).path.clone();
|
||||
let accessible = probe_path(&path).await;
|
||||
record_accessible(id, &path, accessible);
|
||||
{
|
||||
let mut st = EDIT.lock().unwrap_or_else(|e| e.into_inner());
|
||||
if st.open && st.folder_id == id {
|
||||
st.accessible = accessible;
|
||||
st.accessible = accessible == FolderAccess::Available;
|
||||
st.checking_accessible = false;
|
||||
}
|
||||
}
|
||||
@@ -531,17 +547,8 @@ pub async fn change_folder_path(id: i64) {
|
||||
// Folder CRUD
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Probe every NETWORK folder's mount off the publish path and republish if
|
||||
/// anything changed.
|
||||
///
|
||||
/// Shape taken from the reference's `check_accessible`: an `exists()`
|
||||
/// shortcut first (cheap, and a missing mount point answers immediately),
|
||||
/// then a `read_dir` on the blocking pool under a 6 s timeout. A timeout is
|
||||
/// NOT automatically "inaccessible" — it falls back to `exists()`, because a
|
||||
/// slow mount that is genuinely there should not be marked dead.
|
||||
///
|
||||
/// Only republishes when a value actually moved, so the common case (nothing
|
||||
/// changed) costs one publish less than an unconditional refresh.
|
||||
/// Probe every configured folder off the publish path. Keep missing rows
|
||||
/// so the user can reconnect storage, change the path or explicitly remove it.
|
||||
pub async fn spawn_accessibility_probes() {
|
||||
let folders = tokio::task::spawn_blocking(|| {
|
||||
crate::local_state::with_db(|db| db.get_folders_with_metadata()).unwrap_or_default()
|
||||
@@ -549,40 +556,22 @@ pub async fn spawn_accessibility_probes() {
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
|
||||
let mut changed = false;
|
||||
for f in folders.into_iter().filter(|f| f.is_network) {
|
||||
let path = f.path.clone();
|
||||
if !std::path::Path::new(&path).exists() {
|
||||
changed |= record_accessible(f.id, false);
|
||||
continue;
|
||||
for f in folders {
|
||||
let accessible = probe_path(&f.path).await;
|
||||
if record_accessible(f.id, &f.path, accessible) {
|
||||
super::publish_snapshot().await;
|
||||
}
|
||||
let p = path.clone();
|
||||
let res = tokio::time::timeout(
|
||||
std::time::Duration::from_secs(6),
|
||||
tokio::task::spawn_blocking(move || std::fs::read_dir(&p).is_ok()),
|
||||
)
|
||||
.await;
|
||||
let accessible = match res {
|
||||
Ok(Ok(ok)) => ok,
|
||||
Ok(Err(_)) => false,
|
||||
Err(_) => std::path::Path::new(&path).exists(),
|
||||
};
|
||||
changed |= record_accessible(f.id, accessible);
|
||||
}
|
||||
if changed {
|
||||
super::publish_snapshot().await;
|
||||
}
|
||||
}
|
||||
|
||||
/// Store one probe result; `true` when it differs from what was cached.
|
||||
fn record_accessible(id: i64, accessible: bool) -> bool {
|
||||
fn record_accessible(id: i64, path: &str, accessible: FolderAccess) -> bool {
|
||||
let mut map = ACCESSIBLE.lock().unwrap_or_else(|e| e.into_inner());
|
||||
map.insert(id, accessible) != Some(accessible)
|
||||
map.insert((id, path.to_owned()), accessible) != Some(accessible)
|
||||
}
|
||||
|
||||
/// The folder BUTTON next to the path field: raise the native chooser and
|
||||
/// feed whatever comes back into [`add_folder`], so both routes share one
|
||||
/// validation, one network probe and one insert.
|
||||
/// The folder button fills the draft path. Both typed and browsed paths are
|
||||
/// registered only when Add is pressed.
|
||||
///
|
||||
/// The reference does the same thing with the same crate
|
||||
/// (`crates/qbz/src/local_library_settings.rs` -> `rfd::AsyncFileDialog`).
|
||||
@@ -597,12 +586,14 @@ pub async fn pick_and_add_folder() {
|
||||
else {
|
||||
return;
|
||||
};
|
||||
add_folder(dir.path().to_string_lossy().to_string()).await;
|
||||
*PICKED_PATH.lock().unwrap_or_else(|e| e.into_inner()) = dir.path().to_string_lossy().to_string();
|
||||
super::publish_snapshot().await;
|
||||
}
|
||||
|
||||
/// "Add folder": canonicalize once and let Local Library decide whether to
|
||||
/// add, refresh, reuse an ancestor, or reject an overlap.
|
||||
pub async fn add_folder(path: String) {
|
||||
PICKED_PATH.lock().unwrap_or_else(|e| e.into_inner()).clear();
|
||||
let path = path.trim().to_string();
|
||||
if path.is_empty() {
|
||||
return;
|
||||
@@ -631,6 +622,7 @@ pub async fn add_folder(path: String) {
|
||||
})
|
||||
.await;
|
||||
refresh_browse();
|
||||
spawn_accessibility_probes().await;
|
||||
}
|
||||
|
||||
/// Remove folders (their indexed tracks go with them — `remove_folder_with_tracks`).
|
||||
@@ -708,7 +700,7 @@ pub fn scan(folder_id: Option<i64>) -> bool {
|
||||
let Some(host) = crate::local_service_qt::current() else {
|
||||
return false;
|
||||
};
|
||||
scan_for(host, folder_id)
|
||||
scan_for(host, folder_id, false)
|
||||
}
|
||||
|
||||
/// Background observers carry the same captured binding as their root IDs.
|
||||
@@ -716,17 +708,25 @@ pub fn scan(folder_id: Option<i64>) -> bool {
|
||||
pub(crate) fn scan_for(
|
||||
host: std::sync::Arc<crate::local_service_qt::DesktopLibrary>,
|
||||
folder_id: Option<i64>,
|
||||
background: bool,
|
||||
) -> bool {
|
||||
if !crate::local_service_qt::is_current(&host) {
|
||||
return false;
|
||||
}
|
||||
let before = host.service.snapshot();
|
||||
match host.service.scan(folder_id) {
|
||||
let requested = if background {
|
||||
host.service.scan_background(folder_id)
|
||||
} else {
|
||||
host.service.scan(folder_id)
|
||||
};
|
||||
match requested {
|
||||
Ok(true) => {}
|
||||
Ok(false) => return false,
|
||||
Err(error) => {
|
||||
log::error!("[qbz-qt] library scan start failed: {error}");
|
||||
set_status(qbz_i18n::t("Scan failed."));
|
||||
if !background {
|
||||
set_status(qbz_i18n::t("Scan failed."));
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -734,19 +734,23 @@ pub(crate) fn scan_for(
|
||||
return true;
|
||||
}
|
||||
crate::spawn(async move {
|
||||
let mut revision = before.revision;
|
||||
let mut completed = before.last_scan.map(|s| s.job).unwrap_or(0);
|
||||
let mut revision = before.content_revision;
|
||||
let mut completed = before.last_requested_scan.map(|s| s.job).unwrap_or(0);
|
||||
loop {
|
||||
if !crate::local_service_qt::is_current(&host) {
|
||||
break;
|
||||
}
|
||||
let snapshot = host.service.snapshot();
|
||||
if let Some(result) = snapshot.last_scan.as_ref().filter(|s| s.job > completed) {
|
||||
if let Some(result) = snapshot
|
||||
.last_requested_scan
|
||||
.as_ref()
|
||||
.filter(|s| s.job > completed)
|
||||
{
|
||||
completed = result.job;
|
||||
scan_finished(result);
|
||||
}
|
||||
if snapshot.revision != revision {
|
||||
revision = snapshot.revision;
|
||||
if snapshot.content_revision != revision {
|
||||
revision = snapshot.content_revision;
|
||||
crate::local_catalog_qt::request_catch_up();
|
||||
refresh_browse();
|
||||
}
|
||||
@@ -771,6 +775,15 @@ pub(crate) fn scan_for(
|
||||
|
||||
fn scan_finished(result: &qbz_library::service::ScanCompletion) {
|
||||
use qbz_library::service::ScanOutcome;
|
||||
if result.background {
|
||||
log::debug!(
|
||||
"[local-scan] background job {} settled: {:?}, {} skipped",
|
||||
result.job,
|
||||
result.outcome,
|
||||
result.skipped
|
||||
);
|
||||
return;
|
||||
}
|
||||
if CLEANING.load(Ordering::SeqCst) {
|
||||
*CLEANUP_STATUS.lock().unwrap_or_else(|e| e.into_inner()) = match result.outcome {
|
||||
ScanOutcome::Complete => qbz_i18n::t("Scan complete"),
|
||||
@@ -809,6 +822,7 @@ pub fn reset_profile_state() {
|
||||
.clear();
|
||||
STATUS.lock().unwrap_or_else(|e| e.into_inner()).clear();
|
||||
ACCESSIBLE.lock().unwrap_or_else(|e| e.into_inner()).clear();
|
||||
PICKED_PATH.lock().unwrap_or_else(|e| e.into_inner()).clear();
|
||||
*EDIT.lock().unwrap_or_else(|e| e.into_inner()) = FolderEdit::default();
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
//! Playback L2/spool location. Selection is validated now and used next launch.
|
||||
use std::{path::{Path, PathBuf}, sync::{LazyLock, Mutex}};
|
||||
use serde::Serialize;
|
||||
|
||||
#[derive(Default)]
|
||||
struct Draft { candidate: Option<String>, error: String, generation: u64 }
|
||||
static DRAFT: LazyLock<Mutex<Draft>> = LazyLock::new(|| Mutex::new(Draft::default()));
|
||||
|
||||
#[derive(Default, Serialize)]
|
||||
pub struct Snapshot {
|
||||
pub candidate: String,
|
||||
pub configured: String,
|
||||
pub active: String,
|
||||
pub error: String,
|
||||
pub sandbox: String,
|
||||
pub command: String,
|
||||
}
|
||||
|
||||
fn quote(value: &str) -> String { format!("'{}'", value.replace('\'', "'\\''")) }
|
||||
|
||||
fn access_command(path: &str, flatpak: Option<&str>, snap: Option<&str>) -> (String, String) {
|
||||
if let Some(app) = flatpak {
|
||||
let command = if Path::new(path).is_absolute() {
|
||||
format!("flatpak override --user --filesystem={} {}", quote(&format!("{path}:rw")), quote(app))
|
||||
} else { String::new() };
|
||||
return ("Flatpak".into(), command);
|
||||
}
|
||||
if let Some(app) = snap {
|
||||
let external = ["/mnt", "/media", "/run/media"].iter().any(|root| Path::new(path).starts_with(root));
|
||||
return ("Snap".into(), if external {
|
||||
format!("sudo snap connect {}", quote(&format!("{app}:removable-media")))
|
||||
} else { String::new() });
|
||||
}
|
||||
(String::new(), String::new())
|
||||
}
|
||||
|
||||
pub(super) fn snapshot() -> Snapshot {
|
||||
let configured = super::audio_settings().playback_cache.disk_directory.unwrap_or_default();
|
||||
let draft = DRAFT.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let candidate = draft.candidate.clone().unwrap_or_else(|| configured.clone());
|
||||
let flatpak = std::env::var("FLATPAK_ID").ok().or_else(|| {
|
||||
Path::new("/.flatpak-info").exists().then(|| "com.blitzfc.qbz".into())
|
||||
});
|
||||
let snap = std::env::var("SNAP_NAME").ok();
|
||||
let (sandbox, command) = access_command(&candidate, flatpak.as_deref(), snap.as_deref());
|
||||
Snapshot {
|
||||
candidate, configured, sandbox, command, error: draft.error.clone(),
|
||||
active: crate::APP.get().and_then(|r| r.core().player().playback_storage_directory())
|
||||
.map(|path| path.to_string_lossy().into_owned()).unwrap_or_default(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) async fn browse() {
|
||||
if let Some(folder) = rfd::AsyncFileDialog::new().pick_folder().await {
|
||||
set(folder.path().to_string_lossy().into_owned()).await;
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_directory(parent: &str) -> Result<(), String> {
|
||||
if parent.is_empty() { return Ok(()); }
|
||||
let path = PathBuf::from(parent);
|
||||
if !path.is_absolute() || parent.contains('\0') {
|
||||
return Err(qbz_i18n::t("Choose an absolute folder path for playback storage."));
|
||||
}
|
||||
// The chosen parent must already exist: an absent external mount must not
|
||||
// cause QBZ to create a replacement tree on the system disk.
|
||||
std::fs::read_dir(&path).map_err(|e| e.to_string())?;
|
||||
let owned = path.join("qbz-playback");
|
||||
std::fs::create_dir_all(&owned).map_err(|e| e.to_string())?;
|
||||
let mut probe = tempfile::tempfile_in(&owned).map_err(|e| e.to_string())?;
|
||||
use std::io::{Read, Seek, SeekFrom, Write};
|
||||
probe.write_all(b"qbz").map_err(|e| e.to_string())?;
|
||||
probe.seek(SeekFrom::Start(0)).map_err(|e| e.to_string())?;
|
||||
let mut bytes = [0; 3];
|
||||
probe.read_exact(&mut bytes).map_err(|e| e.to_string())?;
|
||||
if &bytes != b"qbz" { return Err("Playback storage read-back failed".into()); }
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) async fn set(candidate: String) {
|
||||
let candidate = candidate.trim().to_string();
|
||||
let generation = {
|
||||
let mut draft = DRAFT.lock().unwrap_or_else(|e| e.into_inner());
|
||||
draft.generation += 1;
|
||||
draft.candidate = Some(candidate.clone());
|
||||
draft.error.clear();
|
||||
draft.generation
|
||||
};
|
||||
let checked = candidate.clone();
|
||||
let result = match tokio::time::timeout(std::time::Duration::from_secs(6),
|
||||
tokio::task::spawn_blocking(move || validate_directory(&checked))).await {
|
||||
Ok(result) => result.map_err(|e| e.to_string()).and_then(|r| r),
|
||||
Err(_) => Err(std::io::Error::from(std::io::ErrorKind::TimedOut).to_string()),
|
||||
};
|
||||
let mut draft = DRAFT.lock().unwrap_or_else(|e| e.into_inner());
|
||||
if draft.generation != generation { return; }
|
||||
let result = result.and_then(|_| super::with_audio(|store| {
|
||||
let mut policy = store.get_settings()?.playback_cache;
|
||||
policy.disk_directory = (!candidate.is_empty()).then(|| candidate.clone());
|
||||
store.set_playback_cache(&policy)
|
||||
}));
|
||||
draft.error = result.err().unwrap_or_default();
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn sandbox_commands_scope_access_and_quote_shell_metacharacters() {
|
||||
let (_, command) = access_command("/mnt/a'$(touch bad)", Some("com.blitzfc.qbz"), None);
|
||||
assert_eq!(command, "flatpak override --user --filesystem='/mnt/a'\\''$(touch bad):rw' 'com.blitzfc.qbz'");
|
||||
assert!(access_command("/mnt/music", None, Some("qbz-player")).1.contains("removable-media"));
|
||||
assert!(access_command("/srv/music", None, Some("qbz-player")).1.is_empty());
|
||||
assert!(access_command("/mntfake/music", None, Some("qbz-player")).1.is_empty());
|
||||
}
|
||||
#[test]
|
||||
fn storage_probe_requires_existing_parent_and_uses_only_owned_subdirectory() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
assert!(validate_directory(dir.path().join("missing").to_str().unwrap()).is_err());
|
||||
validate_directory(dir.path().to_str().unwrap()).unwrap();
|
||||
assert!(dir.path().join("qbz-playback").is_dir());
|
||||
assert_eq!(std::fs::read_dir(dir.path().join("qbz-playback")).unwrap().count(), 0);
|
||||
}
|
||||
}
|
||||
@@ -167,9 +167,39 @@ struct SidebarData {
|
||||
hidden_playlists: HashSet<u64>,
|
||||
/// First-class local playlists, listed alongside the Qobuz set.
|
||||
locals: Vec<SidebarLocal>,
|
||||
// Read-after-write protection while the remote list catches up. Kept in
|
||||
// the same mutex as the rows so an already running load cannot drop them.
|
||||
recent_qobuz: HashMap<u64, std::time::Instant>,
|
||||
}
|
||||
|
||||
static CACHE: Mutex<Option<SidebarData>> = Mutex::new(None);
|
||||
static INSERT_REVISION: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
|
||||
|
||||
pub(crate) fn insertion_revision() -> u64 {
|
||||
INSERT_REVISION.load(std::sync::atomic::Ordering::SeqCst)
|
||||
}
|
||||
|
||||
fn preserve_recent_entries(
|
||||
previous: &SidebarData,
|
||||
next: &mut SidebarData,
|
||||
now: std::time::Instant,
|
||||
online: bool,
|
||||
) {
|
||||
let recent =
|
||||
|at: &std::time::Instant| now.duration_since(*at) < std::time::Duration::from_secs(120);
|
||||
for (&id, &at) in previous
|
||||
.recent_qobuz
|
||||
.iter()
|
||||
.filter(|(_, at)| online && recent(at))
|
||||
{
|
||||
if !next.playlists.iter().any(|p| p.id == id) {
|
||||
if let Some(row) = previous.playlists.iter().find(|p| p.id == id) {
|
||||
next.playlists.push(row.clone());
|
||||
}
|
||||
}
|
||||
next.recent_qobuz.insert(id, at);
|
||||
}
|
||||
}
|
||||
/// Session-only folder expand state (matches Tauri — not persisted).
|
||||
static EXPANDED: std::sync::LazyLock<Mutex<HashSet<String>>> =
|
||||
std::sync::LazyLock::new(|| Mutex::new(HashSet::new()));
|
||||
@@ -354,12 +384,23 @@ pub async fn load(runtime: &Arc<AppRuntime<LoggingAdapter>>) {
|
||||
log::debug!("[qbz-qt] sidebar load: {} local playlist(s)", locals.len());
|
||||
|
||||
let (n_playlists, n_folders) = (playlists.len(), folders.len());
|
||||
*CACHE.lock().unwrap() = Some(SidebarData {
|
||||
playlists,
|
||||
folders,
|
||||
folder_map,
|
||||
hidden_playlists,
|
||||
locals,
|
||||
crate::library_qt::with_deleted_playlists(|deleted| {
|
||||
playlists.retain(|p| !deleted.contains(&p.id.to_string()));
|
||||
let mut next = SidebarData {
|
||||
playlists,
|
||||
folders,
|
||||
folder_map,
|
||||
hidden_playlists,
|
||||
locals,
|
||||
..Default::default()
|
||||
};
|
||||
let mut cache = CACHE.lock().unwrap();
|
||||
if let Some(previous) = cache.as_ref() {
|
||||
preserve_recent_entries(previous, &mut next, std::time::Instant::now(), !offline);
|
||||
}
|
||||
next.playlists
|
||||
.retain(|p| !deleted.contains(&p.id.to_string()));
|
||||
*cache = Some(next);
|
||||
});
|
||||
log::info!("[qbz-qt] sidebar loaded: {n_playlists} playlists, {n_folders} folders");
|
||||
}
|
||||
@@ -738,6 +779,23 @@ pub fn insert_qobuz_entry(id: u64, name: &str, tracks_count: u32, covers: &[Stri
|
||||
position: 0,
|
||||
},
|
||||
);
|
||||
data.recent_qobuz.insert(id, std::time::Instant::now());
|
||||
INSERT_REVISION.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
|
||||
}
|
||||
|
||||
pub(crate) fn insert_local_entry(id: &str, name: &str) {
|
||||
let mut cache = CACHE.lock().unwrap();
|
||||
let data = cache.get_or_insert_with(SidebarData::default);
|
||||
if data.locals.iter().any(|p| p.id == id) {
|
||||
return;
|
||||
}
|
||||
data.locals.push(SidebarLocal {
|
||||
id: id.to_string(),
|
||||
name: name.to_string(),
|
||||
folder_id: None,
|
||||
covers: Vec::new(),
|
||||
});
|
||||
INSERT_REVISION.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
|
||||
}
|
||||
|
||||
/// Drop a Qobuz playlist row from the sidebar cache. Returns whether anything
|
||||
@@ -761,6 +819,7 @@ pub fn remove_qobuz_entry(id: u64) -> bool {
|
||||
return false;
|
||||
};
|
||||
let before = data.playlists.len();
|
||||
data.recent_qobuz.remove(&id);
|
||||
data.playlists.retain(|p| p.id != id);
|
||||
before != data.playlists.len()
|
||||
}
|
||||
@@ -882,3 +941,50 @@ mod tests {
|
||||
let _ = std::fs::remove_dir_all(&account);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod creation_regressions {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn stale_list_preserves_new_playlist_without_duplicates_or_permanent_ghosts() {
|
||||
let now = std::time::Instant::now();
|
||||
let mut previous = SidebarData::default();
|
||||
previous.playlists.push(SidebarPlaylist {
|
||||
id: 42,
|
||||
name: "Created".into(),
|
||||
tracks_count: 0,
|
||||
cover_urls: vec![],
|
||||
position: 0,
|
||||
});
|
||||
previous.recent_qobuz.insert(42, now);
|
||||
let mut stale = SidebarData::default();
|
||||
preserve_recent_entries(&previous, &mut stale, now, true);
|
||||
assert_eq!(stale.playlists.len(), 1);
|
||||
preserve_recent_entries(&previous, &mut stale, now, true);
|
||||
assert_eq!(stale.playlists.len(), 1);
|
||||
let mut confirmed = stale.clone();
|
||||
confirmed.playlists[0].name = "Renamed remotely".into();
|
||||
preserve_recent_entries(&previous, &mut confirmed, now, true);
|
||||
assert_eq!(confirmed.playlists[0].name, "Renamed remotely");
|
||||
let mut offline = SidebarData::default();
|
||||
preserve_recent_entries(&previous, &mut offline, now, false);
|
||||
assert!(
|
||||
offline.playlists.is_empty(),
|
||||
"uncached cloud rows stay hidden offline"
|
||||
);
|
||||
let mut expired = SidebarData::default();
|
||||
preserve_recent_entries(
|
||||
&previous,
|
||||
&mut expired,
|
||||
now + std::time::Duration::from_secs(120),
|
||||
true,
|
||||
);
|
||||
assert!(expired.playlists.is_empty());
|
||||
previous.playlists.clear();
|
||||
previous.recent_qobuz.remove(&42);
|
||||
let mut deleted = SidebarData::default();
|
||||
preserve_recent_entries(&previous, &mut deleted, now, true);
|
||||
assert!(deleted.playlists.is_empty());
|
||||
}
|
||||
}
|
||||
@@ -24,6 +24,7 @@ pub struct StatusDoc {
|
||||
pub qconnect: QconnectStatus,
|
||||
pub network: NetworkStatus,
|
||||
pub last_errors: LatchedErrors,
|
||||
pub playback_cache: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
@@ -185,6 +186,7 @@ fn assemble_live(state: &super::ApiState) -> StatusDoc {
|
||||
};
|
||||
|
||||
StatusDoc {
|
||||
playback_cache: serde_json::to_value(player.playback_cache_stats()).ok(),
|
||||
version: env!("CARGO_PKG_VERSION").to_string(),
|
||||
api_version: crate::API_VERSION,
|
||||
uptime_secs: uptime,
|
||||
@@ -302,7 +304,7 @@ mod tests {
|
||||
/// 02 §3.3.3 NeedsAuth fragment — the serde-shape contract these tests pin.
|
||||
fn needs_auth_doc() -> StatusDoc {
|
||||
StatusDoc {
|
||||
version: "2.1.0".into(),
|
||||
playback_cache: None, version: "2.1.0".into(),
|
||||
api_version: crate::API_VERSION,
|
||||
uptime_secs: 261_360,
|
||||
data_root: "/home/pi/.local/share/qbzd".into(),
|
||||
|
||||
@@ -53,6 +53,7 @@ use crate::qconnect::transport as qconnect_kv;
|
||||
pub enum ApplyClass {
|
||||
Reinit,
|
||||
Reload,
|
||||
Restart,
|
||||
None,
|
||||
}
|
||||
|
||||
@@ -78,6 +79,11 @@ const KEY_TABLE: &[(&str, ApplyClass)] = &[
|
||||
("audio.stream_first_track", ApplyClass::Reload),
|
||||
("audio.stream_buffer_seconds", ApplyClass::Reload),
|
||||
("audio.streaming_only", ApplyClass::Reload),
|
||||
("audio.playback_memory_profile", ApplyClass::Reload),
|
||||
("audio.playback_cache_dynamic", ApplyClass::Reload),
|
||||
("audio.playback_cache_min_mib", ApplyClass::Reload),
|
||||
("audio.playback_cache_max_mib", ApplyClass::Reload),
|
||||
("audio.playback_storage_folder", ApplyClass::Restart),
|
||||
("audio.limit_quality_to_device", ApplyClass::Reload),
|
||||
("audio.allow_quality_fallback", ApplyClass::Reload),
|
||||
("audio.quality_fallback_behavior", ApplyClass::Reload),
|
||||
@@ -271,6 +277,10 @@ fn parse_opt_u32(v: &str) -> Result<Option<u32>, String> {
|
||||
format!("invalid sample rate '{trimmed}' — expected a Hz integer (e.g. 192000) or none")
|
||||
})
|
||||
}
|
||||
fn parse_cache_mib(raw: &str) -> Result<Option<u32>, String> {
|
||||
if raw.trim().is_empty() || raw.trim().eq_ignore_ascii_case("none") { return Ok(None); }
|
||||
raw.trim().parse::<u32>().map(Some).map_err(|_| "Expected an integer budget in MiB or none".into())
|
||||
}
|
||||
fn render_opt_u32(v: Option<u32>) -> String {
|
||||
v.map(|r| r.to_string())
|
||||
.unwrap_or_else(|| "none".to_string())
|
||||
@@ -381,6 +391,11 @@ fn read_all(roots: &ProfileRoots) -> Result<Vec<(&'static str, String)>, String>
|
||||
"audio.stream_first_track" => render_bool(audio.stream_first_track),
|
||||
"audio.stream_buffer_seconds" => audio.stream_buffer_seconds.to_string(),
|
||||
"audio.streaming_only" => render_bool(audio.streaming_only),
|
||||
"audio.playback_memory_profile" => audio.playback_cache.selected_profile().as_str().into(),
|
||||
"audio.playback_cache_dynamic" => render_bool(audio.playback_cache.dynamic),
|
||||
"audio.playback_cache_min_mib" => render_opt_u32(audio.playback_cache.min_mib),
|
||||
"audio.playback_cache_max_mib" => render_opt_u32(audio.playback_cache.max_mib),
|
||||
"audio.playback_storage_folder" => audio.playback_cache.disk_directory.clone().unwrap_or_default(),
|
||||
"audio.limit_quality_to_device" => render_bool(audio.limit_quality_to_device),
|
||||
"audio.allow_quality_fallback" => render_bool(audio.allow_quality_fallback),
|
||||
"audio.quality_fallback_behavior" => audio.quality_fallback_behavior.clone(),
|
||||
@@ -682,6 +697,20 @@ pub(crate) fn write_one(
|
||||
.set_stream_buffer_seconds(v)
|
||||
.map_err(SetError::Io)?
|
||||
}
|
||||
"audio.playback_memory_profile" | "audio.playback_cache_dynamic" | "audio.playback_cache_min_mib" | "audio.playback_cache_max_mib" | "audio.playback_storage_folder" => {
|
||||
let store = open_audio(roots).map_err(SetError::Io)?;
|
||||
let mut policy = store.get_settings().map_err(SetError::Io)?.playback_cache;
|
||||
if key != "audio.playback_memory_profile" && key != "audio.playback_storage_folder" { policy.make_custom(); }
|
||||
match key {
|
||||
"audio.playback_memory_profile" => policy.select_profile(qbz_models::playback_cache::PlaybackMemoryProfile::parse(raw).map_err(SetError::Usage)?),
|
||||
"audio.playback_cache_dynamic" => policy.dynamic = parse_bool(raw).map_err(SetError::Usage)?,
|
||||
"audio.playback_cache_min_mib" => policy.min_mib = parse_cache_mib(raw).map_err(SetError::Usage)?,
|
||||
"audio.playback_storage_folder" => policy.disk_directory = (!raw.is_empty() && raw != "none").then(|| raw.to_string()),
|
||||
_ => policy.max_mib = parse_cache_mib(raw).map_err(SetError::Usage)?,
|
||||
}
|
||||
policy.validate().map_err(SetError::Usage)?;
|
||||
store.set_playback_cache(&policy).map_err(SetError::Io)?;
|
||||
}
|
||||
"audio.streaming_only" => {
|
||||
let v = parse_bool(raw).map_err(SetError::Usage)?;
|
||||
open_audio(roots)
|
||||
@@ -998,10 +1027,13 @@ pub fn set(roots: &ProfileRoots, key: &str, value: &str) -> i32 {
|
||||
return 1;
|
||||
}
|
||||
};
|
||||
if nudge(roots) {
|
||||
if matches!(class, ApplyClass::Restart) {
|
||||
println!("{key} = {value}");
|
||||
println!("changes apply when the daemon starts");
|
||||
} else if nudge(roots) {
|
||||
let hint = match class {
|
||||
ApplyClass::Reinit => " (daemon reinitialized the output device)",
|
||||
ApplyClass::Reload | ApplyClass::None => "",
|
||||
ApplyClass::Reload | ApplyClass::None | ApplyClass::Restart => "",
|
||||
};
|
||||
println!("{key} = {value}{hint}");
|
||||
} else {
|
||||
@@ -1637,6 +1669,47 @@ mod tests {
|
||||
cleanup(&roots);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn playback_cache_cli_persists_reloads_and_rejects_inverted_bounds() {
|
||||
let roots = scratch_roots("playback-cache");
|
||||
for (key, value) in [("audio.playback_cache_max_mib", "1600"), ("audio.playback_cache_min_mib", "400"), ("audio.playback_cache_dynamic", "true")] {
|
||||
write_one(&roots, key, value).unwrap();
|
||||
assert_eq!(classify(key), Some(ApplyClass::Reload));
|
||||
}
|
||||
let values: std::collections::HashMap<_, _> = read_all(&roots).unwrap().into_iter().collect();
|
||||
assert_eq!(values["audio.playback_cache_dynamic"], "true");
|
||||
assert_eq!(values["audio.playback_cache_min_mib"], "400");
|
||||
assert_eq!(values["audio.playback_cache_max_mib"], "1600");
|
||||
assert!(matches!(write_one(&roots, "audio.playback_cache_max_mib", "399"), Err(SetError::Usage(_))));
|
||||
assert!(matches!(write_one(&roots, "audio.playback_cache_min_mib", "-1"), Err(SetError::Usage(_))));
|
||||
write_one(&roots, "audio.playback_cache_dynamic", "false").unwrap();
|
||||
write_one(&roots, "audio.playback_cache_min_mib", "none").unwrap();
|
||||
write_one(&roots, "audio.playback_cache_max_mib", "none").unwrap();
|
||||
write_one(&roots, "audio.playback_memory_profile", "auto").unwrap();
|
||||
assert_eq!(open_audio(&roots).unwrap().get_settings().unwrap().playback_cache, Default::default());
|
||||
cleanup(&roots);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn memory_profile_cli_presets_custom_reset_and_invalid_selection() {
|
||||
let roots=scratch_roots("memory-profiles");
|
||||
for (name,base,dynamic) in [("high",400,true),("desktop",400,false),("low",50,false)] {
|
||||
write_one(&roots,"audio.playback_memory_profile",name).unwrap();
|
||||
let p=open_audio(&roots).unwrap().get_settings().unwrap().playback_cache;
|
||||
assert_eq!(p.selected_profile().as_str(),name);
|
||||
assert_eq!(p.min_mib,Some(base));assert_eq!(p.dynamic,dynamic);
|
||||
}
|
||||
write_one(&roots,"audio.playback_cache_min_mib","64").unwrap();
|
||||
let p=open_audio(&roots).unwrap().get_settings().unwrap().playback_cache;
|
||||
assert_eq!(p.selected_profile().as_str(),"custom");
|
||||
assert!(matches!(write_one(&roots,"audio.playback_memory_profile","unlimited"),Err(SetError::Usage(_))));
|
||||
assert_eq!(open_audio(&roots).unwrap().get_settings().unwrap().playback_cache,p);
|
||||
write_one(&roots,"audio.playback_memory_profile","auto").unwrap();
|
||||
assert_eq!(open_audio(&roots).unwrap().get_settings().unwrap().playback_cache,Default::default());
|
||||
assert_eq!(classify("audio.playback_memory_profile"),Some(ApplyClass::Reload));
|
||||
cleanup(&roots);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn show_json_round_trips_into_set_for_every_canonical_key() {
|
||||
// The brief's Step 1 property: `settings show --json` includes every
|
||||
|
||||