Files
psysonic/src/components/BecauseYouLikeRail.tsx
T
Psychotoxical 7c724a642f chore(eslint): add ESLint toolchain and clean src to strict 0/0 (#1165)
* 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>
2026-06-24 01:33:34 +02:00

710 lines
27 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { getArtist, getArtistInfo } from '../api/subsonicArtists';
import { filterAlbumsToActiveLibrary } from '../api/subsonicLibrary';
import { resolveAlbum, resolveMediaServerId } from '../utils/offline/offlineMediaResolve';
import type { SubsonicAlbum } from '../api/subsonicTypes';
import { songToTrack } from '../utils/playback/songToTrack';
import { shuffleArray } from '../utils/playback/shuffleArray';
import React, { memo, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { Play, ListPlus, Music } from 'lucide-react';
import { useAlbumCoverRef } from '../cover/useLibraryCoverRef';
import { useLibraryCoverPrefetch } from '../cover/useLibraryCoverPrefetch';
import { coverImgSrc } from '../cover/imgSrc';
import { useCoverArt } from '../cover/useCoverArt';
import { primeAlbumCoversForDisplay } from '../cover/warmDiskPeek';
import {
readBecauseYouLikeCache,
writeBecauseYouLikeCache,
type BecauseYouLikeAnchor,
} from '../store/becauseYouLikeCache';
import { usePlayerStore } from '../store/playerStore';
import { useAuthStore } from '../store/authStore';
import { playAlbum, playAlbumShuffled } from '../utils/playback/playAlbum';
import { useLongPressAction } from '../hooks/useLongPressAction';
import { LongPressWaveOverlay } from './LongPressWaveOverlay';
import { formatHumanHoursMinutes } from '../utils/format/formatHumanDuration';
import AlbumRow from './AlbumRow';
import { albumArtistDisplayName } from '../utils/album/deriveAlbumHeaderArtistRefs';
const ANCHOR_HISTORY_KEY_PREFIX = 'psysonic_because_anchor_history:';
const PICKS_HISTORY_KEY_PREFIX = 'psysonic_because_picks:';
/** Legacy single-anchor key from the round-robin era. The history-key prefix
* is `..._anchor_history:` so the colon-suffixed legacy prefix below cannot
* match the new keys — safe to strip on module load. */
const LEGACY_ANCHOR_KEY_PREFIX = 'psysonic_because_anchor:';
(() => {
try {
const stale: string[] = [];
for (let i = 0; i < localStorage.length; i++) {
const k = localStorage.key(i);
if (k && k.startsWith(LEGACY_ANCHOR_KEY_PREFIX)) stale.push(k);
}
stale.forEach(k => { try { localStorage.removeItem(k); } catch { /* ignore */ } });
} catch { /* ignore */ }
})();
const TOP_ARTIST_POOL = 20;
const ANCHOR_MAX_TRIES = 4;
const ANCHOR_COOLDOWN = 5;
const SIMILAR_FETCH = 25;
const SIMILAR_PICK = 6;
const SHOW_COUNT = 3;
const PICKS_HISTORY_SIZE = 30;
/** `.because-card-cover-wrap` layout square (160×160). */
const BECAUSE_CARD_COVER_CSS_PX = 160;
const ROW_STAGGER_MS = 150;
// ── Module-level reserve: next batch pre-fetched in background after each display ──
type BecauseReserve = {
serverId: string;
filterVersion: number;
// poolKey intentionally omitted — reserve is valid for any pool state on the
// same server. Pool (top-played artists) changes slowly; showing a slightly-off
// anchor once before the next fill corrects it is far better than showing a
// skeleton because the pool hadn't loaded yet.
anchor: BecauseYouLikeAnchor;
recs: SubsonicAlbum[];
/** Rotation state to commit to localStorage when this reserve is consumed. */
nextAnchorHistory: string[];
nextPicksHistory: string[];
};
let _becauseReserve: BecauseReserve | null = null;
let _becauseReserveFilling = false;
/** Helper: read a JSON string[] from localStorage, returning [] on any failure. */
function readJsonArray(key: string | null): string[] {
if (!key) return [];
try {
const raw = localStorage.getItem(key);
if (!raw) return [];
const parsed = JSON.parse(raw);
return Array.isArray(parsed) ? parsed.filter((v): v is string => typeof v === 'string') : [];
} catch {
return [];
}
}
/** Resolve a set of album picks for one anchor candidate. */
async function resolvePicks(
candidate: BecauseYouLikeAnchor,
recentPicks: Set<string>,
): Promise<SubsonicAlbum[] | null> {
const info = await getArtistInfo(candidate.id, { similarArtistCount: SIMILAR_FETCH });
const similar = (info.similarArtist ?? []).filter(s => s.id);
if (similar.length === 0) return null;
const sampled = shuffleArray(similar).slice(0, SIMILAR_PICK);
const results = await Promise.all(sampled.map(s => getArtist(s.id).catch(() => null)));
const picks: SubsonicAlbum[] = [];
for (const r of results) {
if (!r) continue;
const albums = await filterAlbumsToActiveLibrary(r.albums);
if (albums.length === 0) continue;
const fresh = albums.filter(a => !recentPicks.has(a.id));
const choice = fresh.length > 0 ? fresh : albums;
const album = choice[Math.floor(Math.random() * choice.length)];
picks.push(album);
if (picks.length >= SHOW_COUNT) break;
}
return picks.length > 0 ? picks : null;
}
type FetchBecauseResult = {
anchor: BecauseYouLikeAnchor;
recs: SubsonicAlbum[];
nextAnchorHistory: string[];
nextPicksHistory: string[];
};
/**
* Core fetch: rotate anchor, call Last.fm / Subsonic, return result + updated
* rotation snapshots. Does NOT touch React state or localStorage — callers do that.
* Reads the CURRENT localStorage values so it always reflects the latest rotation.
*/
async function fetchBecauseYouLike(
pool: BecauseYouLikeAnchor[],
anchorHistKey: string | null,
picksHistKey: string | null,
): Promise<FetchBecauseResult | null> {
const anchorHistory = readJsonArray(anchorHistKey);
const picksHistory = readJsonArray(picksHistKey);
const cooldown = Math.min(ANCHOR_COOLDOWN, Math.max(0, Math.floor(pool.length / 2)));
const recentAnchors = new Set(anchorHistory.slice(-cooldown));
const eligibleRaw = pool.filter(a => !recentAnchors.has(a.id));
const eligible = eligibleRaw.length > 0 ? eligibleRaw : pool.slice();
const candidates = shuffleArray(eligible);
const recentPicks = new Set(picksHistory);
const tries = Math.min(ANCHOR_MAX_TRIES, candidates.length);
const tryList = candidates.slice(0, tries);
const buildResult = (candidate: BecauseYouLikeAnchor, picks: SubsonicAlbum[]): FetchBecauseResult => ({
anchor: candidate,
recs: picks,
nextAnchorHistory: [...anchorHistory, candidate.id].slice(-ANCHOR_COOLDOWN),
nextPicksHistory: [...picksHistory, ...picks.map(p => p.id)].slice(-PICKS_HISTORY_SIZE),
});
/** First two shuffled anchors in parallel — cuts cold-start wait on slow Last.fm. */
if (tryList.length >= 2) {
const raced = await Promise.all(
tryList.slice(0, 2).map(async candidate => {
try {
const picks = await resolvePicks(candidate, recentPicks);
return picks ? { candidate, picks } : null;
} catch {
return null;
}
}),
);
const hit = raced.find((r): r is { candidate: BecauseYouLikeAnchor; picks: SubsonicAlbum[] } => r != null);
if (hit) return buildResult(hit.candidate, hit.picks);
}
for (const candidate of tryList) {
try {
const picks = await resolvePicks(candidate, recentPicks);
if (!picks) continue;
return buildResult(candidate, picks);
} catch {
/* try next anchor */
}
}
return null;
}
/**
* Fire-and-forget: fetch the next batch in the background so the next visit is
* instant. localStorage rotation is NOT updated here — the snapshots are stored
* in the reserve and applied only when the reserve is consumed.
* Covers are NOT pre-warmed here (avoids bumpDiskSrcCache side-effects on the
* currently-visible page); they are warmed via primeAlbumCoversForDisplay on consume.
*/
async function fillBecauseReserve(
pool: BecauseYouLikeAnchor[],
serverId: string,
filterVersion: number,
anchorHistKey: string | null,
picksHistKey: string | null,
): Promise<void> {
if (_becauseReserveFilling) return;
_becauseReserveFilling = true;
try {
const result = await fetchBecauseYouLike(pool, anchorHistKey, picksHistKey);
if (result) {
_becauseReserve = { serverId, filterVersion, ...result };
// Also refresh the session snapshot so a quick leave→return can pick up
// newer cards even before the reserve is explicitly consumed.
writeBecauseYouLikeCache({ serverId, filterVersion, anchor: result.anchor, recs: result.recs });
}
} catch {
/* Network failure — next visit falls back to a fresh fetch. */
} finally {
_becauseReserveFilling = false;
}
}
/** One classic because-card shell, then extra grid slots fill in. */
function useBecauseRowSlotCount(active: boolean, max = SHOW_COUNT): number {
const [count, setCount] = useState(1);
useEffect(() => {
if (!active) {
// React Compiler set-state-in-effect rule: state set from a timer/animation callback.
// eslint-disable-next-line react-hooks/set-state-in-effect
setCount(1);
return;
}
setCount(1);
const timers: ReturnType<typeof setTimeout>[] = [];
for (let slot = 2; slot <= max; slot += 1) {
timers.push(setTimeout(() => setCount(slot), ROW_STAGGER_MS * (slot - 1)));
}
return () => timers.forEach(clearTimeout);
}, [active, max]);
return count;
}
/** Lead placeholder — same shell as a loaded because-card (cover + text block). */
function BecauseCardSkeletonLead() {
return (
<div className="because-card because-card--skeleton because-card--skeleton-lead" aria-hidden="true">
<div className="because-card-cover-wrap">
<div className="because-card-cover because-card-cover-placeholder" />
</div>
<div className="because-card-text">
<div className="because-card-top">
<div className="because-card-skeleton-line because-card-skeleton-line--similar" />
<div className="because-card-skeleton-line because-card-skeleton-line--title" />
<div className="because-card-skeleton-line because-card-skeleton-line--artist" />
<div className="because-card-skeleton-line because-card-skeleton-line--meta" />
</div>
</div>
</div>
);
}
/** Extra grid slots — cover tile only, fills in beside the lead card. */
function BecauseCardSkeletonSlot({ enter }: { enter?: boolean }) {
return (
<div
className={`because-card because-card--skeleton because-card--skeleton-slot${
enter ? ' because-card--slot-enter' : ''
}`}
aria-hidden="true"
>
<div className="because-card-cover-wrap">
<div className="because-card-cover because-card-cover-placeholder" />
</div>
</div>
);
}
function BecauseYouLikeSkeleton({ title, slotCount }: { title: string; slotCount: number }) {
return (
<section className="album-row-section because-you-like-rail">
<div className="album-row-header">
<h2 className="section-title" style={{ marginBottom: 0 }}>
{title}
</h2>
</div>
<div className="because-card-grid because-card-grid--stagger">
{slotCount >= 1 ? <BecauseCardSkeletonLead /> : null}
{slotCount >= 2 ? <BecauseCardSkeletonSlot enter /> : null}
{slotCount >= 3 ? <BecauseCardSkeletonSlot enter /> : null}
</div>
</section>
);
}
interface Props {
mostPlayed: SubsonicAlbum[];
recentlyPlayed?: SubsonicAlbum[];
starred?: SubsonicAlbum[];
disableArtwork?: boolean;
}
/** Round-robin merge of multiple album sources, dedup by artistId.
* Cycling sources (most-played, recently-played, starred) means the per-mount
* rotation cursor visits a different listening *mode* each visit instead of
* walking only down the top-played list. */
function buildAnchorPool(sources: SubsonicAlbum[][], limit: number): BecauseYouLikeAnchor[] {
const seen = new Set<string>();
const out: BecauseYouLikeAnchor[] = [];
const maxLen = sources.reduce((m, s) => Math.max(m, s.length), 0);
for (let i = 0; i < maxLen && out.length < limit; i++) {
for (const src of sources) {
if (out.length >= limit) break;
const a = src[i];
if (!a || !a.artistId || seen.has(a.artistId)) continue;
seen.add(a.artistId);
out.push({ id: a.artistId, name: a.artist });
}
}
return out;
}
/** Both rotation memories are **per-server** — server A and server B keep
* independent state, so switching servers doesn't snap the anchor cooldown
* or the recently-shown-album buffer onto the new server's content. */
function anchorHistoryKey(serverId: string | null): string | null {
return serverId ? `${ANCHOR_HISTORY_KEY_PREFIX}${serverId}` : null;
}
function picksHistoryKey(serverId: string | null): string | null {
return serverId ? `${PICKS_HISTORY_KEY_PREFIX}${serverId}` : null;
}
function hasValidReserve(serverId: string | null, filterVersion: number): boolean {
return (
_becauseReserve != null &&
_becauseReserve.serverId === (serverId ?? '') &&
_becauseReserve.filterVersion === filterVersion
);
}
export default function BecauseYouLikeRail({
mostPlayed,
recentlyPlayed,
starred,
disableArtwork = false,
}: Props) {
const { t } = useTranslation();
const activeServerId = useAuthStore(s => s.activeServerId);
const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion);
const pool = useMemo(
() => buildAnchorPool([mostPlayed, recentlyPlayed ?? [], starred ?? []], TOP_ARTIST_POOL),
[mostPlayed, recentlyPlayed, starred],
);
const poolKey = useMemo(
() => pool.slice(0, 8).map(a => a.id).join('\u0001'),
[pool],
);
// Initialise state in priority order: reserve (new batch) > session cache (stale-while-
// revalidate) > skeleton. Both checks work without poolKey so they fire correctly on the
// first render when pool is still [] (Home.tsx loads mostPlayed asynchronously).
const [anchor, setAnchor] = useState<BecauseYouLikeAnchor | null>(() => {
if (hasValidReserve(activeServerId, musicLibraryFilterVersion)) return _becauseReserve!.anchor;
return readBecauseYouLikeCache(activeServerId, musicLibraryFilterVersion)?.anchor ?? null;
});
const [recs, setRecs] = useState<SubsonicAlbum[]>(() => {
if (hasValidReserve(activeServerId, musicLibraryFilterVersion)) return _becauseReserve!.recs;
return readBecauseYouLikeCache(activeServerId, musicLibraryFilterVersion)?.recs ?? [];
});
const containerRef = useRef<HTMLDivElement>(null);
const [narrow, setNarrow] = useState(false);
const [refreshing, setRefreshing] = useState(() => {
if (hasValidReserve(activeServerId, musicLibraryFilterVersion)) return false;
const snap = readBecauseYouLikeCache(activeServerId, musicLibraryFilterVersion);
return !snap || snap.recs.length === 0;
});
const skeletonSlots = useBecauseRowSlotCount(refreshing, SHOW_COUNT);
const contentReady = !refreshing && Boolean(anchor) && recs.length > 0;
const contentSlots = contentReady ? recs.length : 1;
/** On every navigation / server / pool change: apply reserve immediately
* (synchronous, before browser paint) or fall back to session cache (stale-
* while-revalidate), only clearing to skeleton when nothing is available. */
useLayoutEffect(() => {
if (hasValidReserve(activeServerId, musicLibraryFilterVersion)) {
// React Compiler set-state-in-effect rule: local state synced with store/prop inputs when the effects dependencies change.
// eslint-disable-next-line react-hooks/set-state-in-effect
setAnchor(_becauseReserve!.anchor);
setRecs(_becauseReserve!.recs);
setRefreshing(false);
} else {
const snap = readBecauseYouLikeCache(activeServerId, musicLibraryFilterVersion);
if (snap && snap.recs.length > 0) {
setAnchor(snap.anchor);
setRecs(snap.recs);
setRefreshing(false);
} else {
setRefreshing(true);
setAnchor(null);
setRecs([]);
}
}
}, [activeServerId, musicLibraryFilterVersion, poolKey]);
// 696px ≙ exactly 2 BecauseCards side-by-side (2*340 + 16 gap). Below that
// the hero-style cards stretch full-width and dwarf the rest of the page,
// so we swap in a standard AlbumRow which is already perf-tuned for narrow
// rails (artwork budget, viewport windowing, scroll-paging).
useLayoutEffect(() => {
const el = containerRef.current;
if (!el) return;
setNarrow(el.getBoundingClientRect().width < 696);
const ro = new ResizeObserver(entries => {
for (const entry of entries) {
setNarrow(entry.contentRect.width < 696);
}
});
ro.observe(el);
return () => ro.disconnect();
}, []);
useEffect(() => {
let cancelled = false;
if (pool.length === 0) {
// Pool is still being loaded (Home.tsx fetches data asynchronously). Do not
// run the fetch/reserve logic yet — useLayoutEffect already shows reserve or
// cache content. The effect will re-run once pool is populated.
return;
}
if (!activeServerId) {
// React Compiler set-state-in-effect rule: local state synced with store/prop inputs when the effects dependencies change.
// eslint-disable-next-line react-hooks/set-state-in-effect
setAnchor(null);
setRecs([]);
setRefreshing(false);
return;
}
const anchorHistKey = anchorHistoryKey(activeServerId);
const picksHistKey = picksHistoryKey(activeServerId);
const snap = readBecauseYouLikeCache(activeServerId, musicLibraryFilterVersion);
// Consume module-level reserve (keyed by server + library scope).
const reserved = hasValidReserve(activeServerId, musicLibraryFilterVersion) ? _becauseReserve : null;
_becauseReserve = null;
(async () => {
if (reserved) {
// ── Reserve path: instant display, no network ──────────────────────
await primeAlbumCoversForDisplay(reserved.recs, BECAUSE_CARD_COVER_CSS_PX, {
limit: SHOW_COUNT,
disabled: disableArtwork,
});
if (cancelled) return;
// Advance rotation in localStorage now that these picks are being shown.
try {
if (anchorHistKey) localStorage.setItem(anchorHistKey, JSON.stringify(reserved.nextAnchorHistory));
if (picksHistKey) localStorage.setItem(picksHistKey, JSON.stringify(reserved.nextPicksHistory));
} catch { /* ignore */ }
setAnchor(reserved.anchor);
setRecs(reserved.recs);
if (activeServerId) {
writeBecauseYouLikeCache({
serverId: activeServerId,
filterVersion: musicLibraryFilterVersion,
anchor: reserved.anchor,
recs: reserved.recs,
});
}
setRefreshing(false);
// Pre-fetch the next batch so the next visit is also instant.
void fillBecauseReserve(pool, activeServerId, musicLibraryFilterVersion, anchorHistKey, picksHistKey);
return;
}
// Keep visible cards stable on return visits: if we already have a valid
// session snapshot, leave it on screen and only prefetch the next batch
// for the next mount instead of swapping cards mid-visit.
if (snap && snap.recs.length > 0) {
setRefreshing(false);
void fillBecauseReserve(pool, activeServerId, musicLibraryFilterVersion, anchorHistKey, picksHistKey);
return;
}
// ── Full-fetch path (first visit or reserve miss) ──────────────────
// Only clear to skeleton if nothing is currently displayed. When cached
// content is visible, leave it in place and swap silently (stale-while-
// revalidate) — better UX than flashing a skeleton for a network round-trip.
if (!snap || snap.recs.length === 0) {
setRefreshing(true);
setAnchor(null);
setRecs([]);
}
const result = await fetchBecauseYouLike(pool, anchorHistKey, picksHistKey);
if (cancelled) return;
if (result) {
await primeAlbumCoversForDisplay(result.recs, BECAUSE_CARD_COVER_CSS_PX, {
limit: SHOW_COUNT,
disabled: disableArtwork,
});
if (cancelled) return;
try {
if (anchorHistKey) localStorage.setItem(anchorHistKey, JSON.stringify(result.nextAnchorHistory));
if (picksHistKey) localStorage.setItem(picksHistKey, JSON.stringify(result.nextPicksHistory));
} catch { /* ignore */ }
setAnchor(result.anchor);
setRecs(result.recs);
if (activeServerId) {
writeBecauseYouLikeCache({
serverId: activeServerId,
filterVersion: musicLibraryFilterVersion,
anchor: result.anchor,
recs: result.recs,
});
}
setRefreshing(false);
// Pre-fetch next batch so the next visit is instant.
void fillBecauseReserve(pool, activeServerId, musicLibraryFilterVersion, anchorHistKey, picksHistKey);
} else {
// Network failed — restore session cache if available.
if (snap) {
await primeAlbumCoversForDisplay(snap.recs, BECAUSE_CARD_COVER_CSS_PX, {
limit: SHOW_COUNT,
disabled: disableArtwork,
});
if (cancelled) return;
setAnchor(snap.anchor);
setRecs(snap.recs);
} else if (!cancelled) {
setAnchor(null);
setRecs([]);
}
if (!cancelled) setRefreshing(false);
}
})();
return () => { cancelled = true; };
// Gate on poolKey (the stable top-anchor identity), not the `pool` array ref.
// `pool` is rebuilt whenever Home's mostPlayed changes, so loading more Most
// Played albums (which feeds this pool) would otherwise re-run this effect and
// swap the cards — a height blip above the row that scroll anchoring turns into
// an upward viewport jump. The sibling reserve effect already keys on poolKey.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [poolKey, activeServerId, musicLibraryFilterVersion, disableArtwork]);
useLibraryCoverPrefetch(
disableArtwork || recs.length === 0 ? [] : [{ albums: recs, priority: 'high' }],
[recs, disableArtwork],
);
if (pool.length === 0) {
return <div ref={containerRef} />;
}
if (refreshing || !anchor || recs.length === 0) {
if (!refreshing && (!anchor || recs.length === 0)) {
return <div ref={containerRef} />;
}
return (
<div ref={containerRef}>
<BecauseYouLikeSkeleton title={t('home.becauseYouLike')} slotCount={skeletonSlots} />
</div>
);
}
const sectionTitle = t('home.becauseYouLikeFor', { artist: anchor.name });
return (
<div ref={containerRef}>
{narrow ? (
<AlbumRow title={sectionTitle} albums={recs} disableArtwork={disableArtwork} />
) : (
<section className="album-row-section because-you-like-rail">
<div className="album-row-header">
<h2 className="section-title" style={{ marginBottom: 0 }}>
{sectionTitle}
</h2>
</div>
<div className="because-card-grid because-card-grid--stagger">
{recs.slice(0, contentSlots).map((album, index) => (
<BecauseCard
key={album.id}
album={album}
anchor={anchor.name}
disableArtwork={disableArtwork}
enter={index > 0}
/>
))}
</div>
</section>
)}
</div>
);
}
interface CardProps {
album: SubsonicAlbum;
anchor: string;
disableArtwork: boolean;
enter?: boolean;
}
const BecauseCard = memo(function BecauseCard({ album, anchor, disableArtwork, enter }: CardProps) {
const { t } = useTranslation();
const { isHolding, pressBind } = useLongPressAction({
onShortPress: () => playAlbum(album.id),
onLongPress: () => playAlbumShuffled(album.id),
});
const navigate = useNavigate();
const enqueue = usePlayerStore(s => s.enqueue);
const coverRef = useAlbumCoverRef(album.id, album.coverArt, undefined, { libraryResolve: false });
const coverHandle = useCoverArt(coverRef, BECAUSE_CARD_COVER_CSS_PX, {
surface: 'dense',
ensurePriority: 'high',
});
const imgSrc = coverImgSrc(coverHandle.src);
const bgResolved = coverHandle.src;
const artistLabel = useMemo(() => albumArtistDisplayName(album), [album]);
const handleOpen = () => navigate(`/album/${album.id}`);
const handleEnqueue = async (e: React.MouseEvent) => {
e.stopPropagation();
try {
const serverId = resolveMediaServerId(album.serverId);
if (!serverId) return;
const data = await resolveAlbum(serverId, album.id);
if (!data) return;
enqueue(data.songs.map(songToTrack));
} catch {
/* silent — toast would be too noisy for a hover action */
}
};
return (
<div
role="button"
tabIndex={0}
className={`because-card${enter ? ' because-card--slot-enter' : ''}`}
onClick={handleOpen}
onKeyDown={e => { if (e.key === 'Enter') handleOpen(); }}
aria-label={`${album.name} ${artistLabel}`}
>
{!disableArtwork && bgResolved && (
<div
className="because-card-bg"
style={{ backgroundImage: `url(${bgResolved})` }}
aria-hidden="true"
/>
)}
<div className="because-card-cover-wrap">
{!disableArtwork && album.coverArt ? (
imgSrc ? (
<img
src={imgSrc}
alt={album.name}
className="because-card-cover"
loading="eager"
decoding="sync"
onError={coverHandle.onImgError}
/>
) : (
<div
className="because-card-cover because-card-cover-placeholder because-card-cover-loading"
aria-hidden="true"
/>
)
) : (
<div className="because-card-cover because-card-cover-placeholder" aria-hidden="true">
<Music size={42} strokeWidth={1.5} />
</div>
)}
<div className="album-card-play-overlay">
<button
type="button"
className="album-card-details-btn long-press-play-btn"
{...pressBind}
aria-label={t('hero.playAlbum')}
data-tooltip={t('hero.playAlbumTooltip')}
data-tooltip-pos="top"
>
<LongPressWaveOverlay active={isHolding} size="compact" />
<span className="long-press-play-btn__icon">
<Play size={15} fill="currentColor" />
</span>
</button>
<button
type="button"
className="album-card-details-btn"
onClick={handleEnqueue}
aria-label={t('contextMenu.enqueueAlbum')}
data-tooltip={t('contextMenu.enqueueAlbum')}
data-tooltip-pos="top"
>
<ListPlus size={15} />
</button>
</div>
</div>
<div className="because-card-text">
<div className="because-card-top">
<div className="because-card-similar">
{t('home.similarTo', { artist: anchor })}
</div>
<div className="because-card-title">{album.name}</div>
<div className="because-card-artist">{artistLabel}</div>
</div>
{album.releaseTypes && album.releaseTypes[0] ? (
<div className="because-card-pills">
<span className="because-card-pill because-card-pill-type">{album.releaseTypes[0]}</span>
</div>
) : null}
<div className="because-card-meta">
{album.year ? <span>{album.year}</span> : null}
{album.songCount ? <span>{t('home.becauseYouLikeTracks', { count: album.songCount })}</span> : null}
{album.duration ? <span>{formatHumanHoursMinutes(album.duration)}</span> : null}
</div>
</div>
</div>
);
});