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, ): Promise { 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 { 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 { 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[] = []; 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 (