Files
Psychotoxical-psysonic/src/pages/Home.tsx
T
Psychotoxical 209dd61442 refactor(lib): consolidate generic infra into src/lib
Move domain-agnostic, feature-free helpers out of the flat utils/ and store/
roots into src/lib/ (plan M4, §3 lib/ layer):
- lib/format/: formatBytes, formatClockTime, formatDuration, formatHumanDuration,
  relativeTime (pure format/date/byte/clock helpers)
- lib/i18n.ts: i18next bootstrap (global app infra)
- lib/util/: sanitizeHtml, platform, dedupeById, safeStorage (pure helpers +
  the zustand storage adapter)

playbackScheduleFormat stays in utils/format (runtime usePlayerStore dep =
audio-core coupling, not generic). formatClockTime keeps a type-only
@/store/authStoreTypes import (erased, no runtime inversion — accepted per the
deviceSync precedent).

Pure move via deep @/lib/* specifiers (no barrel → no mock-collapse surface).
tsc 0, lint 0/0, full suite 319 files / 2353 tests green.

Tooling note: lib_move.py regex extended to also rewrite side-effect imports
(import './i18n') and vi.importActual paths — both were silent gaps.
2026-06-30 08:07:04 +02:00

493 lines
22 KiB
TypeScript

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 '../utils/library/browseTextSearch';
import React, { useEffect, useState } from 'react';
import Hero from '../components/Hero';
import { AlbumRow } from '@/features/album';
import SongRail from '../components/SongRail';
import BecauseYouLikeRail from '../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 '../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 '../utils/playback/shuffleArray';
import { useLibraryCoverPrefetch } from '../cover/useLibraryCoverPrefetch';
import { primeAlbumCoversForDisplay, warmHomeMainstageCovers } from '../cover/warmDiskPeek';
import { readBecauseYouLikeCache } from '../store/becauseYouLikeCache';
import {
isHomeFeedSnapshotEmpty,
readHomeFeedCache,
readHomeFeedCacheStale,
writeHomeFeedCache,
type HomeFeedSnapshot,
} from '../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>
);
}