mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-21 22:15:40 +00:00
d75670ec4b
* feat(home): mix recently-played + starred into Because-you-like anchor pool Anchor pool was sourced only from getAlbumList(frequent), so the rotation cursor walked the same eight top-played artists no matter how varied the rest of the listening history was. Round-robin merge of mostPlayed, recentlyPlayed and starred (dedup by artistId) means each mount can land on a different listening *mode* — heavy rotation, current focus, or explicit favorites — instead of stepping through the same top-played sequence. Pool size 8 -> 12 to let the cursor visit all three modes before wrapping. Visibility guard widened so the rail still renders when the server has no frequent-play data yet but starred or recent items exist. Zero new API calls — all three lists are already in Home's initial fetch. * fix(home): drop orphan 3rd Because-card in 2-col range, keep all 3 stacked on mobile auto-fit grid wraps to 2 cols between 696-1051px container width, which left the third card alone on a second row at 1080p. Container query hides the 3rd card only inside that 2-col band; on wider screens the full 3-up row stays, on narrow viewports (single column) all three cards stack vertically as expected. * docs(changelog): Because-you-listened seed pool + 1080p layout polish (PR #493) * docs(changelog): fold PR #493 refinements into the existing Because-you-listened entry Drop the separate Changed section entry — the feature is in the same 1.46.0 release window as PR #489, so readers want a single description of the final behaviour, not "added X, then changed X" for the same release. PR reference becomes "PRs #489, #493".
301 lines
13 KiB
TypeScript
301 lines
13 KiB
TypeScript
import React, { useEffect, useState } from 'react';
|
|
import Hero from '../components/Hero';
|
|
import AlbumRow from '../components/AlbumRow';
|
|
import SongRail from '../components/SongRail';
|
|
import BecauseYouLikeRail from '../components/BecauseYouLikeRail';
|
|
import { getAlbumList, getArtists, getRandomSongs, SubsonicAlbum, SubsonicArtist, SubsonicSong } from '../api/subsonic';
|
|
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/mixRatingFilter';
|
|
import { usePerfProbeFlags } from '../utils/perfFlags';
|
|
import { bumpPerfCounter } from '../utils/perfTelemetry';
|
|
import { dedupeById } from '../utils/dedupeById';
|
|
|
|
/** 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;
|
|
// Keep artwork enabled across Home rows in normal mode.
|
|
const HOME_ARTWORK_VISIBLE_ROW_BUDGET_WHEN_ENABLED = 8;
|
|
|
|
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 mixMinRatingFilterEnabled = useAuthStore(s => s.mixMinRatingFilterEnabled);
|
|
const mixMinRatingAlbum = useAuthStore(s => s.mixMinRatingAlbum);
|
|
const mixMinRatingArtist = useAuthStore(s => s.mixMinRatingArtist);
|
|
const isVisible = (id: string) => homeSections.find(s => s.id === id)?.visible ?? true;
|
|
|
|
const [starred, setStarred] = useState<SubsonicAlbum[]>([]);
|
|
const [recent, setRecent] = useState<SubsonicAlbum[]>([]);
|
|
const [random, setRandom] = useState<SubsonicAlbum[]>([]);
|
|
const [heroAlbums, setHeroAlbums] = useState<SubsonicAlbum[]>([]);
|
|
const [mostPlayed, setMostPlayed] = useState<SubsonicAlbum[]>([]);
|
|
const [recentlyPlayed, setRecentlyPlayed] = useState<SubsonicAlbum[]>([]);
|
|
const [randomArtists, setRandomArtists] = useState<SubsonicArtist[]>([]);
|
|
const [discoverSongs, setDiscoverSongs] = useState<SubsonicSong[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
|
|
useEffect(() => {
|
|
bumpPerfCounter('homeCommits');
|
|
});
|
|
|
|
useEffect(() => {
|
|
let cancelled = false;
|
|
setLoading(true);
|
|
(async () => {
|
|
try {
|
|
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')
|
|
? getRandomSongs(HOME_DISCOVER_SONGS_SIZE).catch(() => [] as SubsonicSong[])
|
|
: Promise.resolve<SubsonicSong[]>([]),
|
|
]);
|
|
if (cancelled) return;
|
|
const r = dedupeById(await filterAlbumsByMixRatings(rRaw, mixCfg));
|
|
setStarred(dedupeById(s));
|
|
setRecent(dedupeById(n));
|
|
setHeroAlbums(r.slice(0, HOME_HERO_COUNT));
|
|
setRandom(r.slice(HOME_HERO_COUNT, HOME_DISCOVER_SLICE));
|
|
setMostPlayed(dedupeById(f));
|
|
setRecentlyPlayed(dedupeById(rp));
|
|
setDiscoverSongs(dedupeById(songs));
|
|
const shuffled = [...artists];
|
|
for (let i = shuffled.length - 1; i > 0; i--) {
|
|
const j = Math.floor(Math.random() * (i + 1));
|
|
[shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]];
|
|
}
|
|
setRandomArtists(dedupeById(shuffled).slice(0, 16));
|
|
} catch {
|
|
/* ignore */
|
|
} finally {
|
|
if (!cancelled) setLoading(false);
|
|
}
|
|
})();
|
|
return () => { cancelled = true; };
|
|
}, [
|
|
activeServerId,
|
|
musicLibraryFilterVersion,
|
|
homeSections,
|
|
mixMinRatingFilterEnabled,
|
|
mixMinRatingAlbum,
|
|
mixMinRatingArtist,
|
|
]);
|
|
|
|
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 homeLiteArtworkFx = perfFlags.disableHomeArtworkFx;
|
|
const homeFlatArtworkClip = perfFlags.disableHomeArtworkClip;
|
|
return (
|
|
<div className={`animate-fade-in${homeLiteArtworkFx ? ' home-lite-artwork' : ''}${homeFlatArtworkClip ? ' home-flat-artwork-clip' : ''}`}>
|
|
{!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>
|
|
) : (
|
|
<>
|
|
{!homeAlbumRowsDisabled && isVisible('recent') && (
|
|
<AlbumRow
|
|
title={t('home.recent')}
|
|
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}
|
|
/>
|
|
)}
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|