perf: inline SVGs, memoize grid slices, batch store notifications

- Replace wa-icon shadow DOM with inline SVG in queue-panel (xmark)
  and album-dropdown (fav icons) to eliminate per-item shadow roots
- Memoize getBeforeEntries/getAfterEntries in cover-grid to prevent
  .slice() creating new array refs that trigger virtualizer relayout
- Remove transition: scale and border-radius from album cards to
  avoid per-frame repaints and anti-aliased path clipping
- Add queueMicrotask batching to player-store and favorites-store
  notify() to coalesce rapid-fire updates into single renders
This commit is contained in:
2026-03-15 09:29:40 -04:00
parent 54df917ffd
commit a4eac394ce
6 changed files with 75 additions and 25 deletions
@@ -1,4 +1,4 @@
import { LitElement, html, css } from 'lit'; import { LitElement, html, svg, css } from 'lit';
import { customElement, property } from 'lit/decorators.js'; import { customElement, property } from 'lit/decorators.js';
import { classMap } from 'lit/directives/class-map.js'; import { classMap } from 'lit/directives/class-map.js';
import type { library } from '@go/models'; import type { library } from '@go/models';
@@ -35,6 +35,21 @@ export interface TrackDragStartDetail {
dataTransfer: DataTransfer | null; dataTransfer: DataTransfer | null;
} }
// Inline SVG paths for favorite icons — eliminates wa-icon shadow DOM
// overhead per visible track row. Font Awesome 6 paths.
const FAV_ICONS = {
heart: {
viewBox: '0 0 512 512',
regular: 'M225.8 468.2l-2.5-2.3L48.1 303.2C17.4 274.7 0 234.7 0 192.8v-3.3c0-70.4 50-130.8 119.2-144C158.6 37.9 198.9 47 231 69.6c9 6.3 17.3 13.5 25 21.5c7.7-8 16-15.2 25-21.5c32.1-22.6 72.4-31.7 111.8-24.2C461.5 59.6 512 124.2 512 192.8v3.3c0 41.9-17.4 81.9-48.1 110.4L288.7 465.9l-2.5 2.3c-8.2 7.6-19 11.9-30.2 11.9s-22-4.2-30.2-11.9z',
solid: 'M47.6 300.4L228.3 469.1c7.5 7 17.4 10.9 27.7 10.9s20.2-3.9 27.7-10.9L464.4 300.4c30.4-28.3 47.6-68 47.6-109.5v-5.8c0-69.9-50.5-129.5-119.4-141C347 36.5 300.6 51.4 268 84L256 96 244 84c-32.6-32.6-79-47.5-124.6-39.9C50.5 55.6 0 115.2 0 185.1v5.8c0 41.5 17.2 81.2 47.6 109.5z',
},
star: {
viewBox: '0 0 576 512',
regular: 'M287.9 0c9.2 0 17.6 5.2 21.6 13.5l68.6 141.3 153.2 22.6c9 1.3 16.5 7.6 19.3 16.3s.5 18.1-5.9 24.5L434.8 326.7l26.2 155.6c1.5 9-2.2 18.1-9.7 23.5s-17.3 6-25.3 1.7L288 439.6 149.7 507.5c-8 4.3-17.8 3.7-25.3-1.7s-11.2-14.5-9.7-23.5l26.2-155.6L31.1 218.2c-6.5-6.4-8.7-15.9-5.9-24.5s10.3-14.9 19.3-16.3l153.2-22.6L266.3 13.5C270.4 5.2 278.7 0 287.9 0z',
solid: 'M316.9 18C311.6 7 300.4 0 288.1 0s-23.4 7-28.8 18L195 150.3 51.4 171.5c-12 1.8-22 10.2-25.7 21.7s-.7 24.2 7.9 32.7L137.8 329 108.4 474.7c-2 12 3 24.2 12.9 31.3s23 8 33.8 2.3L288.1 439.8 420.9 508.3c10.8 5.7 23.9 4.9 33.8-2.3s14.9-19.3 12.9-31.3L437.7 329 542 225.9c8.6-8.4 11.7-21.2 7.9-32.7s-13.7-19.9-25.7-21.7L380.7 150.3 316.9 18z',
},
} as const;
/** /**
* Self-contained dropdown that renders an album's track list. * Self-contained dropdown that renders an album's track list.
* *
@@ -351,8 +366,6 @@ export class AlbumDropdown extends LitElement {
track.FilePath, track.FilePath,
); );
const isFav = this.favCtrl.isFavorited(track.FilePath); const isFav = this.favCtrl.isFavorited(track.FilePath);
const favVariant = isFav ? 'solid' : 'regular';
const classes = [ const classes = [
'track-row', 'track-row',
active ? 'active' : '', active ? 'active' : '',
@@ -398,10 +411,9 @@ export class AlbumDropdown extends LitElement {
void this.favCtrl.toggleFavorite(track.FilePath); void this.favCtrl.toggleFavorite(track.FilePath);
}} }}
> >
<wa-icon <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${FAV_ICONS[this.favCtrl.iconStyle === 'star' ? 'star' : 'heart'].viewBox.split(' ').slice(2).join(' ')}" width="14" height="14">
name=${this.favCtrl.iconName} ${svg`<path fill="currentColor" d="${FAV_ICONS[this.favCtrl.iconStyle === 'star' ? 'star' : 'heart'][isFav ? 'solid' : 'regular']}"/>`}
variant=${favVariant} </svg>
></wa-icon>
</div> </div>
<span <span
class="track-title" class="track-title"
@@ -141,9 +141,10 @@ const gridStyles = css`
display: flex; display: flex;
flex-direction: column; flex-direction: column;
cursor: pointer; cursor: pointer;
border-radius: 8px;
padding: 5px; padding: 5px;
/* transitions removed — software rendering repaints per frame */ /* border-radius removed — forces anti-aliased path clipping on
every paint of every visible card; cover image retains its own
4px radius via .cover-container */
box-sizing: border-box; box-sizing: border-box;
width: var(--card-width, 176px); width: var(--card-width, 176px);
} }
@@ -169,7 +170,7 @@ const gridStyles = css`
border-radius: 4px; border-radius: 4px;
overflow: hidden; overflow: hidden;
background-color: var(--yj-bg-surface, #282828); background-color: var(--yj-bg-surface, #282828);
transition: scale 0.15s ease; /* transition removed — software rendering repaints per frame */
} }
.album-card.selected .cover-container { .album-card.selected .cover-container {
@@ -202,7 +203,7 @@ const gridStyles = css`
margin-top: 4px; margin-top: 4px;
min-width: 0; min-width: 0;
text-align: center; text-align: center;
transition: scale 0.15s ease; /* transition removed — software rendering repaints per frame */
} }
.album-card.selected .album-info { .album-card.selected .album-info {
@@ -253,6 +253,13 @@ export class CoverGrid
private gridEntriesCache: GridEntry[] = []; private gridEntriesCache: GridEntry[] = [];
private gridEntriesCacheKey: library.Album[] = []; private gridEntriesCacheKey: library.Album[] = [];
// getBeforeEntries/getAfterEntries memoization — prevents .slice()
// from creating new array refs that trigger virtualizer relayout.
private beforeEntriesCache: GridEntry[] = [];
private afterEntriesCache: GridEntry[] = [];
private splitEntriesCacheKey: GridEntry[] | null = null;
private splitEntriesCacheIndex = -1;
static override styles = coverGridStyles; static override styles = coverGridStyles;
/* ==================================================================== /* ====================================================================
@@ -909,19 +916,31 @@ export class CoverGrid
return entries; return entries;
} }
/** Entries for the "before" virtualizer. */ /** Rebuild before/after caches if the entries or splitIndex changed. */
private getBeforeEntries(): GridEntry[] { private ensureSplitCache(): void {
return this.buildGridEntries().slice( const entries = this.buildGridEntries();
0, if (
this.splitIndex, entries === this.splitEntriesCacheKey &&
); this.splitIndex === this.splitEntriesCacheIndex
) {
return;
}
this.splitEntriesCacheKey = entries;
this.splitEntriesCacheIndex = this.splitIndex;
this.beforeEntriesCache = entries.slice(0, this.splitIndex);
this.afterEntriesCache = entries.slice(this.splitIndex);
} }
/** Entries for the "after" virtualizer. */ /** Entries for the "before" virtualizer (memoized). */
private getBeforeEntries(): GridEntry[] {
this.ensureSplitCache();
return this.beforeEntriesCache;
}
/** Entries for the "after" virtualizer (memoized). */
private getAfterEntries(): GridEntry[] { private getAfterEntries(): GridEntry[] {
return this.buildGridEntries().slice( this.ensureSplitCache();
this.splitIndex, return this.afterEntriesCache;
);
} }
/* ==================================================================== /* ====================================================================
@@ -1,4 +1,4 @@
import { LitElement, html, css, nothing, unsafeCSS } from 'lit'; import { LitElement, html, svg, css, nothing, unsafeCSS } from 'lit';
import { designTokens } from '../../styles/tokens.css'; import { designTokens } from '../../styles/tokens.css';
import { import {
customElement, customElement,
@@ -1351,7 +1351,9 @@ export class QueuePanel
class="remove-button" class="remove-button"
title="Remove from queue" title="Remove from queue"
> >
<wa-icon name="xmark"></wa-icon> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 384 512" width="14" height="14">
${svg`<path fill="currentColor" d="M342.6 150.6c12.5-12.5 12.5-32.8 0-45.3s-32.8-12.5-45.3 0L192 210.7 86.6 105.4c-12.5-12.5-32.8-12.5-45.3 0s-12.5 32.8 0 45.3L146.7 256 41.4 361.4c-12.5 12.5-12.5 32.8 0 45.3s32.8 12.5 45.3 0L192 301.3 297.4 406.6c12.5 12.5 32.8 12.5 45.3 0s12.5-32.8 0-45.3L237.3 256 342.6 150.6z"/>`}
</svg>
</button> </button>
</div> </div>
`; `;
+9 -1
View File
@@ -34,6 +34,7 @@ class FavoritesStore {
private pinDefault = true; private pinDefault = true;
private favoritedPaths = new Set<string>(); private favoritedPaths = new Set<string>();
private subscribers = new Set<Subscriber>(); private subscribers = new Set<Subscriber>();
private notifyScheduled = false;
private loading = false; private loading = false;
constructor() { constructor() {
@@ -232,7 +233,14 @@ class FavoritesStore {
} }
private notify(): void { private notify(): void {
this.subscribers.forEach((cb) => cb()); if (this.notifyScheduled) return;
this.notifyScheduled = true;
queueMicrotask(() => {
this.notifyScheduled = false;
for (const cb of this.subscribers) {
cb();
}
});
} }
// =============================================================== // ===============================================================
+9 -1
View File
@@ -41,6 +41,7 @@ class PlayerStore {
}; };
private subscribers = new Set<Subscriber>(); private subscribers = new Set<Subscriber>();
private notifyScheduled = false;
constructor() { constructor() {
this.initializeEventListeners(); this.initializeEventListeners();
@@ -115,7 +116,14 @@ class PlayerStore {
} }
private notify(): void { private notify(): void {
this.subscribers.forEach((callback) => callback()); if (this.notifyScheduled) return;
this.notifyScheduled = true;
queueMicrotask(() => {
this.notifyScheduled = false;
for (const sub of this.subscribers) {
sub();
}
});
} }
} }