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:
6 files changed
+97
-17
No files matched your search
@@ -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 ============
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 |
@@ -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 => {
|
||||
|
||||
Reference in new issue
Block a user