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 std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
use discord_rich_presence::{activity, DiscordIpc, DiscordIpcClient};
|
use discord_rich_presence::{activity, DiscordIpc, DiscordIpcClient};
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
/// Public Discord Application ID for QBZ. Public identifier, not a secret.
|
/// Public Discord Application ID for QBZ. Public identifier, not a secret.
|
||||||
const DISCORD_APP_ID: &str = "1501835855587708988";
|
const DISCORD_APP_ID: &str = "1501835855587708988";
|
||||||
@@ -102,6 +103,24 @@ pub struct NowListening {
|
|||||||
pub struct DiscordRpc {
|
pub struct DiscordRpc {
|
||||||
client: Mutex<Option<DiscordIpcClient>>,
|
client: Mutex<Option<DiscordIpcClient>>,
|
||||||
enabled: AtomicBool,
|
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 {
|
impl DiscordRpc {
|
||||||
@@ -109,6 +128,7 @@ impl DiscordRpc {
|
|||||||
Self {
|
Self {
|
||||||
client: Mutex::new(None),
|
client: Mutex::new(None),
|
||||||
enabled: AtomicBool::new(false),
|
enabled: AtomicBool::new(false),
|
||||||
|
retry_after: Mutex::new(None),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -124,6 +144,11 @@ impl DiscordRpc {
|
|||||||
if !enabled {
|
if !enabled {
|
||||||
self.clear();
|
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).
|
/// Clear the activity and drop the IPC connection (no-op when not connected).
|
||||||
@@ -151,6 +176,13 @@ impl DiscordRpc {
|
|||||||
};
|
};
|
||||||
|
|
||||||
if guard.is_none() {
|
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
|
// Bridge the Flatpak sandbox to Discord's IPC socket if we're
|
||||||
// running under Flatpak. No-op outside that context.
|
// running under Flatpak. No-op outside that context.
|
||||||
prepare_flatpak_discord_socket_links();
|
prepare_flatpak_discord_socket_links();
|
||||||
@@ -159,6 +191,13 @@ impl DiscordRpc {
|
|||||||
let mut client = DiscordIpcClient::new(DISCORD_APP_ID);
|
let mut client = DiscordIpcClient::new(DISCORD_APP_ID);
|
||||||
if client.connect().is_ok() {
|
if client.connect().is_ok() {
|
||||||
*guard = Some(client);
|
*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
|
// in #555 logs) — and each suppressed record now costs nothing, since
|
||||||
// `log!` checks the filter before formatting. An explicit RUST_LOG still
|
// `log!` checks the filter before formatting. An explicit RUST_LOG still
|
||||||
// replaces the whole default, so full zbus tracing stays one env var away.
|
// 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(
|
let inner = env_logger::Builder::from_env(
|
||||||
env_logger::Env::default()
|
env_logger::Env::default().default_filter_or(format!(
|
||||||
.default_filter_or(format!("{default_level},zbus=warn,tracing=warn")),
|
"{default_level},zbus=warn,tracing=warn,discord_rich_presence=error"
|
||||||
|
)),
|
||||||
)
|
)
|
||||||
.build();
|
.build();
|
||||||
let level = inner.filter();
|
let level = inner.filter();
|
||||||
|
|||||||
@@ -26,7 +26,6 @@ Rectangle {
|
|||||||
// window edge may opt into a lateral list without changing every other
|
// window edge may opt into a lateral list without changing every other
|
||||||
// QbzSelect instance. Supported values: "below" and "left".
|
// QbzSelect instance. Supported values: "below" and "left".
|
||||||
property string popupPlacement: "below"
|
property string popupPlacement: "below"
|
||||||
property bool enabled: true
|
|
||||||
property bool searchable: false
|
property bool searchable: false
|
||||||
// Bootstrap-style small variant (QbzSelect.slint:74-76). The default is the
|
// Bootstrap-style small variant (QbzSelect.slint:74-76). The default is the
|
||||||
// standard Settings size; TOOLBARS opt in with `sm: true` — 30px tall, r6,
|
// standard Settings size; TOOLBARS opt in with `sm: true` — 30px tall, r6,
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ Rectangle {
|
|||||||
id: root
|
id: root
|
||||||
|
|
||||||
property bool checked: false
|
property bool checked: false
|
||||||
property bool enabled: true
|
|
||||||
signal toggled(bool value)
|
signal toggled(bool value)
|
||||||
|
|
||||||
QbzTheme { id: theme }
|
QbzTheme { id: theme }
|
||||||
|
|||||||
@@ -20,7 +20,6 @@ Rectangle {
|
|||||||
property string trailingIconName: ""
|
property string trailingIconName: ""
|
||||||
property bool danger: false
|
property bool danger: false
|
||||||
property bool busy: false
|
property bool busy: false
|
||||||
property bool enabled: true
|
|
||||||
// The two metrics the MyQBZ modal footers and the Disco builder need. The
|
// 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
|
// 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
|
// 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 primaryColor: QbzShell.ambientPrimary
|
||||||
readonly property color accentColor: QbzShell.ambientAccent
|
readonly property color accentColor: QbzShell.ambientAccent
|
||||||
readonly property color secondaryColor: QbzShell.ambientSecondary
|
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.primaryColor,
|
||||||
root.accentColor,
|
root.accentColor,
|
||||||
root.secondaryColor,
|
root.secondaryColor,
|
||||||
@@ -65,15 +67,15 @@ Rectangle {
|
|||||||
readonly property var bandNames: ["SUB", "BASS", "MID", "PRES", "AIR"]
|
readonly property var bandNames: ["SUB", "BASS", "MID", "PRES", "AIR"]
|
||||||
|
|
||||||
function paletteColor(position, alpha) {
|
function paletteColor(position, alpha) {
|
||||||
var count = root.palette.length
|
var count = root.ringPalette.length
|
||||||
var wrapped = position % count
|
var wrapped = position % count
|
||||||
if (wrapped < 0)
|
if (wrapped < 0)
|
||||||
wrapped += count
|
wrapped += count
|
||||||
var lo = Math.floor(wrapped)
|
var lo = Math.floor(wrapped)
|
||||||
var hi = (lo + 1) % count
|
var hi = (lo + 1) % count
|
||||||
var mix = wrapped - lo
|
var mix = wrapped - lo
|
||||||
var a = Qt.lighter(root.palette[lo], 1.0)
|
var a = Qt.lighter(root.ringPalette[lo], 1.0)
|
||||||
var b = Qt.lighter(root.palette[hi], 1.0)
|
var b = Qt.lighter(root.ringPalette[hi], 1.0)
|
||||||
return Qt.rgba(a.r + (b.r - a.r) * mix,
|
return Qt.rgba(a.r + (b.r - a.r) * mix,
|
||||||
a.g + (b.g - a.g) * mix,
|
a.g + (b.g - a.g) * mix,
|
||||||
a.b + (b.b - a.b) * mix,
|
a.b + (b.b - a.b) * mix,
|
||||||
@@ -181,7 +183,7 @@ Rectangle {
|
|||||||
"speed": 0.006 + intensity * 0.013,
|
"speed": 0.006 + intensity * 0.013,
|
||||||
"color": root.nextColor
|
"color": root.nextColor
|
||||||
})
|
})
|
||||||
root.nextColor = (root.nextColor + 1) % root.palette.length
|
root.nextColor = (root.nextColor + 1) % root.ringPalette.length
|
||||||
if (next.length > root.maxPulseRings)
|
if (next.length > root.maxPulseRings)
|
||||||
next.shift()
|
next.shift()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1001,11 +1001,22 @@ Rectangle {
|
|||||||
anchors.rightMargin: 10
|
anchors.rightMargin: 10
|
||||||
anchors.verticalCenter: parent.verticalCenter
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
spacing: 0
|
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 {
|
Text {
|
||||||
id: compactAlbumTitle
|
id: compactAlbumTitle
|
||||||
width: (albumHeader.artist || "") === ""
|
width: (albumHeader.artist || "") === ""
|
||||||
? compactHeading.width
|
? compactHeading.width
|
||||||
: Math.min(implicitWidth,
|
: Math.min(Math.ceil(compactTitleMetrics.advanceWidth),
|
||||||
Math.max(80, compactHeading.width * 0.62))
|
Math.max(80, compactHeading.width * 0.62))
|
||||||
text: albumHeader.title || ""
|
text: albumHeader.title || ""
|
||||||
color: root.hdrStrong
|
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> {
|
fn with_discover<T>(f: impl FnOnce(&DiscoverPrefsStore) -> T) -> Option<T> {
|
||||||
let cell = DISCOVER.get_or_init(|| {
|
let cell = DISCOVER.get_or_init(|| {
|
||||||
let store =
|
// Before the session restores there is no user directory yet; that
|
||||||
crate::sidebar_qt::user_dir().and_then(|dir| DiscoverPrefsStore::new_at(&dir).ok());
|
// is the normal startup order, not a failure (`bind_qobuz_user`
|
||||||
if store.is_none() {
|
// replaces this handle on activation). Only a directory that exists
|
||||||
log::warn!("[qbz-qt] discover prefs store unavailable");
|
// 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)
|
Mutex::new(store)
|
||||||
});
|
});
|
||||||
let guard = cell.lock().ok()?;
|
let guard = cell.lock().ok()?;
|
||||||
|
|||||||
Reference in new issue
Block a user