mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 07:15:47 +00:00
feat(home): Because you listened recommendation rail (#489)
* feat(home): "Because you listened" recommendation rail New Home rail (under Recently Added, default on, toggleable in Settings → Personalisation → Home Page) that surfaces 3 albums from artists similar to one of your top-played artists. Anchor rotates per Home mount so a different top-artist seeds the recommendations each visit; within each anchor, both the similar-artist subset and the chosen album per artist are randomised, so the same anchor returns different picks on subsequent visits. Card layout matches the regular Album cards' surface (--bg-card with accent-tinted border + 1px inset top highlight) and gets the same Play / Enqueue hover overlay buttons. Cover and meta scale via CSS only — no infinite animations, no filter/blur/transform, no compositing layers. Grid wraps below 3-up at <400px card width instead of shrinking. API budget: one getArtistInfo2 + 6 parallel getArtist calls per Home mount, both reusing the existing mostPlayed payload to derive the anchor pool (no extra API call to find top artists). All 8 locales seeded. * fix(home): ru plurals + per-server anchor + narrower card grid - ru: add _few / _many for becauseYouLikeTracks (CLDR Russian needs 4 forms — 3 треков was wrong, now 3 трека). - Anchor rotation memory is now per-server. The localStorage key becomes psysonic_because_anchor:<serverId>; switching servers no longer aliases server A's rotation onto server B's pool. - because-card grid minmax(400px, 1fr) -> minmax(340px, 1fr) so two cards fit side by side at typical sidebar-expanded widths instead of collapsing to a single card per row. * docs: CHANGELOG + Contributors entry for Because-you-listened rail (PR #489) * style(home): blurred cover backdrop + centred layout for Because-cards - Each Because-card renders the album cover as a blurred, low-opacity full-bleed background layer behind the existing cover thumb and text. Resolved through useCachedUrl so the cache layer feeds it (same key as the thumbnail) instead of a fresh salted URL on every render. - Card content (cover thumb + text block) now centred horizontally and vertically within the card; the meta line lives in a small pill that sits centred under the artist row. - Text contrast halo and meta-pill background are theme-aware via color-mix on var(--bg-card) / var(--text-primary), so the same rules read on dark and light themes (was hard-coded rgba black before and smudged the type on Latte / Nord Snowstorm).
This commit is contained in:
committed by
GitHub
parent
5b37ab70f1
commit
d1ff2fab51
@@ -0,0 +1,260 @@
|
||||
import React, { memo, useEffect, useMemo, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Play, ListPlus } from 'lucide-react';
|
||||
import {
|
||||
SubsonicAlbum,
|
||||
buildCoverArtUrl,
|
||||
coverArtCacheKey,
|
||||
getAlbum,
|
||||
getArtist,
|
||||
getArtistInfo,
|
||||
} from '../api/subsonic';
|
||||
import CachedImage, { useCachedUrl } from './CachedImage';
|
||||
import { usePlayerStore, songToTrack } from '../store/playerStore';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import { playAlbum } from '../utils/playAlbum';
|
||||
|
||||
const ANCHOR_KEY_PREFIX = 'psysonic_because_anchor:';
|
||||
const TOP_ARTIST_POOL = 8;
|
||||
const SIMILAR_FETCH = 12;
|
||||
const SIMILAR_PICK = 6;
|
||||
const SHOW_COUNT = 3;
|
||||
const COVER_SIZE = 300;
|
||||
|
||||
function shuffle<T>(arr: T[]): T[] {
|
||||
const out = arr.slice();
|
||||
for (let i = out.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
[out[i], out[j]] = [out[j], out[i]];
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
interface Anchor {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
mostPlayed: SubsonicAlbum[];
|
||||
disableArtwork?: boolean;
|
||||
}
|
||||
|
||||
function buildAnchorPool(albums: SubsonicAlbum[], limit: number): Anchor[] {
|
||||
const seen = new Set<string>();
|
||||
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;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function formatAlbumDuration(seconds: number, t: (key: string, opts?: Record<string, unknown>) => string): string {
|
||||
const totalMin = Math.max(0, Math.round(seconds / 60));
|
||||
const hours = Math.floor(totalMin / 60);
|
||||
const minutes = totalMin % 60;
|
||||
if (hours > 0) return t('common.durationHoursMinutes', { hours, minutes });
|
||||
return t('common.durationMinutesOnly', { minutes: totalMin });
|
||||
}
|
||||
|
||||
/** Anchor rotation memory is **per-server** — server A and server B keep
|
||||
* independent rotation state, so switching servers doesn't snap the anchor
|
||||
* back to the first artist of the new pool just because the previous server's
|
||||
* anchor id was unknown there. */
|
||||
function anchorKey(serverId: string | null): string | null {
|
||||
return serverId ? `${ANCHOR_KEY_PREFIX}${serverId}` : null;
|
||||
}
|
||||
|
||||
function rotateAnchor(pool: Anchor[], serverId: string | null): Anchor | null {
|
||||
if (pool.length === 0) return null;
|
||||
const key = anchorKey(serverId);
|
||||
let lastId: string | null = null;
|
||||
if (key) {
|
||||
try { lastId = localStorage.getItem(key); } catch { /* ignore */ }
|
||||
}
|
||||
if (!lastId) return pool[0];
|
||||
const idx = pool.findIndex(a => a.id === lastId);
|
||||
if (idx < 0) return pool[0];
|
||||
return pool[(idx + 1) % pool.length];
|
||||
}
|
||||
|
||||
export default function BecauseYouLikeRail({ mostPlayed, disableArtwork = false }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const activeServerId = useAuthStore(s => s.activeServerId);
|
||||
const pool = useMemo(() => buildAnchorPool(mostPlayed, TOP_ARTIST_POOL), [mostPlayed]);
|
||||
const [anchor, setAnchor] = useState<Anchor | null>(null);
|
||||
const [recs, setRecs] = useState<SubsonicAlbum[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const next = rotateAnchor(pool, activeServerId);
|
||||
setAnchor(next);
|
||||
setRecs([]);
|
||||
if (!next) return;
|
||||
const key = anchorKey(activeServerId);
|
||||
if (key) {
|
||||
try { localStorage.setItem(key, next.id); } catch { /* ignore */ }
|
||||
}
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const info = await getArtistInfo(next.id, { similarArtistCount: SIMILAR_FETCH });
|
||||
const similar = (info.similarArtist ?? []).filter(s => s.id);
|
||||
if (similar.length === 0) return;
|
||||
|
||||
const candidates = shuffle(similar).slice(0, SIMILAR_PICK);
|
||||
const results = await Promise.all(
|
||||
candidates.map(s => getArtist(s.id).catch(() => null))
|
||||
);
|
||||
|
||||
const picks: SubsonicAlbum[] = [];
|
||||
for (const r of results) {
|
||||
if (!r || r.albums.length === 0) continue;
|
||||
const album = r.albums[Math.floor(Math.random() * r.albums.length)];
|
||||
picks.push(album);
|
||||
if (picks.length >= SHOW_COUNT) break;
|
||||
}
|
||||
if (!cancelled) setRecs(picks);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
})();
|
||||
|
||||
return () => { cancelled = true; };
|
||||
}, [pool, activeServerId]);
|
||||
|
||||
if (!anchor || recs.length === 0) return null;
|
||||
|
||||
return (
|
||||
<section className="album-row-section because-you-like-rail">
|
||||
<div className="album-row-header">
|
||||
<h2 className="section-title" style={{ marginBottom: 0 }}>
|
||||
{t('home.becauseYouLikeFor', { artist: anchor.name })}
|
||||
</h2>
|
||||
</div>
|
||||
<div className="because-card-grid">
|
||||
{recs.map(album => (
|
||||
<BecauseCard
|
||||
key={album.id}
|
||||
album={album}
|
||||
anchor={anchor.name}
|
||||
disableArtwork={disableArtwork}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
interface CardProps {
|
||||
album: SubsonicAlbum;
|
||||
anchor: string;
|
||||
disableArtwork: boolean;
|
||||
}
|
||||
|
||||
const BecauseCard = memo(function BecauseCard({ album, anchor, disableArtwork }: CardProps) {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const enqueue = usePlayerStore(s => s.enqueue);
|
||||
const coverUrl = useMemo(
|
||||
() => (album.coverArt ? buildCoverArtUrl(album.coverArt, COVER_SIZE) : ''),
|
||||
[album.coverArt],
|
||||
);
|
||||
const coverKey = useMemo(
|
||||
() => (album.coverArt ? coverArtCacheKey(album.coverArt, COVER_SIZE) : ''),
|
||||
[album.coverArt],
|
||||
);
|
||||
const bgResolved = useCachedUrl(coverUrl, coverKey);
|
||||
|
||||
const handleOpen = () => navigate(`/album/${album.id}`);
|
||||
const handlePlay = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
playAlbum(album.id);
|
||||
};
|
||||
const handleEnqueue = async (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
try {
|
||||
const data = await getAlbum(album.id);
|
||||
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"
|
||||
onClick={handleOpen}
|
||||
onKeyDown={e => { if (e.key === 'Enter') handleOpen(); }}
|
||||
aria-label={`${album.name} – ${album.artist}`}
|
||||
>
|
||||
{!disableArtwork && bgResolved && (
|
||||
<div
|
||||
className="because-card-bg"
|
||||
style={{ backgroundImage: `url(${bgResolved})` }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
<div className="because-card-cover-wrap">
|
||||
{!disableArtwork && coverUrl ? (
|
||||
<CachedImage
|
||||
src={coverUrl}
|
||||
cacheKey={coverKey}
|
||||
alt={album.name}
|
||||
className="because-card-cover"
|
||||
loading="lazy"
|
||||
/>
|
||||
) : (
|
||||
<div className="because-card-cover because-card-cover-placeholder" aria-hidden="true" />
|
||||
)}
|
||||
<div className="album-card-play-overlay">
|
||||
<button
|
||||
type="button"
|
||||
className="album-card-details-btn"
|
||||
onClick={handlePlay}
|
||||
aria-label={t('hero.playAlbum')}
|
||||
data-tooltip={t('hero.playAlbum')}
|
||||
data-tooltip-pos="top"
|
||||
>
|
||||
<Play size={15} fill="currentColor" />
|
||||
</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">{album.artist}</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>{formatAlbumDuration(album.duration, t)}</span> : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user