feat(reco): Recommendations in all Discover flyouts + opt-out setting
Surface the new Recommendations tab everywhere Discover sub-sections are listed, and make it opt-out. - Flyouts: the sidebar section-nav flyout and both header nav flyouts (full text + compact icon) now list "Recommendations" alongside Home / Editor's Picks / For You. The Rust route handler already maps "discover-recommendations" (strip_prefix + select_tab + ensure_loaded), so no routing change was needed. - Opt-out: a show_recommendations flag (default on) persisted in the per-user discover-prefs store (qbz_app), seeded into SettingsState on session start. Settings > Integrations gains a "Show Recommendations in Discover" toggle whose description nudges connecting Last.fm + ListenBrainz (MusicBrainz is used automatically to match releases). Off => the tab pill and every flyout entry disappear. - Gating uses two mounted instances per flyout (one with the entry, one without) because Slint can't type-infer [NavMenuEntry] from a ternary of array literals; the HomeView pill uses a plain if. cargo check -p qbz green. cc
This commit is contained in:
9 files changed
+117
-9
No files matched your search
@@ -157,6 +157,8 @@ pub struct DiscoverPrefs {
|
||||
pub home: Vec<SectionPref>,
|
||||
pub editor_picks: Vec<SectionPref>,
|
||||
pub for_you: Vec<SectionPref>,
|
||||
/// Opt-out: show the external "Recommendations" tab in Discover. Default on.
|
||||
pub show_recommendations: bool,
|
||||
}
|
||||
|
||||
/// The exact `DEFAULT_PREFS` from `sectionPrefs.ts`.
|
||||
@@ -204,6 +206,7 @@ pub fn default_prefs() -> DiscoverPrefs {
|
||||
pref(ArtistsToFollow, true),
|
||||
pref(ArtistSpotlight, true),
|
||||
],
|
||||
show_recommendations: true,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -295,6 +298,7 @@ impl DiscoverPrefs {
|
||||
"home": arr(&self.home),
|
||||
"editorPicks": arr(&self.editor_picks),
|
||||
"forYou": arr(&self.for_you),
|
||||
"showRecommendations": self.show_recommendations,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -313,6 +317,8 @@ impl DiscoverPrefs {
|
||||
home: reconcile_list(Some(arr), &defaults.home),
|
||||
editor_picks: defaults.editor_picks,
|
||||
for_you: defaults.for_you,
|
||||
// Legacy V1 (home-only array) predates the flag -> default on.
|
||||
show_recommendations: true,
|
||||
}
|
||||
} else if value.is_object() {
|
||||
let get = |key: &str| value.get(key).and_then(|v| v.as_array());
|
||||
@@ -320,6 +326,11 @@ impl DiscoverPrefs {
|
||||
home: reconcile_list(get("home"), &defaults.home),
|
||||
editor_picks: reconcile_list(get("editorPicks"), &defaults.editor_picks),
|
||||
for_you: reconcile_list(get("forYou"), &defaults.for_you),
|
||||
// Missing key (older persisted blob) -> default on.
|
||||
show_recommendations: value
|
||||
.get("showRecommendations")
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(true),
|
||||
}
|
||||
} else {
|
||||
defaults
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
|
||||
import { Theme } from "../foundation/semantic-colors.slint";
|
||||
import { Typography } from "../foundation/typography.slint";
|
||||
import { HomeState, HomeActions, DiscoverState, DiscoverActions, GenreFilterState, GenreFilterActions, NavState, ContentView, ShellState } from "../state.slint";
|
||||
import { HomeState, HomeActions, DiscoverState, DiscoverActions, GenreFilterState, GenreFilterActions, NavState, ContentView, ShellState, SettingsState } from "../state.slint";
|
||||
import { Carousel } from "Carousel.slint";
|
||||
import { SlimCarousel } from "SlimCarousel.slint";
|
||||
import { PlaylistCarousel } from "PlaylistCarousel.slint";
|
||||
@@ -211,7 +211,7 @@ export component HomeView inherits Rectangle {
|
||||
active: HomeState.active-tab == "forYou";
|
||||
clicked => { HomeActions.select-tab("forYou"); }
|
||||
}
|
||||
Tab {
|
||||
if SettingsState.show-recommendations: Tab {
|
||||
label: @tr("Recommendations");
|
||||
active: HomeState.active-tab == "recommendations";
|
||||
clicked => { HomeActions.select-tab("recommendations"); }
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
import { Theme } from "../foundation/semantic-colors.slint";
|
||||
import { Typography } from "../foundation/typography.slint";
|
||||
import { Radius } from "../foundation/radius.slint";
|
||||
import { ScrobbleState, ScrobbleActions, DiscordState } from "../state.slint";
|
||||
import { ScrobbleState, ScrobbleActions, DiscordState, SettingsState } from "../state.slint";
|
||||
import { SettingRow } from "SettingRow.slint";
|
||||
import { QbzIcon } from "../primitives/QbzIcon.slint";
|
||||
import { QbzToggle } from "../primitives/QbzToggle.slint";
|
||||
@@ -62,10 +62,30 @@ component SecondaryButton inherits Rectangle {
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ===================================================================
|
||||
// SCROBBLERS — master header: title + master toggle + collapse chevron.
|
||||
// Body renders only when `enabled && !ui-collapsed`.
|
||||
|
||||
@@ -246,7 +246,11 @@ export component SettingsView inherits Rectangle {
|
||||
}
|
||||
if !SettingsState.loading && SettingsState.section == 4: LocalLibrarySettings { }
|
||||
if !SettingsState.loading && SettingsState.section == 7: ContentFilteringSettings { }
|
||||
if !SettingsState.loading && SettingsState.section == 6: IntegrationsSettings { }
|
||||
if !SettingsState.loading && SettingsState.section == 6: IntegrationsSettings {
|
||||
settings-bool(k, v) => {
|
||||
root.settings-bool(k, v);
|
||||
}
|
||||
}
|
||||
if !SettingsState.loading && SettingsState.section == 5: DeveloperSettings { }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -768,7 +768,27 @@ export component HeaderBar inherits Rectangle {
|
||||
Rectangle { width: 6px; }
|
||||
// Qobuz-only — HIDDEN entirely while offline (ADR-010 `if` at
|
||||
// the mount site, not rendered-disabled).
|
||||
if !OfflineState.offline: VerticalLayout {
|
||||
// Discover. Two mounts (Slint can't infer [NavMenuEntry] from a
|
||||
// ternary of array literals): WITH the opt-in Recommendations entry,
|
||||
// or without, gated by the opt-out setting.
|
||||
if !OfflineState.offline && SettingsState.show-recommendations: VerticalLayout {
|
||||
alignment: center;
|
||||
NavTabWithMenu {
|
||||
tab-index: 0;
|
||||
icon: @image-url("../assets/icons/compass.svg");
|
||||
label: @tr("Discover");
|
||||
active: true;
|
||||
show-icon: root.show-tab-icons;
|
||||
home-item: 0;
|
||||
items: [
|
||||
{ label: @tr("Home"), has-glyph: false, route: "discover-home" },
|
||||
{ label: @tr("Editor's Picks"), has-glyph: false, route: "discover-editorPicks" },
|
||||
{ label: @tr("For You"), has-glyph: false, route: "discover-forYou" },
|
||||
{ label: @tr("Recommendations"), has-glyph: false, route: "discover-recommendations" },
|
||||
];
|
||||
}
|
||||
}
|
||||
if !OfflineState.offline && !SettingsState.show-recommendations: VerticalLayout {
|
||||
alignment: center;
|
||||
NavTabWithMenu {
|
||||
tab-index: 0;
|
||||
@@ -861,7 +881,25 @@ export component HeaderBar inherits Rectangle {
|
||||
Rectangle { width: 6px; }
|
||||
// Qobuz-only — HIDDEN entirely while offline (ADR-010 `if` at
|
||||
// the mount site, not rendered-disabled).
|
||||
if !OfflineState.offline: VerticalLayout {
|
||||
// Discover. Two mounts (Slint can't infer [NavMenuEntry] from a
|
||||
// ternary of array literals): WITH the opt-in Recommendations entry,
|
||||
// or without, gated by the opt-out setting.
|
||||
if !OfflineState.offline && SettingsState.show-recommendations: VerticalLayout {
|
||||
alignment: center;
|
||||
CompactNavBtn {
|
||||
tab-index: 0;
|
||||
title: @tr("Discover");
|
||||
icon: @image-url("../assets/icons/compass.svg");
|
||||
home-item: 0;
|
||||
items: [
|
||||
{ label: @tr("Home"), has-glyph: false, route: "discover-home" },
|
||||
{ label: @tr("Editor's Picks"), has-glyph: false, route: "discover-editorPicks" },
|
||||
{ label: @tr("For You"), has-glyph: false, route: "discover-forYou" },
|
||||
{ label: @tr("Recommendations"), has-glyph: false, route: "discover-recommendations" },
|
||||
];
|
||||
}
|
||||
}
|
||||
if !OfflineState.offline && !SettingsState.show-recommendations: VerticalLayout {
|
||||
alignment: center;
|
||||
CompactNavBtn {
|
||||
tab-index: 0;
|
||||
|
||||
@@ -16,7 +16,7 @@ import { QbzIcon } from "../primitives/QbzIcon.slint";
|
||||
import { SidebarPlaylistCollage } from "../primitives/SidebarPlaylistCollage.slint";
|
||||
import { ContextMenu } from "../primitives/ContextMenu.slint";
|
||||
import { ContextMenuItem } from "../primitives/ContextMenuItem.slint";
|
||||
import { ShellState, SidebarState, SidebarActions, SidebarEntry, DragState, HeaderMenuState, NavMenuEntry, SidebarFolderPopupState, SidebarTooltipState, MyQbzBrandingState, OfflineState, NavState, ContentView, AppearanceState } from "../state.slint";
|
||||
import { ShellState, SidebarState, SidebarActions, SidebarEntry, DragState, HeaderMenuState, NavMenuEntry, SidebarFolderPopupState, SidebarTooltipState, MyQbzBrandingState, OfflineState, NavState, ContentView, AppearanceState, SettingsState } from "../state.slint";
|
||||
import { ScrollView, LineEdit } from "std-widgets.slint";
|
||||
|
||||
// A single context-menu item row.
|
||||
@@ -703,7 +703,22 @@ export component Sidebar inherits Rectangle {
|
||||
spacing: 2px;
|
||||
// Qobuz-only section — HIDDEN entirely while offline (Local
|
||||
// Library and My QBZ below stay; ADR-010 `if` at the mount site).
|
||||
if !OfflineState.offline: SidebarNavRow {
|
||||
// Discover. Two mounts because Slint can't infer [NavMenuEntry] from
|
||||
// a ternary of array literals: one WITH the opt-in Recommendations
|
||||
// entry, one without, gated by the opt-out setting.
|
||||
if !OfflineState.offline && SettingsState.show-recommendations: SidebarNavRow {
|
||||
tab-index: 0;
|
||||
icon: @image-url("../assets/icons/compass.svg");
|
||||
label: @tr("Discover");
|
||||
home-item: 0;
|
||||
items: [
|
||||
{ label: @tr("Home"), has-glyph: false, route: "discover-home" },
|
||||
{ label: @tr("Editor's Picks"), has-glyph: false, route: "discover-editorPicks" },
|
||||
{ label: @tr("For You"), has-glyph: false, route: "discover-forYou" },
|
||||
{ label: @tr("Recommendations"), has-glyph: false, route: "discover-recommendations" },
|
||||
];
|
||||
}
|
||||
if !OfflineState.offline && !SettingsState.show-recommendations: SidebarNavRow {
|
||||
tab-index: 0;
|
||||
icon: @image-url("../assets/icons/compass.svg");
|
||||
label: @tr("Discover");
|
||||
|
||||
@@ -2714,6 +2714,9 @@ export global SettingsState {
|
||||
in-out property <bool> resume-position: false;
|
||||
in-out property <bool> gapless: true;
|
||||
in-out property <bool> stream-uncached: false;
|
||||
// Discover — opt-out: show the external "Recommendations" tab. Default on.
|
||||
// Seeded from persisted discover prefs at session start (crate::discover_prefs).
|
||||
in-out property <bool> show-recommendations: true;
|
||||
in-out property <bool> streaming-only: false;
|
||||
// Volume normalization (loudness leveling). Applied in the shared player
|
||||
// and bypassed automatically when bit-perfect is active. Surfaced in the
|
||||
|
||||
@@ -27,7 +27,7 @@ use qbz_app::settings::discover_prefs::{
|
||||
use slint::{ComponentHandle, ModelRc, SharedString, VecModel};
|
||||
|
||||
use crate::artwork::{ImageCache, spawn_loads};
|
||||
use crate::{AppWindow, ConfigRow, DiscoverSection, DiscoverState, SectionDescriptor};
|
||||
use crate::{AppWindow, ConfigRow, DiscoverSection, DiscoverState, SectionDescriptor, SettingsState};
|
||||
|
||||
/// Per-user persistent store. `None` outside an active session.
|
||||
static STORE: Mutex<Option<DiscoverPrefsStore>> = Mutex::new(None);
|
||||
@@ -77,6 +77,16 @@ fn persist() {
|
||||
}
|
||||
}
|
||||
|
||||
/// Opt-out toggle for the Discover "Recommendations" tab (Settings > Integrations).
|
||||
/// The Slint `SettingsState.show-recommendations` global is updated by the toggle
|
||||
/// itself; this writes the choice through to the persisted prefs.
|
||||
pub fn set_show_recommendations(value: bool) {
|
||||
if let Some(p) = PREFS.lock().unwrap().as_mut() {
|
||||
p.show_recommendations = value;
|
||||
}
|
||||
persist();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// id -> render kind / i18n label key
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -222,6 +232,9 @@ pub fn push_config_rows(window: &AppWindow, prefs: &DiscoverPrefs, tab: Discover
|
||||
/// the first `apply_home`. Mirrors `myqbz_prefs::seed`.
|
||||
pub fn seed(window: &AppWindow) {
|
||||
let prefs = current();
|
||||
window
|
||||
.global::<SettingsState>()
|
||||
.set_show_recommendations(prefs.show_recommendations);
|
||||
push_descriptors(window, &prefs);
|
||||
}
|
||||
|
||||
|
||||
@@ -867,6 +867,10 @@ pub async fn handle_bool(
|
||||
"show-context-icon" => {
|
||||
with_playback(&ctx.playback, |s| s.set_show_context_icon(value)).map(|_| Apply::None)
|
||||
}
|
||||
"show-recommendations" => {
|
||||
crate::discover_prefs::set_show_recommendations(value);
|
||||
Ok(Apply::None)
|
||||
}
|
||||
"persist-session" => {
|
||||
let r = with_playback(&ctx.playback, |s| s.set_persist_session(value))
|
||||
.map(|_| Apply::None);
|
||||
|
||||
Reference in new issue
Block a user