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:
vicrodh committed 2026-06-27 11:57:46 -06:00
1 parent 05df99b713
commit 332fa430e5
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
+2 -2
View File
@@ -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`.
+5 -1
View File
@@ -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 { }
}
}
+40 -2
View File
@@ -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;
+17 -2
View File
@@ -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");
+3
View File
@@ -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
+14 -1
View File
@@ -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);
}
+4
View File
@@ -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);