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:
vicrodh committed 2026-08-25 18:22:05 -06:00
1 parent 9c22a909d6
commit 720e6d33de
132 files changed
+2418 -1301

No files matched your search

-1
View File
@@ -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
+8 -2
View File
@@ -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()
)
);
}
+29 -6
View File
@@ -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(),
);
}
}
+19 -13
View File
@@ -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(" · "),
+49 -36
View File
@@ -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
},
);
+26 -27
View File
@@ -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
+20 -5
View File
@@ -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);
+18 -4
View File
@@ -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:
+60 -33
View File
@@ -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,
+4 -6
View File
@@ -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;
}
+72 -14
View File
@@ -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.
+4 -1
View File
@@ -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]
+5 -2
View File
@@ -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);
}
+1 -2
View File
@@ -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;
}
-1
View File
@@ -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
+2 -5
View File
@@ -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}");
+11 -4
View File
@@ -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"));
-1
View File
@@ -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
+6 -23
View File
@@ -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()
};
+16 -7
View File
@@ -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)
}
+35 -13
View File
@@ -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
);
}
}
-1
View File
@@ -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
+8 -4
View File
@@ -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;
+54 -24
View File
@@ -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",
-1
View File
@@ -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
+16 -4
View File
@@ -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(),
+1 -6
View File
@@ -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),
+2 -3
View File
@@ -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())
}
// ---------------------------------------------------------------------------
+32 -8
View File
@@ -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)
}
// ---------------------------------------------------------------------------
-1
View File
@@ -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()`
+15 -4
View File
@@ -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())
);
}
}
+3 -1
View File
@@ -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.
+23 -8
View File
@@ -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}");
}
});
+7 -2
View File
@@ -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;
+1 -4
View File
@@ -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
+8 -7
View File
@@ -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
View File
@@ -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()
}
);
}
+6 -3
View File
@@ -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);
}
+17 -5
View File
@@ -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) => {
-1
View File
@@ -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.
+9 -2
View File
@@ -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]
+33 -15
View File
@@ -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");
+16 -3
View File
@@ -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(),
+9 -5
View File
@@ -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
+23 -36
View File
@@ -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;
}
+16 -7
View File
@@ -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(),
+8 -2
View File
@@ -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;
}
+5 -5
View File
@@ -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) {
}
}
}
+5 -1
View File
@@ -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
+4 -17
View File
@@ -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) =
+5 -7
View File
@@ -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);
-1
View File
@@ -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
+9 -4
View File
@@ -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| {
-1
View File
@@ -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
+6 -7
View File
@@ -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 {
-1
View File
@@ -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
+7 -2
View File
@@ -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", "", " "));
}
+84 -37
View File
@@ -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);
});
+4 -6
View File
@@ -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()
}
+11 -15
View File
@@ -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,
+2 -4
View File
@@ -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
+1 -3
View File
@@ -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);
}
+30 -39
View File
@@ -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());
}
+8 -2
View File
@@ -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());
+4 -11
View File
@@ -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);
+7 -2
View File
@@ -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()))
});
}
+10 -16
View File
@@ -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;
+3 -2
View File
@@ -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
+7 -2
View File
@@ -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()
})
+2 -1
View File
@@ -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);
+26 -7
View File
@@ -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(),
+5 -1
View File
@@ -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) => {
+2 -12
View File
@@ -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>) {
+7 -18
View File
@@ -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
+3 -6
View File
@@ -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,
+72 -82
View File
@@ -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
}
+19 -10
View File
@@ -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;
}
}
+3 -10
View File
@@ -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
+6 -1
View File
@@ -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 {}",
+80 -38
View File
@@ -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
+5 -1
View File
@@ -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;
}
-1
View File
@@ -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
+166 -59
View File
@@ -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, &reg);
let (albums, tracks) = annotate(filtered.albums.items, filtered.tracks.items, &reg);
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, &reg.downloaded_ids, &reg.format_map);
let album =
svc::build_purchase_album(&catalog, &purchases, &reg.downloaded_ids, &reg.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, &reg);
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,
&reg.album_counts,
);
album.downloaded =
svc::album_downloaded_from_registry(&album.id, album.tracks_count, &reg.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,
-3
View File
@@ -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
+25 -11
View File
@@ -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) {
+8 -8
View File
@@ -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}]");
}
_ => {}
+134 -92
View File
@@ -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)));
}
+4 -1
View File
@@ -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> {
+4 -1
View File
@@ -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`.
+20 -4
View File
@@ -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
);
}
+1 -11
View File
@@ -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) {
+3 -1
View File
@@ -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);
+4 -1
View File
@@ -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);
+19 -14
View File
@@ -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();
+11 -7
View File
@@ -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);
}
+16 -4
View File
@@ -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