refactor(now-playing): G.71 — extract helpers + cache + NpCardWrap + NpColumnEl + RadioView (cluster) (#638)

Five-cut cluster opening the NowPlaying refactor. 1384 → 1119 LOC
(−265).

nowPlayingHelpers — eight pure helpers + ContributorRow type:
formatTime, formatCompact, formatTotalDuration, sanitizeHtml
(strip dangerous attributes + trailing Last.fm "Read more" link),
isoToParts (date formatting for Bandsintown), buildContributorRows
(dedupes contributor list, hides redundant "Artist = main artist"
row), isRealArtistImage (filter the Last.fm "2a96…" placeholder MD5
that aggregating Subsonic backends still emit).

nowPlayingCache — module-level TTL cache used by all subcomponents:
CACHE_TTL_MS (5 min), CacheEntry type, makeCache() factory.
NowPlaying still instantiates eight `makeCache<…>()` typed caches
inline at module scope; only the factory + TTL constant move.

NpCardWrap — drag-source wrapper around each dashboard card,
participates in the psyDnD drag stream via useDragSource.

NpColumnEl — drop-target column. Owns the document mousemove
listener that determines which wrapper the dragged card would
land before (x-axis decides column, y-axis bisects wrapper rects
to compute insert index). No-op when no card is being dragged.

RadioView — full radio-playing layout: hero card with stream
name + current artist/title/album + AzuraCast progress bar +
listeners badge, "Up Next" card, recently-played list. Subscribes
to nothing on its own; takes the radioMeta tuple + currentRadio +
resolvedCover from the parent. NonNullStoreField type alias moves
with it.

NowPlaying drops the inline definitions; renderStars + the eight
typed cache instances stay in the page for now (renderStars is used
by Hero + TopSongsCard, both of which still live inline; the
typed caches are consumed by NowPlaying's load effects). Pure code
move otherwise.
This commit is contained in:
Frank Stellmacher
2026-05-13 14:09:37 +02:00
committed by GitHub
parent e260669537
commit 1c34cc04c7
6 changed files with 297 additions and 274 deletions
+87
View File
@@ -0,0 +1,87 @@
import type { SubsonicSong } from '../api/subsonicTypes';
export function formatTime(s: number): string {
if (!s || isNaN(s)) return '0:00';
const m = Math.floor(s / 60);
return `${m}:${Math.floor(s % 60).toString().padStart(2, '0')}`;
}
export function formatCompact(n: number): string {
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(n >= 10_000_000 ? 0 : 1)}M`;
if (n >= 1_000) return `${(n / 1_000).toFixed(n >= 10_000 ? 0 : 1)}K`;
return String(n);
}
export function formatTotalDuration(s: number): string {
if (!s || isNaN(s)) return '—';
const h = Math.floor(s / 3600);
const m = Math.floor((s % 3600) / 60);
const sec = Math.floor(s % 60);
if (h > 0) return `${h}h ${m}m`;
if (m > 0) return `${m}m ${sec}s`;
return `${sec}s`;
}
export function sanitizeHtml(html: string): string {
const parser = new DOMParser();
const doc = parser.parseFromString(html, 'text/html');
doc.querySelectorAll('script, style, iframe, object, embed, form, input, button, select, base, meta, link').forEach(el => el.remove());
doc.querySelectorAll('*').forEach(el => {
Array.from(el.attributes).forEach(attr => {
const name = attr.name.toLowerCase();
const val = attr.value.toLowerCase().trim();
if (name.startsWith('on') || (name === 'href' && (val.startsWith('javascript:') || val.startsWith('data:'))) || (name === 'src' && (val.startsWith('javascript:') || val.startsWith('data:')))) {
el.removeAttribute(attr.name);
}
});
});
// Strip trailing "Read more on Last.fm" style links for cleaner clamped bios.
return doc.body.innerHTML.replace(/<a [^>]*>.*?<\/a>\.?\s*$/i, '').trim();
}
export function isoToParts(iso: string): { month: string; day: string; weekday: string; time: string } | null {
if (!iso) return null;
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return null;
return {
month: d.toLocaleString(undefined, { month: 'short' }),
day: String(d.getDate()),
weekday: d.toLocaleString(undefined, { weekday: 'short' }),
time: d.toLocaleString(undefined, { hour: '2-digit', minute: '2-digit' }),
};
}
export interface ContributorRow { role: string; names: string[]; }
export function buildContributorRows(song: SubsonicSong | null | undefined, mainArtistName: string): ContributorRow[] {
if (!song?.contributors || song.contributors.length === 0) return [];
const mainLower = mainArtistName.trim().toLowerCase();
const rows = new Map<string, Set<string>>();
for (const c of song.contributors) {
const role = c.role?.trim();
const name = c.artist?.name?.trim();
if (!role || !name) continue;
const label = c.subRole ? `${role}${c.subRole}` : role;
let bucket = rows.get(label);
if (!bucket) { bucket = new Set(); rows.set(label, bucket); }
bucket.add(name);
}
const out: ContributorRow[] = [];
for (const [role, names] of rows.entries()) {
const list = Array.from(names);
if (role.toLowerCase().startsWith('artist') && list.length === 1 && list[0].toLowerCase() === mainLower) continue;
out.push({ role, names: list });
}
return out;
}
/**
* Filter out the well-known Last.fm "no image" placeholder that Subsonic
* backends aggregate into `largeImageUrl`/`mediumImageUrl` when no real
* artist image exists. The placeholder MD5 is fixed and documented.
*/
export function isRealArtistImage(url?: string): boolean {
if (!url) return false;
if (url.includes('2a96cbd8b46e442fc41c2b86b821562f')) return false;
return true;
}