refactor(home): co-locate Mainstage/home feature into features/home

This commit is contained in:
Psychotoxical
2026-06-30 18:12:27 +02:00
parent f05183d412
commit 2f4303ecc8
13 changed files with 49 additions and 38 deletions
@@ -0,0 +1,709 @@
import { getArtist, getArtistInfo } from '@/lib/api/subsonicArtists';
import { filterAlbumsToActiveLibrary } from '@/lib/api/subsonicLibrary';
import { resolveAlbum, resolveMediaServerId } from '@/features/offline';
import type { SubsonicAlbum } from '@/lib/api/subsonicTypes';
import { songToTrack } from '@/lib/media/songToTrack';
import { shuffleArray } from '@/lib/util/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 '@/features/home/store/becauseYouLikeCache';
import { usePlayerStore } from '@/features/playback/store/playerStore';
import { useAuthStore } from '@/store/authStore';
import { playAlbum, playAlbumShuffled } from '@/features/playback/utils/playback/playAlbum';
import { useLongPressAction } from '@/lib/hooks/useLongPressAction';
import { LongPressWaveOverlay } from '@/components/LongPressWaveOverlay';
import { formatHumanHoursMinutes } from '@/lib/format/formatHumanDuration';
import { AlbumRow } from '@/features/album';
import { albumArtistDisplayName } from '@/features/album';
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>
);
});
+525
View File
@@ -0,0 +1,525 @@
import { getRandomAlbums } from '@/lib/api/subsonicLibrary';
import { resolveAlbum, resolveMediaServerId } from '@/features/offline';
import type { SubsonicAlbum } from '@/lib/api/subsonicTypes';
import { songToTrack } from '@/lib/media/songToTrack';
import React, { useEffect, useState, useRef, useCallback, useMemo } from 'react';
import { useNavigateToAlbum } from '@/features/album';
import { Play, ListPlus, ChevronLeft, ChevronRight } from 'lucide-react';
import { CoverArtImage } from '@/cover/CoverArtImage';
import { useAlbumCoverRef } from '@/cover/useLibraryCoverRef';
import { useArtistBanner, useArtistFanart } from '@/cover/useArtistFanart';
import { useCoverArt } from '@/cover/useCoverArt';
import { useHeroBackdrop } from '@/cover/useHeroBackdrop';
import { useCachedUrl } from '@/ui/CachedImage';
import { usePlayerStore } from '@/features/playback/store/playerStore';
import { useTranslation } from 'react-i18next';
import { useIsMobile } from '@/lib/hooks/useIsMobile';
import { useWindowVisibility } from '@/lib/hooks/useWindowVisibility';
import { useAuthStore } from '@/store/authStore';
import { useThemeStore } from '@/store/themeStore';
import { filterAlbumsByMixRatings, getMixMinRatingsConfigFromAuth } from '@/utils/mix/mixRatingFilter';
import { usePerfProbeFlags } from '@/utils/perf/perfFlags';
import { playAlbum, playAlbumShuffled } from '@/features/playback/utils/playback/playAlbum';
import { useLongPressAction } from '@/lib/hooks/useLongPressAction';
import { LongPressWaveOverlay } from '@/components/LongPressWaveOverlay';
import { albumArtistDisplayName, deriveAlbumArtistRefs } from '@/features/album';
const INTERVAL_MS = 10000;
const HERO_ALBUM_COUNT = 8;
/** Larger pool when mix rating filter is on so we can still fill the hero strip. */
const HERO_RANDOM_POOL = 32;
/** Hero foreground cover (`.hero-cover` 220×220). */
const HERO_FG_CSS_PX = 220;
/** Hero blurred backdrop (full banner height). */
const HERO_BG_CSS_PX = 360;
// Crossfading background — same layer pattern as FullscreenPlayer. Each layer
// carries its own `position` (the banner stays centered, portrait-ish fanart /
// artist covers raise the focal point), so a crossfade never re-frames the
// outgoing image. `position` is keyed off `url` (it only changes when the url
// changes) so the effect dep stays `[url]`.
function HeroBg({ url, position }: { url: string; position?: string }) {
const [layers, setLayers] = useState<
Array<{ url: string; position?: string; id: number; visible: boolean }>
>(() => (url ? [{ url, position, id: 0, visible: true }] : []));
const counter = useRef(url ? 1 : 0);
const latestUrlRef = useRef(url);
// React Compiler refs rule: ref kept in sync with the latest value for use in effects/handlers/cleanup; not render data.
// eslint-disable-next-line react-hooks/refs
latestUrlRef.current = url;
useEffect(() => {
if (!url) {
// React Compiler set-state-in-effect rule: state set from a timer/animation callback.
// eslint-disable-next-line react-hooks/set-state-in-effect
setLayers([]);
return;
}
const id = counter.current++;
setLayers(prev => [...prev, { url, position, id, visible: false }]);
let revealed = false;
let cleanup: ReturnType<typeof setTimeout> | undefined;
const reveal = () => {
if (revealed || latestUrlRef.current !== url) return;
revealed = true;
// Crossfade this layer in; the others fade out, then get dropped.
setLayers(prev => prev.map(l => ({ ...l, visible: l.id === id })));
cleanup = setTimeout(() => {
if (latestUrlRef.current !== url) return;
setLayers(prev => prev.filter(l => l.id === id));
}, 900);
};
// Reveal only once the bytes are decoded, so the crossfade never fades in a
// blank / half-loaded image (the flicker the bare 20 ms timer had). The
// preload + scheduling happen exactly once here per url — no per-render
// <img> ref / onLoad, so this can't stack updates like the reverted attempt.
const pre = new Image();
pre.decoding = 'async';
pre.src = url;
let fallback: ReturnType<typeof setTimeout> | undefined;
if (pre.complete && pre.naturalWidth > 0) {
reveal();
} else {
pre.onload = reveal;
pre.onerror = reveal;
fallback = setTimeout(reveal, 1500);
}
return () => {
if (fallback) clearTimeout(fallback);
if (cleanup) clearTimeout(cleanup);
pre.onload = null;
pre.onerror = null;
};
// `position` is intentionally omitted — it tracks `url` 1:1, and adding it
// would spawn a duplicate layer if it ever changed without the url.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [url]);
return (
<>
{layers.map(layer => (
<img
key={layer.id}
className="hero-bg-image"
src={layer.url}
style={{ opacity: layer.visible ? 1 : 0, objectPosition: layer.position }}
aria-hidden="true"
alt=""
loading="eager"
decoding="sync"
draggable={false}
/>
))}
</>
);
}
interface HeroProps {
albums?: SubsonicAlbum[];
}
export default function Hero({ albums: albumsProp }: HeroProps = {}) {
const perfFlags = usePerfProbeFlags();
const { t } = useTranslation();
const navigateToAlbum = useNavigateToAlbum();
const isMobile = useIsMobile();
const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion);
const mixMinRatingFilterEnabled = useAuthStore(s => s.mixMinRatingFilterEnabled);
const mainstageBackdrop = useThemeStore(s => s.backdrops.mainstageHero);
const mixMinRatingAlbum = useAuthStore(s => s.mixMinRatingAlbum);
const mixMinRatingArtist = useAuthStore(s => s.mixMinRatingArtist);
const [albums, setAlbums] = useState<SubsonicAlbum[]>(() =>
albumsProp?.length ? albumsProp : [],
);
const [activeIdx, setActiveIdx] = useState(0);
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
const windowHidden = useWindowVisibility();
const [windowBlurred, setWindowBlurred] = useState<boolean>(() => Boolean(window.__psyBlurred));
const heroRef = useRef<HTMLDivElement | null>(null);
const heroScrollRootRef = useRef<HTMLElement | null>(null);
const visibilityRafRef = useRef<number | null>(null);
const [heroInView, setHeroInView] = useState(true);
const heroInViewRef = useRef(true);
// React Compiler refs rule: ref kept in sync with the latest value for use in effects/handlers/cleanup; not render data.
// eslint-disable-next-line react-hooks/refs
heroInViewRef.current = heroInView;
const computeHeroVisibleNow = useCallback((): boolean => {
const node = heroRef.current;
if (!node) return false;
const rect = node.getBoundingClientRect();
if (rect.height <= 0 || rect.width <= 0) {
return false;
}
const root = heroScrollRootRef.current;
const viewportTop = root ? root.getBoundingClientRect().top : 0;
const viewportBottom = root ? root.getBoundingClientRect().bottom : window.innerHeight;
const overlap = Math.max(0, Math.min(rect.bottom, viewportBottom) - Math.max(rect.top, viewportTop));
// Consider hero visible only when at least a meaningful slice is on screen.
const minVisiblePx = Math.min(56, rect.height * 0.2);
return overlap >= minVisiblePx;
}, []);
const updateHeroVisibility = useCallback(() => {
const visible = computeHeroVisibleNow();
setHeroInView(prev => (prev === visible ? prev : visible));
}, [computeHeroVisibleNow]);
useEffect(() => {
const node = heroRef.current;
if (!node) return;
// Prefer the nearest actual scrolling ancestor; class fallback for safety.
let scrollRoot: HTMLElement | null = null;
let parent = node.parentElement;
while (parent) {
const styles = window.getComputedStyle(parent);
const overflowY = styles.overflowY;
if ((overflowY === 'auto' || overflowY === 'scroll') && parent.scrollHeight > parent.clientHeight + 2) {
scrollRoot = parent;
break;
}
parent = parent.parentElement;
}
heroScrollRootRef.current =
scrollRoot ?? (node.closest('.app-shell-route-scroll__viewport') as HTMLElement | null);
updateHeroVisibility();
// Layout may settle after first paint (hero mounts after albums hydrate from props).
const layoutRaf = window.requestAnimationFrame(() => updateHeroVisibility());
const root = heroScrollRootRef.current;
const onScroll = () => {
if (visibilityRafRef.current != null) return;
visibilityRafRef.current = window.requestAnimationFrame(() => {
visibilityRafRef.current = null;
updateHeroVisibility();
});
};
const onResize = () => updateHeroVisibility();
const onFocusLike = () => updateHeroVisibility();
root?.addEventListener('scroll', onScroll, { passive: true });
window.addEventListener('resize', onResize);
window.addEventListener('focus', onFocusLike);
document.addEventListener('visibilitychange', onFocusLike);
return () => {
root?.removeEventListener('scroll', onScroll);
window.removeEventListener('resize', onResize);
window.removeEventListener('focus', onFocusLike);
document.removeEventListener('visibilitychange', onFocusLike);
if (visibilityRafRef.current != null) {
window.cancelAnimationFrame(visibilityRafRef.current);
visibilityRafRef.current = null;
}
window.cancelAnimationFrame(layoutRaf);
};
}, [updateHeroVisibility, albums.length]);
useEffect(() => {
const updateBlurState = () => {
setWindowBlurred(Boolean(window.__psyBlurred));
};
window.addEventListener('focus', updateBlurState);
window.addEventListener('blur', updateBlurState);
updateBlurState();
return () => {
window.removeEventListener('focus', updateBlurState);
window.removeEventListener('blur', updateBlurState);
};
}, []);
useEffect(() => {
if (heroInView || windowHidden) return;
// Recovery guard: if a scroll/RAF event was missed while hero was outside
// viewport, keep checking briefly so autoplay/background resume immediately
// after returning into view.
const id = window.setInterval(() => {
updateHeroVisibility();
}, 220);
return () => window.clearInterval(id);
}, [heroInView, windowHidden, updateHeroVisibility]);
useEffect(() => {
// React Compiler set-state-in-effect rule: state set from a timer/animation callback.
// eslint-disable-next-line react-hooks/set-state-in-effect
if (albumsProp?.length) { setAlbums(albumsProp); return; }
const cfg = { ...getMixMinRatingsConfigFromAuth(), minSong: 0 };
const albumMix = cfg.enabled && (cfg.minAlbum > 0 || cfg.minArtist > 0);
const pool = albumMix ? HERO_RANDOM_POOL : HERO_ALBUM_COUNT;
getRandomAlbums(pool)
.then(async raw => {
const list = albumMix
? (await filterAlbumsByMixRatings(raw, cfg)).slice(0, HERO_ALBUM_COUNT)
: raw;
setAlbums(list);
})
.catch(() => {});
}, [
albumsProp,
musicLibraryFilterVersion,
mixMinRatingFilterEnabled,
mixMinRatingAlbum,
mixMinRatingArtist,
]);
// Start / restart auto-advance timer (paused while the Tauri window is hidden).
const startTimer = useCallback((len: number) => {
if (timerRef.current) clearInterval(timerRef.current);
timerRef.current = null;
if (len <= 1 || windowHidden || windowBlurred || !heroInViewRef.current || !computeHeroVisibleNow()) return;
timerRef.current = setInterval(() => {
const visibleNow = computeHeroVisibleNow();
if (!visibleNow && heroInViewRef.current) setHeroInView(false);
if (document.hidden || window.__psyHidden || window.__psyBlurred || !heroInViewRef.current || !visibleNow) {
if (timerRef.current) clearInterval(timerRef.current);
timerRef.current = null;
return;
}
setActiveIdx(prev => (prev + 1) % len);
}, INTERVAL_MS);
}, [windowHidden, windowBlurred, computeHeroVisibleNow]);
useEffect(() => {
// Hard-stop timer immediately when hero leaves viewport.
if (heroInView && !windowBlurred) return;
if (timerRef.current) {
clearInterval(timerRef.current);
timerRef.current = null;
}
}, [heroInView, windowBlurred]);
useEffect(() => {
startTimer(albums.length);
return () => {
if (timerRef.current) clearInterval(timerRef.current);
timerRef.current = null;
};
}, [albums.length, heroInView, startTimer]);
const goPrev = useCallback(() => {
const len = albums.length;
if (len <= 1) return;
setActiveIdx(prev => (prev - 1 + len) % len);
startTimer(len);
}, [albums.length, startTimer]);
const goNext = useCallback(() => {
const len = albums.length;
if (len <= 1) return;
setActiveIdx(prev => (prev + 1) % len);
startTimer(len);
}, [albums.length, startTimer]);
const album = albums[activeIdx] ?? null;
const heroArtistLabel = useMemo(
() => (album ? albumArtistDisplayName(album) : ''),
[album],
);
// Lazily fetch format label for the currently-visible album (cached by id)
const [albumFormats, setAlbumFormats] = useState<Record<string, string>>({});
useEffect(() => {
if (!album || albumFormats[album.id] !== undefined) return;
const serverId = resolveMediaServerId(album.serverId);
if (!serverId) return;
resolveAlbum(serverId, album.id).then(data => {
if (!data) {
setAlbumFormats(prev => ({ ...prev, [album.id]: '' }));
return;
}
const fmts = [...new Set(data.songs.map(s => s.suffix).filter((f): f is string => !!f))];
setAlbumFormats(prev => ({ ...prev, [album.id]: fmts.map(f => f.toUpperCase()).join(' / ') }));
}).catch(() => {
setAlbumFormats(prev => ({ ...prev, [album.id]: '' }));
});
// Intentionally keyed on album?.id only: the format label is fetched once per
// album id and cached in albumFormats. Depending on the album object or the
// albumFormats map would re-run on every render / cache write.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [album?.id]);
const heroCoverRef = useAlbumCoverRef(album?.id, album?.coverArt);
const albumId = album?.id;
// Mainstage hero backdrop — the album artist's fanart (banner → 16:9 fanart),
// but its LAST fallback is the album's own Navidrome cover, not the artist
// image: the hero frames an album, so its base layer stays the album cover
// (the same backdrop shown when the feature is off). The artist-detail header
// keeps the artist cover as its last fallback — that surface frames an artist.
// Fed entirely from the album already in hand (artist id + name + album title),
// so there is no getArtist/getAlbum round-trip: the MBID lookup + fanart fetch
// live Rust-side in cover_cache.
const heroArtist = useMemo(
() => (album ? deriveAlbumArtistRefs(album)[0] : undefined),
[album],
);
const heroArtistId = heroArtist?.id;
const heroBanner = useArtistBanner(heroArtistId, {
artistName: heroArtist?.name,
albumTitle: album?.name,
});
const heroFanart = useArtistFanart(heroArtistId, {
artistName: heroArtist?.name,
albumTitle: album?.name,
});
// Last-fallback layer: the album's own Navidrome cover (HERO_BG_CSS_PX, full
// res), resolved scope-true from the album cover ref already in hand.
const ndAlbum = useCoverArt(heroCoverRef, HERO_BG_CSS_PX, { surface: 'sparse', fullRes: true });
const ndAlbumUrl = useCachedUrl(ndAlbum.src, ndAlbum.cacheKey, true);
const heroBackdrop = useHeroBackdrop(
mainstageBackdrop.sources,
{ banner: heroBanner, fanart: heroFanart, navidrome: ndAlbumUrl },
albumId,
);
const showHeroBackdrop =
mainstageBackdrop.enabled &&
!perfFlags.disableMainstageHeroBackdrop &&
heroInView;
const { isHolding, pressBind } = useLongPressAction({
onShortPress: () => { if (albumId) playAlbum(albumId); },
onLongPress: () => { if (albumId) playAlbumShuffled(albumId); },
});
if (!album) return <div className="hero-placeholder" />;
return (
<div
ref={heroRef}
className="hero"
role="banner"
aria-label={t('hero.eyebrow')}
onClick={() => navigateToAlbum(album.id)}
style={{ cursor: 'pointer' }}
>
{showHeroBackdrop && <HeroBg url={heroBackdrop.url} position={heroBackdrop.position} />}
{showHeroBackdrop && <div className="hero-overlay" aria-hidden="true" />}
{/* key causes re-mount → animate-fade-in triggers on each album change */}
<div className="hero-content" key={album.id}>
{heroCoverRef && !isMobile && (
<CoverArtImage
coverRef={heroCoverRef}
displayCssPx={HERO_FG_CSS_PX}
surface="dense"
ensurePriority="high"
className="hero-cover"
alt={`${album.name} Cover`}
/>
)}
<div className="hero-text">
<span className="hero-eyebrow">{t('hero.eyebrow')}</span>
<h2 className="hero-title">{album.name}</h2>
<p className="hero-artist">{heroArtistLabel}</p>
<div className="hero-meta">
{album.year && <span className="badge">{album.year}</span>}
{album.genre && <span className="badge">{album.genre}</span>}
{!isMobile && album.songCount && <span className="badge">{album.songCount} Tracks</span>}
{!isMobile && albumFormats[album.id] && <span className="badge">{albumFormats[album.id]}</span>}
</div>
{isMobile ? (
<div className="hero-actions-mobile" onClick={e => e.stopPropagation()}>
<button
className="album-icon-btn album-icon-btn--play long-press-play-btn"
{...pressBind}
aria-label={`${t('hero.playAlbum')} ${album.name}`}
data-tooltip={t('hero.playAlbumTooltip')}
>
<LongPressWaveOverlay active={isHolding} size="compact" />
<span className="long-press-play-btn__icon">
<Play size={22} fill="currentColor" />
</span>
</button>
<button
className="album-icon-btn album-icon-btn--queue"
onClick={async e => {
e.stopPropagation();
try {
const serverId = resolveMediaServerId(album.serverId);
if (!serverId) return;
const albumData = await resolveAlbum(serverId, album.id);
if (!albumData) return;
usePlayerStore.getState().enqueue(albumData.songs.map(songToTrack));
} catch (_) { /* ignore: best-effort */ }
}}
aria-label={t('hero.enqueue')}
data-tooltip={t('hero.enqueueTooltip')}
>
<ListPlus size={20} />
</button>
</div>
) : (
<div style={{ display: 'flex', gap: '1rem', flexWrap: 'wrap' }}>
<button
className="hero-play-btn long-press-play-btn"
id="hero-play-btn"
{...pressBind}
aria-label={`${t('hero.playAlbum')} ${album.name}`}
data-tooltip={t('hero.playAlbumTooltip')}
>
<LongPressWaveOverlay active={isHolding} />
<span className="long-press-play-btn__icon" style={{ gap: '8px' }}>
<Play size={18} fill="currentColor" />
{t('hero.playAlbum')}
</span>
</button>
<button
className="btn btn-surface"
onClick={async (e) => {
e.stopPropagation();
try {
const serverId = resolveMediaServerId(album.serverId);
if (!serverId) return;
const albumData = await resolveAlbum(serverId, album.id);
if (!albumData) return;
const tracks = albumData.songs.map(songToTrack);
usePlayerStore.getState().enqueue(tracks);
} catch (_) { /* ignore: best-effort */ }
}}
style={{ padding: '0 1.5rem', fontWeight: 600, fontSize: '0.95rem' }}
data-tooltip={t('hero.enqueueTooltip')}
>
<ListPlus size={18} />
{t('hero.enqueue')}
</button>
</div>
)}
</div>
</div>
{/* Carousel navigation arrows + decorative dot indicators */}
{albums.length > 1 && (
<>
<div className="hero-nav" aria-hidden="false">
<button
type="button"
className="hero-nav-arrow hero-nav-arrow--left"
onClick={e => { e.stopPropagation(); goPrev(); }}
aria-label={t('hero.previousAlbum')}
data-tooltip={t('hero.previousAlbum')}
>
<ChevronLeft size={24} />
</button>
<button
type="button"
className="hero-nav-arrow hero-nav-arrow--right"
onClick={e => { e.stopPropagation(); goNext(); }}
aria-label={t('hero.nextAlbum')}
data-tooltip={t('hero.nextAlbum')}
>
<ChevronRight size={24} />
</button>
</div>
<div className="hero-dots" aria-hidden="true">
{albums.map((_, i) => (
<span
key={i}
className={`hero-dot${i === activeIdx ? ' hero-dot-active' : ''}`}
/>
))}
</div>
</>
)}
</div>
);
}
+168
View File
@@ -0,0 +1,168 @@
import type { SubsonicSong } from '@/lib/api/subsonicTypes';
import React, { useRef, useState, useEffect, useMemo } from 'react';
import { ChevronLeft, ChevronRight, RefreshCw } from 'lucide-react';
import SongCard from '@/components/SongCard';
import { usePerfProbeFlags } from '@/utils/perf/perfFlags';
import { dedupeById } from '@/lib/util/dedupeById';
interface Props {
title: string;
songs: SubsonicSong[];
/** Called when user clicks the reroll button (visible only if provided). */
onReroll?: () => void | Promise<void>;
/** Loading state — disables reroll, optional shimmer */
loading?: boolean;
/** Empty-state copy when songs is empty AND not loading. */
emptyText?: string;
disableArtwork?: boolean;
disableInteractivity?: boolean;
artworkSize?: number;
windowArtworkByViewport?: boolean;
initialArtworkBudget?: number;
}
export default function SongRail({
title,
songs,
onReroll,
loading,
emptyText,
disableArtwork = false,
disableInteractivity = false,
artworkSize,
windowArtworkByViewport = false,
initialArtworkBudget = 10,
}: Props) {
const perfFlags = usePerfProbeFlags();
const artworkDisabled = perfFlags.disableMainstageRailArtwork || disableArtwork;
const interactivityDisabled = perfFlags.disableMainstageRailInteractivity || disableInteractivity;
const scrollRef = useRef<HTMLDivElement>(null);
const uniqueSongs = useMemo(() => dedupeById(songs), [songs]);
const [showLeft, setShowLeft] = useState(false);
const [showRight, setShowRight] = useState(true);
const [artworkBudget, setArtworkBudget] = useState(initialArtworkBudget);
const recomputeArtworkBudget = () => {
if (!windowArtworkByViewport) return;
const el = scrollRef.current;
if (!el) return;
const { scrollLeft, clientWidth } = el;
const firstCard = el.querySelector<HTMLElement>('.song-card');
const cardW = firstCard?.clientWidth || firstCard?.getBoundingClientRect().width || 140;
const gridStyles = window.getComputedStyle(el);
const gap = Number.parseFloat(gridStyles.columnGap || gridStyles.gap || '12') || 12;
const step = Math.max(1, cardW + gap);
const visibleCount = Math.ceil((scrollLeft + clientWidth) / step);
const nextBudget = Math.max(initialArtworkBudget, visibleCount + 12);
setArtworkBudget(prev => (nextBudget > prev ? nextBudget : prev));
};
const handleScroll = () => {
if (windowArtworkByViewport) recomputeArtworkBudget();
if (!scrollRef.current) return;
const { scrollLeft, scrollWidth, clientWidth } = scrollRef.current;
if (!interactivityDisabled) {
setShowLeft(scrollLeft > 0);
setShowRight(scrollLeft < scrollWidth - clientWidth - 5);
}
};
useEffect(() => {
handleScroll();
const raf = window.requestAnimationFrame(() => {
if (windowArtworkByViewport) recomputeArtworkBudget();
});
window.addEventListener('resize', handleScroll);
const ro = new ResizeObserver(() => {
if (windowArtworkByViewport) recomputeArtworkBudget();
});
if (scrollRef.current) ro.observe(scrollRef.current);
return () => {
window.cancelAnimationFrame(raf);
window.removeEventListener('resize', handleScroll);
ro.disconnect();
};
// handleScroll/recomputeArtworkBudget are recreated each render but read live
// refs/props; the listeners are intentionally (re)bound only when the row data
// or artwork config changes, not on every render.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [uniqueSongs, interactivityDisabled, windowArtworkByViewport, initialArtworkBudget]);
const rowArtworkResetKey = uniqueSongs[0]?.id ?? '';
useEffect(() => {
// React Compiler set-state-in-effect rule: state set from a DOM/layout measurement.
// eslint-disable-next-line react-hooks/set-state-in-effect
setArtworkBudget(initialArtworkBudget);
}, [initialArtworkBudget, rowArtworkResetKey]);
const scroll = (dir: 'left' | 'right') => {
if (!scrollRef.current) return;
const amount = scrollRef.current.clientWidth * 0.75;
scrollRef.current.scrollBy({ left: dir === 'left' ? -amount : amount, behavior: 'smooth' });
};
// Hide rail entirely if empty and no empty-state copy
if (uniqueSongs.length === 0 && !loading && !emptyText) return null;
return (
<section className="song-row-section">
<div className="song-row-header">
<h2 className="section-title" style={{ marginBottom: 0 }}>{title}</h2>
<div className="song-row-nav">
{onReroll && (
<button
className="nav-btn song-row-reroll"
onClick={() => onReroll()}
disabled={loading}
aria-label="Reroll"
data-tooltip="Reroll"
data-tooltip-pos="top"
>
<RefreshCw size={16} className={loading ? 'is-spinning' : ''} />
</button>
)}
{!interactivityDisabled && (
<>
<button
className={`nav-btn ${!showLeft ? 'disabled' : ''}`}
onClick={() => scroll('left')}
disabled={!showLeft}
>
<ChevronLeft size={20} />
</button>
<button
className={`nav-btn ${!showRight ? 'disabled' : ''}`}
onClick={() => scroll('right')}
disabled={!showRight}
>
<ChevronRight size={20} />
</button>
</>
)}
</div>
</div>
<div className="song-grid-wrapper">
{uniqueSongs.length === 0 && emptyText ? (
<p className="song-row-empty">{emptyText}</p>
) : (
<div className="song-grid" ref={scrollRef} onScroll={handleScroll}>
{uniqueSongs.map((s, idx) => (
<SongCard
key={s.id}
song={s}
disableArtwork={
artworkDisabled ||
(windowArtworkByViewport && idx >= artworkBudget)
}
artworkSize={artworkSize}
/>
))}
</div>
)}
</div>
</section>
);
}
+11
View File
@@ -0,0 +1,11 @@
/**
* Home (Mainstage) feature — the Home landing page, its Hero banner, the song
* rail + "because you like" recommendation rail, the mainstage section config
* store, and the home-feed / because-you-like client caches. The page is
* lazy-loaded by the router via its deep path, so it is not re-exported here.
*
* Cross-feature consumers pull only the section config store (settings'
* mainstage customiser, the tracks-page chrome).
*/
export { useHomeStore, type HomeSectionId, type HomeSectionConfig, DEFAULT_HOME_SECTIONS } from './store/homeStore';
export { default as SongRail } from './components/SongRail';
+492
View File
@@ -0,0 +1,492 @@
import { getArtists } from '@/lib/api/subsonicArtists';
import { getAlbumList, getRandomSongs } from '@/lib/api/subsonicLibrary';
import type { SubsonicAlbum, SubsonicArtist, SubsonicSong } from '@/lib/api/subsonicTypes';
import { runLocalRandomSongs } from '@/lib/library/browseTextSearch';
import React, { useEffect, useState } from 'react';
import Hero from '@/features/home/components/Hero';
import { AlbumRow } from '@/features/album';
import SongRail from '@/features/home/components/SongRail';
import BecauseYouLikeRail from '@/features/home/components/BecauseYouLikeRail';
import { LosslessAlbumsRail } from '@/features/album';
import { useTranslation } from 'react-i18next';
import { NavLink, useNavigate } from 'react-router-dom';
import { ChevronRight } from 'lucide-react';
import { useHomeStore } from '@/features/home/store/homeStore';
import { useAuthStore } from '@/store/authStore';
import { filterAlbumsByMixRatings, getMixMinRatingsConfigFromAuth } from '@/utils/mix/mixRatingFilter';
import { usePerfProbeFlags } from '@/utils/perf/perfFlags';
import { bumpPerfCounter } from '@/utils/perf/perfTelemetry';
import { dedupeById } from '@/lib/util/dedupeById';
import { shuffleArray } from '@/lib/util/shuffleArray';
import { useLibraryCoverPrefetch } from '@/cover/useLibraryCoverPrefetch';
import { primeAlbumCoversForDisplay, warmHomeMainstageCovers } from '@/cover/warmDiskPeek';
import { readBecauseYouLikeCache } from '@/features/home/store/becauseYouLikeCache';
import {
isHomeFeedSnapshotEmpty,
readHomeFeedCache,
readHomeFeedCacheStale,
writeHomeFeedCache,
type HomeFeedSnapshot,
} from '@/features/home/store/homeFeedCache';
import { useConnectionStatus } from '@/hooks/useConnectionStatus';
import { useOfflineBrowseContext } from '@/features/offline';
import { useOfflineBrowseReloadToken } from '@/features/offline';
import { useDevOfflineBrowseStore } from '@/features/offline';
/** Match Random Albums overshoot when mix filter uses album/artist axes so hero + discover row can still fill. */
const HOME_RANDOM_FETCH = 100;
const HOME_HERO_COUNT = 8;
const HOME_DISCOVER_SLICE = 20;
const HOME_DISCOVER_SONGS_SIZE = 18;
const HOME_ALBUM_ROW_ARTWORK_SIZE = 300;
const HOME_SONG_RAIL_ARTWORK_SIZE = 200;
const HOME_ARTWORK_WINDOWING = true;
// At least one viewport width of cards on first paint (low values left half the row as placeholders).
const HOME_ALBUM_ROW_INITIAL_ARTWORK_BUDGET = 14;
const HOME_SONG_RAIL_INITIAL_ARTWORK_BUDGET = 16;
const HOME_BECAUSE_CARD_COVER_CSS_PX = 160;
// Keep artwork enabled across Home rows in normal mode.
const HOME_ARTWORK_VISIBLE_ROW_BUDGET_WHEN_ENABLED = 8;
/**
* Read the in-memory homeFeedCache synchronously at component mount time.
* Uses Zustand getState() (not a hook) so it can be called from useState lazy
* initializers — by the time the user navigates back to Home the store is
* fully rehydrated and activeServerId is set, so on every return visit the
* first render already has data, eliminating the empty-state flash.
*/
function getInitialHomeFeed(): HomeFeedSnapshot | null {
const { activeServerId, musicLibraryFilterVersion } = useAuthStore.getState();
if (!activeServerId) return null;
return readHomeFeedCache(activeServerId, musicLibraryFilterVersion)
?? readHomeFeedCacheStale(activeServerId);
}
export default function Home() {
const perfFlags = usePerfProbeFlags();
const homeAlbumRowsDisabled = perfFlags.disableMainstageRails || perfFlags.disableHomeAlbumRows;
const homeSongRailsDisabled = perfFlags.disableMainstageRails || perfFlags.disableHomeSongRails;
const homeRailArtworkDisabled = perfFlags.disableMainstageRailArtwork || perfFlags.disableHomeRailArtwork;
const homeSections = useHomeStore(s => s.sections);
const activeServerId = useAuthStore(s => s.activeServerId);
const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion);
const connStatus = useConnectionStatus().status;
const devForceOffline = useDevOfflineBrowseStore(s => s.forceOffline);
const offlineBrowseActive = useOfflineBrowseContext().active;
const offlineBrowseReloadTs = useOfflineBrowseReloadToken();
// Mix-rating deps intentionally NOT subscribed here — they change during Zustand
// rehydration and would trigger a second useEffect fire right after the first,
// showing the cached home feed briefly and then replacing it (~500 ms later)
// when the re-fetch with the rehydrated values completes. getMixMinRatingsConfigFromAuth
// reads the current store state directly inside the effect so the correct
// values are always used without re-triggering the effect on rehydration.
const isVisible = (id: string) => homeSections.find(s => s.id === id)?.visible ?? true;
const [starred, setStarred] = useState<SubsonicAlbum[]>(() => getInitialHomeFeed()?.starred ?? []);
const [recent, setRecent] = useState<SubsonicAlbum[]>(() => getInitialHomeFeed()?.recent ?? []);
const [random, setRandom] = useState<SubsonicAlbum[]>(() => getInitialHomeFeed()?.random ?? []);
const [heroAlbums, setHeroAlbums] = useState<SubsonicAlbum[]>(() => getInitialHomeFeed()?.heroAlbums ?? []);
const [mostPlayed, setMostPlayed] = useState<SubsonicAlbum[]>(() => getInitialHomeFeed()?.mostPlayed ?? []);
const [recentlyPlayed, setRecentlyPlayed] = useState<SubsonicAlbum[]>(() => getInitialHomeFeed()?.recentlyPlayed ?? []);
const [randomArtists, setRandomArtists] = useState<SubsonicArtist[]>(() => getInitialHomeFeed()?.randomArtists ?? []);
const [discoverSongs, setDiscoverSongs] = useState<SubsonicSong[]>(() => getInitialHomeFeed()?.discoverSongs ?? []);
// Pre-populated from cache → no loading spinner on return visits.
const [loading, setLoading] = useState(() => getInitialHomeFeed() == null);
// Track whether state was pre-populated from cache at mount so useEffect can
// skip re-applying the same snapshot (avoids creating new array references
// that would cause child components to re-render with unchanged data).
const [wasPrePopulated] = useState(() => getInitialHomeFeed() != null);
const applyFeedSnapshot = (snap: HomeFeedSnapshot) => {
setStarred(snap.starred);
setRecent(snap.recent);
setRandom(snap.random);
setHeroAlbums(snap.heroAlbums);
setMostPlayed(snap.mostPlayed);
setRecentlyPlayed(snap.recentlyPlayed);
setRandomArtists(snap.randomArtists);
setDiscoverSongs(snap.discoverSongs);
};
useEffect(() => {
bumpPerfCounter('homeCommits');
});
useLibraryCoverPrefetch(
[
{ albums: heroAlbums, priority: 'high' },
{ albums: recent, priority: 'high' },
{
albums: [...random, ...mostPlayed, ...recentlyPlayed, ...starred],
artists: randomArtists,
limit: 24,
priority: 'low',
},
{ songs: discoverSongs, limit: 16, priority: 'middle' },
],
[heroAlbums, recent, random, mostPlayed, recentlyPlayed, starred, randomArtists, discoverSongs],
);
useEffect(() => {
if (!activeServerId) return;
let cancelled = false;
const fetchFreshHomeFeed = async (): Promise<HomeFeedSnapshot | null> => {
const mixCfg = getMixMinRatingsConfigFromAuth();
const albumMix =
mixCfg.enabled && (mixCfg.minAlbum > 0 || mixCfg.minArtist > 0);
const randomSize = albumMix ? HOME_RANDOM_FETCH : HOME_DISCOVER_SLICE;
const [s, n, rRaw, f, rp, artists, songs] = await Promise.all([
getAlbumList('starred', 12).catch(() => []),
getAlbumList('newest', 12).catch(() => []),
getAlbumList('random', randomSize).catch(() => []),
getAlbumList('frequent', 12).catch(() => []),
getAlbumList('recent', 12).catch(() => []),
isVisible('discoverArtists') ? getArtists().catch(() => []) : Promise.resolve<SubsonicArtist[]>([]),
isVisible('discoverSongs')
? (runLocalRandomSongs(activeServerId, HOME_DISCOVER_SONGS_SIZE)
.then(local => local ?? getRandomSongs(HOME_DISCOVER_SONGS_SIZE).catch(() => [] as SubsonicSong[]))
.catch(() => [] as SubsonicSong[]))
: Promise.resolve<SubsonicSong[]>([]),
]);
const r = dedupeById(await filterAlbumsByMixRatings(rRaw, mixCfg));
return {
serverId: activeServerId,
filterVersion: musicLibraryFilterVersion,
savedAt: Date.now(),
starred: dedupeById(s),
recent: dedupeById(n),
heroAlbums: r.slice(0, HOME_HERO_COUNT),
random: r.slice(HOME_HERO_COUNT, HOME_DISCOVER_SLICE),
mostPlayed: dedupeById(f),
recentlyPlayed: dedupeById(rp),
discoverSongs: dedupeById(songs),
randomArtists: dedupeById(shuffleArray(artists)).slice(0, 16),
};
};
const cached = readHomeFeedCache(activeServerId, musicLibraryFilterVersion)
?? (offlineBrowseActive ? readHomeFeedCacheStale(activeServerId) : null);
if (cached) {
// When lazy initializers already pre-populated state from this same
// snapshot, re-applying it would only create new array references and
// trigger unnecessary child re-renders with identical data.
// 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 (!wasPrePopulated) applyFeedSnapshot(cached);
setLoading(false);
void warmHomeMainstageCovers(cached);
const becauseSnap = readBecauseYouLikeCache(activeServerId, musicLibraryFilterVersion);
void primeAlbumCoversForDisplay(becauseSnap?.recs ?? [], HOME_BECAUSE_CARD_COVER_CSS_PX, {
limit: 6,
});
// Keep the current visit visually stable, but prepare fresh data so the
// next re-enter opens with a newer snapshot immediately.
if (!offlineBrowseActive) {
void (async () => {
try {
const fresh = await fetchFreshHomeFeed();
if (!fresh || cancelled || isHomeFeedSnapshotEmpty(fresh)) return;
writeHomeFeedCache(fresh);
void warmHomeMainstageCovers(fresh);
} catch {
/* ignore */
}
})();
}
return () => {
cancelled = true;
};
}
const stale = offlineBrowseActive ? readHomeFeedCacheStale(activeServerId) : null;
if (stale) {
applyFeedSnapshot(stale);
setLoading(false);
return () => { cancelled = true; };
}
setLoading(true);
(async () => {
try {
const snap = await fetchFreshHomeFeed();
if (!snap) return;
if (cancelled) return;
if (offlineBrowseActive && isHomeFeedSnapshotEmpty(snap)) return;
writeHomeFeedCache(snap);
applyFeedSnapshot(snap);
if (!cancelled) setLoading(false);
void warmHomeMainstageCovers(snap);
const becauseSnap = readBecauseYouLikeCache(activeServerId, musicLibraryFilterVersion);
void primeAlbumCoversForDisplay(becauseSnap?.recs ?? [], HOME_BECAUSE_CARD_COVER_CSS_PX, {
limit: 6,
});
} catch {
/* ignore */
} finally {
if (!cancelled) setLoading(false);
}
})();
return () => { cancelled = true; };
// isVisible / wasPrePopulated are read for one-shot gating inside the loader;
// the home feed reloads on server / filter / section / offline changes only,
// not when visibility or the pre-populate flag flips.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
activeServerId,
musicLibraryFilterVersion,
homeSections,
offlineBrowseActive,
offlineBrowseReloadTs,
]);
/** When offline toggles without a library-filter bump, re-apply stale cache if the feed was cleared. */
useEffect(() => {
if (!activeServerId || !offlineBrowseActive) return;
const stale = readHomeFeedCacheStale(activeServerId);
if (!stale || isHomeFeedSnapshotEmpty(stale)) return;
if (recent.length > 0 || random.length > 0 || heroAlbums.length > 0) return;
// 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
applyFeedSnapshot(stale);
setLoading(false);
}, [activeServerId, connStatus, devForceOffline, offlineBrowseActive]); // eslint-disable-line react-hooks/exhaustive-deps
const loadMore = async (
type: 'starred' | 'newest' | 'random' | 'frequent' | 'recent',
currentList: SubsonicAlbum[],
setter: React.Dispatch<React.SetStateAction<SubsonicAlbum[]>>
) => {
try {
const more = await getAlbumList(type, 12, currentList.length);
const mixCfg = getMixMinRatingsConfigFromAuth();
const batchRaw =
type === 'random' ? await filterAlbumsByMixRatings(more, mixCfg) : more;
const batch = dedupeById(batchRaw);
const newItems = batch.filter(m => !currentList.find(c => c.id === m.id));
if (newItems.length > 0) setter(prev => [...prev, ...newItems]);
} catch (e) {
console.error('Failed to load more', e);
}
};
const { t } = useTranslation();
const navigate = useNavigate();
let artworkRowsLeft = homeRailArtworkDisabled ? 0 : HOME_ARTWORK_VISIBLE_ROW_BUDGET_WHEN_ENABLED;
const reserveArtworkRow = () => {
if (artworkRowsLeft <= 0) return false;
artworkRowsLeft -= 1;
return true;
};
const recentArtworkEnabled =
!homeRailArtworkDisabled &&
!homeAlbumRowsDisabled &&
isVisible('recent') &&
recent.length > 0 &&
reserveArtworkRow();
const discoverArtworkEnabled =
!homeRailArtworkDisabled &&
!homeAlbumRowsDisabled &&
isVisible('discover') &&
random.length > 0 &&
reserveArtworkRow();
const discoverSongsArtworkEnabled =
!homeRailArtworkDisabled &&
!homeSongRailsDisabled &&
isVisible('discoverSongs') &&
discoverSongs.length > 0 &&
reserveArtworkRow();
const recentlyPlayedArtworkEnabled =
!homeRailArtworkDisabled &&
!homeAlbumRowsDisabled &&
isVisible('recentlyPlayed') &&
recentlyPlayed.length > 0 &&
reserveArtworkRow();
const starredArtworkEnabled =
!homeRailArtworkDisabled &&
!homeAlbumRowsDisabled &&
isVisible('starred') &&
starred.length > 0 &&
reserveArtworkRow();
const mostPlayedArtworkEnabled =
!homeRailArtworkDisabled &&
!homeAlbumRowsDisabled &&
isVisible('mostPlayed') &&
mostPlayed.length > 0 &&
reserveArtworkRow();
const becauseYouLikeHasSeed =
mostPlayed.length > 0 || recentlyPlayed.length > 0 || starred.length > 0;
const becauseYouLikeArtworkEnabled =
!homeRailArtworkDisabled &&
!homeAlbumRowsDisabled &&
isVisible('becauseYouLike') &&
becauseYouLikeHasSeed &&
reserveArtworkRow();
const losslessAlbumsArtworkEnabled =
!homeRailArtworkDisabled &&
!homeAlbumRowsDisabled &&
isVisible('losslessAlbums') &&
reserveArtworkRow();
const homeLiteArtworkFx = perfFlags.disableHomeArtworkFx;
const homeFlatArtworkClip = perfFlags.disableHomeArtworkClip;
// Treat the library as empty when every album endpoint returned zero. The
// song/artist rails can be empty for non-empty libraries (rare server quirks),
// so they don't count toward this signal.
const libraryEmpty =
!loading &&
recent.length === 0 &&
random.length === 0 &&
mostPlayed.length === 0 &&
recentlyPlayed.length === 0 &&
starred.length === 0;
// Every section toggled off in Settings → Personalisation → Mainstage. The
// page would otherwise be entirely blank, so surface a guided empty state
// pointing back at the toggles (or the option to hide Mainstage from the
// sidebar) instead of leaving the user on nothing.
const allSectionsHidden = homeSections.every(s => !s.visible);
return (
<div className={`animate-fade-in${homeLiteArtworkFx ? ' home-lite-artwork' : ''}${homeFlatArtworkClip ? ' home-flat-artwork-clip' : ''}`}>
{!loading && !perfFlags.disableMainstageHero && isVisible('hero') && <Hero albums={heroAlbums} />}
<div className="content-body" style={{ display: 'flex', flexDirection: 'column', gap: '3rem' }}>
{loading ? (
<div style={{ display: 'flex', justifyContent: 'center', padding: '4rem' }}>
<div className="spinner" />
</div>
) : allSectionsHidden ? (
<div className="empty-state" style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '0.75rem' }}>
<div style={{ fontSize: 18, fontWeight: 600, color: 'var(--text-primary)' }}>
{t('home.mainstageEmptyTitle')}
</div>
<div style={{ maxWidth: 460 }}>{t('home.mainstageEmptyBody')}</div>
<button
type="button"
className="btn btn-primary"
style={{ marginTop: '0.5rem' }}
onClick={() => navigate('/settings', { state: { tab: 'personalisation' } })}
>
{t('home.mainstageEmptyCta')}
</button>
</div>
) : libraryEmpty ? (
<div className="empty-state" style={{ padding: '4rem 1rem', textAlign: 'center' }}>
{t('common.libraryEmpty')}
</div>
) : (
<>
{!homeAlbumRowsDisabled && isVisible('recent') && (
<AlbumRow
title={t('sidebar.newReleases')}
titleLink="/new-releases"
albums={recent}
onLoadMore={() => loadMore('newest', recent, setRecent)}
moreText={t('home.loadMore')}
disableArtwork={!recentArtworkEnabled}
artworkSize={HOME_ALBUM_ROW_ARTWORK_SIZE}
windowArtworkByViewport={HOME_ARTWORK_WINDOWING}
initialArtworkBudget={HOME_ALBUM_ROW_INITIAL_ARTWORK_BUDGET}
/>
)}
{!homeAlbumRowsDisabled && isVisible('becauseYouLike') && becauseYouLikeHasSeed && (
<BecauseYouLikeRail
mostPlayed={mostPlayed}
recentlyPlayed={recentlyPlayed}
starred={starred}
disableArtwork={!becauseYouLikeArtworkEnabled}
/>
)}
{!homeAlbumRowsDisabled && isVisible('discover') && (
<AlbumRow
title={t('home.discover')}
titleLink="/random/albums"
albums={random}
onLoadMore={() => loadMore('random', random, setRandom)}
moreText={t('home.discoverMore')}
disableArtwork={!discoverArtworkEnabled}
artworkSize={HOME_ALBUM_ROW_ARTWORK_SIZE}
windowArtworkByViewport={HOME_ARTWORK_WINDOWING}
initialArtworkBudget={HOME_ALBUM_ROW_INITIAL_ARTWORK_BUDGET}
/>
)}
{!homeSongRailsDisabled && isVisible('discoverSongs') && discoverSongs.length > 0 && (
<SongRail
title={t('home.discoverSongs')}
songs={discoverSongs}
disableArtwork={!discoverSongsArtworkEnabled}
artworkSize={HOME_SONG_RAIL_ARTWORK_SIZE}
windowArtworkByViewport={HOME_ARTWORK_WINDOWING}
initialArtworkBudget={HOME_SONG_RAIL_INITIAL_ARTWORK_BUDGET}
/>
)}
{!perfFlags.disableMainstageGridCards && isVisible('discoverArtists') && randomArtists.length > 0 && (
<section className="album-row-section">
<div className="album-row-header">
<NavLink to="/artists" className="section-title-link" style={{ marginBottom: 0 }}>
{t('home.discoverArtists')}<ChevronRight size={18} className="section-title-chevron" />
</NavLink>
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.5rem' }}>
{randomArtists.map(a => (
<button key={a.id} className="artist-ext-link" onClick={() => navigate(`/artist/${a.id}`)}>
{a.name}
</button>
))}
<button className="artist-ext-link" onClick={() => navigate('/artists')}
style={{ opacity: 0.6 }}>
{t('home.discoverArtistsMore')}
</button>
</div>
</section>
)}
{!homeAlbumRowsDisabled && isVisible('recentlyPlayed') && recentlyPlayed.length > 0 && (
<AlbumRow
title={t('home.recentlyPlayed')}
albums={recentlyPlayed}
onLoadMore={() => loadMore('recent', recentlyPlayed, setRecentlyPlayed)}
moreText={t('home.loadMore')}
disableArtwork={!recentlyPlayedArtworkEnabled}
artworkSize={HOME_ALBUM_ROW_ARTWORK_SIZE}
windowArtworkByViewport={HOME_ARTWORK_WINDOWING}
initialArtworkBudget={HOME_ALBUM_ROW_INITIAL_ARTWORK_BUDGET}
/>
)}
{!homeAlbumRowsDisabled && isVisible('starred') && starred.length > 0 && (
<AlbumRow
title={t('home.starred')}
titleLink="/favorites"
albums={starred}
onLoadMore={() => loadMore('starred', starred, setStarred)}
moreText={t('home.loadMore')}
disableArtwork={!starredArtworkEnabled}
artworkSize={HOME_ALBUM_ROW_ARTWORK_SIZE}
windowArtworkByViewport={HOME_ARTWORK_WINDOWING}
initialArtworkBudget={HOME_ALBUM_ROW_INITIAL_ARTWORK_BUDGET}
/>
)}
{!homeAlbumRowsDisabled && isVisible('mostPlayed') && (
<AlbumRow
title={t('home.mostPlayed')}
titleLink="/most-played"
albums={mostPlayed}
onLoadMore={() => loadMore('frequent', mostPlayed, setMostPlayed)}
moreText={t('home.loadMore')}
disableArtwork={!mostPlayedArtworkEnabled}
artworkSize={HOME_ALBUM_ROW_ARTWORK_SIZE}
windowArtworkByViewport={HOME_ARTWORK_WINDOWING}
initialArtworkBudget={HOME_ALBUM_ROW_INITIAL_ARTWORK_BUDGET}
/>
)}
{!homeAlbumRowsDisabled && isVisible('losslessAlbums') && (
<LosslessAlbumsRail
disableArtwork={!losslessAlbumsArtworkEnabled}
artworkSize={HOME_ALBUM_ROW_ARTWORK_SIZE}
windowArtworkByViewport={HOME_ARTWORK_WINDOWING}
initialArtworkBudget={HOME_ALBUM_ROW_INITIAL_ARTWORK_BUDGET}
/>
)}
</>
)}
</div>
</div>
);
}
@@ -0,0 +1,20 @@
import { describe, expect, it } from 'vitest';
import {
clearBecauseYouLikeCache,
readBecauseYouLikeCache,
writeBecauseYouLikeCache,
} from '@/features/home/store/becauseYouLikeCache';
describe('becauseYouLikeCache', () => {
it('invalidates when music library filter version changes', () => {
clearBecauseYouLikeCache();
writeBecauseYouLikeCache({
serverId: 'srv-1',
filterVersion: 1,
anchor: { id: 'a1', name: 'Artist' },
recs: [{ id: 'alb-1', name: 'Album', artist: 'Artist', artistId: 'a1', songCount: 1, duration: 1 }],
});
expect(readBecauseYouLikeCache('srv-1', 1)?.recs).toHaveLength(1);
expect(readBecauseYouLikeCache('srv-1', 2)).toBeNull();
});
});
@@ -0,0 +1,34 @@
import type { SubsonicAlbum } from '@/lib/api/subsonicTypes';
export type BecauseYouLikeAnchor = { id: string; name: string };
export type BecauseYouLikeSnapshot = {
serverId: string;
filterVersion: number;
savedAt: number;
anchor: BecauseYouLikeAnchor;
recs: SubsonicAlbum[];
};
const TTL_MS = 15 * 60 * 1000;
let snapshot: BecauseYouLikeSnapshot | null = null;
export function readBecauseYouLikeCache(
serverId: string | null | undefined,
filterVersion: number,
): BecauseYouLikeSnapshot | null {
if (!serverId || !snapshot) return null;
if (snapshot.serverId !== serverId || snapshot.filterVersion !== filterVersion) return null;
if (Date.now() - snapshot.savedAt > TTL_MS) return null;
return snapshot;
}
export function writeBecauseYouLikeCache(
data: Omit<BecauseYouLikeSnapshot, 'savedAt'>,
): void {
snapshot = { ...data, savedAt: Date.now() };
}
export function clearBecauseYouLikeCache(): void {
snapshot = null;
}
+58
View File
@@ -0,0 +1,58 @@
import type { SubsonicAlbum, SubsonicArtist, SubsonicSong } from '@/lib/api/subsonicTypes';
/** Session cache so leaving Mainstage and returning does not refetch + reshuffle everything. */
export type HomeFeedSnapshot = {
serverId: string;
filterVersion: number;
savedAt: number;
starred: SubsonicAlbum[];
recent: SubsonicAlbum[];
random: SubsonicAlbum[];
heroAlbums: SubsonicAlbum[];
mostPlayed: SubsonicAlbum[];
recentlyPlayed: SubsonicAlbum[];
randomArtists: SubsonicArtist[];
discoverSongs: SubsonicSong[];
};
const TTL_MS = 15 * 60 * 1000;
let snapshot: HomeFeedSnapshot | null = null;
export function readHomeFeedCache(
serverId: string | null | undefined,
filterVersion: number,
): HomeFeedSnapshot | null {
if (!serverId || !snapshot) return null;
if (snapshot.serverId !== serverId || snapshot.filterVersion !== filterVersion) return null;
if (Date.now() - snapshot.savedAt > TTL_MS) return null;
return snapshot;
}
/** Last good snapshot for this server when filter version changed (e.g. offline filter suspend). */
export function readHomeFeedCacheStale(
serverId: string | null | undefined,
): HomeFeedSnapshot | null {
if (!serverId || !snapshot) return null;
if (snapshot.serverId !== serverId) return null;
if (Date.now() - snapshot.savedAt > TTL_MS) return null;
return snapshot;
}
export function isHomeFeedSnapshotEmpty(snap: HomeFeedSnapshot): boolean {
return snap.heroAlbums.length === 0
&& snap.recent.length === 0
&& snap.random.length === 0
&& snap.starred.length === 0
&& snap.mostPlayed.length === 0
&& snap.recentlyPlayed.length === 0
&& snap.discoverSongs.length === 0
&& snap.randomArtists.length === 0;
}
export function writeHomeFeedCache(data: Omit<HomeFeedSnapshot, 'savedAt'>): void {
snapshot = { ...data, savedAt: Date.now() };
}
export function clearHomeFeedCache(): void {
snapshot = null;
}
+54
View File
@@ -0,0 +1,54 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
export type HomeSectionId = 'hero' | 'recent' | 'discover' | 'becauseYouLike' | 'discoverSongs' | 'discoverArtists' | 'recentlyPlayed' | 'starred' | 'mostPlayed' | 'losslessAlbums';
export interface HomeSectionConfig {
id: HomeSectionId;
visible: boolean;
}
export const DEFAULT_HOME_SECTIONS: HomeSectionConfig[] = [
{ id: 'hero', visible: true },
{ id: 'recent', visible: true },
{ id: 'becauseYouLike', visible: true },
{ id: 'discover', visible: true },
{ id: 'discoverSongs', visible: true },
{ id: 'discoverArtists', visible: true },
{ id: 'recentlyPlayed', visible: true },
{ id: 'starred', visible: true },
{ id: 'mostPlayed', visible: true },
{ id: 'losslessAlbums', visible: true },
];
interface HomeStore {
sections: HomeSectionConfig[];
toggleSection: (id: HomeSectionId) => void;
reset: () => void;
}
export const useHomeStore = create<HomeStore>()(
persist(
(set) => ({
sections: DEFAULT_HOME_SECTIONS,
toggleSection: (id) => set((s) => ({
sections: s.sections.map(sec => sec.id === id ? { ...sec, visible: !sec.visible } : sec),
})),
reset: () => set({ sections: DEFAULT_HOME_SECTIONS }),
}),
{
name: 'psysonic_home',
onRehydrateStorage: () => (state) => {
// Append any sections introduced after the user first persisted their order,
// so new defaults show up without forcing a manual Reset.
if (!state) return;
const safe = (state.sections ?? []).filter(
(s): s is HomeSectionConfig => s != null && typeof s.id === 'string',
);
const known = new Set(safe.map(s => s.id));
const missing = DEFAULT_HOME_SECTIONS.filter(s => !known.has(s.id));
state.sections = missing.length > 0 ? [...safe, ...missing] : safe;
},
}
)
);