// Discover — Home view: a fixed 56px toolbar (tab pill + genre filter + // settings gear) over a scrollable column of section carousels. // // Section data is supplied by the Rust home controller through the // HomeState global. The Editor's Picks / For You tabs, the genre filter, // and the settings gear are visual for now; wiring them is a later // increment. import { Theme } from "../foundation/semantic-colors.slint"; import { Typography } from "../foundation/typography.slint"; import { HomeState, HomeActions, DiscoverState, DiscoverActions, PinnedState, GenreFilterState, GenreFilterActions, NavState, ContentView, ShellState, SettingsState, TooltipState } from "../state.slint"; import { Carousel } from "Carousel.slint"; import { SlimCarousel } from "SlimCarousel.slint"; import { ArtistCarousel } from "ArtistCarousel.slint"; import { QobuzMixesRow } from "QobuzMixesRow.slint"; import { PlaylistCarousel } from "PlaylistCarousel.slint"; import { PinnedCarousel } from "PinnedCarousel.slint"; import { PlaylistTagFilter } from "PlaylistTagFilter.slint"; import { HomeSkeleton } from "HomeSkeleton.slint"; import { ForYouView } from "ForYouView.slint"; import { ExternalRecoView } from "ExternalRecoView.slint"; import { GenreFilterPopup } from "GenreFilterPopup.slint"; import { QbzIcon } from "../primitives/QbzIcon.slint"; import { ListScrollbar } from "../primitives/ListScrollbar.slint"; import { NavButtons } from "../shell/NavButtons.slint"; // Header + hint shown for a recently-played module while it has no data // yet (the play history fills once the playback session records plays). component RecentPlaceholder inherits VerticalLayout { in property title; in property hint; spacing: 10px; Text { text: root.title; color: Theme.text-primary; font-size: 18px; font-weight: Typography.semibold; } Text { text: root.hint; color: Theme.text-muted; font-size: 13px; } } component Tab inherits Rectangle { in property label; in property active: false; callback clicked(); border-radius: 4px; background: root.active ? Theme.surface-main : ta.has-hover ? Theme.surface-hover : transparent; HorizontalLayout { padding-left: 14px; padding-right: 14px; padding-top: 6px; padding-bottom: 6px; Text { text: root.label; color: root.active ? Theme.text-primary : Theme.text-muted; font-size: 13px; font-weight: Typography.medium; } } ta := TouchArea { mouse-cursor: pointer; clicked => { root.clicked(); } } } component GenreButton inherits Rectangle { callback clicked(); // Accent-filled + "N genres" label when a filter is active, // matching Tauri. property active: GenreFilterState.selected-count > 0; // Hug the content — the button is only as wide as its label. width: genre-row.preferred-width; height: 32px; border-radius: 6px; background: root.active ? Theme.accent : (ta.has-hover ? Theme.surface-hover : Theme.surface-elevated); genre-row := HorizontalLayout { padding-left: 12px; padding-right: 14px; spacing: 7px; VerticalLayout { alignment: center; QbzIcon { source: @image-url("../assets/icons/list-filter.svg"); width: 14px; height: 14px; tint: root.active ? #ffffff : Theme.text-secondary; } } Text { text: root.active ? (GenreFilterState.selected-count == 1 ? @tr("1 genre") : @tr("{} genres", GenreFilterState.selected-count)) : @tr("Filter by genre"); color: root.active ? #ffffff : Theme.text-secondary; font-size: 13px; font-weight: root.active ? Typography.medium : Typography.regular; vertical-alignment: center; } } ta := TouchArea { mouse-cursor: pointer; clicked => { root.clicked(); } } } // Settings/config gear for the Home configurator, anchored in the toolbar's // right controls. (The recently-played refresh is NOT here — it lives in each // recently-played row header, left of the page chevrons; see Carousel // `show-refresh`.) component GearButton inherits Rectangle { callback clicked(); width: 32px; height: 32px; border-radius: 4px; background: ta.has-hover ? Theme.surface-hover : transparent; QbzIcon { source: @image-url("../assets/icons/home-gear.svg"); width: 20px; height: 20px; x: Math.round((parent.width - self.width) / 2 / 1px) * 1px; y: Math.round((parent.height - self.height) / 2 / 1px) * 1px; tint: ta.has-hover ? Theme.text-primary : Theme.text-muted; } ta := TouchArea { mouse-cursor: pointer; clicked => { root.clicked(); } } } export component HomeView inherits Rectangle { callback album-clicked(string); callback open-artist(string); callback media-action(string, string, string); // Open the Discover "View all" full-list page for a section // (endpoint, title). Only sections with a non-empty endpoint show // the link. callback discover-view-all(string, string); background: ShellState.app-background-active ? transparent : Theme.surface-main; // The view remounts on every navigation to Home (AppShell's `if` mount), // so this is the "Home regained focus" hook: Rust re-reads the LOCAL // recently-played rails only when a play was recorded while Home was // off-screen (dirty-flag gated — otherwise a no-op). init => { HomeActions.home-mounted(); } VerticalLayout { spacing: 0px; // --- Toolbar (fixed 56px) ---------------------------------------- // Side controls are anchored to the toolbar edges; the three-tab // pill is absolutely centered on the toolbar so it stays at the // true horizontal center regardless of the side controls' widths. Rectangle { height: 56px; background: Theme.surface-main; // History pair + the three-tab nav bar, grouped at the left // with a gap delimiting the two clusters. Explicit width // (= content) so the layout does not fill the toolbar and // overlap the right controls. left-controls := HorizontalLayout { x: 32px; // Align the toolbar content's vertical center to y=25 — // 11px (the back/forward padding-top used by the album / // artist / settings views) + 14px (half the 28px nav // buttons). Keeps the [<] [>] pair at the same screen // position across every view, with no jump on navigation. y: 25px - self.height / 2; width: self.preferred-width; // Explicit height too — without it the layout fills the // 56px toolbar, so `y` resolves to 0 and the tab pill // sticks to the top instead of centering. height: self.preferred-height; spacing: 16px; VerticalLayout { alignment: center; NavButtons { } } tab-pill := Rectangle { width: tab-row.preferred-width; height: tab-row.preferred-height; background: Theme.surface-elevated; border-radius: 6px; tab-row := HorizontalLayout { padding: 3px; spacing: 4px; Tab { label: @tr("Home"); active: HomeState.active-tab == "home"; clicked => { HomeActions.select-tab("home"); } } Tab { label: @tr("Editor's Picks"); active: HomeState.active-tab == "editorPicks"; clicked => { HomeActions.select-tab("editorPicks"); } } Tab { label: @tr("For You"); active: HomeState.active-tab == "forYou"; clicked => { HomeActions.select-tab("forYou"); } } if SettingsState.show-recommendations: Tab { label: @tr("Recommendations"); active: HomeState.active-tab == "recommendations"; clicked => { HomeActions.select-tab("recommendations"); } } } } } // Genre filter + sort gear, anchored right. Explicit width // (= content) so `x` resolves correctly — without it the // layout fills the toolbar and the cluster lands far left, // on top of the history arrows. right-controls := HorizontalLayout { x: parent.width - self.width - 32px; // Same y baseline as the left controls (see above). y: 25px - self.height / 2; width: self.preferred-width; height: self.preferred-height; spacing: 6px; GenreButton { clicked => { GenreFilterActions.set-context("discover"); GenreFilterState.open = !GenreFilterState.open; } } // Per-tab show/hide + reorder control for the Discover // sections (not global Settings — that lives in the header // menu). Opens the configurator modal for the active tab. GearButton { clicked => { DiscoverActions.open-configurator(); } } } } Rectangle { height: 1px; background: Theme.border-subtle; } // --- Scrollable sections ----------------------------------------- // A Flickable (not a ScrollView): wheel scrolling with no scrollbar // gutters at all. viewport-width is pinned to the Flickable width so // the carousels' clipped content can never scroll horizontally. // Wrapped in a non-layout Rectangle so the shared ListScrollbar can // overlay the right gutter without stealing layout space (see the // scrollbar homologation note in ListScrollbar.slint). scroll-host := Rectangle { vertical-stretch: 1; flick := Flickable { width: 100%; height: 100%; viewport-width: self.width; viewport-height: home-content.preferred-height; // Scroll-position restore on back/forward (see nav::scope_for). property sr-armed: NavState.restore-scope; function sr-restore() { if (NavState.restore-scope == "home" && self.viewport-height > self.height) { self.viewport-y = NavState.scroll-restore; NavState.restore-scope = ""; } } init => { self.sr-restore(); } changed sr-armed => { self.sr-restore(); } changed viewport-height => { self.sr-restore(); } changed viewport-y => { NavState.report-scroll(self.viewport-y); } home-content := VerticalLayout { padding: 32px; spacing: 40px; alignment: start; // Loading skeleton: shown while a discover load is in flight and // the active tab's descriptor list is still empty (For You has // its own skeleton inside ForYouView). // Show the skeleton while the initial discover fetch is in // flight. The descriptor list already carries the non-album rows // (recent/continue/playlists) at seed time, so a length check // would suppress it — gate on `loading` alone. The empty-data // placeholders below are gated off while loading, so ONLY the // skeleton shows during the fetch (1:1 Tauri). if DiscoverState.active-tab != "forYou" && DiscoverState.active-tab != "recommendations" && HomeState.loading: HomeSkeleton { } // Prefs-driven render loop (Slice 5). Rust pushes the ordered // ENABLED descriptor list for the active tab (home-sections / // editor-sections); the For You tab's lists are pushed empty so // this repeater renders nothing for it (the unconditional- // repeater form — content is controlled purely via the model, // avoiding an `if cond: for ...`). The delegate is a single-root // VerticalLayout whose inner if-chain mounts the right component // and binds the matching data; exactly one arm matches per id. for desc in (DiscoverState.active-tab == "editorPicks" ? DiscoverState.editor-sections : DiscoverState.home-sections): VerticalLayout { spacing: 0px; // Generic album carousels (data embedded in desc.section by // Rust): newReleases / pressAwards / idealDiscography / // editorPicks / qobuzissimes, plus mostStreamed when it // renders as an album carousel (Editor's Picks). if desc.id == "newReleases" || desc.id == "pressAwards" || desc.id == "idealDiscography" || desc.id == "editorPicks" || desc.id == "qobuzissimes" || (desc.id == "mostStreamed" && desc.kind == "albumCarousel"): Carousel { section: desc.section; show-view-all: desc.section.endpoint != ""; album-clicked(id) => { root.album-clicked(id); } media-action(kind, id, action) => { root.media-action(kind, id, action); } view-all-clicked => { root.discover-view-all(desc.section.endpoint, desc.section.title); } } // Qobuz Playlists row — body=open, overlay=play (1:1 Tauri). // A category multi-select dropdown (ADR-008: no pills) sits // on the title line and filters the row client-side. Mounted // when the row has playlists OR there are category tags to // offer (so a zero-result filter can still be cleared). if desc.id == "qobuzPlaylists" && (HomeState.playlists.length > 0 || HomeState.playlist-tags.length > 0): VerticalLayout { spacing: 12px; // Title + filter dropdown on one line (the carousel // below renders its own paging controls, no title). HorizontalLayout { spacing: 12px; Text { text: @tr("Qobuz Playlists"); color: Theme.text-primary; font-size: 18px; font-weight: Typography.semibold; vertical-alignment: center; horizontal-stretch: 1; } if HomeState.playlist-tags.length > 0: VerticalLayout { alignment: center; PlaylistTagFilter { } } } if HomeState.playlists.length > 0: PlaylistCarousel { // Title shown on the header row above, not here. title: ""; playlists: HomeState.playlists; body-opens: true; // "View all" -> the full PlaylistBrowseView page // (server-side tag + genre filtering; a fresh // open resets its category tab to All). show-view-all: true; view-all-clicked => { HomeActions.open-playlist-browse(); } media-action(kind, id, action) => { root.media-action(kind, id, action); } } // Empty-result hint when a filter excludes everything. if HomeState.playlists.length == 0: Text { text: @tr("No playlists match the selected categories."); color: Theme.text-muted; font-size: 13px; } } // Recently Played Albums — carousel when it has data, else a // placeholder (always present on Home, 1:1 Tauri). if desc.id == "recentlyPlayedAlbums" && HomeState.recent-albums.length > 0: Carousel { section: { title: @tr("Recently Played Albums"), albums: HomeState.recent-albums, }; // "View all" -> the full RecentAlbumsView page (local // play-history, not a /discover endpoint — so it goes // through its own action, not discover-view-all). show-view-all: true; // Manual refresh, in the row header next to the page // chevrons (the recently-played rows are the only ones // that go stale between full home loads). show-refresh: true; refresh-clicked => { HomeActions.refresh-recent(); } album-clicked(id) => { root.album-clicked(id); } media-action(kind, id, action) => { root.media-action(kind, id, action); } view-all-clicked => { HomeActions.open-recent-albums(); } } if desc.id == "recentlyPlayedAlbums" && HomeState.recent-albums.length == 0 && !HomeState.loading: RecentPlaceholder { title: @tr("Recently Played Albums"); hint: @tr("Albums you play will appear here."); } // Recently Played Tracks — slim carousel when it has data, // else a placeholder (continueListening id). if desc.id == "continueListening" && HomeState.recent.length > 0: SlimCarousel { title: @tr("Recently Played Tracks"); items: HomeState.recent; show-refresh: true; refresh-clicked => { HomeActions.refresh-recent(); } item-clicked(id) => { root.media-action("track", id, "play"); } } if desc.id == "continueListening" && HomeState.recent.length == 0 && !HomeState.loading: RecentPlaceholder { title: @tr("Recently Played Tracks"); hint: @tr("Tracks you play will appear here."); } // Most Streamed on Home renders as the Popular slim grid // (kind == "slimGrid"); on Editor's Picks it is the album // carousel handled by the generic arm above. if desc.id == "mostStreamed" && desc.kind == "slimGrid" && HomeState.popular.length > 0: SlimCarousel { title: @tr("Popular albums"); items: HomeState.popular; // "View all" -> the mostStreamed DiscoverBrowse page // (Tauri parity: the Home slim grid's see-all landed // on the Most Streamed full listing). The slimGrid // descriptor carries no section payload, so the // endpoint + localized title are supplied here. show-view-all: true; view-all-clicked => { root.discover-view-all("/discover/mostStreamed", @tr("Most Streamed")); } item-clicked(id) => { root.album-clicked(id); } } // Library Albums (#566) — the user's favorite albums, // mirroring the For You favoriteAlbums carousel. Data + // localized title are pushed by Rust into // HomeState.favorite-albums; self-hides while empty // (no placeholder, 1:1 with the For You arm). if desc.id == "favoriteAlbums" && HomeState.favorite-albums.albums.length > 0: Carousel { section: HomeState.favorite-albums; // Sort dropdown left of the page chevrons (#566). show-sort: true; sort-options: [ @tr("Recently added"), @tr("First added"), @tr("Random"), ]; sort-index: HomeState.library-albums-sort; sort-changed(i) => { HomeState.library-albums-sort = i; HomeActions.set-library-albums-sort(i); } album-clicked(id) => { root.album-clicked(id); } media-action(kind, id, action) => { root.media-action(kind, id, action); } } // Qobuz Mixes (#566) — static navigation tiles, ALWAYS // rendered when enabled (no data gate; 1:1 with the For // You arm and Tauri Home). if desc.id == "qobuzMixes": QobuzMixesRow { open-mix(which) => { root.media-action("mix", which, "open"); } } // Release Watch (#566) — album carousel, mirroring the // For You releaseWatch arm; data + localized title pushed // by Rust into HomeState.release-watch. Self-hides while // empty. if desc.id == "releaseWatch" && HomeState.release-watch.albums.length > 0: Carousel { section: HomeState.release-watch; album-clicked(id) => { root.album-clicked(id); } media-action(kind, id, action) => { root.media-action(kind, id, action); } } // Your Top Artists (#566) — artist carousel, mirroring // the For You topArtists arm (title @tr'd here, like For // You's). Self-hides while empty. if desc.id == "topArtists" && HomeState.top-artists.length > 0: ArtistCarousel { title: @tr("Your Top Artists"); artists: HomeState.top-artists; artist-clicked(id) => { root.open-artist(id); } media-action(kind, id, action) => { root.media-action(kind, id, action); } } // Pinned — the user's mixed pinned albums/artists/ // playlists carousel; data lives in the shared // PinnedState global (the For You arm reads the same // list). Self-hides while nothing is pinned. if desc.id == "pinned" && PinnedState.items.length > 0: PinnedCarousel { title: @tr("Pinned"); items: PinnedState.items; album-clicked(id) => { root.album-clicked(id); } artist-clicked(id) => { root.open-artist(id); } media-action(kind, id, action) => { root.media-action(kind, id, action); } } } // For You renders its own ordered, heterogeneous // sections from ForYouState. if DiscoverState.active-tab == "forYou": ForYouView { open-album(id) => { root.album-clicked(id); } open-artist(id) => { root.open-artist(id); } media-action(kind, id, action) => { root.media-action(kind, id, action); } play-track(id) => { root.media-action("track", id, "play"); } play-top-tracks(id) => { root.media-action("artist", id, "play-top"); } start-radio(id) => { root.media-action("artist", id, "radio"); } start-radio-album(id) => { root.media-action("album", id, "radio"); } open-mix(which) => { root.media-action("mix", which, "open"); } } // Recommendations (4th tab) — external-reco carousels from // ExternalRecoState (lazy-loaded on first open). if DiscoverState.active-tab == "recommendations": ExternalRecoView { open-album(id) => { root.album-clicked(id); } open-artist(id) => { root.open-artist(id); } media-action(kind, id, action) => { root.media-action(kind, id, action); } play-track(id) => { root.media-action("track", id, "play"); } } } } // Shared scrollbar in the right gutter (homologated cross-view). ListScrollbar { x: parent.width - self.width - 4px; width: 14px; height: 100%; viewport-height: flick.viewport-height; visible-height: flick.height; viewport-y <=> flick.viewport-y; auto-hide: true; hover-active: ShellState.pointer-in-window && ShellState.pointer-x >= scroll-host.absolute-position.x && ShellState.pointer-x <= scroll-host.absolute-position.x + scroll-host.width && ShellState.pointer-y >= scroll-host.absolute-position.y && ShellState.pointer-y <= scroll-host.absolute-position.y + scroll-host.height; } } } // --- Genre filter overlay -------------------------------------------- // Backdrop closes the popup on outside click; the panel anchors to // the top-right under the Filter-by-genre button. if GenreFilterState.open: Rectangle { width: 100%; height: 100%; TouchArea { clicked => { GenreFilterState.open = false; } } } if GenreFilterState.open: GenreFilterPopup { x: root.width - self.width - 32px; y: 52px; } }