feat(qbz-qt): wire every integration to its real backend
The Integrations pane rendered rows that did nothing: Last.fm, ListenBrainz, MusicBrainz and Discord all had prefs but no runtime behind them, and Discord never pushed a presence at all. Each row now drives the qbz-integrations APIs for real — connect/disconnect state, the account label once connected, and the per-service toggles — and the runtime starts on shell entry, applying the persisted opt-ins and the scrobble queue's flush watcher. Scrobbles arm on the de-duped track edge (firing from the republish path would re-arm the timer on every poll tick) and the Discord presence refreshes on both the track and the play/pause edges, then clears on logout so a signed-out session stops advertising what was playing. All of it stays strictly opt-in: nothing here does anything until the user connects the service. cc
This commit is contained in:
4 files changed
+759
-46
No files matched your search
@@ -1,16 +1,14 @@
|
||||
// Settings > Integrations (phase 19) — the QML port of
|
||||
// Settings > Integrations — the QML port of
|
||||
// crates/qbz-ui/ui/settings/IntegrationsSettings.slint. Row order, group
|
||||
// headers and auth-gating mirror the Slint 1:1; all state rides the single
|
||||
// settingsJson document (integrations_qt.rs over the SAME scrobbler /
|
||||
// discover / ui_prefs stores the Slint app uses).
|
||||
// discover / ui_prefs / offline-queue stores the Slint app uses).
|
||||
//
|
||||
// POC-NOTEs (named in integrations_qt.rs too):
|
||||
// - No live scrobbling (scrobble::start / on_track_changed not ported) —
|
||||
// toggles + credentials persist for the Slint scrobbler / a future port.
|
||||
// - Discord: the pref + live DiscordRpc flag flip; presence updates are
|
||||
// not fed (no NowListening pushes on track change).
|
||||
// - The Last.fm connect flow opens the system browser (open::that) — the
|
||||
// offscreen smoke never clicks it.
|
||||
// Every integration here is STRICTLY OPT-IN: nothing connects, fires or
|
||||
// queues until the user signs the service in and its toggles are on.
|
||||
//
|
||||
// NOTE: the Last.fm connect flow opens the system browser (open::that) — the
|
||||
// offscreen smoke never clicks it.
|
||||
|
||||
import QtQuick
|
||||
import com.blitzfc.qbz
|
||||
@@ -26,6 +24,21 @@ Column {
|
||||
|
||||
spacing: 4
|
||||
|
||||
// The Slint mirrors the in-progress token in
|
||||
// ScrobbleState.listenbrainz-token-input so its "Connect ListenBrainz"
|
||||
// button can submit it. QbzLineEdit only publishes the COMMITTED value
|
||||
// (it commits on Enter / focus loss, and a SettingsButton click does not
|
||||
// steal focus from the TextInput), so read the live value off its inner
|
||||
// input — the one child that has an echoMode.
|
||||
function lbTokenText() {
|
||||
for (var i = 0; i < lbTokenField.children.length; ++i) {
|
||||
var c = lbTokenField.children[i]
|
||||
if (c && c.echoMode !== undefined)
|
||||
return c.text
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// ======================= RECOMMENDATIONS =============================
|
||||
GroupHeader { text: QbzSession.tr("RECOMMENDATIONS", QbzSession.trRev) }
|
||||
SettingRow {
|
||||
@@ -99,9 +112,10 @@ Column {
|
||||
color: colArea.containsMouse ? theme.surfaceHover : "transparent"
|
||||
QbzIcon {
|
||||
anchors.centerIn: parent
|
||||
// 28px hit-box / 18px glyph (IntegrationsSettings.slint:129).
|
||||
name: root.doc.scrobbleUiCollapsed === true ? "chevron-right" : "chevron-down"
|
||||
width: 16
|
||||
height: 16
|
||||
width: 18
|
||||
height: 18
|
||||
tintName: "secondary"
|
||||
}
|
||||
MouseArea {
|
||||
@@ -155,7 +169,10 @@ Column {
|
||||
}
|
||||
SettingsButton {
|
||||
visible: (root.doc.lastfmAuthUrl || "") !== ""
|
||||
text: QbzSession.tr("Finish", QbzSession.trRev)
|
||||
text: root.doc.lastfmBusy === true
|
||||
? QbzSession.tr("Working...", QbzSession.trRev)
|
||||
: QbzSession.tr("Finish", QbzSession.trRev)
|
||||
enabled: root.doc.lastfmBusy !== true
|
||||
onClicked: QbzBridge.integrationsAction("lastfm-finish")
|
||||
}
|
||||
}
|
||||
@@ -190,9 +207,27 @@ Column {
|
||||
label: QbzSession.tr("User token", QbzSession.trRev)
|
||||
description: QbzSession.tr("From listenbrainz.org/settings.", QbzSession.trRev)
|
||||
QbzLineEdit {
|
||||
id: lbTokenField
|
||||
isPassword: true
|
||||
enabled: root.doc.listenbrainzBusy !== true
|
||||
placeholder: QbzSession.tr("ListenBrainz token", QbzSession.trRev)
|
||||
// Enter (and focus loss) validates, like the Slint `accepted`.
|
||||
onCommitted: function (v) { QbzBridge.settingsString("listenbrainz-token", v) }
|
||||
// The row hides once the sign-in lands; drop the typed token
|
||||
// with it (Slint resets listenbrainz-token-input on success).
|
||||
onVisibleChanged: if (!visible) text = ""
|
||||
}
|
||||
}
|
||||
SettingRow {
|
||||
visible: root.doc.listenbrainzAuthed !== true
|
||||
label: QbzSession.tr("Save token", QbzSession.trRev)
|
||||
description: QbzSession.tr("Validates the token against ListenBrainz.", QbzSession.trRev)
|
||||
SettingsButton {
|
||||
text: root.doc.listenbrainzBusy === true
|
||||
? QbzSession.tr("Validating...", QbzSession.trRev)
|
||||
: QbzSession.tr("Connect ListenBrainz", QbzSession.trRev)
|
||||
enabled: root.doc.listenbrainzBusy !== true
|
||||
onClicked: QbzBridge.settingsString("listenbrainz-token", root.lbTokenText())
|
||||
}
|
||||
}
|
||||
SettingRow {
|
||||
|
||||
@@ -1,31 +1,49 @@
|
||||
//! Integrations settings controller (phase 19) — the Slint
|
||||
//! Integrations settings + runtime controller — the Slint
|
||||
//! `crates/qbz/src/scrobble.rs` + `discord_rpc.rs` + the settings.rs
|
||||
//! integration rows, ported onto the SAME stores:
|
||||
//! `scrobbler_settings.db` (`qbz_app::settings::scrobblers`, per-user),
|
||||
//! `discover_prefs.db` (`qbz_app::settings::discover_prefs`, per-user) and
|
||||
//! the shared `ui_prefs.json` (`musicbrainz_enabled`, `discord_rpc_enabled`).
|
||||
//! `discover_prefs.db` (`qbz_app::settings::discover_prefs`, per-user), the
|
||||
//! shared per-user `offline_settings.db` `scrobble_queue`, the shared
|
||||
//! `cache/listenbrainz_v2.db` (credentials + `listen_queue`) and the shared
|
||||
//! `ui_prefs.json` (`musicbrainz_enabled`, `discord_rpc_enabled`).
|
||||
//!
|
||||
//! POC-NOTEs:
|
||||
//! - No live scrobbling: `scrobble::start` / `on_track_changed` (the
|
||||
//! now-playing/scrobble firing + offline queues) is NOT ported — the
|
||||
//! toggles + credentials persist to the SAME DB the Slint scrobbler
|
||||
//! reads, so they take effect in the Slint app / a future port.
|
||||
//! - Discord: the enable pref persists AND flips the live `DiscordRpc`
|
||||
//! flag, but presence updates (`DiscordRpc::update` on track change)
|
||||
//! are not wired (no rich-presence feed in the POC).
|
||||
//! - ListenBrainz disconnect does NOT clear the shared
|
||||
//! `cache/listenbrainz_v2.db` credentials row (scrobble.rs:334-342 —
|
||||
//! that cache feeds the scrobbler runtime, which the POC does not run).
|
||||
//! Everything here is STRICTLY OPT-IN (the project rule): no client is
|
||||
//! constructed and no request leaves the process until the user connected the
|
||||
//! service AND its enable flags are on. `ScrobblerSettings::lastfm_active()` /
|
||||
//! `listenbrainz_active()` (master + per-service + authed) gate every fire,
|
||||
//! and `DiscordRpc` only opens its IPC socket once `set_enabled(true)` came
|
||||
//! from the persisted opt-in or the toggle.
|
||||
//!
|
||||
//! Inventory (1:1 with `settings/IntegrationsSettings.slint`):
|
||||
//! - Recommendations (discover_prefs `show_recommendations`)
|
||||
//! - MusicBrainz (`ui_prefs.musicbrainz_enabled` -> `core.musicbrainz_set_enabled`)
|
||||
//! - Scrobblers master (+ collapse) -> Last.fm, ListenBrainz
|
||||
//! - Discord Rich Presence (`ui_prefs.discord_rpc_enabled`)
|
||||
//! (Discogs is NOT a settings integration in either reference — it is a tag
|
||||
//! editor remote-metadata provider + an album external link.)
|
||||
//!
|
||||
//! Runtime wiring (the fire path) needs two call sites OUTSIDE this file:
|
||||
//! [`start`] at shell entry and [`on_track_changed`] / [`discord_push`] on the
|
||||
//! playback track-change + play/pause edges. See the "GLUE" notes on those
|
||||
//! functions.
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::sync::{Arc, Mutex, OnceLock};
|
||||
use std::time::Duration;
|
||||
|
||||
use qbz_app::offline_mode::OfflineModeStore;
|
||||
use qbz_app::scrobble_timing::scrobble_delay_secs;
|
||||
use qbz_app::settings::discover_prefs::DiscoverPrefsStore;
|
||||
use qbz_app::settings::scrobblers::{ScrobblerSettings, ScrobblerSettingsState};
|
||||
use qbz_app::shell::AppRuntime;
|
||||
use qbz_core::LoggingAdapter;
|
||||
use qbz_integrations::discord::DiscordRpc;
|
||||
use qbz_integrations::lastfm::LastFmClient;
|
||||
use qbz_integrations::listenbrainz::ListenBrainzClient;
|
||||
use qbz_integrations::listenbrainz::cache::ListenBrainzCache;
|
||||
use qbz_integrations::listenbrainz::{AdditionalInfo, ListenBrainzClient};
|
||||
use qbz_integrations::NowListening;
|
||||
use qbz_models::QueueTrack;
|
||||
|
||||
use crate::settings_qt::{pref_bool, save_pref};
|
||||
|
||||
@@ -68,6 +86,15 @@ fn discord() -> &'static DiscordRpc {
|
||||
DISCORD.get_or_init(DiscordRpc::new)
|
||||
}
|
||||
|
||||
/// `<user_dir>/cache/listenbrainz_v2.db` — the SAME per-user file the Slint
|
||||
/// and Tauri builds open, so credentials AND the offline listen queue are
|
||||
/// shared across frontends (scrobble.rs `listenbrainz_cache_path`).
|
||||
fn listenbrainz_cache_path() -> Option<PathBuf> {
|
||||
let dir = crate::sidebar_qt::user_dir()?.join("cache");
|
||||
std::fs::create_dir_all(&dir).ok()?;
|
||||
Some(dir.join("listenbrainz_v2.db"))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Transient UI state (ScrobbleState busy/auth-url/status + the Last.fm
|
||||
// pending request token — process memory only, like LASTFM_PENDING_TOKEN).
|
||||
@@ -102,9 +129,9 @@ pub fn ui_snapshot() -> IntegrationUi {
|
||||
}
|
||||
}
|
||||
|
||||
fn set_status(text: &str, kind: i32) {
|
||||
fn set_status(text: String, kind: i32) {
|
||||
let mut g = ui_state().lock().unwrap();
|
||||
g.status_text = text.to_string();
|
||||
g.status_text = text;
|
||||
g.status_kind = kind;
|
||||
}
|
||||
|
||||
@@ -124,6 +151,94 @@ pub fn discord_enabled() -> bool {
|
||||
pref_bool("discord_rpc_enabled", false)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shell entry (scrobble::start + discord_rpc::init + the MusicBrainz seed)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// One-shot guard for the offline-engine flush watcher (lives for the process).
|
||||
static FLUSH_WATCHER: OnceLock<()> = OnceLock::new();
|
||||
|
||||
/// Per-user runtime start. GLUE: call from `enter_shell` (main.rs), AFTER the
|
||||
/// per-user stores bind (`sidebar_qt::init_pinned` sets the user dir).
|
||||
///
|
||||
/// Applies the three persisted opt-ins that would otherwise only take effect
|
||||
/// after the user re-toggled them:
|
||||
/// - MusicBrainz -> `core.musicbrainz_set_enabled` (main.rs:9023 in Slint),
|
||||
/// - Discord -> `DiscordRpc::set_enabled` + an initial presence push
|
||||
/// (discord_rpc::init — the PR #477 "enabled on restart"
|
||||
/// fix: applied AFTER the session, never at early boot),
|
||||
/// - ListenBrainz-> adopt the shared-cache credentials when this build has
|
||||
/// none (a Tauri/Slint sign-in carries over; enable flags
|
||||
/// are NOT touched, scrobbling stays opt-in).
|
||||
/// Then drains the offline scrobble queues and watches for offline->online
|
||||
/// edges to drain them again.
|
||||
pub fn start(runtime: &Arc<AppRuntime<LoggingAdapter>>) {
|
||||
// Bind the per-user scrobbler store now (lazy OnceLock) so the fire path
|
||||
// never races the first Settings visit.
|
||||
let _ = scrobble().get_settings();
|
||||
|
||||
let rt = runtime.clone();
|
||||
crate::spawn(async move {
|
||||
// MusicBrainz opt-out is a plain pref; the core caches its own flag.
|
||||
let mb_on = pref_bool("musicbrainz_enabled", true);
|
||||
rt.core().musicbrainz_set_enabled(mb_on).await;
|
||||
|
||||
seed_listenbrainz_from_shared_cache().await;
|
||||
|
||||
let discord_on = discord_enabled();
|
||||
discord().set_enabled(discord_on);
|
||||
if discord_on {
|
||||
discord_push(&rt);
|
||||
}
|
||||
|
||||
if !crate::offline_fwd::engine().is_offline() {
|
||||
flush_offline_queues().await;
|
||||
}
|
||||
});
|
||||
|
||||
FLUSH_WATCHER.get_or_init(|| {
|
||||
crate::spawn(async move {
|
||||
let mut rx = crate::offline_fwd::engine().subscribe();
|
||||
let mut was_offline = rx.borrow_and_update().is_offline();
|
||||
loop {
|
||||
if rx.changed().await.is_err() {
|
||||
break;
|
||||
}
|
||||
let offline = rx.borrow_and_update().is_offline();
|
||||
if was_offline && !offline {
|
||||
log::info!("[qbz-qt] scrobblers: back online, flushing queues");
|
||||
flush_offline_queues().await;
|
||||
}
|
||||
was_offline = offline;
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/// Adopt the shared `ListenBrainzCache` credentials when this build's store
|
||||
/// has no LB token yet (scrobble.rs `seed_listenbrainz_from_shared_cache`).
|
||||
/// Enable flags are NOT touched — scrobbling stays opt-in per build.
|
||||
async fn seed_listenbrainz_from_shared_cache() {
|
||||
if scrobble_settings().listenbrainz_is_authed() {
|
||||
return;
|
||||
}
|
||||
let Some(path) = listenbrainz_cache_path() else {
|
||||
return;
|
||||
};
|
||||
let creds = tokio::task::spawn_blocking(move || {
|
||||
ListenBrainzCache::new(&path).and_then(|c| c.get_credentials())
|
||||
})
|
||||
.await;
|
||||
if let Ok(Ok((Some(token), Some(user_name)))) = creds {
|
||||
if !token.is_empty() {
|
||||
log::info!("[qbz-qt] adopting ListenBrainz credentials from shared cache");
|
||||
if let Err(e) = scrobble().set_listenbrainz_token(&token, &user_name) {
|
||||
log::warn!("[qbz-qt] persist adopted ListenBrainz credentials failed: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Toggle handlers (settings_qt.rs bool arms delegate here)
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -153,9 +268,15 @@ pub fn set_listenbrainz_enabled(value: bool) -> Result<(), String> {
|
||||
scrobble().set_listenbrainz_enabled(value)
|
||||
}
|
||||
|
||||
/// Discord toggle (discord_rpc::set_enabled): persist the opt-in, apply it to
|
||||
/// the live client, and push the current track (enable) — `set_enabled(false)`
|
||||
/// tears the IPC connection down, so the presence disappears immediately.
|
||||
pub fn set_discord_enabled(value: bool) -> Result<(), String> {
|
||||
save_pref("discord_rpc_enabled", serde_json::json!(value));
|
||||
discord().set_enabled(value);
|
||||
if value {
|
||||
discord_push(&crate::app());
|
||||
}
|
||||
log::info!("[qbz-qt] discord_rpc_enabled -> {value}");
|
||||
Ok(())
|
||||
}
|
||||
@@ -165,18 +286,85 @@ pub fn set_musicbrainz_enabled(value: bool) -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Discord Rich Presence (discord_rpc.rs)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Build the "now listening" snapshot from the live queue + playback state and
|
||||
/// push it. No-op when the user has not opted in (cheap early return — nothing
|
||||
/// is fetched, nothing connects).
|
||||
///
|
||||
/// GLUE: call on the playback track-change edge and on play/pause, mirroring
|
||||
/// the Tauri service's (track_id, is_playing) transition pushes.
|
||||
pub fn discord_push(runtime: &Arc<AppRuntime<LoggingAdapter>>) {
|
||||
if !discord().is_enabled() {
|
||||
return;
|
||||
}
|
||||
let runtime = runtime.clone();
|
||||
crate::spawn(async move {
|
||||
let state = runtime.core().get_queue_state().await;
|
||||
let Some(track) = state.current_track else {
|
||||
// Nothing playing — drop the activity.
|
||||
let _ = tokio::task::spawn_blocking(|| discord().clear()).await;
|
||||
return;
|
||||
};
|
||||
let pb = runtime.core().get_playback_state();
|
||||
let title = match track.version.as_deref().filter(|v| !v.is_empty()) {
|
||||
Some(version) => format!("{} ({version})", track.title),
|
||||
None => track.title.clone(),
|
||||
};
|
||||
// Discord's large_image needs an http(s) URL or an asset key; local /
|
||||
// Plex covers are filesystem paths Discord can't fetch, so drop them
|
||||
// (the core falls back to the "cover" asset key).
|
||||
let cover_url = track
|
||||
.artwork_url
|
||||
.clone()
|
||||
.filter(|u| u.starts_with("http://") || u.starts_with("https://"));
|
||||
let meta = NowListening {
|
||||
title,
|
||||
artist: track.artist.clone(),
|
||||
album: track.album.clone(),
|
||||
is_playing: pb.is_playing,
|
||||
current_time: pb.position as f64,
|
||||
duration: track.duration_secs as f64,
|
||||
cover_url,
|
||||
};
|
||||
let _ = tokio::task::spawn_blocking(move || discord().update(&meta)).await;
|
||||
});
|
||||
}
|
||||
|
||||
/// Tear down the live activity + IPC connection (logout / app exit).
|
||||
/// GLUE (optional): call from the logout path and the quit handler.
|
||||
pub fn discord_clear() {
|
||||
crate::spawn(async {
|
||||
let _ = tokio::task::spawn_blocking(|| discord().clear()).await;
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Connection flows (ScrobbleActions)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// ListenBrainz paste-token flow (scrobble.rs:315): validate against
|
||||
/// /validate-token, persist token + username, force-enable on success.
|
||||
/// ListenBrainz paste-token flow (scrobble.rs `listenbrainz_set_token`):
|
||||
/// validate against `/validate-token`, persist the token + username to this
|
||||
/// build's store AND the shared cache (so the other frontends see the same
|
||||
/// sign-in), and force-enable on the first connect.
|
||||
pub async fn listenbrainz_set_token(token: &str) {
|
||||
let token = token.trim().to_string();
|
||||
if token.is_empty() {
|
||||
set_status(qbz_i18n::t("Paste your ListenBrainz user token first"), 3);
|
||||
crate::settings_qt::publish_snapshot().await;
|
||||
return;
|
||||
}
|
||||
ui_state().lock().unwrap().listenbrainz_busy = true;
|
||||
// Re-entrancy guard: the field commits on blur AND the button submits, so
|
||||
// both can land back-to-back; one validation at a time.
|
||||
{
|
||||
let mut g = ui_state().lock().unwrap();
|
||||
if g.listenbrainz_busy {
|
||||
return;
|
||||
}
|
||||
g.listenbrainz_busy = true;
|
||||
}
|
||||
crate::settings_qt::publish_snapshot().await;
|
||||
|
||||
let client = ListenBrainzClient::new();
|
||||
@@ -186,10 +374,22 @@ pub async fn listenbrainz_set_token(token: &str) {
|
||||
log::error!("[qbz-qt] persist listenbrainz token failed: {e}");
|
||||
}
|
||||
// First-connect force-enable (scrobble.rs).
|
||||
let _ = scrobble().set_listenbrainz_enabled(true);
|
||||
set_status(&format!("Signed in as {}.", info.user_name), 2);
|
||||
if !scrobble_settings().listenbrainz_enabled {
|
||||
let _ = scrobble().set_listenbrainz_enabled(true);
|
||||
}
|
||||
// Write-through to the shared cache (the Slint/Tauri builds read
|
||||
// it at session start). Best-effort.
|
||||
if let Some(path) = listenbrainz_cache_path() {
|
||||
let tok = token.clone();
|
||||
let name = info.user_name.clone();
|
||||
let _ = tokio::task::spawn_blocking(move || {
|
||||
ListenBrainzCache::new(&path).and_then(|c| c.save_credentials(&tok, &name))
|
||||
})
|
||||
.await;
|
||||
}
|
||||
set_status(qbz_i18n::t_args("Connected as {}", &[info.user_name.as_str()]), 2);
|
||||
}
|
||||
Err(e) => set_status(&format!("ListenBrainz: {e}"), 3),
|
||||
Err(e) => set_status(qbz_i18n::t_args("Error: {}", &[&e.to_string()]), 3),
|
||||
}
|
||||
ui_state().lock().unwrap().listenbrainz_busy = false;
|
||||
crate::settings_qt::publish_snapshot().await;
|
||||
@@ -198,10 +398,17 @@ pub async fn listenbrainz_set_token(token: &str) {
|
||||
/// integrations_action dispatch (non-toggle rows).
|
||||
pub async fn handle_action(_runtime: &Arc<AppRuntime<LoggingAdapter>>, action: &str) {
|
||||
match action {
|
||||
// Last.fm two-step browser auth (scrobble.rs:190): request token ->
|
||||
// authorize URL in the browser -> Finish exchanges for a session.
|
||||
// Last.fm two-step browser auth (scrobble.rs `lastfm_connect`):
|
||||
// request token -> authorize URL in the browser -> Finish exchanges
|
||||
// it for a session.
|
||||
"lastfm-connect" => {
|
||||
ui_state().lock().unwrap().lastfm_busy = true;
|
||||
{
|
||||
let mut g = ui_state().lock().unwrap();
|
||||
if g.lastfm_busy {
|
||||
return;
|
||||
}
|
||||
g.lastfm_busy = true;
|
||||
}
|
||||
crate::settings_qt::publish_snapshot().await;
|
||||
let client = LastFmClient::new();
|
||||
match client.get_token().await {
|
||||
@@ -211,12 +418,15 @@ pub async fn handle_action(_runtime: &Arc<AppRuntime<LoggingAdapter>>, action: &
|
||||
g.pending_lastfm_token = token;
|
||||
g.lastfm_auth_url = url.clone();
|
||||
}
|
||||
set_status("Authorize QBZ in your browser, then click Finish.", 1);
|
||||
set_status(
|
||||
qbz_i18n::t("Authorize QBZ in your browser, then click \"Finish\""),
|
||||
1,
|
||||
);
|
||||
if let Err(e) = open::that(&url) {
|
||||
log::warn!("[qbz-qt] open Last.fm authorize page failed: {e}");
|
||||
}
|
||||
}
|
||||
Err(e) => set_status(&format!("Last.fm: {e}"), 3),
|
||||
Err(e) => set_status(qbz_i18n::t_args("Error: {}", &[&e.to_string()]), 3),
|
||||
}
|
||||
ui_state().lock().unwrap().lastfm_busy = false;
|
||||
crate::settings_qt::publish_snapshot().await;
|
||||
@@ -232,9 +442,17 @@ pub async fn handle_action(_runtime: &Arc<AppRuntime<LoggingAdapter>>, action: &
|
||||
"lastfm-finish" => {
|
||||
let token = ui_state().lock().unwrap().pending_lastfm_token.clone();
|
||||
if token.is_empty() {
|
||||
set_status(qbz_i18n::t("Start the sign-in first"), 3);
|
||||
crate::settings_qt::publish_snapshot().await;
|
||||
return;
|
||||
}
|
||||
ui_state().lock().unwrap().lastfm_busy = true;
|
||||
{
|
||||
let mut g = ui_state().lock().unwrap();
|
||||
if g.lastfm_busy {
|
||||
return;
|
||||
}
|
||||
g.lastfm_busy = true;
|
||||
}
|
||||
crate::settings_qt::publish_snapshot().await;
|
||||
let mut client = LastFmClient::new();
|
||||
match client.get_session(&token).await {
|
||||
@@ -243,15 +461,26 @@ pub async fn handle_action(_runtime: &Arc<AppRuntime<LoggingAdapter>>, action: &
|
||||
log::error!("[qbz-qt] persist lastfm session failed: {e}");
|
||||
}
|
||||
// First-connect force-enable (scrobble.rs).
|
||||
let _ = scrobble().set_lastfm_enabled(true);
|
||||
if !scrobble_settings().lastfm_enabled {
|
||||
let _ = scrobble().set_lastfm_enabled(true);
|
||||
}
|
||||
{
|
||||
let mut g = ui_state().lock().unwrap();
|
||||
g.pending_lastfm_token.clear();
|
||||
g.lastfm_auth_url.clear();
|
||||
}
|
||||
set_status(&format!("Signed in as {}.", session.name), 2);
|
||||
set_status(
|
||||
qbz_i18n::t_args("Connected as {}", &[session.name.as_str()]),
|
||||
2,
|
||||
);
|
||||
}
|
||||
Err(e) => set_status(&format!("Last.fm: {e}"), 3),
|
||||
Err(e) => set_status(
|
||||
qbz_i18n::t_args(
|
||||
"Error: {} (did you authorize in the browser?)",
|
||||
&[&e.to_string()],
|
||||
),
|
||||
3,
|
||||
),
|
||||
}
|
||||
ui_state().lock().unwrap().lastfm_busy = false;
|
||||
crate::settings_qt::publish_snapshot().await;
|
||||
@@ -260,16 +489,450 @@ pub async fn handle_action(_runtime: &Arc<AppRuntime<LoggingAdapter>>, action: &
|
||||
if let Err(e) = scrobble().disconnect_lastfm() {
|
||||
log::error!("[qbz-qt] lastfm disconnect failed: {e}");
|
||||
}
|
||||
set_status("", 0);
|
||||
{
|
||||
let mut g = ui_state().lock().unwrap();
|
||||
g.pending_lastfm_token.clear();
|
||||
g.lastfm_auth_url.clear();
|
||||
g.lastfm_busy = false;
|
||||
}
|
||||
set_status(qbz_i18n::t("Last.fm disconnected"), 1);
|
||||
crate::settings_qt::publish_snapshot().await;
|
||||
}
|
||||
"listenbrainz-disconnect" => {
|
||||
if let Err(e) = scrobble().disconnect_listenbrainz() {
|
||||
log::error!("[qbz-qt] listenbrainz disconnect failed: {e}");
|
||||
}
|
||||
set_status("", 0);
|
||||
// Clear the SHARED cache credentials too (mirrors the Slint +
|
||||
// Tauri disconnect) — otherwise the next start re-adopts them.
|
||||
if let Some(path) = listenbrainz_cache_path() {
|
||||
let _ = tokio::task::spawn_blocking(move || {
|
||||
ListenBrainzCache::new(&path).and_then(|c| c.clear_credentials())
|
||||
})
|
||||
.await;
|
||||
}
|
||||
ui_state().lock().unwrap().listenbrainz_busy = false;
|
||||
set_status(qbz_i18n::t("ListenBrainz disconnected"), 1);
|
||||
crate::settings_qt::publish_snapshot().await;
|
||||
}
|
||||
other => log::warn!("[qbz-qt] unknown integrations action: {other}"),
|
||||
}
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Fire + schedule (source-agnostic: Qobuz, local AND Plex all funnel through
|
||||
// the normalized QueueTrack). scrobble.rs `on_track_changed` and below.
|
||||
// ===========================================================================
|
||||
|
||||
/// Normalized track facts the fire path needs. The title is the
|
||||
/// version-enriched display title so remixes/editions scrobble correctly
|
||||
/// (issue #360 parity).
|
||||
#[derive(Clone)]
|
||||
pub struct ScrobbleMeta {
|
||||
pub artist: String,
|
||||
pub track: String,
|
||||
/// `None` when empty — the clients take `Option<&str>` for album.
|
||||
pub album: Option<String>,
|
||||
pub duration_secs: u64,
|
||||
}
|
||||
|
||||
/// Build the fire meta from the current queue track (keeps the glue one line).
|
||||
pub fn meta_from_queue_track(track: &QueueTrack) -> ScrobbleMeta {
|
||||
let title = match track.version.as_deref().filter(|v| !v.is_empty()) {
|
||||
Some(version) => format!("{} ({version})", track.title),
|
||||
None => track.title.clone(),
|
||||
};
|
||||
ScrobbleMeta {
|
||||
artist: track.artist.clone(),
|
||||
track: title,
|
||||
// The clean album name — `album_version` is deliberately NOT appended
|
||||
// (qbz-models: Last.fm wants the clean name).
|
||||
album: Some(track.album.clone()).filter(|a| !a.is_empty()),
|
||||
duration_secs: track.duration_secs,
|
||||
}
|
||||
}
|
||||
|
||||
/// Monotonic generation, bumped on every track change so a delayed scrobble
|
||||
/// timer that fires after the user skipped is dropped (the Svelte
|
||||
/// `clearTimeout` equivalent). Like Tauri, pause/stop do NOT cancel it.
|
||||
static SCROBBLE_GEN: AtomicU64 = AtomicU64::new(0);
|
||||
|
||||
/// Track-change entry point. Fires now-playing immediately for each enabled +
|
||||
/// authed service, then arms a delayed scrobble at `min(50% of duration,
|
||||
/// 240s)`. No-op when no service is active (opt-in gate).
|
||||
///
|
||||
/// GLUE: call from the playback poll's de-duped track-change edge only — NOT
|
||||
/// on resume/seek, or the scrobble re-arms.
|
||||
pub fn on_track_changed(meta: ScrobbleMeta) {
|
||||
// Always bump the generation so any in-flight stale timer self-cancels.
|
||||
let my_gen = SCROBBLE_GEN.fetch_add(1, Ordering::SeqCst) + 1;
|
||||
let cfg = scrobble_settings();
|
||||
if !cfg.lastfm_active() && !cfg.listenbrainz_active() {
|
||||
return;
|
||||
}
|
||||
crate::spawn(async move {
|
||||
// Now-playing immediately (skipped while offline — it needs network
|
||||
// and is not worth queueing; matches the Svelte path).
|
||||
if !crate::offline_fwd::engine().is_offline() {
|
||||
send_now_playing(&meta, &cfg).await;
|
||||
}
|
||||
|
||||
// Delayed scrobble. Unknown / too-short duration: skip (Last.fm's
|
||||
// "longer than 30 seconds" rule lives in qbz_app::scrobble_timing).
|
||||
let Some(wait) = scrobble_delay_secs(meta.duration_secs) else {
|
||||
log::debug!(
|
||||
"[qbz-qt] scrobble: skip delayed scrobble, unusable duration for '{}'",
|
||||
meta.track
|
||||
);
|
||||
return;
|
||||
};
|
||||
if wait > 0 {
|
||||
tokio::time::sleep(Duration::from_secs(wait)).await;
|
||||
}
|
||||
// Self-cancel if a newer track change superseded us.
|
||||
if SCROBBLE_GEN.load(Ordering::SeqCst) != my_gen {
|
||||
return;
|
||||
}
|
||||
send_scrobble(&meta).await;
|
||||
});
|
||||
}
|
||||
|
||||
/// The whole integrations reaction to a track-change edge, in one call:
|
||||
/// scrobble now-playing + arm the delayed scrobble, and refresh the Discord
|
||||
/// presence. Both halves early-return when the user has not opted in, so this
|
||||
/// is free for everyone else (the queue state is only read when at least one
|
||||
/// integration is live).
|
||||
///
|
||||
/// GLUE: call from the playback poll's DE-DUPED track-change edge
|
||||
/// (`track_id != last_track_id`), never from `refresh_now_playing` — that runs
|
||||
/// on every play/queue republish and would re-arm the scrobble timer.
|
||||
pub fn on_track_change_edge(runtime: &Arc<AppRuntime<LoggingAdapter>>) {
|
||||
let cfg = scrobble_settings();
|
||||
let scrobblers_live = cfg.lastfm_active() || cfg.listenbrainz_active();
|
||||
if !scrobblers_live && !discord().is_enabled() {
|
||||
return;
|
||||
}
|
||||
let rt = runtime.clone();
|
||||
crate::spawn(async move {
|
||||
if scrobblers_live {
|
||||
let state = rt.core().get_queue_state().await;
|
||||
if let Some(track) = state.current_track {
|
||||
on_track_changed(meta_from_queue_track(&track));
|
||||
}
|
||||
}
|
||||
discord_push(&rt);
|
||||
});
|
||||
}
|
||||
|
||||
/// Optional ListenBrainz extras — duration is the only one the QueueTrack
|
||||
/// carries (no ISRC / MB IDs on the queue model yet).
|
||||
fn lb_info(duration_secs: u64) -> Option<AdditionalInfo> {
|
||||
Some(AdditionalInfo {
|
||||
duration_ms: (duration_secs > 0).then_some(duration_secs * 1000),
|
||||
..Default::default()
|
||||
})
|
||||
}
|
||||
|
||||
/// Fire "now playing" for each enabled service. Failures only log — the
|
||||
/// scrobble path is what queues.
|
||||
async fn send_now_playing(meta: &ScrobbleMeta, cfg: &ScrobblerSettings) {
|
||||
let album = meta.album.as_deref();
|
||||
if cfg.lastfm_active() {
|
||||
let client = LastFmClient::with_session_key(cfg.lastfm_session_key.clone());
|
||||
if let Err(e) = client
|
||||
.update_now_playing(&meta.artist, &meta.track, album)
|
||||
.await
|
||||
{
|
||||
log::debug!("[qbz-qt] Last.fm now-playing failed: {e}");
|
||||
}
|
||||
}
|
||||
if cfg.listenbrainz_active() {
|
||||
let client = ListenBrainzClient::new();
|
||||
client
|
||||
.restore_token(
|
||||
cfg.listenbrainz_token.clone(),
|
||||
cfg.listenbrainz_username.clone(),
|
||||
)
|
||||
.await;
|
||||
if let Err(e) = client
|
||||
.submit_playing_now(&meta.artist, &meta.track, album, lb_info(meta.duration_secs))
|
||||
.await
|
||||
{
|
||||
log::debug!("[qbz-qt] ListenBrainz now-playing failed: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Fire the actual scrobble for each enabled service. Engine offline OR call
|
||||
/// failure queues it — Last.fm to the shared `scrobble_queue`, ListenBrainz to
|
||||
/// the shared `listen_queue`. Settings are re-read in case the user
|
||||
/// disconnected while the timer waited.
|
||||
async fn send_scrobble(meta: &ScrobbleMeta) {
|
||||
let cfg = scrobble_settings();
|
||||
let album = meta.album.as_deref();
|
||||
let timestamp = unix_now();
|
||||
let offline = crate::offline_fwd::engine().is_offline();
|
||||
|
||||
if cfg.lastfm_active() {
|
||||
let sent = if offline {
|
||||
false
|
||||
} else {
|
||||
let client = LastFmClient::with_session_key(cfg.lastfm_session_key.clone());
|
||||
match client
|
||||
.scrobble(&meta.artist, &meta.track, album, timestamp as u64)
|
||||
.await
|
||||
{
|
||||
Ok(()) => {
|
||||
log::info!("[qbz-qt] Last.fm scrobbled: {} - {}", meta.artist, meta.track);
|
||||
true
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("[qbz-qt] Last.fm scrobble failed ({e}); queueing for later");
|
||||
false
|
||||
}
|
||||
}
|
||||
};
|
||||
if !sent {
|
||||
queue_lastfm(meta, timestamp).await;
|
||||
}
|
||||
}
|
||||
|
||||
if cfg.listenbrainz_active() {
|
||||
let sent = if offline {
|
||||
false
|
||||
} else {
|
||||
let client = ListenBrainzClient::new();
|
||||
client
|
||||
.restore_token(
|
||||
cfg.listenbrainz_token.clone(),
|
||||
cfg.listenbrainz_username.clone(),
|
||||
)
|
||||
.await;
|
||||
match client
|
||||
.submit_listen(
|
||||
&meta.artist,
|
||||
&meta.track,
|
||||
album,
|
||||
timestamp,
|
||||
lb_info(meta.duration_secs),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(()) => {
|
||||
log::info!(
|
||||
"[qbz-qt] ListenBrainz scrobbled: {} - {}",
|
||||
meta.artist,
|
||||
meta.track
|
||||
);
|
||||
true
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("[qbz-qt] ListenBrainz scrobble failed ({e}); queueing for later");
|
||||
false
|
||||
}
|
||||
}
|
||||
};
|
||||
if !sent {
|
||||
queue_listenbrainz(meta, timestamp).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn unix_now() -> i64 {
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_secs() as i64)
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
/// Queue a Last.fm scrobble into the SHARED per-user `offline_settings.db`
|
||||
/// `scrobble_queue` (the table the other frontends queue into and flush from).
|
||||
async fn queue_lastfm(meta: &ScrobbleMeta, timestamp: i64) {
|
||||
let Some(dir) = crate::sidebar_qt::user_dir() else {
|
||||
return;
|
||||
};
|
||||
let artist = meta.artist.clone();
|
||||
let track = meta.track.clone();
|
||||
let album = meta.album.clone();
|
||||
let _ = tokio::task::spawn_blocking(move || match OfflineModeStore::new_at(&dir) {
|
||||
Ok(store) => {
|
||||
if let Err(e) = store.queue_scrobble(&artist, &track, album.as_deref(), timestamp) {
|
||||
log::warn!("[qbz-qt] queue Last.fm scrobble failed: {e}");
|
||||
}
|
||||
}
|
||||
Err(e) => log::warn!("[qbz-qt] open offline settings store failed: {e}"),
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
/// Queue a ListenBrainz listen into the SHARED per-user
|
||||
/// `ListenBrainzCache.listen_queue` (the canonical LB offline store).
|
||||
async fn queue_listenbrainz(meta: &ScrobbleMeta, timestamp: i64) {
|
||||
let Some(path) = listenbrainz_cache_path() else {
|
||||
return;
|
||||
};
|
||||
let artist = meta.artist.clone();
|
||||
let track = meta.track.clone();
|
||||
let album = meta.album.clone();
|
||||
let duration_ms = (meta.duration_secs > 0).then_some(meta.duration_secs * 1000);
|
||||
let _ = tokio::task::spawn_blocking(move || match ListenBrainzCache::new(&path) {
|
||||
Ok(cache) => {
|
||||
if let Err(e) = cache.queue_listen(
|
||||
timestamp,
|
||||
&artist,
|
||||
&track,
|
||||
album.as_deref(),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
duration_ms,
|
||||
) {
|
||||
log::warn!("[qbz-qt] queue ListenBrainz listen failed: {e}");
|
||||
}
|
||||
}
|
||||
Err(e) => log::warn!("[qbz-qt] open ListenBrainz cache failed: {e}"),
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Offline flush — drain both queues (shell entry + every offline->online edge)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async fn flush_offline_queues() {
|
||||
flush_lastfm_queue().await;
|
||||
flush_listenbrainz_queue().await;
|
||||
}
|
||||
|
||||
/// Flush the Last.fm queue: up to 50 per pass (the Last.fm batch limit),
|
||||
/// oldest first; entries older than 14 days are dropped (marked sent) since
|
||||
/// Last.fm rejects them. Stops at the first network failure and retries on the
|
||||
/// next edge. Cleans up sent rows older than 7 days afterwards.
|
||||
async fn flush_lastfm_queue() {
|
||||
let cfg = scrobble_settings();
|
||||
if !cfg.lastfm_is_authed() {
|
||||
return;
|
||||
}
|
||||
let Some(dir) = crate::sidebar_qt::user_dir() else {
|
||||
return;
|
||||
};
|
||||
let pending = match tokio::task::spawn_blocking({
|
||||
let dir = dir.clone();
|
||||
move || OfflineModeStore::new_at(&dir).and_then(|s| s.get_queued_scrobbles(50))
|
||||
})
|
||||
.await
|
||||
{
|
||||
Ok(Ok(p)) => p,
|
||||
_ => return,
|
||||
};
|
||||
if pending.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
let client = LastFmClient::with_session_key(cfg.lastfm_session_key.clone());
|
||||
let cutoff = unix_now() - 14 * 86400;
|
||||
let mut sent_ids: Vec<i64> = Vec::new();
|
||||
for item in pending {
|
||||
if item.timestamp < cutoff {
|
||||
// Too old for Last.fm — drop it (mark sent so it stops re-trying).
|
||||
sent_ids.push(item.id);
|
||||
continue;
|
||||
}
|
||||
match client
|
||||
.scrobble(
|
||||
&item.artist,
|
||||
&item.track,
|
||||
item.album.as_deref(),
|
||||
item.timestamp as u64,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(()) => sent_ids.push(item.id),
|
||||
Err(e) => {
|
||||
log::warn!(
|
||||
"[qbz-qt] Last.fm flush stopped at {} - {}: {e}",
|
||||
item.artist,
|
||||
item.track
|
||||
);
|
||||
break; // still offline / failing — retry on the next edge
|
||||
}
|
||||
}
|
||||
}
|
||||
if !sent_ids.is_empty() {
|
||||
let count = sent_ids.len();
|
||||
let _ = tokio::task::spawn_blocking(move || {
|
||||
OfflineModeStore::new_at(&dir).and_then(|s| {
|
||||
s.mark_scrobbles_sent(&sent_ids)?;
|
||||
s.cleanup_sent_scrobbles(7)
|
||||
})
|
||||
})
|
||||
.await;
|
||||
log::info!("[qbz-qt] Last.fm flush: {count} scrobble(s) sent/cleared");
|
||||
}
|
||||
}
|
||||
|
||||
/// Flush the ListenBrainz queue from the shared cache. Stops at the first
|
||||
/// failure and retries on the next edge.
|
||||
async fn flush_listenbrainz_queue() {
|
||||
let cfg = scrobble_settings();
|
||||
if !cfg.listenbrainz_is_authed() {
|
||||
return;
|
||||
}
|
||||
let Some(path) = listenbrainz_cache_path() else {
|
||||
return;
|
||||
};
|
||||
let pending = match tokio::task::spawn_blocking({
|
||||
let path = path.clone();
|
||||
move || ListenBrainzCache::new(&path).and_then(|c| c.get_pending_listens(500))
|
||||
})
|
||||
.await
|
||||
{
|
||||
Ok(Ok(p)) => p,
|
||||
_ => return,
|
||||
};
|
||||
if pending.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
let client = ListenBrainzClient::new();
|
||||
client
|
||||
.restore_token(
|
||||
cfg.listenbrainz_token.clone(),
|
||||
cfg.listenbrainz_username.clone(),
|
||||
)
|
||||
.await;
|
||||
let mut sent_ids: Vec<i64> = Vec::new();
|
||||
for item in pending {
|
||||
let info = AdditionalInfo {
|
||||
recording_mbid: item.recording_mbid.clone(),
|
||||
release_mbid: item.release_mbid.clone(),
|
||||
artist_mbids: item.artist_mbids.clone(),
|
||||
isrc: item.isrc.clone(),
|
||||
duration_ms: item.duration_ms,
|
||||
..Default::default()
|
||||
};
|
||||
if client
|
||||
.submit_listen(
|
||||
&item.artist_name,
|
||||
&item.track_name,
|
||||
item.release_name.as_deref(),
|
||||
item.listened_at,
|
||||
Some(info),
|
||||
)
|
||||
.await
|
||||
.is_ok()
|
||||
{
|
||||
sent_ids.push(item.id);
|
||||
} else {
|
||||
break; // still failing — retry on the next edge
|
||||
}
|
||||
}
|
||||
if !sent_ids.is_empty() {
|
||||
let count = sent_ids.len();
|
||||
let _ = tokio::task::spawn_blocking(move || {
|
||||
ListenBrainzCache::new(&path).and_then(|c| c.mark_listens_sent(&sent_ids))
|
||||
})
|
||||
.await;
|
||||
log::info!("[qbz-qt] ListenBrainz flush: {count} listen(s) sent");
|
||||
}
|
||||
}
|
||||
@@ -154,6 +154,10 @@ fn enter_shell(session: auth_qt::SessionInfo) {
|
||||
load_home_once();
|
||||
// Phase 4: the 1 Hz playback state pump (idempotent).
|
||||
playback_qt::start_poll_loop(app());
|
||||
// Integrations runtime: applies the persisted MusicBrainz / Discord /
|
||||
// ListenBrainz opt-ins and starts the scrobble-queue flush watcher.
|
||||
// Strictly opt-in — every one of them is inert until the user connects it.
|
||||
integrations_qt::start(&app());
|
||||
// Phase 7: the sidebar playlist tree.
|
||||
load_sidebar_once();
|
||||
// Phase 10: seed the playback request tier from the persisted
|
||||
@@ -254,6 +258,9 @@ pub(crate) fn do_logout() {
|
||||
if let Err(e) = auth_qt::logout(&runtime).await {
|
||||
log::error!("[qbz-qt] logout failed: {e}");
|
||||
}
|
||||
// Integrations: drop the Discord presence so a signed-out session
|
||||
// stops advertising what was playing (the Slint discord_rpc::clear).
|
||||
integrations_qt::discord_clear();
|
||||
// A later login must re-fetch Home + Library (new user, fresh data).
|
||||
*HOME_LOADED.lock().unwrap() = false;
|
||||
*LIBRARY_LOADED.lock().unwrap() = false;
|
||||
|
||||
@@ -710,6 +710,11 @@ pub fn start_poll_loop(runtime: Arc<AppRuntime<LoggingAdapter>>) {
|
||||
let _ = runtime.core().sync_current_to_id(track_id).await;
|
||||
refresh_now_playing(&runtime).await;
|
||||
publish_queue(&runtime).await;
|
||||
// Integrations: scrobble now-playing + arm the delayed
|
||||
// scrobble, and refresh the Discord presence. This is the
|
||||
// DE-DUPED track edge on purpose — firing it from
|
||||
// refresh_now_playing would re-arm the timer on every republish.
|
||||
crate::integrations_qt::on_track_change_edge(&runtime);
|
||||
last_track_id = track_id;
|
||||
}
|
||||
|
||||
@@ -748,6 +753,9 @@ pub fn start_poll_loop(runtime: Arc<AppRuntime<LoggingAdapter>>) {
|
||||
}
|
||||
|
||||
seen_position = position;
|
||||
if is_playing != was_playing {
|
||||
crate::integrations_qt::discord_push(&runtime);
|
||||
}
|
||||
was_playing = is_playing;
|
||||
}
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user