feat(home): add Discover Songs rail to Mainstage (#301)

Reuses the SongRail component from the Tracks hub — 18 random songs
fetched in parallel with the other Home queries. No reroll button.
Click-to-play uses enqueueAndPlay so the existing queue stays intact.

New homeStore section id 'discoverSongs' inserted after 'discover'
in DEFAULT_HOME_SECTIONS. onRehydrateStorage now appends any newly
introduced sections to a previously persisted layout — existing users
get the rail without a manual Reset. Settings → Personalisation
Home-Customizer surfaces it automatically via SECTION_LABELS.

i18n key home.discoverSongs added to all 8 locales.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Frank Stellmacher
2026-04-25 14:32:20 +02:00
committed by GitHub
parent e3aabd98b7
commit 807e7b4520
11 changed files with 40 additions and 4 deletions
+1
View File
@@ -41,6 +41,7 @@ export const deTranslation = {
mostPlayed: 'Meistgehört', mostPlayed: 'Meistgehört',
recentlyPlayed: 'Kürzlich gespielt', recentlyPlayed: 'Kürzlich gespielt',
discover: 'Entdecken', discover: 'Entdecken',
discoverSongs: 'Titel entdecken',
loadMore: 'Mehr laden', loadMore: 'Mehr laden',
discoverMore: 'Mehr entdecken', discoverMore: 'Mehr entdecken',
discoverArtists: 'Künstler entdecken', discoverArtists: 'Künstler entdecken',
+1
View File
@@ -43,6 +43,7 @@ export const enTranslation = {
mostPlayed: 'Most Played', mostPlayed: 'Most Played',
recentlyPlayed: 'Recently Played', recentlyPlayed: 'Recently Played',
discover: 'Discover', discover: 'Discover',
discoverSongs: 'Discover Songs',
loadMore: 'Load More', loadMore: 'Load More',
discoverMore: 'Discover More', discoverMore: 'Discover More',
discoverArtists: 'Discover Artists', discoverArtists: 'Discover Artists',
+1
View File
@@ -42,6 +42,7 @@ export const esTranslation = {
mostPlayed: 'Más Reproducidos', mostPlayed: 'Más Reproducidos',
recentlyPlayed: 'Reproducidos Recientemente', recentlyPlayed: 'Reproducidos Recientemente',
discover: 'Descubrir', discover: 'Descubrir',
discoverSongs: 'Descubrir canciones',
loadMore: 'Cargar Más', loadMore: 'Cargar Más',
discoverMore: 'Descubrir Más', discoverMore: 'Descubrir Más',
discoverArtists: 'Descubrir Artistas', discoverArtists: 'Descubrir Artistas',
+1
View File
@@ -41,6 +41,7 @@ export const frTranslation = {
mostPlayed: 'Les plus écoutés', mostPlayed: 'Les plus écoutés',
recentlyPlayed: 'Récemment écoutés', recentlyPlayed: 'Récemment écoutés',
discover: 'Découvrir', discover: 'Découvrir',
discoverSongs: 'Découvrir des titres',
loadMore: 'Charger plus', loadMore: 'Charger plus',
discoverMore: 'Découvrir plus', discoverMore: 'Découvrir plus',
discoverArtists: 'Découvrir des artistes', discoverArtists: 'Découvrir des artistes',
+1
View File
@@ -41,6 +41,7 @@ export const nbTranslation = {
mostPlayed: 'Mest spilt', mostPlayed: 'Mest spilt',
recentlyPlayed: 'Nylig spilt', recentlyPlayed: 'Nylig spilt',
discover: 'Oppdag', discover: 'Oppdag',
discoverSongs: 'Oppdag spor',
loadMore: 'Last inn flere', loadMore: 'Last inn flere',
discoverMore: 'Oppdag flere', discoverMore: 'Oppdag flere',
discoverArtists: 'Oppdag artister', discoverArtists: 'Oppdag artister',
+1
View File
@@ -41,6 +41,7 @@ export const nlTranslation = {
mostPlayed: 'Meest gespeeld', mostPlayed: 'Meest gespeeld',
recentlyPlayed: 'Recent afgespeeld', recentlyPlayed: 'Recent afgespeeld',
discover: 'Ontdekken', discover: 'Ontdekken',
discoverSongs: 'Nummers ontdekken',
loadMore: 'Meer laden', loadMore: 'Meer laden',
discoverMore: 'Meer ontdekken', discoverMore: 'Meer ontdekken',
discoverArtists: 'Artiesten ontdekken', discoverArtists: 'Artiesten ontdekken',
+1
View File
@@ -43,6 +43,7 @@ export const ruTranslation = {
mostPlayed: 'Популярное', mostPlayed: 'Популярное',
recentlyPlayed: 'Недавно проиграно', recentlyPlayed: 'Недавно проиграно',
discover: 'Обзор', discover: 'Обзор',
discoverSongs: 'Открыть треки',
loadMore: 'Ещё', loadMore: 'Ещё',
discoverMore: 'Смотреть ещё', discoverMore: 'Смотреть ещё',
discoverArtists: 'Исполнители', discoverArtists: 'Исполнители',
+1
View File
@@ -41,6 +41,7 @@ export const zhTranslation = {
mostPlayed: '最常播放', mostPlayed: '最常播放',
recentlyPlayed: '最近播放', recentlyPlayed: '最近播放',
discover: '发现', discover: '发现',
discoverSongs: '发现曲目',
loadMore: '加载更多', loadMore: '加载更多',
discoverMore: '发现更多', discoverMore: '发现更多',
discoverArtists: '发现艺术家', discoverArtists: '发现艺术家',
+15 -2
View File
@@ -1,7 +1,8 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import Hero from '../components/Hero'; import Hero from '../components/Hero';
import AlbumRow from '../components/AlbumRow'; import AlbumRow from '../components/AlbumRow';
import { getAlbumList, getArtists, SubsonicAlbum, SubsonicArtist } from '../api/subsonic'; import SongRail from '../components/SongRail';
import { getAlbumList, getArtists, getRandomSongs, SubsonicAlbum, SubsonicArtist, SubsonicSong } from '../api/subsonic';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { NavLink, useNavigate } from 'react-router-dom'; import { NavLink, useNavigate } from 'react-router-dom';
import { ChevronRight } from 'lucide-react'; import { ChevronRight } from 'lucide-react';
@@ -13,6 +14,7 @@ import { filterAlbumsByMixRatings, getMixMinRatingsConfigFromAuth } from '../uti
const HOME_RANDOM_FETCH = 100; const HOME_RANDOM_FETCH = 100;
const HOME_HERO_COUNT = 8; const HOME_HERO_COUNT = 8;
const HOME_DISCOVER_SLICE = 20; const HOME_DISCOVER_SLICE = 20;
const HOME_DISCOVER_SONGS_SIZE = 18;
export default function Home() { export default function Home() {
const homeSections = useHomeStore(s => s.sections); const homeSections = useHomeStore(s => s.sections);
@@ -30,6 +32,7 @@ export default function Home() {
const [mostPlayed, setMostPlayed] = useState<SubsonicAlbum[]>([]); const [mostPlayed, setMostPlayed] = useState<SubsonicAlbum[]>([]);
const [recentlyPlayed, setRecentlyPlayed] = useState<SubsonicAlbum[]>([]); const [recentlyPlayed, setRecentlyPlayed] = useState<SubsonicAlbum[]>([]);
const [randomArtists, setRandomArtists] = useState<SubsonicArtist[]>([]); const [randomArtists, setRandomArtists] = useState<SubsonicArtist[]>([]);
const [discoverSongs, setDiscoverSongs] = useState<SubsonicSong[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
useEffect(() => { useEffect(() => {
@@ -41,13 +44,16 @@ export default function Home() {
const albumMix = const albumMix =
mixCfg.enabled && (mixCfg.minAlbum > 0 || mixCfg.minArtist > 0); mixCfg.enabled && (mixCfg.minAlbum > 0 || mixCfg.minArtist > 0);
const randomSize = albumMix ? HOME_RANDOM_FETCH : HOME_DISCOVER_SLICE; const randomSize = albumMix ? HOME_RANDOM_FETCH : HOME_DISCOVER_SLICE;
const [s, n, rRaw, f, rp, artists] = await Promise.all([ const [s, n, rRaw, f, rp, artists, songs] = await Promise.all([
getAlbumList('starred', 12).catch(() => []), getAlbumList('starred', 12).catch(() => []),
getAlbumList('newest', 12).catch(() => []), getAlbumList('newest', 12).catch(() => []),
getAlbumList('random', randomSize).catch(() => []), getAlbumList('random', randomSize).catch(() => []),
getAlbumList('frequent', 12).catch(() => []), getAlbumList('frequent', 12).catch(() => []),
getAlbumList('recent', 12).catch(() => []), getAlbumList('recent', 12).catch(() => []),
isVisible('discoverArtists') ? getArtists().catch(() => []) : Promise.resolve<SubsonicArtist[]>([]), isVisible('discoverArtists') ? getArtists().catch(() => []) : Promise.resolve<SubsonicArtist[]>([]),
isVisible('discoverSongs')
? getRandomSongs(HOME_DISCOVER_SONGS_SIZE).catch(() => [] as SubsonicSong[])
: Promise.resolve<SubsonicSong[]>([]),
]); ]);
if (cancelled) return; if (cancelled) return;
const r = await filterAlbumsByMixRatings(rRaw, mixCfg); const r = await filterAlbumsByMixRatings(rRaw, mixCfg);
@@ -57,6 +63,7 @@ export default function Home() {
setRandom(r.slice(HOME_HERO_COUNT, HOME_DISCOVER_SLICE)); setRandom(r.slice(HOME_HERO_COUNT, HOME_DISCOVER_SLICE));
setMostPlayed(f); setMostPlayed(f);
setRecentlyPlayed(rp); setRecentlyPlayed(rp);
setDiscoverSongs(songs);
const shuffled = [...artists]; const shuffled = [...artists];
for (let i = shuffled.length - 1; i > 0; i--) { for (let i = shuffled.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1)); const j = Math.floor(Math.random() * (i + 1));
@@ -128,6 +135,12 @@ export default function Home() {
moreText={t('home.discoverMore')} moreText={t('home.discoverMore')}
/> />
)} )}
{isVisible('discoverSongs') && discoverSongs.length > 0 && (
<SongRail
title={t('home.discoverSongs')}
songs={discoverSongs}
/>
)}
{isVisible('discoverArtists') && randomArtists.length > 0 && ( {isVisible('discoverArtists') && randomArtists.length > 0 && (
<section className="album-row-section"> <section className="album-row-section">
<div className="album-row-header"> <div className="album-row-header">
+1
View File
@@ -4209,6 +4209,7 @@ function HomeCustomizer() {
hero: t('home.hero'), hero: t('home.hero'),
recent: t('home.recent'), recent: t('home.recent'),
discover: t('home.discover'), discover: t('home.discover'),
discoverSongs: t('home.discoverSongs'),
discoverArtists: t('home.discoverArtists'), discoverArtists: t('home.discoverArtists'),
recentlyPlayed: t('home.recentlyPlayed'), recentlyPlayed: t('home.recentlyPlayed'),
starred: t('home.starred'), starred: t('home.starred'),
+16 -2
View File
@@ -1,7 +1,7 @@
import { create } from 'zustand'; import { create } from 'zustand';
import { persist } from 'zustand/middleware'; import { persist } from 'zustand/middleware';
export type HomeSectionId = 'hero' | 'recent' | 'discover' | 'discoverArtists' | 'recentlyPlayed' | 'starred' | 'mostPlayed'; export type HomeSectionId = 'hero' | 'recent' | 'discover' | 'discoverSongs' | 'discoverArtists' | 'recentlyPlayed' | 'starred' | 'mostPlayed';
export interface HomeSectionConfig { export interface HomeSectionConfig {
id: HomeSectionId; id: HomeSectionId;
@@ -12,6 +12,7 @@ export const DEFAULT_HOME_SECTIONS: HomeSectionConfig[] = [
{ id: 'hero', visible: true }, { id: 'hero', visible: true },
{ id: 'recent', visible: true }, { id: 'recent', visible: true },
{ id: 'discover', visible: true }, { id: 'discover', visible: true },
{ id: 'discoverSongs', visible: true },
{ id: 'discoverArtists', visible: true }, { id: 'discoverArtists', visible: true },
{ id: 'recentlyPlayed', visible: true }, { id: 'recentlyPlayed', visible: true },
{ id: 'starred', visible: true }, { id: 'starred', visible: true },
@@ -33,6 +34,19 @@ export const useHomeStore = create<HomeStore>()(
})), })),
reset: () => set({ sections: DEFAULT_HOME_SECTIONS }), reset: () => set({ sections: DEFAULT_HOME_SECTIONS }),
}), }),
{ name: 'psysonic_home' } {
name: 'psysonic_home',
onRehydrateStorage: () => (state) => {
// Append any sections introduced after the user first persisted their order,
// so new defaults show up without forcing a manual Reset.
if (!state) return;
const safe = (state.sections ?? []).filter(
(s): s is HomeSectionConfig => s != null && typeof s.id === 'string',
);
const known = new Set(safe.map(s => s.id));
const missing = DEFAULT_HOME_SECTIONS.filter(s => !known.has(s.id));
state.sections = missing.length > 0 ? [...safe, ...missing] : safe;
},
}
) )
); );