perf(slint): virtualize playlist track list + lighter artwork

Large playlists (2000+ tracks) were sluggish. Addressed the root
causes; this strategy is reusable for the library / mixtapes / tracks
views.

  - Virtualization: the track list is now a ListView (only visible
    rows are instantiated) instead of a Flickable with a for over all
    rows. The header + column header are pinned above it.
  - Smaller images: track rows use ImageSet::smallest() (new) instead
    of best() — they are 40px thumbnails, so best() was downloading
    mega/large covers.
  - Per-target decode size: row thumbnails decode to 96px instead of
    the 264px card size, so a 2000-row model holds ~75MB of pixels
    rather than ~550MB.
  - Track-number column widened to fit 4-digit positions.
  - Back-to-top button: appears once scrolled past one viewport
    (bottom-right), scrolls the list home. A shared rule for big lists.
  - Custom-artwork buttons only show on cover hover.

cc
This commit is contained in:
vicrodh committed 2026-05-22 09:27:45 -06:00
1 parent 4fe034d86b
commit 85223b03ff
6 files changed
+97 -17

No files matched your search

+11
View File
@@ -182,6 +182,17 @@ impl ImageSet {
.or(self.thumbnail.as_ref())
.or(self.small.as_ref())
}
/// The smallest available variant — for list-row thumbnails, where
/// `best()` (mega/large) would needlessly download huge images.
pub fn smallest(&self) -> Option<&String> {
self.small
.as_ref()
.or(self.thumbnail.as_ref())
.or(self.large.as_ref())
.or(self.extralarge.as_ref())
.or(self.mega.as_ref())
}
}
// ============ Core Media Types ============
+18 -1
View File
@@ -94,6 +94,22 @@ pub enum ArtworkTarget {
PlaylistCover,
}
impl ArtworkTarget {
/// Pixel size to decode the cover to. List-row thumbnails are tiny
/// (~40px), so decoding them to the card size (264) would retain
/// huge buffers in the model — a 2000-row playlist would hold
/// hundreds of MB. Decode row thumbnails small.
fn decode_size(&self) -> u32 {
match self {
ArtworkTarget::SearchTrack { .. }
| ArtworkTarget::FavoriteTrack { .. }
| ArtworkTarget::MixTrack { .. }
| ArtworkTarget::PlaylistTrack { .. } => 96,
_ => DECODE_SIZE,
}
}
}
/// An artwork download job: which card, and the image URL.
pub struct ArtworkJob {
pub target: ArtworkTarget,
@@ -165,8 +181,9 @@ pub fn spawn_loads(jobs: Vec<ArtworkJob>, window: slint::Weak<AppWindow>, cache:
let cache = cache.clone();
tokio::spawn(async move {
let _permit = semaphore.acquire().await.ok()?;
let decode_size = job.target.decode_size();
let (pixels, width, height) =
fetch_and_decode(&job.url, &cache, DECODE_SIZE).await?;
fetch_and_decode(&job.url, &cache, decode_size).await?;
let target = job.target;
let _ = window.upgrade_in_event_loop(move |w| {
apply_artwork(&w, target, &pixels, width, height);
+4 -2
View File
@@ -125,10 +125,12 @@ fn to_item(track: &Track) -> TrackItem {
.into(),
explicit: track.parental_warning,
selected: false,
// Smallest variant — these are 40px row thumbnails; best()
// would download mega/large covers (2000-row perf killer).
artwork_url: track
.album
.as_ref()
.and_then(|a| a.image.best().cloned())
.and_then(|a| a.image.smallest().cloned())
.unwrap_or_default()
.into(),
artwork: slint::Image::default(),
@@ -187,7 +189,7 @@ pub fn artwork_jobs(data: &PlaylistData) -> Vec<ArtworkJob> {
.filter_map(|(i, t)| {
t.album
.as_ref()
.and_then(|a| a.image.best().cloned())
.and_then(|a| a.image.smallest().cloned())
.map(|url| ArtworkJob {
url,
target: ArtworkTarget::PlaylistTrack { index: i },
@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#ffffff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="m18 15-6-6-6 6" />
</svg>

After

Width:  |  Height:  |  Size: 214 B

+56 -11
View File
@@ -11,6 +11,7 @@ import { QbzIcon } from "../primitives/QbzIcon.slint";
import { TrackRow } from "../primitives/TrackRow.slint";
import { LoadingSpinner } from "../primitives/LoadingSpinner.slint";
import { NavButtons } from "../shell/NavButtons.slint";
import { ListView } from "std-widgets.slint";
component CircleAction inherits Rectangle {
in property <image> icon;
@@ -57,16 +58,10 @@ export component PlaylistView inherits Rectangle {
background: Theme.surface-main;
Flickable {
viewport-width: self.width;
viewport-height: page.preferred-height;
page := VerticalLayout {
width: parent.width;
padding-left: 32px;
padding-right: 32px;
padding-top: 11px;
padding-bottom: 100px;
spacing: 0px;
alignment: start;
@@ -92,6 +87,12 @@ export component PlaylistView inherits Rectangle {
background: Theme.surface-elevated;
clip: true;
// Hover state — drives the artwork buttons (only
// shown on mouse-over). OR'd with the buttons so
// moving onto a button keeps them visible.
property <bool> hovered:
cover-hover.has-hover || set-ta.has-hover || clr-ta.has-hover;
// Custom artwork / single server image.
property <bool> show-single:
PlaylistState.has-custom
@@ -155,8 +156,17 @@ export component PlaylistView inherits Rectangle {
tint: Theme.text-muted;
}
// Set custom artwork (owner only) — bottom-right.
if PlaylistState.is-owner: Rectangle {
// Hover detector (below the buttons so they stay
// clickable). Owner only.
cover-hover := TouchArea {
mouse-cursor: PlaylistState.is-owner ? pointer : default;
enabled: PlaylistState.is-owner;
}
// Set custom artwork (owner only) — bottom-right,
// shown on hover.
Rectangle {
visible: PlaylistState.is-owner && cover-box.hovered;
width: 32px;
height: 32px;
border-radius: 16px;
@@ -178,8 +188,10 @@ export component PlaylistView inherits Rectangle {
}
}
}
// Clear custom artwork — top-right, only when set.
if PlaylistState.is-owner && PlaylistState.has-custom: Rectangle {
// Clear custom artwork — top-right, shown on hover
// when a custom image is set.
Rectangle {
visible: PlaylistState.is-owner && PlaylistState.has-custom && cover-box.hovered;
width: 28px;
height: 28px;
border-radius: 14px;
@@ -317,7 +329,7 @@ export component PlaylistView inherits Rectangle {
spacing: 14px;
Text {
text: "#";
width: 20px;
width: 32px;
color: Theme.text-muted;
font-size: Typography.legal;
horizontal-alignment: center;
@@ -422,6 +434,10 @@ export component PlaylistView inherits Rectangle {
}
if PlaylistState.multi-select-mode: Rectangle { height: 8px; }
// Virtualized track list — only the visible rows are
// instantiated, so a 2000-track playlist stays smooth.
track-list := ListView {
vertical-stretch: 1;
for track[i] in PlaylistState.tracks: TrackRow {
track: track;
index: i;
@@ -439,4 +455,33 @@ export component PlaylistView inherits Rectangle {
}
}
}
// Back to top — appears once the list is scrolled past one
// viewport. A shared rule for big lists (playlists, library, ...).
if track-list.viewport-y < -track-list.visible-height: Rectangle {
width: 44px;
height: 44px;
border-radius: 22px;
x: parent.width - self.width - 24px;
y: parent.height - self.height - 24px;
background: btt-ta.has-hover ? Theme.accent : Theme.surface-elevated;
border-width: 1px;
border-color: Theme.border-subtle;
drop-shadow-blur: 16px;
drop-shadow-color: #00000066;
QbzIcon {
source: @image-url("../assets/icons/chevron-up.svg");
width: 20px;
height: 20px;
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
tint: btt-ta.has-hover ? #ffffff : Theme.text-secondary;
}
btt-ta := TouchArea {
mouse-cursor: pointer;
clicked => {
track-list.viewport-y = 0;
}
}
}
}
@@ -60,10 +60,12 @@ export component TrackRow inherits Rectangle {
alignment: center;
Text {
text: root.number-text;
width: 20px;
// Wide enough for 4-digit positions (big playlists).
width: 32px;
color: Theme.text-muted;
font-size: Typography.body;
horizontal-alignment: center;
overflow: elide;
}
}
@@ -73,14 +75,14 @@ export component TrackRow inherits Rectangle {
VerticalLayout {
alignment: center;
Rectangle {
width: root.show-artwork ? 40px : 28px;
width: root.show-artwork ? 40px : 32px;
height: root.show-artwork ? 40px : 28px;
play-cell := TrackPlayCell {
track-id: root.track.id;
number: root.number-text;
artwork: root.track.artwork;
show-artwork: root.show-artwork;
number-width: 28px;
number-width: 32px;
row-hovered: root.row-hovered && !root.multi-select-mode;
visible: !root.multi-select-mode;
play => {