From d75670ec4b8c4eec0a65d30fea2e60e749dc681e Mon Sep 17 00:00:00 2001 From: Frank Stellmacher <171614930+Psychotoxical@users.noreply.github.com> Date: Thu, 7 May 2026 09:12:47 +0200 Subject: [PATCH] feat(home): broaden Because-you-like seed pool + tidy orphan card at 1080p (#493) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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". --- CHANGELOG.md | 9 ++++--- src/components/BecauseYouLikeRail.tsx | 36 ++++++++++++++++++++------- src/pages/Home.tsx | 8 ++++-- src/styles/components.css | 13 ++++++++++ 4 files changed, 51 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 047fd0b4..aeeae0d2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -68,11 +68,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Home — "Because you listened" recommendation rail -**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#489](https://github.com/Psychotoxical/psysonic/pull/489)** +**By [@Psychotoxical](https://github.com/Psychotoxical), PRs [#489](https://github.com/Psychotoxical/psysonic/pull/489), [#493](https://github.com/Psychotoxical/psysonic/pull/493)** -* New Home rail that surfaces albums **similar to one of your most-played artists** — Spotify-style "Because you listened to …" recommendations. -* Anchor artist is rotated **round-robin** between Home opens through the top **8** entries in Most Played (so the rail does not get stuck on the same name). `getArtistInfo` returns up to **12** similar artists; the rail randomly samples **6** of them and surfaces **3** albums (one random per matching artist) that exist on your server. -* Anchor rotation is **per-server**: switching servers keeps independent rotation state instead of aliasing one server's anchor id onto the next server's pool. +* New Home rail that surfaces albums **similar to one of your favourite artists** — Spotify-style "Because you listened to …" recommendations. +* Anchor pool round-robin merges **Most Played**, **Recently Played** and **Favorites** (deduped per artist), so the per-mount rotation lands on a different listening *mode* each visit instead of walking only the top-played list. Pool size **12** lets the cursor visit all three sources before wrapping. Within each anchor, `getArtistInfo` returns up to **12** similar artists; the rail randomly samples **6** of them and surfaces **3** albums (one random per matching artist) that exist on your server. +* Anchor rotation is **per-server**: switching servers keeps independent rotation state instead of aliasing one server's anchor id onto the next server's pool. The rail also renders on fresh servers that have no frequent-play history yet, as long as they have starred or recently played items. Zero extra API calls — all three seed lists are already in the Home initial fetch. +* **Responsive layout:** **3** cards in one row on 2K-class screens, **2** cards in one row at 1080p (the orphan third card on a second row is hidden via container query), and all **3** stacked vertically on truly narrow / mobile widths. * Toggleable in the Home customizer like every other rail; respects the existing performance flags ("Disable rail artwork", "Disable Home album rows"). ## Changed diff --git a/src/components/BecauseYouLikeRail.tsx b/src/components/BecauseYouLikeRail.tsx index d6579ca7..1e1ceafb 100644 --- a/src/components/BecauseYouLikeRail.tsx +++ b/src/components/BecauseYouLikeRail.tsx @@ -16,7 +16,7 @@ import { useAuthStore } from '../store/authStore'; import { playAlbum } from '../utils/playAlbum'; const ANCHOR_KEY_PREFIX = 'psysonic_because_anchor:'; -const TOP_ARTIST_POOL = 8; +const TOP_ARTIST_POOL = 12; const SIMILAR_FETCH = 12; const SIMILAR_PICK = 6; const SHOW_COUNT = 3; @@ -38,17 +38,27 @@ interface Anchor { interface Props { mostPlayed: SubsonicAlbum[]; + recentlyPlayed?: SubsonicAlbum[]; + starred?: SubsonicAlbum[]; disableArtwork?: boolean; } -function buildAnchorPool(albums: SubsonicAlbum[], limit: number): Anchor[] { +/** 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): Anchor[] { const seen = new Set(); const out: Anchor[] = []; - for (const a of albums) { - if (!a.artistId || seen.has(a.artistId)) continue; - seen.add(a.artistId); - out.push({ id: a.artistId, name: a.artist }); - if (out.length >= limit) break; + 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; } @@ -82,10 +92,18 @@ function rotateAnchor(pool: Anchor[], serverId: string | null): Anchor | null { return pool[(idx + 1) % pool.length]; } -export default function BecauseYouLikeRail({ mostPlayed, disableArtwork = false }: Props) { +export default function BecauseYouLikeRail({ + mostPlayed, + recentlyPlayed, + starred, + disableArtwork = false, +}: Props) { const { t } = useTranslation(); const activeServerId = useAuthStore(s => s.activeServerId); - const pool = useMemo(() => buildAnchorPool(mostPlayed, TOP_ARTIST_POOL), [mostPlayed]); + const pool = useMemo( + () => buildAnchorPool([mostPlayed, recentlyPlayed ?? [], starred ?? []], TOP_ARTIST_POOL), + [mostPlayed, recentlyPlayed, starred], + ); const [anchor, setAnchor] = useState(null); const [recs, setRecs] = useState([]); diff --git a/src/pages/Home.tsx b/src/pages/Home.tsx index f52f2560..ba52421f 100644 --- a/src/pages/Home.tsx +++ b/src/pages/Home.tsx @@ -168,11 +168,13 @@ export default function Home() { isVisible('mostPlayed') && mostPlayed.length > 0 && reserveArtworkRow(); + const becauseYouLikeHasSeed = + mostPlayed.length > 0 || recentlyPlayed.length > 0 || starred.length > 0; const becauseYouLikeArtworkEnabled = !homeRailArtworkDisabled && !homeAlbumRowsDisabled && isVisible('becauseYouLike') && - mostPlayed.length > 0 && + becauseYouLikeHasSeed && reserveArtworkRow(); const homeLiteArtworkFx = perfFlags.disableHomeArtworkFx; @@ -201,9 +203,11 @@ export default function Home() { initialArtworkBudget={HOME_ALBUM_ROW_INITIAL_ARTWORK_BUDGET} /> )} - {!homeAlbumRowsDisabled && isVisible('becauseYouLike') && mostPlayed.length > 0 && ( + {!homeAlbumRowsDisabled && isVisible('becauseYouLike') && becauseYouLikeHasSeed && ( )} diff --git a/src/styles/components.css b/src/styles/components.css index f3ca48ab..259c3016 100644 --- a/src/styles/components.css +++ b/src/styles/components.css @@ -13905,11 +13905,24 @@ html[data-perf-disable-home-artwork-clip="true"] .home-lite-artwork .song-card-c /* Because-you-like rail — performance-conscious by design. No filter/blur/transform animations (WebKitGTK / software-compositing friendly). */ +.because-you-like-rail { + container-type: inline-size; +} .because-card-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(340px, 1fr)); gap: 1rem; } +/* Hide the 3rd card only inside the 2-column range, so: + >= 1052px (3 cols) -> 3 cards in one row + 696-1051px (2 cols) -> 2 cards in one row, orphan dropped + < 696px (1 col) -> 3 cards stacked, orphan welcome + Math: 3*340 + 2*16 = 1052 (3-col cutoff), 2*340 + 1*16 = 696 (2-col cutoff). */ +@container (min-width: 696px) and (max-width: 1051px) { + .because-card-grid > :nth-child(n+3) { + display: none; + } +} .because-card { position: relative; overflow: hidden;