style(rust): normalize pending formatting
Apply rustfmt consistently to the live Qt frontend and shared audio support modules without changing behavior. cx
This commit is contained in:
132 files changed
+2418
-1301
No files matched your search
@@ -65,7 +65,6 @@ pub mod qbz_about_bridge {
|
||||
/// `hasBody`, the TOC and the flat block model. Produced by
|
||||
/// `whats_new_qt.rs`. Same `"{}"` default, same reason.
|
||||
#[qproperty(QString, whats_new_json)]
|
||||
|
||||
type QbzAbout = super::QbzAboutRust;
|
||||
|
||||
/// Registers this object's Qt-thread hop. Harmless to call twice, and
|
||||
|
||||
@@ -231,7 +231,10 @@ async fn ensure_avatar(client: &reqwest::Client, handle: &str) -> Option<String>
|
||||
let out = dir.join(format!("{handle}.png"));
|
||||
// A zero-byte file is a failed write that would otherwise count as a hit
|
||||
// forever (the artwork cache's documented trap) — treat it as a miss.
|
||||
if std::fs::metadata(&out).map(|m| m.len() > 0).unwrap_or(false) {
|
||||
if std::fs::metadata(&out)
|
||||
.map(|m| m.len() > 0)
|
||||
.unwrap_or(false)
|
||||
{
|
||||
return Some(crate::artwork_qt::file_url(&out.to_string_lossy()));
|
||||
}
|
||||
std::fs::create_dir_all(&dir).ok()?;
|
||||
@@ -318,7 +321,10 @@ mod tests {
|
||||
let doc = snapshot_doc();
|
||||
assert_eq!(
|
||||
doc.release_url,
|
||||
format!("https://github.com/vicrodh/qbz/releases/tag/v{}", app_version())
|
||||
format!(
|
||||
"https://github.com/vicrodh/qbz/releases/tag/v{}",
|
||||
app_version()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -48,7 +48,6 @@ pub mod qbz_album_bridge {
|
||||
#[qproperty(bool, album_info_loading)]
|
||||
#[qproperty(QString, album_info_json)]
|
||||
#[qproperty(QString, album_info_error)]
|
||||
|
||||
type QbzAlbum = super::QbzAlbumRust;
|
||||
|
||||
/// Registers this object's Qt-thread hop (Main.qml boots EVERY
|
||||
@@ -78,7 +77,12 @@ pub mod qbz_album_bridge {
|
||||
#[qinvokable]
|
||||
fn cover_remove_custom(self: Pin<&mut QbzAlbum>, album_id: QString);
|
||||
#[qinvokable]
|
||||
fn cover_save_as(self: Pin<&mut QbzAlbum>, album_id: QString, title: QString, artwork_url: QString);
|
||||
fn cover_save_as(
|
||||
self: Pin<&mut QbzAlbum>,
|
||||
album_id: QString,
|
||||
title: QString,
|
||||
artwork_url: QString,
|
||||
);
|
||||
/// ⋯ menu Share rows (share_qt.rs): copy the Qobuz link, or resolve
|
||||
/// UPC -> Deezer -> Album.link and copy that (async, toasts).
|
||||
#[qinvokable]
|
||||
@@ -98,7 +102,12 @@ pub mod qbz_album_bridge {
|
||||
/// Multi-select bulk bar (album_qt::bulk_action): ids in visible
|
||||
/// order, action id from the bar's vocabulary.
|
||||
#[qinvokable]
|
||||
fn album_bulk_action(self: Pin<&mut QbzAlbum>, album_id: QString, ids_json: QString, action: QString);
|
||||
fn album_bulk_action(
|
||||
self: Pin<&mut QbzAlbum>,
|
||||
album_id: QString,
|
||||
ids_json: QString,
|
||||
action: QString,
|
||||
);
|
||||
}
|
||||
|
||||
impl cxx_qt::Threading for QbzAlbum {}
|
||||
@@ -170,7 +179,12 @@ impl qbz_album_bridge::QbzAlbum {
|
||||
pub fn cover_remove_custom(self: Pin<&mut Self>, album_id: QString) {
|
||||
crate::cover_artwork_qt::remove_custom_cover(album_id.to_string());
|
||||
}
|
||||
pub fn cover_save_as(self: Pin<&mut Self>, album_id: QString, title: QString, artwork_url: QString) {
|
||||
pub fn cover_save_as(
|
||||
self: Pin<&mut Self>,
|
||||
album_id: QString,
|
||||
title: QString,
|
||||
artwork_url: QString,
|
||||
) {
|
||||
crate::cover_artwork_qt::save_cover_as(
|
||||
album_id.to_string(),
|
||||
title.to_string(),
|
||||
@@ -194,7 +208,16 @@ impl qbz_album_bridge::QbzAlbum {
|
||||
// not just the failed rows (Slint main.rs:12204).
|
||||
crate::offline_cache_qt::redownload_album(album_id.to_string(), false);
|
||||
}
|
||||
pub fn album_bulk_action(self: Pin<&mut Self>, album_id: QString, ids_json: QString, action: QString) {
|
||||
crate::album_qt::bulk_action(album_id.to_string(), ids_json.to_string(), action.to_string());
|
||||
pub fn album_bulk_action(
|
||||
self: Pin<&mut Self>,
|
||||
album_id: QString,
|
||||
ids_json: QString,
|
||||
action: QString,
|
||||
) {
|
||||
crate::album_qt::bulk_action(
|
||||
album_id.to_string(),
|
||||
ids_json.to_string(),
|
||||
action.to_string(),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -118,18 +118,19 @@ fn roles_suffix(roles: &[String]) -> String {
|
||||
}
|
||||
|
||||
fn map_track(index: usize, t: &Track, album_artist: &str) -> InfoTrack {
|
||||
let performers: Vec<PerformerRow> = parse_performers(t.performers.as_deref().unwrap_or_default())
|
||||
.into_iter()
|
||||
.map(|p| PerformerRow {
|
||||
roles: roles_suffix(&p.roles),
|
||||
primary_role: p
|
||||
.roles
|
||||
.first()
|
||||
.cloned()
|
||||
.unwrap_or_else(|| qbz_i18n::t("Performer")),
|
||||
name: p.name,
|
||||
})
|
||||
.collect();
|
||||
let performers: Vec<PerformerRow> =
|
||||
parse_performers(t.performers.as_deref().unwrap_or_default())
|
||||
.into_iter()
|
||||
.map(|p| PerformerRow {
|
||||
roles: roles_suffix(&p.roles),
|
||||
primary_role: p
|
||||
.roles
|
||||
.first()
|
||||
.cloned()
|
||||
.unwrap_or_else(|| qbz_i18n::t("Performer")),
|
||||
name: p.name,
|
||||
})
|
||||
.collect();
|
||||
let copyright = t
|
||||
.copyright
|
||||
.as_deref()
|
||||
@@ -195,7 +196,12 @@ fn map(album: Album, requested_id: &str) -> AlbumInfoDoc {
|
||||
let meta_line = match album.genre.as_ref().filter(|g| !g.name.is_empty()) {
|
||||
Some(g) => [
|
||||
g.name.clone(),
|
||||
qbz_i18n::tf("{} track", "{} tracks", track_count as i64, &[&track_count.to_string()]),
|
||||
qbz_i18n::tf(
|
||||
"{} track",
|
||||
"{} tracks",
|
||||
track_count as i64,
|
||||
&[&track_count.to_string()],
|
||||
),
|
||||
album_duration(album.duration.unwrap_or(0)),
|
||||
]
|
||||
.join(" · "),
|
||||
|
||||
@@ -356,12 +356,11 @@ fn spawn_header_color(generation: u64, artwork_url: String) {
|
||||
.await
|
||||
.ok()
|
||||
.flatten();
|
||||
let atmo = tokio::task::spawn_blocking(move || {
|
||||
crate::atmosphere_qt::for_cover_blocking(&p2)
|
||||
})
|
||||
.await
|
||||
.ok()
|
||||
.flatten();
|
||||
let atmo =
|
||||
tokio::task::spawn_blocking(move || crate::atmosphere_qt::for_cover_blocking(&p2))
|
||||
.await
|
||||
.ok()
|
||||
.flatten();
|
||||
if hex.is_some() || atmo.is_some() {
|
||||
publish_patch(generation, move |doc| {
|
||||
if let Some(hex) = hex {
|
||||
@@ -456,24 +455,24 @@ fn credit_role(roles: Option<&Vec<String>>) -> String {
|
||||
/// album.rs `build_credits` (releaseArtistsMapper parity, minus the
|
||||
/// "VARIOUS"-composer drop it also applies).
|
||||
fn build_credits(album: &Album) -> Vec<(String, String, String)> {
|
||||
let mut credits: Vec<(String, String, String)> = match album.artists.as_ref().filter(|v| !v.is_empty())
|
||||
{
|
||||
Some(list) => list
|
||||
.iter()
|
||||
.map(|a| {
|
||||
(
|
||||
a.name.clone(),
|
||||
a.id.to_string(),
|
||||
credit_role(a.roles.as_ref()),
|
||||
)
|
||||
})
|
||||
.collect(),
|
||||
None => vec![(
|
||||
album.artist.name.clone(),
|
||||
album.artist.id.to_string(),
|
||||
String::new(),
|
||||
)],
|
||||
};
|
||||
let mut credits: Vec<(String, String, String)> =
|
||||
match album.artists.as_ref().filter(|v| !v.is_empty()) {
|
||||
Some(list) => list
|
||||
.iter()
|
||||
.map(|a| {
|
||||
(
|
||||
a.name.clone(),
|
||||
a.id.to_string(),
|
||||
credit_role(a.roles.as_ref()),
|
||||
)
|
||||
})
|
||||
.collect(),
|
||||
None => vec![(
|
||||
album.artist.name.clone(),
|
||||
album.artist.id.to_string(),
|
||||
String::new(),
|
||||
)],
|
||||
};
|
||||
// Album-level composer appended last, unless it's the localized
|
||||
// "Various Composers" placeholder (releaseArtistsMapper's VARIOUS drop).
|
||||
if let Some(composer) = album.composer.as_ref().filter(|c| !c.name.is_empty()) {
|
||||
@@ -552,7 +551,10 @@ fn map_track(track: &Track) -> TrackRow {
|
||||
}
|
||||
|
||||
/// Fetch + map the album detail (album.rs `map_album` port).
|
||||
pub async fn load_album(runtime: &Arc<AppRuntime<LoggingAdapter>>, album_id: &str) -> Result<AlbumViewData, String> {
|
||||
pub async fn load_album(
|
||||
runtime: &Arc<AppRuntime<LoggingAdapter>>,
|
||||
album_id: &str,
|
||||
) -> Result<AlbumViewData, String> {
|
||||
let album = runtime
|
||||
.core()
|
||||
.get_album(album_id)
|
||||
@@ -578,9 +580,9 @@ pub async fn load_album(runtime: &Arc<AppRuntime<LoggingAdapter>>, album_id: &st
|
||||
.as_ref()
|
||||
.filter(|g| !g.name.is_empty())
|
||||
.map(|g| g.name.clone());
|
||||
let tracks_str = album.tracks_count.map(|count| {
|
||||
qbz_i18n::tf("{} track", "{} tracks", count as i64, &[&count.to_string()])
|
||||
});
|
||||
let tracks_str = album
|
||||
.tracks_count
|
||||
.map(|count| qbz_i18n::tf("{} track", "{} tracks", count as i64, &[&count.to_string()]));
|
||||
let duration_str = album.duration.map(format_duration);
|
||||
|
||||
let mut pre_parts: Vec<String> = Vec::new();
|
||||
@@ -892,10 +894,7 @@ fn pick_booklet_url(album: &Album) -> String {
|
||||
/// `booklet::download_booklet`): fetch with a 30s client, then a native
|
||||
/// save-as dialog seeded `{title}.pdf`. No-op when the open album has none.
|
||||
pub fn download_booklet() {
|
||||
let (title, url) = BOOKLET_STASH
|
||||
.lock()
|
||||
.map(|s| s.clone())
|
||||
.unwrap_or_default();
|
||||
let (title, url) = BOOKLET_STASH.lock().map(|s| s.clone()).unwrap_or_default();
|
||||
if url.is_empty() {
|
||||
return;
|
||||
}
|
||||
@@ -1038,7 +1037,10 @@ fn map_release_card(release: &qbz_models::PageArtistRelease) -> AlbumCardData {
|
||||
.and_then(|list| list.first().map(|a| a.name.clone()))
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let bit_depth = release.audio_info.as_ref().and_then(|a| a.maximum_bit_depth);
|
||||
let bit_depth = release
|
||||
.audio_info
|
||||
.as_ref()
|
||||
.and_then(|a| a.maximum_bit_depth);
|
||||
let sample_rate = release
|
||||
.audio_info
|
||||
.as_ref()
|
||||
@@ -1115,7 +1117,8 @@ pub async fn load_more_from_artist(
|
||||
/// One Last.fm recommendation as the card shape the two Qobuz rows already use,
|
||||
/// so the third row reuses their delegate instead of a fourth card variant.
|
||||
fn reco_to_card(r: qbz_external_reco::AlbumReco) -> AlbumCardData {
|
||||
let art_url = crate::cover_artwork_qt::prefer_album_cover(&r.qobuz_album_id, r.artwork_url.clone());
|
||||
let art_url =
|
||||
crate::cover_artwork_qt::prefer_album_cover(&r.qobuz_album_id, r.artwork_url.clone());
|
||||
AlbumCardData {
|
||||
is_pinned: crate::sidebar_qt::is_pinned("album", &r.qobuz_album_id),
|
||||
is_favorite: crate::fav_cache_qt::is_album_favorite(&r.qobuz_album_id),
|
||||
@@ -1161,7 +1164,12 @@ pub async fn load_suggestions(
|
||||
let date = a
|
||||
.dates
|
||||
.as_ref()
|
||||
.and_then(|d| d.original.clone().or(d.download.clone()).or(d.stream.clone()))
|
||||
.and_then(|d| {
|
||||
d.original
|
||||
.clone()
|
||||
.or(d.download.clone())
|
||||
.or(d.stream.clone())
|
||||
})
|
||||
.or(a.release_date_original.clone());
|
||||
let (artist, card_artist_id) = card_artist(a);
|
||||
AlbumCardData {
|
||||
@@ -1339,7 +1347,12 @@ fn spawn_deferred_rows(
|
||||
},
|
||||
async {
|
||||
let suggestions = load_suggestions(&runtime, &album_id).await;
|
||||
publish_row(generation, "suggestions", "suggestionsLoading", &suggestions);
|
||||
publish_row(
|
||||
generation,
|
||||
"suggestions",
|
||||
"suggestionsLoading",
|
||||
&suggestions,
|
||||
);
|
||||
suggestions
|
||||
},
|
||||
);
|
||||
|
||||
@@ -114,9 +114,7 @@ fn dominant_hue_bin(img: &image::RgbaImage) -> (usize, f32, u32) {
|
||||
hist[bin] += 1.0;
|
||||
chromatic += 1;
|
||||
}
|
||||
let score_at = |i: usize| {
|
||||
hist[i] + 0.5 * (hist[(i + BINS - 1) % BINS] + hist[(i + 1) % BINS])
|
||||
};
|
||||
let score_at = |i: usize| hist[i] + 0.5 * (hist[(i + BINS - 1) % BINS] + hist[(i + 1) % BINS]);
|
||||
let mut best_i = 0usize;
|
||||
let mut best = -1.0f32;
|
||||
for (i, _) in hist.iter().enumerate() {
|
||||
@@ -150,9 +148,7 @@ fn spectrum_colors(img: &image::RgbaImage) -> ((u8, u8, u8), (u8, u8, u8)) {
|
||||
let bin = ((h / 360.0 * BINS as f32) as usize).min(BINS - 1);
|
||||
hist[bin] += 1.0;
|
||||
}
|
||||
let score_at = |i: usize| {
|
||||
hist[i] + 0.5 * (hist[(i + BINS - 1) % BINS] + hist[(i + 1) % BINS])
|
||||
};
|
||||
let score_at = |i: usize| hist[i] + 0.5 * (hist[(i + BINS - 1) % BINS] + hist[(i + 1) % BINS]);
|
||||
let mut sec_i: Option<usize> = None;
|
||||
let mut sec_best = 0.0f32;
|
||||
for i in 0..BINS {
|
||||
@@ -253,9 +249,7 @@ mod tests {
|
||||
fn dominant_hue_wins_over_speck() {
|
||||
// 3/4 red-ish + 1/4 blue-ish: the coverage-weighted primary must be
|
||||
// the red hue (not the more saturated speck).
|
||||
let img = img_from(&[
|
||||
(200, 30, 30), (200, 30, 30), (200, 30, 30), (30, 30, 220),
|
||||
]);
|
||||
let img = img_from(&[(200, 30, 30), (200, 30, 30), (200, 30, 30), (30, 30, 220)]);
|
||||
let (primary, _secondary) = spectrum_colors(&img);
|
||||
let (h, _s, _l) = rgb_to_hsl(primary.0, primary.1, primary.2);
|
||||
assert!(h < 15.0 || h > 345.0, "primary hue {h} should be red-ish");
|
||||
@@ -275,7 +269,10 @@ mod tests {
|
||||
let img = img_from(&[(200, 30, 30), (200, 30, 30), (200, 30, 30), (210, 40, 40)]);
|
||||
let accent = lyrics_accent_color(&img);
|
||||
let (h, _s, _l) = rgb_to_hsl(accent.0, accent.1, accent.2);
|
||||
assert!((170.0..=190.0).contains(&h), "accent hue {h} should be cyan-ish");
|
||||
assert!(
|
||||
(170.0..=190.0).contains(&h),
|
||||
"accent hue {h} should be cyan-ish"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -287,7 +284,10 @@ mod tests {
|
||||
let (ph, _, _) = rgb_to_hsl(primary.0, primary.1, primary.2);
|
||||
let (sh, _, sl) = rgb_to_hsl(secondary.0, secondary.1, secondary.2);
|
||||
let dist = (ph - sh).abs().min(360.0 - (ph - sh).abs());
|
||||
assert!(dist < 30.0, "mono cover: secondary hue {sh} vs primary {ph}");
|
||||
assert!(
|
||||
dist < 30.0,
|
||||
"mono cover: secondary hue {sh} vs primary {ph}"
|
||||
);
|
||||
assert!(sl < 0.45, "mono cover: secondary should be deeper (L={sl})");
|
||||
}
|
||||
|
||||
@@ -340,7 +340,6 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// Recompute the ambient triad from the current track's artwork and publish
|
||||
/// it to the bridge (ambientPrimary/Secondary/Accent). Called on track
|
||||
/// change from playback_qt's now-playing refresh. A cover that is not on
|
||||
@@ -365,24 +364,15 @@ pub fn update_for_artwork(artwork_url: &str) {
|
||||
let bytes = std::fs::read(&path).ok()?;
|
||||
let img = image::load_from_memory(&bytes).ok()?;
|
||||
let rgba = img.to_rgba8();
|
||||
let tiny = image::imageops::resize(
|
||||
&rgba,
|
||||
16,
|
||||
16,
|
||||
image::imageops::FilterType::Triangle,
|
||||
);
|
||||
let tiny =
|
||||
image::imageops::resize(&rgba, 16, 16, image::imageops::FilterType::Triangle);
|
||||
let (primary, secondary) = spectrum_colors(&tiny);
|
||||
let accent = lyrics_accent_color(&tiny);
|
||||
// Immersive (contract §4.3): the SAME decode feeds the 8x8 glow
|
||||
// sample and the atmosphere asset — both inside this
|
||||
// spawn_blocking, never on the Qt thread
|
||||
// (atmosphere_qt.rs:126-127).
|
||||
let tiny8 = image::imageops::resize(
|
||||
&rgba,
|
||||
8,
|
||||
8,
|
||||
image::imageops::FilterType::Triangle,
|
||||
);
|
||||
let tiny8 = image::imageops::resize(&rgba, 8, 8, image::imageops::FilterType::Triangle);
|
||||
let glow = glow_hex_qt(glow_color(&tiny8));
|
||||
let atmosphere = crate::atmosphere_qt::for_cover_blocking(&path);
|
||||
// B1: the SAME decode feeds the Tunnel Flow palette — the Tauri
|
||||
@@ -397,7 +387,14 @@ pub fn update_for_artwork(artwork_url: &str) {
|
||||
let tunnel = crate::tunnelflow_qt::line_palette_json(
|
||||
&crate::tunnelflow_qt::line_palette(&tiny36),
|
||||
);
|
||||
Some((hex(primary), hex(secondary), hex(accent), glow, atmosphere, tunnel))
|
||||
Some((
|
||||
hex(primary),
|
||||
hex(secondary),
|
||||
hex(accent),
|
||||
glow,
|
||||
atmosphere,
|
||||
tunnel,
|
||||
))
|
||||
})
|
||||
.await
|
||||
.ok()
|
||||
@@ -405,10 +402,12 @@ pub fn update_for_artwork(artwork_url: &str) {
|
||||
if let Some((primary, secondary, accent, glow, atmosphere, tunnel)) = triad {
|
||||
log::info!("[qbz-qt] ambient palette: {primary} / {secondary} / {accent}");
|
||||
crate::shell_bridge::ui(move |mut b| {
|
||||
b.as_mut().set_ambient_primary(QString::from(primary.as_str()));
|
||||
b.as_mut()
|
||||
.set_ambient_primary(QString::from(primary.as_str()));
|
||||
b.as_mut()
|
||||
.set_ambient_secondary(QString::from(secondary.as_str()));
|
||||
b.as_mut().set_ambient_accent(QString::from(accent.as_str()));
|
||||
b.as_mut()
|
||||
.set_ambient_accent(QString::from(accent.as_str()));
|
||||
});
|
||||
// Publish ONLY on success; on failure (or a missing atmosphere)
|
||||
// the previous values stay — never cleared on track change (the
|
||||
|
||||
@@ -336,8 +336,14 @@ mod tests {
|
||||
add(42, "X", None).expect("add succeeds with a bound store");
|
||||
assert!(is_blacklisted(42), "added id is blacklisted");
|
||||
assert!(is_blacklisted_id_str("42"), "string-id check matches");
|
||||
assert!(!is_blacklisted_id_str("not-a-number"), "local ids never match");
|
||||
assert!(ids_snapshot().contains(&42), "snapshot contains the added id");
|
||||
assert!(
|
||||
!is_blacklisted_id_str("not-a-number"),
|
||||
"local ids never match"
|
||||
);
|
||||
assert!(
|
||||
ids_snapshot().contains(&42),
|
||||
"snapshot contains the added id"
|
||||
);
|
||||
assert_eq!(count(), 1);
|
||||
|
||||
// Album axis: orthogonal, String-keyed, shares the enabled flag.
|
||||
@@ -351,7 +357,10 @@ mod tests {
|
||||
);
|
||||
assert_eq!(album_count(), 1);
|
||||
// A blocked album drops via the shared stamp predicate, artist-independent.
|
||||
assert!(stamp_row("qobuz", &[], Some("zzz")), "album-blocked row drops");
|
||||
assert!(
|
||||
stamp_row("qobuz", &[], Some("zzz")),
|
||||
"album-blocked row drops"
|
||||
);
|
||||
assert!(
|
||||
!stamp_row("local", &[], Some("zzz")),
|
||||
"non-qobuz row is protected even when album-blocked"
|
||||
@@ -366,13 +375,19 @@ mod tests {
|
||||
"non-qobuz track is protected"
|
||||
);
|
||||
assert!(card_blacklisted("zzz", "1"), "card drops on the album axis");
|
||||
assert!(card_blacklisted("aaa", "42"), "card drops on the artist axis");
|
||||
assert!(
|
||||
card_blacklisted("aaa", "42"),
|
||||
"card drops on the artist axis"
|
||||
);
|
||||
assert_eq!(count(), 1, "album add did not touch the artist count");
|
||||
|
||||
teardown();
|
||||
assert!(!is_blacklisted(42), "fail-open after teardown");
|
||||
assert!(ids_snapshot().is_empty(), "empty snapshot after teardown");
|
||||
assert!(!is_album_blacklisted("zzz"), "album fail-open after teardown");
|
||||
assert!(
|
||||
!is_album_blacklisted("zzz"),
|
||||
"album fail-open after teardown"
|
||||
);
|
||||
assert!(album_ids_snapshot().is_empty(), "empty album snapshot");
|
||||
assert_eq!(count(), 0);
|
||||
assert_eq!(album_count(), 0);
|
||||
|
||||
@@ -53,7 +53,6 @@ pub mod qbz_artist_bridge {
|
||||
// `loading` rides INSIDE the document rather than as a second
|
||||
// property — the reason is written down on that struct.
|
||||
#[qproperty(QString, artist_releases_json)]
|
||||
|
||||
type QbzArtist = super::QbzArtistRust;
|
||||
|
||||
/// Registers this object's Qt-thread hop (Main.qml boots EVERY
|
||||
@@ -100,7 +99,12 @@ pub mod qbz_artist_bridge {
|
||||
|
||||
/// ArtistView per-section "Load more" — the next releases page.
|
||||
#[qinvokable]
|
||||
fn load_release_section(self: Pin<&mut QbzArtist>, artist_id: QString, release_type: QString, offset: i32);
|
||||
fn load_release_section(
|
||||
self: Pin<&mut QbzArtist>,
|
||||
artist_id: QString,
|
||||
release_type: QString,
|
||||
offset: i32,
|
||||
);
|
||||
|
||||
/// ArtistView per-section sort — `ReleaseGrid.slint:26`
|
||||
/// `set-section-sort(release-type, sort)`, fired by that view's
|
||||
@@ -161,7 +165,12 @@ pub mod qbz_artist_bridge {
|
||||
|
||||
/// Emitted with the next page of a releases bucket.
|
||||
#[qsignal]
|
||||
fn release_section_ready(self: Pin<&mut QbzArtist>, release_type: QString, cards_json: QString, has_more: bool);
|
||||
fn release_section_ready(
|
||||
self: Pin<&mut QbzArtist>,
|
||||
release_type: QString,
|
||||
cards_json: QString,
|
||||
has_more: bool,
|
||||
);
|
||||
}
|
||||
|
||||
impl cxx_qt::Threading for QbzArtist {}
|
||||
@@ -213,7 +222,12 @@ impl qbz_artist_bridge::QbzArtist {
|
||||
crate::open_artist(artist_id.to_string());
|
||||
}
|
||||
|
||||
pub fn load_release_section(self: Pin<&mut Self>, artist_id: QString, release_type: QString, offset: i32) {
|
||||
pub fn load_release_section(
|
||||
self: Pin<&mut Self>,
|
||||
artist_id: QString,
|
||||
release_type: QString,
|
||||
offset: i32,
|
||||
) {
|
||||
crate::load_release_section(artist_id.to_string(), release_type.to_string(), offset);
|
||||
}
|
||||
/// Straight through to `artist_qt`, with no `crate::` forwarder in main.rs:
|
||||
|
||||
@@ -413,7 +413,10 @@ pub(crate) fn map_release(release: &PageArtistRelease) -> AlbumCardData {
|
||||
.as_ref()
|
||||
.map(|a| a.id.to_string())
|
||||
.unwrap_or_default();
|
||||
let bit_depth = release.audio_info.as_ref().and_then(|a| a.maximum_bit_depth);
|
||||
let bit_depth = release
|
||||
.audio_info
|
||||
.as_ref()
|
||||
.and_then(|a| a.maximum_bit_depth);
|
||||
let sample_rate = release
|
||||
.audio_info
|
||||
.as_ref()
|
||||
@@ -538,12 +541,18 @@ fn map_track(index: usize, track: PageArtistTrack) -> TrackRow {
|
||||
let (album_id, album, artwork_url) = track
|
||||
.album
|
||||
.map(|a| {
|
||||
let url = a.image.and_then(|img| img.smallest().cloned()).unwrap_or_default();
|
||||
let url = a
|
||||
.image
|
||||
.and_then(|img| img.smallest().cloned())
|
||||
.unwrap_or_default();
|
||||
(a.id, a.title, url)
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let bit_depth = track.audio_info.as_ref().and_then(|a| a.maximum_bit_depth);
|
||||
let sample_rate = track.audio_info.as_ref().and_then(|a| a.maximum_sampling_rate);
|
||||
let sample_rate = track
|
||||
.audio_info
|
||||
.as_ref()
|
||||
.and_then(|a| a.maximum_sampling_rate);
|
||||
// /artist/page reports availability on the nested `rights` object, not as a
|
||||
// flat key — the same read `playback_qt::artist_top_queue_tracks` (:731)
|
||||
// already does when it builds the queue row from this payload. `unwrap_or
|
||||
@@ -557,7 +566,11 @@ fn map_track(index: usize, track: PageArtistTrack) -> TrackRow {
|
||||
TrackRow {
|
||||
is_favorite: crate::fav_cache_qt::contains_track(track.id),
|
||||
id: track.id.to_string(),
|
||||
cache_status: if crate::offline_qt::is_cached(&track.id.to_string()) { 3 } else { 0 },
|
||||
cache_status: if crate::offline_qt::is_cached(&track.id.to_string()) {
|
||||
3
|
||||
} else {
|
||||
0
|
||||
},
|
||||
number: (index + 1).to_string(),
|
||||
title,
|
||||
artist,
|
||||
@@ -881,8 +894,15 @@ static TOP_QUEUE: std::sync::Mutex<Vec<QueueTrack>> = std::sync::Mutex::new(Vec:
|
||||
fn track_row_to_queue(row: &TrackRow) -> QueueTrack {
|
||||
let duration_secs = {
|
||||
let mut parts = row.duration.split(':');
|
||||
parts.next().and_then(|m| m.parse::<u64>().ok()).unwrap_or(0) * 60
|
||||
+ parts.next().and_then(|s| s.parse::<u64>().ok()).unwrap_or(0)
|
||||
parts
|
||||
.next()
|
||||
.and_then(|m| m.parse::<u64>().ok())
|
||||
.unwrap_or(0)
|
||||
* 60
|
||||
+ parts
|
||||
.next()
|
||||
.and_then(|s| s.parse::<u64>().ok())
|
||||
.unwrap_or(0)
|
||||
};
|
||||
QueueTrack {
|
||||
id: row.id.parse().unwrap_or(0),
|
||||
@@ -967,7 +987,13 @@ pub async fn load_release_page(
|
||||
.map_err(|_| format!("invalid artist id: {artist_id}"))?;
|
||||
let resp = runtime
|
||||
.core()
|
||||
.get_releases_grid(id, release_type, RELEASE_PAGE_SIZE, offset, Some("release_date"))
|
||||
.get_releases_grid(
|
||||
id,
|
||||
release_type,
|
||||
RELEASE_PAGE_SIZE,
|
||||
offset,
|
||||
Some("release_date"),
|
||||
)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
let has_more = resp.has_more;
|
||||
@@ -1006,9 +1032,10 @@ fn merge_release_page(
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let Some(section) = sections.iter_mut().find(|s| {
|
||||
s.get("releaseType").and_then(|v| v.as_str()) == Some(release_type)
|
||||
}) else {
|
||||
let Some(section) = sections
|
||||
.iter_mut()
|
||||
.find(|s| s.get("releaseType").and_then(|v| v.as_str()) == Some(release_type))
|
||||
else {
|
||||
return;
|
||||
};
|
||||
section["hasMore"] = json!(has_more);
|
||||
@@ -1110,12 +1137,16 @@ pub(crate) fn resort_section(release_type: &str, sort: &str) {
|
||||
let release_type = release_type.to_string();
|
||||
let sort = sort.to_string();
|
||||
publish_patch(generation, move |doc| {
|
||||
let Some(sections) = doc.get_mut("releaseSections").and_then(|v| v.as_array_mut()) else {
|
||||
let Some(sections) = doc
|
||||
.get_mut("releaseSections")
|
||||
.and_then(|v| v.as_array_mut())
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let Some(section) = sections.iter_mut().find(|s| {
|
||||
s.get("releaseType").and_then(|v| v.as_str()) == Some(release_type.as_str())
|
||||
}) else {
|
||||
let Some(section) = sections
|
||||
.iter_mut()
|
||||
.find(|s| s.get("releaseType").and_then(|v| v.as_str()) == Some(release_type.as_str()))
|
||||
else {
|
||||
return;
|
||||
};
|
||||
// Stamp the key BEFORE the cards: `sortBy` is what seats the picker
|
||||
@@ -1186,8 +1217,11 @@ pub async fn load_artist_view(
|
||||
|
||||
spawn_header_color(generation, header_art);
|
||||
|
||||
let similar_names: Vec<String> =
|
||||
data.similar_artists.iter().map(|s| s.name.clone()).collect();
|
||||
let similar_names: Vec<String> = data
|
||||
.similar_artists
|
||||
.iter()
|
||||
.map(|s| s.name.clone())
|
||||
.collect();
|
||||
spawn_enrichment(
|
||||
runtime.clone(),
|
||||
generation,
|
||||
@@ -1332,12 +1366,11 @@ fn spawn_header_color(generation: u64, artwork_url: String) {
|
||||
.await
|
||||
.ok()
|
||||
.flatten();
|
||||
let atmo = tokio::task::spawn_blocking(move || {
|
||||
crate::atmosphere_qt::for_cover_blocking(&p2)
|
||||
})
|
||||
.await
|
||||
.ok()
|
||||
.flatten();
|
||||
let atmo =
|
||||
tokio::task::spawn_blocking(move || crate::atmosphere_qt::for_cover_blocking(&p2))
|
||||
.await
|
||||
.ok()
|
||||
.flatten();
|
||||
if hex.is_some() || atmo.is_some() {
|
||||
publish_patch(generation, move |doc| {
|
||||
if let Some(hex) = hex {
|
||||
@@ -1390,8 +1423,7 @@ fn spawn_enrichment(
|
||||
crate::spawn(async move {
|
||||
let stories = load_stories(&runtime, &artist_id).await;
|
||||
publish_patch(generation, move |doc| {
|
||||
doc["stories"] =
|
||||
serde_json::to_value(&stories).unwrap_or_else(|_| json!([]));
|
||||
doc["stories"] = serde_json::to_value(&stories).unwrap_or_else(|_| json!([]));
|
||||
doc["storiesLoading"] = json!(false);
|
||||
});
|
||||
});
|
||||
@@ -1499,10 +1531,7 @@ fn map_story(item: ArtistStoryItem) -> StoryRow {
|
||||
|
||||
/// The artist's Magazine stories (limit 2, like the official client). Any
|
||||
/// failure yields an empty list and the tab shows "No stories".
|
||||
async fn load_stories(
|
||||
runtime: &Arc<AppRuntime<LoggingAdapter>>,
|
||||
artist_id: &str,
|
||||
) -> Vec<StoryRow> {
|
||||
async fn load_stories(runtime: &Arc<AppRuntime<LoggingAdapter>>, artist_id: &str) -> Vec<StoryRow> {
|
||||
let Ok(id) = artist_id.parse::<u64>() else {
|
||||
return Vec::new();
|
||||
};
|
||||
@@ -1573,8 +1602,7 @@ fn map_origin(meta: &qbz_integrations::musicbrainz::ArtistMetadata) -> MbOriginJ
|
||||
.as_ref()
|
||||
.map(|loc| loc.display_name.clone())
|
||||
.unwrap_or_default();
|
||||
let has_data =
|
||||
!begin_date.is_empty() || !end_date.is_empty() || !location_display.is_empty();
|
||||
let has_data = !begin_date.is_empty() || !end_date.is_empty() || !location_display.is_empty();
|
||||
|
||||
// ---- Artist Scene payload, from the SAME metadata this call already has.
|
||||
// No extra fetch: `meta` carries the whole location and the affinity
|
||||
@@ -1590,9 +1618,8 @@ fn map_origin(meta: &qbz_integrations::musicbrainz::ArtistMetadata) -> MbOriginJ
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
// The INTENDED form of Tauri's guard — see the field's doc.
|
||||
let location_clickable = loc.is_some_and(|l| {
|
||||
!matches!(l.precision, LocationPrecision::Country) || l.city.is_some()
|
||||
});
|
||||
let location_clickable =
|
||||
loc.is_some_and(|l| !matches!(l.precision, LocationPrecision::Country) || l.city.is_some());
|
||||
|
||||
MbOriginJson {
|
||||
is_person,
|
||||
|
||||
@@ -330,11 +330,7 @@ pub fn retry() {
|
||||
if s.artist_id.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some((
|
||||
s.artist_id.clone(),
|
||||
s.name.clone(),
|
||||
s.release_type.clone(),
|
||||
))
|
||||
Some((s.artist_id.clone(), s.name.clone(), s.release_type.clone()))
|
||||
}) else {
|
||||
return;
|
||||
};
|
||||
@@ -397,7 +393,9 @@ fn fetch(generation: u64, artist_id: String, release_type: String, offset: u32,
|
||||
{
|
||||
Ok(resp) => resp,
|
||||
Err(e) => {
|
||||
log::warn!("[qbz-qt] artist releases page failed ({artist_id}/{release_type}): {e}");
|
||||
log::warn!(
|
||||
"[qbz-qt] artist releases page failed ({artist_id}/{release_type}): {e}"
|
||||
);
|
||||
fail(generation, first);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -903,7 +903,11 @@ fn apply_page(
|
||||
s.error.clear();
|
||||
s.progress_phase = "done";
|
||||
s.progress_pct = 100;
|
||||
s.view_state = if s.artists.is_empty() { "empty" } else { "ready" };
|
||||
s.view_state = if s.artists.is_empty() {
|
||||
"empty"
|
||||
} else {
|
||||
"ready"
|
||||
};
|
||||
true
|
||||
});
|
||||
if changed {
|
||||
@@ -1416,10 +1420,7 @@ fn is_live_album(album: &Album) -> bool {
|
||||
return true;
|
||||
}
|
||||
|
||||
let studio_variant = has_any_word(
|
||||
&title,
|
||||
&["remaster", "deluxe", "anniversary", "expanded"],
|
||||
);
|
||||
let studio_variant = has_any_word(&title, &["remaster", "deluxe", "anniversary", "expanded"]);
|
||||
// `\b(tour|on tour)\b(?!.*\bedition\b)` — a lookahead over the REST of the
|
||||
// string, which is why "edition" is tested against the whole title here.
|
||||
if has_word(&title, "tour") && !has_word(&title, "edition") && !studio_variant {
|
||||
@@ -1434,15 +1435,72 @@ fn is_live_album(album: &Album) -> bool {
|
||||
|
||||
/// The city list from `qobuzAdapters.ts:222`, verbatim and in order.
|
||||
const LIVE_CITIES: &[&str] = &[
|
||||
"seattle", "tokyo", "london", "paris", "new york", "los angeles", "chicago", "dallas",
|
||||
"amsterdam", "berlin", "sydney", "melbourne", "montreal", "toronto", "rio", "sao paulo",
|
||||
"mexico city", "denver", "boston", "philadelphia", "san francisco", "cleveland", "detroit",
|
||||
"atlanta", "miami", "phoenix", "houston", "minneapolis", "st. louis", "st louis",
|
||||
"kansas city", "nashville", "memphis", "austin", "portland", "oakland", "stockholm", "oslo",
|
||||
"dublin", "manchester", "birmingham", "glasgow", "brussels", "madrid", "barcelona", "rome",
|
||||
"milan", "vienna", "munich", "hamburg", "zurich", "prague", "warsaw", "moscow", "seoul",
|
||||
"osaka", "singapore", "hong kong", "taipei", "manila", "jakarta", "mumbai", "delhi",
|
||||
"bangalore", "cape town", "johannesburg",
|
||||
"seattle",
|
||||
"tokyo",
|
||||
"london",
|
||||
"paris",
|
||||
"new york",
|
||||
"los angeles",
|
||||
"chicago",
|
||||
"dallas",
|
||||
"amsterdam",
|
||||
"berlin",
|
||||
"sydney",
|
||||
"melbourne",
|
||||
"montreal",
|
||||
"toronto",
|
||||
"rio",
|
||||
"sao paulo",
|
||||
"mexico city",
|
||||
"denver",
|
||||
"boston",
|
||||
"philadelphia",
|
||||
"san francisco",
|
||||
"cleveland",
|
||||
"detroit",
|
||||
"atlanta",
|
||||
"miami",
|
||||
"phoenix",
|
||||
"houston",
|
||||
"minneapolis",
|
||||
"st. louis",
|
||||
"st louis",
|
||||
"kansas city",
|
||||
"nashville",
|
||||
"memphis",
|
||||
"austin",
|
||||
"portland",
|
||||
"oakland",
|
||||
"stockholm",
|
||||
"oslo",
|
||||
"dublin",
|
||||
"manchester",
|
||||
"birmingham",
|
||||
"glasgow",
|
||||
"brussels",
|
||||
"madrid",
|
||||
"barcelona",
|
||||
"rome",
|
||||
"milan",
|
||||
"vienna",
|
||||
"munich",
|
||||
"hamburg",
|
||||
"zurich",
|
||||
"prague",
|
||||
"warsaw",
|
||||
"moscow",
|
||||
"seoul",
|
||||
"osaka",
|
||||
"singapore",
|
||||
"hong kong",
|
||||
"taipei",
|
||||
"manila",
|
||||
"jakarta",
|
||||
"mumbai",
|
||||
"delhi",
|
||||
"bangalore",
|
||||
"cape town",
|
||||
"johannesburg",
|
||||
];
|
||||
|
||||
/// `\b(19[6-9]\d|20[0-2]\d)\b` — a standalone 4-digit token in 1960..=2029.
|
||||
|
||||
@@ -172,7 +172,10 @@ mod tests {
|
||||
let centre = out.get_pixel(64, 64);
|
||||
let corner = out.get_pixel(2, 2);
|
||||
let lum = |p: &Rgba<u8>| p[0] as u32 + p[1] as u32 + p[2] as u32;
|
||||
assert!(lum(corner) < lum(centre), "corner {corner:?} vs centre {centre:?}");
|
||||
assert!(
|
||||
lum(corner) < lum(centre),
|
||||
"corner {corner:?} vs centre {centre:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -347,7 +347,7 @@ where
|
||||
// Phase 15: intelligent-search service (cortinilla
|
||||
// cache+ranking).
|
||||
crate::search_qt::init(&dir, crate::search_qt::intelligent_search_pref());
|
||||
crate::search_cache_qt::init();
|
||||
crate::search_cache_qt::init();
|
||||
// Phase 17: playlist is_owner math.
|
||||
crate::playlist_qt::set_user_id(user_id);
|
||||
bind_per_user_stores(&dir, user_id).await;
|
||||
@@ -600,7 +600,10 @@ mod tests {
|
||||
query_param("code_autorisation=abc123", "code_autorisation"),
|
||||
Some("abc123".to_string())
|
||||
);
|
||||
assert_eq!(query_param("a=1&code=x%2Fy", "code"), Some("x/y".to_string()));
|
||||
assert_eq!(
|
||||
query_param("a=1&code=x%2Fy", "code"),
|
||||
Some("x/y".to_string())
|
||||
);
|
||||
assert_eq!(query_param("other=1", "code"), None);
|
||||
}
|
||||
|
||||
|
||||
@@ -679,8 +679,7 @@ async fn favorite_award_ids() -> HashSet<String> {
|
||||
}
|
||||
|
||||
pub fn toggle_follow() {
|
||||
let (id, following, busy) =
|
||||
with_state(|s| (s.id.clone(), s.is_following, s.follow_toggling));
|
||||
let (id, following, busy) = with_state(|s| (s.id.clone(), s.is_following, s.follow_toggling));
|
||||
if id.is_empty() || busy {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -46,7 +46,6 @@ pub mod qbz_blacklist_bridge {
|
||||
// artists") before the spinner. Slint's BlacklistState.loading has the
|
||||
// same `: true` default for the same reason.
|
||||
#[qproperty(bool, blacklist_loading)]
|
||||
|
||||
type QbzBlacklist = super::QbzBlacklistRust;
|
||||
|
||||
/// Registers this object's Qt-thread hop (Main.qml boots EVERY domain
|
||||
|
||||
@@ -327,8 +327,7 @@ pub fn publish() {
|
||||
};
|
||||
let json = serde_json::to_string(&doc).unwrap_or_else(|_| "{}".to_string());
|
||||
crate::blacklist_bridge::ui(move |mut b| {
|
||||
b.as_mut()
|
||||
.set_blacklist_json(QString::from(json.as_str()));
|
||||
b.as_mut().set_blacklist_json(QString::from(json.as_str()));
|
||||
});
|
||||
}
|
||||
|
||||
@@ -687,9 +686,7 @@ pub fn dismiss_artist(artist_id: String, artist_name: String, image_url: String)
|
||||
let (name, image) = match runtime.core().get_artist(id).await {
|
||||
Ok(a) => (
|
||||
a.name,
|
||||
a.image
|
||||
.and_then(|i| i.best().cloned())
|
||||
.unwrap_or_default(),
|
||||
a.image.and_then(|i| i.best().cloned()).unwrap_or_default(),
|
||||
),
|
||||
Err(e) => {
|
||||
log::warn!("[qbz-qt] reco dismiss name resolve failed for {id}: {e}");
|
||||
|
||||
@@ -266,8 +266,11 @@ fn fetch_discover(generation: u64) {
|
||||
Ok(data) => {
|
||||
let has_more = data.has_more;
|
||||
let fetched = data.items.len() as u32;
|
||||
let mut cards: Vec<HomeCard> =
|
||||
data.items.into_iter().map(crate::home_qt::map_album).collect();
|
||||
let mut cards: Vec<HomeCard> = data
|
||||
.items
|
||||
.into_iter()
|
||||
.map(crate::home_qt::map_album)
|
||||
.collect();
|
||||
// The return is the MISSING list, and nobody wants it any more —
|
||||
// see the note at the publish below. This call is kept purely
|
||||
// for its disk-cache side effect.
|
||||
@@ -696,7 +699,8 @@ fn publish_history() {
|
||||
(doc, s.loading)
|
||||
};
|
||||
crate::home_bridge::ui(move |mut b| {
|
||||
b.as_mut().set_play_history_json(QString::from(json.as_str()));
|
||||
b.as_mut()
|
||||
.set_play_history_json(QString::from(json.as_str()));
|
||||
b.as_mut().set_play_history_loading(loading);
|
||||
});
|
||||
}
|
||||
@@ -909,7 +913,10 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn visible_json_filters() {
|
||||
let cards = vec![card("Blue Train", "Coltrane"), card("Giant Steps", "Coltrane")];
|
||||
let cards = vec![
|
||||
card("Blue Train", "Coltrane"),
|
||||
card("Giant Steps", "Coltrane"),
|
||||
];
|
||||
assert!(visible_json(&cards, "blue").contains("Blue Train"));
|
||||
assert!(!visible_json(&cards, "blue").contains("Giant Steps"));
|
||||
assert!(visible_json(&cards, "").contains("Giant Steps"));
|
||||
|
||||
@@ -104,7 +104,6 @@ pub mod qbz_cast_bridge {
|
||||
// Raw last-error for the picker's error line (empty = none), same
|
||||
// as the Slint `CastState.error`.
|
||||
#[qproperty(QString, error)]
|
||||
|
||||
type QbzCast = super::QbzCastRust;
|
||||
|
||||
/// Registers this object's Qt-thread hop (Main.qml boots EVERY
|
||||
|
||||
@@ -510,11 +510,9 @@ impl CastService {
|
||||
cast_bridge::ui(|mut b| {
|
||||
b.as_mut().set_quality_limit_cause(0);
|
||||
b.as_mut().set_quality_over_cap(false);
|
||||
b.as_mut()
|
||||
.set_quality_origin(cxx_qt_lib::QString::from(""));
|
||||
b.as_mut().set_quality_origin(cxx_qt_lib::QString::from(""));
|
||||
b.as_mut().set_device_cap_available(false);
|
||||
b.as_mut()
|
||||
.set_device_cap_key(cxx_qt_lib::QString::from(""));
|
||||
b.as_mut().set_device_cap_key(cxx_qt_lib::QString::from(""));
|
||||
b.as_mut().set_device_cap_index(0);
|
||||
});
|
||||
self.push_connection_state().await;
|
||||
@@ -668,10 +666,7 @@ impl CastService {
|
||||
"FLAC"
|
||||
}
|
||||
.to_string(),
|
||||
crate::quality_state::detail(
|
||||
Some(p.bits_per_sample),
|
||||
Some(p.sample_rate as f64),
|
||||
),
|
||||
crate::quality_state::detail(Some(p.bits_per_sample), Some(p.sample_rate as f64)),
|
||||
),
|
||||
None => quality_label_from_track(track),
|
||||
};
|
||||
@@ -1073,8 +1068,7 @@ impl CastService {
|
||||
// The guard only makes sense when the position signal is
|
||||
// usable: renderers whose RelTime never moves honor
|
||||
// STOPPED like pre-guard behavior.
|
||||
let position_reliable =
|
||||
inner.cast_max_position > CAST_POSITION_SIGNAL_MIN_SECS;
|
||||
let position_reliable = inner.cast_max_position > CAST_POSITION_SIGNAL_MIN_SECS;
|
||||
let near_end = duration <= 0.0
|
||||
|| !position_reliable
|
||||
|| max_position >= duration - CAST_END_GUARD_SECS;
|
||||
@@ -1116,13 +1110,7 @@ impl CastService {
|
||||
crate::viz_qt::set_paused(!playing);
|
||||
|
||||
// The cast poll drives the bar while connected.
|
||||
crate::now_playing::set_position(
|
||||
position as i32,
|
||||
duration as i32,
|
||||
playing,
|
||||
0.0,
|
||||
true,
|
||||
);
|
||||
crate::now_playing::set_position(position as i32, duration as i32, playing, 0.0, true);
|
||||
push_position(position as f32, duration as f32, playing);
|
||||
|
||||
if ended {
|
||||
@@ -1264,12 +1252,7 @@ impl CastService {
|
||||
// requested-mp3 shortcut is for the LOCAL engine path; here a
|
||||
// successful STREAMINFO parse proves the bytes are FLAC, never MP3.
|
||||
let effective_tier = if delivered.downgraded && info.probe.is_some() {
|
||||
if eff_bits >= 24 {
|
||||
"hires"
|
||||
} else {
|
||||
"cd"
|
||||
}
|
||||
.to_string()
|
||||
if eff_bits >= 24 { "hires" } else { "cd" }.to_string()
|
||||
} else {
|
||||
delivered.effective_tier.clone()
|
||||
};
|
||||
|
||||
@@ -125,7 +125,11 @@ async fn lookup_musicbrainz(disc_id: &str) -> Option<DiscMeta> {
|
||||
if status.as_u16() == 503 {
|
||||
log::warn!(
|
||||
"[qbz-qt] cd: MusicBrainz is rate-limiting (503){}",
|
||||
if attempt == 0 { ", retrying once" } else { ", giving up" }
|
||||
if attempt == 0 {
|
||||
", retrying once"
|
||||
} else {
|
||||
", giving up"
|
||||
}
|
||||
);
|
||||
continue;
|
||||
}
|
||||
@@ -185,7 +189,10 @@ async fn lookup_musicbrainz(disc_id: &str) -> Option<DiscMeta> {
|
||||
.and_then(|m| m.as_array())
|
||||
.and_then(|m| m.iter().find(|m| m.get("tracks").is_some()));
|
||||
let Some(media) = media else {
|
||||
log::warn!("[qbz-qt] cd: release {:?} carries no track list", release.get("title"));
|
||||
log::warn!(
|
||||
"[qbz-qt] cd: release {:?} carries no track list",
|
||||
release.get("title")
|
||||
);
|
||||
return None;
|
||||
};
|
||||
|
||||
@@ -212,10 +219,8 @@ async fn lookup_musicbrainz(disc_id: &str) -> Option<DiscMeta> {
|
||||
.iter()
|
||||
.filter_map(|c| {
|
||||
let name = c.get("name").and_then(|n| n.as_str())?;
|
||||
let join = c
|
||||
.get("joinphrase")
|
||||
.and_then(|j| j.as_str())
|
||||
.unwrap_or("");
|
||||
let join =
|
||||
c.get("joinphrase").and_then(|j| j.as_str()).unwrap_or("");
|
||||
Some(format!("{name}{join}"))
|
||||
})
|
||||
.collect::<String>()
|
||||
@@ -406,7 +411,11 @@ pub async fn open_disc() -> Result<usize, String> {
|
||||
log::info!(
|
||||
"[qbz-qt] cd: remembered as {:?} ({})",
|
||||
m.album,
|
||||
if m.edited { "corrected by hand" } else { "from a previous lookup" }
|
||||
if m.edited {
|
||||
"corrected by hand"
|
||||
} else {
|
||||
"from a previous lookup"
|
||||
}
|
||||
);
|
||||
meta_from_memory(m)
|
||||
}
|
||||
|
||||
@@ -166,14 +166,16 @@ pub fn save_cover_as(album_id: String, title: String, artwork_url: String) {
|
||||
let dest_path = dest.path().to_path_buf();
|
||||
|
||||
// Local sources first: the override file, then the artwork cache.
|
||||
let local = album_cover(&album_id).filter(|p| !p.is_empty()).or_else(|| {
|
||||
let cached = crate::artwork_qt::cached_path(&artwork_url);
|
||||
if cached.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(cached)
|
||||
}
|
||||
});
|
||||
let local = album_cover(&album_id)
|
||||
.filter(|p| !p.is_empty())
|
||||
.or_else(|| {
|
||||
let cached = crate::artwork_qt::cached_path(&artwork_url);
|
||||
if cached.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(cached)
|
||||
}
|
||||
});
|
||||
if let Some(src) = local {
|
||||
if let Err(e) = tokio::fs::copy(&src, &dest_path).await {
|
||||
log::warn!("[qbz-qt] cover save-as copy failed: {e}");
|
||||
@@ -193,7 +195,12 @@ pub fn save_cover_as(album_id: String, title: String, artwork_url: String) {
|
||||
return;
|
||||
}
|
||||
};
|
||||
match client.get(&artwork_url).send().await.and_then(|r| r.error_for_status()) {
|
||||
match client
|
||||
.get(&artwork_url)
|
||||
.send()
|
||||
.await
|
||||
.and_then(|r| r.error_for_status())
|
||||
{
|
||||
Ok(resp) => match resp.bytes().await {
|
||||
Ok(bytes) => {
|
||||
if let Err(e) = tokio::fs::write(&dest_path, &bytes).await {
|
||||
@@ -374,7 +381,12 @@ pub fn save_artist_image_as(artist_name: String, artwork_url: String) {
|
||||
return;
|
||||
}
|
||||
};
|
||||
match client.get(&artwork_url).send().await.and_then(|r| r.error_for_status()) {
|
||||
match client
|
||||
.get(&artwork_url)
|
||||
.send()
|
||||
.await
|
||||
.and_then(|r| r.error_for_status())
|
||||
{
|
||||
Ok(resp) => match resp.bytes().await {
|
||||
Ok(bytes) => {
|
||||
if let Err(e) = tokio::fs::write(&dest_path, &bytes).await {
|
||||
@@ -403,7 +415,11 @@ struct PlaylistStore {
|
||||
}
|
||||
|
||||
fn playlist_store_path() -> Option<PathBuf> {
|
||||
Some(dirs::data_dir()?.join("qbz").join("custom_playlist_covers.json"))
|
||||
Some(
|
||||
dirs::data_dir()?
|
||||
.join("qbz")
|
||||
.join("custom_playlist_covers.json"),
|
||||
)
|
||||
}
|
||||
|
||||
fn load_playlist_store() -> PlaylistStore {
|
||||
@@ -647,8 +663,14 @@ mod tests {
|
||||
// The whole point of scoping the widening: an arbitrary URL must not
|
||||
// start colliding with another one on its bare filename.
|
||||
assert_eq!(art_key("https://example.com/thing/photo.jpg"), None);
|
||||
assert_eq!(art_key("https://static.qobuz.com/images/covers/tr/9l/abc123.jpg"), None);
|
||||
assert_eq!(
|
||||
art_key("https://static.qobuz.com/images/covers/tr/9l/abc123.jpg"),
|
||||
None
|
||||
);
|
||||
assert_eq!(art_key(""), None);
|
||||
assert_eq!(art_key("https://static.qobuz.com/images/artists/covers/large/"), None);
|
||||
assert_eq!(
|
||||
art_key("https://static.qobuz.com/images/artists/covers/large/"),
|
||||
None
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -36,7 +36,6 @@ pub mod qbz_dac_wizard_bridge {
|
||||
/// The whole wizard state (see `dac_wizard_qt::WizardDoc`). Parseable
|
||||
/// default so QML's `JSON.parse` never throws on the pre-publish frame.
|
||||
#[qproperty(QString, wizard_json)]
|
||||
|
||||
type QbzDacWizard = super::QbzDacWizardRust;
|
||||
|
||||
/// Registers this object's Qt-thread hop. Without it every publish
|
||||
|
||||
@@ -281,7 +281,12 @@ pub fn set_init(index: i32) {
|
||||
fn recompute_check() {
|
||||
with_state(|s| {
|
||||
let health = s.last_health.unwrap_or_else(core::probe_health);
|
||||
let view = core::check_view(health, s.doc.distro_index, s.doc.init_index, s.doc.sandboxed);
|
||||
let view = core::check_view(
|
||||
health,
|
||||
s.doc.distro_index,
|
||||
s.doc.init_index,
|
||||
s.doc.sandboxed,
|
||||
);
|
||||
s.doc.health_ok = view.health_ok;
|
||||
s.doc.health_summary = view.summary;
|
||||
s.doc.remediations = view
|
||||
@@ -535,9 +540,8 @@ pub fn verify_own() {
|
||||
s.doc.test_playing = false;
|
||||
s.doc.test_rate_matched = false;
|
||||
s.doc.test_negotiated_label = String::new();
|
||||
s.doc.test_requested_label = qbz_i18n::t(
|
||||
"Your queue is empty — add some tracks first, or press Play test.",
|
||||
);
|
||||
s.doc.test_requested_label =
|
||||
qbz_i18n::t("Your queue is empty — add some tracks first, or press Play test.");
|
||||
});
|
||||
publish();
|
||||
return;
|
||||
|
||||
@@ -162,7 +162,10 @@ fn snapshot_doc() -> Doc {
|
||||
|
||||
fn publish_now() {
|
||||
let json = serde_json::to_string(&snapshot_doc()).unwrap_or_else(|_| "{}".into());
|
||||
ui(move |mut b| b.as_mut().set_diagnostics_json(QString::from(json.as_str())));
|
||||
ui(move |mut b| {
|
||||
b.as_mut()
|
||||
.set_diagnostics_json(QString::from(json.as_str()))
|
||||
});
|
||||
}
|
||||
|
||||
/// Publish the document — but only while the panel is expanded. A collapsed
|
||||
@@ -253,13 +256,14 @@ fn gather_blocking() -> Blocking {
|
||||
let graphics = qbz_app::settings::graphics::GraphicsSettings::default();
|
||||
let developer = qbz_app::settings::developer::DeveloperSettings::default();
|
||||
let gfx = qbz_app::diagnostics::detect_graphics_runtime(&graphics, false);
|
||||
let diag = qbz_app::diagnostics::runtime_diagnostics(&qbz_app::diagnostics::DiagnosticsInputs {
|
||||
audio: &audio,
|
||||
graphics: &graphics,
|
||||
developer: &developer,
|
||||
gfx,
|
||||
app_version: env!("CARGO_PKG_VERSION"),
|
||||
});
|
||||
let diag =
|
||||
qbz_app::diagnostics::runtime_diagnostics(&qbz_app::diagnostics::DiagnosticsInputs {
|
||||
audio: &audio,
|
||||
graphics: &graphics,
|
||||
developer: &developer,
|
||||
gfx,
|
||||
app_version: env!("CARGO_PKG_VERSION"),
|
||||
});
|
||||
let sys = qbz_app::diagnostics::system_info();
|
||||
let (active_output, available_outputs, active_fmt) = collect_output_sinks();
|
||||
|
||||
@@ -378,9 +382,10 @@ fn gather_catalog_diagnostics() -> CatalogDiagnostics {
|
||||
reason: String::new(),
|
||||
generation: Some(manifest.active_generation),
|
||||
tracks: stats.as_ref().ok().map(|value| value.track_count),
|
||||
bytes: stats.as_ref().ok().map(|value| {
|
||||
value.page_size_bytes.saturating_mul(value.page_count)
|
||||
}),
|
||||
bytes: stats
|
||||
.as_ref()
|
||||
.ok()
|
||||
.map(|value| value.page_size_bytes.saturating_mul(value.page_count)),
|
||||
runtime_check,
|
||||
runtime_ok,
|
||||
fts_activation_verified: true,
|
||||
@@ -672,7 +677,10 @@ fn build_system_rows(
|
||||
rows.push(row(
|
||||
"Local Catalog Size",
|
||||
"—",
|
||||
&catalog.bytes.map(format_bytes).unwrap_or_else(|| "—".to_string()),
|
||||
&catalog
|
||||
.bytes
|
||||
.map(format_bytes)
|
||||
.unwrap_or_else(|| "—".to_string()),
|
||||
0,
|
||||
));
|
||||
rows.push(row(
|
||||
@@ -689,7 +697,11 @@ fn build_system_rows(
|
||||
} else {
|
||||
"not active"
|
||||
},
|
||||
if catalog.fts_activation_verified { 1 } else { 0 },
|
||||
if catalog.fts_activation_verified {
|
||||
1
|
||||
} else {
|
||||
0
|
||||
},
|
||||
));
|
||||
rows.push(row("Local Library Routes", "—", &catalog.routes, 0));
|
||||
rows
|
||||
@@ -741,7 +753,12 @@ fn build_audio_rows(g: &Gathered) -> Vec<Row> {
|
||||
.unwrap_or("—");
|
||||
|
||||
vec![
|
||||
row("Output Device", &saved_output, output_runtime, output_status),
|
||||
row(
|
||||
"Output Device",
|
||||
&saved_output,
|
||||
output_runtime,
|
||||
output_status,
|
||||
),
|
||||
row("Backend", &opt(&d.audio_backend_type), "—", 0),
|
||||
row("Exclusive Mode", yn(d.audio_exclusive_mode), "—", 0),
|
||||
row("DAC Passthrough", yn(d.audio_dac_passthrough), "—", 0),
|
||||
@@ -757,7 +774,12 @@ fn build_audio_rows(g: &Gathered) -> Vec<Row> {
|
||||
0,
|
||||
),
|
||||
row("Gapless", yn(d.audio_gapless_enabled), "—", 0),
|
||||
row("PW Force Bitperfect", yn(d.audio_pw_force_bitperfect), "—", 0),
|
||||
row(
|
||||
"PW Force Bitperfect",
|
||||
yn(d.audio_pw_force_bitperfect),
|
||||
"—",
|
||||
0,
|
||||
),
|
||||
row(
|
||||
"Stream Buffer",
|
||||
&format!("{}s", d.audio_stream_buffer_seconds),
|
||||
@@ -775,12 +797,7 @@ fn build_graphics_rows(g: &Gathered) -> Vec<Row> {
|
||||
// The Qt analogue of the reference's "Renderer (Slint)": the saved
|
||||
// Settings > Appearance preference vs the RHI api that actually
|
||||
// resolved at startup (`renderer_qt::active_api`).
|
||||
row(
|
||||
"Renderer (Qt)",
|
||||
&g.renderer_saved,
|
||||
&g.renderer_runtime,
|
||||
0,
|
||||
),
|
||||
row("Renderer (Qt)", &g.renderer_saved, &g.renderer_runtime, 0),
|
||||
// Stands where the reference's `renderer_decision_summary()` adapter
|
||||
// string does. NOT byte-identical: this is the Vulkan enumeration in
|
||||
// `QT_VK_PHYSICAL_DEVICE_INDEX` order, not wgpu's adapter list.
|
||||
@@ -807,13 +824,21 @@ fn build_graphics_rows(g: &Gathered) -> Vec<Row> {
|
||||
row(
|
||||
"GPU: NVIDIA",
|
||||
"—",
|
||||
if d.runtime_has_nvidia { "Detected" } else { "No" },
|
||||
if d.runtime_has_nvidia {
|
||||
"Detected"
|
||||
} else {
|
||||
"No"
|
||||
},
|
||||
0,
|
||||
),
|
||||
row(
|
||||
"GPU: Intel",
|
||||
"—",
|
||||
if d.runtime_has_intel { "Detected" } else { "No" },
|
||||
if d.runtime_has_intel {
|
||||
"Detected"
|
||||
} else {
|
||||
"No"
|
||||
},
|
||||
0,
|
||||
),
|
||||
row(
|
||||
@@ -900,7 +925,12 @@ fn build_playback_rows(
|
||||
),
|
||||
row("Has Track", "—", yn(track.is_some()), 0),
|
||||
row("Track Title", "—", &opt(&track.map(|t| t.title.clone())), 0),
|
||||
row("Track Artist", "—", &opt(&track.map(|t| t.artist.clone())), 0),
|
||||
row(
|
||||
"Track Artist",
|
||||
"—",
|
||||
&opt(&track.map(|t| t.artist.clone())),
|
||||
0,
|
||||
),
|
||||
row("Track Album", "—", &opt(&track.map(|t| t.album.clone())), 0),
|
||||
row(
|
||||
"Track Source",
|
||||
|
||||
@@ -32,7 +32,6 @@ pub mod qbz_disc_meta_bridge {
|
||||
/// The whole modal in one document. Parseable while closed so a
|
||||
/// binding reading `doc.open` on frame 1 cannot throw.
|
||||
#[qproperty(QString, meta_json)]
|
||||
|
||||
type QbzDiscMeta = super::QbzDiscMetaRust;
|
||||
|
||||
/// Registers the Qt-thread hop. Without it every publish from Rust is
|
||||
|
||||
@@ -219,7 +219,9 @@ pub fn search(query: &str) {
|
||||
d.preview = None;
|
||||
d.selected_id.clear();
|
||||
(
|
||||
d.provider.parse::<RemoteProvider>().unwrap_or(RemoteProvider::MusicBrainz),
|
||||
d.provider
|
||||
.parse::<RemoteProvider>()
|
||||
.unwrap_or(RemoteProvider::MusicBrainz),
|
||||
// The album artist as the app currently knows it, which is what
|
||||
// lets the orchestration split "artist album" back apart.
|
||||
crate::local_ephemeral::tracks_snapshot()
|
||||
@@ -243,7 +245,11 @@ pub fn search(query: &str) {
|
||||
"[qbz-qt] disc meta: {} returned {} result(s){}",
|
||||
provider_word(provider),
|
||||
response.results.len(),
|
||||
if response.rate_limited { " (rate limited)" } else { "" }
|
||||
if response.rate_limited {
|
||||
" (rate limited)"
|
||||
} else {
|
||||
""
|
||||
}
|
||||
);
|
||||
with(|d| {
|
||||
// A late answer from a provider the user has since switched away
|
||||
@@ -292,7 +298,9 @@ pub fn select(provider_id: &str) {
|
||||
d.selected_id = id.clone();
|
||||
d.loading_id = id.clone();
|
||||
d.preview = None;
|
||||
d.provider.parse::<RemoteProvider>().unwrap_or(RemoteProvider::MusicBrainz)
|
||||
d.provider
|
||||
.parse::<RemoteProvider>()
|
||||
.unwrap_or(RemoteProvider::MusicBrainz)
|
||||
});
|
||||
publish();
|
||||
|
||||
@@ -375,7 +383,11 @@ pub fn apply() {
|
||||
tracks: (0..disc_tracks)
|
||||
.map(|i| qbz_disc::store::TrackMemory {
|
||||
number: i as u32 + 1,
|
||||
title: meta.tracks.get(i).map(|t| t.title.clone()).unwrap_or_default(),
|
||||
title: meta
|
||||
.tracks
|
||||
.get(i)
|
||||
.map(|t| t.title.clone())
|
||||
.unwrap_or_default(),
|
||||
artist: meta.artist.clone(),
|
||||
})
|
||||
.collect(),
|
||||
|
||||
@@ -48,7 +48,6 @@ pub mod qbz_disco_bridge {
|
||||
// loading/error, order, tri-state header flags, footer counts and the
|
||||
// grouped candidates with their alternates).
|
||||
#[qproperty(QString, disco_json)]
|
||||
|
||||
type QbzDisco = super::QbzDiscoRust;
|
||||
|
||||
/// Registers this object's Qt-thread hop (Main.qml boots EVERY domain
|
||||
@@ -95,11 +94,7 @@ pub mod qbz_disco_bridge {
|
||||
|
||||
/// Clears one override (back to the classifier's verdict).
|
||||
#[qinvokable]
|
||||
fn reset_type_override(
|
||||
self: Pin<&mut QbzDisco>,
|
||||
source: QString,
|
||||
source_item_id: QString,
|
||||
);
|
||||
fn reset_type_override(self: Pin<&mut QbzDisco>, source: QString, source_item_id: QString);
|
||||
|
||||
/// Create the artist_collection: trimmed-empty name ⇒ the raw literal
|
||||
/// "Artist Collection" (untranslated, 1:1 with myqbz_builder.rs:858),
|
||||
|
||||
@@ -26,11 +26,11 @@
|
||||
//! is out of scope. The toolbar gear is DISABLED on that tab instead of
|
||||
//! opening an empty modal (HomeView.qml).
|
||||
|
||||
use cxx_qt_lib::QString;
|
||||
use qbz_app::settings::discover_prefs::{
|
||||
DiscoverPrefs, DiscoverPrefsStore, DiscoverySectionId, DiscoveryTab, DEFAULT_RAIL_SIZE,
|
||||
RAIL_SIZE_PRESETS,
|
||||
};
|
||||
use cxx_qt_lib::QString;
|
||||
use serde::Serialize;
|
||||
|
||||
/// id -> label. Verbatim copy of `discover_prefs::label_for` in the Slint
|
||||
@@ -93,8 +93,7 @@ struct ConfigDoc {
|
||||
/// per-user prefs file as the section order, and `recommendations_qt` writes it
|
||||
/// — one accessor beats two copies of the user-dir lookup drifting apart.
|
||||
pub(crate) fn store() -> Option<DiscoverPrefsStore> {
|
||||
crate::sidebar_qt::user_dir()
|
||||
.and_then(|dir| DiscoverPrefsStore::new_at(&dir).ok())
|
||||
crate::sidebar_qt::user_dir().and_then(|dir| DiscoverPrefsStore::new_at(&dir).ok())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -77,7 +77,10 @@ pub fn init_for_user(base_dir: &Path) {
|
||||
match store.get_favorite_album_ids() {
|
||||
Ok(ids) => {
|
||||
let set: HashSet<String> = ids.into_iter().collect();
|
||||
log::info!("[qbz-qt] favorites cache: {} album ids seeded from disk", set.len());
|
||||
log::info!(
|
||||
"[qbz-qt] favorites cache: {} album ids seeded from disk",
|
||||
set.len()
|
||||
);
|
||||
if let Ok(mut guard) = FAV_ALBUMS.write() {
|
||||
*guard = set;
|
||||
}
|
||||
@@ -116,8 +119,14 @@ pub fn init_for_user(base_dir: &Path) {
|
||||
/// Seed one numeric set from the store's `i64` rows (the three of them read
|
||||
/// identically; the album set is the odd one out because its ids are text).
|
||||
fn write_u64(cell: &RwLock<HashSet<u64>>, ids: Vec<i64>, what: &str) {
|
||||
let set: HashSet<u64> = ids.into_iter().filter_map(|id| u64::try_from(id).ok()).collect();
|
||||
log::info!("[qbz-qt] favorites cache: {} {what} ids seeded from disk", set.len());
|
||||
let set: HashSet<u64> = ids
|
||||
.into_iter()
|
||||
.filter_map(|id| u64::try_from(id).ok())
|
||||
.collect();
|
||||
log::info!(
|
||||
"[qbz-qt] favorites cache: {} {what} ids seeded from disk",
|
||||
set.len()
|
||||
);
|
||||
if let Ok(mut guard) = cell.write() {
|
||||
*guard = set;
|
||||
}
|
||||
@@ -177,7 +186,10 @@ pub fn is_favorite(kind: &str, id: &str) -> bool {
|
||||
}
|
||||
|
||||
pub fn contains_track(track_id: u64) -> bool {
|
||||
FAV_TRACKS.read().map(|g| g.contains(&track_id)).unwrap_or(false)
|
||||
FAV_TRACKS
|
||||
.read()
|
||||
.map(|g| g.contains(&track_id))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Snapshot of the whole favourite-track set — the shape the queue publisher
|
||||
@@ -187,15 +199,24 @@ pub fn all_tracks() -> HashSet<u64> {
|
||||
}
|
||||
|
||||
pub fn is_album_favorite(album_id: &str) -> bool {
|
||||
FAV_ALBUMS.read().map(|g| g.contains(album_id)).unwrap_or(false)
|
||||
FAV_ALBUMS
|
||||
.read()
|
||||
.map(|g| g.contains(album_id))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
pub fn is_artist_favorite(artist_id: u64) -> bool {
|
||||
FAV_ARTISTS.read().map(|g| g.contains(&artist_id)).unwrap_or(false)
|
||||
FAV_ARTISTS
|
||||
.read()
|
||||
.map(|g| g.contains(&artist_id))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
pub fn is_label_favorite(label_id: u64) -> bool {
|
||||
FAV_LABELS.read().map(|g| g.contains(&label_id)).unwrap_or(false)
|
||||
FAV_LABELS
|
||||
.read()
|
||||
.map(|g| g.contains(&label_id))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Snapshot of the followed-label id set — the label page seeds its header
|
||||
@@ -208,7 +229,10 @@ pub fn all_labels() -> HashSet<u64> {
|
||||
/// authority `library_qt::toggle_playlist_favorite` reads to pick its
|
||||
/// direction, so the drawn heart and the click cannot disagree.
|
||||
pub fn is_playlist_favorite(playlist_id: u64) -> bool {
|
||||
FAV_PLAYLISTS.read().map(|g| g.contains(&playlist_id)).unwrap_or(false)
|
||||
FAV_PLAYLISTS
|
||||
.read()
|
||||
.map(|g| g.contains(&playlist_id))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -61,7 +61,6 @@ pub mod qbz_folder_edit_bridge {
|
||||
// The full folder editor (§4.5). Parseable default so QML's
|
||||
// JSON.parse never throws on the pre-publish frame.
|
||||
#[qproperty(QString, edit_json)]
|
||||
|
||||
type QbzFolderEdit = super::QbzFolderEditRust;
|
||||
|
||||
/// Registers this object's Qt-thread hop. Without it every `ui()`
|
||||
|
||||
@@ -602,8 +602,8 @@ pub(crate) fn set_hidden(folder_id: &str, hidden: bool) {
|
||||
return;
|
||||
}
|
||||
crate::spawn(async move {
|
||||
let _ = tokio::task::spawn_blocking(move || folders_qt::set_folder_hidden(&id, hidden))
|
||||
.await;
|
||||
let _ =
|
||||
tokio::task::spawn_blocking(move || folders_qt::set_folder_hidden(&id, hidden)).await;
|
||||
after_write();
|
||||
});
|
||||
}
|
||||
@@ -625,7 +625,15 @@ mod tests {
|
||||
fn the_preset_list_is_the_references_seven_in_order() {
|
||||
assert_eq!(
|
||||
PRESETS,
|
||||
["heart", "star", "music", "folder", "disc", "library", "headphones"]
|
||||
[
|
||||
"heart",
|
||||
"star",
|
||||
"music",
|
||||
"folder",
|
||||
"disc",
|
||||
"library",
|
||||
"headphones"
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
@@ -639,6 +647,9 @@ mod tests {
|
||||
assert_eq!(v["presets"].as_array().map(|a| a.len()), Some(7));
|
||||
assert_eq!(v["swatches"].as_array().map(|a| a.len()), Some(11));
|
||||
assert_eq!(v["swatches"][0]["isAccent"], serde_json::Value::Bool(true));
|
||||
assert_eq!(v["customImagePath"], serde_json::Value::String(String::new()));
|
||||
assert_eq!(
|
||||
v["customImagePath"],
|
||||
serde_json::Value::String(String::new())
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -187,7 +187,9 @@ pub fn delete_folder(id: &str) -> bool {
|
||||
|
||||
/// Move a playlist into `folder_id`, or to root when `None`.
|
||||
pub fn move_playlist(playlist_id: u64, folder_id: Option<&str>) {
|
||||
with_db(true, |db| db.move_playlist_to_folder(playlist_id, folder_id));
|
||||
with_db(true, |db| {
|
||||
db.move_playlist_to_folder(playlist_id, folder_id)
|
||||
});
|
||||
}
|
||||
|
||||
/// Set a playlist's favorite flag.
|
||||
|
||||
@@ -329,7 +329,10 @@ where
|
||||
});
|
||||
}
|
||||
|
||||
async fn load_spotlight<A>(runtime: &Arc<AppRuntime<A>>, favorites: &[Artist]) -> Option<SpotlightDoc>
|
||||
async fn load_spotlight<A>(
|
||||
runtime: &Arc<AppRuntime<A>>,
|
||||
favorites: &[Artist],
|
||||
) -> Option<SpotlightDoc>
|
||||
where
|
||||
A: FrontendAdapter + Send + Sync + 'static,
|
||||
{
|
||||
@@ -413,7 +416,11 @@ where
|
||||
category: page.artist_category.clone().unwrap_or_default(),
|
||||
art_url,
|
||||
art_path: String::new(),
|
||||
has_top_tracks: page.top_tracks.as_ref().map(|t| !t.is_empty()).unwrap_or(false),
|
||||
has_top_tracks: page
|
||||
.top_tracks
|
||||
.as_ref()
|
||||
.map(|t| !t.is_empty())
|
||||
.unwrap_or(false),
|
||||
albums,
|
||||
})
|
||||
}
|
||||
@@ -865,7 +872,10 @@ where
|
||||
/// PRIMARY DailyQ / WeeklyQ path, Tauri `buildSeeds`): `get_track` each,
|
||||
/// extract `{track_id, artist_id, genre_id, label_id}` (artist = performer,
|
||||
/// else composer; missing ids default to 0), drop any with `artist_id == 0`.
|
||||
async fn build_tracks_to_analyse<A>(runtime: &Arc<AppRuntime<A>>, seeds: &[u64]) -> Vec<TrackToAnalyse>
|
||||
async fn build_tracks_to_analyse<A>(
|
||||
runtime: &Arc<AppRuntime<A>>,
|
||||
seeds: &[u64],
|
||||
) -> Vec<TrackToAnalyse>
|
||||
where
|
||||
A: FrontendAdapter + Send + Sync + 'static,
|
||||
{
|
||||
@@ -1075,7 +1085,11 @@ fn to_row(track: &Track) -> TrackRow {
|
||||
.and_then(|a| a.image.best().cloned())
|
||||
.unwrap_or_default(),
|
||||
is_favorite: crate::fav_cache_qt::is_favorite("track", &track.id.to_string()),
|
||||
cache_status: if crate::offline_qt::is_cached(&track.id.to_string()) { 3 } else { 0 },
|
||||
cache_status: if crate::offline_qt::is_cached(&track.id.to_string()) {
|
||||
3
|
||||
} else {
|
||||
0
|
||||
},
|
||||
// NOT covered by `..default()`: that would leave every For You / mix row
|
||||
// affirmatively claiming to be available, while `to_queue_track` two
|
||||
// functions down reads the real value — the row and the queue built from
|
||||
@@ -1188,7 +1202,10 @@ pub(crate) fn mix_play_track(track_id: String) {
|
||||
|
||||
/// A row's ⋯ menu: "next" | "later" | "queue".
|
||||
pub(crate) fn mix_enqueue_track(track_id: String, mode: String) {
|
||||
let Some(track) = mix_queue().into_iter().find(|t| t.id.to_string() == track_id) else {
|
||||
let Some(track) = mix_queue()
|
||||
.into_iter()
|
||||
.find(|t| t.id.to_string() == track_id)
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let runtime = crate::app();
|
||||
@@ -1208,9 +1225,7 @@ fn play_queue_from(index: usize, shuffle: bool) {
|
||||
}
|
||||
let runtime = crate::app();
|
||||
crate::spawn(async move {
|
||||
if let Err(e) =
|
||||
crate::playback_qt::play_track_list(&runtime, queue, index, shuffle).await
|
||||
{
|
||||
if let Err(e) = crate::playback_qt::play_track_list(&runtime, queue, index, shuffle).await {
|
||||
log::warn!("[qbz-qt] mix play failed: {e}");
|
||||
}
|
||||
});
|
||||
|
||||
@@ -393,7 +393,8 @@ pub(crate) fn current_json() -> String {
|
||||
fn publish() {
|
||||
let json = current_json();
|
||||
crate::ui(move |mut b| {
|
||||
b.as_mut().set_genre_filter_json(QString::from(json.as_str()));
|
||||
b.as_mut()
|
||||
.set_genre_filter_json(QString::from(json.as_str()));
|
||||
});
|
||||
}
|
||||
|
||||
@@ -455,7 +456,11 @@ async fn load_descendants(id: u64) {
|
||||
let kids: Vec<u64> = STATE
|
||||
.lock()
|
||||
.ok()
|
||||
.and_then(|s| s.children.get(&id).map(|k| k.iter().map(|c| c.id).collect()))
|
||||
.and_then(|s| {
|
||||
s.children
|
||||
.get(&id)
|
||||
.map(|k| k.iter().map(|c| c.id).collect())
|
||||
})
|
||||
.unwrap_or_default();
|
||||
for kid in kids {
|
||||
load_children(kid).await;
|
||||
|
||||
@@ -116,7 +116,6 @@ pub mod qbz_home {
|
||||
#[qproperty(bool, award_loading)]
|
||||
#[qproperty(QString, award_albums_json)]
|
||||
#[qproperty(bool, award_albums_loading)]
|
||||
|
||||
// --- Radio start-up indicator (src/foryou_qt.rs) -------------------
|
||||
// "" while idle, else the KEY of the radio currently being built:
|
||||
// "album:<id>" or "artist:<id>". A radio is not one request — it is a
|
||||
@@ -136,7 +135,6 @@ pub mod qbz_home {
|
||||
//
|
||||
// READ + NOTIFY: only `foryou_qt` writes it, always in pairs.
|
||||
#[qproperty(QString, radio_pending, READ, NOTIFY)]
|
||||
|
||||
type QbzHome = super::QbzHomeRust;
|
||||
|
||||
/// Registers this object's Qt-thread hop (Main.qml boots EVERY
|
||||
@@ -733,8 +731,7 @@ impl qbz_home::QbzHome {
|
||||
if patch.is_empty() {
|
||||
return;
|
||||
}
|
||||
self.as_mut()
|
||||
.reco_art_ready(QString::from(patch.as_str()));
|
||||
self.as_mut().reco_art_ready(QString::from(patch.as_str()));
|
||||
}
|
||||
|
||||
/// Synchronous, in-memory re-hand on HomeView mount. The history stores
|
||||
|
||||
@@ -39,9 +39,7 @@ use cxx_qt::CxxQtThread;
|
||||
use cxx_qt::Threading as _;
|
||||
use cxx_qt_lib::QString;
|
||||
|
||||
use crate::hotkeys_qt::{
|
||||
CaptureOutcome, EscapeState, EscapeTarget, QT_KEY_DOWN, QT_KEY_UP,
|
||||
};
|
||||
use crate::hotkeys_qt::{CaptureOutcome, EscapeState, EscapeTarget, QT_KEY_DOWN, QT_KEY_UP};
|
||||
|
||||
/// Full-shape default for the groups document (trap 15): the QML columns read
|
||||
/// `.col1.length` in the pre-publish frame. `boot()` refreshes immediately.
|
||||
@@ -77,7 +75,6 @@ pub mod qbz_hotkeys {
|
||||
// Escape stack arms 2 and 3 read them).
|
||||
#[qproperty(bool, cheatsheet_open)]
|
||||
#[qproperty(bool, customize_open)]
|
||||
|
||||
type QbzHotkeys = super::QbzHotkeysRust;
|
||||
|
||||
/// Registers this object's Qt-thread hop (Main.qml boots EVERY
|
||||
@@ -263,8 +260,10 @@ impl qbz_hotkeys::QbzHotkeys {
|
||||
// Live pending + the conflict label; recording STAYS so
|
||||
// the user can pick a different combo
|
||||
// (keybindings.rs:454-459).
|
||||
self.as_mut().set_pending_display(QString::from(display.as_str()));
|
||||
self.as_mut().set_conflict_label(QString::from(label.as_str()));
|
||||
self.as_mut()
|
||||
.set_pending_display(QString::from(display.as_str()));
|
||||
self.as_mut()
|
||||
.set_conflict_label(QString::from(label.as_str()));
|
||||
}
|
||||
CaptureOutcome::Bound { shortcut } => {
|
||||
crate::hotkeys_qt::set_binding(&recording, &shortcut);
|
||||
@@ -349,7 +348,9 @@ impl qbz_hotkeys::QbzHotkeys {
|
||||
publish_groups(self.as_mut());
|
||||
}
|
||||
"ui.openLink" => {
|
||||
log::debug!("[qbz-qt] hotkeys: ui.openLink — no-op seam, LinkResolver not ported (K3)");
|
||||
log::debug!(
|
||||
"[qbz-qt] hotkeys: ui.openLink — no-op seam, LinkResolver not ported (K3)"
|
||||
);
|
||||
}
|
||||
// Shift+M — the reference's `toggle_miniplayer()`
|
||||
// (keybindings.rs:547-558). Its gamescope guard is NOT repeated
|
||||
|
||||
+204
-43
@@ -135,27 +135,135 @@ pub struct ActionDef {
|
||||
/// (keybindings.rs:88-117; contract §2 verbatim).
|
||||
pub const ACTIONS: &[ActionDef] = &[
|
||||
// Playback
|
||||
ActionDef { id: "playback.toggle", label_en: "Play / Pause", category: Category::Playback, default: "Space", context: Context::None },
|
||||
ActionDef { id: "playback.next", label_en: "Next Track", category: Category::Playback, default: "Ctrl+ArrowRight", context: Context::None },
|
||||
ActionDef { id: "playback.prev", label_en: "Previous Track", category: Category::Playback, default: "Ctrl+ArrowLeft", context: Context::None },
|
||||
ActionDef {
|
||||
id: "playback.toggle",
|
||||
label_en: "Play / Pause",
|
||||
category: Category::Playback,
|
||||
default: "Space",
|
||||
context: Context::None,
|
||||
},
|
||||
ActionDef {
|
||||
id: "playback.next",
|
||||
label_en: "Next Track",
|
||||
category: Category::Playback,
|
||||
default: "Ctrl+ArrowRight",
|
||||
context: Context::None,
|
||||
},
|
||||
ActionDef {
|
||||
id: "playback.prev",
|
||||
label_en: "Previous Track",
|
||||
category: Category::Playback,
|
||||
default: "Ctrl+ArrowLeft",
|
||||
context: Context::None,
|
||||
},
|
||||
// Navigation
|
||||
ActionDef { id: "nav.back", label_en: "Go Back", category: Category::Navigation, default: "Alt+ArrowLeft", context: Context::None },
|
||||
ActionDef { id: "nav.forward", label_en: "Go Forward", category: Category::Navigation, default: "Alt+ArrowRight", context: Context::None },
|
||||
ActionDef { id: "nav.search", label_en: "Search", category: Category::Navigation, default: "Ctrl+f", context: Context::None },
|
||||
ActionDef { id: "nav.settings", label_en: "Settings", category: Category::Navigation, default: "Ctrl+,", context: Context::None },
|
||||
ActionDef {
|
||||
id: "nav.back",
|
||||
label_en: "Go Back",
|
||||
category: Category::Navigation,
|
||||
default: "Alt+ArrowLeft",
|
||||
context: Context::None,
|
||||
},
|
||||
ActionDef {
|
||||
id: "nav.forward",
|
||||
label_en: "Go Forward",
|
||||
category: Category::Navigation,
|
||||
default: "Alt+ArrowRight",
|
||||
context: Context::None,
|
||||
},
|
||||
ActionDef {
|
||||
id: "nav.search",
|
||||
label_en: "Search",
|
||||
category: Category::Navigation,
|
||||
default: "Ctrl+f",
|
||||
context: Context::None,
|
||||
},
|
||||
ActionDef {
|
||||
id: "nav.settings",
|
||||
label_en: "Settings",
|
||||
category: Category::Navigation,
|
||||
default: "Ctrl+,",
|
||||
context: Context::None,
|
||||
},
|
||||
// Interface
|
||||
ActionDef { id: "ui.sidebar", label_en: "Toggle Sidebar", category: Category::Ui, default: "Shift+S", context: Context::None },
|
||||
ActionDef { id: "ui.focusMode", label_en: "Immersive Mode", category: Category::Ui, default: "Shift+I", context: Context::None },
|
||||
ActionDef { id: "ui.queue", label_en: "Queue", category: Category::Ui, default: "q", context: Context::None },
|
||||
ActionDef { id: "ui.escape", label_en: "Close / Dismiss", category: Category::Ui, default: "Escape", context: Context::None },
|
||||
ActionDef { id: "ui.showShortcuts", label_en: "Show Shortcuts", category: Category::Ui, default: "?", context: Context::None },
|
||||
ActionDef { id: "ui.openLink", label_en: "Open Qobuz Link", category: Category::Ui, default: "Ctrl+l", context: Context::None },
|
||||
ActionDef { id: "ui.miniPlayer", label_en: "Toggle Mini Player", category: Category::Ui, default: "Shift+M", context: Context::None },
|
||||
ActionDef {
|
||||
id: "ui.sidebar",
|
||||
label_en: "Toggle Sidebar",
|
||||
category: Category::Ui,
|
||||
default: "Shift+S",
|
||||
context: Context::None,
|
||||
},
|
||||
ActionDef {
|
||||
id: "ui.focusMode",
|
||||
label_en: "Immersive Mode",
|
||||
category: Category::Ui,
|
||||
default: "Shift+I",
|
||||
context: Context::None,
|
||||
},
|
||||
ActionDef {
|
||||
id: "ui.queue",
|
||||
label_en: "Queue",
|
||||
category: Category::Ui,
|
||||
default: "q",
|
||||
context: Context::None,
|
||||
},
|
||||
ActionDef {
|
||||
id: "ui.escape",
|
||||
label_en: "Close / Dismiss",
|
||||
category: Category::Ui,
|
||||
default: "Escape",
|
||||
context: Context::None,
|
||||
},
|
||||
ActionDef {
|
||||
id: "ui.showShortcuts",
|
||||
label_en: "Show Shortcuts",
|
||||
category: Category::Ui,
|
||||
default: "?",
|
||||
context: Context::None,
|
||||
},
|
||||
ActionDef {
|
||||
id: "ui.openLink",
|
||||
label_en: "Open Qobuz Link",
|
||||
category: Category::Ui,
|
||||
default: "Ctrl+l",
|
||||
context: Context::None,
|
||||
},
|
||||
ActionDef {
|
||||
id: "ui.miniPlayer",
|
||||
label_en: "Toggle Mini Player",
|
||||
category: Category::Ui,
|
||||
default: "Shift+M",
|
||||
context: Context::None,
|
||||
},
|
||||
// Immersive (contextual)
|
||||
ActionDef { id: "focus.seekForward", label_en: "Seek Forward (5s)", category: Category::Immersive, default: "ArrowRight", context: Context::Immersive },
|
||||
ActionDef { id: "focus.seekBack", label_en: "Seek Back (5s)", category: Category::Immersive, default: "ArrowLeft", context: Context::Immersive },
|
||||
ActionDef { id: "focus.seekForwardLong", label_en: "Seek Forward (10s)", category: Category::Immersive, default: "Shift+ArrowRight", context: Context::Immersive },
|
||||
ActionDef { id: "focus.seekBackLong", label_en: "Seek Back (10s)", category: Category::Immersive, default: "Shift+ArrowLeft", context: Context::Immersive },
|
||||
ActionDef {
|
||||
id: "focus.seekForward",
|
||||
label_en: "Seek Forward (5s)",
|
||||
category: Category::Immersive,
|
||||
default: "ArrowRight",
|
||||
context: Context::Immersive,
|
||||
},
|
||||
ActionDef {
|
||||
id: "focus.seekBack",
|
||||
label_en: "Seek Back (5s)",
|
||||
category: Category::Immersive,
|
||||
default: "ArrowLeft",
|
||||
context: Context::Immersive,
|
||||
},
|
||||
ActionDef {
|
||||
id: "focus.seekForwardLong",
|
||||
label_en: "Seek Forward (10s)",
|
||||
category: Category::Immersive,
|
||||
default: "Shift+ArrowRight",
|
||||
context: Context::Immersive,
|
||||
},
|
||||
ActionDef {
|
||||
id: "focus.seekBackLong",
|
||||
label_en: "Seek Back (10s)",
|
||||
category: Category::Immersive,
|
||||
default: "Shift+ArrowLeft",
|
||||
context: Context::Immersive,
|
||||
},
|
||||
// Mini Player (contextual — dispatched on the mini window)
|
||||
ActionDef {
|
||||
id: "mini.micro",
|
||||
@@ -646,8 +754,7 @@ pub fn is_ctrl_a(key: i32, modifiers: i32, text: &str) -> bool {
|
||||
if !ctrl {
|
||||
return false;
|
||||
}
|
||||
token_from_qt_key(key, modifiers, text)
|
||||
.is_some_and(|tok| tok.eq_ignore_ascii_case("a"))
|
||||
token_from_qt_key(key, modifiers, text).is_some_and(|tok| tok.eq_ignore_ascii_case("a"))
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
@@ -740,14 +847,35 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn named_keys_come_from_the_key_code() {
|
||||
assert_eq!(token_from_qt_key(QT_KEY_SPACE, 0, " "), Some("Space".into()));
|
||||
assert_eq!(token_from_qt_key(QT_KEY_LEFT, 0, ""), Some("ArrowLeft".into()));
|
||||
assert_eq!(token_from_qt_key(QT_KEY_RIGHT, 0, ""), Some("ArrowRight".into()));
|
||||
assert_eq!(
|
||||
token_from_qt_key(QT_KEY_SPACE, 0, " "),
|
||||
Some("Space".into())
|
||||
);
|
||||
assert_eq!(
|
||||
token_from_qt_key(QT_KEY_LEFT, 0, ""),
|
||||
Some("ArrowLeft".into())
|
||||
);
|
||||
assert_eq!(
|
||||
token_from_qt_key(QT_KEY_RIGHT, 0, ""),
|
||||
Some("ArrowRight".into())
|
||||
);
|
||||
assert_eq!(token_from_qt_key(QT_KEY_UP, 0, ""), Some("ArrowUp".into()));
|
||||
assert_eq!(token_from_qt_key(QT_KEY_DOWN, 0, ""), Some("ArrowDown".into()));
|
||||
assert_eq!(token_from_qt_key(QT_KEY_ESCAPE, 0, ""), Some("Escape".into()));
|
||||
assert_eq!(token_from_qt_key(QT_KEY_RETURN, 0, "\r"), Some("Enter".into()));
|
||||
assert_eq!(token_from_qt_key(QT_KEY_ENTER, 0, "\r"), Some("Enter".into()));
|
||||
assert_eq!(
|
||||
token_from_qt_key(QT_KEY_DOWN, 0, ""),
|
||||
Some("ArrowDown".into())
|
||||
);
|
||||
assert_eq!(
|
||||
token_from_qt_key(QT_KEY_ESCAPE, 0, ""),
|
||||
Some("Escape".into())
|
||||
);
|
||||
assert_eq!(
|
||||
token_from_qt_key(QT_KEY_RETURN, 0, "\r"),
|
||||
Some("Enter".into())
|
||||
);
|
||||
assert_eq!(
|
||||
token_from_qt_key(QT_KEY_ENTER, 0, "\r"),
|
||||
Some("Enter".into())
|
||||
);
|
||||
assert_eq!(token_from_qt_key(QT_KEY_TAB, 0, "\t"), Some("Tab".into()));
|
||||
assert_eq!(
|
||||
token_from_qt_key(QT_KEY_BACKSPACE, 0, "\u{8}"),
|
||||
@@ -765,7 +893,10 @@ mod tests {
|
||||
// is folded into the glyph — the stored string is "?".
|
||||
let token = token_from_qt_key(0x2f, QT_SHIFT, "?").unwrap();
|
||||
assert_eq!(token, "?");
|
||||
assert_eq!(shortcut_from_parts(false, false, true, &token), Some("?".into()));
|
||||
assert_eq!(
|
||||
shortcut_from_parts(false, false, true, &token),
|
||||
Some("?".into())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -911,8 +1042,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn groups_split_round_robin_into_three_columns() {
|
||||
let doc: serde_json::Value =
|
||||
serde_json::from_str(&groups_json(&no_overrides())).unwrap();
|
||||
let doc: serde_json::Value = serde_json::from_str(&groups_json(&no_overrides())).unwrap();
|
||||
let labels = |col: &str| -> Vec<String> {
|
||||
doc[col]
|
||||
.as_array()
|
||||
@@ -928,8 +1058,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn groups_rows_carry_id_label_shortcut_modified_contextual() {
|
||||
let doc: serde_json::Value =
|
||||
serde_json::from_str(&groups_json(&no_overrides())).unwrap();
|
||||
let doc: serde_json::Value = serde_json::from_str(&groups_json(&no_overrides())).unwrap();
|
||||
let row = &doc["col1"][0]["rows"][0];
|
||||
assert_eq!(row["id"], "playback.toggle");
|
||||
assert_eq!(row["label"], "Play / Pause"); // en fallback (msgid)
|
||||
@@ -973,18 +1102,48 @@ mod tests {
|
||||
}
|
||||
}
|
||||
// Everything open → the link-resolver arm wins (order position 1).
|
||||
assert_eq!(escape_target(&s(true, true, true, true, true, true, true, true)), EscapeTarget::LinkResolver);
|
||||
assert_eq!(
|
||||
escape_target(&s(true, true, true, true, true, true, true, true)),
|
||||
EscapeTarget::LinkResolver
|
||||
);
|
||||
// Then, in order, each surface beats every LATER one.
|
||||
assert_eq!(escape_target(&s(false, true, true, true, true, true, true, true)), EscapeTarget::Customize);
|
||||
assert_eq!(escape_target(&s(false, false, true, true, true, true, true, true)), EscapeTarget::Cheatsheet);
|
||||
assert_eq!(escape_target(&s(false, false, false, true, true, true, true, true)), EscapeTarget::MusicianModal);
|
||||
assert_eq!(escape_target(&s(false, false, false, false, true, true, true, true)), EscapeTarget::Cortinilla);
|
||||
assert_eq!(escape_target(&s(false, false, false, false, false, true, true, true)), EscapeTarget::Immersive);
|
||||
assert_eq!(escape_target(&s(false, false, false, false, false, false, true, true)), EscapeTarget::MultiSelect);
|
||||
assert_eq!(escape_target(&s(false, false, false, false, false, false, false, true)), EscapeTarget::Queue);
|
||||
assert_eq!(escape_target(&s(false, false, false, false, false, false, false, false)), EscapeTarget::None);
|
||||
assert_eq!(
|
||||
escape_target(&s(false, true, true, true, true, true, true, true)),
|
||||
EscapeTarget::Customize
|
||||
);
|
||||
assert_eq!(
|
||||
escape_target(&s(false, false, true, true, true, true, true, true)),
|
||||
EscapeTarget::Cheatsheet
|
||||
);
|
||||
assert_eq!(
|
||||
escape_target(&s(false, false, false, true, true, true, true, true)),
|
||||
EscapeTarget::MusicianModal
|
||||
);
|
||||
assert_eq!(
|
||||
escape_target(&s(false, false, false, false, true, true, true, true)),
|
||||
EscapeTarget::Cortinilla
|
||||
);
|
||||
assert_eq!(
|
||||
escape_target(&s(false, false, false, false, false, true, true, true)),
|
||||
EscapeTarget::Immersive
|
||||
);
|
||||
assert_eq!(
|
||||
escape_target(&s(false, false, false, false, false, false, true, true)),
|
||||
EscapeTarget::MultiSelect
|
||||
);
|
||||
assert_eq!(
|
||||
escape_target(&s(false, false, false, false, false, false, false, true)),
|
||||
EscapeTarget::Queue
|
||||
);
|
||||
assert_eq!(
|
||||
escape_target(&s(false, false, false, false, false, false, false, false)),
|
||||
EscapeTarget::None
|
||||
);
|
||||
// Multi-select beats the queue (order position 7 vs 8).
|
||||
assert_eq!(escape_target(&s(false, false, false, false, false, false, true, true)), EscapeTarget::MultiSelect);
|
||||
assert_eq!(
|
||||
escape_target(&s(false, false, false, false, false, false, true, true)),
|
||||
EscapeTarget::MultiSelect
|
||||
);
|
||||
}
|
||||
|
||||
/// The musician modal is opened FROM the immersive Track-info panel
|
||||
@@ -1034,7 +1193,9 @@ mod tests {
|
||||
let key_w = QT_KEY_A + 22;
|
||||
assert_eq!(
|
||||
capture_step(&overrides, "ui.queue", key_w, 0, "w"),
|
||||
CaptureOutcome::Bound { shortcut: "w".into() }
|
||||
CaptureOutcome::Bound {
|
||||
shortcut: "w".into()
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -87,7 +87,6 @@ pub mod qbz_immersive {
|
||||
#[qproperty(f64, imm_search_scroll_y)]
|
||||
// Two-way with the search field.
|
||||
#[qproperty(QString, search_input_text)]
|
||||
|
||||
type QbzImmersive = super::QbzImmersiveRust;
|
||||
|
||||
/// Registers this object's Qt-thread hop (Main.qml boots EVERY
|
||||
@@ -288,7 +287,8 @@ fn apply_open(mut this: Pin<&mut QbzImmersive>, value: bool) {
|
||||
// numbers; pref_i32 is the compatible reader — delivery notes.)
|
||||
let vm = crate::settings_qt::pref_i32("immersive_last_view_mode", DEFAULT_VIEW_MODE);
|
||||
let m = crate::settings_qt::pref_i32("immersive_last_mode", DEFAULT_MODE);
|
||||
let sp = crate::settings_qt::pref_i32("immersive_last_split_panel", DEFAULT_SPLIT_PANEL);
|
||||
let sp =
|
||||
crate::settings_qt::pref_i32("immersive_last_split_panel", DEFAULT_SPLIT_PANEL);
|
||||
this.as_mut().set_view_mode(vm);
|
||||
this.as_mut().set_mode(m);
|
||||
this.as_mut().set_split_panel(sp);
|
||||
@@ -428,7 +428,10 @@ mod tests {
|
||||
// Owner ruling 2026-08-15: with no remembered triple, the
|
||||
// remember-restore fallbacks AND the bridge construction default land
|
||||
// on viewMode=1 (SPLIT), splitPanel=0 (Lyrics) — never a FOCUS panel.
|
||||
assert_eq!((DEFAULT_VIEW_MODE, DEFAULT_MODE, DEFAULT_SPLIT_PANEL), (1, 0, 0));
|
||||
assert_eq!(
|
||||
(DEFAULT_VIEW_MODE, DEFAULT_MODE, DEFAULT_SPLIT_PANEL),
|
||||
(1, 0, 0)
|
||||
);
|
||||
assert_eq!(QbzImmersiveRust::default().view_mode, 1);
|
||||
assert_eq!(QbzImmersiveRust::default().split_panel, 0);
|
||||
}
|
||||
|
||||
@@ -69,8 +69,8 @@ static DISCOVER: OnceLock<Mutex<Option<DiscoverPrefsStore>>> = OnceLock::new();
|
||||
|
||||
fn with_discover<T>(f: impl FnOnce(&DiscoverPrefsStore) -> T) -> Option<T> {
|
||||
let cell = DISCOVER.get_or_init(|| {
|
||||
let store = crate::sidebar_qt::user_dir()
|
||||
.and_then(|dir| DiscoverPrefsStore::new_at(&dir).ok());
|
||||
let store =
|
||||
crate::sidebar_qt::user_dir().and_then(|dir| DiscoverPrefsStore::new_at(&dir).ok());
|
||||
if store.is_none() {
|
||||
log::warn!("[qbz-qt] discover prefs store unavailable");
|
||||
}
|
||||
@@ -407,7 +407,10 @@ pub async fn listenbrainz_set_token(token: &str) {
|
||||
})
|
||||
.await;
|
||||
}
|
||||
set_status(qbz_i18n::t_args("Connected as {}", &[info.user_name.as_str()]), 2);
|
||||
set_status(
|
||||
qbz_i18n::t_args("Connected as {}", &[info.user_name.as_str()]),
|
||||
2,
|
||||
);
|
||||
}
|
||||
Err(e) => set_status(qbz_i18n::t_args("Error: {}", &[&e.to_string()]), 3),
|
||||
}
|
||||
@@ -697,7 +700,12 @@ async fn send_now_playing(meta: &ScrobbleMeta, cfg: &ScrobblerSettings) {
|
||||
)
|
||||
.await;
|
||||
if let Err(e) = client
|
||||
.submit_playing_now(&meta.artist, &meta.track, album, lb_info(meta.duration_secs))
|
||||
.submit_playing_now(
|
||||
&meta.artist,
|
||||
&meta.track,
|
||||
album,
|
||||
lb_info(meta.duration_secs),
|
||||
)
|
||||
.await
|
||||
{
|
||||
log::debug!("[qbz-qt] ListenBrainz now-playing failed: {e}");
|
||||
@@ -725,7 +733,11 @@ async fn send_scrobble(meta: &ScrobbleMeta) {
|
||||
.await
|
||||
{
|
||||
Ok(()) => {
|
||||
log::info!("[qbz-qt] Last.fm scrobbled: {} - {}", meta.artist, meta.track);
|
||||
log::info!(
|
||||
"[qbz-qt] Last.fm scrobbled: {} - {}",
|
||||
meta.artist,
|
||||
meta.track
|
||||
);
|
||||
true
|
||||
}
|
||||
Err(e) => {
|
||||
|
||||
@@ -61,7 +61,6 @@ pub mod qbz_kiosk_nav_bridge {
|
||||
#[qproperty(i32, dir)]
|
||||
/// Enter pulse for conditionally-mounted content views.
|
||||
#[qproperty(i32, activate_seq)]
|
||||
|
||||
type QbzKioskNav = super::QbzKioskNavRust;
|
||||
|
||||
/// Arrow keys, from the kiosk shell's root key handler.
|
||||
|
||||
@@ -366,7 +366,11 @@ mod tests {
|
||||
for _ in 0..10 {
|
||||
m.right();
|
||||
}
|
||||
assert!(m.index < m.count, "never past the data ({before} -> {})", m.index);
|
||||
assert!(
|
||||
m.index < m.count,
|
||||
"never past the data ({before} -> {})",
|
||||
m.index
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -387,7 +391,10 @@ mod tests {
|
||||
m.right();
|
||||
} // index 10, column 5
|
||||
m.up();
|
||||
assert_eq!(m.index, 4, "min(tabs - 1, column) with 5 tabs and 6 columns");
|
||||
assert_eq!(
|
||||
m.index, 4,
|
||||
"min(tabs - 1, column) with 5 tabs and 6 columns"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -303,7 +303,9 @@ fn fetch_label(generation: u64, id: u64) {
|
||||
c.id.as_deref()
|
||||
.map(|id| {
|
||||
let id = id.to_lowercase();
|
||||
id.contains("award") || id.contains("critic") || id.contains("press")
|
||||
id.contains("award")
|
||||
|| id.contains("critic")
|
||||
|| id.contains("press")
|
||||
})
|
||||
.unwrap_or(false)
|
||||
})
|
||||
@@ -632,9 +634,7 @@ pub fn enqueue_top(mode: String) {
|
||||
}
|
||||
let runtime = crate::app();
|
||||
crate::spawn(async move {
|
||||
if let Err(e) =
|
||||
crate::playback_qt::enqueue_track_list_mode(&runtime, queue, &mode).await
|
||||
{
|
||||
if let Err(e) = crate::playback_qt::enqueue_track_list_mode(&runtime, queue, &mode).await {
|
||||
log::error!("[qbz-qt] label enqueue-top ({mode}) failed: {e}");
|
||||
}
|
||||
});
|
||||
@@ -1152,7 +1152,9 @@ fn parse_artist_image(raw: &Value) -> String {
|
||||
portrait.get("hash").and_then(|v| v.as_str()),
|
||||
portrait.get("format").and_then(|v| v.as_str()),
|
||||
) {
|
||||
return format!("https://static.qobuz.com/images/artists/covers/medium/{hash}.{format}");
|
||||
return format!(
|
||||
"https://static.qobuz.com/images/artists/covers/medium/{hash}.{format}"
|
||||
);
|
||||
}
|
||||
}
|
||||
String::new()
|
||||
@@ -1289,8 +1291,15 @@ fn parse_top_track(index: usize, raw: &Value) -> TrackRow {
|
||||
})
|
||||
.unwrap_or(true);
|
||||
TrackRow {
|
||||
is_favorite: id.parse::<u64>().map(crate::fav_cache_qt::contains_track).unwrap_or(false),
|
||||
cache_status: if crate::offline_qt::is_cached(&id) { 3 } else { 0 },
|
||||
is_favorite: id
|
||||
.parse::<u64>()
|
||||
.map(crate::fav_cache_qt::contains_track)
|
||||
.unwrap_or(false),
|
||||
cache_status: if crate::offline_qt::is_cached(&id) {
|
||||
3
|
||||
} else {
|
||||
0
|
||||
},
|
||||
id,
|
||||
number: (index + 1).to_string(),
|
||||
title,
|
||||
@@ -1327,7 +1336,10 @@ fn parse_playlist(raw: &Value) -> HomeCard {
|
||||
.filter(|s| !s.is_empty())
|
||||
.unwrap_or("Qobuz")
|
||||
.to_string();
|
||||
let track_count = raw.get("tracks_count").and_then(|v| v.as_u64()).unwrap_or(0);
|
||||
let track_count = raw
|
||||
.get("tracks_count")
|
||||
.and_then(|v| v.as_u64())
|
||||
.unwrap_or(0);
|
||||
let id = raw.get("id").map(value_to_string).unwrap_or_default();
|
||||
let pid = id.parse::<u64>().ok();
|
||||
HomeCard {
|
||||
@@ -1339,7 +1351,9 @@ fn parse_playlist(raw: &Value) -> HomeCard {
|
||||
// raw JSON with no owner block worth trusting, so ownership is
|
||||
// resolved by ID against the user's own playlist list rather than by
|
||||
// comparing an owner field that may not be there.
|
||||
is_favorite: pid.map(crate::fav_cache_qt::is_playlist_favorite).unwrap_or(false),
|
||||
is_favorite: pid
|
||||
.map(crate::fav_cache_qt::is_playlist_favorite)
|
||||
.unwrap_or(false),
|
||||
playlist_owned: pid.map(crate::playlist_qt::is_owned).unwrap_or(false),
|
||||
playlist_following: pid.map(crate::playlist_qt::is_following).unwrap_or(false),
|
||||
id,
|
||||
@@ -1409,8 +1423,15 @@ fn parse_more_labels(resp: &qbz_models::LabelExploreResponse, current: u64) -> V
|
||||
fn track_row_to_queue(row: &TrackRow) -> QueueTrack {
|
||||
let duration_secs = {
|
||||
let mut parts = row.duration.split(':');
|
||||
parts.next().and_then(|m| m.parse::<u64>().ok()).unwrap_or(0) * 60
|
||||
+ parts.next().and_then(|s| s.parse::<u64>().ok()).unwrap_or(0)
|
||||
parts
|
||||
.next()
|
||||
.and_then(|m| m.parse::<u64>().ok())
|
||||
.unwrap_or(0)
|
||||
* 60
|
||||
+ parts
|
||||
.next()
|
||||
.and_then(|s| s.parse::<u64>().ok())
|
||||
.unwrap_or(0)
|
||||
};
|
||||
QueueTrack {
|
||||
id: row.id.parse().unwrap_or(0),
|
||||
@@ -1525,10 +1546,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn sorting_uses_the_plain_year() {
|
||||
let mut items = vec![
|
||||
card("A", "X", "1999", "cd"),
|
||||
card("B", "Y", "2021", "cd"),
|
||||
];
|
||||
let mut items = vec![card("A", "X", "1999", "cd"), card("B", "Y", "2021", "cd")];
|
||||
sort_cards(&mut items, "newest");
|
||||
assert_eq!(items[0].year, "2021");
|
||||
sort_cards(&mut items, "oldest");
|
||||
|
||||
@@ -46,7 +46,6 @@ pub mod qbz_library_bridge {
|
||||
// (library_sidepanel.rs). Its own document, never folded into
|
||||
// `library_json`: a selection must not re-serialize a 10k-row feed.
|
||||
#[qproperty(QString, sidepanel_json)]
|
||||
|
||||
type QbzLibrary = super::QbzLibraryRust;
|
||||
|
||||
/// Registers this object's Qt-thread hop (Main.qml boots EVERY
|
||||
@@ -127,7 +126,14 @@ pub mod qbz_library_bridge {
|
||||
|
||||
/// AlbumCard pin badge: toggle pin (album/artist/playlist).
|
||||
#[qinvokable]
|
||||
fn toggle_pin(self: Pin<&mut QbzLibrary>, kind: QString, id: QString, title: QString, subtitle: QString, artwork_url: QString);
|
||||
fn toggle_pin(
|
||||
self: Pin<&mut QbzLibrary>,
|
||||
kind: QString,
|
||||
id: QString,
|
||||
title: QString,
|
||||
subtitle: QString,
|
||||
artwork_url: QString,
|
||||
);
|
||||
/// Emitted after a pin toggle (`{kind}:{id}` key like artKey).
|
||||
#[qsignal]
|
||||
fn pin_changed(self: Pin<&mut QbzLibrary>, key: QString, value: bool);
|
||||
@@ -250,7 +256,14 @@ impl qbz_library_bridge::QbzLibrary {
|
||||
crate::library_qt::play_visible_all(visible_ids_json.to_string(), shuffle);
|
||||
}
|
||||
|
||||
pub fn toggle_pin(self: Pin<&mut Self>, kind: QString, id: QString, title: QString, subtitle: QString, artwork_url: QString) {
|
||||
pub fn toggle_pin(
|
||||
self: Pin<&mut Self>,
|
||||
kind: QString,
|
||||
id: QString,
|
||||
title: QString,
|
||||
subtitle: QString,
|
||||
artwork_url: QString,
|
||||
) {
|
||||
crate::toggle_pin(
|
||||
kind.to_string(),
|
||||
id.to_string(),
|
||||
|
||||
@@ -51,8 +51,7 @@ fn publish_selection() {
|
||||
let json = lib::to_json(&tree::tree_visible());
|
||||
let count = tree::tree_selected_count();
|
||||
ui(move |mut b| {
|
||||
b.as_mut()
|
||||
.set_local_tree_json(QString::from(json.as_str()));
|
||||
b.as_mut().set_local_tree_json(QString::from(json.as_str()));
|
||||
b.as_mut().set_local_tree_selected_count(count);
|
||||
});
|
||||
}
|
||||
@@ -71,7 +70,8 @@ pub fn set_select_mode(on: bool) {
|
||||
/// Folder checkbox: recursive, so it is a DB read on a blocking thread.
|
||||
pub fn toggle_folder_select(path: String) {
|
||||
crate::spawn(async move {
|
||||
let _ = tokio::task::spawn_blocking(move || tree::toggle_folder_select_blocking(&path)).await;
|
||||
let _ =
|
||||
tokio::task::spawn_blocking(move || tree::toggle_folder_select_blocking(&path)).await;
|
||||
publish_selection();
|
||||
});
|
||||
}
|
||||
@@ -80,7 +80,8 @@ pub fn toggle_folder_select(path: String) {
|
||||
/// from the parent folder listing — blocking either way.
|
||||
pub fn toggle_track_select(path: String) {
|
||||
crate::spawn(async move {
|
||||
let _ = tokio::task::spawn_blocking(move || tree::toggle_track_select_blocking(&path)).await;
|
||||
let _ =
|
||||
tokio::task::spawn_blocking(move || tree::toggle_track_select_blocking(&path)).await;
|
||||
publish_selection();
|
||||
});
|
||||
}
|
||||
@@ -152,7 +153,10 @@ pub fn bulk_action(scope: String, ids_json: String, action: String) {
|
||||
/// last resort for a local id that scrolled out of the cached page.
|
||||
fn resolve_blocking(scope: &str, ids: &[String]) -> Vec<LocalTrack> {
|
||||
if scope == "album" {
|
||||
return ids.iter().flat_map(|key| fetch_album_tracks_blocking(key)).collect();
|
||||
return ids
|
||||
.iter()
|
||||
.flat_map(|key| fetch_album_tracks_blocking(key))
|
||||
.collect();
|
||||
}
|
||||
let cached: HashMap<i64, LocalTrack> = state(|s| {
|
||||
s.tracks_raw
|
||||
|
||||
@@ -91,9 +91,11 @@ pub fn list_blocking() -> Vec<repo::LocalPlaylist> {
|
||||
}
|
||||
|
||||
pub fn get_blocking(id: &str) -> Option<repo::LocalPlaylist> {
|
||||
with_db(false, |db| Ok(db.with_connection(|conn| repo::get(conn, id))))
|
||||
.and_then(|r| r.ok())
|
||||
.flatten()
|
||||
with_db(false, |db| {
|
||||
Ok(db.with_connection(|conn| repo::get(conn, id)))
|
||||
})
|
||||
.and_then(|r| r.ok())
|
||||
.flatten()
|
||||
}
|
||||
|
||||
pub fn get_tracks_blocking(id: &str) -> Vec<repo::LocalPlaylistTrack> {
|
||||
@@ -104,7 +106,11 @@ pub fn get_tracks_blocking(id: &str) -> Vec<repo::LocalPlaylistTrack> {
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
pub fn create_blocking(name: &str, description: Option<&str>, offline_only: bool) -> Option<String> {
|
||||
pub fn create_blocking(
|
||||
name: &str,
|
||||
description: Option<&str>,
|
||||
offline_only: bool,
|
||||
) -> Option<String> {
|
||||
with_db(true, |db| {
|
||||
Ok(db.with_connection(|conn| repo::create(conn, name, description, offline_only)))
|
||||
})
|
||||
@@ -392,10 +398,7 @@ async fn ready_cached_rows(track_ids: &[u64]) -> HashMap<u64, RowItem> {
|
||||
let Ok(Some(info)) = db.get_track(*track_id) else {
|
||||
continue;
|
||||
};
|
||||
if !matches!(
|
||||
info.status,
|
||||
qbz_offline_cache::OfflineCacheStatus::Ready
|
||||
) {
|
||||
if !matches!(info.status, qbz_offline_cache::OfflineCacheStatus::Ready) {
|
||||
continue;
|
||||
}
|
||||
let artwork_path = info.resolve_cover_path(&cache_path);
|
||||
@@ -695,15 +698,10 @@ pub(crate) fn row_to_display(item: &RowItem) -> (PlaylistTrackRow, Option<QueueT
|
||||
title: title.clone(),
|
||||
artist: artist.clone(),
|
||||
album: album.clone(),
|
||||
duration: crate::playlist_qt::mmss(
|
||||
(*duration_secs).min(u32::MAX as u64) as u32,
|
||||
),
|
||||
duration: crate::playlist_qt::mmss((*duration_secs).min(u32::MAX as u64) as u32),
|
||||
duration_secs: *duration_secs,
|
||||
quality_tier: crate::playlist_qt::tier(*bit_depth).to_string(),
|
||||
quality_detail: crate::home_qt::quality_detail_from_parts(
|
||||
*bit_depth,
|
||||
*sample_rate,
|
||||
),
|
||||
quality_detail: crate::home_qt::quality_detail_from_parts(*bit_depth, *sample_rate),
|
||||
quality_label: crate::playlist_qt::quality_label(*bit_depth, *sample_rate),
|
||||
bit_depth: *bit_depth,
|
||||
sample_rate: *sample_rate,
|
||||
@@ -765,11 +763,7 @@ pub(crate) fn row_to_display(item: &RowItem) -> (PlaylistTrackRow, Option<QueueT
|
||||
// no bit depth.
|
||||
quality_tier: crate::local_rows::tier_of(&t.format, t.bit_depth, t.sample_rate)
|
||||
.to_string(),
|
||||
quality_detail: crate::local_rows::detail_of(
|
||||
&t.format,
|
||||
t.bit_depth,
|
||||
t.sample_rate,
|
||||
),
|
||||
quality_detail: crate::local_rows::detail_of(&t.format, t.bit_depth, t.sample_rate),
|
||||
// The raw numbers, normalized to kHz exactly like
|
||||
// `local_playback::local_queue_track` (:155-159) does — a local
|
||||
// row reports Hz (44100), the catalog reports kHz (44.1), and
|
||||
@@ -833,11 +827,7 @@ pub(crate) fn row_to_display(item: &RowItem) -> (PlaylistTrackRow, Option<QueueT
|
||||
|
||||
fn build_row_models(
|
||||
rows: &[LoadedRow],
|
||||
) -> (
|
||||
Vec<QueueTrack>,
|
||||
Vec<PlaylistTrackRow>,
|
||||
HashMap<String, i32>,
|
||||
) {
|
||||
) -> (Vec<QueueTrack>, Vec<PlaylistTrackRow>, HashMap<String, i32>) {
|
||||
let mut display = Vec::with_capacity(rows.len());
|
||||
let mut queue = Vec::new();
|
||||
let mut positions = HashMap::new();
|
||||
@@ -1104,12 +1094,8 @@ pub async fn load(runtime: &Runtime, playlist_id: &str) -> bool {
|
||||
// The DB column survives as a READ fallback so a cover stored there by an
|
||||
// earlier build is not silently lost; `clear_custom_artwork_blocking` is
|
||||
// what lets Remove clear one of those.
|
||||
let custom_cover = crate::cover_artwork_qt::playlist_cover(&id).or_else(|| {
|
||||
header
|
||||
.custom_artwork_path
|
||||
.clone()
|
||||
.filter(|p| !p.is_empty())
|
||||
});
|
||||
let custom_cover = crate::cover_artwork_qt::playlist_cover(&id)
|
||||
.or_else(|| header.custom_artwork_path.clone().filter(|p| !p.is_empty()));
|
||||
|
||||
let doc = PlaylistDoc {
|
||||
id: header.id.clone(),
|
||||
@@ -1265,10 +1251,7 @@ pub async fn play_shuffled(runtime: &Runtime) {
|
||||
async fn play_in(runtime: &Runtime, start_row_id: &str, shuffle: bool) {
|
||||
let (queue, offline_only, playlist_id) = {
|
||||
let Ok(q) = CURRENT_QUEUE.lock() else { return };
|
||||
let meta = CURRENT_META
|
||||
.lock()
|
||||
.ok()
|
||||
.and_then(|m| m.clone());
|
||||
let meta = CURRENT_META.lock().ok().and_then(|m| m.clone());
|
||||
let offline_only = meta.as_ref().map(|(_, o)| *o).unwrap_or(false);
|
||||
let playlist_id = meta.map(|(id, _)| id).unwrap_or_default();
|
||||
(q.clone(), offline_only, playlist_id)
|
||||
@@ -1443,7 +1426,11 @@ pub async fn reorder_row(runtime: &Runtime, from_row: usize, to_slot: usize) {
|
||||
if from_row >= ids.len() || to_slot > ids.len() {
|
||||
return;
|
||||
}
|
||||
let anchor = if to_slot > from_row { to_slot - 1 } else { to_slot };
|
||||
let anchor = if to_slot > from_row {
|
||||
to_slot - 1
|
||||
} else {
|
||||
to_slot
|
||||
};
|
||||
if anchor == from_row {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -20,7 +20,9 @@ use serde::Serialize;
|
||||
|
||||
use qbz_source::SourceId;
|
||||
|
||||
use crate::local_rows::{art_token, basename, folder_key, map_track, FolderDetail, SubfolderRow, TrackRow, TreeNode};
|
||||
use crate::local_rows::{
|
||||
art_token, basename, folder_key, map_track, FolderDetail, SubfolderRow, TrackRow, TreeNode,
|
||||
};
|
||||
use crate::local_state::{state, with_art, with_db};
|
||||
|
||||
/// `list_folder_children` + the reference's on-disk cover fallback for the
|
||||
@@ -153,7 +155,12 @@ pub fn tree_collapse(path: &str) -> Vec<TreeNode> {
|
||||
/// preload).
|
||||
pub fn tree_expand_blocking(path: &str) -> Vec<TreeNode> {
|
||||
let children = folder_children_with_covers(path);
|
||||
let nodes = state(|s| s.tree.iter().position(|n| n.path == path).map(|pos| (pos, s.tree[pos].depth)));
|
||||
let nodes = state(|s| {
|
||||
s.tree
|
||||
.iter()
|
||||
.position(|n| n.path == path)
|
||||
.map(|pos| (pos, s.tree[pos].depth))
|
||||
});
|
||||
let Some((pos, depth)) = nodes else {
|
||||
return state(|s| s.tree.clone());
|
||||
};
|
||||
@@ -204,7 +211,9 @@ fn visible_nodes() -> Vec<TreeNode> {
|
||||
.iter()
|
||||
.filter(|n| {
|
||||
n.segment.to_lowercase().contains(q)
|
||||
|| matches.iter().any(|m| m.starts_with(&format!("{}/", n.path)))
|
||||
|| matches
|
||||
.iter()
|
||||
.any(|m| m.starts_with(&format!("{}/", n.path)))
|
||||
})
|
||||
.cloned()
|
||||
.collect()
|
||||
@@ -413,10 +422,10 @@ pub fn load_folder_detail_blocking(path: &str) -> FolderDetail {
|
||||
let key = folder_key(path);
|
||||
if let Some(a) = artwork.as_ref().filter(|a| !a.is_empty()) {
|
||||
// The folder tree is `library.db`: every cover here is a
|
||||
// local file.
|
||||
if let Some(t) = art_token(Some("local"), a) {
|
||||
art.insert(key.clone(), t);
|
||||
}
|
||||
// local file.
|
||||
if let Some(t) = art_token(Some("local"), a) {
|
||||
art.insert(key.clone(), t);
|
||||
}
|
||||
}
|
||||
Some(SubfolderRow {
|
||||
path: path.clone(),
|
||||
|
||||
@@ -126,7 +126,10 @@ fn snapshot_doc() -> Doc {
|
||||
auto_tail: AUTO_TAIL.load(Ordering::SeqCst),
|
||||
uploading: UPLOADING.load(Ordering::SeqCst),
|
||||
copied: COPIED.load(Ordering::SeqCst),
|
||||
uploaded_url: UPLOADED_URL.lock().unwrap_or_else(|e| e.into_inner()).clone(),
|
||||
uploaded_url: UPLOADED_URL
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner())
|
||||
.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -292,7 +295,10 @@ pub async fn upload() {
|
||||
}
|
||||
|
||||
pub fn copy_url() {
|
||||
let url = UPLOADED_URL.lock().unwrap_or_else(|e| e.into_inner()).clone();
|
||||
let url = UPLOADED_URL
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner())
|
||||
.clone();
|
||||
if url.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -100,7 +100,6 @@ pub mod qbz_lyrics_bridge {
|
||||
// Inline notice line (this shell has no toast host): translation
|
||||
// failures and the copy confirmation land here. "" = hidden.
|
||||
#[qproperty(QString, notice)]
|
||||
|
||||
// --- Display prefs (Slint LyricsState S5 block) -------------------
|
||||
// Persisted per user in <data_dir>/qbz/users/<id>/lyrics_prefs.json —
|
||||
// the SAME file and field values the Slint build writes, so a user
|
||||
@@ -120,7 +119,6 @@ pub mod qbz_lyrics_bridge {
|
||||
// Active (karaoke fill) color: false = follow the theme accent.
|
||||
#[qproperty(bool, use_custom_color)]
|
||||
#[qproperty(QString, custom_color)]
|
||||
|
||||
type QbzLyrics = super::QbzLyricsRust;
|
||||
|
||||
/// Registers this object's Qt-thread hop and seeds the persisted
|
||||
@@ -355,7 +353,9 @@ impl qbz_lyrics_bridge::QbzLyrics {
|
||||
// with the new language (the Slint build reaches the same result via
|
||||
// the next track change; here it is immediate).
|
||||
let language_index = prefs.translation_language_index();
|
||||
if translation_enabled() && language_index != LAST_LANGUAGE.swap(language_index, Ordering::SeqCst) {
|
||||
if translation_enabled()
|
||||
&& language_index != LAST_LANGUAGE.swap(language_index, Ordering::SeqCst)
|
||||
{
|
||||
self.as_mut().set_translation_busy(true);
|
||||
let pref = prefs.translation_language.clone();
|
||||
crate::spawn(async move {
|
||||
@@ -740,7 +740,8 @@ fn publish(doc: LyricsDocJson, translation_available: bool, show_translation: bo
|
||||
// by the shell bridge; other surfaces may still bind it).
|
||||
let shell_json = json.clone();
|
||||
crate::shell_bridge::ui(move |mut b| {
|
||||
b.as_mut().set_lyrics_json(QString::from(shell_json.as_str()));
|
||||
b.as_mut()
|
||||
.set_lyrics_json(QString::from(shell_json.as_str()));
|
||||
});
|
||||
ui(move |mut b| {
|
||||
b.as_mut().set_doc_json(QString::from(json.as_str()));
|
||||
@@ -1061,4 +1062,3 @@ async fn enable_translation(pref: String) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -175,7 +175,11 @@ pub(crate) fn apply_and_center() -> bool {
|
||||
.unwrap_or(false);
|
||||
let original = container.frame();
|
||||
let mut frame = original;
|
||||
frame.origin.y += if parent_flipped { move_down } else { -move_down };
|
||||
frame.origin.y += if parent_flipped {
|
||||
move_down
|
||||
} else {
|
||||
-move_down
|
||||
};
|
||||
unsafe { container.setFrame(frame) };
|
||||
|
||||
// Re-measure: if the shift landed FURTHER from target than where it
|
||||
|
||||
@@ -72,7 +72,6 @@ pub mod qbz_mini {
|
||||
// binding and the first frame is already too late for a post-boot push.
|
||||
#[qproperty(f32, mini_width)]
|
||||
#[qproperty(f32, mini_height)]
|
||||
|
||||
type QbzMini = super::QbzMiniRust;
|
||||
|
||||
/// Registers this object's Qt-thread hop (Main.qml boots EVERY domain
|
||||
@@ -101,7 +100,6 @@ pub mod qbz_mini {
|
||||
#[qinvokable]
|
||||
fn toggle_background_blur(self: Pin<&mut QbzMini>);
|
||||
|
||||
|
||||
/// Persist the expanded window size (contract A-14). Qt has no
|
||||
/// `WindowEvent::Resized`, so `MiniWindow.qml`'s 400 ms debounce calls
|
||||
/// in. The §4.7 guards live in `mini_qt::geometry_to_persist` — the
|
||||
@@ -153,12 +151,7 @@ pub mod qbz_mini {
|
||||
/// `text_input_focused` argument** — the mini has no text fields, and
|
||||
/// `dispatch_mini` has no text-input guard either. `true` = consumed.
|
||||
#[qinvokable]
|
||||
fn key_pressed(
|
||||
self: Pin<&mut QbzMini>,
|
||||
key: i32,
|
||||
modifiers: i32,
|
||||
text: QString,
|
||||
) -> bool;
|
||||
fn key_pressed(self: Pin<&mut QbzMini>, key: i32, modifiers: i32, text: QString) -> bool;
|
||||
}
|
||||
|
||||
// The custom WRITE targets. NOT qinvokables — QML reaches them by writing
|
||||
@@ -386,7 +379,6 @@ impl qbz_mini::QbzMini {
|
||||
crate::mini_qt::save_background_blur(value);
|
||||
}
|
||||
|
||||
|
||||
/// Contract A-14 — the debounced resize persist.
|
||||
///
|
||||
/// Three things happen here and the ORDER matters. The §4.7 guards decide
|
||||
@@ -484,8 +476,8 @@ impl qbz_mini::QbzMini {
|
||||
crate::spawn(async move {
|
||||
if let Some(svc) = crate::qconnect_qt::service() {
|
||||
match svc.play_remote_renderer_track_if_active(track_id).await {
|
||||
Ok(true) => return, // handled by the peer
|
||||
Ok(false) => {} // not applicable -> local path
|
||||
Ok(true) => return, // handled by the peer
|
||||
Ok(false) => {} // not applicable -> local path
|
||||
Err(e) => {
|
||||
log::warn!("[qbz-qt] mini: queue play remote handoff: {e}");
|
||||
return; // connected but errored -> do NOT play locally
|
||||
@@ -529,12 +521,7 @@ impl qbz_mini::QbzMini {
|
||||
/// reads the user's ACTIVE bindings, so a rebound `mini.queue` works here
|
||||
/// exactly as the reference's `dispatch_mini` intends (`keybindings.rs:631`).
|
||||
/// A matched action consumes (`:647`); anything else propagates (`:645`).
|
||||
pub fn key_pressed(
|
||||
self: Pin<&mut Self>,
|
||||
key: i32,
|
||||
modifiers: i32,
|
||||
text: QString,
|
||||
) -> bool {
|
||||
pub fn key_pressed(self: Pin<&mut Self>, key: i32, modifiers: i32, text: QString) -> bool {
|
||||
let text = text.to_string();
|
||||
let overrides = crate::hotkeys_qt::load_overrides();
|
||||
let Some(action) =
|
||||
|
||||
@@ -230,7 +230,6 @@ pub(crate) fn initial_background_blur() -> bool {
|
||||
crate::settings_qt::pref_bool(KEY_BACKGROUND_BLUR, false)
|
||||
}
|
||||
|
||||
|
||||
pub(crate) fn initial_width() -> f32 {
|
||||
crate::settings_qt::pref_f32(KEY_WIDTH, DEFAULT_WIDTH)
|
||||
}
|
||||
@@ -320,12 +319,11 @@ mod tests {
|
||||
let upcoming: Vec<QueueTrack> = (1..=60)
|
||||
.map(|i| track(100 + i, &format!("up-{i}"), None, 187, false))
|
||||
.collect();
|
||||
let doc: serde_json::Value =
|
||||
serde_json::from_str(&mini_queue_doc(&state(
|
||||
Some(track(7, "Now", Some("Remastered"), 187, true)),
|
||||
upcoming.clone(),
|
||||
)))
|
||||
.unwrap();
|
||||
let doc: serde_json::Value = serde_json::from_str(&mini_queue_doc(&state(
|
||||
Some(track(7, "Now", Some("Remastered"), 187, true)),
|
||||
upcoming.clone(),
|
||||
)))
|
||||
.unwrap();
|
||||
|
||||
let rows = doc["rows"].as_array().unwrap();
|
||||
assert_eq!(rows.len(), 61);
|
||||
|
||||
@@ -56,7 +56,6 @@ pub mod qbz_musician_bridge {
|
||||
/// (resolving the same musician twice), the same trick
|
||||
/// `QbzSession.trRev` plays.
|
||||
#[qproperty(i32, musician_rev)]
|
||||
|
||||
type QbzMusician = super::QbzMusicianRust;
|
||||
|
||||
/// Registers this object's Qt-thread hop (Main.qml boots EVERY domain
|
||||
|
||||
@@ -445,7 +445,9 @@ fn store_bands(mbid: &str, bands: &[BandRow]) {
|
||||
/// air for the same MBID.
|
||||
fn claim_inflight(mbid: &str) -> bool {
|
||||
let mut guard = BANDS_INFLIGHT.lock().unwrap();
|
||||
guard.get_or_insert_with(HashSet::new).insert(mbid.to_string())
|
||||
guard
|
||||
.get_or_insert_with(HashSet::new)
|
||||
.insert(mbid.to_string())
|
||||
}
|
||||
|
||||
fn release_inflight(mbid: &str) {
|
||||
@@ -735,9 +737,12 @@ pub fn open_band(mbid: String) {
|
||||
// since when it is open it is the one the user is clicking in.
|
||||
let name = {
|
||||
let guard = MODAL.lock().unwrap();
|
||||
guard
|
||||
.as_ref()
|
||||
.and_then(|m| m.bands.iter().find(|b| b.mbid == mbid).map(|b| b.name.clone()))
|
||||
guard.as_ref().and_then(|m| {
|
||||
m.bands
|
||||
.iter()
|
||||
.find(|b| b.mbid == mbid)
|
||||
.map(|b| b.name.clone())
|
||||
})
|
||||
}
|
||||
.or_else(|| {
|
||||
with_state(|s| {
|
||||
|
||||
@@ -43,7 +43,6 @@ pub mod qbz_myqbz_add_bridge {
|
||||
// ONE JSON document (myqbz_add_qt.rs AddDoc: open/loading flags, header
|
||||
// strings, the create sub-panel and the collection rows).
|
||||
#[qproperty(QString, add_json)]
|
||||
|
||||
type QbzMyQbzAdd = super::QbzMyQbzAddRust;
|
||||
|
||||
/// Registers this object's Qt-thread hop (Main.qml boots EVERY domain
|
||||
|
||||
@@ -75,10 +75,7 @@ static PENDING: LazyLock<Mutex<Vec<AddItem>>> = LazyLock::new(|| Mutex::new(Vec:
|
||||
/// Snapshot of the pending items (a clone — the store is cleared on close, not
|
||||
/// on read, so a failed write can be retried from the same open modal).
|
||||
fn pending_snapshot() -> Vec<AddItem> {
|
||||
PENDING
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner())
|
||||
.clone()
|
||||
PENDING.lock().unwrap_or_else(|e| e.into_inner()).clone()
|
||||
}
|
||||
|
||||
fn item_type_from_str(s: &str) -> ItemType {
|
||||
@@ -381,8 +378,8 @@ pub struct LoadedRow {
|
||||
pub(crate) fn load_rows(accepts: Accepts, items: &[AddItem]) -> Vec<LoadedRow> {
|
||||
crate::library_db_qt::with_db(false, |db| {
|
||||
Ok(db.with_connection(|conn| {
|
||||
let mut cols: Vec<MixtapeCollection> =
|
||||
qbz_mixtape::repo::list_collections(conn, None).unwrap_or_else(|e| {
|
||||
let mut cols: Vec<MixtapeCollection> = qbz_mixtape::repo::list_collections(conn, None)
|
||||
.unwrap_or_else(|e| {
|
||||
log::warn!("[qbz-qt] myqbz_add list_collections failed: {e}");
|
||||
Vec::new()
|
||||
});
|
||||
@@ -612,7 +609,9 @@ pub(crate) fn show_create(kind: &str) {
|
||||
accepts.create_fallback()
|
||||
};
|
||||
let Some(resolved) = resolved else {
|
||||
log::warn!("[qbz-qt] myqbz_add: create '{kind}' refused — nothing is creatable for this payload");
|
||||
log::warn!(
|
||||
"[qbz-qt] myqbz_add: create '{kind}' refused — nothing is creatable for this payload"
|
||||
);
|
||||
return;
|
||||
};
|
||||
if resolved != wanted {
|
||||
|
||||
@@ -62,7 +62,6 @@ pub mod qbz_myqbz_bridge {
|
||||
#[qproperty(QString, mix_json)]
|
||||
// --- Nav branding (label + custom icon), seeded at construction -----
|
||||
#[qproperty(QString, branding_json)]
|
||||
|
||||
type QbzMyQbz = super::QbzMyQbzRust;
|
||||
|
||||
/// Registers this object's Qt-thread hop (Main.qml boots EVERY domain
|
||||
|
||||
@@ -155,7 +155,8 @@ pub fn fetch_local_and_plex(artist_name: &str) -> Vec<Candidate> {
|
||||
// (qbz/src/local_library.rs). This port has no equivalent and every
|
||||
// other call site hardcodes `false` (local_albums.rs:42,70,94,173);
|
||||
// the builder must see the SAME set the Albums tab shows.
|
||||
/* exclude_network_folders */ false,
|
||||
/* exclude_network_folders */
|
||||
false,
|
||||
plex_path.as_deref(),
|
||||
remote_path.as_deref(),
|
||||
&remote_words,
|
||||
@@ -234,7 +235,11 @@ mod tests {
|
||||
#[test]
|
||||
fn matches_artist_is_case_insensitive_over_both_columns() {
|
||||
assert!(matches_artist("Radiohead", "", "radiohead"));
|
||||
assert!(matches_artist("Various", "Foo, Radiohead , Bar", "RADIOHEAD"));
|
||||
assert!(matches_artist(
|
||||
"Various",
|
||||
"Foo, Radiohead , Bar",
|
||||
"RADIOHEAD"
|
||||
));
|
||||
assert!(!matches_artist("Radioheads", "", "radiohead"));
|
||||
assert!(!matches_artist("Radiohead", "", " "));
|
||||
}
|
||||
|
||||
@@ -247,8 +247,7 @@ pub(crate) fn normalize_title(title: &str) -> String {
|
||||
let ch = bytes[i] as char;
|
||||
if ch == '(' || ch == '[' {
|
||||
let inner = lower[i + 1..].trim_start();
|
||||
let starts_kw =
|
||||
KEYWORDS.iter().any(|k| inner.starts_with(k)) || starts_with_nth(inner);
|
||||
let starts_kw = KEYWORDS.iter().any(|k| inner.starts_with(k)) || starts_with_nth(inner);
|
||||
if starts_kw {
|
||||
cut = i;
|
||||
break;
|
||||
@@ -294,8 +293,7 @@ pub(crate) fn classify_release(
|
||||
let l = title.to_lowercase();
|
||||
let rt = qobuz_release_type.unwrap_or("");
|
||||
let gt = qobuz_group_type.unwrap_or("");
|
||||
let type_is =
|
||||
|needle: &str| rt.eq_ignore_ascii_case(needle) || gt.eq_ignore_ascii_case(needle);
|
||||
let type_is = |needle: &str| rt.eq_ignore_ascii_case(needle) || gt.eq_ignore_ascii_case(needle);
|
||||
|
||||
if rt.to_lowercase().contains("compilation")
|
||||
|| gt.to_lowercase().contains("compilation")
|
||||
@@ -819,24 +817,20 @@ pub fn open(artist_id: String) {
|
||||
Ok((qobuz, artist_name, avatar_url)) => {
|
||||
// 2. Local + Plex by the resolved name (sequential, mandatory).
|
||||
let name_for_local = artist_name.clone();
|
||||
let mut local = tokio::task::spawn_blocking(move || {
|
||||
fetch_local_and_plex(&name_for_local)
|
||||
})
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
let mut local =
|
||||
tokio::task::spawn_blocking(move || fetch_local_and_plex(&name_for_local))
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
|
||||
// 2b. Plex cold-start retry: if Plex is enabled and we got
|
||||
// nothing from the Plex source, wait 2s and refetch once.
|
||||
if crate::local_plex::is_enabled()
|
||||
&& !local.iter().any(|c| c.source == "plex")
|
||||
{
|
||||
if crate::local_plex::is_enabled() && !local.iter().any(|c| c.source == "plex") {
|
||||
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
|
||||
let name_retry = artist_name.clone();
|
||||
let retried = tokio::task::spawn_blocking(move || {
|
||||
fetch_local_and_plex(&name_retry)
|
||||
})
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
let retried =
|
||||
tokio::task::spawn_blocking(move || fetch_local_and_plex(&name_retry))
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
if retried.iter().any(|c| c.source == "plex") {
|
||||
local = retried;
|
||||
}
|
||||
@@ -850,8 +844,7 @@ pub fn open(artist_id: String) {
|
||||
|
||||
// 4. Fetch the avatar if it was not already cached, then ONE
|
||||
// republish (T17 — never one per image).
|
||||
if !avatar_url.is_empty()
|
||||
&& crate::artwork_qt::cached_path(&avatar_url).is_empty()
|
||||
if !avatar_url.is_empty() && crate::artwork_qt::cached_path(&avatar_url).is_empty()
|
||||
{
|
||||
crate::artwork_qt::download_missing(vec![avatar_url.clone()]).await;
|
||||
let path = crate::artwork_qt::cached_path(&avatar_url);
|
||||
@@ -1114,12 +1107,18 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn normalize_title_strips_edition_suffixes() {
|
||||
assert_eq!(normalize_title("OK Computer (Deluxe Edition)"), "ok computer");
|
||||
assert_eq!(
|
||||
normalize_title("OK Computer (Deluxe Edition)"),
|
||||
"ok computer"
|
||||
);
|
||||
assert_eq!(normalize_title("Nevermind [Remastered]"), "nevermind");
|
||||
assert_eq!(normalize_title("Grace (20th Anniversary)"), "grace");
|
||||
assert_eq!(normalize_title("Kid A (10th Anniversary)"), "kid a");
|
||||
// A parenthetical that is NOT an edition marker is preserved.
|
||||
assert_eq!(normalize_title("Song (Live at Wembley)"), "song (live at wembley)");
|
||||
assert_eq!(
|
||||
normalize_title("Song (Live at Wembley)"),
|
||||
"song (live at wembley)"
|
||||
);
|
||||
}
|
||||
|
||||
/// The sharp edges of `normalize_title` that decide which releases land in
|
||||
@@ -1183,15 +1182,36 @@ mod tests {
|
||||
classify_release("Greatest Hits Live", Some(20), Some("album"), None, true),
|
||||
"compilation"
|
||||
);
|
||||
assert_eq!(classify_release("Unplugged", Some(12), None, None, false), "live");
|
||||
assert_eq!(classify_release("Some EP", Some(12), None, None, false), "ep");
|
||||
assert_eq!(classify_release("Whatever", Some(2), None, None, false), "single");
|
||||
assert_eq!(classify_release("Whatever", Some(5), None, None, false), "ep");
|
||||
assert_eq!(classify_release("Whatever", Some(12), None, None, false), "album");
|
||||
assert_eq!(
|
||||
classify_release("Unplugged", Some(12), None, None, false),
|
||||
"live"
|
||||
);
|
||||
assert_eq!(
|
||||
classify_release("Some EP", Some(12), None, None, false),
|
||||
"ep"
|
||||
);
|
||||
assert_eq!(
|
||||
classify_release("Whatever", Some(2), None, None, false),
|
||||
"single"
|
||||
);
|
||||
assert_eq!(
|
||||
classify_release("Whatever", Some(5), None, None, false),
|
||||
"ep"
|
||||
);
|
||||
assert_eq!(
|
||||
classify_release("Whatever", Some(12), None, None, false),
|
||||
"album"
|
||||
);
|
||||
// "epic" must NOT match the `ep` word test.
|
||||
assert_eq!(classify_release("Epic", Some(12), None, None, false), "album");
|
||||
assert_eq!(
|
||||
classify_release("Epic", Some(12), None, None, false),
|
||||
"album"
|
||||
);
|
||||
// type_is matches either the item's or the group's type.
|
||||
assert_eq!(classify_release("X", Some(12), None, Some("single"), false), "single");
|
||||
assert_eq!(
|
||||
classify_release("X", Some(12), None, Some("single"), false),
|
||||
"single"
|
||||
);
|
||||
}
|
||||
|
||||
/// The arms `classify_precedence` does not reach. Each one changes the TYPE
|
||||
@@ -1210,10 +1230,19 @@ mod tests {
|
||||
"compilation"
|
||||
);
|
||||
// live: the other two words, and punctuation counts as a boundary.
|
||||
assert_eq!(classify_release("The Concert", Some(20), None, None, false), "live");
|
||||
assert_eq!(classify_release("Live!", Some(20), None, None, false), "live");
|
||||
assert_eq!(
|
||||
classify_release("The Concert", Some(20), None, None, false),
|
||||
"live"
|
||||
);
|
||||
assert_eq!(
|
||||
classify_release("Live!", Some(20), None, None, false),
|
||||
"live"
|
||||
);
|
||||
// "alive" must NOT match the `live` word test.
|
||||
assert_eq!(classify_release("Alive", Some(20), None, None, false), "album");
|
||||
assert_eq!(
|
||||
classify_release("Alive", Some(20), None, None, false),
|
||||
"album"
|
||||
);
|
||||
// The GROUP's type beats the item's for live (type_is is an OR).
|
||||
assert_eq!(
|
||||
classify_release("X", Some(20), Some("album"), Some("live"), false),
|
||||
@@ -1242,8 +1271,14 @@ mod tests {
|
||||
#[test]
|
||||
fn quality_score_ordering() {
|
||||
// bit depth dominates, then rate, then format bonus.
|
||||
assert!(quality_score(Some(24), Some(96.0), "FLAC") > quality_score(Some(16), Some(192.0), "FLAC"));
|
||||
assert!(quality_score(Some(16), Some(44.1), "FLAC") > quality_score(Some(16), Some(44.1), "MP3"));
|
||||
assert!(
|
||||
quality_score(Some(24), Some(96.0), "FLAC")
|
||||
> quality_score(Some(16), Some(192.0), "FLAC")
|
||||
);
|
||||
assert!(
|
||||
quality_score(Some(16), Some(44.1), "FLAC")
|
||||
> quality_score(Some(16), Some(44.1), "MP3")
|
||||
);
|
||||
// Unknown format sits between mp3 (0) and flac (1000).
|
||||
let unknown = quality_score(Some(16), Some(44.1), "OGG");
|
||||
assert!(unknown > quality_score(Some(16), Some(44.1), "MP3"));
|
||||
@@ -1267,7 +1302,10 @@ mod tests {
|
||||
// "fix" in the fetchers is not mistaken for a change here.
|
||||
assert_eq!(quality_score(Some(16), Some(44100.0), "FLAC"), 204_101_000);
|
||||
// AIFF is lossless but not in the bonus list, so it scores BELOW FLAC.
|
||||
assert!(quality_score(Some(24), Some(96.0), "AIFF") < quality_score(Some(24), Some(96.0), "FLAC"));
|
||||
assert!(
|
||||
quality_score(Some(24), Some(96.0), "AIFF")
|
||||
< quality_score(Some(24), Some(96.0), "FLAC")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1303,7 +1341,10 @@ mod tests {
|
||||
);
|
||||
let by_title = ordered_groups(&groups, "title");
|
||||
assert_eq!(
|
||||
by_title.iter().map(|g| g.title.as_str()).collect::<Vec<_>>(),
|
||||
by_title
|
||||
.iter()
|
||||
.map(|g| g.title.as_str())
|
||||
.collect::<Vec<_>>(),
|
||||
vec!["Aye", "Bee", "Cee"]
|
||||
);
|
||||
let manual = ordered_groups(&groups, "manual");
|
||||
@@ -1422,7 +1463,10 @@ mod tests {
|
||||
|
||||
// Compilation-only selection: the header box is EMPTY (neither all nor
|
||||
// some) but the footer still counts 1 and Create is enabled.
|
||||
s.checked.get_mut(&comp_key).unwrap().push(comp_primary.clone());
|
||||
s.checked
|
||||
.get_mut(&comp_key)
|
||||
.unwrap()
|
||||
.push(comp_primary.clone());
|
||||
s.checked.get_mut(&c_key).unwrap().clear();
|
||||
assert!(!all_primaries_checked(&s));
|
||||
assert!(!some_primaries_checked(&s));
|
||||
@@ -1470,7 +1514,10 @@ mod tests {
|
||||
toggle_all();
|
||||
session(|s| {
|
||||
assert!(!is_checked(s, &a_key, &a_primary));
|
||||
assert!(is_checked(s, &comp_key, &comp_primary), "compilation untouched");
|
||||
assert!(
|
||||
is_checked(s, &comp_key, &comp_primary),
|
||||
"compilation untouched"
|
||||
);
|
||||
assert!(is_checked(s, &a_key, "qobuz|alt"), "alternate untouched");
|
||||
assert_eq!(selected_count(s), 2);
|
||||
});
|
||||
|
||||
@@ -65,12 +65,10 @@ fn set_custom_artwork(id: &str, path: Option<&str>) -> bool {
|
||||
let id = id.to_string();
|
||||
let path = path.map(|p| p.to_string());
|
||||
crate::library_db_qt::with_db(true, move |db| {
|
||||
db.with_connection(|conn| {
|
||||
qbz_mixtape::repo::set_custom_artwork(conn, &id, path.as_deref())
|
||||
})
|
||||
.map_err(|e| {
|
||||
qbz_library::LibraryError::Database(format!("set_custom_artwork failed: {e}"))
|
||||
})
|
||||
db.with_connection(|conn| qbz_mixtape::repo::set_custom_artwork(conn, &id, path.as_deref()))
|
||||
.map_err(|e| {
|
||||
qbz_library::LibraryError::Database(format!("set_custom_artwork failed: {e}"))
|
||||
})
|
||||
})
|
||||
.is_some()
|
||||
}
|
||||
|
||||
@@ -500,8 +500,7 @@ fn publish_rows(collection_id: &str, patches: Vec<RowPatch>) {
|
||||
})
|
||||
.unwrap_or_else(|_| "{}".into());
|
||||
crate::myqbz_bridge::ui(move |mut b| {
|
||||
b.as_mut()
|
||||
.detail_rows_patched(QString::from(json.as_str()));
|
||||
b.as_mut().detail_rows_patched(QString::from(json.as_str()));
|
||||
});
|
||||
}
|
||||
|
||||
@@ -918,10 +917,7 @@ fn track_to_item(track: &QueueTrack) -> InlineTrackRow {
|
||||
id: track.id.to_string(),
|
||||
title: inline_track_title(track),
|
||||
artist: track.artist.clone(),
|
||||
artist_id: track
|
||||
.artist_id
|
||||
.map(|id| id.to_string())
|
||||
.unwrap_or_default(),
|
||||
artist_id: track.artist_id.map(|id| id.to_string()).unwrap_or_default(),
|
||||
album: String::new(),
|
||||
album_id: track.album_id.clone().unwrap_or_default(),
|
||||
duration: track_duration_str(track.duration_secs),
|
||||
@@ -1036,10 +1032,8 @@ fn rebuild(d: &mut DetailDoc) {
|
||||
|
||||
let source_count = i32::from(d.src_qobuz) + i32::from(d.src_plex) + i32::from(d.src_local);
|
||||
d.filter_count = source_count + i32::from(d.type_filter != "all");
|
||||
d.has_any_filter = d.type_filter != "all"
|
||||
|| source_count > 0
|
||||
|| d.sort != "position"
|
||||
|| d.sort_dir == "desc";
|
||||
d.has_any_filter =
|
||||
d.type_filter != "all" || source_count > 0 || d.sort != "position" || d.sort_dir == "desc";
|
||||
}
|
||||
|
||||
/// Re-derive, resolve the newly-visible rows' artwork, publish, and — while in
|
||||
@@ -1476,10 +1470,7 @@ fn resolve_from_tracks(item: &MixtapeCollectionItem, tracks: &[QueueTrack]) -> R
|
||||
/// same album twice hydrates both copies rather than only the first.
|
||||
fn apply_resolved(item: &MixtapeCollectionItem, resolved: ResolvedItem, collection_id: &str) {
|
||||
let key = cache_key(source_str(item.source), &item.source_item_id);
|
||||
RESOLVE_CACHE
|
||||
.lock()
|
||||
.unwrap()
|
||||
.insert(key, resolved.clone());
|
||||
RESOLVE_CACHE.lock().unwrap().insert(key, resolved.clone());
|
||||
|
||||
let stored_art_empty = item
|
||||
.artwork_url
|
||||
@@ -2242,7 +2233,12 @@ pub(crate) fn bulk_action(id: String) {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn item(position: i32, title: &str, kind: ItemType, source: AlbumSource) -> MixtapeCollectionItem {
|
||||
fn item(
|
||||
position: i32,
|
||||
title: &str,
|
||||
kind: ItemType,
|
||||
source: AlbumSource,
|
||||
) -> MixtapeCollectionItem {
|
||||
MixtapeCollectionItem {
|
||||
collection_id: "c".into(),
|
||||
position,
|
||||
|
||||
@@ -256,10 +256,8 @@ pub(crate) fn toggle_play_mode() {
|
||||
let write_id = id.clone();
|
||||
let result = tokio::task::spawn_blocking(move || {
|
||||
write_repo(|db| {
|
||||
db.with_connection(|conn| {
|
||||
qbz_mixtape::repo::set_play_mode(conn, &write_id, next)
|
||||
})
|
||||
.map_err(|e| e.to_string())
|
||||
db.with_connection(|conn| qbz_mixtape::repo::set_play_mode(conn, &write_id, next))
|
||||
.map_err(|e| e.to_string())
|
||||
})
|
||||
})
|
||||
.await
|
||||
|
||||
@@ -200,9 +200,7 @@ pub(crate) fn open() {
|
||||
let unique = qbz_mixtape::shuffle::unique_track_count(&tracks) as i32;
|
||||
if unique <= 0 {
|
||||
close();
|
||||
crate::toast_qt::error(qbz_i18n::t(
|
||||
"This collection resolved to 0 playable tracks",
|
||||
));
|
||||
crate::toast_qt::error(qbz_i18n::t("This collection resolved to 0 playable tracks"));
|
||||
} else {
|
||||
apply_options(unique);
|
||||
}
|
||||
|
||||
@@ -334,15 +334,12 @@ pub(crate) async fn play_all_tracks(
|
||||
tracks: Vec<QueueTrack>,
|
||||
) {
|
||||
if tracks.is_empty() {
|
||||
crate::toast_qt::error(qbz_i18n::t(
|
||||
"This collection resolved to 0 playable tracks",
|
||||
));
|
||||
crate::toast_qt::error(qbz_i18n::t("This collection resolved to 0 playable tracks"));
|
||||
return;
|
||||
}
|
||||
// Context `None` — `derive_context` infers album-or-artist (T16).
|
||||
// F1: the anchor comes back from the seam, never from `tracks[0]`.
|
||||
let Some(anchor) =
|
||||
crate::playback_qt::set_queue_stamped(runtime, tracks, Some(0), None).await
|
||||
let Some(anchor) = crate::playback_qt::set_queue_stamped(runtime, tracks, Some(0), None).await
|
||||
else {
|
||||
// The seam already toasted the count and left the previous queue alone.
|
||||
// `set_queue_source_collection` and `touch_play` below are bookkeeping
|
||||
@@ -361,12 +358,7 @@ pub(crate) async fn play_all_tracks(
|
||||
// regardless of where the audio plays (the Slint collection-play flow
|
||||
// records it on the routed path too).
|
||||
if !crate::playback_qt::route_play_to_peer(runtime, first_id).await {
|
||||
if let Err(e) = crate::playback_qt::play_resolved_offline_aware(
|
||||
runtime,
|
||||
first_id,
|
||||
0,
|
||||
)
|
||||
.await
|
||||
if let Err(e) = crate::playback_qt::play_resolved_offline_aware(runtime, first_id, 0).await
|
||||
{
|
||||
log::error!("[qbz-qt] myqbz_play: play_track {first_id} failed: {e}");
|
||||
}
|
||||
@@ -429,16 +421,16 @@ async fn persist_album_shuffle(collection_id: &str) {
|
||||
db.with_connection(|conn| {
|
||||
qbz_mixtape::repo::set_play_mode(conn, &write_id, CollectionPlayMode::AlbumShuffle)
|
||||
})
|
||||
.map_err(|e| {
|
||||
qbz_library::LibraryError::Database(format!("set_play_mode failed: {e}"))
|
||||
})
|
||||
.map_err(|e| qbz_library::LibraryError::Database(format!("set_play_mode failed: {e}")))
|
||||
})
|
||||
})
|
||||
.await;
|
||||
match persisted {
|
||||
Ok(Some(())) => {}
|
||||
Ok(None) => {
|
||||
log::warn!("[qbz-qt] myqbz_play: persist album_shuffle({collection_id}): db write failed");
|
||||
log::warn!(
|
||||
"[qbz-qt] myqbz_play: persist album_shuffle({collection_id}): db write failed"
|
||||
);
|
||||
return;
|
||||
}
|
||||
Err(e) => {
|
||||
@@ -542,12 +534,8 @@ pub(crate) fn item_action(source_item_id: String, action: String) {
|
||||
if crate::playback_qt::route_play_to_peer(&runtime, first_id).await {
|
||||
return;
|
||||
}
|
||||
if let Err(e) = crate::playback_qt::play_resolved_offline_aware(
|
||||
&runtime,
|
||||
first_id,
|
||||
0,
|
||||
)
|
||||
.await
|
||||
if let Err(e) =
|
||||
crate::playback_qt::play_resolved_offline_aware(&runtime, first_id, 0).await
|
||||
{
|
||||
log::error!("[qbz-qt] myqbz_play: play_track {first_id} failed: {e}");
|
||||
}
|
||||
@@ -713,11 +701,7 @@ fn bulk_enqueue_inner(items: Vec<MixtapeCollectionItem>, mode: BulkEnqueueMode)
|
||||
/// (`MyQbzDetailView.qml:628-630` sends it): the reference intercepts the same
|
||||
/// action string before the playback modes (`qbz/src/main.rs:6790-6811`) and
|
||||
/// opens the PARENT item with the parent's REAL type.
|
||||
pub(crate) fn play_inline_track(
|
||||
item_source_item_id: String,
|
||||
track_id: String,
|
||||
action: String,
|
||||
) {
|
||||
pub(crate) fn play_inline_track(item_source_item_id: String, track_id: String, action: String) {
|
||||
if action == "go-to-album" {
|
||||
// Route through the PARENT item, never the track's own ids, and with the
|
||||
// parent's real item_type — a playlist parent must reach the playlist
|
||||
@@ -780,12 +764,8 @@ pub(crate) fn play_inline_track(
|
||||
if crate::playback_qt::route_play_to_peer(&runtime, first_id).await {
|
||||
return;
|
||||
}
|
||||
if let Err(e) = crate::playback_qt::play_resolved_offline_aware(
|
||||
&runtime,
|
||||
first_id,
|
||||
0,
|
||||
)
|
||||
.await
|
||||
if let Err(e) =
|
||||
crate::playback_qt::play_resolved_offline_aware(&runtime, first_id, 0).await
|
||||
{
|
||||
log::error!("[qbz-qt] myqbz_play: play_track {first_id} failed: {e}");
|
||||
}
|
||||
@@ -802,8 +782,9 @@ pub(crate) fn play_inline_track(
|
||||
if crate::playback_qt::route_track_to_peer(&track, "next").await {
|
||||
return;
|
||||
}
|
||||
let added_castable =
|
||||
crate::playback_qt::batch_all_qconnect_castable(std::slice::from_ref(&track));
|
||||
let added_castable = crate::playback_qt::batch_all_qconnect_castable(
|
||||
std::slice::from_ref(&track),
|
||||
);
|
||||
runtime.core().add_track_next(track).await;
|
||||
crate::playback_qt::sync_qconnect_after_add(added_castable).await;
|
||||
}
|
||||
@@ -821,8 +802,9 @@ pub(crate) fn play_inline_track(
|
||||
if crate::playback_qt::route_track_to_peer(&track, "queue").await {
|
||||
return;
|
||||
}
|
||||
let added_castable =
|
||||
crate::playback_qt::batch_all_qconnect_castable(std::slice::from_ref(&track));
|
||||
let added_castable = crate::playback_qt::batch_all_qconnect_castable(
|
||||
std::slice::from_ref(&track),
|
||||
);
|
||||
runtime.core().add_tracks(vec![track]).await;
|
||||
crate::playback_qt::sync_qconnect_after_add(added_castable).await;
|
||||
}
|
||||
@@ -840,9 +822,18 @@ mod tests {
|
||||
#[test]
|
||||
fn row_mode_accepts_both_spellings() {
|
||||
assert!(matches!(RowMode::parse("play"), Some(RowMode::Play)));
|
||||
assert!(matches!(RowMode::parse("play_next"), Some(RowMode::PlayNext)));
|
||||
assert!(matches!(RowMode::parse("play-later"), Some(RowMode::PlayLater)));
|
||||
assert!(matches!(RowMode::parse("append"), Some(RowMode::AddToQueue)));
|
||||
assert!(matches!(
|
||||
RowMode::parse("play_next"),
|
||||
Some(RowMode::PlayNext)
|
||||
));
|
||||
assert!(matches!(
|
||||
RowMode::parse("play-later"),
|
||||
Some(RowMode::PlayLater)
|
||||
));
|
||||
assert!(matches!(
|
||||
RowMode::parse("append"),
|
||||
Some(RowMode::AddToQueue)
|
||||
));
|
||||
assert!(RowMode::parse("nonsense").is_none());
|
||||
}
|
||||
|
||||
|
||||
@@ -255,7 +255,10 @@ pub(crate) fn pick_icon() {
|
||||
crate::spawn(async move {
|
||||
let Some(file) = rfd::AsyncFileDialog::new()
|
||||
.set_title(&qbz_i18n::t_args("Choose a {} icon", &[DEFAULT_LABEL]))
|
||||
.add_filter(&qbz_i18n::t("Image"), &["svg", "png", "jpg", "jpeg", "webp"])
|
||||
.add_filter(
|
||||
&qbz_i18n::t("Image"),
|
||||
&["svg", "png", "jpg", "jpeg", "webp"],
|
||||
)
|
||||
.pick_file()
|
||||
.await
|
||||
else {
|
||||
@@ -642,7 +645,10 @@ mod tests {
|
||||
r#"{"a":["qobuz|1","local|/x",""],"b":{"nope":true},"c":["qobuz|2",7],"d":null}"#,
|
||||
)
|
||||
.expect("parses");
|
||||
assert_eq!(parse_open_rows_entry(doc.get("a")), vec!["qobuz|1", "local|/x"]);
|
||||
assert_eq!(
|
||||
parse_open_rows_entry(doc.get("a")),
|
||||
vec!["qobuz|1", "local|/x"]
|
||||
);
|
||||
assert!(parse_open_rows_entry(doc.get("b")).is_empty());
|
||||
assert_eq!(parse_open_rows_entry(doc.get("c")), vec!["qobuz|2"]);
|
||||
assert!(parse_open_rows_entry(doc.get("d")).is_empty());
|
||||
|
||||
@@ -223,7 +223,8 @@ fn publish(grid: Grid) {
|
||||
b.as_mut().set_mixtapes_json(QString::from(json.as_str()));
|
||||
}),
|
||||
Grid::Collections => crate::myqbz_bridge::ui(move |mut b| {
|
||||
b.as_mut().set_collections_json(QString::from(json.as_str()));
|
||||
b.as_mut()
|
||||
.set_collections_json(QString::from(json.as_str()));
|
||||
}),
|
||||
}
|
||||
}
|
||||
@@ -378,13 +379,7 @@ pub(crate) fn small_qobuz_url(url: &str, target: u32) -> String {
|
||||
return String::new();
|
||||
}
|
||||
const TOKENS: [&str; 8] = [
|
||||
"_50.jpg",
|
||||
"_100.jpg",
|
||||
"_150.jpg",
|
||||
"_230.jpg",
|
||||
"_300.jpg",
|
||||
"_600.jpg",
|
||||
"_max.jpg",
|
||||
"_50.jpg", "_100.jpg", "_150.jpg", "_230.jpg", "_300.jpg", "_600.jpg", "_max.jpg",
|
||||
"_org.jpg",
|
||||
];
|
||||
let lower = url.to_lowercase();
|
||||
@@ -745,9 +740,7 @@ pub(crate) fn grid_set_kind_filter(kind: &str) {
|
||||
"collection" | "artist_collection" => kind,
|
||||
_ => "all",
|
||||
};
|
||||
with_grid(Grid::Collections, |g| {
|
||||
g.doc.kind_filter = kind.to_string()
|
||||
});
|
||||
with_grid(Grid::Collections, |g| g.doc.kind_filter = kind.to_string());
|
||||
rebuild(Grid::Collections);
|
||||
publish(Grid::Collections);
|
||||
artwork_refresh(Grid::Collections);
|
||||
|
||||
@@ -463,7 +463,11 @@ pub fn clear_track() {
|
||||
pub fn set_cast_session(active: bool, protocol: &str, target: &str) {
|
||||
mutate(|m| {
|
||||
m.cast_active = active;
|
||||
m.cast_protocol = if active { protocol.to_string() } else { String::new() };
|
||||
m.cast_protocol = if active {
|
||||
protocol.to_string()
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
if active {
|
||||
m.cast_target = target.to_string();
|
||||
} else if !m.is_remote {
|
||||
@@ -595,7 +599,8 @@ pub fn set_artwork_path_large(path: String) {
|
||||
*last = path.clone();
|
||||
}
|
||||
crate::player_bridge::ui(move |mut b| {
|
||||
b.as_mut().set_np_artwork_path_large(QString::from(path.as_str()))
|
||||
b.as_mut()
|
||||
.set_np_artwork_path_large(QString::from(path.as_str()))
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -18,8 +18,8 @@
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use qbz_offline_cache::{CacheEvent, CacheEventSink, TrackCacheInfo};
|
||||
use qbz_offline_cache::OfflineCacheState;
|
||||
use qbz_offline_cache::{CacheEvent, CacheEventSink, TrackCacheInfo};
|
||||
|
||||
use crate::offline_qt;
|
||||
use crate::shell_bridge;
|
||||
@@ -31,11 +31,8 @@ use cxx_qt_lib::QString;
|
||||
fn push_status(track_id: u64, status: i32, progress: f64) {
|
||||
let id = track_id.to_string();
|
||||
shell_bridge::ui(move |mut b| {
|
||||
b.as_mut().track_cache_status_changed(
|
||||
QString::from(id.as_str()),
|
||||
status,
|
||||
progress,
|
||||
);
|
||||
b.as_mut()
|
||||
.track_cache_status_changed(QString::from(id.as_str()), status, progress);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -167,9 +164,9 @@ pub fn cache_track(id: u64) {
|
||||
let root = std::path::PathBuf::from(off.get_cache_path());
|
||||
if let Err(e) = qbz_offline_cache::maintenance::check_cache_limit(db, &root, limit) {
|
||||
log::warn!("[qbz-qt] cache limit reached: {e}");
|
||||
crate::toast_qt::error(
|
||||
qbz_i18n::t("Offline cache is full — free space or raise the limit"),
|
||||
);
|
||||
crate::toast_qt::error(qbz_i18n::t(
|
||||
"Offline cache is full — free space or raise the limit",
|
||||
));
|
||||
return;
|
||||
}
|
||||
if let Err(e) = db.insert_track(&info, &file_path_str) {
|
||||
@@ -214,9 +211,9 @@ pub fn cache_tracks_with_album(
|
||||
let root = std::path::PathBuf::from(off.get_cache_path());
|
||||
if let Err(e) = qbz_offline_cache::maintenance::check_cache_limit(db, &root, limit) {
|
||||
log::warn!("[qbz-qt] batch cache limit reached: {e}");
|
||||
crate::toast_qt::error(
|
||||
qbz_i18n::t("Offline cache is full — free space or raise the limit"),
|
||||
);
|
||||
crate::toast_qt::error(qbz_i18n::t(
|
||||
"Offline cache is full — free space or raise the limit",
|
||||
));
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -260,10 +257,7 @@ pub fn cache_album(album_id: String) {
|
||||
return;
|
||||
}
|
||||
};
|
||||
let tracks: Vec<qbz_models::Track> = album
|
||||
.tracks
|
||||
.map(|c| c.items)
|
||||
.unwrap_or_default();
|
||||
let tracks: Vec<qbz_models::Track> = album.tracks.map(|c| c.items).unwrap_or_default();
|
||||
if tracks.is_empty() {
|
||||
crate::toast_qt::error(qbz_i18n::t("This album has no playable tracks"));
|
||||
return;
|
||||
|
||||
@@ -100,7 +100,7 @@ fn apply_status(
|
||||
mut b: core::pin::Pin<&mut crate::session_bridge::qbz_session::QbzSession>,
|
||||
status: OfflineStatus,
|
||||
) {
|
||||
b.as_mut().set_offline(status.is_offline());
|
||||
b.as_mut().set_offline(status.is_offline());
|
||||
b.as_mut().set_offline_mode(match status.mode {
|
||||
OfflineMode::Online => 0,
|
||||
OfflineMode::RealOffline => 1,
|
||||
@@ -112,7 +112,8 @@ fn apply_status(
|
||||
Connectivity::Down => 2,
|
||||
});
|
||||
b.as_mut().set_captive_portal(status.captive_portal);
|
||||
b.as_mut().set_show_recovery_banner(status.show_recovery_banner());
|
||||
b.as_mut()
|
||||
.set_show_recovery_banner(status.show_recovery_banner());
|
||||
// The header badge's "Logged out" state needs the raw session flag —
|
||||
// show_recovery_banner() is false while connectivity is down.
|
||||
b.as_mut().set_offline_session(status.offline_session);
|
||||
|
||||
@@ -58,7 +58,6 @@ pub mod qbz_offline_manager_bridge {
|
||||
/// spinner — on the one screen whose whole job is to show you that you
|
||||
/// DO have downloads.
|
||||
#[qproperty(bool, manager_loading)]
|
||||
|
||||
type QbzOffline = super::QbzOfflineRust;
|
||||
|
||||
/// Registers this object's Qt-thread hop (Main.qml boots EVERY domain
|
||||
|
||||
@@ -126,7 +126,10 @@ fn cached_ids() -> &'static StdMutex<HashSet<u64>> {
|
||||
/// this set with persisted playlist membership without holding the mutex over
|
||||
/// a database read.
|
||||
pub(crate) fn cached_ids_set() -> HashSet<u64> {
|
||||
cached_ids().lock().map(|ids| ids.clone()).unwrap_or_default()
|
||||
cached_ids()
|
||||
.lock()
|
||||
.map(|ids| ids.clone())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// True if `track_id` has a ready offline copy. Used to seed each track row's
|
||||
@@ -181,7 +184,9 @@ async fn load_cached_ids() {
|
||||
.map(|tracks| {
|
||||
tracks
|
||||
.into_iter()
|
||||
.filter(|t| matches!(t.status, qbz_offline_cache::OfflineCacheStatus::Ready))
|
||||
.filter(|t| {
|
||||
matches!(t.status, qbz_offline_cache::OfflineCacheStatus::Ready)
|
||||
})
|
||||
.map(|t| t.track_id)
|
||||
.collect()
|
||||
})
|
||||
|
||||
@@ -126,7 +126,8 @@ pub fn publish(audio: &AudioSettings) {
|
||||
let l = output_labels(audio);
|
||||
let locked = volume_locked(audio);
|
||||
crate::player_bridge::ui(move |mut b| {
|
||||
b.as_mut().set_np_output_backend_label(QString::from(l.backend));
|
||||
b.as_mut()
|
||||
.set_np_output_backend_label(QString::from(l.backend));
|
||||
b.as_mut().set_np_output_mode_label(QString::from(l.mode));
|
||||
b.as_mut().set_np_output_backend_active(l.backend_active);
|
||||
b.as_mut().set_np_output_mode_active(l.mode_active);
|
||||
|
||||
@@ -90,7 +90,6 @@ pub mod qbz_player {
|
||||
// (ImmersiveSpectralOverlay.slint:45 parity).
|
||||
#[qproperty(i32, np_eff_rate_hz)]
|
||||
#[qproperty(i32, np_eff_bits)]
|
||||
|
||||
// --- Output LEDs (settings.rs `output_labels`) -------------------
|
||||
// PIPEWIRE|ALSA|JACK|PULS|SYST|AUTO — *_active lights the LED.
|
||||
#[qproperty(QString, np_output_backend_label)]
|
||||
@@ -105,7 +104,6 @@ pub mod qbz_player {
|
||||
// disabled with a tooltip instead of pretending it works. No audio
|
||||
// behaviour hangs off this flag — it is a UI mirror only.
|
||||
#[qproperty(bool, np_volume_locked)]
|
||||
|
||||
// --- Cast / remote (Qobuz Connect + Chromecast/DLNA) -------------
|
||||
// A peer Qobuz Connect renderer owns playback (transport is remote).
|
||||
#[qproperty(bool, np_is_remote)]
|
||||
@@ -150,7 +148,6 @@ pub mod qbz_player {
|
||||
// "Show track playing context" pref (Playback settings) — feeds the
|
||||
// SongCard layers icon.
|
||||
#[qproperty(bool, show_context_icon)]
|
||||
|
||||
type QbzPlayer = super::QbzPlayerRust;
|
||||
|
||||
/// Registers this object's Qt-thread hop (Main.qml boots EVERY
|
||||
@@ -204,7 +201,12 @@ pub mod qbz_player {
|
||||
fn play_album_from(self: Pin<&mut QbzPlayer>, album_id: QString, track_id: QString);
|
||||
/// AlbumView row "Play next" ("next") / "Add to queue" ("later").
|
||||
#[qinvokable]
|
||||
fn enqueue_album_track(self: Pin<&mut QbzPlayer>, album_id: QString, track_id: QString, mode: QString);
|
||||
fn enqueue_album_track(
|
||||
self: Pin<&mut QbzPlayer>,
|
||||
album_id: QString,
|
||||
track_id: QString,
|
||||
mode: QString,
|
||||
);
|
||||
/// AlbumCard ⋯ menu: Play next ("next") / Add to queue ("later").
|
||||
#[qinvokable]
|
||||
fn enqueue_album(self: Pin<&mut QbzPlayer>, album_id: QString, mode: QString);
|
||||
@@ -220,7 +222,13 @@ pub mod qbz_player {
|
||||
/// (bulk_tracks_qt.rs): ids in visible order + the bar's action id +
|
||||
/// the queue context to stamp ("" = none).
|
||||
#[qinvokable]
|
||||
fn bulk_tracks_action(self: Pin<&mut QbzPlayer>, ids_json: QString, action: QString, context_kind: QString, context_id: QString);
|
||||
fn bulk_tracks_action(
|
||||
self: Pin<&mut QbzPlayer>,
|
||||
ids_json: QString,
|
||||
action: QString,
|
||||
context_kind: QString,
|
||||
context_id: QString,
|
||||
);
|
||||
/// ArtistView Popular Tracks row play (whole list as the queue).
|
||||
#[qinvokable]
|
||||
fn play_artist_track(self: Pin<&mut QbzPlayer>, track_id: QString);
|
||||
@@ -471,7 +479,12 @@ impl qbz_player::QbzPlayer {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn enqueue_album_track(self: Pin<&mut Self>, album_id: QString, track_id: QString, mode: QString) {
|
||||
pub fn enqueue_album_track(
|
||||
self: Pin<&mut Self>,
|
||||
album_id: QString,
|
||||
track_id: QString,
|
||||
mode: QString,
|
||||
) {
|
||||
if let Ok(tid) = track_id.to_string().parse::<u64>() {
|
||||
crate::enqueue_album_track(album_id.to_string(), tid, mode.to_string());
|
||||
}
|
||||
@@ -497,7 +510,13 @@ impl qbz_player::QbzPlayer {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn bulk_tracks_action(self: Pin<&mut Self>, ids_json: QString, action: QString, context_kind: QString, context_id: QString) {
|
||||
pub fn bulk_tracks_action(
|
||||
self: Pin<&mut Self>,
|
||||
ids_json: QString,
|
||||
action: QString,
|
||||
context_kind: QString,
|
||||
context_id: QString,
|
||||
) {
|
||||
crate::bulk_tracks_qt::run(
|
||||
ids_json.to_string(),
|
||||
action.to_string(),
|
||||
|
||||
@@ -289,7 +289,11 @@ pub(crate) fn submit(
|
||||
})
|
||||
.await;
|
||||
}
|
||||
log::info!("[qbz-qt] playlist created: {} ({})", playlist.name, playlist.id);
|
||||
log::info!(
|
||||
"[qbz-qt] playlist created: {} ({})",
|
||||
playlist.name,
|
||||
playlist.id
|
||||
);
|
||||
settle_success(new_id, false);
|
||||
}
|
||||
Err(e) => {
|
||||
|
||||
@@ -88,7 +88,6 @@ pub mod qbz_playlist_edit_bridge {
|
||||
/// paid that bill once: the bridge header's own note about a missing
|
||||
/// `boot()` being "silently and forever" dropped is not hypothetical.
|
||||
#[qproperty(QString, create_json)]
|
||||
|
||||
type QbzPlaylistEdit = super::QbzPlaylistEditRust;
|
||||
|
||||
/// Registers this object's Qt-thread hop. Without it every `ui()`
|
||||
@@ -213,17 +212,8 @@ impl qbz_playlist_edit_bridge::QbzPlaylistEdit {
|
||||
crate::playlist_edit_qt::open(&id.to_string());
|
||||
}
|
||||
|
||||
pub fn save(
|
||||
self: Pin<&mut Self>,
|
||||
name: QString,
|
||||
description: QString,
|
||||
offline_only: bool,
|
||||
) {
|
||||
crate::playlist_edit_qt::save(
|
||||
&name.to_string(),
|
||||
&description.to_string(),
|
||||
offline_only,
|
||||
);
|
||||
pub fn save(self: Pin<&mut Self>, name: QString, description: QString, offline_only: bool) {
|
||||
crate::playlist_edit_qt::save(&name.to_string(), &description.to_string(), offline_only);
|
||||
}
|
||||
|
||||
pub fn delete_playlist(self: Pin<&mut Self>) {
|
||||
|
||||
@@ -202,12 +202,11 @@ pub(crate) fn open(id: &str) {
|
||||
fn open_local(id: String) {
|
||||
crate::spawn(async move {
|
||||
let lookup = id.clone();
|
||||
let found = tokio::task::spawn_blocking(move || {
|
||||
crate::local_playlist_qt::get_blocking(&lookup)
|
||||
})
|
||||
.await
|
||||
.ok()
|
||||
.flatten();
|
||||
let found =
|
||||
tokio::task::spawn_blocking(move || crate::local_playlist_qt::get_blocking(&lookup))
|
||||
.await
|
||||
.ok()
|
||||
.flatten();
|
||||
|
||||
let Some(p) = found else {
|
||||
// Deleted underneath us, or the DB is unreadable. Opening an
|
||||
@@ -333,12 +332,7 @@ pub(crate) fn save(name: &str, description: &str, offline_only: bool) {
|
||||
});
|
||||
}
|
||||
|
||||
async fn save_local(
|
||||
id: &str,
|
||||
name: &str,
|
||||
desc: Option<String>,
|
||||
offline_only: bool,
|
||||
) -> bool {
|
||||
async fn save_local(id: &str, name: &str, desc: Option<String>, offline_only: bool) -> bool {
|
||||
let id = id.to_string();
|
||||
let name = name.to_string();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
@@ -351,12 +345,7 @@ async fn save_local(
|
||||
Some(d) => Some(d),
|
||||
None => crate::local_playlist_qt::get_blocking(&id).and_then(|p| p.description),
|
||||
};
|
||||
crate::local_playlist_qt::update_blocking(
|
||||
&id,
|
||||
&name,
|
||||
description.as_deref(),
|
||||
offline_only,
|
||||
)
|
||||
crate::local_playlist_qt::update_blocking(&id, &name, description.as_deref(), offline_only)
|
||||
})
|
||||
.await
|
||||
.unwrap_or(false)
|
||||
|
||||
@@ -40,7 +40,6 @@ pub mod qbz_playlist_import_bridge {
|
||||
#[qml_singleton]
|
||||
// ONE JSON document (playlist_import_qt.rs ImportDoc).
|
||||
#[qproperty(QString, import_json)]
|
||||
|
||||
type QbzPlaylistImport = super::QbzPlaylistImportRust;
|
||||
|
||||
/// Registers this object's Qt-thread hop (Main.qml boots EVERY domain
|
||||
|
||||
@@ -946,8 +946,8 @@ fn build_source(st: &State) -> Option<PlaylistSource> {
|
||||
let bytes = st.file_bytes.clone()?;
|
||||
// Detection runs HERE rather than at parse time so a wrong pick
|
||||
// fails before the modal flips into step B.
|
||||
let format = qbz_playlist_import::sources::file::detect_format(&bytes, &st.file_name)
|
||||
.ok()?;
|
||||
let format =
|
||||
qbz_playlist_import::sources::file::detect_format(&bytes, &st.file_name).ok()?;
|
||||
Some(PlaylistSource::File {
|
||||
format,
|
||||
bytes,
|
||||
@@ -1244,10 +1244,7 @@ fn apply_execute_ok(summary: &ImportSummary) {
|
||||
qbz_i18n::t_args("Skipped: {}", &[&summary.skipped_tracks.to_string()]);
|
||||
// Hidden when there were none, which is every well-formed source.
|
||||
st.summary_duplicates = if summary.duplicate_tracks > 0 {
|
||||
qbz_i18n::t_args(
|
||||
"Duplicates: {}",
|
||||
&[&summary.duplicate_tracks.to_string()],
|
||||
)
|
||||
qbz_i18n::t_args("Duplicates: {}", &[&summary.duplicate_tracks.to_string()])
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
|
||||
@@ -59,7 +59,6 @@ pub mod qbz_playlist_manager_bridge {
|
||||
// touches it — see playlist_manager_qt's header for what breaks when
|
||||
// the two are conflated.
|
||||
#[qproperty(bool, pm_loading)]
|
||||
|
||||
type QbzPlaylistManager = super::QbzPlaylistManagerRust;
|
||||
|
||||
/// Registers this object's Qt-thread hop. Until Main.qml calls it,
|
||||
|
||||
@@ -56,22 +56,20 @@ use crate::playlist_manager_qt::{patch_cache, publish_document, visible_qobuz_id
|
||||
/// DB write below has to derive the value itself.
|
||||
fn patch_favorite(id: &str) -> Option<bool> {
|
||||
let mut out = None;
|
||||
patch_cache(|data| {
|
||||
match PlaylistRef::parse(id) {
|
||||
Some(PlaylistRef::Local(local)) => {
|
||||
if let Some(p) = data.locals.iter_mut().find(|p| p.id == local) {
|
||||
p.is_favorite = !p.is_favorite;
|
||||
out = Some(p.is_favorite);
|
||||
}
|
||||
patch_cache(|data| match PlaylistRef::parse(id) {
|
||||
Some(PlaylistRef::Local(local)) => {
|
||||
if let Some(p) = data.locals.iter_mut().find(|p| p.id == local) {
|
||||
p.is_favorite = !p.is_favorite;
|
||||
out = Some(p.is_favorite);
|
||||
}
|
||||
Some(PlaylistRef::Qobuz(pid)) => {
|
||||
if let Some(p) = data.playlists.iter_mut().find(|p| p.id == pid) {
|
||||
p.is_favorite = !p.is_favorite;
|
||||
out = Some(p.is_favorite);
|
||||
}
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
Some(PlaylistRef::Qobuz(pid)) => {
|
||||
if let Some(p) = data.playlists.iter_mut().find(|p| p.id == pid) {
|
||||
p.is_favorite = !p.is_favorite;
|
||||
out = Some(p.is_favorite);
|
||||
}
|
||||
}
|
||||
None => {}
|
||||
});
|
||||
out
|
||||
}
|
||||
@@ -89,27 +87,25 @@ pub(crate) fn toggle_favorite(id: &str) {
|
||||
}
|
||||
let id = id.to_string();
|
||||
crate::spawn(async move {
|
||||
let _ = tokio::task::spawn_blocking(move || {
|
||||
match PlaylistRef::parse(&id) {
|
||||
Some(PlaylistRef::Local(local)) => {
|
||||
let value = optimistic.unwrap_or_else(|| {
|
||||
!local_playlist_qt::get_blocking(&local)
|
||||
.map(|p| p.favorite)
|
||||
.unwrap_or(false)
|
||||
});
|
||||
local_playlist_qt::set_favorite_blocking(&local, value);
|
||||
}
|
||||
Some(PlaylistRef::Qobuz(pid)) => {
|
||||
let value = optimistic.unwrap_or_else(|| {
|
||||
!folders_qt::playlist_settings_map()
|
||||
.get(&pid)
|
||||
.map(|s| s.is_favorite)
|
||||
.unwrap_or(false)
|
||||
});
|
||||
folders_qt::set_favorite(pid, value);
|
||||
}
|
||||
None => {}
|
||||
let _ = tokio::task::spawn_blocking(move || match PlaylistRef::parse(&id) {
|
||||
Some(PlaylistRef::Local(local)) => {
|
||||
let value = optimistic.unwrap_or_else(|| {
|
||||
!local_playlist_qt::get_blocking(&local)
|
||||
.map(|p| p.favorite)
|
||||
.unwrap_or(false)
|
||||
});
|
||||
local_playlist_qt::set_favorite_blocking(&local, value);
|
||||
}
|
||||
Some(PlaylistRef::Qobuz(pid)) => {
|
||||
let value = optimistic.unwrap_or_else(|| {
|
||||
!folders_qt::playlist_settings_map()
|
||||
.get(&pid)
|
||||
.map(|s| s.is_favorite)
|
||||
.unwrap_or(false)
|
||||
});
|
||||
folders_qt::set_favorite(pid, value);
|
||||
}
|
||||
None => {}
|
||||
})
|
||||
.await;
|
||||
});
|
||||
@@ -121,22 +117,20 @@ pub(crate) fn toggle_favorite(id: &str) {
|
||||
|
||||
fn patch_hidden(id: &str) -> Option<bool> {
|
||||
let mut out = None;
|
||||
patch_cache(|data| {
|
||||
match PlaylistRef::parse(id) {
|
||||
Some(PlaylistRef::Local(local)) => {
|
||||
if let Some(p) = data.locals.iter_mut().find(|p| p.id == local) {
|
||||
p.is_hidden = !p.is_hidden;
|
||||
out = Some(p.is_hidden);
|
||||
}
|
||||
patch_cache(|data| match PlaylistRef::parse(id) {
|
||||
Some(PlaylistRef::Local(local)) => {
|
||||
if let Some(p) = data.locals.iter_mut().find(|p| p.id == local) {
|
||||
p.is_hidden = !p.is_hidden;
|
||||
out = Some(p.is_hidden);
|
||||
}
|
||||
Some(PlaylistRef::Qobuz(pid)) => {
|
||||
if let Some(p) = data.playlists.iter_mut().find(|p| p.id == pid) {
|
||||
p.is_hidden = !p.is_hidden;
|
||||
out = Some(p.is_hidden);
|
||||
}
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
Some(PlaylistRef::Qobuz(pid)) => {
|
||||
if let Some(p) = data.playlists.iter_mut().find(|p| p.id == pid) {
|
||||
p.is_hidden = !p.is_hidden;
|
||||
out = Some(p.is_hidden);
|
||||
}
|
||||
}
|
||||
None => {}
|
||||
});
|
||||
out
|
||||
}
|
||||
@@ -156,27 +150,25 @@ pub(crate) fn toggle_hidden(id: &str) {
|
||||
}
|
||||
let id = id.to_string();
|
||||
crate::spawn(async move {
|
||||
let _ = tokio::task::spawn_blocking(move || {
|
||||
match PlaylistRef::parse(&id) {
|
||||
Some(PlaylistRef::Local(local)) => {
|
||||
let value = optimistic.unwrap_or_else(|| {
|
||||
!local_playlist_qt::get_blocking(&local)
|
||||
.map(|p| p.hidden)
|
||||
.unwrap_or(false)
|
||||
});
|
||||
local_playlist_qt::set_hidden_blocking(&local, value);
|
||||
}
|
||||
Some(PlaylistRef::Qobuz(pid)) => {
|
||||
let value = optimistic.unwrap_or_else(|| {
|
||||
!folders_qt::playlist_settings_map()
|
||||
.get(&pid)
|
||||
.map(|s| s.hidden)
|
||||
.unwrap_or(false)
|
||||
});
|
||||
folders_qt::set_hidden(pid, value);
|
||||
}
|
||||
None => {}
|
||||
let _ = tokio::task::spawn_blocking(move || match PlaylistRef::parse(&id) {
|
||||
Some(PlaylistRef::Local(local)) => {
|
||||
let value = optimistic.unwrap_or_else(|| {
|
||||
!local_playlist_qt::get_blocking(&local)
|
||||
.map(|p| p.hidden)
|
||||
.unwrap_or(false)
|
||||
});
|
||||
local_playlist_qt::set_hidden_blocking(&local, value);
|
||||
}
|
||||
Some(PlaylistRef::Qobuz(pid)) => {
|
||||
let value = optimistic.unwrap_or_else(|| {
|
||||
!folders_qt::playlist_settings_map()
|
||||
.get(&pid)
|
||||
.map(|s| s.hidden)
|
||||
.unwrap_or(false)
|
||||
});
|
||||
folders_qt::set_hidden(pid, value);
|
||||
}
|
||||
None => {}
|
||||
})
|
||||
.await;
|
||||
// AFTER the write settles, inside the same task — otherwise the
|
||||
@@ -194,22 +186,20 @@ pub(crate) fn toggle_hidden(id: &str) {
|
||||
fn patch_folder(id: &str, folder_id: &str) -> bool {
|
||||
let mut patched = false;
|
||||
let target = (!folder_id.is_empty()).then(|| folder_id.to_string());
|
||||
patch_cache(|data| {
|
||||
match PlaylistRef::parse(id) {
|
||||
Some(PlaylistRef::Local(local)) => {
|
||||
if let Some(p) = data.locals.iter_mut().find(|p| p.id == local) {
|
||||
p.folder_id = target;
|
||||
patched = true;
|
||||
}
|
||||
patch_cache(|data| match PlaylistRef::parse(id) {
|
||||
Some(PlaylistRef::Local(local)) => {
|
||||
if let Some(p) = data.locals.iter_mut().find(|p| p.id == local) {
|
||||
p.folder_id = target;
|
||||
patched = true;
|
||||
}
|
||||
Some(PlaylistRef::Qobuz(pid)) => {
|
||||
if let Some(p) = data.playlists.iter_mut().find(|p| p.id == pid) {
|
||||
p.folder_id = target;
|
||||
patched = true;
|
||||
}
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
Some(PlaylistRef::Qobuz(pid)) => {
|
||||
if let Some(p) = data.playlists.iter_mut().find(|p| p.id == pid) {
|
||||
p.folder_id = target;
|
||||
patched = true;
|
||||
}
|
||||
}
|
||||
None => {}
|
||||
});
|
||||
patched
|
||||
}
|
||||
|
||||
@@ -72,14 +72,12 @@ static CACHE: Mutex<Option<PmData>> = Mutex::new(None);
|
||||
/// the DB and by [`build_document`] from a warm cache; it is what
|
||||
/// `foldersJson` always carries, so a cold-cache publish can never clobber it
|
||||
/// with `[]`.
|
||||
static FOLDERS_CACHE: LazyLock<Mutex<Vec<FolderRow>>> =
|
||||
LazyLock::new(|| Mutex::new(Vec::new()));
|
||||
static FOLDERS_CACHE: LazyLock<Mutex<Vec<FolderRow>>> = LazyLock::new(|| Mutex::new(Vec::new()));
|
||||
|
||||
/// Session tree-expand state. Never reset — only the latch below is, so a
|
||||
/// re-entry re-expands every folder on top of whatever the user collapsed.
|
||||
/// That is the reference behaviour (§5.5); keep it.
|
||||
static EXPANDED: LazyLock<Mutex<HashSet<String>>> =
|
||||
LazyLock::new(|| Mutex::new(HashSet::new()));
|
||||
static EXPANDED: LazyLock<Mutex<HashSet<String>>> = LazyLock::new(|| Mutex::new(HashSet::new()));
|
||||
|
||||
/// True once the tree has auto-expanded folders since the last `navigate()`.
|
||||
static TREE_INIT: Mutex<bool> = Mutex::new(false);
|
||||
@@ -431,7 +429,11 @@ pub(crate) fn search_changed(query: &str) {
|
||||
|
||||
pub(crate) fn set_filter(value: &str) {
|
||||
let valid = matches!(value, "all" | "visible" | "hidden");
|
||||
TOOLBAR.lock().unwrap().filter = if valid { value.to_string() } else { "all".into() };
|
||||
TOOLBAR.lock().unwrap().filter = if valid {
|
||||
value.to_string()
|
||||
} else {
|
||||
"all".into()
|
||||
};
|
||||
publish_document();
|
||||
}
|
||||
|
||||
@@ -568,10 +570,14 @@ async fn load(runtime: &Runtime) -> PmData {
|
||||
let remote: Vec<qbz_models::Playlist> = if offline {
|
||||
Vec::new()
|
||||
} else {
|
||||
runtime.core().get_user_playlists().await.unwrap_or_else(|e| {
|
||||
log::warn!("[qbz-qt] playlist-manager playlists load failed: {e}");
|
||||
Vec::new()
|
||||
})
|
||||
runtime
|
||||
.core()
|
||||
.get_user_playlists()
|
||||
.await
|
||||
.unwrap_or_else(|e| {
|
||||
log::warn!("[qbz-qt] playlist-manager playlists load failed: {e}");
|
||||
Vec::new()
|
||||
})
|
||||
};
|
||||
crate::playlist_snapshot_qt::record_names_detached(
|
||||
remote
|
||||
@@ -725,7 +731,10 @@ async fn load(runtime: &Runtime) -> PmData {
|
||||
// `folder_id.is_none()` test.
|
||||
let mut locals = locals;
|
||||
for p in &mut locals {
|
||||
if p.folder_id.as_ref().is_some_and(|f| !folder_ids.contains(f)) {
|
||||
if p.folder_id
|
||||
.as_ref()
|
||||
.is_some_and(|f| !folder_ids.contains(f))
|
||||
{
|
||||
p.folder_id = None;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -257,12 +257,7 @@ pub(crate) fn playlist_item(p: &PmPlaylist) -> PlaylistRow {
|
||||
PlaylistRow {
|
||||
id: p.id.to_string(),
|
||||
name: p.name.clone(),
|
||||
tracks_line: qbz_i18n::tf(
|
||||
"{} track",
|
||||
"{} tracks",
|
||||
total as i64,
|
||||
&[&total.to_string()],
|
||||
),
|
||||
tracks_line: qbz_i18n::tf("{} track", "{} tracks", total as i64, &[&total.to_string()]),
|
||||
duration_line: format_duration(p.duration),
|
||||
local_line: if p.local_count > 0 {
|
||||
qbz_i18n::t_args("({} local)", &[&p.local_count.to_string()])
|
||||
@@ -815,8 +810,7 @@ mod tests {
|
||||
let flat = visible_playlist_rows(&data, "", "all", "name", true, false, "grid", false);
|
||||
assert_eq!(ids(&flat).len(), 4);
|
||||
// Tree mode publishes the flat set unconditionally too (§4.2).
|
||||
let tree_mode =
|
||||
visible_playlist_rows(&data, "", "all", "name", true, true, "tree", false);
|
||||
let tree_mode = visible_playlist_rows(&data, "", "all", "name", true, true, "tree", false);
|
||||
assert_eq!(ids(&tree_mode).len(), 4);
|
||||
}
|
||||
|
||||
@@ -838,8 +832,7 @@ mod tests {
|
||||
let visible =
|
||||
visible_playlist_rows(&data, "", "visible", "name", true, false, "grid", false);
|
||||
assert_eq!(ids(&visible), vec!["local:l2", "2"]);
|
||||
let hidden =
|
||||
visible_playlist_rows(&data, "", "hidden", "name", true, false, "grid", false);
|
||||
let hidden = visible_playlist_rows(&data, "", "hidden", "name", true, false, "grid", false);
|
||||
assert_eq!(ids(&hidden), vec!["local:l1", "1"]);
|
||||
}
|
||||
|
||||
|
||||
@@ -49,7 +49,6 @@ pub mod qbz_playlist_picker_bridge {
|
||||
// creating flags, the playlist rows, the inline create panel and the
|
||||
// duplicate-confirm sub-modal).
|
||||
#[qproperty(QString, picker_json)]
|
||||
|
||||
type QbzPlaylistPicker = super::QbzPlaylistPickerRust;
|
||||
|
||||
/// Registers this object's Qt-thread hop (Main.qml boots EVERY domain
|
||||
|
||||
@@ -695,7 +695,12 @@ fn toast_added(n: usize, name: &str) {
|
||||
return;
|
||||
}
|
||||
let msg = if name.is_empty() {
|
||||
qbz_i18n::tf("Added {} track", "Added {} tracks", n as i64, &[&n.to_string()])
|
||||
qbz_i18n::tf(
|
||||
"Added {} track",
|
||||
"Added {} tracks",
|
||||
n as i64,
|
||||
&[&n.to_string()],
|
||||
)
|
||||
} else {
|
||||
qbz_i18n::tf(
|
||||
"Added {} track to {}",
|
||||
|
||||
@@ -119,7 +119,11 @@ pub struct PlaylistTrackRow {
|
||||
pub unavailable: bool,
|
||||
/// The raw stored ref behind `unavailable`, shown so the user can see WHAT
|
||||
/// is broken rather than just that something is.
|
||||
#[serde(default, rename = "unavailableRef", skip_serializing_if = "String::is_empty")]
|
||||
#[serde(
|
||||
default,
|
||||
rename = "unavailableRef",
|
||||
skip_serializing_if = "String::is_empty"
|
||||
)]
|
||||
pub unavailable_ref: String,
|
||||
/// Qobuz PULLED this track: the catalog reports `streamable: false` for it
|
||||
/// (contract §5.1). DISTINCT from [`Self::unavailable`] above, and the two
|
||||
@@ -218,12 +222,20 @@ pub struct PlaylistDoc {
|
||||
/// This detail is a LOCAL playlist (`local:<uuid>`), not a Qobuz one. The
|
||||
/// view drops every Qobuz-only affordance on it (follow, copy, share, the
|
||||
/// Qobuz heart) and offers the local ones instead.
|
||||
#[serde(default, rename = "isLocalPlaylist", skip_serializing_if = "std::ops::Not::not")]
|
||||
#[serde(
|
||||
default,
|
||||
rename = "isLocalPlaylist",
|
||||
skip_serializing_if = "std::ops::Not::not"
|
||||
)]
|
||||
pub is_local_playlist: bool,
|
||||
/// D8: an OFFLINE-ONLY local playlist. Nothing from one may ever reach
|
||||
/// Qobuz — the queue it builds is stamped so the QConnect push site skips
|
||||
/// the cloud entirely.
|
||||
#[serde(default, rename = "offlineOnly", skip_serializing_if = "std::ops::Not::not")]
|
||||
#[serde(
|
||||
default,
|
||||
rename = "offlineOnly",
|
||||
skip_serializing_if = "std::ops::Not::not"
|
||||
)]
|
||||
pub offline_only: bool,
|
||||
/// This QOBUZ detail carries SIDECAR rows — local files and/or Plex tracks
|
||||
/// added to it through `playlist_local_tracks` / `playlist_plex_tracks`.
|
||||
@@ -235,7 +247,11 @@ pub struct PlaylistDoc {
|
||||
/// those affordances. What it loses is drag/chevron reorder — see
|
||||
/// `apply_custom_order`, which keys its stored order by a `u64` catalog id
|
||||
/// and cannot tell one from a library row id.
|
||||
#[serde(default, rename = "isMixed", skip_serializing_if = "std::ops::Not::not")]
|
||||
#[serde(
|
||||
default,
|
||||
rename = "isMixed",
|
||||
skip_serializing_if = "std::ops::Not::not"
|
||||
)]
|
||||
pub is_mixed: bool,
|
||||
}
|
||||
|
||||
@@ -293,10 +309,7 @@ pub(crate) fn mark_mixed() {
|
||||
/// A Plex row never rides its numeric id (the synthetic ids do not resolve):
|
||||
/// its rating key comes back from the open detail's queue snapshot through
|
||||
/// `local_picker_ref_for_row`, which returns it as `"plex:<key>"`.
|
||||
pub async fn remove_sidecar_row(
|
||||
runtime: &Arc<AppRuntime<LoggingAdapter>>,
|
||||
row_id: &str,
|
||||
) -> bool {
|
||||
pub async fn remove_sidecar_row(runtime: &Arc<AppRuntime<LoggingAdapter>>, row_id: &str) -> bool {
|
||||
let Some(playlist_id) = with_doc(|d| d.id.clone()).and_then(|id| id.parse::<u64>().ok()) else {
|
||||
return false;
|
||||
};
|
||||
@@ -333,8 +346,12 @@ pub async fn remove_sidecar_row(
|
||||
let _ = tokio::task::spawn_blocking(move || {
|
||||
crate::library_db_qt::with_db(false, |db| {
|
||||
match &removal {
|
||||
SidecarRemoval::Local(id) => db.remove_local_track_from_playlist(playlist_id, *id)?,
|
||||
SidecarRemoval::Plex(key) => db.remove_plex_track_from_playlist(playlist_id, key)?,
|
||||
SidecarRemoval::Local(id) => {
|
||||
db.remove_local_track_from_playlist(playlist_id, *id)?
|
||||
}
|
||||
SidecarRemoval::Plex(key) => {
|
||||
db.remove_plex_track_from_playlist(playlist_id, key)?
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
@@ -970,7 +987,10 @@ fn collage_urls(playlist: &Playlist, tracks: &[Track]) -> Vec<String> {
|
||||
// Load
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
pub async fn load(runtime: &Arc<AppRuntime<LoggingAdapter>>, playlist_id: u64) -> Result<(), String> {
|
||||
pub async fn load(
|
||||
runtime: &Arc<AppRuntime<LoggingAdapter>>,
|
||||
playlist_id: u64,
|
||||
) -> Result<(), String> {
|
||||
{
|
||||
let mut guard = PAGE.lock().unwrap();
|
||||
let doc = &mut guard.get_or_insert_with(PageState::default).doc;
|
||||
@@ -1108,7 +1128,8 @@ pub async fn load(runtime: &Arc<AppRuntime<LoggingAdapter>>, playlist_id: u64) -
|
||||
// library.db mirror in `fav_cache_qt` — the same authority the write reads
|
||||
// — and only falls back to the feed.
|
||||
let is_favorite = crate::library_qt::is_favorite("playlist", &playlist_id.to_string());
|
||||
let is_owner = pl.owner.id != 0 && pl.owner.id == USER_ID.load(std::sync::atomic::Ordering::SeqCst);
|
||||
let is_owner =
|
||||
pl.owner.id != 0 && pl.owner.id == USER_ID.load(std::sync::atomic::Ordering::SeqCst);
|
||||
// Following is only meaningful for a FOREIGN playlist, and its authority
|
||||
// is the user's own playlist list (the ownership snapshot). The feed stays
|
||||
// as the fallback for the window before the first snapshot lands.
|
||||
@@ -1118,13 +1139,9 @@ pub async fn load(runtime: &Arc<AppRuntime<LoggingAdapter>>, playlist_id: u64) -
|
||||
let is_following = !is_owner
|
||||
&& (self::is_following(playlist_id)
|
||||
|| crate::library_qt::with_library(|d| {
|
||||
d.feed
|
||||
.iter()
|
||||
.any(|i| {
|
||||
i.kind == "playlist"
|
||||
&& i.id == playlist_id.to_string()
|
||||
&& i.playlist_following
|
||||
})
|
||||
d.feed.iter().any(|i| {
|
||||
i.kind == "playlist" && i.id == playlist_id.to_string() && i.playlist_following
|
||||
})
|
||||
})
|
||||
.unwrap_or(false));
|
||||
let pinned = crate::sidebar_qt::is_pinned("playlist", &playlist_id.to_string());
|
||||
@@ -1136,7 +1153,8 @@ pub async fn load(runtime: &Arc<AppRuntime<LoggingAdapter>>, playlist_id: u64) -
|
||||
let is_copied = !is_owner && crate::library_db_qt::is_playlist_copied(playlist_id);
|
||||
|
||||
// Custom drag order (applied when sort == custom).
|
||||
let sort_state = with_doc(|d| (d.sort_field.clone(), d.sort_asc)).unwrap_or(("default".into(), true));
|
||||
let sort_state =
|
||||
with_doc(|d| (d.sort_field.clone(), d.sort_asc)).unwrap_or(("default".into(), true));
|
||||
|
||||
let track_count = rows.len() as i32;
|
||||
let total_duration = total_duration_label(&rows);
|
||||
@@ -1225,9 +1243,15 @@ fn apply_sort(doc: &mut PlaylistDoc, field: &str, asc: bool) {
|
||||
doc.sort_field = field.to_string();
|
||||
doc.sort_asc = asc;
|
||||
match field {
|
||||
"title" => doc.tracks.sort_by(|a, b| a.title.to_lowercase().cmp(&b.title.to_lowercase())),
|
||||
"artist" => doc.tracks.sort_by(|a, b| a.artist.to_lowercase().cmp(&b.artist.to_lowercase())),
|
||||
"album" => doc.tracks.sort_by(|a, b| a.album.to_lowercase().cmp(&b.album.to_lowercase())),
|
||||
"title" => doc
|
||||
.tracks
|
||||
.sort_by(|a, b| a.title.to_lowercase().cmp(&b.title.to_lowercase())),
|
||||
"artist" => doc
|
||||
.tracks
|
||||
.sort_by(|a, b| a.artist.to_lowercase().cmp(&b.artist.to_lowercase())),
|
||||
"album" => doc
|
||||
.tracks
|
||||
.sort_by(|a, b| a.album.to_lowercase().cmp(&b.album.to_lowercase())),
|
||||
"duration" => doc.tracks.sort_by_key(|t| t.duration_secs),
|
||||
// A MIXED playlist has no usable custom order, and forcing one would
|
||||
// scramble the merge that just ran: `apply_custom_order` keys its
|
||||
@@ -1271,7 +1295,11 @@ fn load_custom_orders(playlist_id: u64) -> Vec<u64> {
|
||||
v.get(playlist_id.to_string()).and_then(|ids| {
|
||||
ids.as_array().map(|a| {
|
||||
a.iter()
|
||||
.filter_map(|id| id.as_str().and_then(|s| s.parse::<u64>().ok()).or(id.as_u64()))
|
||||
.filter_map(|id| {
|
||||
id.as_str()
|
||||
.and_then(|s| s.parse::<u64>().ok())
|
||||
.or(id.as_u64())
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
})
|
||||
@@ -1572,7 +1600,8 @@ pub async fn copy_playlist(runtime: &Arc<AppRuntime<LoggingAdapter>>) {
|
||||
}
|
||||
// Persist the copy against the SOURCE id (idempotent). rusqlite is
|
||||
// blocking, so it goes off the async path like every other db write here.
|
||||
let _ = tokio::task::spawn_blocking(move || crate::library_db_qt::mark_playlist_copied(pid)).await;
|
||||
let _ =
|
||||
tokio::task::spawn_blocking(move || crate::library_db_qt::mark_playlist_copied(pid)).await;
|
||||
// Only the page that asked for the copy gets the flag: the user may have
|
||||
// navigated away while the create was in flight (reference `is_open`).
|
||||
let source_id = pid.to_string();
|
||||
@@ -1586,7 +1615,10 @@ pub async fn copy_playlist(runtime: &Arc<AppRuntime<LoggingAdapter>>) {
|
||||
if let Some(Some(doc)) = patched {
|
||||
publish(&doc);
|
||||
}
|
||||
log::info!("[qbz-qt] playlist {pid} copied to library as {}", new_playlist.id);
|
||||
log::info!(
|
||||
"[qbz-qt] playlist {pid} copied to library as {}",
|
||||
new_playlist.id
|
||||
);
|
||||
crate::toast_qt::success(qbz_i18n::t("Copied to your library"));
|
||||
|
||||
// The copy is OWNED, so it joins the sidebar and the Library's favorites
|
||||
@@ -1785,7 +1817,8 @@ pub async fn remove_track(runtime: &Arc<AppRuntime<LoggingAdapter>>, playlist_tr
|
||||
let Some(pid) = pid else { return };
|
||||
// Optimistic removal; the API is the source of truth on failure.
|
||||
with_doc(|d| {
|
||||
d.tracks.retain(|t| t.playlist_track_id != playlist_track_id);
|
||||
d.tracks
|
||||
.retain(|t| t.playlist_track_id != playlist_track_id);
|
||||
d.track_count = d.tracks.len() as i32;
|
||||
d.total_duration = total_duration_label(&d.tracks);
|
||||
let doc = d.clone();
|
||||
@@ -1894,7 +1927,10 @@ pub fn move_row(row_id: &str, delta: i32) {
|
||||
/// Fetch a playlist and build its queue (the card-level actions: the
|
||||
/// LibPlaylistCard overlay Play and its menu's queueing — no open view
|
||||
/// needed).
|
||||
async fn queue_for(runtime: &Arc<AppRuntime<LoggingAdapter>>, playlist_id: u64) -> Result<Vec<QueueTrack>, String> {
|
||||
async fn queue_for(
|
||||
runtime: &Arc<AppRuntime<LoggingAdapter>>,
|
||||
playlist_id: u64,
|
||||
) -> Result<Vec<QueueTrack>, String> {
|
||||
let pl = runtime
|
||||
.core()
|
||||
.get_playlist(playlist_id)
|
||||
@@ -2024,7 +2060,11 @@ pub async fn set_follow_by_id(
|
||||
/// Add tracks to a playlist by id (the DnD drop path): Qobuz catalog ids
|
||||
/// via the membership API, then refresh the sidebar + the open detail when
|
||||
/// it matches.
|
||||
pub async fn add_tracks(runtime: &Arc<AppRuntime<LoggingAdapter>>, playlist_id: u64, track_ids: &[u64]) {
|
||||
pub async fn add_tracks(
|
||||
runtime: &Arc<AppRuntime<LoggingAdapter>>,
|
||||
playlist_id: u64,
|
||||
track_ids: &[u64],
|
||||
) {
|
||||
if track_ids.is_empty() {
|
||||
return;
|
||||
}
|
||||
@@ -2034,7 +2074,10 @@ pub async fn add_tracks(runtime: &Arc<AppRuntime<LoggingAdapter>>, playlist_id:
|
||||
.await
|
||||
{
|
||||
Ok(()) => {
|
||||
log::info!("[qbz-qt] dropped {} track(s) onto playlist {playlist_id}", track_ids.len());
|
||||
log::info!(
|
||||
"[qbz-qt] dropped {} track(s) onto playlist {playlist_id}",
|
||||
track_ids.len()
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("[qbz-qt] drop add to playlist {playlist_id} failed: {e}");
|
||||
@@ -2099,13 +2142,9 @@ async fn play_queue_at(
|
||||
if crate::playback_qt::route_play_to_peer(runtime, first_id).await {
|
||||
return Ok(());
|
||||
}
|
||||
crate::playback_qt::play_resolved_offline_aware(
|
||||
runtime,
|
||||
first_id,
|
||||
0,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| format!("play_track {first_id} failed: {e}"))?;
|
||||
crate::playback_qt::play_resolved_offline_aware(runtime, first_id, 0)
|
||||
.await
|
||||
.map_err(|e| format!("play_track {first_id} failed: {e}"))?;
|
||||
crate::playback_qt::refresh_now_playing(runtime).await;
|
||||
Ok(())
|
||||
}
|
||||
@@ -2135,7 +2174,10 @@ pub async fn play_shuffled(runtime: &Arc<AppRuntime<LoggingAdapter>>) -> Result<
|
||||
}
|
||||
|
||||
/// Row play: the playlist as the queue, starting at this track.
|
||||
pub async fn play_track(runtime: &Arc<AppRuntime<LoggingAdapter>>, track_id: &str) -> Result<(), String> {
|
||||
pub async fn play_track(
|
||||
runtime: &Arc<AppRuntime<LoggingAdapter>>,
|
||||
track_id: &str,
|
||||
) -> Result<(), String> {
|
||||
let start = with_doc(|d| d.tracks.iter().position(|t| t.id == track_id)).flatten();
|
||||
let tracks = current_queue();
|
||||
play_queue_at(runtime, tracks, start.unwrap_or(0), open_context()).await
|
||||
|
||||
@@ -189,7 +189,11 @@ pub async fn generate_code(server_url: String) {
|
||||
|
||||
/// Open the Plex sign-in page in the user's browser.
|
||||
pub async fn open_auth_url() {
|
||||
let url = PIN.lock().unwrap_or_else(|e| e.into_inner()).auth_url.clone();
|
||||
let url = PIN
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner())
|
||||
.auth_url
|
||||
.clone();
|
||||
if url.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -60,7 +60,6 @@ pub mod qbz_purchases_bridge {
|
||||
/// (re-opening the same album, or a filter toggle that changes nothing
|
||||
/// visible), the same trick `QbzSession.trRev` plays.
|
||||
#[qproperty(i32, purchases_rev)]
|
||||
|
||||
type QbzPurchases = super::QbzPurchasesRust;
|
||||
|
||||
/// Registers this object's Qt-thread hop (Main.qml boots EVERY domain
|
||||
|
||||
@@ -543,7 +543,11 @@ fn downloaded_for_format(downloaded_format_ids: &[u32], selected: Option<u32>) -
|
||||
/// album `formatId` is set and differs from the selected format reports NOTHING
|
||||
/// — that is what makes changing the dropdown hide the green checks. Every
|
||||
/// other status passes through unscoped.
|
||||
fn status_scoped(state: Option<&AlbumDownload>, track_id: u64, selected: Option<u32>) -> Option<DlStatus> {
|
||||
fn status_scoped(
|
||||
state: Option<&AlbumDownload>,
|
||||
track_id: u64,
|
||||
selected: Option<u32>,
|
||||
) -> Option<DlStatus> {
|
||||
let state = state?;
|
||||
let status = *state.statuses.get(&track_id)?;
|
||||
if status == DlStatus::Complete {
|
||||
@@ -838,7 +842,8 @@ async fn ensure_registry() -> Registry {
|
||||
// than that import.
|
||||
|
||||
fn publish_list() {
|
||||
let json = with_list(|s| serde_json::to_string(&build_list_doc(s)).unwrap_or_else(|_| "{}".into()));
|
||||
let json =
|
||||
with_list(|s| serde_json::to_string(&build_list_doc(s)).unwrap_or_else(|_| "{}".into()));
|
||||
crate::purchases_bridge::ui(move |mut b| {
|
||||
b.as_mut().set_list_json(QString::from(json.as_str()));
|
||||
let next = b.as_ref().purchases_rev() + 1;
|
||||
@@ -847,7 +852,8 @@ fn publish_list() {
|
||||
}
|
||||
|
||||
fn publish_album() {
|
||||
let json = with_detail(|s| serde_json::to_string(&build_album_doc(s)).unwrap_or_else(|_| "{}".into()));
|
||||
let json =
|
||||
with_detail(|s| serde_json::to_string(&build_album_doc(s)).unwrap_or_else(|_| "{}".into()));
|
||||
crate::purchases_bridge::ui(move |mut b| {
|
||||
b.as_mut().set_album_json(QString::from(json.as_str()));
|
||||
let next = b.as_ref().purchases_rev() + 1;
|
||||
@@ -874,7 +880,12 @@ fn build_list_doc(s: &ListState) -> ListDoc {
|
||||
.iter()
|
||||
.filter(|a| !s.hide_unavailable || a.downloadable)
|
||||
.filter(|a| {
|
||||
matches_quality(filter, a.hires, a.maximum_bit_depth, a.maximum_sampling_rate)
|
||||
matches_quality(
|
||||
filter,
|
||||
a.hires,
|
||||
a.maximum_bit_depth,
|
||||
a.maximum_sampling_rate,
|
||||
)
|
||||
})
|
||||
.filter(|a| !s.hide_downloaded || !a.downloaded)
|
||||
.collect();
|
||||
@@ -888,7 +899,12 @@ fn build_list_doc(s: &ListState) -> ListDoc {
|
||||
.iter()
|
||||
.filter(|t| !s.hide_downloaded || !t.downloaded)
|
||||
.filter(|t| {
|
||||
matches_quality(filter, t.hires, t.maximum_bit_depth, t.maximum_sampling_rate)
|
||||
matches_quality(
|
||||
filter,
|
||||
t.hires,
|
||||
t.maximum_bit_depth,
|
||||
t.maximum_sampling_rate,
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
|
||||
@@ -940,7 +956,13 @@ fn sort_albums(list: &mut [&PurchaseAlbum], key: &str, ascending: bool) {
|
||||
flip(ord)
|
||||
}),
|
||||
// "purchased" / "date" — the default.
|
||||
_ => list.sort_by(|a, b| flip(a.purchased_at.unwrap_or(0).cmp(&b.purchased_at.unwrap_or(0)))),
|
||||
_ => list.sort_by(|a, b| {
|
||||
flip(
|
||||
a.purchased_at
|
||||
.unwrap_or(0)
|
||||
.cmp(&b.purchased_at.unwrap_or(0)),
|
||||
)
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1206,7 +1228,9 @@ fn build_album_doc(s: &DetailState) -> AlbumDoc {
|
||||
// a destination exists in the STORE — i.e. a download really happened,
|
||||
// not merely that the user picked a folder.
|
||||
add_to_library_visible: all_complete
|
||||
&& dl_ref.and_then(|d| d.resolved_album_folder.as_ref()).is_some(),
|
||||
&& dl_ref
|
||||
.and_then(|d| d.resolved_album_folder.as_ref())
|
||||
.is_some(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1243,7 +1267,10 @@ async fn read_registry() -> Registry {
|
||||
};
|
||||
let mut format_map: HashMap<i64, Vec<u32>> = HashMap::new();
|
||||
for (track_id, format_id) in formats {
|
||||
format_map.entry(track_id).or_default().push(format_id as u32);
|
||||
format_map
|
||||
.entry(track_id)
|
||||
.or_default()
|
||||
.push(format_id as u32);
|
||||
}
|
||||
Registry {
|
||||
downloaded_ids: ids.into_iter().collect(),
|
||||
@@ -1283,7 +1310,11 @@ fn open_owned_library_db() -> Option<LibraryDatabase> {
|
||||
/// SHARED `apply_download_flags`, which carries the owner's §11-1 ruling (the
|
||||
/// album rule reads the local registry) — never a local re-derivation, so the
|
||||
/// list and the detail cannot disagree about the same album.
|
||||
fn annotate(albums: Vec<PurchaseAlbum>, tracks: Vec<PurchaseTrack>, reg: &Registry) -> (Vec<PurchaseAlbum>, Vec<PurchaseTrack>) {
|
||||
fn annotate(
|
||||
albums: Vec<PurchaseAlbum>,
|
||||
tracks: Vec<PurchaseTrack>,
|
||||
reg: &Registry,
|
||||
) -> (Vec<PurchaseAlbum>, Vec<PurchaseTrack>) {
|
||||
let mut response = PurchaseResponse {
|
||||
albums: SearchResultsPage {
|
||||
total: albums.len() as u32,
|
||||
@@ -1415,7 +1446,11 @@ fn load_tab(generation: u64, tab: String, force: bool) {
|
||||
if s.generation != generation {
|
||||
return false;
|
||||
}
|
||||
let already = if tab == "tracks" { s.tracks_loaded } else { s.albums_loaded };
|
||||
let already = if tab == "tracks" {
|
||||
s.tracks_loaded
|
||||
} else {
|
||||
s.albums_loaded
|
||||
};
|
||||
if already && !force {
|
||||
return false;
|
||||
}
|
||||
@@ -1479,7 +1514,11 @@ fn load_tab(generation: u64, tab: String, force: bool) {
|
||||
&mut s.counts.albums
|
||||
};
|
||||
if *counter == 0 {
|
||||
*counter = if server_total > 0 { server_total } else { loaded_len };
|
||||
*counter = if server_total > 0 {
|
||||
server_total
|
||||
} else {
|
||||
loaded_len
|
||||
};
|
||||
}
|
||||
|
||||
s.loading = false;
|
||||
@@ -1574,8 +1613,7 @@ pub fn search(query: String) {
|
||||
match result {
|
||||
Ok(response) => {
|
||||
let filtered = svc::filter_purchase_response(response, &query);
|
||||
let (albums, tracks) =
|
||||
annotate(filtered.albums.items, filtered.tracks.items, ®);
|
||||
let (albums, tracks) = annotate(filtered.albums.items, filtered.tracks.items, ®);
|
||||
let applied = with_list(|s| {
|
||||
// Two guards: the visit, and the search token — a slow
|
||||
// earlier query must not overwrite a newer one's results.
|
||||
@@ -1628,7 +1666,13 @@ pub fn set_group(key: String) {
|
||||
}
|
||||
|
||||
pub fn set_view_mode(mode: String) {
|
||||
with_list(|s| s.view_mode = if mode == "list" { "list".into() } else { "grid".into() });
|
||||
with_list(|s| {
|
||||
s.view_mode = if mode == "list" {
|
||||
"list".into()
|
||||
} else {
|
||||
"grid".into()
|
||||
}
|
||||
});
|
||||
publish_list();
|
||||
}
|
||||
|
||||
@@ -1745,7 +1789,8 @@ async fn load_album(generation: u64, album_id: String) {
|
||||
|
||||
let reg = ensure_registry().await;
|
||||
|
||||
let album = svc::build_purchase_album(&catalog, &purchases, ®.downloaded_ids, ®.format_map);
|
||||
let album =
|
||||
svc::build_purchase_album(&catalog, &purchases, ®.downloaded_ids, ®.format_map);
|
||||
let formats = svc::synth_formats(&catalog);
|
||||
let cover_url = catalog.image.best().cloned().unwrap_or_default();
|
||||
|
||||
@@ -1812,7 +1857,10 @@ pub fn set_format(format_id: i32) {
|
||||
let changed = with_detail(|s| {
|
||||
let id = format_id as u32;
|
||||
if !s.formats.iter().any(|f| f.id == id) {
|
||||
log::warn!("[qbz-qt] purchases: format {id} is not offered for album {}", s.album_id);
|
||||
log::warn!(
|
||||
"[qbz-qt] purchases: format {id} is not offered for album {}",
|
||||
s.album_id
|
||||
);
|
||||
return false;
|
||||
}
|
||||
s.selected_format_id = Some(id);
|
||||
@@ -1971,7 +2019,12 @@ async fn ensure_destination(mut plan: DownloadPlan) -> Option<DownloadPlan> {
|
||||
/// The album-level context every track is tagged from (§14): built ONCE before
|
||||
/// the loop, with the cover fetched once rather than per track.
|
||||
async fn build_context(catalog: &Album) -> svc::PurchaseAlbumContext {
|
||||
let cover = match catalog.image.large.as_deref().or(catalog.image.best().map(String::as_str)) {
|
||||
let cover = match catalog
|
||||
.image
|
||||
.large
|
||||
.as_deref()
|
||||
.or(catalog.image.best().map(String::as_str))
|
||||
{
|
||||
Some(url) if !url.is_empty() => svc::fetch_asset_bytes(url).await,
|
||||
_ => None,
|
||||
};
|
||||
@@ -2019,17 +2072,20 @@ async fn run_album_download(plan: DownloadPlan) {
|
||||
// the tracks. The folder is the one the FIRST successful track produced —
|
||||
// the store's rewritten destination — falling back to the deterministic
|
||||
// derivation when nothing landed.
|
||||
let folder = with_store(|st| st.get(&plan.album_id).and_then(|d| d.resolved_album_folder.clone()))
|
||||
.filter(|d| d != &plan.destination)
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(|| {
|
||||
album_dir_for(
|
||||
&plan.destination,
|
||||
&plan.artist,
|
||||
&plan.album_title,
|
||||
&plan.quality_dir,
|
||||
)
|
||||
});
|
||||
let folder = with_store(|st| {
|
||||
st.get(&plan.album_id)
|
||||
.and_then(|d| d.resolved_album_folder.clone())
|
||||
})
|
||||
.filter(|d| d != &plan.destination)
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(|| {
|
||||
album_dir_for(
|
||||
&plan.destination,
|
||||
&plan.artist,
|
||||
&plan.album_title,
|
||||
&plan.quality_dir,
|
||||
)
|
||||
});
|
||||
if cover.is_some() || back.is_some() {
|
||||
let _ = tokio::task::spawn_blocking(move || {
|
||||
svc::write_album_cover_files(&folder, cover.as_deref(), back.as_deref());
|
||||
@@ -2058,7 +2114,10 @@ fn run_sequential(
|
||||
ctx: svc::PurchaseAlbumContext,
|
||||
cancellable: bool,
|
||||
) {
|
||||
let rt = match tokio::runtime::Builder::new_current_thread().enable_all().build() {
|
||||
let rt = match tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
{
|
||||
Ok(rt) => rt,
|
||||
Err(e) => {
|
||||
log::error!("[qbz-qt] purchases: download runtime build failed: {e}");
|
||||
@@ -2192,7 +2251,12 @@ async fn refresh_after_download() {
|
||||
let reg = read_registry().await;
|
||||
set_registry(reg.clone());
|
||||
|
||||
let (albums, tracks) = with_list(|s| (std::mem::take(&mut s.albums_raw), std::mem::take(&mut s.tracks_raw)));
|
||||
let (albums, tracks) = with_list(|s| {
|
||||
(
|
||||
std::mem::take(&mut s.albums_raw),
|
||||
std::mem::take(&mut s.tracks_raw),
|
||||
)
|
||||
});
|
||||
let (albums, tracks) = annotate(albums, tracks, ®);
|
||||
with_list(|s| {
|
||||
s.albums_raw = albums;
|
||||
@@ -2210,15 +2274,11 @@ async fn refresh_after_download() {
|
||||
for track in &mut page.items {
|
||||
let tid = track.id as i64;
|
||||
track.downloaded = reg.downloaded_ids.contains(&tid);
|
||||
track.downloaded_format_ids =
|
||||
reg.format_map.get(&tid).cloned().unwrap_or_default();
|
||||
track.downloaded_format_ids = reg.format_map.get(&tid).cloned().unwrap_or_default();
|
||||
}
|
||||
}
|
||||
album.downloaded = svc::album_downloaded_from_registry(
|
||||
&album.id,
|
||||
album.tracks_count,
|
||||
®.album_counts,
|
||||
);
|
||||
album.downloaded =
|
||||
svc::album_downloaded_from_registry(&album.id, album.tracks_count, ®.album_counts);
|
||||
true
|
||||
});
|
||||
if refreshed {
|
||||
@@ -2275,17 +2335,20 @@ pub fn download_goodies() {
|
||||
|
||||
// The album folder the tracks went into, or the one they WOULD go into
|
||||
// — a booklet is worth downloading before any audio is.
|
||||
let folder = with_store(|st| st.get(&plan.album_id).and_then(|d| d.resolved_album_folder.clone()))
|
||||
.filter(|d| d != &plan.destination)
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(|| {
|
||||
album_dir_for(
|
||||
&plan.destination,
|
||||
&plan.artist,
|
||||
&plan.album_title,
|
||||
&plan.quality_dir,
|
||||
)
|
||||
});
|
||||
let folder = with_store(|st| {
|
||||
st.get(&plan.album_id)
|
||||
.and_then(|d| d.resolved_album_folder.clone())
|
||||
})
|
||||
.filter(|d| d != &plan.destination)
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(|| {
|
||||
album_dir_for(
|
||||
&plan.destination,
|
||||
&plan.artist,
|
||||
&plan.album_title,
|
||||
&plan.quality_dir,
|
||||
)
|
||||
});
|
||||
|
||||
for goody in &goodies {
|
||||
// A goodie failure never fails anything: `download_goodie` returns
|
||||
@@ -2321,8 +2384,11 @@ pub fn download_goodies() {
|
||||
/// (`database.rs:1105-1120`) and the gold badge in the Local Library.
|
||||
pub fn add_to_library() {
|
||||
let (album_id, destination) = with_detail(|s| {
|
||||
let dest = with_store(|st| st.get(&s.album_id).and_then(|d| d.resolved_album_folder.clone()))
|
||||
.unwrap_or_else(|| s.destination.clone());
|
||||
let dest = with_store(|st| {
|
||||
st.get(&s.album_id)
|
||||
.and_then(|d| d.resolved_album_folder.clone())
|
||||
})
|
||||
.unwrap_or_else(|| s.destination.clone());
|
||||
(s.album_id.clone(), dest)
|
||||
});
|
||||
if album_id.is_empty() || destination.trim().is_empty() {
|
||||
@@ -2523,7 +2589,10 @@ mod tests {
|
||||
// hiding a failure behind a dropdown change would hide the retry
|
||||
// control with it.
|
||||
let failed = state_with(Some(27), &[(1, DlStatus::Failed)]);
|
||||
assert_eq!(status_scoped(Some(&failed), 1, Some(7)), Some(DlStatus::Failed));
|
||||
assert_eq!(
|
||||
status_scoped(Some(&failed), 1, Some(7)),
|
||||
Some(DlStatus::Failed)
|
||||
);
|
||||
|
||||
let running = state_with(Some(27), &[(1, DlStatus::Downloading)]);
|
||||
assert_eq!(
|
||||
@@ -2534,7 +2603,10 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn all_complete_is_format_scoped_and_gates_add_to_library() {
|
||||
let state = state_with(Some(27), &[(1, DlStatus::Complete), (2, DlStatus::Complete)]);
|
||||
let state = state_with(
|
||||
Some(27),
|
||||
&[(1, DlStatus::Complete), (2, DlStatus::Complete)],
|
||||
);
|
||||
assert!(all_complete_for_format(Some(&state), Some(27)));
|
||||
assert!(!all_complete_for_format(Some(&state), Some(7)));
|
||||
assert!(!all_complete_for_format(Some(&state), None));
|
||||
@@ -2673,17 +2745,47 @@ mod tests {
|
||||
#[test]
|
||||
fn the_quality_filter_matches_the_reference_predicates() {
|
||||
assert!(matches_quality(QualityFilter::All, false, None, None));
|
||||
assert!(matches_quality(QualityFilter::Hires, true, Some(24), Some(96.0)));
|
||||
assert!(!matches_quality(QualityFilter::Hires, false, Some(16), Some(44.1)));
|
||||
assert!(matches_quality(
|
||||
QualityFilter::Hires,
|
||||
true,
|
||||
Some(24),
|
||||
Some(96.0)
|
||||
));
|
||||
assert!(!matches_quality(
|
||||
QualityFilter::Hires,
|
||||
false,
|
||||
Some(16),
|
||||
Some(44.1)
|
||||
));
|
||||
// CD: not hires AND (16-bit OR nothing known at all).
|
||||
assert!(matches_quality(QualityFilter::Cd, false, Some(16), Some(44.1)));
|
||||
assert!(matches_quality(
|
||||
QualityFilter::Cd,
|
||||
false,
|
||||
Some(16),
|
||||
Some(44.1)
|
||||
));
|
||||
assert!(matches_quality(QualityFilter::Cd, false, None, None));
|
||||
assert!(!matches_quality(QualityFilter::Cd, false, None, Some(44.1)));
|
||||
assert!(!matches_quality(QualityFilter::Cd, true, Some(16), Some(44.1)));
|
||||
assert!(!matches_quality(
|
||||
QualityFilter::Cd,
|
||||
true,
|
||||
Some(16),
|
||||
Some(44.1)
|
||||
));
|
||||
// Lossy: no bit depth, or under 16.
|
||||
assert!(matches_quality(QualityFilter::Lossy, false, None, Some(44.1)));
|
||||
assert!(matches_quality(
|
||||
QualityFilter::Lossy,
|
||||
false,
|
||||
None,
|
||||
Some(44.1)
|
||||
));
|
||||
assert!(matches_quality(QualityFilter::Lossy, false, Some(8), None));
|
||||
assert!(!matches_quality(QualityFilter::Lossy, false, Some(16), None));
|
||||
assert!(!matches_quality(
|
||||
QualityFilter::Lossy,
|
||||
false,
|
||||
Some(16),
|
||||
None
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -2767,7 +2869,12 @@ mod tests {
|
||||
"So What",
|
||||
"flac",
|
||||
);
|
||||
let derived = album_dir_for("/music", "Miles Davis", "Kind of Blue", "[FLAC][24-bit,96kHz]");
|
||||
let derived = album_dir_for(
|
||||
"/music",
|
||||
"Miles Davis",
|
||||
"Kind of Blue",
|
||||
"[FLAC][24-bit,96kHz]",
|
||||
);
|
||||
assert_eq!(track.parent().unwrap(), derived.as_path());
|
||||
assert_eq!(
|
||||
derived,
|
||||
|
||||
@@ -51,14 +51,12 @@ pub mod qbz_qconnect {
|
||||
// auto-connect success tail and the offline force-disconnect watcher —
|
||||
// the facade itself never writes it (1:1 with the reference).
|
||||
#[qproperty(bool, qconnect_connected)]
|
||||
|
||||
// --- Device picker -------------------------------------------------
|
||||
// JSON array of device rows (see the module header for the exact
|
||||
// shape). Rebuilt on every inbound event by the sink.
|
||||
#[qproperty(QString, devices_json)]
|
||||
// -1 = none.
|
||||
#[qproperty(i32, active_renderer_id)]
|
||||
|
||||
// --- Diagnostics modal (DeveloperSettings > QOBUZ CONNECT) ---------
|
||||
// QML-driven open/close (DeveloperSettings sets it true; the modal's
|
||||
// own close sets it false).
|
||||
@@ -67,7 +65,6 @@ pub mod qbz_qconnect {
|
||||
#[qproperty(QString, diag_status)]
|
||||
// The rolling 150-line event log, newest first.
|
||||
#[qproperty(QString, diag_log_text)]
|
||||
|
||||
type QbzQConnect = super::QbzQConnectRust;
|
||||
|
||||
/// Registers this object's Qt-thread hop (Main.qml boots EVERY
|
||||
|
||||
@@ -92,10 +92,14 @@ impl QconnectRendererEngine for QtRendererEngine {
|
||||
self.core().stop().map_err(|err| err.to_string())
|
||||
}
|
||||
fn seek(&self, position_secs: u64) -> Result<(), String> {
|
||||
self.core().seek(position_secs).map_err(|err| err.to_string())
|
||||
self.core()
|
||||
.seek(position_secs)
|
||||
.map_err(|err| err.to_string())
|
||||
}
|
||||
fn set_volume(&self, fraction: f32) -> Result<(), String> {
|
||||
self.core().set_volume(fraction).map_err(|err| err.to_string())
|
||||
self.core()
|
||||
.set_volume(fraction)
|
||||
.map_err(|err| err.to_string())
|
||||
}
|
||||
fn get_playback_state(&self) -> PlaybackState {
|
||||
self.core().get_playback_state()
|
||||
@@ -220,14 +224,10 @@ impl QconnectRendererEngine for QtRendererEngine {
|
||||
|
||||
fn current_output_format(&self) -> Option<(u32, u32)> {
|
||||
let player = self.core().player();
|
||||
Some((
|
||||
player.state.get_sample_rate(),
|
||||
player.state.get_bit_depth(),
|
||||
))
|
||||
Some((player.state.get_sample_rate(), player.state.get_bit_depth()))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
async fn download_remote_audio(url: &str) -> Result<Vec<u8>, String> {
|
||||
let response = reqwest::Client::new()
|
||||
.get(url)
|
||||
@@ -358,7 +358,12 @@ async fn probe_remote_stream_info(url: &str) -> Result<RemoteStreamInfo, String>
|
||||
.header("User-Agent", "Mozilla/5.0")
|
||||
.send()
|
||||
.await
|
||||
.map_err(|err| format!("probe HEAD request failed: {}", describe_reqwest_error(&err)))?;
|
||||
.map_err(|err| {
|
||||
format!(
|
||||
"probe HEAD request failed: {}",
|
||||
describe_reqwest_error(&err)
|
||||
)
|
||||
})?;
|
||||
|
||||
if !head_response.status().is_success() {
|
||||
return Err(format!(
|
||||
@@ -381,7 +386,12 @@ async fn probe_remote_stream_info(url: &str) -> Result<RemoteStreamInfo, String>
|
||||
.header("Range", "bytes=0-65535")
|
||||
.send()
|
||||
.await
|
||||
.map_err(|err| format!("probe range request failed: {}", describe_reqwest_error(&err)))?;
|
||||
.map_err(|err| {
|
||||
format!(
|
||||
"probe range request failed: {}",
|
||||
describe_reqwest_error(&err)
|
||||
)
|
||||
})?;
|
||||
|
||||
if !range_response.status().is_success() {
|
||||
return Err(format!(
|
||||
@@ -486,8 +496,12 @@ async fn download_and_stream_remote_track(
|
||||
let mut last_log_time = Instant::now();
|
||||
|
||||
while let Some(chunk_result) = stream.next().await {
|
||||
let chunk = chunk_result
|
||||
.map_err(|err| format!("remote streaming chunk failed: {}", describe_reqwest_error(&err)))?;
|
||||
let chunk = chunk_result.map_err(|err| {
|
||||
format!(
|
||||
"remote streaming chunk failed: {}",
|
||||
describe_reqwest_error(&err)
|
||||
)
|
||||
})?;
|
||||
bytes_received += chunk.len() as u64;
|
||||
|
||||
if let Err(err) = writer.push_chunk(&chunk) {
|
||||
|
||||
@@ -203,7 +203,10 @@ impl QtQconnectEventSink {
|
||||
let is_remote = transport_connected && is_peer_renderer_active(session);
|
||||
let active = session.active_renderer_id;
|
||||
let active_info = active.and_then(|active_id| {
|
||||
session.renderers.iter().find(|r| r.renderer_id == active_id)
|
||||
session
|
||||
.renderers
|
||||
.iter()
|
||||
.find(|r| r.renderer_id == active_id)
|
||||
});
|
||||
let cast_target = if is_remote {
|
||||
active_info
|
||||
@@ -302,8 +305,8 @@ impl QtQconnectEventSink {
|
||||
.await;
|
||||
|
||||
if let Some(loop_mode) = outcome.apply_loop_mode {
|
||||
if let Err(err) = qconnect_app::renderer::apply_remote_loop_mode(&self.engine, loop_mode)
|
||||
.await
|
||||
if let Err(err) =
|
||||
qconnect_app::renderer::apply_remote_loop_mode(&self.engine, loop_mode).await
|
||||
{
|
||||
log::warn!("[QConnect] Failed to apply remote loop mode: {err}");
|
||||
}
|
||||
@@ -503,8 +506,7 @@ impl QconnectEventSink for QtQconnectEventSink {
|
||||
} else {
|
||||
log::info!("[QConnect] Renderer command applied: {:?}", command);
|
||||
}
|
||||
let became_active =
|
||||
matches!(command, RendererCommand::SetActive { active: true });
|
||||
let became_active = matches!(command, RendererCommand::SetActive { active: true });
|
||||
if let Err(err) = qconnect_app::renderer::apply_renderer_command(
|
||||
&self.engine,
|
||||
&self.sync_state,
|
||||
@@ -560,9 +562,7 @@ impl QconnectEventSink for QtQconnectEventSink {
|
||||
// (Reference TODO(slint-qconnect-ui); kept unwired per §9 D6.)
|
||||
log::info!("[QConnect] Lifecycle -> {state:?} (UI badge TODO)");
|
||||
}
|
||||
QconnectAppEvent::Diagnostic {
|
||||
channel, level, ..
|
||||
} => {
|
||||
QconnectAppEvent::Diagnostic { channel, level, .. } => {
|
||||
log::debug!("[QConnect] diagnostic {channel} [{level}]");
|
||||
}
|
||||
_ => {}
|
||||
|
||||
@@ -53,16 +53,13 @@ use qconnect_app::queue_resolution::{
|
||||
resolve_controller_queue_item_from_snapshots, resolve_queue_item_ids_from_queue_state,
|
||||
QconnectRemoteSkipDirection,
|
||||
};
|
||||
use qconnect_app::renderer::{
|
||||
PLAYING_STATE_PAUSED, PLAYING_STATE_PLAYING, PLAYING_STATE_STOPPED,
|
||||
};
|
||||
use qconnect_app::renderer::{PLAYING_STATE_PAUSED, PLAYING_STATE_PLAYING, PLAYING_STATE_STOPPED};
|
||||
use qconnect_app::{
|
||||
build_effective_renderer_snapshot, ensure_session_renderer_state, is_local_renderer_active,
|
||||
is_peer_renderer_active, renderer_allows_remote_volume, QConnectQueueState,
|
||||
QConnectRendererState, QconnectApp, QconnectAppEvent, QconnectEventSink,
|
||||
queue_item_snapshot_for_cursor, QconnectFileAudioQualitySnapshot, QconnectLifecycleState,
|
||||
QconnectRemoteSyncState, QconnectSessionState, QueueCommandType, RendererReport,
|
||||
RendererReportType, SessionLoopHost,
|
||||
is_peer_renderer_active, queue_item_snapshot_for_cursor, renderer_allows_remote_volume,
|
||||
QConnectQueueState, QConnectRendererState, QconnectApp, QconnectAppEvent, QconnectEventSink,
|
||||
QconnectFileAudioQualitySnapshot, QconnectLifecycleState, QconnectRemoteSyncState,
|
||||
QconnectSessionState, QueueCommandType, RendererReport, RendererReportType, SessionLoopHost,
|
||||
};
|
||||
use qconnect_transport_ws::{NativeWsTransport, WsTransportConfig};
|
||||
use serde_json::{json, Value};
|
||||
@@ -75,8 +72,8 @@ use crate::qconnect_transport_qt::{
|
||||
build_set_position_player_state_request, default_qconnect_device_info,
|
||||
default_qconnect_device_info_with_name, load_persisted_device_name, resolve_transport_config,
|
||||
QconnectJoinSessionRequest, QconnectMuteVolumeRequest, QconnectQueueVersionPayload,
|
||||
QconnectSetPlayerStateQueueItemPayload, QconnectSetPlayerStateRequest, QconnectSetVolumeRequest,
|
||||
AUDIO_QUALITY_HIRES_LEVEL2, BUFFER_STATE_OK,
|
||||
QconnectSetPlayerStateQueueItemPayload, QconnectSetPlayerStateRequest,
|
||||
QconnectSetVolumeRequest, AUDIO_QUALITY_HIRES_LEVEL2, BUFFER_STATE_OK,
|
||||
};
|
||||
|
||||
const QCONNECT_PLAY_TRACK_HANDOFF_WAIT_MS: u64 = 1_500;
|
||||
@@ -788,7 +785,11 @@ impl QtQconnectService {
|
||||
let active_id = session.active_renderer_id;
|
||||
let local_id = session.local_renderer_id;
|
||||
if let Some(active_id) = active_id {
|
||||
if let Some(renderer) = session.renderers.iter().find(|r| r.renderer_id == active_id) {
|
||||
if let Some(renderer) = session
|
||||
.renderers
|
||||
.iter()
|
||||
.find(|r| r.renderer_id == active_id)
|
||||
{
|
||||
snap.active_name = renderer.friendly_name.clone();
|
||||
snap.active_brand = renderer.brand.clone();
|
||||
snap.active_model = renderer.model.clone();
|
||||
@@ -1215,8 +1216,11 @@ impl QtQconnectService {
|
||||
{
|
||||
let state = sync_state.lock().await;
|
||||
if let Some(applied) = &state.last_applied_queue_state {
|
||||
let applied_ids: Vec<u64> =
|
||||
applied.queue_items.iter().map(|item| item.track_id).collect();
|
||||
let applied_ids: Vec<u64> = applied
|
||||
.queue_items
|
||||
.iter()
|
||||
.map(|item| item.track_id)
|
||||
.collect();
|
||||
if applied_ids == ordered_ids {
|
||||
return;
|
||||
}
|
||||
@@ -1261,7 +1265,9 @@ impl QtQconnectService {
|
||||
log::info!(
|
||||
"[QConnect] Pushed local queue to Connect ({count} tracks, start={start_index})"
|
||||
);
|
||||
dev_push_event(format!("-> QueueLoadTracks {count} tracks start={start_index}"));
|
||||
dev_push_event(format!(
|
||||
"-> QueueLoadTracks {count} tracks start={start_index}"
|
||||
));
|
||||
*self.last_pushed_queue_ids.lock().await = Some(ordered_ids);
|
||||
}
|
||||
Err(err) => log::warn!("[QConnect] Failed to push local queue: {err}"),
|
||||
@@ -1350,9 +1356,9 @@ impl QtQconnectService {
|
||||
log::info!(
|
||||
"[QConnect] play_on_peer: pushed queue ({count} tracks, start={start_index})"
|
||||
);
|
||||
dev_push_event(
|
||||
format!("-> play_on_peer QueueLoadTracks {count} start={start_index}"),
|
||||
);
|
||||
dev_push_event(format!(
|
||||
"-> play_on_peer QueueLoadTracks {count} start={start_index}"
|
||||
));
|
||||
*self.last_pushed_queue_ids.lock().await = Some(ordered_ids);
|
||||
}
|
||||
Err(err) => {
|
||||
@@ -1437,9 +1443,9 @@ impl QtQconnectService {
|
||||
log::info!(
|
||||
"[QConnect] play_next_on_peer: inserted track {track_id} (after={insert_after:?})"
|
||||
);
|
||||
dev_push_event(
|
||||
format!("-> play_next QueueInsertTracks {track_id} after={insert_after:?}"),
|
||||
);
|
||||
dev_push_event(format!(
|
||||
"-> play_next QueueInsertTracks {track_id} after={insert_after:?}"
|
||||
));
|
||||
self.note_controller_insert(insert_after, &[track_id]).await;
|
||||
}
|
||||
Err(err) => {
|
||||
@@ -1458,7 +1464,11 @@ impl QtQconnectService {
|
||||
/// Mirrors the webplayer `queue_add_tracks` path: a single append command with
|
||||
/// a fresh `context_uuid`, `autoplay_reset: false`, `autoplay_loading: false`.
|
||||
/// Same admission + echo handling as `play_next_on_peer_if_active`.
|
||||
pub async fn add_to_queue_on_peer_if_active(&self, track_id: u64, source: Option<&str>) -> bool {
|
||||
pub async fn add_to_queue_on_peer_if_active(
|
||||
&self,
|
||||
track_id: u64,
|
||||
source: Option<&str>,
|
||||
) -> bool {
|
||||
let peer_active = self.is_peer_renderer_active().await;
|
||||
if !peer_active {
|
||||
return false;
|
||||
@@ -1469,7 +1479,9 @@ impl QtQconnectService {
|
||||
"[QConnect] add_to_queue_on_peer: track {track_id} not Qobuz-castable; refusing"
|
||||
);
|
||||
crate::toast_qt::error(qbz_i18n::t("Track not castable to Qobuz Connect"));
|
||||
dev_push_event(format!("-> add_to_queue REFUSED (non-Qobuz track {track_id})"));
|
||||
dev_push_event(format!(
|
||||
"-> add_to_queue REFUSED (non-Qobuz track {track_id})"
|
||||
));
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -1622,9 +1634,9 @@ impl QtQconnectService {
|
||||
log::info!(
|
||||
"[QConnect] play_next_batch_on_peer: inserted {count} tracks (after={insert_after:?})"
|
||||
);
|
||||
dev_push_event(
|
||||
format!("-> play_next QueueInsertTracks {count} tracks after={insert_after:?}"),
|
||||
);
|
||||
dev_push_event(format!(
|
||||
"-> play_next QueueInsertTracks {count} tracks after={insert_after:?}"
|
||||
));
|
||||
self.note_controller_insert(insert_after, &ids_u64).await;
|
||||
}
|
||||
Err(err) => {
|
||||
@@ -1680,7 +1692,9 @@ impl QtQconnectService {
|
||||
"[QConnect] play_later_on_peer: track {track_id} not Qobuz-castable; refusing"
|
||||
);
|
||||
crate::toast_qt::error(qbz_i18n::t("Track not castable to Qobuz Connect"));
|
||||
dev_push_event(format!("-> play_later REFUSED (non-Qobuz track {track_id})"));
|
||||
dev_push_event(format!(
|
||||
"-> play_later REFUSED (non-Qobuz track {track_id})"
|
||||
));
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -1704,9 +1718,9 @@ impl QtQconnectService {
|
||||
log::info!(
|
||||
"[QConnect] play_later_on_peer: inserted track {track_id} (after={insert_after:?})"
|
||||
);
|
||||
dev_push_event(
|
||||
format!("-> play_later QueueInsertTracks {track_id} after={insert_after:?}"),
|
||||
);
|
||||
dev_push_event(format!(
|
||||
"-> play_later QueueInsertTracks {track_id} after={insert_after:?}"
|
||||
));
|
||||
self.note_controller_insert(insert_after, &[track_id]).await;
|
||||
}
|
||||
Err(err) => {
|
||||
@@ -1750,11 +1764,11 @@ impl QtQconnectService {
|
||||
return false;
|
||||
}
|
||||
if !self.is_track_castable(track_id, source) {
|
||||
log::info!(
|
||||
"[QConnect] insert_at_slot: track {track_id} not Qobuz-castable; refusing"
|
||||
);
|
||||
log::info!("[QConnect] insert_at_slot: track {track_id} not Qobuz-castable; refusing");
|
||||
crate::toast_qt::error(qbz_i18n::t("Track not castable to Qobuz Connect"));
|
||||
dev_push_event(format!("-> insert_at_slot REFUSED (non-Qobuz track {track_id})"));
|
||||
dev_push_event(format!(
|
||||
"-> insert_at_slot REFUSED (non-Qobuz track {track_id})"
|
||||
));
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -1861,9 +1875,9 @@ impl QtQconnectService {
|
||||
log::info!(
|
||||
"[QConnect] play_later_batch_on_peer: inserted {count} tracks (after={insert_after:?})"
|
||||
);
|
||||
dev_push_event(
|
||||
format!("-> play_later QueueInsertTracks {count} tracks after={insert_after:?}"),
|
||||
);
|
||||
dev_push_event(format!(
|
||||
"-> play_later QueueInsertTracks {count} tracks after={insert_after:?}"
|
||||
));
|
||||
self.note_controller_insert(insert_after, &ids_u64).await;
|
||||
}
|
||||
Err(err) => {
|
||||
@@ -2091,8 +2105,11 @@ impl QtQconnectService {
|
||||
/// snapshot (`playing_state == PLAYING`). Title/artist/art come from the
|
||||
/// materialized local core queue, so only these three fields are needed here.
|
||||
pub async fn remote_now_playing(&self) -> Option<RemoteNowPlaying> {
|
||||
let (renderer, queue, _session) =
|
||||
self.effective_remote_renderer_snapshot().await.ok().flatten()?;
|
||||
let (renderer, queue, _session) = self
|
||||
.effective_remote_renderer_snapshot()
|
||||
.await
|
||||
.ok()
|
||||
.flatten()?;
|
||||
Some(RemoteNowPlaying {
|
||||
position_ms: renderer.current_position_ms.unwrap_or(0),
|
||||
updated_at_ms: renderer.updated_at_ms,
|
||||
@@ -2212,21 +2229,23 @@ impl QtQconnectService {
|
||||
}
|
||||
};
|
||||
log::info!("[QConnect] skip {direction_label} handoff skipped: {reason}");
|
||||
dev_push_event(format!("controller skip {direction_label}: local ({reason})"));
|
||||
dev_push_event(format!(
|
||||
"controller skip {direction_label}: local ({reason})"
|
||||
));
|
||||
return Ok(false);
|
||||
};
|
||||
|
||||
let resolution =
|
||||
resolve_controller_queue_item_from_snapshots(&queue, &renderer, direction);
|
||||
let resolution = resolve_controller_queue_item_from_snapshots(&queue, &renderer, direction);
|
||||
|
||||
let Some(target_queue_item_id) = resolution.target_queue_item_id else {
|
||||
log::warn!(
|
||||
"[QConnect] skip {direction_label} handoff: no target queue item resolved (strategy={})",
|
||||
resolution.strategy
|
||||
);
|
||||
dev_push_event(
|
||||
format!("controller skip {direction_label}: NO TARGET ({})", resolution.strategy),
|
||||
);
|
||||
dev_push_event(format!(
|
||||
"controller skip {direction_label}: NO TARGET ({})",
|
||||
resolution.strategy
|
||||
));
|
||||
return Err(format!(
|
||||
"remote renderer active but no {direction_label} target queue item could be resolved"
|
||||
));
|
||||
@@ -2259,9 +2278,10 @@ impl QtQconnectService {
|
||||
"[QConnect] skip {direction_label} handoff -> queue_item {target_queue_item_id} (strategy={})",
|
||||
resolution.strategy
|
||||
);
|
||||
dev_push_event(
|
||||
format!("controller skip {direction_label} -> qid {target_queue_item_id} (active={:?})", session.active_renderer_id),
|
||||
);
|
||||
dev_push_event(format!(
|
||||
"controller skip {direction_label} -> qid {target_queue_item_id} (active={:?})",
|
||||
session.active_renderer_id
|
||||
));
|
||||
|
||||
Ok(true)
|
||||
}
|
||||
@@ -2319,9 +2339,10 @@ impl QtQconnectService {
|
||||
.await;
|
||||
|
||||
log::info!("[QConnect] toggle_play handoff -> playing_state {next_playing_state}");
|
||||
dev_push_event(
|
||||
format!("controller toggle_play -> {next_playing_state} (active={:?})", session.active_renderer_id),
|
||||
);
|
||||
dev_push_event(format!(
|
||||
"controller toggle_play -> {next_playing_state} (active={:?})",
|
||||
session.active_renderer_id
|
||||
));
|
||||
|
||||
Ok(true)
|
||||
}
|
||||
@@ -2364,14 +2385,15 @@ impl QtQconnectService {
|
||||
state.last_load_attempt = Some((track_id, std::time::Instant::now()));
|
||||
}
|
||||
log::info!("[QConnect] play_track handoff skipped: {reason} (track {track_id})");
|
||||
dev_push_event(format!("controller play_track {track_id}: local ({reason})"));
|
||||
dev_push_event(format!(
|
||||
"controller play_track {track_id}: local ({reason})"
|
||||
));
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
let deadline = tokio::time::Instant::now()
|
||||
+ std::time::Duration::from_millis(QCONNECT_PLAY_TRACK_HANDOFF_WAIT_MS);
|
||||
let poll_interval =
|
||||
std::time::Duration::from_millis(QCONNECT_PLAY_TRACK_HANDOFF_POLL_MS);
|
||||
let poll_interval = std::time::Duration::from_millis(QCONNECT_PLAY_TRACK_HANDOFF_POLL_MS);
|
||||
let mut attempts: u32 = 0;
|
||||
loop {
|
||||
attempts += 1;
|
||||
@@ -2412,9 +2434,9 @@ impl QtQconnectService {
|
||||
log::info!(
|
||||
"[QConnect] play_track handoff -> qid {target_queue_item_id} (track {track_id}, attempts={attempts})"
|
||||
);
|
||||
dev_push_event(
|
||||
format!("controller play_track {track_id} -> qid {target_queue_item_id}"),
|
||||
);
|
||||
dev_push_event(format!(
|
||||
"controller play_track {track_id} -> qid {target_queue_item_id}"
|
||||
));
|
||||
|
||||
return Ok(true);
|
||||
}
|
||||
@@ -2429,7 +2451,9 @@ impl QtQconnectService {
|
||||
log::warn!(
|
||||
"[QConnect] play_track handoff: track {track_id} not present in remote queue after {QCONNECT_PLAY_TRACK_HANDOFF_WAIT_MS}ms"
|
||||
);
|
||||
dev_push_event(format!("controller play_track {track_id}: NOT IN QUEUE (timeout)"));
|
||||
dev_push_event(format!(
|
||||
"controller play_track {track_id}: NOT IN QUEUE (timeout)"
|
||||
));
|
||||
Err(format!(
|
||||
"remote renderer active but track {track_id} was not present in qconnect queue after {QCONNECT_PLAY_TRACK_HANDOFF_WAIT_MS}ms"
|
||||
))
|
||||
@@ -2513,10 +2537,14 @@ impl QtQconnectService {
|
||||
self.send_command(QueueCommandType::CtrlSrvrSetShuffleMode, payload)
|
||||
.await?;
|
||||
|
||||
log::info!("[QConnect] shuffle -> {next_shuffle} (cloud, active={:?})", session.active_renderer_id);
|
||||
dev_push_event(
|
||||
format!("shuffle -> {next_shuffle} (active={:?})", session.active_renderer_id),
|
||||
log::info!(
|
||||
"[QConnect] shuffle -> {next_shuffle} (cloud, active={:?})",
|
||||
session.active_renderer_id
|
||||
);
|
||||
dev_push_event(format!(
|
||||
"shuffle -> {next_shuffle} (active={:?})",
|
||||
session.active_renderer_id
|
||||
));
|
||||
|
||||
Ok(true)
|
||||
}
|
||||
@@ -2545,10 +2573,14 @@ impl QtQconnectService {
|
||||
self.send_command(QueueCommandType::CtrlSrvrSetLoopMode, payload)
|
||||
.await?;
|
||||
|
||||
log::info!("[QConnect] repeat -> {next_loop} (cloud, active={:?})", session.active_renderer_id);
|
||||
dev_push_event(
|
||||
format!("repeat -> {next_loop} (active={:?})", session.active_renderer_id),
|
||||
log::info!(
|
||||
"[QConnect] repeat -> {next_loop} (cloud, active={:?})",
|
||||
session.active_renderer_id
|
||||
);
|
||||
dev_push_event(format!(
|
||||
"repeat -> {next_loop} (active={:?})",
|
||||
session.active_renderer_id
|
||||
));
|
||||
|
||||
Ok(true)
|
||||
}
|
||||
@@ -2602,12 +2634,10 @@ impl QtQconnectService {
|
||||
"[QConnect] reorder upcoming {from_index} -> {to_index} (cloud, active={:?})",
|
||||
session.active_renderer_id
|
||||
);
|
||||
dev_push_event(
|
||||
format!(
|
||||
"reorder {from_index} -> {to_index} (active={:?})",
|
||||
session.active_renderer_id
|
||||
),
|
||||
);
|
||||
dev_push_event(format!(
|
||||
"reorder {from_index} -> {to_index} (active={:?})",
|
||||
session.active_renderer_id
|
||||
));
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
@@ -2650,9 +2680,10 @@ impl QtQconnectService {
|
||||
.await?;
|
||||
|
||||
log::info!("[QConnect] set_volume handoff -> {volume}");
|
||||
dev_push_event(
|
||||
format!("controller volume -> {volume} (active={:?})", session.active_renderer_id),
|
||||
);
|
||||
dev_push_event(format!(
|
||||
"controller volume -> {volume} (active={:?})",
|
||||
session.active_renderer_id
|
||||
));
|
||||
|
||||
Ok(true)
|
||||
}
|
||||
@@ -2674,9 +2705,10 @@ impl QtQconnectService {
|
||||
.await?;
|
||||
|
||||
log::info!("[QConnect] mute handoff -> {value}");
|
||||
dev_push_event(
|
||||
format!("controller mute -> {value} (active={:?})", session.active_renderer_id),
|
||||
);
|
||||
dev_push_event(format!(
|
||||
"controller mute -> {value} (active={:?})",
|
||||
session.active_renderer_id
|
||||
));
|
||||
|
||||
Ok(true)
|
||||
}
|
||||
@@ -2701,9 +2733,10 @@ impl QtQconnectService {
|
||||
.await?;
|
||||
|
||||
log::info!("[QConnect] set_autoplay_mode handoff -> {enabled}");
|
||||
dev_push_event(
|
||||
format!("controller autoplay -> {enabled} (active={:?})", session.active_renderer_id),
|
||||
);
|
||||
dev_push_event(format!(
|
||||
"controller autoplay -> {enabled} (active={:?})",
|
||||
session.active_renderer_id
|
||||
));
|
||||
|
||||
Ok(true)
|
||||
}
|
||||
@@ -2733,9 +2766,10 @@ impl QtQconnectService {
|
||||
.await?;
|
||||
|
||||
log::info!("[QConnect] autoplay_load_tracks handoff -> {track_count} tracks");
|
||||
dev_push_event(
|
||||
format!("controller autoplay_load {track_count} (active={:?})", session.active_renderer_id),
|
||||
);
|
||||
dev_push_event(format!(
|
||||
"controller autoplay_load {track_count} (active={:?})",
|
||||
session.active_renderer_id
|
||||
));
|
||||
|
||||
Ok(true)
|
||||
}
|
||||
@@ -2776,9 +2810,10 @@ impl QtQconnectService {
|
||||
.await;
|
||||
|
||||
log::info!("[QConnect] stop handoff");
|
||||
dev_push_event(
|
||||
format!("controller stop (active={:?})", session.active_renderer_id),
|
||||
);
|
||||
dev_push_event(format!(
|
||||
"controller stop (active={:?})",
|
||||
session.active_renderer_id
|
||||
));
|
||||
|
||||
Ok(true)
|
||||
}
|
||||
@@ -2816,9 +2851,10 @@ impl QtQconnectService {
|
||||
}
|
||||
|
||||
log::info!("[QConnect] set_position handoff -> {position_ms}ms");
|
||||
dev_push_event(
|
||||
format!("controller seek -> {position_ms}ms (active={:?})", session.active_renderer_id),
|
||||
);
|
||||
dev_push_event(format!(
|
||||
"controller seek -> {position_ms}ms (active={:?})",
|
||||
session.active_renderer_id
|
||||
));
|
||||
|
||||
Ok(true)
|
||||
}
|
||||
@@ -2836,9 +2872,9 @@ impl QtQconnectService {
|
||||
.ok_or_else(|| "QConnect service is not running".to_string())?
|
||||
};
|
||||
let handled = app.send_set_active_renderer(renderer_id).await?;
|
||||
dev_push_event(
|
||||
format!("controller set_active_renderer -> {renderer_id} (sent={handled})"),
|
||||
);
|
||||
dev_push_event(format!(
|
||||
"controller set_active_renderer -> {renderer_id} (sent={handled})"
|
||||
));
|
||||
Ok(handled)
|
||||
}
|
||||
}
|
||||
@@ -3277,7 +3313,10 @@ mod tests {
|
||||
#[test]
|
||||
fn qconnect_admits_catalog_and_offline_catalog_rows() {
|
||||
for source in ["qobuz", "qobuz_download", "qobuz_purchase", "offline"] {
|
||||
assert!(is_qconnect_queue_track(&track(Some(source), true)), "{source}");
|
||||
assert!(
|
||||
is_qconnect_queue_track(&track(Some(source), true)),
|
||||
"{source}"
|
||||
);
|
||||
}
|
||||
assert!(is_qconnect_queue_track(&track(None, false)));
|
||||
}
|
||||
@@ -3285,7 +3324,10 @@ mod tests {
|
||||
#[test]
|
||||
fn qconnect_rejects_every_server_and_file_source() {
|
||||
for source in ["local", "plex", "jellyfin", "subsonic", "navidrome"] {
|
||||
assert!(!is_qconnect_queue_track(&track(Some(source), true)), "{source}");
|
||||
assert!(
|
||||
!is_qconnect_queue_track(&track(Some(source), true)),
|
||||
"{source}"
|
||||
);
|
||||
}
|
||||
assert!(!is_qconnect_queue_track(&track(None, true)));
|
||||
}
|
||||
|
||||
@@ -574,7 +574,10 @@ fn normalize_opt_string(value: Option<String>) -> Option<String> {
|
||||
}
|
||||
|
||||
pub fn parse_subscribe_channels(items: Vec<String>) -> Result<Vec<Vec<u8>>, String> {
|
||||
items.into_iter().map(|item| decode_hex_channel(&item)).collect()
|
||||
items
|
||||
.into_iter()
|
||||
.map(|item| decode_hex_channel(&item))
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn decode_hex_channel(raw: &str) -> Result<Vec<u8>, String> {
|
||||
|
||||
@@ -43,7 +43,10 @@ pub(crate) fn dsd_multiple_label(sample_rate: Option<f64>) -> String {
|
||||
Some(r) if r >= 1_000.0 => r * 1000.0,
|
||||
_ => return "DSD".to_string(),
|
||||
};
|
||||
format!("DSD{}", ((hz / 2_822_400.0).round() as u32).saturating_mul(64))
|
||||
format!(
|
||||
"DSD{}",
|
||||
((hz / 2_822_400.0).round() as u32).saturating_mul(64)
|
||||
)
|
||||
}
|
||||
|
||||
/// `(tier, detail)` for `controls/QualityBadgeFull.qml`.
|
||||
|
||||
@@ -162,7 +162,10 @@ pub fn dsd_multiple_label(sample_rate: Option<f64>) -> String {
|
||||
Some(r) if r >= 1_000.0 => r * 1000.0,
|
||||
_ => return "DSD".to_string(),
|
||||
};
|
||||
format!("DSD{}", ((hz / 2_822_400.0).round() as u32).saturating_mul(64))
|
||||
format!(
|
||||
"DSD{}",
|
||||
((hz / 2_822_400.0).round() as u32).saturating_mul(64)
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -242,7 +245,10 @@ mod tests {
|
||||
QualityLimit::None as i32
|
||||
);
|
||||
// Ungoverned source (id 0) → none.
|
||||
assert_eq!(classify_limit_cause(true, 0, 1, 16), QualityLimit::None as i32);
|
||||
assert_eq!(
|
||||
classify_limit_cause(true, 0, 1, 16),
|
||||
QualityLimit::None as i32
|
||||
);
|
||||
// Requested Hi-Res+ and still short → Qobuz had no more.
|
||||
assert_eq!(
|
||||
classify_limit_cause(true, Quality::UltraHiRes.id(), 0, 24),
|
||||
@@ -251,12 +257,22 @@ mod tests {
|
||||
// Requested Hi-Res, delivered 24-bit → the promise held, so the
|
||||
// request-time cause did this.
|
||||
assert_eq!(
|
||||
classify_limit_cause(true, Quality::HiRes.id(), QualityLimit::Preference as i32, 24),
|
||||
classify_limit_cause(
|
||||
true,
|
||||
Quality::HiRes.id(),
|
||||
QualityLimit::Preference as i32,
|
||||
24
|
||||
),
|
||||
QualityLimit::Preference as i32
|
||||
);
|
||||
// Requested Hi-Res, delivered 16-bit → the promise broke: catalog.
|
||||
assert_eq!(
|
||||
classify_limit_cause(true, Quality::HiRes.id(), QualityLimit::Preference as i32, 16),
|
||||
classify_limit_cause(
|
||||
true,
|
||||
Quality::HiRes.id(),
|
||||
QualityLimit::Preference as i32,
|
||||
16
|
||||
),
|
||||
QualityLimit::CatalogAvailability as i32
|
||||
);
|
||||
}
|
||||
|
||||
@@ -32,16 +32,13 @@ pub mod qbz_queue {
|
||||
// --- Queue panel -------------------------------------------------
|
||||
// POC: empty until phase 4 (QML shows the empty states).
|
||||
#[qproperty(QList_QVariant, queue_model)]
|
||||
|
||||
// --- Queue panel (phase 9) -----------------------------------------
|
||||
// One JSON document (queue_qt.rs QueueDoc: current/upcoming/history
|
||||
// + pagination + #442 section markers). Supersedes `queueModel`.
|
||||
#[qproperty(QString, queue_json)]
|
||||
|
||||
// Full chronological queue projection used only by QueueView. It is
|
||||
// opt-in so ordinary sidebar publishes stay paged and cheap.
|
||||
#[qproperty(QString, extended_queue_json)]
|
||||
|
||||
// --- Immersive coverflow (2026-08-02 immersive-port contract §4.4) --
|
||||
// `{"index":i32,"tracks":[{id,title,artist,artUrl}]}` over the FULL
|
||||
// flat queue ([history oldest-first, NOW, upcoming]); `index` = flat
|
||||
@@ -50,7 +47,6 @@ pub mod qbz_queue {
|
||||
// immersive Queue badge + coverflow read THIS, never the paged
|
||||
// `queue_json`. Full-shape default, never "{}" (trap 15).
|
||||
#[qproperty(QString, coverflow_json)]
|
||||
|
||||
// --- Sleep timer (queue footer) ------------------------------------
|
||||
// Kept OUT of `queue_json`: the countdown reformats every second and
|
||||
// the document is re-parsed by three QML files on every change, so
|
||||
@@ -71,7 +67,6 @@ pub mod qbz_queue {
|
||||
#[qproperty(bool, drop_play_prompt)]
|
||||
#[qproperty(bool, sleep_active)]
|
||||
#[qproperty(QString, sleep_remaining)]
|
||||
|
||||
type QbzQueue = super::QbzQueueRust;
|
||||
|
||||
/// Registers this object's Qt-thread hop (Main.qml boots EVERY
|
||||
@@ -260,12 +255,7 @@ impl qbz_queue::QbzQueue {
|
||||
expected_id: QString,
|
||||
slot: i32,
|
||||
) {
|
||||
crate::queue_extended_drop(
|
||||
phase.to_string(),
|
||||
index,
|
||||
expected_id.to_string(),
|
||||
slot,
|
||||
);
|
||||
crate::queue_extended_drop(phase.to_string(), index, expected_id.to_string(), slot);
|
||||
}
|
||||
|
||||
pub fn queue_remove_upcoming_flat(self: Pin<&mut Self>, index: i32) {
|
||||
|
||||
@@ -221,7 +221,9 @@ pub(crate) fn prune_albums(album_ids: &[String]) -> usize {
|
||||
store
|
||||
.tracks
|
||||
.retain(|t| !album_ids.iter().any(|k| k == &t.album_id));
|
||||
store.albums.retain(|a| !album_ids.iter().any(|k| k == &a.id));
|
||||
store
|
||||
.albums
|
||||
.retain(|a| !album_ids.iter().any(|k| k == &a.id));
|
||||
let removed = tracks_before - store.tracks.len();
|
||||
if removed > 0 || albums_before != store.albums.len() {
|
||||
write_store(&store);
|
||||
|
||||
@@ -223,7 +223,10 @@ mod tests {
|
||||
dismiss(9, "Lost", ""); // no-op while unbound; must not panic
|
||||
init_for_user(&dir);
|
||||
assert!(ids_snapshot().contains(&42), "rows survive a re-bind");
|
||||
assert!(!ids_snapshot().contains(&9), "unbound mutation did not persist");
|
||||
assert!(
|
||||
!ids_snapshot().contains(&9),
|
||||
"unbound mutation did not persist"
|
||||
);
|
||||
|
||||
// Undo.
|
||||
remove(42);
|
||||
|
||||
@@ -88,10 +88,7 @@ static LOADED: AtomicBool = AtomicBool::new(false);
|
||||
static IN_FLIGHT: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
fn rows_snapshot() -> Rows {
|
||||
ROWS.lock()
|
||||
.ok()
|
||||
.and_then(|g| g.clone())
|
||||
.unwrap_or_default()
|
||||
ROWS.lock().ok().and_then(|g| g.clone()).unwrap_or_default()
|
||||
}
|
||||
|
||||
fn mutate(f: impl FnOnce(&mut Rows)) {
|
||||
@@ -294,7 +291,12 @@ fn publish_weeklies(download: bool) {
|
||||
"slimTracks",
|
||||
rows.weekly_exploration.clone(),
|
||||
),
|
||||
section(WEEKLY_JAMS, String::new(), "slimTracks", rows.weekly_jams.clone()),
|
||||
section(
|
||||
WEEKLY_JAMS,
|
||||
String::new(),
|
||||
"slimTracks",
|
||||
rows.weekly_jams.clone(),
|
||||
),
|
||||
]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
@@ -310,7 +312,8 @@ fn publish_weeklies(download: bool) {
|
||||
}
|
||||
let json = serde_json::to_string(&doc).unwrap_or_else(|_| "{}".to_string());
|
||||
crate::home_bridge::ui(move |mut b| {
|
||||
b.as_mut().set_reco_weekly_json(QString::from(json.as_str()));
|
||||
b.as_mut()
|
||||
.set_reco_weekly_json(QString::from(json.as_str()));
|
||||
});
|
||||
|
||||
if download && !missing.is_empty() {
|
||||
@@ -742,9 +745,11 @@ async fn run(force: bool) {
|
||||
// ListenBrainz's own weekly cadence and have their own per-week cache, so
|
||||
// one transient empty build must not hide them for the whole window.
|
||||
if !force {
|
||||
let cached = inputs
|
||||
.cache
|
||||
.and_then(|c| c.lock().ok().and_then(|g| g.get_results(&source_key, ttl_secs)));
|
||||
let cached = inputs.cache.and_then(|c| {
|
||||
c.lock()
|
||||
.ok()
|
||||
.and_then(|g| g.get_results(&source_key, ttl_secs))
|
||||
});
|
||||
if let Some(json) = cached {
|
||||
if let Ok(result) = serde_json::from_str::<ExternalCarousels>(&json) {
|
||||
apply_all(result, &followed);
|
||||
@@ -848,7 +853,10 @@ async fn run(force: bool) {
|
||||
results-cache write (likely transient; next open re-fetches)"
|
||||
);
|
||||
} else {
|
||||
let json = collector.lock().ok().and_then(|g| serde_json::to_string(&*g).ok());
|
||||
let json = collector
|
||||
.lock()
|
||||
.ok()
|
||||
.and_then(|g| serde_json::to_string(&*g).ok());
|
||||
if let (Ok(guard), Some(json)) = (cache_mutex.lock(), json) {
|
||||
guard.put_results(&source_key, &json);
|
||||
}
|
||||
@@ -875,10 +883,7 @@ async fn build_and_apply_weeklies(inputs: &RecoInputs<'_>) {
|
||||
if inputs.listenbrainz.is_none() {
|
||||
return;
|
||||
}
|
||||
let (explore, jams) = tokio::join!(
|
||||
build_weekly_exploration(inputs),
|
||||
build_weekly_jams(inputs)
|
||||
);
|
||||
let (explore, jams) = tokio::join!(build_weekly_exploration(inputs), build_weekly_jams(inputs));
|
||||
mutate(|rows| {
|
||||
rows.weekly_exploration = explore.iter().map(card_from_track).collect();
|
||||
rows.weekly_jams = jams.iter().map(card_from_track).collect();
|
||||
|
||||
@@ -449,8 +449,8 @@ pub fn apply_gpu_preference() {
|
||||
// A GPU pick needs Vulkan (see below), so it cannot also honour an explicit
|
||||
// `software`/`opengl` choice — the more specific, explicitly-chosen
|
||||
// RENDERER wins and this says so rather than quietly overriding it.
|
||||
if let Some(forced) = std::env::var_os("QSG_RHI_BACKEND")
|
||||
.or_else(|| std::env::var_os("QT_QUICK_BACKEND"))
|
||||
if let Some(forced) =
|
||||
std::env::var_os("QSG_RHI_BACKEND").or_else(|| std::env::var_os("QT_QUICK_BACKEND"))
|
||||
{
|
||||
log::info!(
|
||||
"[qbz-qt] gpu: the renderer row already forced {forced:?}; a GPU pick needs \
|
||||
@@ -461,16 +461,17 @@ pub fn apply_gpu_preference() {
|
||||
let pref = crate::settings_qt::pref_str("gpu_power", "auto");
|
||||
let Some(gpu) = resolve_gpu(&pref) else {
|
||||
if pref != "auto" && !pref.is_empty() {
|
||||
log::warn!(
|
||||
"[qbz-qt] gpu: '{pref}' matches no device present on this machine -> Auto"
|
||||
);
|
||||
log::warn!("[qbz-qt] gpu: '{pref}' matches no device present on this machine -> Auto");
|
||||
}
|
||||
return;
|
||||
};
|
||||
if gpu.index == default_gpu_index() {
|
||||
// Already the default: selecting it needs no override, and forcing one
|
||||
// would buy the Vulkan coupling below for nothing.
|
||||
log::info!("[qbz-qt] gpu: '{}' is device 0 (the default) -> no override", gpu.name);
|
||||
log::info!(
|
||||
"[qbz-qt] gpu: '{}' is device 0 (the default) -> no override",
|
||||
gpu.name
|
||||
);
|
||||
return;
|
||||
}
|
||||
// THE VULKAN COUPLING, and why it is the only honest route.
|
||||
@@ -545,7 +546,10 @@ mod tests {
|
||||
assert!(!reduce_motion(false), "GPU + desktop = full motion");
|
||||
assert!(reduce_motion(true), "kiosk forces it on regardless of tier");
|
||||
GPU_TIER.store(false, Ordering::SeqCst);
|
||||
assert!(reduce_motion(false), "a software tier forces it on by itself");
|
||||
assert!(
|
||||
reduce_motion(false),
|
||||
"a software tier forces it on by itself"
|
||||
);
|
||||
assert!(reduce_motion(true));
|
||||
GPU_TIER.store(true, Ordering::SeqCst);
|
||||
}
|
||||
|
||||
@@ -72,7 +72,10 @@ fn with_status<R>(f: impl FnOnce(&mut Status) -> R) -> R {
|
||||
|
||||
fn publish_status() {
|
||||
let json = with_status(|s| serde_json::to_string(s).unwrap_or_else(|_| "null".into()));
|
||||
ui(move |mut b| b.as_mut().set_local_rip_status(QString::from(json.as_str())));
|
||||
ui(move |mut b| {
|
||||
b.as_mut()
|
||||
.set_local_rip_status(QString::from(json.as_str()))
|
||||
});
|
||||
}
|
||||
|
||||
/// Show / hide the progress panel. The JOB is untouched either way — this
|
||||
@@ -207,7 +210,8 @@ pub fn run(
|
||||
(overall * 100.0) as u32
|
||||
);
|
||||
ui(move |mut b| {
|
||||
b.as_mut().set_local_rip_progress(QString::from(text.as_str()));
|
||||
b.as_mut()
|
||||
.set_local_rip_progress(QString::from(text.as_str()));
|
||||
});
|
||||
true
|
||||
})
|
||||
@@ -281,7 +285,11 @@ fn build_plan(
|
||||
if rows.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let album = if album.is_empty() { rows[0].album.clone() } else { album.to_string() };
|
||||
let album = if album.is_empty() {
|
||||
rows[0].album.clone()
|
||||
} else {
|
||||
album.to_string()
|
||||
};
|
||||
let album_artist = if album_artist.is_empty() {
|
||||
rows[0]
|
||||
.album_artist
|
||||
@@ -305,7 +313,11 @@ fn build_plan(
|
||||
let (title, artist) = named.get(i).cloned().unwrap_or_default();
|
||||
Some(qbz_rip::RipTrack {
|
||||
number: t.track_number.unwrap_or(i as u32 + 1),
|
||||
title: if title.is_empty() { t.title.clone() } else { title },
|
||||
title: if title.is_empty() {
|
||||
t.title.clone()
|
||||
} else {
|
||||
title
|
||||
},
|
||||
artist: match artist {
|
||||
a if !a.is_empty() => a,
|
||||
_ if t.artist.is_empty() => album_artist.clone(),
|
||||
|
||||
Loaded 100 of 132 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user