fix(slint): network-folder visibility keyed on connectivity, playback lock on missing files
Root cause of the empty Albums tab: transposed positional booleans from
the Plex-port rewrite (a45840d1) left exclude_network_folders=true and
include_qobuz_downloads=false unconditionally on every browse query since
2026-06-07. The owner-approved model replaces it: network-folder content
hides ONLY under hard offline (link confirmed down - no link means no LAN
mounts either); a logged-out session or induced offline with the link up
shows everything (the Qobuz verdict says nothing about NFS/SMB). The
show-network-folders toggle is retired from Settings (DB column stays,
Tauri-compatible).
Playback lock instead of hiding: local tracks get an existence check at
the play seam and in the advance walk (FileMissing verdict, 'is the drive
mounted?' toasts, never on the audio thread - D-state caveat documented).
Scan-cleanup guard: tracks under a network folder whose mount is
unreachable are never treated as missing (a refresh pressed before
remounting used to hard-delete the folder's whole index). mount_info: nfs4
and smb3-style version suffixes now classify as network (its red test is
green) and the longest-mount match respects path boundaries.
cc
This commit is contained in:
8 files changed
+317
-130
No files matched your search
@@ -56,22 +56,9 @@ pub fn is_network_path(path: &Path) -> bool {
|
|||||||
let target = path
|
let target = path
|
||||||
.canonicalize()
|
.canonicalize()
|
||||||
.unwrap_or_else(|_| path.to_path_buf());
|
.unwrap_or_else(|_| path.to_path_buf());
|
||||||
let target_str = target.to_string_lossy();
|
|
||||||
|
|
||||||
// Longest-prefix match wins — `/` is always present but any
|
match best_fs_type(&mounts, &target.to_string_lossy()) {
|
||||||
// deeper mount shadows it.
|
Some(fs_type) => is_network_fs(fs_type),
|
||||||
let mut best: Option<(&str, usize)> = None;
|
|
||||||
for (mount_point, fs_type) in &mounts {
|
|
||||||
if target_str.starts_with(mount_point) {
|
|
||||||
let len = mount_point.len();
|
|
||||||
if best.map(|(_, l)| l < len).unwrap_or(true) {
|
|
||||||
best = Some((fs_type.as_str(), len));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
match best {
|
|
||||||
Some((fs_type, _)) => is_network_fs(fs_type),
|
|
||||||
None => false,
|
None => false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -96,24 +83,48 @@ pub fn network_fs_label(path: &Path) -> Option<String> {
|
|||||||
let target = path
|
let target = path
|
||||||
.canonicalize()
|
.canonicalize()
|
||||||
.unwrap_or_else(|_| path.to_path_buf());
|
.unwrap_or_else(|_| path.to_path_buf());
|
||||||
let target_str = target.to_string_lossy();
|
|
||||||
|
|
||||||
let mut best: Option<(&str, usize)> = None;
|
let fs_type = best_fs_type(&mounts, &target.to_string_lossy())?;
|
||||||
for (mount_point, fs_type) in &mounts {
|
|
||||||
if target_str.starts_with(mount_point) {
|
|
||||||
let len = mount_point.len();
|
|
||||||
if best.map(|(_, l)| l < len).unwrap_or(true) {
|
|
||||||
best = Some((fs_type.as_str(), len));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let fs_type = best.map(|(t, _)| t)?;
|
|
||||||
if !is_network_fs(fs_type) {
|
if !is_network_fs(fs_type) {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
Some(normalize_network_label(fs_type))
|
Some(normalize_network_label(fs_type))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Longest-mount-point match of `target` against the mount table, honoring
|
||||||
|
/// path-component boundaries: `/mnt/music` matches `/mnt/music` and
|
||||||
|
/// `/mnt/music/Albums/x.flac` but NOT `/mnt/music2` (the previous raw
|
||||||
|
/// `starts_with` matched the sibling too, inheriting the wrong fs type).
|
||||||
|
/// `/` is always present and any deeper mount shadows it.
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
fn best_fs_type<'a>(mounts: &'a [(String, String)], target: &str) -> Option<&'a str> {
|
||||||
|
let mut best: Option<(&'a str, usize)> = None;
|
||||||
|
for (mount_point, fs_type) in mounts {
|
||||||
|
if !path_within_mount(target, mount_point) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let len = mount_point.len();
|
||||||
|
if best.map(|(_, l)| l < len).unwrap_or(true) {
|
||||||
|
best = Some((fs_type.as_str(), len));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
best.map(|(t, _)| t)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// True when `target` IS `mount_point` or lives underneath it, on a path
|
||||||
|
/// component boundary.
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
fn path_within_mount(target: &str, mount_point: &str) -> bool {
|
||||||
|
if mount_point == "/" {
|
||||||
|
return target.starts_with('/');
|
||||||
|
}
|
||||||
|
match target.strip_prefix(mount_point.trim_end_matches('/')) {
|
||||||
|
Some("") => true,
|
||||||
|
Some(rest) => rest.starts_with('/'),
|
||||||
|
None => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(not(target_os = "linux"))]
|
#[cfg(not(target_os = "linux"))]
|
||||||
pub fn network_fs_label(_path: &Path) -> Option<String> {
|
pub fn network_fs_label(_path: &Path) -> Option<String> {
|
||||||
None
|
None
|
||||||
@@ -180,9 +191,20 @@ fn parse_mounts(contents: &str) -> Vec<(String, String)> {
|
|||||||
|
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
fn is_network_fs(fs_type: &str) -> bool {
|
fn is_network_fs(fs_type: &str) -> bool {
|
||||||
|
// A prefix hits on: the exact type ("nfs", "cifs"), a dotted scheme
|
||||||
|
// ("fuse.sshfs.x"), or a version suffix ("nfs4", "nfs3", "smb3" — pure
|
||||||
|
// digits after the prefix). The previous `== || starts_with("{prefix}.")`
|
||||||
|
// missed the version-suffixed forms, so `nfs4` — the fs type every
|
||||||
|
// modern NFS mount reports in /proc/mounts — classified as LOCAL.
|
||||||
NETWORK_FS_PREFIXES
|
NETWORK_FS_PREFIXES
|
||||||
.iter()
|
.iter()
|
||||||
.any(|prefix| fs_type == *prefix || fs_type.starts_with(&format!("{}.", prefix)))
|
.any(|prefix| match fs_type.strip_prefix(prefix) {
|
||||||
|
Some("") => true,
|
||||||
|
Some(rest) => {
|
||||||
|
rest.starts_with('.') || rest.chars().all(|c| c.is_ascii_digit())
|
||||||
|
}
|
||||||
|
None => false,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(all(test, target_os = "linux"))]
|
#[cfg(all(test, target_os = "linux"))]
|
||||||
@@ -207,6 +229,31 @@ mod tests {
|
|||||||
assert!(!is_network_fs("fuse.gocryptfs"));
|
assert!(!is_network_fs("fuse.gocryptfs"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn best_fs_type_respects_path_boundaries() {
|
||||||
|
let mounts = vec![
|
||||||
|
("/".to_string(), "ext4".to_string()),
|
||||||
|
("/mnt/music".to_string(), "nfs4".to_string()),
|
||||||
|
];
|
||||||
|
assert_eq!(best_fs_type(&mounts, "/mnt/music"), Some("nfs4"));
|
||||||
|
assert_eq!(best_fs_type(&mounts, "/mnt/music/Albums/x.flac"), Some("nfs4"));
|
||||||
|
// A sibling dir sharing the string prefix must NOT inherit the
|
||||||
|
// mount's fs type — it falls through to `/`.
|
||||||
|
assert_eq!(best_fs_type(&mounts, "/mnt/music2/x.flac"), Some("ext4"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn best_fs_type_longest_mount_wins() {
|
||||||
|
let mounts = vec![
|
||||||
|
("/".to_string(), "ext4".to_string()),
|
||||||
|
("/mnt".to_string(), "xfs".to_string()),
|
||||||
|
("/mnt/nas".to_string(), "cifs".to_string()),
|
||||||
|
];
|
||||||
|
assert_eq!(best_fs_type(&mounts, "/mnt/nas/music"), Some("cifs"));
|
||||||
|
assert_eq!(best_fs_type(&mounts, "/mnt/local"), Some("xfs"));
|
||||||
|
assert_eq!(best_fs_type(&mounts, "/home/user"), Some("ext4"));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn parse_mounts_reads_typical_entries() {
|
fn parse_mounts_reads_typical_entries() {
|
||||||
let sample = "\
|
let sample = "\
|
||||||
|
|||||||
@@ -316,30 +316,49 @@ pub fn scan_with_progress(
|
|||||||
|
|
||||||
// Cleanup: remove tracks whose files no longer exist. Full scan checks the
|
// Cleanup: remove tracks whose files no longer exist. Full scan checks the
|
||||||
// whole DB; single-folder scan only the scanned folders' subtrees.
|
// whole DB; single-folder scan only the scanned folders' subtrees.
|
||||||
|
//
|
||||||
|
// GUARD: a network folder whose mount is currently DOWN must not have its
|
||||||
|
// subtree treated as missing — while unmounted every stat fails, and
|
||||||
|
// deleting the rows would wipe a whole folder's index over a transient
|
||||||
|
// condition (e.g. a reboot where the share didn't auto-mount). Those
|
||||||
|
// subtrees are skipped; they rehabilitate on the next scan after remount.
|
||||||
on_event(ScanEvent::Cleanup);
|
on_event(ScanEvent::Cleanup);
|
||||||
|
let folder_prefix = |path: &str| {
|
||||||
|
if path.ends_with('/') {
|
||||||
|
path.to_string()
|
||||||
|
} else {
|
||||||
|
format!("{}/", path)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let unavailable_prefixes: Vec<String> = db
|
||||||
|
.get_folders_with_metadata()
|
||||||
|
.map(|folders| {
|
||||||
|
folders
|
||||||
|
.iter()
|
||||||
|
.filter(|f| f.is_network && std::fs::read_dir(&f.path).is_err())
|
||||||
|
.map(|f| folder_prefix(&f.path))
|
||||||
|
.collect()
|
||||||
|
})
|
||||||
|
.unwrap_or_default();
|
||||||
|
let under_unavailable =
|
||||||
|
|p: &str| unavailable_prefixes.iter().any(|pre| p.starts_with(pre.as_str()));
|
||||||
if let Ok(tracks) = db.get_all_track_paths() {
|
if let Ok(tracks) = db.get_all_track_paths() {
|
||||||
let missing: Vec<i64> = if single {
|
let missing: Vec<i64> = if single {
|
||||||
let prefixes: Vec<String> = targets
|
let prefixes: Vec<String> =
|
||||||
.iter()
|
targets.iter().map(|f| folder_prefix(&f.path)).collect();
|
||||||
.map(|f| {
|
|
||||||
if f.path.ends_with('/') {
|
|
||||||
f.path.clone()
|
|
||||||
} else {
|
|
||||||
format!("{}/", f.path)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
tracks
|
tracks
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|(_, p)| {
|
.filter(|(_, p)| {
|
||||||
prefixes.iter().any(|pre| p.starts_with(pre)) && !Path::new(p).exists()
|
prefixes.iter().any(|pre| p.starts_with(pre))
|
||||||
|
&& !under_unavailable(p)
|
||||||
|
&& !Path::new(p).exists()
|
||||||
})
|
})
|
||||||
.map(|(id, _)| *id)
|
.map(|(id, _)| *id)
|
||||||
.collect()
|
.collect()
|
||||||
} else {
|
} else {
|
||||||
tracks
|
tracks
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|(_, p)| !Path::new(p).exists())
|
.filter(|(_, p)| !under_unavailable(p) && !Path::new(p).exists())
|
||||||
.map(|(id, _)| *id)
|
.map(|(id, _)| *id)
|
||||||
.collect()
|
.collect()
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -459,6 +459,42 @@ fn plex_cache_db_path() -> Option<std::path::PathBuf> {
|
|||||||
dirs::data_dir().map(|d| d.join("qbz").join("plex_cache.db"))
|
dirs::data_dir().map(|d| d.join("qbz").join("plex_cache.db"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// NETWORK-FOLDER VISIBILITY (owner verdict 2026-06-10, refined same day):
|
||||||
|
// hiding network-folder content is keyed on RAW CONNECTIVITY, never on the
|
||||||
|
// offline MODE. A logged-out session or induced offline with the link up
|
||||||
|
// says nothing about LAN mounts — content stays visible there (offline mode
|
||||||
|
// exists precisely to use the local library). Only a CONFIRMED-down link
|
||||||
|
// (hard offline: no default route / probes dead) hides network folders, the
|
||||||
|
// one state where LAN mounts are gone too. An unmounted-while-online path is
|
||||||
|
// handled at PLAYBACK time instead (existence guard + friendly toast in
|
||||||
|
// playback.rs), not by hiding library content.
|
||||||
|
//
|
||||||
|
// Known approximation, accepted with the model: an ISP outage with a live
|
||||||
|
// LAN reads as hard offline and hides NAS content; per-mount accessibility
|
||||||
|
// checks in every browse query would be the exact-but-costly alternative.
|
||||||
|
|
||||||
|
/// True only under HARD offline (connectivity confirmed down). See the
|
||||||
|
/// NETWORK-FOLDER VISIBILITY note above.
|
||||||
|
pub(crate) fn exclude_network_folders_now() -> bool {
|
||||||
|
crate::offline_mode::engine().status().connectivity
|
||||||
|
== qbz_app::offline_mode::Connectivity::Down
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reset the four browse-tab models so each tab re-fetches on its next visit
|
||||||
|
/// (the `ensure_*_loaded` guards key on an empty model). Used after a scan,
|
||||||
|
/// after the danger-zone clear, and on offline-mode flips (where the
|
||||||
|
/// connectivity-keyed network-folder gate may change the browse SET — see
|
||||||
|
/// the NETWORK-FOLDER VISIBILITY note above).
|
||||||
|
pub fn reset_browse_models(window: &AppWindow) {
|
||||||
|
let s = window.global::<LocalLibraryState>();
|
||||||
|
let empty_albums = ModelRc::new(VecModel::from(Vec::<AlbumCardItem>::new()));
|
||||||
|
let empty_tracks = ModelRc::new(VecModel::from(Vec::<TrackItem>::new()));
|
||||||
|
s.set_albums(empty_albums.clone());
|
||||||
|
s.set_folders(empty_albums);
|
||||||
|
s.set_tracks(empty_tracks);
|
||||||
|
s.set_artists(ModelRc::new(VecModel::from(Vec::<LocalArtistItem>::new())));
|
||||||
|
}
|
||||||
|
|
||||||
/// Upper bound for the full-load page. The Albums tab loads the entire set in
|
/// Upper bound for the full-load page. The Albums tab loads the entire set in
|
||||||
/// one shot (search/sort/filter/group are all derived client-side over the
|
/// one shot (search/sort/filter/group are all derived client-side over the
|
||||||
/// cached set in `derive_albums`), so we request a single large page rather
|
/// cached set in `derive_albums`), so we request a single large page rather
|
||||||
@@ -485,6 +521,11 @@ fn spawn_albums_load(
|
|||||||
handle.spawn(async move {
|
handle.spawn(async move {
|
||||||
let loaded: Option<(Vec<qbz_library::LocalAlbum>, Vec<crate::album_map::AlbumCard>)> =
|
let loaded: Option<(Vec<qbz_library::LocalAlbum>, Vec<crate::album_map::AlbumCard>)> =
|
||||||
tokio::task::spawn_blocking(move || {
|
tokio::task::spawn_blocking(move || {
|
||||||
|
// include_qobuz_downloads: true (offline copies belong in the
|
||||||
|
// grid — the toolbar's "Offline" source filter selects them).
|
||||||
|
// exclude_network_folders: connectivity-keyed — see the
|
||||||
|
// NETWORK-FOLDER VISIBILITY note.
|
||||||
|
let exclude_network = exclude_network_folders_now();
|
||||||
crate::library_db::with_db(|db| {
|
crate::library_db::with_db(|db| {
|
||||||
let page = db.get_albums_metadata_page(
|
let page = db.get_albums_metadata_page(
|
||||||
0,
|
0,
|
||||||
@@ -492,8 +533,8 @@ fn spawn_albums_load(
|
|||||||
None,
|
None,
|
||||||
"artist",
|
"artist",
|
||||||
"asc",
|
"asc",
|
||||||
false,
|
|
||||||
true,
|
true,
|
||||||
|
exclude_network,
|
||||||
plex_path.as_deref(),
|
plex_path.as_deref(),
|
||||||
)?;
|
)?;
|
||||||
let albums = page.albums;
|
let albums = page.albums;
|
||||||
@@ -581,6 +622,10 @@ pub fn seed_counts(weak: slint::Weak<AppWindow>, handle: tokio::runtime::Handle)
|
|||||||
let plex_enabled = crate::plex_settings::get().enabled;
|
let plex_enabled = crate::plex_settings::get().enabled;
|
||||||
handle.spawn(async move {
|
handle.spawn(async move {
|
||||||
let counts: Option<(usize, usize, usize, usize)> = tokio::task::spawn_blocking(move || {
|
let counts: Option<(usize, usize, usize, usize)> = tokio::task::spawn_blocking(move || {
|
||||||
|
// Same include-qobuz / network flags as the Albums tab loader, so
|
||||||
|
// the badge always matches the grid (network content always in —
|
||||||
|
// see the NETWORK-FOLDER VISIBILITY note).
|
||||||
|
let exclude_network = exclude_network_folders_now();
|
||||||
crate::library_db::with_db(|db| {
|
crate::library_db::with_db(|db| {
|
||||||
// Total under the same filter the Albums tab uses, incl. the
|
// Total under the same filter the Albums tab uses, incl. the
|
||||||
// Plex union when enabled, so the badge matches the grid.
|
// Plex union when enabled, so the badge matches the grid.
|
||||||
@@ -591,8 +636,8 @@ pub fn seed_counts(weak: slint::Weak<AppWindow>, handle: tokio::runtime::Handle)
|
|||||||
None,
|
None,
|
||||||
"artist",
|
"artist",
|
||||||
"asc",
|
"asc",
|
||||||
false,
|
|
||||||
true,
|
true,
|
||||||
|
exclude_network,
|
||||||
plex_path.as_deref(),
|
plex_path.as_deref(),
|
||||||
)
|
)
|
||||||
.map(|p| p.total as usize)
|
.map(|p| p.total as usize)
|
||||||
@@ -759,8 +804,11 @@ fn fetch_tracks_page(
|
|||||||
offset: u64,
|
offset: u64,
|
||||||
plex: bool,
|
plex: bool,
|
||||||
) -> Option<(Vec<qbz_library::LocalTrack>, bool)> {
|
) -> Option<(Vec<qbz_library::LocalTrack>, bool)> {
|
||||||
|
// exclude_network_folders: connectivity-keyed — see the NETWORK-FOLDER
|
||||||
|
// VISIBILITY note.
|
||||||
|
let exclude_network = exclude_network_folders_now();
|
||||||
let mut rows = crate::library_db::with_db(|db| {
|
let mut rows = crate::library_db::with_db(|db| {
|
||||||
db.search_with_filter_page(query.trim(), offset, TRACKS_PAGE, true, false)
|
db.search_with_filter_page(query.trim(), offset, TRACKS_PAGE, true, exclude_network)
|
||||||
})?;
|
})?;
|
||||||
let has_more = rows.len() as u64 == TRACKS_PAGE;
|
let has_more = rows.len() as u64 == TRACKS_PAGE;
|
||||||
if plex && offset == 0 {
|
if plex && offset == 0 {
|
||||||
@@ -1401,6 +1449,10 @@ fn spawn_plex_quality_hydration(
|
|||||||
// The album aggregate (MAX over tracks) is a pure runtime query; the
|
// The album aggregate (MAX over tracks) is a pure runtime query; the
|
||||||
// refreshed Plex track quality flows into it automatically.
|
// refreshed Plex track quality flows into it automatically.
|
||||||
let album = plex_path.as_deref().and_then(|p| {
|
let album = plex_path.as_deref().and_then(|p| {
|
||||||
|
// Same flags as the Albums tab loader so the looked-up
|
||||||
|
// aggregate comes from the identical set (network content
|
||||||
|
// always in — see the NETWORK-FOLDER VISIBILITY note).
|
||||||
|
let exclude_network = exclude_network_folders_now();
|
||||||
crate::library_db::with_db(|db| {
|
crate::library_db::with_db(|db| {
|
||||||
let page = db.get_albums_metadata_page(
|
let page = db.get_albums_metadata_page(
|
||||||
0,
|
0,
|
||||||
@@ -1408,8 +1460,8 @@ fn spawn_plex_quality_hydration(
|
|||||||
None,
|
None,
|
||||||
"artist",
|
"artist",
|
||||||
"asc",
|
"asc",
|
||||||
false,
|
|
||||||
true,
|
true,
|
||||||
|
exclude_network,
|
||||||
Some(p),
|
Some(p),
|
||||||
)?;
|
)?;
|
||||||
Ok(page.albums.into_iter().find(|a| a.id == gk))
|
Ok(page.albums.into_iter().find(|a| a.id == gk))
|
||||||
@@ -1631,11 +1683,15 @@ pub fn current_album_version_tracks(window: &AppWindow) -> Vec<qbz_library::Loca
|
|||||||
// the Tracks table). Reuses AlbumCollectionView; covers load per card.
|
// the Tracks table). Reuses AlbumCollectionView; covers load per card.
|
||||||
|
|
||||||
fn fetch_folder_albums() -> Vec<crate::album_map::AlbumCard> {
|
fn fetch_folder_albums() -> Vec<crate::album_map::AlbumCard> {
|
||||||
crate::library_db::with_db(|db| {
|
// exclude_network_folders: connectivity-keyed — an unmounted-but-online
|
||||||
|
// folder stays visible (the index is the source); only hard offline
|
||||||
|
// hides it (see the NETWORK-FOLDER VISIBILITY note).
|
||||||
|
let exclude_network = exclude_network_folders_now();
|
||||||
|
crate::library_db::with_db(move |db| {
|
||||||
db.get_albums_with_full_filter(
|
db.get_albums_with_full_filter(
|
||||||
/* include_hidden */ false,
|
/* include_hidden */ false,
|
||||||
/* include_qobuz_downloads */ true,
|
/* include_qobuz_downloads */ true,
|
||||||
/* exclude_network_folders */ false,
|
exclude_network,
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
.unwrap_or_default()
|
.unwrap_or_default()
|
||||||
@@ -2792,8 +2848,13 @@ pub fn ensure_artists_loaded(
|
|||||||
let plex_path = plex_cache_db_path();
|
let plex_path = plex_cache_db_path();
|
||||||
handle.spawn(async move {
|
handle.spawn(async move {
|
||||||
let items = tokio::task::spawn_blocking(move || {
|
let items = tokio::task::spawn_blocking(move || {
|
||||||
let artists = crate::library_db::with_db(|db| db.get_artists_with_filter(true, false))
|
// Same network flag as every browse tab: connectivity-keyed
|
||||||
.unwrap_or_default();
|
// — see the NETWORK-FOLDER VISIBILITY note.
|
||||||
|
let exclude_network = exclude_network_folders_now();
|
||||||
|
let artists = crate::library_db::with_db(|db| {
|
||||||
|
db.get_artists_with_filter(true, exclude_network)
|
||||||
|
})
|
||||||
|
.unwrap_or_default();
|
||||||
// Album cache for the right pane + album_count. With Plex ON,
|
// Album cache for the right pane + album_count. With Plex ON,
|
||||||
// use the Plex-aware ATTACH/union query so the artist-detail grid
|
// use the Plex-aware ATTACH/union query so the artist-detail grid
|
||||||
// and counts include Plex albums (1:1 with the Albums tab set);
|
// and counts include Plex albums (1:1 with the Albums tab set);
|
||||||
@@ -2806,8 +2867,8 @@ pub fn ensure_artists_loaded(
|
|||||||
None,
|
None,
|
||||||
"artist",
|
"artist",
|
||||||
"asc",
|
"asc",
|
||||||
false,
|
|
||||||
true,
|
true,
|
||||||
|
exclude_network,
|
||||||
plex_path.as_deref(),
|
plex_path.as_deref(),
|
||||||
)
|
)
|
||||||
.map(|p| p.albums)
|
.map(|p| p.albums)
|
||||||
@@ -2815,7 +2876,7 @@ pub fn ensure_artists_loaded(
|
|||||||
.unwrap_or_default()
|
.unwrap_or_default()
|
||||||
} else {
|
} else {
|
||||||
crate::library_db::with_db(|db| {
|
crate::library_db::with_db(|db| {
|
||||||
db.get_albums_with_full_filter(false, true, false)
|
db.get_albums_with_full_filter(false, true, exclude_network)
|
||||||
})
|
})
|
||||||
.unwrap_or_default()
|
.unwrap_or_default()
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -515,10 +515,31 @@ pub fn cleanup_missing(weak: Weak<AppWindow>, handle: tokio::runtime::Handle) {
|
|||||||
handle.spawn(async move {
|
handle.spawn(async move {
|
||||||
let result = tokio::task::spawn_blocking(|| {
|
let result = tokio::task::spawn_blocking(|| {
|
||||||
let paths = crate::library_db::with_db(|db| db.get_all_track_paths())?;
|
let paths = crate::library_db::with_db(|db| db.get_all_track_paths())?;
|
||||||
|
// Same guard as the scan's cleanup phase: a network folder whose
|
||||||
|
// mount is DOWN right now stats as missing for every file — that
|
||||||
|
// is "unreachable", not "deleted". Skip those subtrees so a
|
||||||
|
// maintenance click while a share is unmounted can't wipe its
|
||||||
|
// index.
|
||||||
|
let skip: Vec<String> =
|
||||||
|
crate::library_db::with_db(|db| db.get_folders_with_metadata())
|
||||||
|
.unwrap_or_default()
|
||||||
|
.into_iter()
|
||||||
|
.filter(|f| f.is_network && std::fs::read_dir(&f.path).is_err())
|
||||||
|
.map(|f| {
|
||||||
|
if f.path.ends_with('/') {
|
||||||
|
f.path
|
||||||
|
} else {
|
||||||
|
format!("{}/", f.path)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
let checked = paths.len();
|
let checked = paths.len();
|
||||||
let missing: Vec<i64> = paths
|
let missing: Vec<i64> = paths
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.filter(|(_, p)| !std::path::Path::new(p).exists())
|
.filter(|(_, p)| {
|
||||||
|
!skip.iter().any(|pre| p.starts_with(pre.as_str()))
|
||||||
|
&& !std::path::Path::new(p).exists()
|
||||||
|
})
|
||||||
.map(|(id, _)| id)
|
.map(|(id, _)| id)
|
||||||
.collect();
|
.collect();
|
||||||
let mut removed = 0usize;
|
let mut removed = 0usize;
|
||||||
@@ -602,13 +623,7 @@ pub fn clear_library(weak: Weak<AppWindow>, handle: tokio::runtime::Handle) {
|
|||||||
let _ = weak.upgrade_in_event_loop(|w| {
|
let _ = weak.upgrade_in_event_loop(|w| {
|
||||||
w.global::<LibraryFoldersState>().set_clearing_library(false);
|
w.global::<LibraryFoldersState>().set_clearing_library(false);
|
||||||
// Reset the browse models so the tabs re-fetch on next visit.
|
// Reset the browse models so the tabs re-fetch on next visit.
|
||||||
let s = w.global::<crate::LocalLibraryState>();
|
crate::local_library::reset_browse_models(&w);
|
||||||
let empty_albums = ModelRc::new(VecModel::from(Vec::<crate::AlbumCardItem>::new()));
|
|
||||||
let empty_tracks = ModelRc::new(VecModel::from(Vec::<crate::TrackItem>::new()));
|
|
||||||
s.set_albums(empty_albums.clone());
|
|
||||||
s.set_folders(empty_albums);
|
|
||||||
s.set_tracks(empty_tracks);
|
|
||||||
s.set_artists(ModelRc::new(VecModel::from(Vec::<crate::LocalArtistItem>::new())));
|
|
||||||
});
|
});
|
||||||
if ok {
|
if ok {
|
||||||
crate::toast::success_weak(&weak, "Library database cleared");
|
crate::toast::success_weak(&weak, "Library database cleared");
|
||||||
@@ -752,13 +767,7 @@ fn run_scan(weak: Weak<AppWindow>, handle: tokio::runtime::Handle, ids: Option<V
|
|||||||
// Post-scan: refresh the folder list (last_scan labels) + reset the
|
// Post-scan: refresh the folder list (last_scan labels) + reset the
|
||||||
// browse models so the tabs re-fetch the new index on next visit.
|
// browse models so the tabs re-fetch the new index on next visit.
|
||||||
let _ = weak.upgrade_in_event_loop(|w| {
|
let _ = weak.upgrade_in_event_loop(|w| {
|
||||||
let s = w.global::<crate::LocalLibraryState>();
|
crate::local_library::reset_browse_models(&w);
|
||||||
let empty_albums = ModelRc::new(VecModel::from(Vec::<crate::AlbumCardItem>::new()));
|
|
||||||
let empty_tracks = ModelRc::new(VecModel::from(Vec::<crate::TrackItem>::new()));
|
|
||||||
s.set_albums(empty_albums.clone());
|
|
||||||
s.set_folders(empty_albums);
|
|
||||||
s.set_tracks(empty_tracks);
|
|
||||||
s.set_artists(ModelRc::new(VecModel::from(Vec::<crate::LocalArtistItem>::new())));
|
|
||||||
});
|
});
|
||||||
load_folders(weak, h);
|
load_folders(weak, h);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -415,15 +415,20 @@ pub fn fetch_local_and_plex(artist_name: &str) -> Vec<Candidate> {
|
|||||||
// local rows carry `a.artwork_path`, with the same cover.jpg/folder.jpg
|
// local rows carry `a.artwork_path`, with the same cover.jpg/folder.jpg
|
||||||
// on-disk fallback the grid uses so a DB row missing artwork_path still
|
// on-disk fallback the grid uses so a DB row missing artwork_path still
|
||||||
// resolves a cover.
|
// resolves a cover.
|
||||||
crate::library_db::with_db(|db| {
|
// Same flags as the LocalLibrary Albums tab (include offline copies;
|
||||||
|
// network content connectivity-keyed — see local_library.rs's
|
||||||
|
// NETWORK-FOLDER VISIBILITY note) so the builder sees the identical
|
||||||
|
// candidate set.
|
||||||
|
let exclude_network = crate::local_library::exclude_network_folders_now();
|
||||||
|
crate::library_db::with_db(move |db| {
|
||||||
let page = db.get_albums_metadata_page(
|
let page = db.get_albums_metadata_page(
|
||||||
0,
|
0,
|
||||||
1_000_000,
|
1_000_000,
|
||||||
None,
|
None,
|
||||||
"artist",
|
"artist",
|
||||||
"asc",
|
"asc",
|
||||||
false,
|
|
||||||
true,
|
true,
|
||||||
|
exclude_network,
|
||||||
plex_path.as_deref(),
|
plex_path.as_deref(),
|
||||||
)?;
|
)?;
|
||||||
let out: Vec<Candidate> = page
|
let out: Vec<Candidate> = page
|
||||||
|
|||||||
@@ -221,18 +221,60 @@ enum OfflinePlayability {
|
|||||||
/// The track IS offline-cached but the D4 subscription grace window has
|
/// The track IS offline-cached but the D4 subscription grace window has
|
||||||
/// elapsed — gets its own honest message.
|
/// elapsed — gets its own honest message.
|
||||||
GraceExpired,
|
GraceExpired,
|
||||||
|
/// A LOCAL user file whose indexed path is not on disk right now —
|
||||||
|
/// typically an unmounted network drive. Checked online AND offline
|
||||||
|
/// (library content is never hidden, so playback is where this must
|
||||||
|
/// surface) — gets the "is the drive mounted?" message.
|
||||||
|
FileMissing,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Cheap existence guard for a LOCAL queue track's underlying file: resolve
|
||||||
|
/// the indexed path (ephemeral store, or one indexed library-DB read) and
|
||||||
|
/// stat it with `Path::exists()`. Unresolvable id/path → `true` (don't
|
||||||
|
/// invent a skip; the play path has its own not-found handling).
|
||||||
|
///
|
||||||
|
/// D-STATE CAVEAT: `exists()` on an UNMOUNTED path returns false instantly
|
||||||
|
/// (the path simply isn't there) — that is the case this guards. But a stat
|
||||||
|
/// on a DEAD-yet-still-MOUNTED NFS/CIFS share can block in uninterruptible
|
||||||
|
/// sleep (D state). This is therefore only ever called from the async layer
|
||||||
|
/// (the advance walk and the play fast-fail run on the tokio runtime;
|
||||||
|
/// `play_local_file_audible` checks inside `spawn_blocking`) and NEVER from
|
||||||
|
/// the audio callback thread — a worst-case hang stalls an advance, not the
|
||||||
|
/// audio pipeline. Do NOT add mount probing here; the hot path stays one
|
||||||
|
/// stat per advance.
|
||||||
|
fn local_track_file_exists(track: &QueueTrack) -> bool {
|
||||||
|
let path = if crate::ephemeral::is_ephemeral_id(track.id as i64) {
|
||||||
|
crate::ephemeral::get_track(track.id as i64).map(|row| row.file_path)
|
||||||
|
} else {
|
||||||
|
crate::library_db::with_db(|db| db.get_track(track.id as i64))
|
||||||
|
.flatten()
|
||||||
|
.map(|row| row.file_path)
|
||||||
|
};
|
||||||
|
match path {
|
||||||
|
Some(p) => std::path::Path::new(&p).exists(),
|
||||||
|
None => true,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Decide whether `track` can play under the CURRENT offline status.
|
/// Decide whether `track` can play under the CURRENT offline status.
|
||||||
/// Online → always playable (the normal path pays one status read).
|
/// Local / ephemeral user files → existence-checked regardless of
|
||||||
|
/// online/offline (the library never hides network-folder content — see
|
||||||
|
/// local_library.rs's NETWORK-FOLDER VISIBILITY note — so an unmounted
|
||||||
|
/// drive is caught here, at playback time).
|
||||||
|
/// Online → otherwise always playable (the normal path pays one status read).
|
||||||
/// Offline:
|
/// Offline:
|
||||||
/// - local / ephemeral → playable (file presence is the player's own
|
|
||||||
/// failure path)
|
|
||||||
/// - plex → induced offline only (a LAN Plex server may be reachable;
|
/// - plex → induced offline only (a LAN Plex server may be reachable;
|
||||||
/// under real offline it is not — Tauri parity)
|
/// under real offline it is not — Tauri parity)
|
||||||
/// - qobuz (incl. "qobuz_download" copies, which keep the real Qobuz id)
|
/// - qobuz (incl. "qobuz_download" copies, which keep the real Qobuz id)
|
||||||
/// → offline-cached AND within the D4 subscription grace window
|
/// → offline-cached AND within the D4 subscription grace window
|
||||||
fn offline_playability(track: &QueueTrack) -> OfflinePlayability {
|
fn offline_playability(track: &QueueTrack) -> OfflinePlayability {
|
||||||
|
if matches!(track.source.as_deref(), Some("local") | Some("ephemeral")) {
|
||||||
|
return if local_track_file_exists(track) {
|
||||||
|
OfflinePlayability::Playable
|
||||||
|
} else {
|
||||||
|
OfflinePlayability::FileMissing
|
||||||
|
};
|
||||||
|
}
|
||||||
let status = crate::offline_mode::engine().status();
|
let status = crate::offline_mode::engine().status();
|
||||||
if !status.is_offline() {
|
if !status.is_offline() {
|
||||||
return OfflinePlayability::Playable;
|
return OfflinePlayability::Playable;
|
||||||
@@ -241,7 +283,7 @@ fn offline_playability(track: &QueueTrack) -> OfflinePlayability {
|
|||||||
return OfflinePlayability::Playable;
|
return OfflinePlayability::Playable;
|
||||||
}
|
}
|
||||||
match track.source.as_deref() {
|
match track.source.as_deref() {
|
||||||
Some("local") | Some("ephemeral") => OfflinePlayability::Playable,
|
// ("local" / "ephemeral" never reach here — handled above.)
|
||||||
Some("plex") => {
|
Some("plex") => {
|
||||||
if status.mode == qbz_app::offline_mode::OfflineMode::InducedOffline {
|
if status.mode == qbz_app::offline_mode::OfflineMode::InducedOffline {
|
||||||
OfflinePlayability::Playable
|
OfflinePlayability::Playable
|
||||||
@@ -266,12 +308,14 @@ fn offline_track_playable(track: &QueueTrack) -> bool {
|
|||||||
offline_playability(track) == OfflinePlayability::Playable
|
offline_playability(track) == OfflinePlayability::Playable
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Move the queue cursor forward/backward to the next offline-playable
|
/// Move the queue cursor forward/backward to the next playable track.
|
||||||
/// track. Online this returns the immediate neighbor on the first
|
/// Online this returns the immediate neighbor on the first iteration unless
|
||||||
/// iteration. Offline it skips unavailable tracks, bounded at
|
/// that neighbor is a LOCAL file whose path is gone (unmounted drive) — the
|
||||||
/// [`MAX_OFFLINE_SKIPS`] consecutive (Tauri #467 parity); on exhaustion
|
/// only possible online skip. Offline it also skips unavailable tracks.
|
||||||
/// (bound hit, or queue edge after at least one skip) playback stops and
|
/// Bounded at [`MAX_OFFLINE_SKIPS`] consecutive (Tauri #467 parity); on
|
||||||
/// ONE toast reports it.
|
/// exhaustion (bound hit, or queue edge after at least one skip) playback
|
||||||
|
/// stops and ONE toast reports it — worded for the drive when every skip was
|
||||||
|
/// a missing local file, for offline otherwise.
|
||||||
///
|
///
|
||||||
/// The gapless-prefetched target never passes through here: a gapless
|
/// The gapless-prefetched target never passes through here: a gapless
|
||||||
/// hand-off happens inside the audio engine and surfaces to the poll loop
|
/// hand-off happens inside the audio engine and surfaces to the poll loop
|
||||||
@@ -283,6 +327,17 @@ async fn advance_to_playable(
|
|||||||
forward: bool,
|
forward: bool,
|
||||||
) -> Option<QueueTrack> {
|
) -> Option<QueueTrack> {
|
||||||
let mut skips = 0usize;
|
let mut skips = 0usize;
|
||||||
|
let mut missing_files = 0usize;
|
||||||
|
// One message for the whole walk: when every skipped track was a local
|
||||||
|
// file that isn't on disk, point at the drive; any other mix keeps the
|
||||||
|
// offline wording (online, FileMissing is the only possible skip).
|
||||||
|
let walk_toast = |skips: usize, missing_files: usize| {
|
||||||
|
if missing_files == skips {
|
||||||
|
"Files not available — is the drive mounted?"
|
||||||
|
} else {
|
||||||
|
"No tracks available offline"
|
||||||
|
}
|
||||||
|
};
|
||||||
loop {
|
loop {
|
||||||
let step = if forward {
|
let step = if forward {
|
||||||
runtime.core().next_track().await
|
runtime.core().next_track().await
|
||||||
@@ -295,27 +350,29 @@ async fn advance_to_playable(
|
|||||||
if skips > 0 {
|
if skips > 0 {
|
||||||
crate::toast::show_weak(
|
crate::toast::show_weak(
|
||||||
weak,
|
weak,
|
||||||
"No tracks available offline",
|
walk_toast(skips, missing_files),
|
||||||
crate::ToastKind::Warning,
|
crate::ToastKind::Warning,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return None;
|
return None;
|
||||||
};
|
};
|
||||||
if offline_track_playable(&track) {
|
match offline_playability(&track) {
|
||||||
return Some(track);
|
OfflinePlayability::Playable => return Some(track),
|
||||||
|
OfflinePlayability::FileMissing => missing_files += 1,
|
||||||
|
_ => {}
|
||||||
}
|
}
|
||||||
skips += 1;
|
skips += 1;
|
||||||
log::info!(
|
log::info!(
|
||||||
"[qbz-slint] offline: skipping unavailable track {} ({skips}/{MAX_OFFLINE_SKIPS})",
|
"[qbz-slint] advance: skipping unavailable track {} ({skips}/{MAX_OFFLINE_SKIPS})",
|
||||||
track.id
|
track.id
|
||||||
);
|
);
|
||||||
if skips >= MAX_OFFLINE_SKIPS {
|
if skips >= MAX_OFFLINE_SKIPS {
|
||||||
if let Err(e) = runtime.core().stop() {
|
if let Err(e) = runtime.core().stop() {
|
||||||
log::warn!("[qbz-slint] offline: stop after skip bound failed: {e}");
|
log::warn!("[qbz-slint] advance: stop after skip bound failed: {e}");
|
||||||
}
|
}
|
||||||
crate::toast::show_weak(
|
crate::toast::show_weak(
|
||||||
weak,
|
weak,
|
||||||
"No tracks available offline",
|
walk_toast(skips, missing_files),
|
||||||
crate::ToastKind::Warning,
|
crate::ToastKind::Warning,
|
||||||
);
|
);
|
||||||
return None;
|
return None;
|
||||||
@@ -359,6 +416,17 @@ async fn play_audible(runtime: &Runtime, weak: &slint::Weak<AppWindow>, track_id
|
|||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
OfflinePlayability::FileMissing => {
|
||||||
|
log::info!(
|
||||||
|
"[qbz-slint] local play: refused track {track_id} (file missing — unmounted drive?)"
|
||||||
|
);
|
||||||
|
crate::toast::show_weak(
|
||||||
|
weak,
|
||||||
|
"File not available — is the drive mounted?",
|
||||||
|
crate::ToastKind::Warning,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -482,13 +550,30 @@ async fn play_local_file_audible(
|
|||||||
clear_loading(weak, row_id);
|
clear_loading(weak, row_id);
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
|
// PLAYBACK LOCK (owner verdict 2026-06-10): the library never hides
|
||||||
|
// network-folder content, so an unmounted drive surfaces HERE — one cheap
|
||||||
|
// `Path::exists()` stat before the read, with friendly feedback instead
|
||||||
|
// of a silent log-only failure. Runs inside spawn_blocking, never on the
|
||||||
|
// audio callback thread: an unmounted path returns false instantly; only
|
||||||
|
// a dead-but-still-mounted NFS/CIFS share could block, and then it blocks
|
||||||
|
// a pool thread, not audio (see `local_track_file_exists`).
|
||||||
let read_path = path.clone();
|
let read_path = path.clone();
|
||||||
let bytes = tokio::task::spawn_blocking(move || std::fs::read(&read_path))
|
let bytes = tokio::task::spawn_blocking(move || {
|
||||||
.await
|
if !std::path::Path::new(&read_path).exists() {
|
||||||
.ok()
|
return None;
|
||||||
.and_then(Result::ok);
|
}
|
||||||
|
std::fs::read(&read_path).ok()
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.ok()
|
||||||
|
.flatten();
|
||||||
let Some(bytes) = bytes else {
|
let Some(bytes) = bytes else {
|
||||||
log::error!("[qbz-slint] local play: failed to read {path}");
|
log::error!("[qbz-slint] local play: file not available at {path}");
|
||||||
|
crate::toast::show_weak(
|
||||||
|
weak,
|
||||||
|
"File not available — is the drive mounted?",
|
||||||
|
crate::ToastKind::Warning,
|
||||||
|
);
|
||||||
clear_loading(weak, row_id);
|
clear_loading(weak, row_id);
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -754,19 +754,12 @@ pub async fn handle_bool(
|
|||||||
value: bool,
|
value: bool,
|
||||||
) {
|
) {
|
||||||
let key = key.as_str();
|
let key = key.as_str();
|
||||||
// Offline-MODE toggles persist through the shared engine's per-user
|
// The offline-MODE toggle persists through the shared engine's per-user
|
||||||
// store, not the audio/playback stores — routed apart from the Apply
|
// store, not the audio/playback stores — routed apart from the Apply
|
||||||
// machinery below.
|
// machinery below.
|
||||||
match key {
|
if key == "offline-mode-enabled" {
|
||||||
"offline-mode-enabled" => {
|
set_offline_mode(ctx, runtime, weak, value).await;
|
||||||
set_offline_mode(ctx, runtime, weak, value).await;
|
return;
|
||||||
return;
|
|
||||||
}
|
|
||||||
"offline-show-network-folders" => {
|
|
||||||
set_offline_network_folders(weak, value);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
_ => {}
|
|
||||||
}
|
}
|
||||||
// Cross-setting cascades — force dependent settings off and persist
|
// Cross-setting cascades — force dependent settings off and persist
|
||||||
// those forced changes. `cascaded` flags whether a full snapshot
|
// those forced changes. `cascaded` flags whether a full snapshot
|
||||||
@@ -944,25 +937,6 @@ async fn set_offline_mode(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Settings > Offline — the Show Network Folder Content policy toggle
|
|
||||||
/// (visible only while induced offline is on). Pure persistence; the
|
|
||||||
/// gating slice consumes the flag.
|
|
||||||
fn set_offline_network_folders(weak: slint::Weak<AppWindow>, value: bool) {
|
|
||||||
let engine = crate::offline_mode::engine();
|
|
||||||
if let Err(e) = engine.set_show_network_folders(value) {
|
|
||||||
log::error!("[qbz-slint] show-network-folders toggle failed: {e}");
|
|
||||||
// Revert the optimistic toggle to the persisted state.
|
|
||||||
let actual = engine
|
|
||||||
.settings()
|
|
||||||
.map(|s| s.show_network_folders_in_manual_offline)
|
|
||||||
.unwrap_or(!value);
|
|
||||||
let _ = weak.upgrade_in_event_loop(move |w| {
|
|
||||||
w.global::<SettingsState>()
|
|
||||||
.set_offline_show_network_folders(actual);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Handle a slider change: persist it and reload the player settings.
|
/// Handle a slider change: persist it and reload the player settings.
|
||||||
/// Currently only the Initial Buffer Size slider exists.
|
/// Currently only the Initial Buffer Size slider exists.
|
||||||
pub fn handle_slider(
|
pub fn handle_slider(
|
||||||
|
|||||||
@@ -119,19 +119,6 @@ export component OfflineSettings inherits VerticalLayout {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// The network-folders policy only applies while induced offline is on,
|
|
||||||
// so the row only mounts then (Tauri parity; ADR-010 conditional mount).
|
|
||||||
if SettingsState.offline-mode-enabled: SettingRow {
|
|
||||||
label: @tr("Show Network Folder Content");
|
|
||||||
description: @tr("Display content from network-mounted folders (NAS, Samba, etc.) while in offline mode.");
|
|
||||||
QbzToggle {
|
|
||||||
checked: SettingsState.offline-show-network-folders;
|
|
||||||
toggled(v) => {
|
|
||||||
SettingsState.offline-show-network-folders = v;
|
|
||||||
root.settings-bool("offline-show-network-folders", v);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Rectangle { height: 12px; }
|
Rectangle { height: 12px; }
|
||||||
Divider { }
|
Divider { }
|
||||||
|
|||||||
Reference in new issue
Block a user