mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 15:25:46 +00:00
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:
committed by
GitHub
parent
e260669537
commit
1c34cc04c7
@@ -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 (
|
||||
<div
|
||||
data-np-wrapper
|
||||
data-np-card-id={id}
|
||||
className={`np-dash-card-wrap${isDraggingThis ? ' is-dragging' : ''}`}
|
||||
{...dragProps}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<HTMLDivElement>(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<HTMLElement>('[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 (
|
||||
<div
|
||||
ref={ref}
|
||||
className={`np-dash-col${isOverHere ? ' is-drop-target' : ''}${isDndActive ? ' is-dnd-active' : ''}`}
|
||||
>
|
||||
{children}
|
||||
{empty && <div className="np-dash-col-empty">{emptyLabel}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<K extends keyof ReturnType<typeof usePlayerStore.getState>> =
|
||||
NonNullable<ReturnType<typeof usePlayerStore.getState>[K]>;
|
||||
|
||||
interface RadioViewProps {
|
||||
radioMeta: ReturnType<typeof useRadioMetadata>;
|
||||
currentRadio: NonNullStoreField<'currentRadio'>;
|
||||
resolvedCover: string;
|
||||
}
|
||||
|
||||
const RadioView = memo(function RadioView({ radioMeta, currentRadio, resolvedCover }: RadioViewProps) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="np-radio-section">
|
||||
<div className="np-hero-card">
|
||||
<div className="np-hero-left">
|
||||
<div className="np-hero-info">
|
||||
<div className="np-title" style={{ color: 'var(--accent)' }}>{currentRadio.name}</div>
|
||||
{radioMeta.currentTitle && (
|
||||
<div className="np-artist-album">
|
||||
{radioMeta.currentArtist && (<><span className="np-link">{radioMeta.currentArtist}</span><span className="np-sep">·</span></>)}
|
||||
<span>{radioMeta.currentTitle}</span>
|
||||
{radioMeta.currentAlbum && (<><span className="np-sep">·</span><span style={{ opacity: 0.6 }}>{radioMeta.currentAlbum}</span></>)}
|
||||
</div>
|
||||
)}
|
||||
<div className="np-tech-row">
|
||||
<span className="np-badge np-badge-live"><Radio size={10} style={{ marginRight: 3 }} />{t('radio.live')}</span>
|
||||
{radioMeta.source === 'azuracast' && <span className="np-badge np-badge-azuracast">AzuraCast</span>}
|
||||
{radioMeta.listeners != null && (
|
||||
<span className="np-badge"><Users size={10} style={{ marginRight: 3 }} />{t('radio.listenerCount', { count: radioMeta.listeners })}</span>
|
||||
)}
|
||||
</div>
|
||||
{radioMeta.source === 'azuracast' && radioMeta.elapsed != null && radioMeta.duration != null && radioMeta.duration > 0 && (
|
||||
<div className="np-radio-progress-wrap">
|
||||
<span className="np-radio-time">{formatTime(radioMeta.elapsed)}</span>
|
||||
<div className="np-radio-progress-bar">
|
||||
<div className="np-radio-progress-fill" style={{ width: `${Math.min(100, (radioMeta.elapsed / radioMeta.duration) * 100)}%` }} />
|
||||
</div>
|
||||
<span className="np-radio-time">{formatTime(radioMeta.duration)}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="np-hero-cover-wrap">
|
||||
{resolvedCover
|
||||
? <img src={resolvedCover} alt={currentRadio.name} className="np-cover" />
|
||||
: radioMeta.currentArt
|
||||
? <img src={radioMeta.currentArt} alt="" className="np-cover" onError={e => { (e.target as HTMLImageElement).style.display = 'none'; }} />
|
||||
: <div className="np-cover np-cover-fallback"><Cast size={52} /></div>}
|
||||
</div>
|
||||
<div style={{ flex: 1 }} />
|
||||
</div>
|
||||
|
||||
{radioMeta.nextSong && (
|
||||
<div className="np-info-card">
|
||||
<div className="np-card-header">
|
||||
<h3 className="np-card-title"><SkipForward size={13} style={{ marginRight: 5 }} />{t('radio.upNext')}</h3>
|
||||
</div>
|
||||
<div className="np-radio-next-track">
|
||||
{radioMeta.nextSong.art && (
|
||||
<img src={radioMeta.nextSong.art} alt="" className="np-radio-track-art"
|
||||
onError={e => { (e.target as HTMLImageElement).style.display = 'none'; }} />
|
||||
)}
|
||||
<div className="np-radio-track-info">
|
||||
<span className="np-radio-track-title">{radioMeta.nextSong.title}</span>
|
||||
{radioMeta.nextSong.artist && <span className="np-radio-track-artist">{radioMeta.nextSong.artist}</span>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{radioMeta.history.length > 0 && (
|
||||
<div className="np-info-card">
|
||||
<div className="np-card-header">
|
||||
<h3 className="np-card-title"><Clock size={13} style={{ marginRight: 5 }} />{t('radio.recentlyPlayed')}</h3>
|
||||
</div>
|
||||
<div className="np-album-tracklist">
|
||||
{radioMeta.history.map((item, idx) => (
|
||||
<div key={idx} className="np-album-track">
|
||||
{item.song.art && (
|
||||
<img src={item.song.art} alt="" className="np-radio-track-art np-radio-track-art--sm"
|
||||
onError={e => { (e.target as HTMLImageElement).style.display = 'none'; }} />
|
||||
)}
|
||||
<span className="np-album-track-title truncate">
|
||||
{item.song.artist ? `${item.song.artist} — ${item.song.title}` : item.song.title}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export default RadioView;
|
||||
Reference in New Issue
Block a user