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>
165 lines
9.0 KiB
JavaScript
165 lines
9.0 KiB
JavaScript
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,'"')})"><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,'"')})">
|
||
<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);
|
||
}
|