feat(slint): Last.fm and ListenBrainz scrobblers wired to player and settings

Re-implements the parked scrobblers-wip stash intent on the current tree
(the stash no longer applied; it remains untouched in the stash list) with
three corrections: the Last.fm offline queue uses the SHARED per-user
offline_settings.db scrobble_queue (byte-identical SQL to Tauri - queues
flush from either frontend) instead of a private table; the ListenBrainz
cache opens Tauri's per-user path so LB creds and listen_queue are truly
shared (a Tauri LB sign-in pre-seeds Slint); and the queue flush is driven
by the offline engine's online edge (plus initial flush), not login-only.

New frontend-agnostic qbz_app::settings::scrobblers store (master enable,
Last.fm session/username, LB token/username; tests) and the shared
scrobble-queue API on OfflineModeStore. Player wiring hooks the de-duped
track-change edge inside the QConnect remote gate: now-playing fires on
change (skipped offline), the scrobble arms at min(50%, 240s) with a
generation counter cancelling stale timers, all sources scrobble (Tauri
parity incl. the #360 version-enriched title). Offline or call failure
queues; Last.fm flush honors the 50-batch and 14-day cutoff.

Settings gains an Integrations section (Plex-section idiom): Last.fm
browser-auth connect/finish/disconnect, ListenBrainz token field +
validate, per-service enable toggles. Known gap documented: Last.fm creds
cannot be shared with Tauri (webview localStorage); LB creds and both
queues are shared.

cc
This commit is contained in:
vicrodh committed 2026-06-10 02:52:17 -06:00
1 parent 81587f4d17
commit 952846b6a0
12 files changed
+1945 -2

No files matched your search

+1 -1
View File
@@ -18,7 +18,7 @@ import { AppShell } from "shell/AppShell.slint";
import { Toast } from "primitives/Toast.slint";
// Re-export the state globals so the Rust layer can populate them.
export { HomeState, HomeActions, DiscoverBrowseState, DiscoverBrowseActions, ForYouState, MixState, GenreFilterState, GenreFilterActions, AlbumState, ArtistState, NavState, ShellState, SessionState, SettingsState, AlbumActions, ArtistActions, ArtworkActions, NowPlayingState, QueueState, SearchState, SearchActions, NetworkSidebarState, NetworkSidebarActions, MusicianState, MusicianActions, LabelState, LabelActions, LocationViewState, LocationViewActions, FavoritesState, FavoritesActions, PlaylistPickerState, PlaylistPickerActions, PlaylistState, PlaylistActions, SidebarState, SidebarActions, SidebarFolderPopupState, CreatePlaylistState, CreatePlaylistActions, EditPlaylistState, EditPlaylistActions, CreateFolderState, CreateFolderActions, MyQbzCreateState, MyQbzCreateActions, DragState, DragActions, PlaylistManagerState, PlaylistManagerActions, OfflineManagerState, OfflineManagerActions, MyQbzState, MyQbzActions, MixtapeCardItem, MyQbzAddState, MyQbzAddActions, MyQbzAddRow, MyQbzDetailState, MyQbzDetailActions, MixtapeDetailItem, MyQbzEditState, MyQbzEditActions, MyQbzMixState, MyQbzMixActions, DiscoBuilderState, DiscoBuilderActions, DiscoGroup, DiscoCandidate, LocalLibraryState, LocalLibraryActions, LibraryFoldersState, LibFolderEditState, LibraryManageActions, LibraryScanState, LibAlbumFilterState, LocalAlbumState, LocalAlbumActions, TagEditorState, TagEditorActions, FolderEditState, FolderEditActions, ToastState, TextUtil, QconnectDevState, QconnectDevice, AppearanceState, MyQbzBrandingState, EphemeralPlayChoiceState, EphemeralPlayChoiceActions, PlexSettingsState, PlexAuthActions, PlexSectionItem, OfflineState, OfflineModeActions, OfflineFavoritesState, OfflineFavoritesActions } from "state.slint";
export { HomeState, HomeActions, DiscoverBrowseState, DiscoverBrowseActions, ForYouState, MixState, GenreFilterState, GenreFilterActions, AlbumState, ArtistState, NavState, ShellState, SessionState, SettingsState, AlbumActions, ArtistActions, ArtworkActions, NowPlayingState, QueueState, SearchState, SearchActions, NetworkSidebarState, NetworkSidebarActions, MusicianState, MusicianActions, LabelState, LabelActions, LocationViewState, LocationViewActions, FavoritesState, FavoritesActions, PlaylistPickerState, PlaylistPickerActions, PlaylistState, PlaylistActions, SidebarState, SidebarActions, SidebarFolderPopupState, CreatePlaylistState, CreatePlaylistActions, EditPlaylistState, EditPlaylistActions, CreateFolderState, CreateFolderActions, MyQbzCreateState, MyQbzCreateActions, DragState, DragActions, PlaylistManagerState, PlaylistManagerActions, OfflineManagerState, OfflineManagerActions, MyQbzState, MyQbzActions, MixtapeCardItem, MyQbzAddState, MyQbzAddActions, MyQbzAddRow, MyQbzDetailState, MyQbzDetailActions, MixtapeDetailItem, MyQbzEditState, MyQbzEditActions, MyQbzMixState, MyQbzMixActions, DiscoBuilderState, DiscoBuilderActions, DiscoGroup, DiscoCandidate, LocalLibraryState, LocalLibraryActions, LibraryFoldersState, LibFolderEditState, LibraryManageActions, LibraryScanState, LibAlbumFilterState, LocalAlbumState, LocalAlbumActions, TagEditorState, TagEditorActions, FolderEditState, FolderEditActions, ToastState, TextUtil, QconnectDevState, QconnectDevice, AppearanceState, MyQbzBrandingState, EphemeralPlayChoiceState, EphemeralPlayChoiceActions, PlexSettingsState, PlexAuthActions, PlexSectionItem, ScrobbleState, ScrobbleActions, OfflineState, OfflineModeActions, OfflineFavoritesState, OfflineFavoritesActions } from "state.slint";
// Which top-level screen is shown. The app starts on `splash` while it
// restores a saved session, then resolves to `shell` or `login`.
@@ -0,0 +1,269 @@
// Settings > Integrations — source-agnostic scrobblers (Last.fm + ListenBrainz).
// Mirrors the Plex section structure in LocalLibrarySettings.slint: a master
// enable + collapse header, then per-service connect/disconnect rows that bind
// the `ScrobbleState` global for state and `ScrobbleActions` for actions. The
// fire path is source-agnostic (Qobuz, local, AND Plex) and lives in Rust.
// ADR-008: no pills — plain bordered buttons.
import { Theme } from "../foundation/semantic-colors.slint";
import { Typography } from "../foundation/typography.slint";
import { Radius } from "../foundation/radius.slint";
import { ScrobbleState, ScrobbleActions } from "../state.slint";
import { SettingRow } from "SettingRow.slint";
import { QbzIcon } from "../primitives/QbzIcon.slint";
import { QbzToggle } from "../primitives/QbzToggle.slint";
import { LineEdit } from "std-widgets.slint";
component GroupHeader inherits Text {
color: Theme.text-muted;
font-size: 11px;
letter-spacing: 1.5px;
font-weight: Typography.semibold;
}
// Matches OfflineSettings/LocalLibrarySettings SecondaryButton.
component SecondaryButton inherits Rectangle {
in property <string> label;
in property <bool> danger: false;
in property <bool> enabled: true;
callback clicked();
height: 32px;
width: row.preferred-width + 24px;
border-radius: Radius.sm;
border-width: 1px;
border-color: root.danger ? #e0564f : Theme.border-subtle;
background: !root.enabled
? transparent
: (ta.has-hover
? (root.danger ? #e0564f22 : Theme.surface-hover)
: Theme.surface-elevated);
opacity: root.enabled ? 1.0 : 0.45;
row := HorizontalLayout {
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
Text {
text: root.label;
color: root.danger ? #e0564f : Theme.text-primary;
font-size: Typography.legal;
font-weight: Typography.semibold;
vertical-alignment: center;
}
}
ta := TouchArea {
mouse-cursor: root.enabled ? pointer : default;
clicked => {
if (root.enabled) {
root.clicked();
}
}
}
}
export component IntegrationsSettings inherits VerticalLayout {
spacing: 4px;
init => {
ScrobbleActions.load();
}
// ===================================================================
// SCROBBLERS — master header: title + master toggle + collapse chevron.
// Body renders only when `enabled && !ui-collapsed`.
// ===================================================================
HorizontalLayout {
spacing: 10px;
VerticalLayout {
alignment: center;
horizontal-stretch: 1;
spacing: 2px;
HorizontalLayout {
spacing: 8px;
GroupHeader { text: @tr("SCROBBLERS"); vertical-alignment: center; }
if ScrobbleState.enabled: VerticalLayout {
alignment: center;
Rectangle {
width: 20px;
height: 20px;
border-radius: Radius.sm;
background: chev-ta.has-hover ? Theme.surface-hover : transparent;
QbzIcon {
source: ScrobbleState.ui-collapsed
? @image-url("../assets/icons/chevron-right.svg")
: @image-url("../assets/icons/chevron-down.svg");
width: 14px;
height: 14px;
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
tint: Theme.text-secondary;
}
chev-ta := TouchArea {
mouse-cursor: pointer;
clicked => {
ScrobbleState.ui-collapsed = !ScrobbleState.ui-collapsed;
ScrobbleActions.collapse-toggle(ScrobbleState.ui-collapsed);
}
}
}
}
}
Text {
text: @tr("Send your plays to Last.fm and ListenBrainz. Works for Qobuz, local, and Plex tracks.");
color: Theme.text-muted;
font-size: 12px;
wrap: word-wrap;
}
}
VerticalLayout {
alignment: center;
QbzToggle {
checked: ScrobbleState.enabled;
toggled(v) => {
ScrobbleState.enabled = v;
ScrobbleActions.enable-toggle(v);
}
}
}
}
// Body — only when enabled and not collapsed.
if ScrobbleState.enabled && !ScrobbleState.ui-collapsed: VerticalLayout {
spacing: 4px;
Rectangle { height: 12px; }
// ----------------------------------------------------------------
// LAST.FM
// ----------------------------------------------------------------
GroupHeader { text: @tr("LAST.FM"); }
Rectangle { height: 4px; }
SettingRow {
label: @tr("Scrobble to Last.fm");
description: ScrobbleState.lastfm-authed
? @tr("Signed in as {}.", ScrobbleState.lastfm-username)
: @tr("Connect your Last.fm account to enable scrobbling.");
QbzToggle {
checked: ScrobbleState.lastfm-enabled;
toggled(v) => {
ScrobbleState.lastfm-enabled = v;
ScrobbleActions.lastfm-enable-toggle(v);
}
}
}
// Connect (when not authed) — two-step browser flow.
if !ScrobbleState.lastfm-authed: SettingRow {
label: @tr("Connect");
description: @tr("Authorize QBZ in your browser, then click Finish.");
HorizontalLayout {
spacing: 8px;
SecondaryButton {
label: ScrobbleState.lastfm-busy ? @tr("Working...") : @tr("Connect Last.fm");
enabled: !ScrobbleState.lastfm-busy;
clicked => { ScrobbleActions.lastfm-connect(); }
}
if ScrobbleState.lastfm-auth-url != "": SecondaryButton {
label: @tr("Open authorize page");
clicked => { ScrobbleActions.lastfm-open-auth-url(); }
}
if ScrobbleState.lastfm-auth-url != "": SecondaryButton {
label: ScrobbleState.lastfm-busy ? @tr("Working...") : @tr("Finish");
enabled: !ScrobbleState.lastfm-busy;
clicked => { ScrobbleActions.lastfm-confirm(); }
}
}
}
// Disconnect (when authed).
if ScrobbleState.lastfm-authed: SettingRow {
label: @tr("Disconnect Last.fm");
description: @tr("Sign out of Last.fm.");
SecondaryButton {
label: @tr("Disconnect");
danger: true;
clicked => { ScrobbleActions.lastfm-disconnect(); }
}
}
Rectangle { height: 18px; }
// ----------------------------------------------------------------
// LISTENBRAINZ
// ----------------------------------------------------------------
GroupHeader { text: @tr("LISTENBRAINZ"); }
Rectangle { height: 4px; }
SettingRow {
label: @tr("Scrobble to ListenBrainz");
description: ScrobbleState.listenbrainz-authed
? @tr("Signed in as {}.", ScrobbleState.listenbrainz-username)
: @tr("Paste your ListenBrainz user token to enable scrobbling.");
QbzToggle {
checked: ScrobbleState.listenbrainz-enabled;
toggled(v) => {
ScrobbleState.listenbrainz-enabled = v;
ScrobbleActions.listenbrainz-enable-toggle(v);
}
}
}
// Token field (when not authed).
if !ScrobbleState.listenbrainz-authed: SettingRow {
label: @tr("User token");
description: @tr("From listenbrainz.org/settings.");
HorizontalLayout {
width: 240px;
VerticalLayout {
alignment: center;
horizontal-stretch: 1;
LineEdit {
text: ScrobbleState.listenbrainz-token-input;
placeholder-text: @tr("ListenBrainz token");
input-type: password;
enabled: !ScrobbleState.listenbrainz-busy;
edited(s) => {
ScrobbleState.listenbrainz-token-input = s;
}
accepted(s) => {
ScrobbleActions.listenbrainz-set-token(s);
}
}
}
}
}
if !ScrobbleState.listenbrainz-authed: SettingRow {
label: @tr("Save token");
description: @tr("Validates the token against ListenBrainz.");
SecondaryButton {
label: ScrobbleState.listenbrainz-busy ? @tr("Validating...") : @tr("Connect ListenBrainz");
enabled: !ScrobbleState.listenbrainz-busy;
clicked => {
ScrobbleActions.listenbrainz-set-token(ScrobbleState.listenbrainz-token-input);
}
}
}
// Disconnect (when authed).
if ScrobbleState.listenbrainz-authed: SettingRow {
label: @tr("Disconnect ListenBrainz");
description: @tr("Sign out of ListenBrainz.");
SecondaryButton {
label: @tr("Disconnect");
danger: true;
clicked => { ScrobbleActions.listenbrainz-disconnect(); }
}
}
// Shared status line.
if ScrobbleState.status-text != "": Text {
text: ScrobbleState.status-text;
color: ScrobbleState.status-kind == 3
? #e0564f
: (ScrobbleState.status-kind == 2 ? #3fae6a : Theme.text-muted);
font-size: Typography.legal;
wrap: word-wrap;
}
}
}
@@ -16,6 +16,7 @@ import { PlaybackSettings } from "PlaybackSettings.slint";
import { AppearanceSettings } from "AppearanceSettings.slint";
import { OfflineSettings } from "OfflineSettings.slint";
import { LocalLibrarySettings } from "LocalLibrarySettings.slint";
import { IntegrationsSettings } from "IntegrationsSettings.slint";
import { DeveloperSettings } from "DeveloperSettings.slint";
import { ScrollView } from "std-widgets.slint";
@@ -150,6 +151,14 @@ export component SettingsView inherits Rectangle {
SettingsState.section = 4;
}
}
SubNavItem {
icon: @image-url("../assets/icons/refresh-cw.svg");
label: @tr("Integrations");
active: SettingsState.section == 6;
clicked => {
SettingsState.section = 6;
}
}
SubNavItem {
icon: @image-url("../assets/icons/bug.svg");
label: @tr("Developer");
@@ -225,6 +234,7 @@ export component SettingsView inherits Rectangle {
}
}
if !SettingsState.loading && SettingsState.section == 4: LocalLibrarySettings { }
if !SettingsState.loading && SettingsState.section == 6: IntegrationsSettings { }
if !SettingsState.loading && SettingsState.section == 5: DeveloperSettings { }
}
}
+46
View File
@@ -854,6 +854,52 @@ export global PlexAuthActions {
callback load(); // panel init: seed from store + warm cache
}
// ============================ Scrobblers ==================================
// Settings > Integrations — source-agnostic Last.fm + ListenBrainz scrobbling.
// State mirrors `scrobbler_settings.db`; actions delegate to the Rust
// `scrobble` controller (auth flows + the now-playing/scrobble fire live
// there). ADR-008: no pills.
export global ScrobbleState {
// --- Master section toggles -------------------------------------------
in-out property <bool> enabled: false; // master toggle (default OFF)
in-out property <bool> ui-collapsed: false; // collapse chevron
// --- Last.fm ----------------------------------------------------------
in-out property <bool> lastfm-enabled: false; // per-service scrobble flag
in property <bool> lastfm-authed: false; // session key present
in property <string> lastfm-username: ""; // "Signed in as ..."
in property <string> lastfm-auth-url: ""; // non-empty after connect
in property <bool> lastfm-busy: false; // get_token / get_session in flight
// --- ListenBrainz -----------------------------------------------------
in-out property <bool> listenbrainz-enabled: false;
in property <bool> listenbrainz-authed: false;
in property <string> listenbrainz-username: "";
in-out property <string> listenbrainz-token-input: ""; // token field buffer
in property <bool> listenbrainz-busy: false; // set_token in flight
// --- Shared status line (0 none, 1 info, 2 ok, 3 error) ---------------
in property <string> status-text: "";
in property <int> status-kind: 0;
}
export global ScrobbleActions {
callback load(); // panel init: seed from store
callback enable-toggle(bool);
callback collapse-toggle(bool);
// Last.fm.
callback lastfm-enable-toggle(bool);
callback lastfm-connect(); // get_token + open browser
callback lastfm-open-auth-url(); // re-open authorize URL
callback lastfm-confirm(); // get_session after approve
callback lastfm-disconnect();
// ListenBrainz.
callback listenbrainz-enable-toggle(bool);
callback listenbrainz-set-token(string);
callback listenbrainz-disconnect();
}
// ============================ Tag editor ==================================
// Local-album metadata editor (Settings-independent modal launched from the
// local album detail). Sidecar (default) + opt-in direct file-write. Remote