diff --git a/CHANGELOG.md b/CHANGELOG.md index d88b3aa4..13e888ef 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -66,6 +66,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 * **Composers are a first-class share entity.** `psysonic2-` links with `k=composer` paste to `/composer/:id`; the Share button on the detail page and the right-click menu both copy a `composer` link. * Sidebar entry is **off by default** (classical-music use case is a niche) — toggle in Settings → Sidebar. +### Home — "Because you listened" recommendation rail + +**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#489](https://github.com/Psychotoxical/psysonic/pull/489)** + +* 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. +* Toggleable in the Home customizer like every other rail; respects the existing performance flags ("Disable rail artwork", "Disable Home album rows"). + ## Changed ### Dependencies — npm / Cargo refresh and rodio 0.22 diff --git a/src/components/BecauseYouLikeRail.tsx b/src/components/BecauseYouLikeRail.tsx new file mode 100644 index 00000000..46558583 --- /dev/null +++ b/src/components/BecauseYouLikeRail.tsx @@ -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(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(); + 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): 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(null); + const [recs, setRecs] = useState([]); + + 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 ( +
+
+

+ {t('home.becauseYouLikeFor', { artist: anchor.name })} +

+
+
+ {recs.map(album => ( + + ))} +
+
+ ); +} + +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 ( +
{ if (e.key === 'Enter') handleOpen(); }} + aria-label={`${album.name} – ${album.artist}`} + > + {!disableArtwork && bgResolved && ( +