From 1c34cc04c7252e76ff1575b50344a734731c205b Mon Sep 17 00:00:00 2001 From: Frank Stellmacher <171614930+Psychotoxical@users.noreply.github.com> Date: Wed, 13 May 2026 14:09:37 +0200 Subject: [PATCH] =?UTF-8?q?refactor(now-playing):=20G.71=20=E2=80=94=20ext?= =?UTF-8?q?ract=20helpers=20+=20cache=20+=20NpCardWrap=20+=20NpColumnEl=20?= =?UTF-8?q?+=20RadioView=20(cluster)=20(#638)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/components/nowPlaying/NpCardWrap.tsx | 27 +++ src/components/nowPlaying/NpColumnEl.tsx | 52 +++++ src/components/nowPlaying/RadioView.tsx | 102 ++++++++ src/pages/NowPlaying.tsx | 283 +---------------------- src/utils/nowPlayingCache.ts | 20 ++ src/utils/nowPlayingHelpers.ts | 87 +++++++ 6 files changed, 297 insertions(+), 274 deletions(-) create mode 100644 src/components/nowPlaying/NpCardWrap.tsx create mode 100644 src/components/nowPlaying/NpColumnEl.tsx create mode 100644 src/components/nowPlaying/RadioView.tsx create mode 100644 src/utils/nowPlayingCache.ts create mode 100644 src/utils/nowPlayingHelpers.ts diff --git a/src/components/nowPlaying/NpCardWrap.tsx b/src/components/nowPlaying/NpCardWrap.tsx new file mode 100644 index 00000000..451f952d --- /dev/null +++ b/src/components/nowPlaying/NpCardWrap.tsx @@ -0,0 +1,27 @@ +import React from 'react'; +import { useDragSource } from '../../contexts/DragDropContext'; +import type { NpCardId } from '../../store/nowPlayingLayoutStore'; + +interface NpCardWrapProps { + id: NpCardId; + label: string; + isDraggingThis: boolean; + children: React.ReactNode; +} + +export default function NpCardWrap({ id, label, isDraggingThis, children }: NpCardWrapProps) { + const dragProps = useDragSource(() => ({ + data: JSON.stringify({ kind: 'np-card', id }), + label, + })); + return ( +
+ {children} +
+ ); +} diff --git a/src/components/nowPlaying/NpColumnEl.tsx b/src/components/nowPlaying/NpColumnEl.tsx new file mode 100644 index 00000000..a3382d4c --- /dev/null +++ b/src/components/nowPlaying/NpColumnEl.tsx @@ -0,0 +1,52 @@ +import React, { useEffect, useRef } from 'react'; +import type { NpCardId, NpColumn } from '../../store/nowPlayingLayoutStore'; + +interface NpColumnProps { + col: NpColumn; + children: React.ReactNode; + empty: boolean; + emptyLabel: string; + isDndActive: boolean; + draggingCardId: NpCardId | null; + onHover: (col: NpColumn, idx: number) => void; + isOverHere: boolean; +} + +export default function NpColumnEl({ col, children, empty, emptyLabel, isDndActive, draggingCardId, onHover, isOverHere }: NpColumnProps) { + const ref = useRef(null); + + useEffect(() => { + if (!draggingCardId) return; + const el = ref.current; + if (!el) return; + + const onMove = (e: MouseEvent) => { + const rect = el.getBoundingClientRect(); + // Use only the x-axis to decide "which column". This keeps the whole + // vertical strip above / below the last card part of the drop zone, + // so the user can drop "at the very bottom" of either column. + if (e.clientX < rect.left || e.clientX > rect.right) return; + const wrappers = Array.from(el.querySelectorAll('[data-np-wrapper]')) + .filter(w => w.getAttribute('data-np-card-id') !== draggingCardId); + let idx = wrappers.length; + for (let i = 0; i < wrappers.length; i++) { + const r = wrappers[i].getBoundingClientRect(); + if (e.clientY < r.top + r.height / 2) { idx = i; break; } + } + onHover(col, idx); + }; + + document.addEventListener('mousemove', onMove); + return () => document.removeEventListener('mousemove', onMove); + }, [draggingCardId, col, onHover]); + + return ( +
+ {children} + {empty &&
{emptyLabel}
} +
+ ); +} diff --git a/src/components/nowPlaying/RadioView.tsx b/src/components/nowPlaying/RadioView.tsx new file mode 100644 index 00000000..3d0fb047 --- /dev/null +++ b/src/components/nowPlaying/RadioView.tsx @@ -0,0 +1,102 @@ +import React, { memo } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Cast, Clock, Radio, SkipForward, Users } from 'lucide-react'; +import type { useRadioMetadata } from '../../hooks/useRadioMetadata'; +import { usePlayerStore } from '../../store/playerStore'; +import { formatTime } from '../../utils/nowPlayingHelpers'; + +type NonNullStoreField> = + NonNullable[K]>; + +interface RadioViewProps { + radioMeta: ReturnType; + currentRadio: NonNullStoreField<'currentRadio'>; + resolvedCover: string; +} + +const RadioView = memo(function RadioView({ radioMeta, currentRadio, resolvedCover }: RadioViewProps) { + const { t } = useTranslation(); + return ( +
+
+
+
+
{currentRadio.name}
+ {radioMeta.currentTitle && ( +
+ {radioMeta.currentArtist && (<>{radioMeta.currentArtist}·)} + {radioMeta.currentTitle} + {radioMeta.currentAlbum && (<>·{radioMeta.currentAlbum})} +
+ )} +
+ {t('radio.live')} + {radioMeta.source === 'azuracast' && AzuraCast} + {radioMeta.listeners != null && ( + {t('radio.listenerCount', { count: radioMeta.listeners })} + )} +
+ {radioMeta.source === 'azuracast' && radioMeta.elapsed != null && radioMeta.duration != null && radioMeta.duration > 0 && ( +
+ {formatTime(radioMeta.elapsed)} +
+
+
+ {formatTime(radioMeta.duration)} +
+ )} +
+
+
+ {resolvedCover + ? {currentRadio.name} + : radioMeta.currentArt + ? { (e.target as HTMLImageElement).style.display = 'none'; }} /> + :
} +
+
+
+ + {radioMeta.nextSong && ( +
+
+

{t('radio.upNext')}

+
+
+ {radioMeta.nextSong.art && ( + { (e.target as HTMLImageElement).style.display = 'none'; }} /> + )} +
+ {radioMeta.nextSong.title} + {radioMeta.nextSong.artist && {radioMeta.nextSong.artist}} +
+
+
+ )} + + {radioMeta.history.length > 0 && ( +
+
+

{t('radio.recentlyPlayed')}

+
+
+ {radioMeta.history.map((item, idx) => ( +
+ {item.song.art && ( + { (e.target as HTMLImageElement).style.display = 'none'; }} /> + )} + + {item.song.artist ? `${item.song.artist} — ${item.song.title}` : item.song.title} + +
+ ))} +
+
+ )} +
+ ); +}); + +export default RadioView; diff --git a/src/pages/NowPlaying.tsx b/src/pages/NowPlaying.tsx index fb0654a4..f63367a3 100644 --- a/src/pages/NowPlaying.tsx +++ b/src/pages/NowPlaying.tsx @@ -32,91 +32,15 @@ import { // ─── Helpers ────────────────────────────────────────────────────────────────── -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')}`; -} - -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); -} - -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`; -} - -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>\.?\s*$/i, '').trim(); -} - -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' }), - }; -} - -interface ContributorRow { role: string; names: string[]; } - -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>(); - 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. - */ -function isRealArtistImage(url?: string): boolean { - if (!url) return false; - if (url.includes('2a96cbd8b46e442fc41c2b86b821562f')) return false; - return true; -} +import { + formatTime, formatCompact, formatTotalDuration, sanitizeHtml, isoToParts, + buildContributorRows, isRealArtistImage, + type ContributorRow, +} from '../utils/nowPlayingHelpers'; +import { makeCache } from '../utils/nowPlayingCache'; +import NpCardWrap from '../components/nowPlaying/NpCardWrap'; +import NpColumnEl from '../components/nowPlaying/NpColumnEl'; +import RadioView from '../components/nowPlaying/RadioView'; function renderStars(rating?: number) { if (!rating) return null; @@ -134,23 +58,6 @@ function renderStars(rating?: number) { // ─── Module-level TTL caches (shared across mounts) ─────────────────────────── -const CACHE_TTL_MS = 5 * 60 * 1000; - -interface CacheEntry { value: T; ts: number; } - -function makeCache() { - const map = new Map>(); - return { - get(key: string): T | undefined { - const e = map.get(key); - if (!e) return undefined; - if (Date.now() - e.ts > CACHE_TTL_MS) { map.delete(key); return undefined; } - return e.value; - }, - set(key: string, value: T) { map.set(key, { value, ts: Date.now() }); }, - }; -} - const songMetaCache = makeCache(); const artistInfoCache = makeCache(); const albumCache = makeCache<{ album: SubsonicAlbum; songs: SubsonicSong[] } | null>(); @@ -723,178 +630,6 @@ const DiscographyCard = memo(function DiscographyCard({ artistId, albums, curren ); }); -// ─── Widget wrapper (drag source via psyDnD) ───────────────────────────────── - -interface NpCardWrapProps { - id: NpCardId; - label: string; - isDraggingThis: boolean; - children: React.ReactNode; -} - -function NpCardWrap({ id, label, isDraggingThis, children }: NpCardWrapProps) { - const dragProps = useDragSource(() => ({ - data: JSON.stringify({ kind: 'np-card', id }), - label, - })); - return ( -
- {children} -
- ); -} - -// ─── Column (drop target via psy-drop + global mousemove) ──────────────────── - -interface NpColumnProps { - col: NpColumn; - children: React.ReactNode; - empty: boolean; - emptyLabel: string; - isDndActive: boolean; - draggingCardId: NpCardId | null; - onHover: (col: NpColumn, idx: number) => void; - isOverHere: boolean; -} - -function NpColumnEl({ col, children, empty, emptyLabel, isDndActive, draggingCardId, onHover, isOverHere }: NpColumnProps) { - const ref = useRef(null); - - useEffect(() => { - if (!draggingCardId) return; - const el = ref.current; - if (!el) return; - - const onMove = (e: MouseEvent) => { - const rect = el.getBoundingClientRect(); - // Use only the x-axis to decide "which column". This keeps the whole - // vertical strip above / below the last card part of the drop zone, - // so the user can drop "at the very bottom" of either column. - if (e.clientX < rect.left || e.clientX > rect.right) return; - const wrappers = Array.from(el.querySelectorAll('[data-np-wrapper]')) - .filter(w => w.getAttribute('data-np-card-id') !== draggingCardId); - let idx = wrappers.length; - for (let i = 0; i < wrappers.length; i++) { - const r = wrappers[i].getBoundingClientRect(); - if (e.clientY < r.top + r.height / 2) { idx = i; break; } - } - onHover(col, idx); - }; - - document.addEventListener('mousemove', onMove); - return () => document.removeEventListener('mousemove', onMove); - }, [draggingCardId, col, onHover]); - - return ( -
- {children} - {empty &&
{emptyLabel}
} -
- ); -} - -type NonNullStoreField> = - NonNullable[K]>; - -interface RadioViewProps { - radioMeta: ReturnType; - currentRadio: NonNullStoreField<'currentRadio'>; - resolvedCover: string; -} - -const RadioView = memo(function RadioView({ radioMeta, currentRadio, resolvedCover }: RadioViewProps) { - const { t } = useTranslation(); - return ( -
-
-
-
-
{currentRadio.name}
- {radioMeta.currentTitle && ( -
- {radioMeta.currentArtist && (<>{radioMeta.currentArtist}·)} - {radioMeta.currentTitle} - {radioMeta.currentAlbum && (<>·{radioMeta.currentAlbum})} -
- )} -
- {t('radio.live')} - {radioMeta.source === 'azuracast' && AzuraCast} - {radioMeta.listeners != null && ( - {t('radio.listenerCount', { count: radioMeta.listeners })} - )} -
- {radioMeta.source === 'azuracast' && radioMeta.elapsed != null && radioMeta.duration != null && radioMeta.duration > 0 && ( -
- {formatTime(radioMeta.elapsed)} -
-
-
- {formatTime(radioMeta.duration)} -
- )} -
-
-
- {resolvedCover - ? {currentRadio.name} - : radioMeta.currentArt - ? { (e.target as HTMLImageElement).style.display = 'none'; }} /> - :
} -
-
-
- - {radioMeta.nextSong && ( -
-
-

{t('radio.upNext')}

-
-
- {radioMeta.nextSong.art && ( - { (e.target as HTMLImageElement).style.display = 'none'; }} /> - )} -
- {radioMeta.nextSong.title} - {radioMeta.nextSong.artist && {radioMeta.nextSong.artist}} -
-
-
- )} - - {radioMeta.history.length > 0 && ( -
-
-

{t('radio.recentlyPlayed')}

-
-
- {radioMeta.history.map((item, idx) => ( -
- {item.song.art && ( - { (e.target as HTMLImageElement).style.display = 'none'; }} /> - )} - - {item.song.artist ? `${item.song.artist} — ${item.song.title}` : item.song.title} - -
- ))} -
-
- )} -
- ); -}); - // ─── Main Page ──────────────────────────────────────────────────────────────── export default function NowPlaying() { diff --git a/src/utils/nowPlayingCache.ts b/src/utils/nowPlayingCache.ts new file mode 100644 index 00000000..68e0f77f --- /dev/null +++ b/src/utils/nowPlayingCache.ts @@ -0,0 +1,20 @@ +// Module-level TTL caches (shared across mounts). +// Used by NowPlaying subcomponents to avoid hammering Subsonic / Last.fm / +// Bandsintown on every track / artist change. + +export const CACHE_TTL_MS = 5 * 60 * 1000; + +interface CacheEntry { value: T; ts: number; } + +export function makeCache() { + const map = new Map>(); + return { + get(key: string): T | undefined { + const e = map.get(key); + if (!e) return undefined; + if (Date.now() - e.ts > CACHE_TTL_MS) { map.delete(key); return undefined; } + return e.value; + }, + set(key: string, value: T) { map.set(key, { value, ts: Date.now() }); }, + }; +} diff --git a/src/utils/nowPlayingHelpers.ts b/src/utils/nowPlayingHelpers.ts new file mode 100644 index 00000000..6231acd9 --- /dev/null +++ b/src/utils/nowPlayingHelpers.ts @@ -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>\.?\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>(); + 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; +}