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 = `
${fallback}
`; 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 = `
${imgUrl ? `` : `
${fallback}
`}
${esc(title)}
${esc(sub||'')}
`; 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 ? `${esc(a.name)}` : `${esc(a.name)}` ).join(' • ') : ''; const playsHtml = S.cfg.showPlayCount ? `${s.playCount ? s.playCount.toLocaleString() + '×' : '—'}` : ''; el.innerHTML = `
${idx+1}
${cv ? `` : '
♪
'}
${esc(dispTitle(s))}
${artistHtml}
${formatDur(s.duration)}
${playsHtml}
`; 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 = `
${art ? `` : '
🎵
'}
${t.trackNumber || ''}
${esc(t.trackName||t.collectionName||'—')}${t.trackExplicitness==='explicit'?'E':''}
${esc(t.artistName||'—')} · ${esc(t.collectionName||'')} ${t.releaseDate?'· '+new Date(t.releaseDate).getFullYear():''}
${t.previewUrl ? `` : ''}
`; 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 = `
${title}
`; return d; } // ─── HORIZONTAL SCROLL-ROW ARROWS (desktop) ─── const _CHEV_L = ''; const _CHEV_R = ''; /** 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); }