feat(discover): Qobuz Playlists "View all" page + shared header tools

Port the Tauri playlist "View all": a new PlaylistBrowseView with the category
tag bar, search filter, genre filter and grid/list toggle, extracted into a
shared BrowseHeaderTools and a reusable PlaylistListRow. Restores the "Popular
albums" see-all and aligns PlaylistCarousel's "View all" affordance with the
standard carousels.

cc
This commit is contained in:
vicrodh committed 2026-07-16 16:30:12 -06:00
1 parent 6aa314b8ef
commit 64920a76e4
9 files changed
+1114 -308

No files matched your search

@@ -0,0 +1,173 @@
// Shared "View all" header tools — the search box, the genre-filter
// trigger and the grid/list toggle used by the browse pages
// (DiscoverBrowseView, PlaylistBrowseView). Extracted from
// DiscoverBrowseView so every browse page renders the identical tool
// cluster (ui-control-alignment-standard: 34px controls, Radius.sm).
// Each page binds its own state: the search text and the view mode are
// two-way properties; GenreButton reads the SHARED GenreFilterState.
import { Theme } from "../foundation/semantic-colors.slint";
import { Typography } from "../foundation/typography.slint";
import { GenreFilterState, UiFocusState } from "../state.slint";
import { QbzIcon } from "../primitives/QbzIcon.slint";
import { ToggleButton } from "../primitives/ToggleButton.slint";
// Search field — a leading magnifier, the input, and a trailing X that
// clears (mirrors Tauri's .search-wrapper). `text` two-way-binds to the
// caller's state global; `edited()` fires on every change, including the
// clear-X.
export component BrowseSearch inherits Rectangle {
in-out property <string> text;
callback edited();
height: 34px;
width: 200px;
border-radius: 6px;
background: Theme.surface-elevated;
border-width: 1px;
border-color: input.has-focus ? Theme.accent : Theme.border-subtle;
HorizontalLayout {
padding-left: 10px;
padding-right: 6px;
spacing: 8px;
VerticalLayout {
alignment: center;
QbzIcon {
source: @image-url("../assets/icons/search.svg");
width: 15px;
height: 15px;
tint: Theme.text-muted;
}
}
Rectangle {
horizontal-stretch: 1;
clip: true;
input := TextInput {
width: 100%;
height: 100%;
color: Theme.text-primary;
font-size: 13px;
vertical-alignment: center;
single-line: true;
text <=> root.text;
changed has-focus => { UiFocusState.text-input-focused = self.has-focus; }
edited => {
root.edited();
}
}
Text {
visible: input.text == "";
width: 100%;
height: 100%;
text: @tr("Search…");
color: Theme.text-muted;
font-size: 13px;
vertical-alignment: center;
overflow: elide;
}
}
// Trailing X — clears the query.
VerticalLayout {
alignment: center;
Rectangle {
visible: root.text != "";
width: 22px;
height: 22px;
border-radius: 4px;
background: clear-ta.has-hover ? Theme.surface-hover : transparent;
QbzIcon {
source: @image-url("../assets/icons/x.svg");
width: 12px;
height: 12px;
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
tint: clear-ta.has-hover ? Theme.text-primary : Theme.text-muted;
}
clear-ta := TouchArea {
mouse-cursor: pointer;
clicked => {
root.text = "";
root.edited();
}
}
}
}
}
}
// Genre-filter trigger — same visual as HomeView's GenreButton: accent
// fill + "N genres" when a selection is active. Reads the SHARED
// GenreFilterState; the caller decides which context the click opens.
export component GenreButton inherits Rectangle {
callback clicked();
property <bool> active: GenreFilterState.selected-count > 0;
width: genre-row.preferred-width;
height: 34px;
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();
}
}
}
// Grid/list view toggle — the 68x34 two-button pair. `mode` two-way-binds
// to the caller's view-mode state ("grid" | "list").
export component ViewModeToggle inherits Rectangle {
in-out property <string> mode;
width: 68px;
height: 34px;
border-radius: 6px;
background: Theme.surface-elevated;
clip: true;
HorizontalLayout {
ToggleButton {
icon: @image-url("../assets/icons/layout-grid.svg");
active: root.mode == "grid";
clicked => {
root.mode = "grid";
}
}
ToggleButton {
icon: @image-url("../assets/icons/list.svg");
active: root.mode == "list";
clicked => {
root.mode = "list";
}
}
}
}
@@ -10,140 +10,13 @@
import { Theme } from "../foundation/semantic-colors.slint";
import { Typography } from "../foundation/typography.slint";
import { DiscoverBrowseState, DiscoverBrowseActions, GenreFilterState, GenreFilterActions, NavState, ShellState, UiFocusState } from "../state.slint";
import { DiscoverBrowseState, DiscoverBrowseActions, GenreFilterState, GenreFilterActions, NavState, ShellState } from "../state.slint";
import { AlbumCollectionView } from "AlbumCollectionView.slint";
import { BrowseSearch, GenreButton, ViewModeToggle } from "BrowseHeaderTools.slint";
import { GenreFilterPopup } from "GenreFilterPopup.slint";
import { LoadingSpinner } from "../primitives/LoadingSpinner.slint";
import { ListScrollbar } from "../primitives/ListScrollbar.slint";
import { NavButtons } from "../shell/NavButtons.slint";
import { QbzIcon } from "../primitives/QbzIcon.slint";
import { ToggleButton } from "../primitives/ToggleButton.slint";
// Search field — a leading magnifier, the input, and a trailing X that
// clears (mirrors Tauri's .search-wrapper).
component BrowseSearch inherits Rectangle {
height: 34px;
width: 200px;
border-radius: 6px;
background: Theme.surface-elevated;
border-width: 1px;
border-color: input.has-focus ? Theme.accent : Theme.border-subtle;
HorizontalLayout {
padding-left: 10px;
padding-right: 6px;
spacing: 8px;
VerticalLayout {
alignment: center;
QbzIcon {
source: @image-url("../assets/icons/search.svg");
width: 15px;
height: 15px;
tint: Theme.text-muted;
}
}
Rectangle {
horizontal-stretch: 1;
clip: true;
input := TextInput {
width: 100%;
height: 100%;
color: Theme.text-primary;
font-size: 13px;
vertical-alignment: center;
single-line: true;
text <=> DiscoverBrowseState.search-query;
changed has-focus => { UiFocusState.text-input-focused = self.has-focus; }
edited => {
DiscoverBrowseActions.search-changed();
}
}
Text {
visible: input.text == "";
width: 100%;
height: 100%;
text: @tr("Search…");
color: Theme.text-muted;
font-size: 13px;
vertical-alignment: center;
overflow: elide;
}
}
// Trailing X — clears the query.
VerticalLayout {
alignment: center;
Rectangle {
visible: DiscoverBrowseState.search-query != "";
width: 22px;
height: 22px;
border-radius: 4px;
background: clear-ta.has-hover ? Theme.surface-hover : transparent;
QbzIcon {
source: @image-url("../assets/icons/x.svg");
width: 12px;
height: 12px;
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
tint: clear-ta.has-hover ? Theme.text-primary : Theme.text-muted;
}
clear-ta := TouchArea {
mouse-cursor: pointer;
clicked => {
DiscoverBrowseState.search-query = "";
DiscoverBrowseActions.search-changed();
}
}
}
}
}
}
// Genre-filter trigger — same visual as HomeView's GenreButton: accent
// fill + "N genres" when a selection is active.
component GenreButton inherits Rectangle {
callback clicked();
property <bool> active: GenreFilterState.selected-count > 0;
width: genre-row.preferred-width;
height: 34px;
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();
}
}
}
export component DiscoverBrowseView inherits Rectangle {
callback open-album(string);
@@ -204,7 +77,12 @@ export component DiscoverBrowseView inherits Rectangle {
spacing: 8px;
VerticalLayout {
alignment: center;
BrowseSearch { }
BrowseSearch {
text <=> DiscoverBrowseState.search-query;
edited => {
DiscoverBrowseActions.search-changed();
}
}
}
VerticalLayout {
alignment: center;
@@ -217,28 +95,8 @@ export component DiscoverBrowseView inherits Rectangle {
}
VerticalLayout {
alignment: center;
Rectangle {
width: 68px;
height: 34px;
border-radius: 6px;
background: Theme.surface-elevated;
clip: true;
HorizontalLayout {
ToggleButton {
icon: @image-url("../assets/icons/layout-grid.svg");
active: DiscoverBrowseState.view-mode == "grid";
clicked => {
DiscoverBrowseState.view-mode = "grid";
}
}
ToggleButton {
icon: @image-url("../assets/icons/list.svg");
active: DiscoverBrowseState.view-mode == "list";
clicked => {
DiscoverBrowseState.view-mode = "list";
}
}
}
ViewModeToggle {
mode <=> DiscoverBrowseState.view-mode;
}
}
}
@@ -0,0 +1,338 @@
// Qobuz Playlists "View all" full-list page — the playlist twin of
// DiscoverBrowseView (same fixed 56px header with the shared browse
// tools, genre overlay, infinite scroll and scrollbar), plus a
// single-select category tag bar under the header (1:1 with Tauri's
// DiscoverPlaylistsBrowseView radio row; ADR-008: radio dots, no pills).
// The tag and the shared genre selection filter SERVER-side (re-fetch
// from offset 0); the search box filters the loaded set client-side and
// disables load-more while active (Tauri does the same).
import { Theme } from "../foundation/semantic-colors.slint";
import { Typography } from "../foundation/typography.slint";
import { PlaylistBrowseState, PlaylistBrowseActions, GenreFilterState, GenreFilterActions, NavState, ShellState } from "../state.slint";
import { BrowseSearch, GenreButton, ViewModeToggle } from "BrowseHeaderTools.slint";
import { GenreFilterPopup } from "GenreFilterPopup.slint";
import { PlaylistCard } from "PlaylistCard.slint";
import { PlaylistListRow } from "../primitives/PlaylistListRow.slint";
import { LoadingSpinner } from "../primitives/LoadingSpinner.slint";
import { ListScrollbar } from "../primitives/ListScrollbar.slint";
import { NavButtons } from "../shell/NavButtons.slint";
// A single-select category option — a ring with an accent dot when
// selected, and a label (the SearchResultsView FilterRadio style;
// ADR-008 allows native-style radio dots for option selection).
component FilterRadio inherits Rectangle {
in property <string> label;
in property <bool> selected;
callback clicked();
height: 22px;
width: radio-row.preferred-width;
radio-row := HorizontalLayout {
spacing: 6px;
VerticalLayout {
alignment: center;
Rectangle {
width: 14px;
height: 14px;
border-radius: 7px;
border-width: 1.5px;
border-color: root.selected ? Theme.accent : Theme.text-muted;
Rectangle {
width: 8px;
height: 8px;
border-radius: 4px;
background: root.selected ? Theme.accent : transparent;
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
}
}
}
Text {
text: root.label;
color: (root.selected || ta.has-hover)
? Theme.text-primary
: Theme.text-secondary;
font-size: 12px;
vertical-alignment: center;
}
}
ta := TouchArea {
mouse-cursor: pointer;
clicked => {
root.clicked();
}
}
}
export component PlaylistBrowseView inherits Rectangle {
// kind ("playlist"), playlist id, action — the shell's media-action
// funnel handles "open" / "play" / "play-next" / "queue" / "favorite".
callback media-action(string, string, string);
property <length> pl-w: 200px;
property <length> pl-h: 246px;
property <length> gap: 24px;
// Fixed chrome above the scrolling list: the 56px header plus the
// 36px tag bar when the tag set is non-empty.
property <length> header-h: PlaylistBrowseState.tags.length > 0 ? 92px : 56px;
background: Theme.surface-main;
VerticalLayout {
spacing: 0px;
// --- Fixed header (always visible) -------------------------------
// Identical geometry to DiscoverBrowseView: NavButtons + title on
// the LEFT; search / genre / grid-list tools as one group anchored
// RIGHT at the 32px gutter. Only the list below scrolls.
Rectangle {
height: 56px;
background: Theme.surface-main;
// Left cluster — nav buttons then the title, inline.
HorizontalLayout {
x: 32px;
y: 25px - self.height / 2;
width: self.preferred-width;
height: self.preferred-height;
spacing: 16px;
VerticalLayout {
alignment: center;
NavButtons { }
}
VerticalLayout {
alignment: center;
Text {
text: PlaylistBrowseState.title;
color: Theme.text-primary;
font-size: Typography.section;
font-weight: Typography.bold;
vertical-alignment: center;
}
}
}
// Right cluster — the header tools as one group, anchored right.
HorizontalLayout {
x: parent.width - self.preferred-width - 32px;
y: 25px - self.height / 2;
width: self.preferred-width;
height: self.preferred-height;
spacing: 8px;
VerticalLayout {
alignment: center;
BrowseSearch {
text <=> PlaylistBrowseState.search-query;
edited => {
PlaylistBrowseActions.search-changed();
}
}
}
VerticalLayout {
alignment: center;
GenreButton {
clicked => {
GenreFilterActions.set-context("discover");
GenreFilterState.open = !GenreFilterState.open;
}
}
}
VerticalLayout {
alignment: center;
ViewModeToggle {
mode <=> PlaylistBrowseState.view-mode;
}
}
}
}
// --- Category tag bar (single-select, server-side) ---------------
// "All" + the localized /playlist/getTags categories as a radio
// row; overflow scrolls horizontally inside its own Flickable.
if PlaylistBrowseState.tags.length > 0: Rectangle {
height: 36px;
background: Theme.surface-main;
Flickable {
x: 32px;
width: parent.width - 64px;
height: parent.height;
viewport-width: tag-row.preferred-width;
viewport-height: self.height;
tag-row := HorizontalLayout {
spacing: 16px;
padding-top: 7px;
alignment: start;
FilterRadio {
label: @tr("All");
selected: PlaylistBrowseState.selected-tag == "";
clicked => {
PlaylistBrowseActions.select-tag("");
}
}
for tag in PlaylistBrowseState.tags: FilterRadio {
// Tag names arrive pre-localized from the API.
label: tag.name;
selected: tag.selected;
clicked => {
PlaylistBrowseActions.select-tag(tag.slug);
}
}
}
}
}
// --- Scrolling list (only the listing scrolls) ------------------
flick := Flickable {
vertical-stretch: 1;
viewport-width: self.width;
viewport-height: page.preferred-height;
// Scroll-position restore on back/forward (see nav::scope_for).
property <string> sr-armed: NavState.restore-scope;
function sr-restore() {
if (NavState.restore-scope == "playlist-browse" && 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(); }
// Infinite scroll — when the viewport nears the bottom of the
// content (within 600px), pull the next page. Guarded by
// has-more + the loading flags AND an empty search query, so a
// client-side search filter does not keep paging in the catalog.
changed viewport-y => {
NavState.report-scroll(self.viewport-y);
if (-self.viewport-y + self.height >= self.viewport-height - 600px
&& PlaylistBrowseState.has-more
&& !PlaylistBrowseState.loading-more
&& !PlaylistBrowseState.loading
&& PlaylistBrowseState.search-query == "") {
PlaylistBrowseActions.load-more();
}
}
page := VerticalLayout {
width: parent.width;
padding-left: 32px;
padding-right: 32px;
padding-top: 8px;
padding-bottom: 100px;
spacing: 0px;
alignment: start;
if PlaylistBrowseState.loading: HorizontalLayout {
alignment: center;
LoadingSpinner { size: 36px; }
}
// Empty-state when a search filters everything out.
if !PlaylistBrowseState.loading
&& PlaylistBrowseState.visible.length == 0
&& PlaylistBrowseState.search-query != "": HorizontalLayout {
alignment: center;
Text {
text: @tr("No results found.");
color: Theme.text-muted;
font-size: 14px;
}
}
// Empty-state when the tag/genre selection returns nothing.
if !PlaylistBrowseState.loading
&& PlaylistBrowseState.visible.length == 0
&& PlaylistBrowseState.search-query == "": HorizontalLayout {
alignment: center;
Text {
text: @tr("No playlists match the selected categories.");
color: Theme.text-muted;
font-size: 14px;
}
}
// --- Playlist collection (grid / list) --------------------
// Grid math cloned from FavoritesView's playlists grid: the
// abs-positioned cards measure against this EXACT Rectangle
// (abs-pos inside Flickable rule), whose height is derived
// from the column count — no sibling-height binding loops.
if !PlaylistBrowseState.loading
&& PlaylistBrowseState.view-mode != "list": playlists-grid := Rectangle {
property <int> columns: Math.max(
1,
Math.floor((self.width + root.gap) / (root.pl-w + root.gap)));
property <int> rows: Math.ceil(PlaylistBrowseState.visible.length / self.columns);
height: self.rows > 0
? self.rows * root.pl-h + Math.max(0, self.rows - 1) * root.gap
: 0px;
for pl[i] in PlaylistBrowseState.visible: PlaylistCard {
x: Math.mod(i, playlists-grid.columns) * (root.pl-w + root.gap);
y: Math.floor(i / playlists-grid.columns) * (root.pl-h + root.gap);
playlist: pl;
body-opens: true;
media-action(k, id, a) => {
root.media-action(k, id, a);
}
}
}
if !PlaylistBrowseState.loading
&& PlaylistBrowseState.view-mode == "list": VerticalLayout {
spacing: 2px;
for pl[i] in PlaylistBrowseState.visible: PlaylistListRow {
playlist: pl;
index: i;
media-action(k, id, a) => {
root.media-action(k, id, a);
}
}
}
// Bottom load-more spinner while the next page is in flight.
if PlaylistBrowseState.loading-more: HorizontalLayout {
alignment: center;
Rectangle { height: 24px; }
LoadingSpinner { size: 28px; }
}
}
}
}
// Shared scrollbar in the right gutter (homologated cross-view). Sits
// below the fixed header (+ tag bar) so it tracks only the list.
ListScrollbar {
x: parent.width - self.width - 4px;
y: root.header-h;
width: 14px;
height: parent.height - root.header-h;
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 >= flick.absolute-position.x
&& ShellState.pointer-x <= flick.absolute-position.x + flick.width
&& ShellState.pointer-y >= flick.absolute-position.y
&& ShellState.pointer-y <= flick.absolute-position.y + flick.height;
}
// --- Genre filter overlay --------------------------------------------
// Backdrop closes the popup on outside click; the panel anchors to
// the top-right under the Filter-by-genre button (mirrors
// DiscoverBrowseView).
if GenreFilterState.open: Rectangle {
width: 100%;
height: 100%;
TouchArea {
clicked => {
GenreFilterState.open = false;
}
}
}
if GenreFilterState.open: GenreFilterPopup {
x: root.width - self.width - 32px;
y: 56px;
}
}
@@ -46,8 +46,10 @@ component ViewAllLink inherits Rectangle {
border-radius: 4px;
background: ta.has-hover ? Theme.surface-hover : transparent;
link-text := Text {
text: @tr("View all");
color: Theme.accent;
// Muted with the trailing arrow — 1:1 with the standard Carousel's
// "View all →" (not the theme accent) so every Home row's link matches.
text: @tr("View all →");
color: ta.has-hover ? Theme.text-primary : Theme.text-secondary;
font-size: 14px;
font-weight: Typography.medium;
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
@@ -98,6 +100,18 @@ export component PlaylistCarousel inherits VerticalLayout {
vertical-alignment: center;
horizontal-stretch: 1;
}
// "View all →" sits to the LEFT of the page chevrons, 4px control
// grouping — identical to the standard Carousel header.
HorizontalLayout {
spacing: 4px;
if root.show-view-all: VerticalLayout {
alignment: center;
ViewAllLink {
clicked => {
root.view-all-clicked();
}
}
}
VerticalLayout {
alignment: center;
NavButton {
@@ -118,13 +132,6 @@ export component PlaylistCarousel inherits VerticalLayout {
}
}
}
if root.show-view-all: VerticalLayout {
alignment: center;
ViewAllLink {
clicked => {
root.view-all-clicked();
}
}
}
}
@@ -43,10 +43,38 @@ component NavButton inherits Rectangle {
}
}
// "View all" header link — same affordance as PlaylistCarousel/Carousel.
component ViewAllLink inherits Rectangle {
callback clicked();
width: link-text.preferred-width + 16px;
height: 26px;
border-radius: 4px;
background: ta.has-hover ? Theme.surface-hover : transparent;
link-text := Text {
text: @tr("View all");
color: Theme.accent;
font-size: 14px;
font-weight: Typography.medium;
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
}
ta := TouchArea {
mouse-cursor: pointer;
clicked => {
root.clicked();
}
}
}
export component SlimCarousel inherits VerticalLayout {
in property <string> title;
in property <[SlimItem]> items;
callback item-clicked(string);
// Optional "View all" link at the end of the header row (only the
// Home "Popular albums" grid opts in — it lands on the mostStreamed
// DiscoverBrowse page, Tauri parity).
in property <bool> show-view-all: false;
callback view-all-clicked();
// P7: optional title-adjacent list actions ("add to queue" / "create
// playlist from this list"). Off by default so the shared SlimCarousel rows
// (recent, popular) are unchanged; only the Weekly rows opt in.
@@ -139,6 +167,14 @@ export component SlimCarousel inherits VerticalLayout {
}
}
}
if root.show-view-all: VerticalLayout {
alignment: center;
ViewAllLink {
clicked => {
root.view-all-clicked();
}
}
}
}
// --- Track: a horizontal Flickable so the grid drags by page like
+1 -147
View File
@@ -15,7 +15,6 @@ import {
TrackItem,
FavoriteArtistItem,
FavoriteLabelItem,
SearchPlaylistItem,
DragState,
GenreFilterState,
GenreFilterActions,
@@ -32,10 +31,9 @@ import { MultiSelectBar } from "../primitives/MultiSelectBar.slint";
import { ToggleButton } from "../primitives/ToggleButton.slint";
import { QbzSelect } from "../primitives/QbzSelect.slint";
import { QbzIcon } from "../primitives/QbzIcon.slint";
import { ContextMenu } from "../primitives/ContextMenu.slint";
import { ContextMenuItem } from "../primitives/ContextMenuItem.slint";
import { LoadingSpinner } from "../primitives/LoadingSpinner.slint";
import { ListScrollbar } from "../primitives/ListScrollbar.slint";
import { PlaylistListRow } from "../primitives/PlaylistListRow.slint";
import { SegmentedTabBar, SegTab } from "../primitives/SegmentedTabBar.slint";
import { NavButtons } from "../shell/NavButtons.slint";
import { ListView } from "std-widgets.slint";
@@ -100,150 +98,6 @@ component PlaylistSubTab inherits Rectangle {
}
}
// Compact list row for the Favorites Playlists list view (Slint-better;
// Tauri ships grid only). Thumb + name + owner·tracks + play + more-menu.
component PlaylistListRow inherits Rectangle {
in property <SearchPlaylistItem> playlist;
in property <int> index;
callback media-action(string, string, string);
property <bool> row-hover: body-ta.has-hover || play-ta.has-hover || more-ta.has-hover;
height: 60px;
border-radius: 8px;
background: root.row-hover
? #ffffff14
: (Math.mod(root.index, 2) == 1 ? #ffffff07 : transparent);
// Full-row click target (behind the action buttons) -> open the playlist.
body-ta := TouchArea {
mouse-cursor: pointer;
clicked => {
root.media-action("playlist", root.playlist.id, "open");
}
}
HorizontalLayout {
padding-left: 10px;
padding-right: 10px;
spacing: 12px;
VerticalLayout {
alignment: center;
Rectangle {
width: 44px;
height: 44px;
border-radius: 6px;
background: Theme.surface-elevated;
clip: true;
Image {
width: 100%;
height: 100%;
image-fit: cover;
source: root.playlist.cover1;
}
if root.playlist.cover-count == 0: QbzIcon {
source: @image-url("../assets/icons/list-music.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: Theme.text-muted;
}
}
}
VerticalLayout {
alignment: center;
horizontal-stretch: 1;
Text {
text: root.playlist.title;
color: Theme.text-primary;
font-size: Typography.body;
font-weight: Typography.medium;
overflow: elide;
}
Text {
text: root.playlist.subtitle;
color: Theme.text-muted;
font-size: Typography.legal;
overflow: elide;
}
}
VerticalLayout {
alignment: center;
play-ta := TouchArea {
width: 30px;
height: 30px;
mouse-cursor: pointer;
clicked => {
root.media-action("playlist", root.playlist.id, "play");
}
QbzIcon {
source: @image-url("../assets/icons/play-fill.svg");
width: 16px;
height: 16px;
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
tint: root.row-hover ? Theme.text-primary : Theme.text-muted;
}
}
}
VerticalLayout {
alignment: center;
more-ta := TouchArea {
width: 30px;
height: 30px;
mouse-cursor: pointer;
clicked => {
row-menu.show();
}
QbzIcon {
source: @image-url("../assets/icons/ellipsis.svg");
width: 16px;
height: 16px;
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
tint: Theme.text-secondary;
}
row-menu := PopupWindow {
x: -166px;
y: 32px;
width: 196px;
height: 150px;
ContextMenu {
ContextMenuItem {
icon: @image-url("../assets/icons/play.svg");
label: @tr("Play");
clicked => {
root.media-action("playlist", root.playlist.id, "play");
}
}
ContextMenuItem {
icon: @image-url("../assets/icons/list-plus.svg");
label: @tr("Play next");
clicked => {
root.media-action("playlist", root.playlist.id, "play-next");
}
}
ContextMenuItem {
icon: @image-url("../assets/icons/list-end.svg");
label: @tr("Add to queue");
clicked => {
root.media-action("playlist", root.playlist.id, "queue");
}
}
ContextMenuItem {
icon: @image-url("../assets/icons/heart.svg");
label: @tr("Add to Library");
clicked => {
root.media-action("playlist", root.playlist.id, "favorite");
}
}
}
}
}
}
}
}
// One label tile in the Favorites > Labels grid — a circular cover (or
// an indigo->violet gradient + disc placeholder), name, and album count.
// Mirrors FavArtistCard; the gradient placeholder is the 1:1 match with
@@ -0,0 +1,153 @@
// Compact playlist list row (Slint-better; Tauri ships grid only).
// Thumb + name + owner·tracks + play + more-menu. Shared by the Favorites
// Playlists list view and the Qobuz Playlists browse page — extracted
// verbatim from FavoritesView, no styling changes.
import { Theme } from "../foundation/semantic-colors.slint";
import { Typography } from "../foundation/typography.slint";
import { SearchPlaylistItem } from "../state.slint";
import { QbzIcon } from "QbzIcon.slint";
import { ContextMenu } from "ContextMenu.slint";
import { ContextMenuItem } from "ContextMenuItem.slint";
export component PlaylistListRow inherits Rectangle {
in property <SearchPlaylistItem> playlist;
in property <int> index;
callback media-action(string, string, string);
property <bool> row-hover: body-ta.has-hover || play-ta.has-hover || more-ta.has-hover;
height: 60px;
border-radius: 8px;
background: root.row-hover
? #ffffff14
: (Math.mod(root.index, 2) == 1 ? #ffffff07 : transparent);
// Full-row click target (behind the action buttons) -> open the playlist.
body-ta := TouchArea {
mouse-cursor: pointer;
clicked => {
root.media-action("playlist", root.playlist.id, "open");
}
}
HorizontalLayout {
padding-left: 10px;
padding-right: 10px;
spacing: 12px;
VerticalLayout {
alignment: center;
Rectangle {
width: 44px;
height: 44px;
border-radius: 6px;
background: Theme.surface-elevated;
clip: true;
Image {
width: 100%;
height: 100%;
image-fit: cover;
source: root.playlist.cover1;
}
if root.playlist.cover-count == 0: QbzIcon {
source: @image-url("../assets/icons/list-music.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: Theme.text-muted;
}
}
}
VerticalLayout {
alignment: center;
horizontal-stretch: 1;
Text {
text: root.playlist.title;
color: Theme.text-primary;
font-size: Typography.body;
font-weight: Typography.medium;
overflow: elide;
}
Text {
text: root.playlist.subtitle;
color: Theme.text-muted;
font-size: Typography.legal;
overflow: elide;
}
}
VerticalLayout {
alignment: center;
play-ta := TouchArea {
width: 30px;
height: 30px;
mouse-cursor: pointer;
clicked => {
root.media-action("playlist", root.playlist.id, "play");
}
QbzIcon {
source: @image-url("../assets/icons/play-fill.svg");
width: 16px;
height: 16px;
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
tint: root.row-hover ? Theme.text-primary : Theme.text-muted;
}
}
}
VerticalLayout {
alignment: center;
more-ta := TouchArea {
width: 30px;
height: 30px;
mouse-cursor: pointer;
clicked => {
row-menu.show();
}
QbzIcon {
source: @image-url("../assets/icons/ellipsis.svg");
width: 16px;
height: 16px;
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
tint: Theme.text-secondary;
}
row-menu := PopupWindow {
x: -166px;
y: 32px;
width: 196px;
height: 150px;
ContextMenu {
ContextMenuItem {
icon: @image-url("../assets/icons/play.svg");
label: @tr("Play");
clicked => {
root.media-action("playlist", root.playlist.id, "play");
}
}
ContextMenuItem {
icon: @image-url("../assets/icons/list-plus.svg");
label: @tr("Play next");
clicked => {
root.media-action("playlist", root.playlist.id, "play-next");
}
}
ContextMenuItem {
icon: @image-url("../assets/icons/list-end.svg");
label: @tr("Add to queue");
clicked => {
root.media-action("playlist", root.playlist.id, "queue");
}
}
ContextMenuItem {
icon: @image-url("../assets/icons/heart.svg");
label: @tr("Add to Library");
clicked => {
root.media-action("playlist", root.playlist.id, "favorite");
}
}
}
}
}
}
}
}
+4
View File
@@ -55,6 +55,10 @@ pub enum NavEntry {
/// store (crate::recently), reloaded on every navigation, so the entry
/// carries no payload.
RecentAlbums,
/// PlaylistBrowseView — the Qobuz Playlists "View all" full-list page
/// reached from the Home / Editor's Picks rail. Tag / search / view-mode
/// are session state in PlaylistBrowseState, so no payload.
PlaylistBrowse,
/// A Qobuz mix detail page ("daily" | "weekly" | "fav" | "top").
Mix {
kind: String,
+383
View File
@@ -0,0 +1,383 @@
//! Qobuz Playlists "View all" full-list controller.
//!
//! The playlist twin of `discover_browse`: opens the Qobuz Playlists rail
//! (Home / Editor's Picks) as a paginated full grid/list page backed by
//! `/discover/playlists`. Two filters are SERVER-side — the single-select
//! category tag bar (`/playlist/getTags`, localized names) and the shared
//! "discover" genre selection both re-fetch from offset 0 — while the
//! header search box filters the loaded set client-side and disables
//! load-more while active (same contract as the album browse page).
//!
//! Unlike the album endpoints there is no sub-genre narrowing here: the
//! `genre_ids` facet (`current_genre_filter()`, top-level ancestor ids) is
//! passed through as-is — Discover playlists carry no `genre.path` to
//! narrow against client-side.
//!
//! The selected tag is process state (`SELECTED_TAG`) rather than a
//! read-back of the Slint global, so the fetch tasks can read it off the
//! UI thread. It survives genre-filter re-navigations and resets only on
//! a fresh open from the rail's "View all" link (Tauri resets on mount).
use std::sync::{Arc, Mutex};
use qbz_app::shell::AppRuntime;
use qbz_models::{DiscoverPlaylist, PlaylistTag};
use slint::{ComponentHandle, Model, ModelRc, VecModel};
use crate::adapter::SlintAdapter;
use crate::artwork::{self, ArtworkJob, ArtworkTarget, ImageCache};
use crate::{
AppWindow, ContentView, NavState, PlaylistBrowseState, PlaylistTagItem, SearchPlaylistItem,
};
/// Page size — mirrors discover_browse (and Tauri's limit=50).
const PAGE_SIZE: u32 = 50;
/// The active category tag slug ("" = All). See the module docs for why
/// this lives outside the Slint global.
static SELECTED_TAG: Mutex<String> = Mutex::new(String::new());
fn selected_tag() -> String {
SELECTED_TAG
.lock()
.map(|s| s.clone())
.unwrap_or_default()
}
/// One loaded playlist: the shared single-cover card plus the list-row
/// subtitle (owner + track count) that the rail cards drop but the browse
/// list view renders.
struct BrowseCard {
card: crate::home::PlaylistCardData,
subtitle: String,
}
/// Map a Discover playlist reusing the Home rail mapper, capturing the
/// owner + localized track count for the list rows before the payload
/// moves into `map_playlist` (same "owner • N tracks" convention as
/// the search playlist rows).
fn map_browse(p: DiscoverPlaylist) -> BrowseCard {
let mut subtitle = p.owner.name.clone();
if p.tracks_count > 0 {
let n = p.tracks_count;
let tracks_label =
qbz_i18n::tf("{} track", "{} tracks", n as i64, &[&n.to_string()]);
if subtitle.is_empty() {
subtitle = tracks_label;
} else {
subtitle = format!("{} • {}", subtitle, tracks_label);
}
}
BrowseCard {
card: crate::home::map_playlist(p),
subtitle,
}
}
/// Convert to the Slint item — the rail converter plus the subtitle (the
/// grid cards ignore it; the list rows render it).
fn to_item(bc: &BrowseCard) -> SearchPlaylistItem {
let mut item = crate::home::playlist_to_item(&bc.card);
item.subtitle = bc.subtitle.clone().into();
item
}
/// Fetch one page of `/discover/playlists` faceted by the tag slug
/// ("" = All) + the shared genre selection. Returns the cards and the
/// backend `has_more` flag (the endpoint carries no `total`).
async fn fetch_page(
runtime: &Arc<AppRuntime<SlintAdapter>>,
tag: &str,
genre_ids: Option<Vec<u64>>,
offset: u32,
) -> Result<(Vec<BrowseCard>, bool), String> {
let tag_opt = (!tag.is_empty()).then(|| tag.to_string());
let data = runtime
.core()
.get_discover_playlists(tag_opt, genre_ids, Some(PAGE_SIZE), Some(offset))
.await
.map_err(|e| e.to_string())?;
let has_more = data.has_more;
Ok((data.items.into_iter().map(map_browse).collect(), has_more))
}
/// Open the Qobuz Playlists full-list page: reset the page state, switch
/// the view, fetch the tag bar + the first page concurrently, then fan
/// out artwork. `genre_ids` is the shared genre-filter selection (None =
/// no filter). `reset_tag` picks the tag semantics: true on a fresh open
/// from the rail's "View all" (back to All), false on genre-filter /
/// history re-navigations (the selected tab survives).
pub fn navigate(
runtime: Arc<AppRuntime<SlintAdapter>>,
weak: slint::Weak<AppWindow>,
handle: &tokio::runtime::Handle,
image_cache: ImageCache,
genre_ids: Option<Vec<u64>>,
reset_tag: bool,
) {
if reset_tag {
if let Ok(mut s) = SELECTED_TAG.lock() {
s.clear();
}
}
let selected = selected_tag();
handle.spawn(async move {
// Reset the page state and switch the view on the UI thread. The
// search query clears on a fresh navigation; the view mode and the
// selected tag persist (the tag was cleared above when this is a
// fresh open from the rail).
{
let selected = selected.clone();
let _ = weak.upgrade_in_event_loop(move |w| {
let state = w.global::<PlaylistBrowseState>();
state.set_title(qbz_i18n::t("Qobuz Playlists").into());
state.set_selected_tag(selected.into());
state.set_next_offset(0);
state.set_search_query("".into());
state.set_playlists(ModelRc::new(VecModel::from(
Vec::<SearchPlaylistItem>::new(),
)));
state.set_visible(ModelRc::new(VecModel::from(
Vec::<SearchPlaylistItem>::new(),
)));
state.set_loading(true);
state.set_loading_more(false);
state.set_has_more(true);
w.global::<NavState>().set_view(ContentView::PlaylistBrowse);
});
}
let (tags_res, page_res) = futures_util::join!(
runtime.core().get_playlist_tags(),
fetch_page(&runtime, &selected, genre_ids, 0)
);
// A tag-bar failure is non-fatal: the page still lists playlists.
let tags: Vec<PlaylistTag> = match tags_res {
Ok(tags) => tags,
Err(e) => {
log::warn!("[qbz-slint] playlist-browse tags load failed: {e}");
Vec::new()
}
};
match page_res {
Ok((cards, has_more)) => {
let jobs = artwork_jobs(&cards, 0);
let _ = weak.upgrade_in_event_loop(move |w| {
let items: Vec<SearchPlaylistItem> = cards.iter().map(to_item).collect();
let fetched = items.len() as i32;
let state = w.global::<PlaylistBrowseState>();
state.set_tags(ModelRc::new(VecModel::from(
tags.into_iter()
.map(|t| {
let is_selected = t.slug == selected;
PlaylistTagItem {
slug: t.slug.into(),
name: t.name.into(),
selected: is_selected,
}
})
.collect::<Vec<_>>(),
)));
state.set_playlists(ModelRc::new(VecModel::from(items)));
state.set_next_offset(fetched);
state.set_has_more(has_more);
state.set_loading(false);
apply_filter(&w);
});
artwork::spawn_loads(jobs, weak.clone(), image_cache.clone());
}
Err(e) => {
log::error!("[qbz-slint] playlist-browse load failed: {e}");
let _ = weak.upgrade_in_event_loop(|w| {
let state = w.global::<PlaylistBrowseState>();
state.set_loading(false);
state.set_has_more(false);
});
}
}
});
}
/// Fetch the next page (offset = PlaylistBrowseState.next-offset) and
/// append it. Wired to PlaylistBrowseActions::load-more; `genre_ids` is
/// the shared genre-filter selection. UI thread entry.
pub fn load_more(
runtime: Arc<AppRuntime<SlintAdapter>>,
weak: slint::Weak<AppWindow>,
handle: &tokio::runtime::Handle,
image_cache: ImageCache,
genre_ids: Option<Vec<u64>>,
) {
let Some(w) = weak.upgrade() else {
return;
};
let state = w.global::<PlaylistBrowseState>();
if !state.get_has_more() || state.get_loading_more() || state.get_loading() {
return;
}
// A non-empty search filters the loaded set client-side; pulling more
// pages while filtering matches no UX (Tauri disables load-more too).
if !state.get_search_query().is_empty() {
return;
}
let offset = state.get_next_offset().max(0) as u32;
let base_index = state.get_playlists().row_count();
state.set_loading_more(true);
let selected = selected_tag();
handle.spawn(async move {
match fetch_page(&runtime, &selected, genre_ids, offset).await {
Ok((cards, has_more)) => {
let jobs = artwork_jobs(&cards, base_index);
let _ = weak.upgrade_in_event_loop(move |w| {
append_playlists(&w, cards, has_more);
});
artwork::spawn_loads(jobs, weak.clone(), image_cache.clone());
}
Err(e) => {
log::error!("[qbz-slint] playlist-browse load-more failed: {e}");
let _ = weak.upgrade_in_event_loop(|w| {
w.global::<PlaylistBrowseState>().set_loading_more(false);
});
}
}
});
}
/// Append a freshly-fetched page onto the loaded set, advancing the
/// offset and updating has-more. UI thread only.
fn append_playlists(window: &AppWindow, cards: Vec<BrowseCard>, has_more: bool) {
let state = window.global::<PlaylistBrowseState>();
let model = state.get_playlists();
let mut combined: Vec<SearchPlaylistItem> = (0..model.row_count())
.filter_map(|i| model.row_data(i))
.collect();
combined.extend(cards.iter().map(to_item));
state.set_playlists(ModelRc::new(VecModel::from(combined)));
state.set_next_offset(state.get_next_offset() + cards.len() as i32);
state.set_has_more(has_more);
state.set_loading_more(false);
apply_filter(window);
}
/// Rebuild `visible` from `playlists` honoring the current search query
/// (case-insensitive substring over title + subtitle). UI thread only.
/// Wired to PlaylistBrowseActions::search-changed and called after every
/// model mutation so the rendered list stays consistent.
pub fn apply_filter(window: &AppWindow) {
let state = window.global::<PlaylistBrowseState>();
let query = state.get_search_query().trim().to_lowercase();
let playlists = state.get_playlists();
if query.is_empty() {
// No filter — share the SAME model so artwork-pipeline updates
// (which mutate `playlists[idx]`) propagate to the rendered list
// without rebuilding it. This is the common case.
state.set_visible(playlists);
return;
}
let visible: Vec<SearchPlaylistItem> = (0..playlists.row_count())
.filter_map(|i| playlists.row_data(i))
.filter(|p| {
p.title.to_lowercase().contains(&query)
|| p.subtitle.to_lowercase().contains(&query)
})
.collect();
state.set_visible(ModelRc::new(VecModel::from(visible)));
}
/// Select a category tag (slug; "" = All): update the radio flags, then
/// re-fetch page 0 server-side with the tag + the shared genre selection
/// (same as `navigate` minus the view switch and the tag re-fetch). The
/// search query is kept — it is a client-side filter over whatever set is
/// loaded (Tauri keeps it across tag switches too). UI thread entry.
pub fn select_tag(
runtime: Arc<AppRuntime<SlintAdapter>>,
weak: slint::Weak<AppWindow>,
handle: &tokio::runtime::Handle,
image_cache: ImageCache,
slug: String,
genre_ids: Option<Vec<u64>>,
) {
{
let Ok(mut s) = SELECTED_TAG.lock() else {
return;
};
if *s == slug {
// Re-clicking the active tag (or All) is a no-op.
return;
}
*s = slug.clone();
}
let Some(w) = weak.upgrade() else {
return;
};
let state = w.global::<PlaylistBrowseState>();
state.set_selected_tag(slug.clone().into());
let tags = state.get_tags();
for i in 0..tags.row_count() {
if let Some(mut t) = tags.row_data(i) {
let sel = t.slug.as_str() == slug;
if t.selected != sel {
t.selected = sel;
tags.set_row_data(i, t);
}
}
}
// Reset the pagination and reload page 0.
state.set_next_offset(0);
state.set_playlists(ModelRc::new(VecModel::from(
Vec::<SearchPlaylistItem>::new(),
)));
state.set_visible(ModelRc::new(VecModel::from(
Vec::<SearchPlaylistItem>::new(),
)));
state.set_loading(true);
state.set_loading_more(false);
state.set_has_more(true);
handle.spawn(async move {
match fetch_page(&runtime, &slug, genre_ids, 0).await {
Ok((cards, has_more)) => {
let jobs = artwork_jobs(&cards, 0);
let _ = weak.upgrade_in_event_loop(move |w| {
let items: Vec<SearchPlaylistItem> = cards.iter().map(to_item).collect();
let fetched = items.len() as i32;
let state = w.global::<PlaylistBrowseState>();
state.set_playlists(ModelRc::new(VecModel::from(items)));
state.set_next_offset(fetched);
state.set_has_more(has_more);
state.set_loading(false);
apply_filter(&w);
});
artwork::spawn_loads(jobs, weak.clone(), image_cache.clone());
}
Err(e) => {
log::error!("[qbz-slint] playlist-browse tag load failed: {e}");
let _ = weak.upgrade_in_event_loop(|w| {
let state = w.global::<PlaylistBrowseState>();
state.set_loading(false);
state.set_has_more(false);
});
}
}
});
}
/// Artwork jobs for a page of cards, targeting their absolute indices in
/// `PlaylistBrowseState.playlists` (`base_index` is the model length
/// before the page was appended).
fn artwork_jobs(cards: &[BrowseCard], base_index: usize) -> Vec<ArtworkJob> {
cards
.iter()
.enumerate()
.filter(|(_, bc)| !bc.card.artwork_url.is_empty())
.map(|(i, bc)| ArtworkJob {
url: bc.card.artwork_url.clone(),
target: ArtworkTarget::PlaylistBrowseCover {
idx: base_index + i,
},
})
.collect()
}