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:
9 files changed
+1128
-322
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,31 +100,36 @@ export component PlaylistCarousel inherits VerticalLayout {
|
||||
vertical-alignment: center;
|
||||
horizontal-stretch: 1;
|
||||
}
|
||||
VerticalLayout {
|
||||
alignment: center;
|
||||
NavButton {
|
||||
icon: @image-url("../assets/icons/chevron-left.svg");
|
||||
enabled: root.current-page > 0;
|
||||
clicked => {
|
||||
root.page = root.current-page - 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 {
|
||||
icon: @image-url("../assets/icons/chevron-right.svg");
|
||||
enabled: root.current-page < root.page-count - 1;
|
||||
clicked => {
|
||||
root.page = root.current-page + 1;
|
||||
VerticalLayout {
|
||||
alignment: center;
|
||||
NavButton {
|
||||
icon: @image-url("../assets/icons/chevron-left.svg");
|
||||
enabled: root.current-page > 0;
|
||||
clicked => {
|
||||
root.page = root.current-page - 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if root.show-view-all: VerticalLayout {
|
||||
alignment: center;
|
||||
ViewAllLink {
|
||||
clicked => {
|
||||
root.view-all-clicked();
|
||||
VerticalLayout {
|
||||
alignment: center;
|
||||
NavButton {
|
||||
icon: @image-url("../assets/icons/chevron-right.svg");
|
||||
enabled: root.current-page < root.page-count - 1;
|
||||
clicked => {
|
||||
root.page = root.current-page + 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
Reference in new issue
Block a user