Files
qbz/crates/qbz-ui/ui/settings/IntegrationsSettings.slint
T
vicrodh c4ddca0223 fix(input): gate hotkeys while text inputs are focused
A use-site `changed has-focus` on a std-widgets LineEdit/TextEdit never
fires — has-focus is an alias into the inner TextInput (slint#7747) — so
typing a letter that doubles as a shortcut ran the action instead of
inserting the character. Sweep every text-input surface (search, import,
create/edit playlist & folder, tag editor, colour picker, settings
fields, QConnect dev modal, kiosk shell) and drive
UiFocusState.text-input-focused from a `guard-focused` probe property
that the change tracker does observe.

cc
2026-07-16 20:37:57 -06:00

337 lines
14 KiB
Plaintext

// 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, DiscordState, SettingsState , UiFocusState } 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 AppearanceSettings/OfflineSettings' SecondaryButton — the 160px
// settings-row standard: fixed width, 34px, body/medium label, right edge
// of every row lands on the same vertical line.
component SecondaryButton inherits Rectangle {
in property <string> label;
in property <bool> danger: false;
in property <bool> enabled: true;
callback clicked();
// 160px floor keeps the settings right-edge grid; longer translations grow.
width: Math.max(label-txt.preferred-width + 32px, 160px);
height: 34px;
border-radius: Radius.sm;
border-width: 1px;
border-color: root.danger ? #e0564f : Theme.border-subtle;
background: ta.has-hover && root.enabled ? Theme.surface-hover : Theme.surface-elevated;
opacity: root.enabled ? 1.0 : 0.4;
label-txt := Text {
width: 100%;
height: 100%;
text: root.label;
color: root.danger ? #e0564f : Theme.text-secondary;
font-size: Typography.body;
font-weight: Typography.medium;
horizontal-alignment: center;
vertical-alignment: center;
}
ta := TouchArea {
mouse-cursor: root.enabled ? pointer : default;
clicked => {
if (root.enabled) {
root.clicked();
}
}
}
}
export component IntegrationsSettings inherits VerticalLayout {
spacing: 4px;
// Bubbles a persisted bool toggle up to SettingsView -> Rust handle_bool.
callback settings-bool(string, bool);
init => {
ScrobbleActions.load();
}
// ===================================================================
// RECOMMENDATIONS — opt-out for the external Discover "Recommendations"
// tab. The description nudges connecting the sources that feed it.
// ===================================================================
GroupHeader { text: @tr("RECOMMENDATIONS"); }
SettingRow {
label: @tr("Show Recommendations in Discover");
description: @tr("A personalized Discover tab built from your listening. For the best results connect Last.fm and ListenBrainz below — MusicBrainz is used automatically to match releases.");
QbzToggle {
checked: SettingsState.show-recommendations;
toggled(v) => {
SettingsState.show-recommendations = v;
root.settings-bool("show-recommendations", v);
}
}
}
// ===================================================================
// METADATA — MusicBrainz opt-out (default on). Enriches artist pages
// (relationships, origin/scene) and playlist song suggestions. No telemetry.
// ===================================================================
Rectangle { height: 12px; }
GroupHeader { text: @tr("METADATA"); }
Rectangle { height: 4px; }
SettingRow {
label: @tr("MusicBrainz");
description: @tr("Enable artist relationships and enhanced metadata from MusicBrainz. No telemetry — it only matches releases to enrich artist pages and playlist song suggestions. Turn off to disable those sections.");
QbzToggle {
checked: SettingsState.musicbrainz-enabled;
toggled(v) => {
SettingsState.musicbrainz-enabled = v;
root.settings-bool("musicbrainz", v);
}
}
}
// ===================================================================
// 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;
GroupHeader { text: @tr("SCROBBLERS"); vertical-alignment: center; }
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;
}
}
// Collapse chevron — moved to row level so it is vertically centered
// with the master toggle (it used to be inline with the title and sat
// too high), and enlarged (28px hit-box / 18px glyph).
if ScrobbleState.enabled: VerticalLayout {
alignment: center;
Rectangle {
width: 28px;
height: 28px;
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: 18px;
height: 18px;
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);
}
}
}
}
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");
// Hotkey-guard probe: a use-site `changed has-focus` on a std-widgets
// input never fires (has-focus is an alias into the inner TextInput),
// so observe it through a local property instead (#619).
property <bool> guard-focused: self.has-focus;
changed guard-focused => { UiFocusState.text-input-focused = self.guard-focused; }
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;
}
}
// ===================================================================
// DISCORD — Rich Presence "now listening" (opt-in, independent of the
// scrobblers above). State + persistence live in DiscordState / ui_prefs;
// the fire + IPC live in the `discord_rpc` controller.
// ===================================================================
Rectangle { height: 12px; }
GroupHeader { text: @tr("DISCORD"); }
Rectangle { height: 4px; }
SettingRow {
label: @tr("Discord Rich Presence");
description: @tr("Show what you're listening to as your Discord status. Opt-in — Discord must be running.");
QbzToggle {
checked: DiscordState.enabled;
toggled(v) => {
DiscordState.enabled = v;
DiscordState.set-enabled(v);
}
}
}
SettingRow {
label: @tr("Flatpak socket access");
description: @tr("Flatpak install and the presence isn't showing? Grant access to Discord's IPC socket, then restart QBZ:\nflatpak override --user --filesystem=xdg-run/discord-ipc-0 com.blitzfc.qbz");
}
}