merge: integrate the listening-session QA fixes into pre-release
cc
This commit is contained in:
8 files changed
+97
-16
No files matched your search
@@ -17,6 +17,7 @@ use std::sync::Mutex;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use discord_rich_presence::{activity, DiscordIpc, DiscordIpcClient};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
/// Public Discord Application ID for QBZ. Public identifier, not a secret.
|
||||
const DISCORD_APP_ID: &str = "1501835855587708988";
|
||||
@@ -102,6 +103,24 @@ pub struct NowListening {
|
||||
pub struct DiscordRpc {
|
||||
client: Mutex<Option<DiscordIpcClient>>,
|
||||
enabled: AtomicBool,
|
||||
/// Set after a failed IPC connect: no new attempt before this instant.
|
||||
/// Without it every now-playing edge (two per track: the immediate
|
||||
/// publish and the poll's) knocked on a socket that is not there and
|
||||
/// the crate logged a warning each time — with Discord closed, a
|
||||
/// listening session read as an error stream.
|
||||
retry_after: Mutex<Option<Instant>>,
|
||||
}
|
||||
|
||||
/// How long a failed IPC connect keeps the integration quiet. A track edge
|
||||
/// inside the window pushes nothing; the first edge after it retries.
|
||||
pub const CONNECT_RETRY_AFTER: Duration = Duration::from_secs(300);
|
||||
|
||||
/// Pure backoff rule: attempt when no deadline is set or it has passed.
|
||||
pub fn connect_allowed(now: Instant, retry_after: Option<Instant>) -> bool {
|
||||
match retry_after {
|
||||
None => true,
|
||||
Some(deadline) => now >= deadline,
|
||||
}
|
||||
}
|
||||
|
||||
impl DiscordRpc {
|
||||
@@ -109,6 +128,7 @@ impl DiscordRpc {
|
||||
Self {
|
||||
client: Mutex::new(None),
|
||||
enabled: AtomicBool::new(false),
|
||||
retry_after: Mutex::new(None),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -124,6 +144,11 @@ impl DiscordRpc {
|
||||
if !enabled {
|
||||
self.clear();
|
||||
}
|
||||
// Opting in (again) is an explicit request: forget any backoff so the
|
||||
// next update connects right away.
|
||||
if let Ok(mut retry) = self.retry_after.lock() {
|
||||
*retry = None;
|
||||
}
|
||||
}
|
||||
|
||||
/// Clear the activity and drop the IPC connection (no-op when not connected).
|
||||
@@ -151,6 +176,13 @@ impl DiscordRpc {
|
||||
};
|
||||
|
||||
if guard.is_none() {
|
||||
let now = Instant::now();
|
||||
let Ok(mut retry) = self.retry_after.lock() else {
|
||||
return;
|
||||
};
|
||||
if !connect_allowed(now, *retry) {
|
||||
return;
|
||||
}
|
||||
// Bridge the Flatpak sandbox to Discord's IPC socket if we're
|
||||
// running under Flatpak. No-op outside that context.
|
||||
prepare_flatpak_discord_socket_links();
|
||||
@@ -159,6 +191,13 @@ impl DiscordRpc {
|
||||
let mut client = DiscordIpcClient::new(DISCORD_APP_ID);
|
||||
if client.connect().is_ok() {
|
||||
*guard = Some(client);
|
||||
*retry = None;
|
||||
} else {
|
||||
*retry = Some(now + CONNECT_RETRY_AFTER);
|
||||
log::info!(
|
||||
"[discord] Discord is not reachable (not running, or its socket is not visible); next attempt in {} s",
|
||||
CONNECT_RETRY_AFTER.as_secs()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -208,3 +247,19 @@ impl DiscordRpc {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod backoff_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn connect_backoff_waits_for_the_deadline_then_retries() {
|
||||
let now = Instant::now();
|
||||
assert!(connect_allowed(now, None));
|
||||
let deadline = now + CONNECT_RETRY_AFTER;
|
||||
assert!(!connect_allowed(now, Some(deadline)));
|
||||
assert!(!connect_allowed(now + CONNECT_RETRY_AFTER / 2, Some(deadline)));
|
||||
assert!(connect_allowed(deadline, Some(deadline)));
|
||||
assert!(connect_allowed(deadline + Duration::from_secs(1), Some(deadline)));
|
||||
}
|
||||
}
|
||||
@@ -60,9 +60,13 @@ fn install_with_file_sink(default_level: &str, file_name: Option<&str>) {
|
||||
// in #555 logs) — and each suppressed record now costs nothing, since
|
||||
// `log!` checks the filter before formatting. An explicit RUST_LOG still
|
||||
// replaces the whole default, so full zbus tracing stays one env var away.
|
||||
// discord_rich_presence warns on every failed IPC connect ("find_pipe:
|
||||
// could not find pipe"); qbz-integrations logs its own single line and
|
||||
// backs off, so the crate's copy only needs to surface real errors.
|
||||
let inner = env_logger::Builder::from_env(
|
||||
env_logger::Env::default()
|
||||
.default_filter_or(format!("{default_level},zbus=warn,tracing=warn")),
|
||||
env_logger::Env::default().default_filter_or(format!(
|
||||
"{default_level},zbus=warn,tracing=warn,discord_rich_presence=error"
|
||||
)),
|
||||
)
|
||||
.build();
|
||||
let level = inner.filter();
|
||||
|
||||
@@ -26,7 +26,6 @@ Rectangle {
|
||||
// window edge may opt into a lateral list without changing every other
|
||||
// QbzSelect instance. Supported values: "below" and "left".
|
||||
property string popupPlacement: "below"
|
||||
property bool enabled: true
|
||||
property bool searchable: false
|
||||
// Bootstrap-style small variant (QbzSelect.slint:74-76). The default is the
|
||||
// standard Settings size; TOOLBARS opt in with `sm: true` — 30px tall, r6,
|
||||
|
||||
@@ -14,7 +14,6 @@ Rectangle {
|
||||
id: root
|
||||
|
||||
property bool checked: false
|
||||
property bool enabled: true
|
||||
signal toggled(bool value)
|
||||
|
||||
QbzTheme { id: theme }
|
||||
|
||||
@@ -20,7 +20,6 @@ Rectangle {
|
||||
property string trailingIconName: ""
|
||||
property bool danger: false
|
||||
property bool busy: false
|
||||
property bool enabled: true
|
||||
// The two metrics the MyQBZ modal footers and the Disco builder need. The
|
||||
// Slint's SecondaryButton is 34px/min-160 in Settings and 38px/hug in a
|
||||
// modal footer, which is one control with two call-site numbers — not two
|
||||
|
||||
@@ -53,7 +53,9 @@ Rectangle {
|
||||
readonly property color primaryColor: QbzShell.ambientPrimary
|
||||
readonly property color accentColor: QbzShell.ambientAccent
|
||||
readonly property color secondaryColor: QbzShell.ambientSecondary
|
||||
readonly property var palette: [
|
||||
// `ringPalette`, not `palette`: Item already owns a `palette` (QPalette)
|
||||
// and shadowing it drew a propertyCache warning on every start.
|
||||
readonly property var ringPalette: [
|
||||
root.primaryColor,
|
||||
root.accentColor,
|
||||
root.secondaryColor,
|
||||
@@ -65,15 +67,15 @@ Rectangle {
|
||||
readonly property var bandNames: ["SUB", "BASS", "MID", "PRES", "AIR"]
|
||||
|
||||
function paletteColor(position, alpha) {
|
||||
var count = root.palette.length
|
||||
var count = root.ringPalette.length
|
||||
var wrapped = position % count
|
||||
if (wrapped < 0)
|
||||
wrapped += count
|
||||
var lo = Math.floor(wrapped)
|
||||
var hi = (lo + 1) % count
|
||||
var mix = wrapped - lo
|
||||
var a = Qt.lighter(root.palette[lo], 1.0)
|
||||
var b = Qt.lighter(root.palette[hi], 1.0)
|
||||
var a = Qt.lighter(root.ringPalette[lo], 1.0)
|
||||
var b = Qt.lighter(root.ringPalette[hi], 1.0)
|
||||
return Qt.rgba(a.r + (b.r - a.r) * mix,
|
||||
a.g + (b.g - a.g) * mix,
|
||||
a.b + (b.b - a.b) * mix,
|
||||
@@ -181,7 +183,7 @@ Rectangle {
|
||||
"speed": 0.006 + intensity * 0.013,
|
||||
"color": root.nextColor
|
||||
})
|
||||
root.nextColor = (root.nextColor + 1) % root.palette.length
|
||||
root.nextColor = (root.nextColor + 1) % root.ringPalette.length
|
||||
if (next.length > root.maxPulseRings)
|
||||
next.shift()
|
||||
}
|
||||
|
||||
@@ -1001,11 +1001,22 @@ Rectangle {
|
||||
anchors.rightMargin: 10
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
spacing: 0
|
||||
// The title's natural width, measured OUTSIDE the
|
||||
// Text: an elided Text recomputes its implicit size
|
||||
// after laying out at the bound width, so a `width`
|
||||
// that reads its own `implicitWidth` is a binding
|
||||
// loop (Qt reported it on every album open).
|
||||
TextMetrics {
|
||||
id: compactTitleMetrics
|
||||
font.pixelSize: theme.fontSection
|
||||
font.weight: theme.weightBold
|
||||
text: albumHeader.title || ""
|
||||
}
|
||||
Text {
|
||||
id: compactAlbumTitle
|
||||
width: (albumHeader.artist || "") === ""
|
||||
? compactHeading.width
|
||||
: Math.min(implicitWidth,
|
||||
: Math.min(Math.ceil(compactTitleMetrics.advanceWidth),
|
||||
Math.max(80, compactHeading.width * 0.62))
|
||||
text: albumHeader.title || ""
|
||||
color: root.hdrStrong
|
||||
|
||||
@@ -122,11 +122,23 @@ 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());
|
||||
if store.is_none() {
|
||||
log::warn!("[qbz-qt] discover prefs store unavailable");
|
||||
}
|
||||
// Before the session restores there is no user directory yet; that
|
||||
// is the normal startup order, not a failure (`bind_qobuz_user`
|
||||
// replaces this handle on activation). Only a directory that exists
|
||||
// and still cannot open its store is worth a warning.
|
||||
let store = match crate::sidebar_qt::user_dir() {
|
||||
None => {
|
||||
log::debug!("[qbz-qt] discover prefs store not bound yet: no active user");
|
||||
None
|
||||
}
|
||||
Some(dir) => match DiscoverPrefsStore::new_at(&dir) {
|
||||
Ok(store) => Some(store),
|
||||
Err(e) => {
|
||||
log::warn!("[qbz-qt] discover prefs store unavailable: {e}");
|
||||
None
|
||||
}
|
||||
},
|
||||
};
|
||||
Mutex::new(store)
|
||||
});
|
||||
let guard = cell.lock().ok()?;
|
||||
|
||||
Reference in new issue
Block a user