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:
12 files changed
+1945
-2
No files matched your search
@@ -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 { }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user