mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-21 14:05:41 +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>
710 lines
27 KiB
TypeScript
710 lines
27 KiB
TypeScript
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 effect’s 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 effect’s 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>
|
||
);
|
||
});
|