Files
NovaSonic/src/ui/components.js
T
HoneyandClaude Opus 4.8 d50e22b62d perf: stop per-cover HttpClient churn (Android) + scroll-arrow reflow (web)
Android: MusicRepository.coverArtUrl/streamUrl built a fresh SubsonicClient
per call — each spins up (and never closes) a Ktor HttpClient. That ran for
every cover on every recomposition, causing heavy GC/alloc churn (and a leak)
that showed up as scroll/tab lag. Reuse the cached ClientHolder client for URL
building instead.

Web: the home scroll-row arrows called getBoundingClientRect on every scroll
event (layout thrashing). Split it: only toggle arrow visibility on scroll
(rAF-throttled); do the getBoundingClientRect vertical-centering only on
resize/content change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 23:02:11 +02:00

165 lines
9.0 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { S } from '../state.js';
import { esc, formatDur } from '../utils.js';
// Exposed on window so inline onerror= handlers can call it.
// Retries the same src up to 3 times with increasing delays, then shows fallback.
window._imgRetry = function(img, fallback) {
const n = (img._r = (img._r || 0) + 1);
if (n > 3 || !img.src) { img.parentNode.innerHTML = `<div class="no-art">${fallback}</div>`; return; }
setTimeout(() => { const s = img.src; img.src = ''; img.src = s; }, 1500 * n);
};
import { coverUrl, dispTitle, dispArtistParts, dispArtistById, dispAlbumName } from '../api.js';
// Circular imports — safe (only used inside event handlers after module init)
import { playQueue, toggleStar, playPreview, parseArtists } from '../player/player.js';
import { showCtxMenu, openDlModal } from './ui.js';
import { navigate, quickPlayAlbum, quickPlayArtist } from '../views/views.js';
// ─── GENERIC CARD ───
export function makeCard(title, sub, imgUrl, fallback='💿') {
const c = document.createElement('div');
c.className = 'card';
c.innerHTML = `<div class="card-art">
${imgUrl ? `<img src="${imgUrl}" loading="lazy" onerror="_imgRetry(this,'${fallback}')">` : `<div class="no-art">${fallback}</div>`}
<div class="play-overlay"><button class="play-btn"><svg width="13" height="13" viewBox="0 0 24 24" fill="currentColor"><polygon points="5 3 19 12 5 21 5 3"/></svg></button></div>
</div>
<div class="card-info"><div class="card-title">${esc(title)}</div><div class="card-sub">${esc(sub||'')}</div></div>`;
return c;
}
export function makeAlbumCard(a) {
const cv = coverUrl(a.coverArt, 512);
const c = makeCard(dispAlbumName(a), dispArtistById(a.artistId, a.artist||''), cv, '💿');
c.querySelector('.card-art').onclick = (e) => { e.stopPropagation(); quickPlayAlbum(a); };
c.querySelector('.card-info').onclick = () => navigate('album-detail', a);
return c;
}
// ─── SONG LIST ITEM (library) ───
export function makeSongItem(s, idx, list, { noArtistLink = false } = {}) {
const el = document.createElement('div');
if(s.starred && !s._starred) s._starred = true;
el.className = 'song-item' + (S.queue[S.qIdx]?.id === s.id ? ' playing' : '');
el.dataset.songId = s.id;
const cv = coverUrl(s.coverArt, 128);
const artistParts = dispArtistParts(s, parseArtists);
const artistHtml = artistParts.length
? artistParts.map(a => noArtistLink
? `<span style="color:var(--text2)">${esc(a.name)}</span>`
: `<span class="song-link" onclick="event.stopPropagation();s_goArtist(event,'${esc(a.originalName||a.name)}','${a.id||''}')">${esc(a.name)}</span>`
).join('<span style="color:var(--text3)"> • </span>')
: '';
const playsHtml = S.cfg.showPlayCount
? `<span class="song-plays">${s.playCount ? s.playCount.toLocaleString() + '<span class="song-plays-x">×</span>' : '—'}</span>`
: '';
el.innerHTML = `<div class="song-num">${idx+1}</div>
<div class="song-thumb">${cv ? `<img src="${cv}" loading="lazy" onerror="_imgRetry(this,'♪')">` : '<div class="no-art">♪</div>'}</div>
<div class="song-info">
<div class="song-title">${esc(dispTitle(s))}</div>
<div class="song-artist">${artistHtml}</div>
</div>
<div class="song-meta">
<span class="song-dur">${formatDur(s.duration)}</span>
<div class="song-actions">
<button class="icon-btn${s._starred?' starred':''}" title="Favorit"><svg width="12" height="12" viewBox="0 0 24 24" fill="${s._starred?'currentColor':'none'}" stroke="currentColor" stroke-width="2"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/></svg></button>
<button class="icon-btn song-menu-btn" title="Mehr Optionen"><svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><circle cx="12" cy="5" r="1.5"/><circle cx="12" cy="12" r="1.5"/><circle cx="12" cy="19" r="1.5"/></svg></button>
</div>
${playsHtml}
</div>`;
const btns = el.querySelectorAll('.icon-btn');
btns[0].onclick = (e) => { e.stopPropagation(); toggleStar(s, btns[0]); };
btns[1].onclick = (e) => { e.stopPropagation(); showCtxMenu(e, s, list, idx); };
el.onclick = () => playQueue(list, idx, { noShuffle: true });
el.addEventListener('contextmenu', e => { e.preventDefault(); showCtxMenu(e, s, list, idx); });
return el;
}
// ─── GLOBAL TRACK ITEM (iTunes / Spotify results) ───
export function makeGlobalTrackItem(t, idx, list) {
const el = document.createElement('div');
el.className = 'gtrack-item';
const art = t.artworkUrl60 || t.artworkUrl100 || '';
el.innerHTML = `
<div class="gtrack-art">${art ? `<img src="${art}" loading="lazy" onerror="this.parentNode.innerHTML='<div style=\\'width:100%;height:100%;display:flex;align-items:center;justify-content:center;font-size:22px\\'>🎵</div>'">` : '<div style="width:100%;height:100%;display:flex;align-items:center;justify-content:center;font-size:22px">🎵</div>'}</div>
<div class="gtrack-num">${t.trackNumber || ''}</div>
<div class="gtrack-info">
<div class="gtrack-title">${esc(t.trackName||t.collectionName||'—')}${t.trackExplicitness==='explicit'?'<span class="explicit-badge">E</span>':''}</div>
<div class="gtrack-sub">${esc(t.artistName||'—')} · ${esc(t.collectionName||'')} ${t.releaseDate?'· '+new Date(t.releaseDate).getFullYear():''}</div>
</div>
<div class="gtrack-actions">
${t.previewUrl ? `<button class="icon-btn blue" title="30s Preview" onclick="event.stopPropagation();playPreview(${JSON.stringify(t).replace(/"/g,'&quot;')})"><svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><polygon points="5 3 19 12 5 21 5 3"/></svg></button>` : ''}
<button class="icon-btn green" title="Auf Server laden" onclick="event.stopPropagation();openDlModal(${JSON.stringify(t).replace(/"/g,'&quot;')})">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
</button>
</div>`;
el.onclick = () => { if(t.previewUrl) playPreview(t); };
return el;
}
// ─── SEARCH SECTION HEADER ───
export function mkSection(title) {
const d = document.createElement('div');
d.className = 'search-section';
d.innerHTML = `<div class="search-section-title">${title}</div>`;
return d;
}
// ─── HORIZONTAL SCROLL-ROW ARROWS (desktop) ───
const _CHEV_L = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="15 18 9 12 15 6"/></svg>';
const _CHEV_R = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 18 15 12 9 6"/></svg>';
/** Wrap a `.scroll-row` with left/right arrows that appear only for the scrollable direction. */
export function enhanceScrollRow(row) {
if (!row || row.dataset.saEnhanced) return;
row.dataset.saEnhanced = '1';
const wrap = document.createElement('div');
wrap.className = 'scroll-row-wrap';
row.parentNode.insertBefore(wrap, row);
wrap.appendChild(row);
const mkBtn = (cls, html, label) => {
const b = document.createElement('button');
b.type = 'button';
b.className = 'scroll-arrow ' + cls;
b.setAttribute('aria-label', label);
b.innerHTML = html;
wrap.appendChild(b);
return b;
};
const left = mkBtn('sa-left', _CHEV_L, 'Zurück');
const right = mkBtn('sa-right', _CHEV_R, 'Weiter');
const EPS = 4;
// Cheap per-scroll work — just toggle which arrow is available; rAF-throttled to once per frame.
let _rafPending = false;
const updateArrows = () => {
_rafPending = false;
left.classList.toggle('can-scroll', row.scrollLeft > EPS);
right.classList.toggle('can-scroll', row.scrollLeft < row.scrollWidth - row.clientWidth - EPS);
};
const onScroll = () => { if (!_rafPending) { _rafPending = true; requestAnimationFrame(updateArrows); } };
// Expensive (getBoundingClientRect forces layout) — only on size/content change, NOT every scroll.
const positionArrows = () => {
const art = row.querySelector('.card-art') || row.querySelector('.card');
if (art) {
const top = art.getBoundingClientRect().top - wrap.getBoundingClientRect().top + art.offsetHeight / 2;
if (top > 0) { left.style.top = right.style.top = top + 'px'; }
}
updateArrows();
};
const step = () => Math.max(160, row.clientWidth * 0.8);
left.addEventListener('click', () => row.scrollBy({ left: -step(), behavior: 'smooth' }));
right.addEventListener('click', () => row.scrollBy({ left: step(), behavior: 'smooth' }));
row.addEventListener('scroll', onScroll, { passive: true });
new ResizeObserver(positionArrows).observe(row);
new MutationObserver(positionArrows).observe(row, { childList: true });
positionArrows();
}
/** Enhance every `.scroll-row` currently in the DOM (the static Home rows). */
export function initScrollRows() {
document.querySelectorAll('.scroll-row').forEach(enhanceScrollRow);
}