mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-21 23:05:46 +00:00
refactor(home): co-locate Mainstage/home feature into features/home
This commit is contained in:
@@ -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 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>
|
||||
);
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
},
|
||||
}
|
||||
)
|
||||
);
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useHomeStore, HomeSectionId } from '@/store/homeStore';
|
||||
import { useHomeStore, HomeSectionId } from '@/features/home';
|
||||
|
||||
export function HomeCustomizer() {
|
||||
const { t } = useTranslation();
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Disc3, LayoutGrid, ListOrdered, ListTodo, PanelLeft, RotateCcw, Users }
|
||||
import { useArtistLayoutStore } from '@/features/artist';
|
||||
import { useAuthStore } from '@/store/authStore';
|
||||
import type { QueueDisplayMode } from '@/store/authStoreTypes';
|
||||
import { useHomeStore } from '@/store/homeStore';
|
||||
import { useHomeStore } from '@/features/home';
|
||||
import { usePlayerBarLayoutStore } from '@/store/playerBarLayoutStore';
|
||||
import { usePlaylistLayoutStore } from '@/features/playlist';
|
||||
import { useQueueToolbarStore } from '@/store/queueToolbarStore';
|
||||
|
||||
Reference in New Issue
Block a user