mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-22 14:35:41 +00:00
1c34cc04c7
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.
88 lines
3.4 KiB
TypeScript
88 lines
3.4 KiB
TypeScript
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;
|
|
}
|