From 199c91013fd806f6aefce49357df8a32b46faaa0 Mon Sep 17 00:00:00 2001 From: Caleb Allen Date: Sun, 15 Mar 2026 09:00:59 -0400 Subject: [PATCH] perf: reduce software rendering overhead for NVIDIA+Wayland MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Targeted optimizations for the DMABuf-disabled rendering path where every frame is software-composited: - Replace infinite CSS scroll-text animation with transition-based cycle that only repaints during active scroll, not during pauses - Remove CSS mask-image on scrolling text (mask + animation was the single most expensive continuous repaint) - Replace wa-icon in track rows with inline SVG — eliminates 30-50 shadow DOM trees (each with SVG fetch/parse) during scroll - Remove hover transitions on album cards, artist cards, genre cards, fav icons, queue remove buttons — each transition was causing per-frame software repaints - Use visibility:hidden instead of opacity:0 for queue remove button (binary switch vs per-frame alpha blend) - Add decoding=async to now-playing cover art images (prevents main-thread blocking during image decode on track change) - Add contain:strict to fixed-height track rows (33px) and queue items (49px) — browser skips size contribution calculations --- .../components/artists-view/artists-view.ts | 4 +- .../cover-grid/cover-grid-styles.ts | 4 +- .../src/components/genres-view/genres-view.ts | 4 +- .../src/components/now-playing/now-playing.ts | 113 +++++++++++++----- .../src/components/queue-panel/queue-panel.ts | 6 +- .../src/components/track-list/track-list.ts | 34 ++++-- 6 files changed, 116 insertions(+), 49 deletions(-) diff --git a/frontend/src/components/artists-view/artists-view.ts b/frontend/src/components/artists-view/artists-view.ts index c0eae57..9964262 100644 --- a/frontend/src/components/artists-view/artists-view.ts +++ b/frontend/src/components/artists-view/artists-view.ts @@ -245,9 +245,7 @@ export class ArtistsView padding: 5px; border-radius: 8px; cursor: pointer; - transition: - background-color 0.15s ease, - transform 0.15s ease; + /* transitions removed — software rendering repaints per frame */ overflow: hidden; } diff --git a/frontend/src/components/cover-grid/cover-grid-styles.ts b/frontend/src/components/cover-grid/cover-grid-styles.ts index fe1bb08..e5a5905 100644 --- a/frontend/src/components/cover-grid/cover-grid-styles.ts +++ b/frontend/src/components/cover-grid/cover-grid-styles.ts @@ -143,9 +143,7 @@ const gridStyles = css` cursor: pointer; border-radius: 8px; padding: 5px; - transition: - background-color 0.2s ease, - transform 0.15s ease; + /* transitions removed — software rendering repaints per frame */ box-sizing: border-box; width: var(--card-width, 176px); } diff --git a/frontend/src/components/genres-view/genres-view.ts b/frontend/src/components/genres-view/genres-view.ts index 2ccb6eb..57ef4bb 100644 --- a/frontend/src/components/genres-view/genres-view.ts +++ b/frontend/src/components/genres-view/genres-view.ts @@ -249,9 +249,7 @@ export class GenresView padding: 5px; border-radius: 8px; cursor: pointer; - transition: - background-color 0.15s ease, - transform 0.15s ease; + /* transitions removed — software rendering repaints per frame */ overflow: hidden; } diff --git a/frontend/src/components/now-playing/now-playing.ts b/frontend/src/components/now-playing/now-playing.ts index 7b68f20..77e6771 100644 --- a/frontend/src/components/now-playing/now-playing.ts +++ b/frontend/src/components/now-playing/now-playing.ts @@ -47,6 +47,18 @@ export class NowPlaying extends LitElement { @state() private artistHovered = false; + /** Whether each field is actively mid-scroll (class toggle). */ + @state() + private titleScrolling = false; + + @state() + private artistScrolling = false; + + private scrollTimers: Record | null> = { + title: null, + artist: null, + }; + private resizeObserver?: ResizeObserver; static override styles = [designTokens, css` @@ -185,35 +197,17 @@ export class NowPlaying extends LitElement { white-space: nowrap; } - /* Fade-out masks on both edges when scrolling */ - .track-title.will-scroll, - .track-artist.will-scroll { - mask-image: linear-gradient( - to right, - transparent 0%, - black 8%, - black 92%, - transparent 100% - ); - -webkit-mask-image: linear-gradient( - to right, - transparent 0%, - black 8%, - black 92%, - transparent 100% - ); - } - + /* Scrolling text: use a single transition instead of an infinite + CSS animation. The infinite animation + mask-image was repainting + every frame in software rendering mode (no DMABuf). A transition + only repaints during the active scroll, and pauses are free. */ .will-scroll .scroll-content { - animation: scroll-text var(--scroll-duration, 5s) linear infinite; - padding-right: 2em; /* gap before the text repeats visually */ + transition: transform var(--scroll-duration, 5s) linear; + padding-right: 2em; } - @keyframes scroll-text { - 0% { transform: translateX(0); } - 5% { transform: translateX(0); } - 95% { transform: translateX(var(--scroll-distance, -100%)); } - 100% { transform: translateX(var(--scroll-distance, -100%)); } + .will-scroll.scrolling .scroll-content { + transform: translateX(var(--scroll-distance, -100%)); } .resize-handle { @@ -253,12 +247,15 @@ export class NowPlaying extends LitElement { document.removeEventListener('mouseup', this.handleMouseUp); window.removeEventListener(SCROLL_CHANGE_EVENT, this.handleScrollModeEvent); this.resizeObserver?.disconnect(); + this.stopScrollCycle('title'); + this.stopScrollCycle('artist'); } protected override updated(): void { this.checkOverflows(); this.observeTextContainers(); this.applyScrollDistances(); + this.syncScrollCycles(); } override render() { @@ -298,6 +295,7 @@ export class NowPlaying extends LitElement { ? html`Album cover { const img = e.target as HTMLImageElement; if ( @@ -325,6 +323,7 @@ export class NowPlaying extends LitElement { Album cover full size ` @@ -334,16 +333,18 @@ export class NowPlaying extends LitElement {
this.onScrollCycleEnd('title')} > ${track.title} this.onScrollCycleEnd('artist')} > ${track.artist || 'Unknown Artist'} @@ -456,6 +457,62 @@ export class NowPlaying extends LitElement { } } + // =================================================================== + // TRANSITION-BASED SCROLL CYCLE + // =================================================================== + + /** + * Start a scroll cycle for a field. Adds the `scrolling` class which + * triggers a CSS transition. When the transition ends, we pause then + * snap back and repeat. This replaces the old infinite CSS animation + * which repainted every frame even during the pause phases. + */ + private startScrollCycle(field: 'title' | 'artist'): void { + if (!this.shouldScroll(field)) return; + + // Small delay before starting the scroll + this.scrollTimers[field] = setTimeout(() => { + if (field === 'title') this.titleScrolling = true; + else this.artistScrolling = true; + }, 1500); + } + + private stopScrollCycle(field: 'title' | 'artist'): void { + if (this.scrollTimers[field] !== null) { + clearTimeout(this.scrollTimers[field]!); + this.scrollTimers[field] = null; + } + + if (field === 'title') this.titleScrolling = false; + else this.artistScrolling = false; + } + + private onScrollCycleEnd(field: 'title' | 'artist'): void { + // Transition finished → snap back after a pause, then repeat + if (field === 'title') this.titleScrolling = false; + else this.artistScrolling = false; + + // Restart after a pause (2s at the scrolled-to position) + this.scrollTimers[field] = setTimeout(() => { + this.startScrollCycle(field); + }, 2000); + } + + /** Called from updated() when shouldScroll state changes. */ + private syncScrollCycles(): void { + for (const field of ['title', 'artist'] as const) { + const should = this.shouldScroll(field); + const active = this.scrollTimers[field] !== null || + (field === 'title' ? this.titleScrolling : this.artistScrolling); + + if (should && !active) { + this.startScrollCycle(field); + } else if (!should && active) { + this.stopScrollCycle(field); + } + } + } + // =================================================================== // HOVER HANDLERS // =================================================================== diff --git a/frontend/src/components/queue-panel/queue-panel.ts b/frontend/src/components/queue-panel/queue-panel.ts index 6721c2b..0c87e9e 100644 --- a/frontend/src/components/queue-panel/queue-panel.ts +++ b/frontend/src/components/queue-panel/queue-panel.ts @@ -319,6 +319,7 @@ export class QueuePanel box-sizing: border-box; height: 49px; overflow: hidden; + contain: strict; } .track-item:hover { @@ -384,12 +385,11 @@ export class QueuePanel padding: 4px; display: flex; align-items: center; - opacity: 0; - transition: opacity 0.15s; + visibility: hidden; } .track-item:hover .remove-button { - opacity: 1; + visibility: visible; } .remove-button:hover { diff --git a/frontend/src/components/track-list/track-list.ts b/frontend/src/components/track-list/track-list.ts index 7cb6185..033e941 100644 --- a/frontend/src/components/track-list/track-list.ts +++ b/frontend/src/components/track-list/track-list.ts @@ -1,5 +1,5 @@ import { library } from '@go/models'; -import { LitElement, html, css, nothing } from 'lit'; +import { LitElement, html, svg, css, nothing } from 'lit'; import { designTokens } from '../../styles/tokens.css'; import { customElement, @@ -61,6 +61,21 @@ const SORT_DIR_KEY = 'track-list-sort-direction'; const MIN_COLUMN_WIDTH = 50; const DEFAULT_FIXED_WIDTH = 80; +// Inline SVG paths for favorite icons — eliminates wa-icon shadow DOM +// overhead (30-50 shadow roots during scroll). 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; + type SortDirection = 'asc' | 'desc'; @customElement('track-list') @@ -956,6 +971,7 @@ export class TrackList extends LitElement implements SelectionHost, ContextMenuH overflow: hidden; height: 33px; box-sizing: border-box; + contain: strict; } .track-row > * { @@ -1012,7 +1028,6 @@ export class TrackList extends LitElement implements SelectionHost, ContextMenuH cursor: pointer; color: var(--yj-text-tertiary, #666); font-size: var(--yj-text-sm); - transition: color 0.1s ease; } .fav-icon:hover { @@ -1652,9 +1667,11 @@ export class TrackList extends LitElement implements SelectionHost, ContextMenuH const isFav = this.favCtrl.isFavorited( track.FilePath, ); - const favVariant = isFav - ? 'solid' - : 'regular'; + + // Inline SVG instead of wa-icon — eliminates a shadow DOM tree + // per visible row (~30-50 during scroll). + const iconDef = FAV_ICONS[this.favCtrl.iconStyle === 'star' ? 'star' : 'heart']; + const iconPath = isFav ? iconDef.solid : iconDef.regular; // No inline closures — all events delegated via data-index // on the virtualizer element (see firstUpdated). @@ -1675,10 +1692,9 @@ export class TrackList extends LitElement implements SelectionHost, ContextMenuH favorited: isFav, })} > - + + ${svg``} +
${cols.map((col) => { const val = col.accessor(track);