feat(slint): virtualize the favorites tracks list
Drop the redundant "N tracks" / "N albums" toolbar count (the tab-menu badge is now the permanent counter) and split the content area: the Tracks tab renders in a virtualized, self-scrolling ListView + ListScrollbar (the PlaylistView pattern, now possible thanks to the fixed-chrome restructure), while Albums/Artists/Playlists/Labels keep a Flickable. Loading/error states center in the content area. Group headers + all per-row behavior are preserved inside the ListView items. cc
This commit is contained in:
1 file changed
+217
-221
@@ -32,6 +32,7 @@ import { QbzIcon } from "../primitives/QbzIcon.slint";
|
||||
import { LoadingSpinner } from "../primitives/LoadingSpinner.slint";
|
||||
import { ListScrollbar } from "../primitives/ListScrollbar.slint";
|
||||
import { NavButtons } from "../shell/NavButtons.slint";
|
||||
import { ListView } from "std-widgets.slint";
|
||||
|
||||
// One tab in the favorites tab menu.
|
||||
component FavTab inherits Rectangle {
|
||||
@@ -468,18 +469,6 @@ export component FavoritesView inherits Rectangle {
|
||||
if FavoritesState.active-tab == "tracks"
|
||||
&& FavoritesState.tracks.length > 0: HorizontalLayout {
|
||||
spacing: 8px;
|
||||
VerticalLayout {
|
||||
alignment: center;
|
||||
Text {
|
||||
text: FavoritesState.tracks-visible.length
|
||||
+ (FavoritesState.tracks-search != ""
|
||||
? " / " + FavoritesState.tracks.length
|
||||
: "")
|
||||
+ " " + @tr("tracks");
|
||||
color: Theme.text-muted;
|
||||
font-size: Typography.legal;
|
||||
}
|
||||
}
|
||||
VerticalLayout {
|
||||
alignment: center;
|
||||
ExpandableSearch {
|
||||
@@ -521,18 +510,6 @@ export component FavoritesView inherits Rectangle {
|
||||
if FavoritesState.active-tab == "albums"
|
||||
&& FavoritesState.albums.length > 0: HorizontalLayout {
|
||||
spacing: 8px;
|
||||
VerticalLayout {
|
||||
alignment: center;
|
||||
Text {
|
||||
text: FavoritesState.albums-shown
|
||||
+ (FavoritesState.albums-search != ""
|
||||
? " / " + FavoritesState.albums.length
|
||||
: "")
|
||||
+ " " + @tr("albums");
|
||||
color: Theme.text-muted;
|
||||
font-size: Typography.legal;
|
||||
}
|
||||
}
|
||||
VerticalLayout {
|
||||
alignment: center;
|
||||
ExpandableSearch {
|
||||
@@ -606,107 +583,104 @@ export component FavoritesView inherits Rectangle {
|
||||
vertical-stretch: 1;
|
||||
clip: true;
|
||||
|
||||
flick := Flickable {
|
||||
interactive: !DragState.active;
|
||||
viewport-width: self.width;
|
||||
viewport-height: page.preferred-height;
|
||||
// Loading (any tab).
|
||||
if FavoritesState.loading: VerticalLayout {
|
||||
alignment: center;
|
||||
HorizontalLayout {
|
||||
alignment: center;
|
||||
LoadingSpinner { size: 36px; }
|
||||
}
|
||||
}
|
||||
|
||||
page := VerticalLayout {
|
||||
width: parent.width;
|
||||
padding-left: 32px;
|
||||
padding-right: 32px;
|
||||
padding-top: 16px;
|
||||
padding-bottom: 100px;
|
||||
spacing: 0px;
|
||||
alignment: start;
|
||||
|
||||
if FavoritesState.loading: HorizontalLayout {
|
||||
alignment: center;
|
||||
LoadingSpinner { size: 36px; }
|
||||
}
|
||||
|
||||
// Load error + retry.
|
||||
if !FavoritesState.loading && FavoritesState.load-error != "": VerticalLayout {
|
||||
alignment: center;
|
||||
spacing: 10px;
|
||||
Rectangle { height: 24px; }
|
||||
Text {
|
||||
text: @tr("Couldn't load favorites.");
|
||||
// Load error + retry (any tab).
|
||||
if !FavoritesState.loading && FavoritesState.load-error != "": VerticalLayout {
|
||||
alignment: center;
|
||||
spacing: 10px;
|
||||
Text {
|
||||
text: @tr("Couldn't load favorites.");
|
||||
color: Theme.text-primary;
|
||||
font-size: Typography.body;
|
||||
font-weight: Typography.semibold;
|
||||
horizontal-alignment: center;
|
||||
}
|
||||
Text {
|
||||
text: FavoritesState.load-error;
|
||||
color: Theme.text-muted;
|
||||
font-size: Typography.legal;
|
||||
horizontal-alignment: center;
|
||||
}
|
||||
HorizontalLayout {
|
||||
alignment: center;
|
||||
Rectangle {
|
||||
width: retry-label.preferred-width + 28px;
|
||||
height: 32px;
|
||||
border-radius: 6px;
|
||||
background: retry-ta.has-hover ? Theme.surface-hover : Theme.surface-elevated;
|
||||
border-width: 1px;
|
||||
border-color: Theme.border-subtle;
|
||||
retry-label := Text {
|
||||
text: @tr("Retry");
|
||||
color: Theme.text-primary;
|
||||
font-size: Typography.body;
|
||||
font-weight: Typography.semibold;
|
||||
horizontal-alignment: center;
|
||||
}
|
||||
Text {
|
||||
text: FavoritesState.load-error;
|
||||
color: Theme.text-muted;
|
||||
font-size: Typography.legal;
|
||||
horizontal-alignment: center;
|
||||
x: (parent.width - self.width) / 2;
|
||||
y: (parent.height - self.height) / 2;
|
||||
}
|
||||
HorizontalLayout {
|
||||
alignment: center;
|
||||
Rectangle {
|
||||
width: retry-label.preferred-width + 28px;
|
||||
height: 32px;
|
||||
border-radius: 6px;
|
||||
background: retry-ta.has-hover ? Theme.surface-hover : Theme.surface-elevated;
|
||||
border-width: 1px;
|
||||
border-color: Theme.border-subtle;
|
||||
retry-label := Text {
|
||||
text: @tr("Retry");
|
||||
color: Theme.text-primary;
|
||||
font-size: Typography.legal;
|
||||
x: (parent.width - self.width) / 2;
|
||||
y: (parent.height - self.height) / 2;
|
||||
}
|
||||
retry-ta := TouchArea {
|
||||
mouse-cursor: pointer;
|
||||
clicked => {
|
||||
FavoritesActions.retry-load();
|
||||
}
|
||||
}
|
||||
retry-ta := TouchArea {
|
||||
mouse-cursor: pointer;
|
||||
clicked => {
|
||||
FavoritesActions.retry-load();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Tracks tab ---------------------------------------
|
||||
if !FavoritesState.loading && FavoritesState.load-error == ""
|
||||
&& FavoritesState.active-tab == "tracks": VerticalLayout {
|
||||
spacing: 0px;
|
||||
// --- Tracks tab — virtualized self-scrolling list --------------
|
||||
if !FavoritesState.loading && FavoritesState.load-error == ""
|
||||
&& FavoritesState.active-tab == "tracks": VerticalLayout {
|
||||
padding-left: 32px;
|
||||
padding-right: 32px;
|
||||
padding-top: 16px;
|
||||
spacing: 0px;
|
||||
|
||||
// Bulk action bar — visible in multi-select edit mode.
|
||||
if FavoritesState.tracks-multi-select: MultiSelectBar {
|
||||
selected-count: FavoritesState.tracks-selected-count;
|
||||
actions: [
|
||||
{ id: "select-all", label: @tr("Select all"), danger: false, needs-selection: false },
|
||||
{ id: "play-next", label: @tr("Play next"), danger: false, needs-selection: true },
|
||||
{ id: "queue", label: @tr("Add to queue"), danger: false, needs-selection: true },
|
||||
{ id: "add-to-playlist", label: @tr("Add to playlist"), danger: false, needs-selection: true },
|
||||
{ id: "remove-selected", label: @tr("Remove from favorites"), danger: true, needs-selection: true },
|
||||
{ id: "clear", label: @tr("Clear"), danger: false, needs-selection: true },
|
||||
];
|
||||
action(id) => {
|
||||
FavoritesActions.bulk-action(id);
|
||||
}
|
||||
}
|
||||
if FavoritesState.tracks-multi-select: Rectangle { height: 8px; }
|
||||
// Bulk action bar — visible in multi-select edit mode.
|
||||
if FavoritesState.tracks-multi-select: MultiSelectBar {
|
||||
selected-count: FavoritesState.tracks-selected-count;
|
||||
actions: [
|
||||
{ id: "select-all", label: @tr("Select all"), danger: false, needs-selection: false },
|
||||
{ id: "play-next", label: @tr("Play next"), danger: false, needs-selection: true },
|
||||
{ id: "queue", label: @tr("Add to queue"), danger: false, needs-selection: true },
|
||||
{ id: "add-to-playlist", label: @tr("Add to playlist"), danger: false, needs-selection: true },
|
||||
{ id: "remove-selected", label: @tr("Remove from favorites"), danger: true, needs-selection: true },
|
||||
{ id: "clear", label: @tr("Clear"), danger: false, needs-selection: true },
|
||||
];
|
||||
action(id) => {
|
||||
FavoritesActions.bulk-action(id);
|
||||
}
|
||||
}
|
||||
if FavoritesState.tracks-multi-select: Rectangle { height: 8px; }
|
||||
|
||||
if FavoritesState.tracks.length == 0: Text {
|
||||
text: @tr("No favorite tracks yet.");
|
||||
color: Theme.text-muted;
|
||||
font-size: Typography.body;
|
||||
}
|
||||
// Search filtered everything out.
|
||||
if FavoritesState.tracks.length > 0
|
||||
&& FavoritesState.tracks-visible.length == 0: Text {
|
||||
text: @tr("No tracks match your search.");
|
||||
color: Theme.text-muted;
|
||||
font-size: Typography.body;
|
||||
}
|
||||
if FavoritesState.tracks.length == 0: Text {
|
||||
text: @tr("No favorite tracks yet.");
|
||||
color: Theme.text-muted;
|
||||
font-size: Typography.body;
|
||||
}
|
||||
if FavoritesState.tracks.length > 0
|
||||
&& FavoritesState.tracks-visible.length == 0: Text {
|
||||
text: @tr("No tracks match your search.");
|
||||
color: Theme.text-muted;
|
||||
font-size: Typography.body;
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
vertical-stretch: 1;
|
||||
track-list := ListView {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
vertical-scrollbar-policy: ScrollBarPolicy.always-off;
|
||||
for track[i] in FavoritesState.tracks-visible: VerticalLayout {
|
||||
spacing: 0px;
|
||||
// Group header rows when grouping by album / artist
|
||||
// (a new header each time the group key changes).
|
||||
// Group header rows when grouping by album / artist.
|
||||
if FavoritesState.tracks-group-mode == "album" && track.album != ""
|
||||
&& (i == 0 || FavoritesState.tracks-visible[i - 1].album != track.album): Rectangle {
|
||||
height: 32px;
|
||||
@@ -758,118 +732,140 @@ export component FavoritesView inherits Rectangle {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Albums tab ---------------------------------------
|
||||
if !FavoritesState.loading && FavoritesState.load-error == ""
|
||||
&& FavoritesState.active-tab == "albums": VerticalLayout {
|
||||
spacing: 0px;
|
||||
if FavoritesState.albums.length == 0: Text {
|
||||
text: @tr("No favorite albums yet.");
|
||||
color: Theme.text-muted;
|
||||
font-size: Typography.body;
|
||||
}
|
||||
if FavoritesState.albums.length > 0
|
||||
&& FavoritesState.albums-shown == 0: Text {
|
||||
text: @tr("No albums match your search.");
|
||||
color: Theme.text-muted;
|
||||
font-size: Typography.body;
|
||||
}
|
||||
AlbumCollectionView {
|
||||
albums: FavoritesState.albums-visible;
|
||||
grouped: FavoritesState.albums-grouped;
|
||||
view-mode: FavoritesState.albums-view-mode;
|
||||
is-grouped: FavoritesState.albums-group-mode != "off";
|
||||
show-source: false;
|
||||
open-album(id) => {
|
||||
root.open-album(id);
|
||||
}
|
||||
open-artist(id) => {
|
||||
root.open-artist(id);
|
||||
}
|
||||
media-action(kind, id, action) => {
|
||||
if (action == "favorite") {
|
||||
FavoritesActions.unfavorite-album(id);
|
||||
} else {
|
||||
FavoritesActions.album-action(id, action);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Artists tab --------------------------------------
|
||||
if !FavoritesState.loading && FavoritesState.load-error == ""
|
||||
&& FavoritesState.active-tab == "artists": artists-grid := Rectangle {
|
||||
property <int> columns: Math.max(
|
||||
1,
|
||||
Math.floor((self.width + root.gap) / (root.artist-w + root.gap)));
|
||||
property <int> rows: Math.ceil(FavoritesState.artists.length / self.columns);
|
||||
height: self.rows > 0
|
||||
? self.rows * root.artist-h + Math.max(0, self.rows - 1) * root.gap
|
||||
: 0px;
|
||||
if FavoritesState.artists.length == 0: Text {
|
||||
text: @tr("No favorite artists yet.");
|
||||
color: Theme.text-muted;
|
||||
font-size: Typography.body;
|
||||
}
|
||||
for artist[i] in FavoritesState.artists: FavArtistCard {
|
||||
x: Math.mod(i, artists-grid.columns) * (root.artist-w + root.gap);
|
||||
y: Math.floor(i / artists-grid.columns) * (root.artist-h + root.gap);
|
||||
item: artist;
|
||||
clicked(id) => { root.open-artist(id); }
|
||||
}
|
||||
}
|
||||
|
||||
// --- Playlists tab ------------------------------------
|
||||
if !FavoritesState.loading && FavoritesState.load-error == ""
|
||||
&& FavoritesState.active-tab == "playlists": playlists-grid := Rectangle {
|
||||
property <length> pl-w: 180px;
|
||||
property <length> pl-h: 234px;
|
||||
property <int> columns: Math.max(
|
||||
1,
|
||||
Math.floor((self.width + root.gap) / (self.pl-w + root.gap)));
|
||||
property <int> rows: Math.ceil(FavoritesState.playlists.length / self.columns);
|
||||
height: self.rows > 0
|
||||
? self.rows * self.pl-h + Math.max(0, self.rows - 1) * root.gap
|
||||
: 0px;
|
||||
if FavoritesState.playlists.length == 0: Text {
|
||||
text: @tr("No favorite playlists yet.");
|
||||
color: Theme.text-muted;
|
||||
font-size: Typography.body;
|
||||
}
|
||||
for pl[i] in FavoritesState.playlists: FavPlaylistCard {
|
||||
x: Math.mod(i, playlists-grid.columns) * (playlists-grid.pl-w + root.gap);
|
||||
y: Math.floor(i / playlists-grid.columns) * (playlists-grid.pl-h + root.gap);
|
||||
item: pl;
|
||||
clicked(id) => { FavoritesActions.open-playlist(id); }
|
||||
}
|
||||
}
|
||||
|
||||
// --- Labels tab ---------------------------------------
|
||||
if !FavoritesState.loading && FavoritesState.load-error == ""
|
||||
&& FavoritesState.active-tab == "labels": VerticalLayout {
|
||||
spacing: 0px;
|
||||
if FavoritesState.labels.length == 0: Text {
|
||||
text: @tr("No favorite labels yet.");
|
||||
color: Theme.text-muted;
|
||||
font-size: Typography.body;
|
||||
}
|
||||
for label[i] in FavoritesState.labels: FavLabelRow {
|
||||
item: label;
|
||||
index: i;
|
||||
clicked(id, name) => { FavoritesActions.open-label(id, name); }
|
||||
}
|
||||
ListScrollbar {
|
||||
x: parent.width - self.width - 4px;
|
||||
width: 14px;
|
||||
height: 100%;
|
||||
viewport-height: track-list.viewport-height;
|
||||
visible-height: track-list.visible-height;
|
||||
viewport-y <=> track-list.viewport-y;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Scrollbar in the right gutter of the content area.
|
||||
ListScrollbar {
|
||||
x: parent.width - self.width - 4px;
|
||||
width: 14px;
|
||||
height: 100%;
|
||||
viewport-height: flick.viewport-height;
|
||||
visible-height: flick.height;
|
||||
viewport-y <=> flick.viewport-y;
|
||||
// --- Albums / Artists / Playlists / Labels — scrolling ---------
|
||||
if !FavoritesState.loading && FavoritesState.load-error == ""
|
||||
&& FavoritesState.active-tab != "tracks": Rectangle {
|
||||
other-flick := Flickable {
|
||||
interactive: !DragState.active;
|
||||
viewport-width: self.width;
|
||||
viewport-height: other-page.preferred-height;
|
||||
|
||||
other-page := VerticalLayout {
|
||||
width: parent.width;
|
||||
padding-left: 32px;
|
||||
padding-right: 32px;
|
||||
padding-top: 16px;
|
||||
padding-bottom: 100px;
|
||||
spacing: 0px;
|
||||
alignment: start;
|
||||
|
||||
// --- Albums tab ---
|
||||
if FavoritesState.active-tab == "albums": VerticalLayout {
|
||||
spacing: 0px;
|
||||
if FavoritesState.albums.length == 0: Text {
|
||||
text: @tr("No favorite albums yet.");
|
||||
color: Theme.text-muted;
|
||||
font-size: Typography.body;
|
||||
}
|
||||
if FavoritesState.albums.length > 0
|
||||
&& FavoritesState.albums-shown == 0: Text {
|
||||
text: @tr("No albums match your search.");
|
||||
color: Theme.text-muted;
|
||||
font-size: Typography.body;
|
||||
}
|
||||
AlbumCollectionView {
|
||||
albums: FavoritesState.albums-visible;
|
||||
grouped: FavoritesState.albums-grouped;
|
||||
view-mode: FavoritesState.albums-view-mode;
|
||||
is-grouped: FavoritesState.albums-group-mode != "off";
|
||||
show-source: false;
|
||||
open-album(id) => {
|
||||
root.open-album(id);
|
||||
}
|
||||
open-artist(id) => {
|
||||
root.open-artist(id);
|
||||
}
|
||||
media-action(kind, id, action) => {
|
||||
if (action == "favorite") {
|
||||
FavoritesActions.unfavorite-album(id);
|
||||
} else {
|
||||
FavoritesActions.album-action(id, action);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Artists tab ---
|
||||
if FavoritesState.active-tab == "artists": artists-grid := Rectangle {
|
||||
property <int> columns: Math.max(
|
||||
1,
|
||||
Math.floor((self.width + root.gap) / (root.artist-w + root.gap)));
|
||||
property <int> rows: Math.ceil(FavoritesState.artists.length / self.columns);
|
||||
height: self.rows > 0
|
||||
? self.rows * root.artist-h + Math.max(0, self.rows - 1) * root.gap
|
||||
: 0px;
|
||||
if FavoritesState.artists.length == 0: Text {
|
||||
text: @tr("No favorite artists yet.");
|
||||
color: Theme.text-muted;
|
||||
font-size: Typography.body;
|
||||
}
|
||||
for artist[i] in FavoritesState.artists: FavArtistCard {
|
||||
x: Math.mod(i, artists-grid.columns) * (root.artist-w + root.gap);
|
||||
y: Math.floor(i / artists-grid.columns) * (root.artist-h + root.gap);
|
||||
item: artist;
|
||||
clicked(id) => { root.open-artist(id); }
|
||||
}
|
||||
}
|
||||
|
||||
// --- Playlists tab ---
|
||||
if FavoritesState.active-tab == "playlists": playlists-grid := Rectangle {
|
||||
property <length> pl-w: 180px;
|
||||
property <length> pl-h: 234px;
|
||||
property <int> columns: Math.max(
|
||||
1,
|
||||
Math.floor((self.width + root.gap) / (self.pl-w + root.gap)));
|
||||
property <int> rows: Math.ceil(FavoritesState.playlists.length / self.columns);
|
||||
height: self.rows > 0
|
||||
? self.rows * self.pl-h + Math.max(0, self.rows - 1) * root.gap
|
||||
: 0px;
|
||||
if FavoritesState.playlists.length == 0: Text {
|
||||
text: @tr("No favorite playlists yet.");
|
||||
color: Theme.text-muted;
|
||||
font-size: Typography.body;
|
||||
}
|
||||
for pl[i] in FavoritesState.playlists: FavPlaylistCard {
|
||||
x: Math.mod(i, playlists-grid.columns) * (playlists-grid.pl-w + root.gap);
|
||||
y: Math.floor(i / playlists-grid.columns) * (playlists-grid.pl-h + root.gap);
|
||||
item: pl;
|
||||
clicked(id) => { FavoritesActions.open-playlist(id); }
|
||||
}
|
||||
}
|
||||
|
||||
// --- Labels tab ---
|
||||
if FavoritesState.active-tab == "labels": VerticalLayout {
|
||||
spacing: 0px;
|
||||
if FavoritesState.labels.length == 0: Text {
|
||||
text: @tr("No favorite labels yet.");
|
||||
color: Theme.text-muted;
|
||||
font-size: Typography.body;
|
||||
}
|
||||
for label[i] in FavoritesState.labels: FavLabelRow {
|
||||
item: label;
|
||||
index: i;
|
||||
clicked(id, name) => { FavoritesActions.open-label(id, name); }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
ListScrollbar {
|
||||
x: parent.width - self.width - 4px;
|
||||
width: 14px;
|
||||
height: 100%;
|
||||
viewport-height: other-flick.viewport-height;
|
||||
visible-height: other-flick.height;
|
||||
viewport-y <=> other-flick.viewport-y;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user