mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-21 14:55:43 +00:00
7c724a642f
* chore(eslint): add eslint toolchain and configs * fix(eslint): resolve gradual-config errors (rules-of-hooks, no-empty, prefer-const, …) * chore(eslint): clear unused vars in config, contexts, app and test * chore(eslint): clear unused vars in api and music-network * chore(eslint): clear unused vars in utils * chore(eslint): clear unused vars in store * chore(eslint): clear unused vars in cover and hooks * chore(eslint): clear unused vars in components * chore(eslint): clear unused vars in pages * chore(eslint): remove explicit any in src * chore(eslint): align react-hooks exhaustive-deps * chore(eslint): zero gradual config on src * chore(eslint): strict hook rules in store and utils * chore(eslint): strict hook rules in hooks and cover * chore(eslint): strict hook rules in components * chore(eslint): strict hook rules in pages and contexts * chore(eslint): document scripts ignore in eslint config * chore(eslint): add lint script and zero strict findings * chore(eslint): address review round 1 (gradual 0/0, per-site disable reasons) * chore(eslint): tighten two set-state disable comments (review round 2 LOW) * chore(nix): sync npmDepsHash with package-lock.json * docs(changelog): add Under the Hood entry for ESLint setup (PR #1165) --------- Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
373 lines
16 KiB
TypeScript
373 lines
16 KiB
TypeScript
import { getSongForServer } from '../api/subsonicLibrary';
|
|
import { getArtistInfoForServer } from '../api/subsonicArtists';
|
|
import type { SubsonicArtistInfo, SubsonicSong } from '../api/subsonicTypes';
|
|
import React, { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
|
import { useTranslation } from 'react-i18next';
|
|
import { Info } from 'lucide-react';
|
|
import { open as shellOpen } from '@tauri-apps/plugin-shell';
|
|
import { usePlayerStore } from '../store/playerStore';
|
|
import { useAuthStore } from '../store/authStore';
|
|
import { usePlaybackServerId } from '../hooks/usePlaybackServerId';
|
|
import { fetchBandsintownEvents, type BandsintownEvent } from '../api/bandsintown';
|
|
import CachedImage from './CachedImage';
|
|
import OverlayScrollArea from './OverlayScrollArea';
|
|
import { primaryTrackArtistRef } from '../utils/playback/trackArtistRefs';
|
|
|
|
const TOUR_LIMIT = 5;
|
|
const BIO_CLAMP_LINES = 4;
|
|
|
|
/**
|
|
* Cross-mount caches keyed by stable IDs so jumping between tracks of the same
|
|
* artist / album doesn't refire the network call. Cleared on app restart.
|
|
*/
|
|
const artistInfoCache = new Map<string, SubsonicArtistInfo | null>();
|
|
const songDetailCache = new Map<string, SubsonicSong | null>();
|
|
|
|
type ArtistInfoEntry = { id: string; info: SubsonicArtistInfo | null };
|
|
type SongDetailEntry = { id: string; song: SubsonicSong | null };
|
|
|
|
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;
|
|
const month = d.toLocaleString(undefined, { month: 'short' });
|
|
const day = String(d.getDate());
|
|
const weekday = d.toLocaleString(undefined, { weekday: 'short' });
|
|
const time = d.toLocaleString(undefined, { hour: '2-digit', minute: '2-digit' });
|
|
return { month, day, weekday, time };
|
|
}
|
|
|
|
interface ContributorRow {
|
|
role: string;
|
|
names: string[];
|
|
}
|
|
|
|
/**
|
|
* Build credits from OpenSubsonic `contributors[]` only. The legacy
|
|
* artist/albumArtist/composer fallback is intentionally dropped — it
|
|
* just repeats what's already shown above the tab.
|
|
*/
|
|
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);
|
|
}
|
|
// Drop a row that only restates the main artist under the "artist" role.
|
|
const out: ContributorRow[] = [];
|
|
for (const [role, names] of rows.entries()) {
|
|
const list = Array.from(names);
|
|
const isMainArtistOnly =
|
|
role.toLowerCase().startsWith('artist') &&
|
|
list.length === 1 &&
|
|
list[0].toLowerCase() === mainLower;
|
|
if (isMainArtistOnly) continue;
|
|
out.push({ role, names: list });
|
|
}
|
|
return out;
|
|
}
|
|
|
|
function queuePanelCacheKey(serverId: string, id: string): string {
|
|
return serverId ? `${serverId}:${id}` : id;
|
|
}
|
|
|
|
export default function NowPlayingInfo() {
|
|
const { t } = useTranslation();
|
|
const currentTrack = usePlayerStore(s => s.currentTrack);
|
|
const enableBandsintown = useAuthStore(s => s.enableBandsintown);
|
|
const setEnableBandsintown = useAuthStore(s => s.setEnableBandsintown);
|
|
const subsonicServerId = usePlaybackServerId();
|
|
const subsonicReady = Boolean(subsonicServerId);
|
|
|
|
const primaryArtist = currentTrack ? primaryTrackArtistRef(currentTrack) : null;
|
|
const artistName = primaryArtist?.name ?? currentTrack?.artist ?? '';
|
|
const artistId = primaryArtist?.id ?? '';
|
|
const songId = currentTrack?.id || '';
|
|
|
|
// Tuple { id, info } gates rendering on "info matches the current artistId" so
|
|
// `heroImage` (from info) and `heroCacheKey` (from artistId) can never be from
|
|
// different tracks. Otherwise a track switch would render one frame with a
|
|
// lagging url under the new key, and CachedImage's IndexedDB would persist
|
|
// the wrong blob under the new key — sticky "previous track" image (#…).
|
|
const [artistInfoEntry, setArtistInfoEntry] = useState<ArtistInfoEntry | null>(() => {
|
|
if (!artistId || !subsonicServerId) return null;
|
|
const cached = artistInfoCache.get(queuePanelCacheKey(subsonicServerId, artistId));
|
|
return cached === undefined ? null : { id: artistId, info: cached };
|
|
});
|
|
const [songDetailEntry, setSongDetailEntry] = useState<SongDetailEntry | null>(() => {
|
|
if (!songId || !subsonicServerId) return null;
|
|
const cached = songDetailCache.get(queuePanelCacheKey(subsonicServerId, songId));
|
|
return cached === undefined ? null : { id: songId, song: cached };
|
|
});
|
|
const [tourEvents, setTourEvents] = useState<BandsintownEvent[]>([]);
|
|
const [tourLoading, setTourLoading] = useState(false);
|
|
const [bioExpanded, setBioExpanded] = useState(false);
|
|
const [bioOverflows, setBioOverflows] = useState(false);
|
|
const [showAllTours, setShowAllTours] = useState(false);
|
|
|
|
const bioRef = useRef<HTMLParagraphElement | null>(null);
|
|
|
|
// Reset per-track UI state when the track changes
|
|
// React Compiler set-state-in-effect rule: state set from an async result resolved in this effect.
|
|
// eslint-disable-next-line react-hooks/set-state-in-effect
|
|
useEffect(() => { setBioExpanded(false); setShowAllTours(false); }, [artistId, songId]);
|
|
|
|
// Artist bio + image
|
|
useEffect(() => {
|
|
// React Compiler set-state-in-effect rule: state set from an async result resolved in this effect.
|
|
// eslint-disable-next-line react-hooks/set-state-in-effect
|
|
if (!subsonicReady || !subsonicServerId || !artistId) { setArtistInfoEntry(null); return; }
|
|
const cacheKey = queuePanelCacheKey(subsonicServerId, artistId);
|
|
const cached = artistInfoCache.get(cacheKey);
|
|
if (cached !== undefined) { setArtistInfoEntry({ id: artistId, info: cached }); return; }
|
|
setArtistInfoEntry(null);
|
|
let cancelled = false;
|
|
getArtistInfoForServer(subsonicServerId, artistId)
|
|
.then(info => { if (!cancelled) { artistInfoCache.set(cacheKey, info ?? null); setArtistInfoEntry({ id: artistId, info: info ?? null }); } })
|
|
.catch(() => { if (!cancelled) { artistInfoCache.set(cacheKey, null); setArtistInfoEntry({ id: artistId, info: null }); } });
|
|
return () => { cancelled = true; };
|
|
}, [subsonicReady, subsonicServerId, artistId]);
|
|
|
|
// Song detail (for OpenSubsonic contributors[])
|
|
useEffect(() => {
|
|
// React Compiler set-state-in-effect rule: state set from an async result resolved in this effect.
|
|
// eslint-disable-next-line react-hooks/set-state-in-effect
|
|
if (!subsonicReady || !subsonicServerId || !songId) { setSongDetailEntry(null); return; }
|
|
const cacheKey = queuePanelCacheKey(subsonicServerId, songId);
|
|
const cached = songDetailCache.get(cacheKey);
|
|
if (cached !== undefined) { setSongDetailEntry({ id: songId, song: cached }); return; }
|
|
setSongDetailEntry(null);
|
|
let cancelled = false;
|
|
getSongForServer(subsonicServerId, songId)
|
|
.then(song => { if (!cancelled) { songDetailCache.set(cacheKey, song ?? null); setSongDetailEntry({ id: songId, song: song ?? null }); } })
|
|
.catch(() => { if (!cancelled) { songDetailCache.set(cacheKey, null); setSongDetailEntry({ id: songId, song: null }); } });
|
|
return () => { cancelled = true; };
|
|
}, [subsonicReady, subsonicServerId, songId]);
|
|
|
|
// Bandsintown — only when opt-in toggle is on
|
|
useEffect(() => {
|
|
// React Compiler set-state-in-effect rule: state set from an async result resolved in this effect.
|
|
// eslint-disable-next-line react-hooks/set-state-in-effect
|
|
if (!enableBandsintown || !artistName) { setTourEvents([]); return; }
|
|
let cancelled = false;
|
|
setTourLoading(true);
|
|
fetchBandsintownEvents(artistName)
|
|
.then(events => { if (!cancelled) setTourEvents(events); })
|
|
.finally(() => { if (!cancelled) setTourLoading(false); });
|
|
return () => { cancelled = true; };
|
|
}, [enableBandsintown, artistName]);
|
|
|
|
// Only consume info that belongs to the current track — never render with a
|
|
// stale entry from the previous track.
|
|
const matchedArtistInfo =
|
|
artistInfoEntry && artistInfoEntry.id === artistId ? artistInfoEntry.info : null;
|
|
const matchedSongDetail =
|
|
songDetailEntry && songDetailEntry.id === songId ? songDetailEntry.song : null;
|
|
|
|
// Detect whether the (clamped) bio actually overflows so we hide the toggle
|
|
// when it would do nothing.
|
|
const bio = matchedArtistInfo?.biography?.trim() || '';
|
|
const bioClean = bio.replace(/<a [^>]*>.*?<\/a>\.?/gi, '').trim();
|
|
useLayoutEffect(() => {
|
|
const el = bioRef.current;
|
|
if (!el) { setBioOverflows(false); return; }
|
|
setBioOverflows(el.scrollHeight - el.clientHeight > 1);
|
|
}, [bioClean]);
|
|
|
|
const contributorRows = useMemo(
|
|
() => buildContributorRows(matchedSongDetail, currentTrack?.artist ?? ''),
|
|
[matchedSongDetail, currentTrack?.artist],
|
|
);
|
|
|
|
if (!currentTrack) {
|
|
return (
|
|
<div className="np-info-empty">
|
|
{t('nowPlayingInfo.empty', 'Play something to see info')}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const heroImage =
|
|
matchedArtistInfo?.largeImageUrl || matchedArtistInfo?.mediumImageUrl || '';
|
|
const heroCacheKey = matchedArtistInfo && artistId ? `artistInfo:${artistId}:hero` : '';
|
|
|
|
const visibleTours = showAllTours ? tourEvents : tourEvents.slice(0, TOUR_LIMIT);
|
|
const hiddenTourCount = Math.max(0, tourEvents.length - visibleTours.length);
|
|
|
|
return (
|
|
<OverlayScrollArea
|
|
className="np-info"
|
|
viewportClassName="np-info__viewport"
|
|
railInset="panel"
|
|
measureDeps={[
|
|
currentTrack?.id,
|
|
artistId,
|
|
songId,
|
|
enableBandsintown,
|
|
tourLoading,
|
|
tourEvents.length,
|
|
showAllTours,
|
|
bioExpanded,
|
|
bioOverflows,
|
|
bioClean.length,
|
|
contributorRows.length,
|
|
]}
|
|
>
|
|
{/* Artist card */}
|
|
<section className="np-info-section np-info-artist">
|
|
{heroImage && heroCacheKey && (
|
|
<div className="np-info-artist-image-wrap">
|
|
<CachedImage
|
|
src={heroImage}
|
|
cacheKey={heroCacheKey}
|
|
alt={artistName}
|
|
className="np-info-artist-image"
|
|
/>
|
|
</div>
|
|
)}
|
|
<div className="np-info-artist-body">
|
|
<div className="np-info-section-title">{t('nowPlayingInfo.artist', 'Artist')}</div>
|
|
<div className="np-info-artist-name">{artistName || t('common.unknownArtist', 'Unknown artist')}</div>
|
|
{bioClean && (
|
|
<>
|
|
<p
|
|
ref={bioRef}
|
|
className={`np-info-artist-bio${bioExpanded ? '' : ' is-clamped'}`}
|
|
style={bioExpanded ? undefined : { WebkitLineClamp: BIO_CLAMP_LINES }}
|
|
>
|
|
{bioClean}
|
|
</p>
|
|
{(bioOverflows || bioExpanded) && (
|
|
<button
|
|
type="button"
|
|
className="np-info-link-btn"
|
|
onClick={() => setBioExpanded(v => !v)}
|
|
>
|
|
{bioExpanded
|
|
? t('nowPlayingInfo.bioReadLess', 'Show less')
|
|
: t('nowPlayingInfo.bioReadMore', 'Read more')}
|
|
</button>
|
|
)}
|
|
</>
|
|
)}
|
|
</div>
|
|
</section>
|
|
|
|
{/* Song info / contributors — only when OpenSubsonic provided real credits */}
|
|
{contributorRows.length > 0 && (
|
|
<section className="np-info-section">
|
|
<div className="np-info-section-title">{t('nowPlayingInfo.songInfo', 'Song info')}</div>
|
|
<ul className="np-info-credits">
|
|
{contributorRows.map(row => (
|
|
<li key={row.role} className="np-info-credit-row">
|
|
<span className="np-info-credit-names">{row.names.join(', ')}</span>
|
|
<span className="np-info-credit-role">{t(`nowPlayingInfo.role.${row.role}`, row.role)}</span>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</section>
|
|
)}
|
|
|
|
{/* Tour: prompt to opt-in when off, list when on */}
|
|
{!enableBandsintown ? (
|
|
<section className="np-info-section">
|
|
<div className="np-info-bandsintown-prompt">
|
|
<div className="np-info-bandsintown-prompt-title">
|
|
<span>{t('nowPlayingInfo.enableBandsintownPrompt', 'See upcoming tour dates?')}</span>
|
|
<span
|
|
className="np-info-bandsintown-prompt-info"
|
|
data-tooltip={t('nowPlayingInfo.enableBandsintownPrivacy', 'When enabled, the current artist\'s name is sent to the Bandsintown API to fetch tour dates. No personal account information leaves your device.')}
|
|
data-tooltip-pos="bottom"
|
|
data-tooltip-wrap="true"
|
|
aria-label={t('nowPlayingInfo.enableBandsintownPrivacy', 'When enabled, the current artist\'s name is sent to the Bandsintown API to fetch tour dates. No personal account information leaves your device.')}
|
|
tabIndex={0}
|
|
>
|
|
<Info size={13} />
|
|
</span>
|
|
</div>
|
|
<div className="np-info-bandsintown-prompt-desc">
|
|
{t('nowPlayingInfo.enableBandsintownPromptDesc', 'Optional. Loads concerts for the current artist via Bandsintown.')}
|
|
</div>
|
|
<button
|
|
type="button"
|
|
className="np-info-bandsintown-prompt-btn"
|
|
onClick={() => setEnableBandsintown(true)}
|
|
>
|
|
{t('nowPlayingInfo.enableBandsintownAction', 'Enable')}
|
|
</button>
|
|
</div>
|
|
</section>
|
|
) : (
|
|
<section className="np-info-section">
|
|
<div className="np-info-section-title">{t('nowPlayingInfo.onTour', 'On tour')}</div>
|
|
{tourLoading && tourEvents.length === 0 && (
|
|
<div className="np-info-tour-empty">{t('nowPlayingInfo.tourLoading', 'Loading…')}</div>
|
|
)}
|
|
{!tourLoading && tourEvents.length === 0 && (
|
|
<div className="np-info-tour-empty">{t('nowPlayingInfo.noTourEvents', 'No upcoming shows')}</div>
|
|
)}
|
|
{visibleTours.length > 0 && (
|
|
<ul className="np-info-tour">
|
|
{visibleTours.map((ev, idx) => {
|
|
const parts = isoToParts(ev.datetime);
|
|
const place = [ev.venueCity, ev.venueRegion, ev.venueCountry]
|
|
.filter(Boolean).join(', ');
|
|
return (
|
|
<li
|
|
key={`${ev.datetime}-${ev.venueName}-${idx}`}
|
|
className="np-info-tour-item"
|
|
onClick={() => ev.url && shellOpen(ev.url).catch(() => {})}
|
|
role={ev.url ? 'button' : undefined}
|
|
tabIndex={ev.url ? 0 : undefined}
|
|
>
|
|
{parts && (
|
|
<div className="np-info-tour-date">
|
|
<div className="np-info-tour-date-month">{parts.month}</div>
|
|
<div className="np-info-tour-date-day">{parts.day}</div>
|
|
</div>
|
|
)}
|
|
<div className="np-info-tour-meta">
|
|
<div className="np-info-tour-venue">{ev.venueName || place}</div>
|
|
<div className="np-info-tour-place">
|
|
{parts && (
|
|
<span className="np-info-tour-when">{parts.weekday}, {parts.time}</span>
|
|
)}
|
|
{parts && place && <span className="np-info-tour-sep"> • </span>}
|
|
<span>{place}</span>
|
|
</div>
|
|
</div>
|
|
</li>
|
|
);
|
|
})}
|
|
</ul>
|
|
)}
|
|
{(hiddenTourCount > 0 || (showAllTours && tourEvents.length > TOUR_LIMIT)) && (
|
|
<button
|
|
type="button"
|
|
className="np-info-tour-more"
|
|
onClick={() => setShowAllTours(v => !v)}
|
|
>
|
|
{showAllTours
|
|
? t('nowPlayingInfo.showLessTours', 'Show less')
|
|
: t('nowPlayingInfo.showMoreTours', { defaultValue: 'Show {{count}} more', count: hiddenTourCount })}
|
|
</button>
|
|
)}
|
|
<div className="np-info-tour-credit">
|
|
{t('nowPlayingInfo.poweredByBandsintown', 'Tour data via Bandsintown')}
|
|
</div>
|
|
</section>
|
|
)}
|
|
</OverlayScrollArea>
|
|
);
|
|
}
|