mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-21 22:15:40 +00:00
feat(home): broaden Because-you-like seed pool + tidy orphan card at 1080p (#493)
* 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".
This commit is contained in:
committed by
GitHub
parent
b01e76df9c
commit
d75670ec4b
+5
-4
@@ -68,11 +68,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
### Home — "Because you listened" recommendation rail
|
### 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.
|
* New Home rail that surfaces albums **similar to one of your favourite 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 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.
|
* 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").
|
* Toggleable in the Home customizer like every other rail; respects the existing performance flags ("Disable rail artwork", "Disable Home album rows").
|
||||||
|
|
||||||
## Changed
|
## Changed
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import { useAuthStore } from '../store/authStore';
|
|||||||
import { playAlbum } from '../utils/playAlbum';
|
import { playAlbum } from '../utils/playAlbum';
|
||||||
|
|
||||||
const ANCHOR_KEY_PREFIX = 'psysonic_because_anchor:';
|
const ANCHOR_KEY_PREFIX = 'psysonic_because_anchor:';
|
||||||
const TOP_ARTIST_POOL = 8;
|
const TOP_ARTIST_POOL = 12;
|
||||||
const SIMILAR_FETCH = 12;
|
const SIMILAR_FETCH = 12;
|
||||||
const SIMILAR_PICK = 6;
|
const SIMILAR_PICK = 6;
|
||||||
const SHOW_COUNT = 3;
|
const SHOW_COUNT = 3;
|
||||||
@@ -38,17 +38,27 @@ interface Anchor {
|
|||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
mostPlayed: SubsonicAlbum[];
|
mostPlayed: SubsonicAlbum[];
|
||||||
|
recentlyPlayed?: SubsonicAlbum[];
|
||||||
|
starred?: SubsonicAlbum[];
|
||||||
disableArtwork?: boolean;
|
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<string>();
|
const seen = new Set<string>();
|
||||||
const out: Anchor[] = [];
|
const out: Anchor[] = [];
|
||||||
for (const a of albums) {
|
const maxLen = sources.reduce((m, s) => Math.max(m, s.length), 0);
|
||||||
if (!a.artistId || seen.has(a.artistId)) continue;
|
for (let i = 0; i < maxLen && out.length < limit; i++) {
|
||||||
seen.add(a.artistId);
|
for (const src of sources) {
|
||||||
out.push({ id: a.artistId, name: a.artist });
|
if (out.length >= limit) break;
|
||||||
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;
|
return out;
|
||||||
}
|
}
|
||||||
@@ -82,10 +92,18 @@ function rotateAnchor(pool: Anchor[], serverId: string | null): Anchor | null {
|
|||||||
return pool[(idx + 1) % pool.length];
|
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 { t } = useTranslation();
|
||||||
const activeServerId = useAuthStore(s => s.activeServerId);
|
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<Anchor | null>(null);
|
const [anchor, setAnchor] = useState<Anchor | null>(null);
|
||||||
const [recs, setRecs] = useState<SubsonicAlbum[]>([]);
|
const [recs, setRecs] = useState<SubsonicAlbum[]>([]);
|
||||||
|
|
||||||
|
|||||||
+6
-2
@@ -168,11 +168,13 @@ export default function Home() {
|
|||||||
isVisible('mostPlayed') &&
|
isVisible('mostPlayed') &&
|
||||||
mostPlayed.length > 0 &&
|
mostPlayed.length > 0 &&
|
||||||
reserveArtworkRow();
|
reserveArtworkRow();
|
||||||
|
const becauseYouLikeHasSeed =
|
||||||
|
mostPlayed.length > 0 || recentlyPlayed.length > 0 || starred.length > 0;
|
||||||
const becauseYouLikeArtworkEnabled =
|
const becauseYouLikeArtworkEnabled =
|
||||||
!homeRailArtworkDisabled &&
|
!homeRailArtworkDisabled &&
|
||||||
!homeAlbumRowsDisabled &&
|
!homeAlbumRowsDisabled &&
|
||||||
isVisible('becauseYouLike') &&
|
isVisible('becauseYouLike') &&
|
||||||
mostPlayed.length > 0 &&
|
becauseYouLikeHasSeed &&
|
||||||
reserveArtworkRow();
|
reserveArtworkRow();
|
||||||
|
|
||||||
const homeLiteArtworkFx = perfFlags.disableHomeArtworkFx;
|
const homeLiteArtworkFx = perfFlags.disableHomeArtworkFx;
|
||||||
@@ -201,9 +203,11 @@ export default function Home() {
|
|||||||
initialArtworkBudget={HOME_ALBUM_ROW_INITIAL_ARTWORK_BUDGET}
|
initialArtworkBudget={HOME_ALBUM_ROW_INITIAL_ARTWORK_BUDGET}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{!homeAlbumRowsDisabled && isVisible('becauseYouLike') && mostPlayed.length > 0 && (
|
{!homeAlbumRowsDisabled && isVisible('becauseYouLike') && becauseYouLikeHasSeed && (
|
||||||
<BecauseYouLikeRail
|
<BecauseYouLikeRail
|
||||||
mostPlayed={mostPlayed}
|
mostPlayed={mostPlayed}
|
||||||
|
recentlyPlayed={recentlyPlayed}
|
||||||
|
starred={starred}
|
||||||
disableArtwork={!becauseYouLikeArtworkEnabled}
|
disableArtwork={!becauseYouLikeArtworkEnabled}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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.
|
/* Because-you-like rail — performance-conscious by design.
|
||||||
No filter/blur/transform animations (WebKitGTK / software-compositing friendly). */
|
No filter/blur/transform animations (WebKitGTK / software-compositing friendly). */
|
||||||
|
.because-you-like-rail {
|
||||||
|
container-type: inline-size;
|
||||||
|
}
|
||||||
.because-card-grid {
|
.because-card-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fit, minmax(340px, 1fr));
|
grid-template-columns: repeat(auto-fit, minmax(340px, 1fr));
|
||||||
gap: 1rem;
|
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 {
|
.because-card {
|
||||||
position: relative;
|
position: relative;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
|||||||
Reference in New Issue
Block a user