mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-21 14:55:43 +00:00
fix(mainstage): rename Home Page setting, start-page fallback + empty state (#975)
* fix(mainstage): rename Home Page setting, add start-page fallback + empty state - Rename "Home Page" personalisation section to "Mainstage" across 9 locales so the heading matches the sidebar entry; add "mainstage" search keyword. - Index route "/" now redirects to the first visible library item when the Mainstage sidebar entry is hidden, instead of stranding the app on a blank page. New pure resolveStartRoute() mirrors sidebar order + nav-mode gating. - Show a guided empty state on Mainstage when every section is toggled off, with a CTA into Settings -> Personalisation. - Unit tests for resolveStartRoute. * docs(changelog): Mainstage rename, start-page fallback + empty state (#975)
This commit is contained in:
committed by
GitHub
parent
3be8c367dd
commit
908c349cfd
@@ -411,6 +411,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
### Mainstage — renamed to match the sidebar
|
||||||
|
|
||||||
|
**By [@Psychotoxical](https://github.com/Psychotoxical), reported by zunoz on Discord, PR [#975](https://github.com/Psychotoxical/psysonic/pull/975)**
|
||||||
|
|
||||||
|
* The **Settings → Personalisation** section for customising the home page is renamed **Mainstage** so it matches the sidebar entry it controls. Localised across all 9 languages; settings search still finds it under the new name.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
## Fixed
|
## Fixed
|
||||||
|
|
||||||
@@ -840,6 +848,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
* The queue collapse handle now shows a hand cursor like every other button; the thin resize line beside it keeps the resize cursor.
|
* The queue collapse handle now shows a hand cursor like every other button; the thin resize line beside it keeps the resize cursor.
|
||||||
* On Favorites, the **Plays**, **Last Played** and **BPM** columns are now actually sortable — they showed a clickable cursor but clicking did nothing.
|
* On Favorites, the **Plays**, **Last Played** and **BPM** columns are now actually sortable — they showed a clickable cursor but clicking did nothing.
|
||||||
|
|
||||||
|
### Mainstage — no more blank start page
|
||||||
|
|
||||||
|
**By [@Psychotoxical](https://github.com/Psychotoxical), reported by zunoz on Discord, PR [#975](https://github.com/Psychotoxical/psysonic/pull/975)**
|
||||||
|
|
||||||
|
* Hiding **Mainstage** from the sidebar no longer leaves the app opening on a blank page — it now starts on the first visible library entry instead.
|
||||||
|
* When every Mainstage section is turned off, the page shows a short message with a shortcut into **Settings → Personalisation** rather than appearing empty.
|
||||||
|
|
||||||
## [1.46.0] - 2026-05-18
|
## [1.46.0] - 2026-05-18
|
||||||
|
|
||||||
> **🙏 Special thanks to [@zz5zz](https://github.com/zz5zz)** for his tireless quirk-spotting and bug reports on the [Psysonic Discord](https://discord.gg/AMnDRErm4u) — several of the polish fixes in this release landed directly off the back of his messages.
|
> **🙏 Special thanks to [@zz5zz](https://github.com/zz5zz)** for his tireless quirk-spotting and bug reports on the [Psysonic Discord](https://discord.gg/AMnDRErm4u) — several of the polish fixes in this release landed directly off the back of his messages.
|
||||||
|
|||||||
+23
-2
@@ -1,7 +1,11 @@
|
|||||||
import { lazy } from 'react';
|
import { lazy } from 'react';
|
||||||
import { Route, Routes } from 'react-router-dom';
|
import { Navigate, Route, Routes } from 'react-router-dom';
|
||||||
import MobilePlayerView from '../components/MobilePlayerView';
|
import MobilePlayerView from '../components/MobilePlayerView';
|
||||||
import { useIsMobile } from '../hooks/useIsMobile';
|
import { useIsMobile } from '../hooks/useIsMobile';
|
||||||
|
import { useSidebarStore } from '../store/sidebarStore';
|
||||||
|
import { useAuthStore } from '../store/authStore';
|
||||||
|
import { useLuckyMixAvailable } from '../hooks/useLuckyMixAvailable';
|
||||||
|
import { resolveStartRoute } from '../utils/componentHelpers/sidebarNavReorder';
|
||||||
|
|
||||||
// Route-level lazy loading: keeps the non-page graph (shell, player, stores) in
|
// Route-level lazy loading: keeps the non-page graph (shell, player, stores) in
|
||||||
// the entry chunk; each page is fetched when its route is first visited.
|
// the entry chunk; each page is fetched when its route is first visited.
|
||||||
@@ -36,6 +40,23 @@ const InternetRadio = lazy(() => import('../pages/InternetRadio'));
|
|||||||
const Genres = lazy(() => import('../pages/Genres'));
|
const Genres = lazy(() => import('../pages/Genres'));
|
||||||
const GenreDetail = lazy(() => import('../pages/GenreDetail'));
|
const GenreDetail = lazy(() => import('../pages/GenreDetail'));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Index route ("/") = Mainstage. When the user has hidden Mainstage from the
|
||||||
|
* sidebar there is no nav link back to it, so landing on "/" would strand them
|
||||||
|
* on a page they deliberately removed (and which is blank when its sections are
|
||||||
|
* all off too). In that case redirect to the first visible library entry —
|
||||||
|
* mirroring the sidebar's own ordering — so the app never opens on a dead page.
|
||||||
|
*/
|
||||||
|
function MainstageRoute() {
|
||||||
|
const items = useSidebarStore(s => s.items);
|
||||||
|
const randomNavMode = useAuthStore(s => s.randomNavMode);
|
||||||
|
const luckyMixAvailable = useLuckyMixAvailable() && randomNavMode === 'separate';
|
||||||
|
const mainstageVisible = items.find(i => i.id === 'mainstage')?.visible ?? true;
|
||||||
|
if (mainstageVisible) return <Home />;
|
||||||
|
const target = resolveStartRoute(items, randomNavMode, luckyMixAvailable);
|
||||||
|
return target ? <Navigate to={target} replace /> : <Home />;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The main application route table. Rendered inside `AppShell`'s scroll
|
* The main application route table. Rendered inside `AppShell`'s scroll
|
||||||
* viewport. `/now-playing` swaps to the mobile player view on narrow widths;
|
* viewport. `/now-playing` swaps to the mobile player view on narrow widths;
|
||||||
@@ -46,7 +67,7 @@ export default function AppRoutes() {
|
|||||||
const isMobile = useIsMobile();
|
const isMobile = useIsMobile();
|
||||||
return (
|
return (
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={<Home />} />
|
<Route path="/" element={<MainstageRoute />} />
|
||||||
<Route path="/albums" element={<Albums />} />
|
<Route path="/albums" element={<Albums />} />
|
||||||
<Route path="/tracks" element={<SearchBrowsePage />} />
|
<Route path="/tracks" element={<SearchBrowsePage />} />
|
||||||
<Route path="/random" element={<RandomLanding />} />
|
<Route path="/random" element={<RandomLanding />} />
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ export const SETTINGS_INDEX: SearchIndexEntry[] = [
|
|||||||
{ tab: 'integrations', titleKey: 'settings.nowPlayingEnabled', keywords: 'now playing share dropdown presence' },
|
{ tab: 'integrations', titleKey: 'settings.nowPlayingEnabled', keywords: 'now playing share dropdown presence' },
|
||||||
{ tab: 'personalisation',titleKey: 'settings.sidebarTitle', keywords: 'sidebar nav navigation items reorder customize' },
|
{ tab: 'personalisation',titleKey: 'settings.sidebarTitle', keywords: 'sidebar nav navigation items reorder customize' },
|
||||||
{ tab: 'personalisation',titleKey: 'settings.artistLayoutTitle', keywords: 'artist page layout sections order' },
|
{ tab: 'personalisation',titleKey: 'settings.artistLayoutTitle', keywords: 'artist page layout sections order' },
|
||||||
{ tab: 'personalisation',titleKey: 'settings.homeCustomizerTitle', keywords: 'home page customize sections' },
|
{ tab: 'personalisation',titleKey: 'settings.homeCustomizerTitle', keywords: 'mainstage home page customize sections' },
|
||||||
{ tab: 'personalisation',titleKey: 'settings.queueToolbarTitle', keywords: 'queue toolbar buttons reorder customize shuffle save load' },
|
{ tab: 'personalisation',titleKey: 'settings.queueToolbarTitle', keywords: 'queue toolbar buttons reorder customize shuffle save load' },
|
||||||
{ tab: 'personalisation',titleKey: 'settings.playlistLayoutTitle', keywords: 'playlist page layout add songs import csv download zip cache offline suggestions controls hide show' },
|
{ tab: 'personalisation',titleKey: 'settings.playlistLayoutTitle', keywords: 'playlist page layout add songs import csv download zip cache offline suggestions controls hide show' },
|
||||||
{ tab: 'personalisation',titleKey: 'settings.playerBarTitle', keywords: 'player bar playback favorites stars rating lastfm love equalizer mini player controls hide show' },
|
{ tab: 'personalisation',titleKey: 'settings.playerBarTitle', keywords: 'player bar playback favorites stars rating lastfm love equalizer mini player controls hide show' },
|
||||||
|
|||||||
@@ -14,5 +14,8 @@ export const home = {
|
|||||||
becauseYouLikeFor: 'Weil du {{artist}} gehört hast',
|
becauseYouLikeFor: 'Weil du {{artist}} gehört hast',
|
||||||
similarTo: 'Ähnlich wie {{artist}}',
|
similarTo: 'Ähnlich wie {{artist}}',
|
||||||
becauseYouLikeTracks_one: '{{count}} Titel',
|
becauseYouLikeTracks_one: '{{count}} Titel',
|
||||||
becauseYouLikeTracks_other: '{{count}} Titel'
|
becauseYouLikeTracks_other: '{{count}} Titel',
|
||||||
|
mainstageEmptyTitle: 'Deine Mainstage ist leer',
|
||||||
|
mainstageEmptyBody: 'Alle Bereiche sind ausgeschaltet. Schalte Bereiche wieder ein oder blende die Mainstage aus der Seitenleiste aus.',
|
||||||
|
mainstageEmptyCta: 'Personalisierung öffnen'
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -326,7 +326,7 @@ export const settings = {
|
|||||||
searchNoResults: 'Keine Treffer für deine Suche.',
|
searchNoResults: 'Keine Treffer für deine Suche.',
|
||||||
integrationsPrivacyTitle: 'Hinweis zum Datenschutz',
|
integrationsPrivacyTitle: 'Hinweis zum Datenschutz',
|
||||||
integrationsPrivacyBody: 'Alle Integrationen auf diesem Tab sind <strong>Opt-in</strong> und senden, sobald aktiviert, Daten an externe Dienste oder an deinen Navidrome-Server. Last.fm erhält deinen Wiedergabeverlauf, Discord zeigt den aktuell laufenden Track in deinem Profil an, Bandsintown wird pro Künstler für Tour-Daten abgefragt, und der „Jetzt läuft"-Hinweis veröffentlicht deinen aktuellen Titel für andere Benutzer deines Navidrome-Servers. Wer das nicht möchte, lässt den jeweiligen Abschnitt einfach deaktiviert.',
|
integrationsPrivacyBody: 'Alle Integrationen auf diesem Tab sind <strong>Opt-in</strong> und senden, sobald aktiviert, Daten an externe Dienste oder an deinen Navidrome-Server. Last.fm erhält deinen Wiedergabeverlauf, Discord zeigt den aktuell laufenden Track in deinem Profil an, Bandsintown wird pro Künstler für Tour-Daten abgefragt, und der „Jetzt läuft"-Hinweis veröffentlicht deinen aktuellen Titel für andere Benutzer deines Navidrome-Servers. Wer das nicht möchte, lässt den jeweiligen Abschnitt einfach deaktiviert.',
|
||||||
homeCustomizerTitle: 'Startseite',
|
homeCustomizerTitle: 'Mainstage',
|
||||||
queueModeTitle: 'Warteschlangen-Ansicht',
|
queueModeTitle: 'Warteschlangen-Ansicht',
|
||||||
queueModeQueueSub: 'Zeigt nur kommende Titel. Der laufende Titel bleibt im Kopf und verschwindet aus der Liste, sobald er gespielt wurde.',
|
queueModeQueueSub: 'Zeigt nur kommende Titel. Der laufende Titel bleibt im Kopf und verschwindet aus der Liste, sobald er gespielt wurde.',
|
||||||
queueModePlaylistSub: 'Behält die ganze Warteschlange in der Liste, der laufende Titel oben hervorgehoben; gespielte Titel bleiben stehen.',
|
queueModePlaylistSub: 'Behält die ganze Warteschlange in der Liste, der laufende Titel oben hervorgehoben; gespielte Titel bleiben stehen.',
|
||||||
|
|||||||
@@ -14,5 +14,8 @@ export const home = {
|
|||||||
becauseYouLikeFor: 'Because you listened to {{artist}}',
|
becauseYouLikeFor: 'Because you listened to {{artist}}',
|
||||||
similarTo: 'Similar to {{artist}}',
|
similarTo: 'Similar to {{artist}}',
|
||||||
becauseYouLikeTracks_one: '{{count}} track',
|
becauseYouLikeTracks_one: '{{count}} track',
|
||||||
becauseYouLikeTracks_other: '{{count}} tracks'
|
becauseYouLikeTracks_other: '{{count}} tracks',
|
||||||
|
mainstageEmptyTitle: 'Your Mainstage is empty',
|
||||||
|
mainstageEmptyBody: 'Every section is turned off. Turn sections back on, or hide Mainstage from the sidebar.',
|
||||||
|
mainstageEmptyCta: 'Open Personalisation settings'
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -390,7 +390,7 @@ export const settings = {
|
|||||||
searchNoResults: 'No settings match your search.',
|
searchNoResults: 'No settings match your search.',
|
||||||
integrationsPrivacyTitle: 'Privacy notice',
|
integrationsPrivacyTitle: 'Privacy notice',
|
||||||
integrationsPrivacyBody: 'All integrations on this tab are <strong>opt-in</strong> and send data to external services or to your Navidrome server when enabled. Last.fm receives your listening history, Discord shows the currently playing track in your profile, Bandsintown is queried per artist to fetch tour dates, and the Now-Playing share publishes your current track to other users of your Navidrome server. If you don\'t want any of this, simply leave the corresponding section disabled.',
|
integrationsPrivacyBody: 'All integrations on this tab are <strong>opt-in</strong> and send data to external services or to your Navidrome server when enabled. Last.fm receives your listening history, Discord shows the currently playing track in your profile, Bandsintown is queried per artist to fetch tour dates, and the Now-Playing share publishes your current track to other users of your Navidrome server. If you don\'t want any of this, simply leave the corresponding section disabled.',
|
||||||
homeCustomizerTitle: 'Home Page',
|
homeCustomizerTitle: 'Mainstage',
|
||||||
queueModeTitle: 'Queue Display Mode',
|
queueModeTitle: 'Queue Display Mode',
|
||||||
queueModeQueueSub: 'Show only upcoming tracks. The current track stays in the header and leaves the list once it has played.',
|
queueModeQueueSub: 'Show only upcoming tracks. The current track stays in the header and leaves the list once it has played.',
|
||||||
queueModePlaylistSub: 'Keep the whole queue in the list with the current track highlighted at the top; played tracks stay.',
|
queueModePlaylistSub: 'Keep the whole queue in the list with the current track highlighted at the top; played tracks stay.',
|
||||||
|
|||||||
@@ -14,5 +14,8 @@ export const home = {
|
|||||||
becauseYouLikeFor: 'Porque escuchaste a {{artist}}',
|
becauseYouLikeFor: 'Porque escuchaste a {{artist}}',
|
||||||
similarTo: 'Similar a {{artist}}',
|
similarTo: 'Similar a {{artist}}',
|
||||||
becauseYouLikeTracks_one: '{{count}} canción',
|
becauseYouLikeTracks_one: '{{count}} canción',
|
||||||
becauseYouLikeTracks_other: '{{count}} canciones'
|
becauseYouLikeTracks_other: '{{count}} canciones',
|
||||||
|
mainstageEmptyTitle: 'Tu Escenario Principal está vacío',
|
||||||
|
mainstageEmptyBody: 'Todas las secciones están desactivadas. Vuelve a activarlas u oculta el Escenario Principal de la barra lateral.',
|
||||||
|
mainstageEmptyCta: 'Abrir Personalización'
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -325,7 +325,7 @@ export const settings = {
|
|||||||
aboutMaintainersLabel: 'Mantenedores',
|
aboutMaintainersLabel: 'Mantenedores',
|
||||||
integrationsPrivacyTitle: 'Aviso de privacidad',
|
integrationsPrivacyTitle: 'Aviso de privacidad',
|
||||||
integrationsPrivacyBody: 'Todas las integraciones de esta pestaña son <strong>opt-in</strong> y, una vez activadas, envían datos a servicios externos o a tu servidor Navidrome. Last.fm recibe tu historial de escucha, Discord muestra la pista actual en tu perfil, Bandsintown se consulta por artista para obtener fechas de gira, y la compartición «En reproducción» publica tu pista actual para otros usuarios de tu servidor Navidrome. Si no quieres nada de esto, simplemente deja desactivada la sección correspondiente.',
|
integrationsPrivacyBody: 'Todas las integraciones de esta pestaña son <strong>opt-in</strong> y, una vez activadas, envían datos a servicios externos o a tu servidor Navidrome. Last.fm recibe tu historial de escucha, Discord muestra la pista actual en tu perfil, Bandsintown se consulta por artista para obtener fechas de gira, y la compartición «En reproducción» publica tu pista actual para otros usuarios de tu servidor Navidrome. Si no quieres nada de esto, simplemente deja desactivada la sección correspondiente.',
|
||||||
homeCustomizerTitle: 'Página de Inicio',
|
homeCustomizerTitle: 'Escenario Principal',
|
||||||
queueModeTitle: 'Modo de visualización de la cola',
|
queueModeTitle: 'Modo de visualización de la cola',
|
||||||
queueModeQueueSub: 'Muestra solo las próximas pistas. La pista actual permanece en el encabezado y sale de la lista al reproducirse.',
|
queueModeQueueSub: 'Muestra solo las próximas pistas. La pista actual permanece en el encabezado y sale de la lista al reproducirse.',
|
||||||
queueModePlaylistSub: 'Mantiene toda la cola en la lista, con la pista actual resaltada arriba; las reproducidas permanecen.',
|
queueModePlaylistSub: 'Mantiene toda la cola en la lista, con la pista actual resaltada arriba; las reproducidas permanecen.',
|
||||||
|
|||||||
@@ -14,5 +14,8 @@ export const home = {
|
|||||||
becauseYouLikeFor: 'Parce que tu as écouté {{artist}}',
|
becauseYouLikeFor: 'Parce que tu as écouté {{artist}}',
|
||||||
similarTo: 'Similaire à {{artist}}',
|
similarTo: 'Similaire à {{artist}}',
|
||||||
becauseYouLikeTracks_one: '{{count}} titre',
|
becauseYouLikeTracks_one: '{{count}} titre',
|
||||||
becauseYouLikeTracks_other: '{{count}} titres'
|
becauseYouLikeTracks_other: '{{count}} titres',
|
||||||
|
mainstageEmptyTitle: 'Votre Scène principale est vide',
|
||||||
|
mainstageEmptyBody: 'Toutes les sections sont désactivées. Réactivez des sections ou masquez la Scène principale de la barre latérale.',
|
||||||
|
mainstageEmptyCta: 'Ouvrir la Personnalisation'
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -323,7 +323,7 @@ export const settings = {
|
|||||||
aboutMaintainersLabel: 'Mainteneurs',
|
aboutMaintainersLabel: 'Mainteneurs',
|
||||||
integrationsPrivacyTitle: 'Avis de confidentialité',
|
integrationsPrivacyTitle: 'Avis de confidentialité',
|
||||||
integrationsPrivacyBody: 'Toutes les intégrations de cet onglet sont <strong>facultatives</strong> et, une fois activées, envoient des données à des services externes ou à votre serveur Navidrome. Last.fm reçoit votre historique d\'écoute, Discord affiche le morceau en cours dans votre profil, Bandsintown est interrogé par artiste pour récupérer les dates de tournée, et le partage « En cours de lecture » publie votre morceau actuel auprès des autres utilisateurs de votre serveur Navidrome. Si vous ne voulez rien de tout cela, laissez simplement la section correspondante désactivée.',
|
integrationsPrivacyBody: 'Toutes les intégrations de cet onglet sont <strong>facultatives</strong> et, une fois activées, envoient des données à des services externes ou à votre serveur Navidrome. Last.fm reçoit votre historique d\'écoute, Discord affiche le morceau en cours dans votre profil, Bandsintown est interrogé par artiste pour récupérer les dates de tournée, et le partage « En cours de lecture » publie votre morceau actuel auprès des autres utilisateurs de votre serveur Navidrome. Si vous ne voulez rien de tout cela, laissez simplement la section correspondante désactivée.',
|
||||||
homeCustomizerTitle: 'Page d\'accueil',
|
homeCustomizerTitle: 'Scène principale',
|
||||||
queueModeTitle: 'Mode d\'affichage de la file',
|
queueModeTitle: 'Mode d\'affichage de la file',
|
||||||
queueModeQueueSub: 'N\'affiche que les pistes à venir. La piste en cours reste dans l\'en-tête et quitte la liste une fois jouée.',
|
queueModeQueueSub: 'N\'affiche que les pistes à venir. La piste en cours reste dans l\'en-tête et quitte la liste une fois jouée.',
|
||||||
queueModePlaylistSub: 'Conserve toute la file dans la liste, la piste en cours surlignée en haut ; les pistes jouées restent.',
|
queueModePlaylistSub: 'Conserve toute la file dans la liste, la piste en cours surlignée en haut ; les pistes jouées restent.',
|
||||||
|
|||||||
@@ -14,5 +14,8 @@ export const home = {
|
|||||||
becauseYouLikeFor: 'Fordi du har hørt på {{artist}}',
|
becauseYouLikeFor: 'Fordi du har hørt på {{artist}}',
|
||||||
similarTo: 'Likt {{artist}}',
|
similarTo: 'Likt {{artist}}',
|
||||||
becauseYouLikeTracks_one: '{{count}} spor',
|
becauseYouLikeTracks_one: '{{count}} spor',
|
||||||
becauseYouLikeTracks_other: '{{count}} spor'
|
becauseYouLikeTracks_other: '{{count}} spor',
|
||||||
|
mainstageEmptyTitle: 'Hovedscenen din er tom',
|
||||||
|
mainstageEmptyBody: 'Alle seksjoner er slått av. Slå på seksjoner igjen, eller skjul Hovedscene fra sidefeltet.',
|
||||||
|
mainstageEmptyCta: 'Åpne Tilpasning'
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -322,7 +322,7 @@ export const settings = {
|
|||||||
aboutMaintainersLabel: 'Ansvarlige',
|
aboutMaintainersLabel: 'Ansvarlige',
|
||||||
integrationsPrivacyTitle: 'Personvern-merknad',
|
integrationsPrivacyTitle: 'Personvern-merknad',
|
||||||
integrationsPrivacyBody: 'Alle integrasjoner på denne fanen er <strong>frivillige</strong> og sender, når aktivert, data til eksterne tjenester eller til Navidrome-serveren din. Last.fm mottar lyttehistorikken din, Discord viser gjeldende spor i profilen din, Bandsintown spørres per artist for turnédatoer, og "Spilles nå"-delingen publiserer gjeldende spor til andre brukere av Navidrome-serveren din. Hvis du ikke ønsker noe av dette, la den aktuelle seksjonen stå deaktivert.',
|
integrationsPrivacyBody: 'Alle integrasjoner på denne fanen er <strong>frivillige</strong> og sender, når aktivert, data til eksterne tjenester eller til Navidrome-serveren din. Last.fm mottar lyttehistorikken din, Discord viser gjeldende spor i profilen din, Bandsintown spørres per artist for turnédatoer, og "Spilles nå"-delingen publiserer gjeldende spor til andre brukere av Navidrome-serveren din. Hvis du ikke ønsker noe av dette, la den aktuelle seksjonen stå deaktivert.',
|
||||||
homeCustomizerTitle: 'Hjemmeside',
|
homeCustomizerTitle: 'Hovedscene',
|
||||||
queueModeTitle: 'Visningsmodus for kø',
|
queueModeTitle: 'Visningsmodus for kø',
|
||||||
queueModeQueueSub: 'Viser bare kommende spor. Det gjeldende sporet blir i toppen og forsvinner fra listen når det er spilt.',
|
queueModeQueueSub: 'Viser bare kommende spor. Det gjeldende sporet blir i toppen og forsvinner fra listen når det er spilt.',
|
||||||
queueModePlaylistSub: 'Beholder hele køen i listen med gjeldende spor uthevet øverst; avspilte spor blir værende.',
|
queueModePlaylistSub: 'Beholder hele køen i listen med gjeldende spor uthevet øverst; avspilte spor blir værende.',
|
||||||
|
|||||||
@@ -14,5 +14,8 @@ export const home = {
|
|||||||
becauseYouLikeFor: 'Omdat je naar {{artist}} hebt geluisterd',
|
becauseYouLikeFor: 'Omdat je naar {{artist}} hebt geluisterd',
|
||||||
similarTo: 'Lijkt op {{artist}}',
|
similarTo: 'Lijkt op {{artist}}',
|
||||||
becauseYouLikeTracks_one: '{{count}} nummer',
|
becauseYouLikeTracks_one: '{{count}} nummer',
|
||||||
becauseYouLikeTracks_other: '{{count}} nummers'
|
becauseYouLikeTracks_other: '{{count}} nummers',
|
||||||
|
mainstageEmptyTitle: 'Je Hoofdpodium is leeg',
|
||||||
|
mainstageEmptyBody: 'Alle secties zijn uitgeschakeld. Schakel secties weer in of verberg het Hoofdpodium in de zijbalk.',
|
||||||
|
mainstageEmptyCta: 'Personalisatie openen'
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -323,7 +323,7 @@ export const settings = {
|
|||||||
aboutMaintainersLabel: 'Beheerders',
|
aboutMaintainersLabel: 'Beheerders',
|
||||||
integrationsPrivacyTitle: 'Privacyverklaring',
|
integrationsPrivacyTitle: 'Privacyverklaring',
|
||||||
integrationsPrivacyBody: 'Alle integraties op dit tabblad zijn <strong>opt-in</strong> en sturen, eenmaal ingeschakeld, gegevens naar externe diensten of naar je Navidrome-server. Last.fm ontvangt je luistergeschiedenis, Discord toont het nu spelende nummer in je profiel, Bandsintown wordt per artiest bevraagd voor tourdatums, en de "Nu aan het afspelen"-deling publiceert je huidige nummer bij andere gebruikers van je Navidrome-server. Wil je dit niet, laat dan de bijbehorende sectie gewoon uitgeschakeld.',
|
integrationsPrivacyBody: 'Alle integraties op dit tabblad zijn <strong>opt-in</strong> en sturen, eenmaal ingeschakeld, gegevens naar externe diensten of naar je Navidrome-server. Last.fm ontvangt je luistergeschiedenis, Discord toont het nu spelende nummer in je profiel, Bandsintown wordt per artiest bevraagd voor tourdatums, en de "Nu aan het afspelen"-deling publiceert je huidige nummer bij andere gebruikers van je Navidrome-server. Wil je dit niet, laat dan de bijbehorende sectie gewoon uitgeschakeld.',
|
||||||
homeCustomizerTitle: 'Startpagina',
|
homeCustomizerTitle: 'Hoofdpodium',
|
||||||
queueModeTitle: 'Weergavemodus wachtrij',
|
queueModeTitle: 'Weergavemodus wachtrij',
|
||||||
queueModeQueueSub: 'Toont alleen komende nummers. Het huidige nummer blijft in de kop en verdwijnt uit de lijst zodra het is afgespeeld.',
|
queueModeQueueSub: 'Toont alleen komende nummers. Het huidige nummer blijft in de kop en verdwijnt uit de lijst zodra het is afgespeeld.',
|
||||||
queueModePlaylistSub: 'Houdt de hele wachtrij in de lijst met het huidige nummer bovenaan gemarkeerd; afgespeelde nummers blijven staan.',
|
queueModePlaylistSub: 'Houdt de hele wachtrij in de lijst met het huidige nummer bovenaan gemarkeerd; afgespeelde nummers blijven staan.',
|
||||||
|
|||||||
@@ -14,5 +14,8 @@ export const home = {
|
|||||||
becauseYouLikeFor: 'Deoarece ai ascultat {{artist}}',
|
becauseYouLikeFor: 'Deoarece ai ascultat {{artist}}',
|
||||||
similarTo: 'Similar cu {{artist}}',
|
similarTo: 'Similar cu {{artist}}',
|
||||||
becauseYouLikeTracks_one: '{{count}} piesă',
|
becauseYouLikeTracks_one: '{{count}} piesă',
|
||||||
becauseYouLikeTracks_other: '{{count}} piese'
|
becauseYouLikeTracks_other: '{{count}} piese',
|
||||||
|
mainstageEmptyTitle: 'Scena Principală este goală',
|
||||||
|
mainstageEmptyBody: 'Toate secțiunile sunt dezactivate. Reactivează secțiuni sau ascunde Scena Principală din bara laterală.',
|
||||||
|
mainstageEmptyCta: 'Deschide Personalizare'
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -328,7 +328,7 @@ export const settings = {
|
|||||||
searchNoResults: 'Nicio setare nu corespunde cu ce ai căutat.',
|
searchNoResults: 'Nicio setare nu corespunde cu ce ai căutat.',
|
||||||
integrationsPrivacyTitle: 'Notificare de confidențialitate',
|
integrationsPrivacyTitle: 'Notificare de confidențialitate',
|
||||||
integrationsPrivacyBody: 'Toate integrările din acest tab sunt <strong>opționale</strong> și trimit date către servicii externe sau server-ul tău Navidrome când sunt activate. Last.fm primește istoricul ascultărilor tale, Discord arată piesa redată curent în profilul tău, Bandsintown e interogat per artist pentru a aduce date de turnee, și distribuirea Now-Playing publică piesa curentă către alți useri din server-ul tău Navidrome. Dacă nu vrei vreo opțiune, doar lasă secțiunea corespunzătoare dezactivată',
|
integrationsPrivacyBody: 'Toate integrările din acest tab sunt <strong>opționale</strong> și trimit date către servicii externe sau server-ul tău Navidrome când sunt activate. Last.fm primește istoricul ascultărilor tale, Discord arată piesa redată curent în profilul tău, Bandsintown e interogat per artist pentru a aduce date de turnee, și distribuirea Now-Playing publică piesa curentă către alți useri din server-ul tău Navidrome. Dacă nu vrei vreo opțiune, doar lasă secțiunea corespunzătoare dezactivată',
|
||||||
homeCustomizerTitle: 'Pagina Principală',
|
homeCustomizerTitle: 'Scena Principală',
|
||||||
queueModeTitle: 'Mod de afișare a cozii',
|
queueModeTitle: 'Mod de afișare a cozii',
|
||||||
queueModeQueueSub: 'Arată doar piesele următoare. Piesa curentă rămâne în antet și iese din listă după ce a fost redată.',
|
queueModeQueueSub: 'Arată doar piesele următoare. Piesa curentă rămâne în antet și iese din listă după ce a fost redată.',
|
||||||
queueModePlaylistSub: 'Păstrează toată coada în listă, cu piesa curentă evidențiată sus; piesele redate rămân.',
|
queueModePlaylistSub: 'Păstrează toată coada în listă, cu piesa curentă evidențiată sus; piesele redate rămân.',
|
||||||
|
|||||||
@@ -17,4 +17,7 @@ export const home = {
|
|||||||
becauseYouLikeTracks_few: '{{count}} трека',
|
becauseYouLikeTracks_few: '{{count}} трека',
|
||||||
becauseYouLikeTracks_many: '{{count}} треков',
|
becauseYouLikeTracks_many: '{{count}} треков',
|
||||||
becauseYouLikeTracks_other: '{{count}} треков',
|
becauseYouLikeTracks_other: '{{count}} треков',
|
||||||
|
mainstageEmptyTitle: 'Раздел «Для вас» пуст',
|
||||||
|
mainstageEmptyBody: 'Все разделы отключены. Включите разделы снова или скройте «Для вас» из боковой панели.',
|
||||||
|
mainstageEmptyCta: 'Открыть Персонализацию',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -404,7 +404,7 @@ export const settings = {
|
|||||||
aboutMaintainersLabel: 'Сопровождающие',
|
aboutMaintainersLabel: 'Сопровождающие',
|
||||||
integrationsPrivacyTitle: 'Уведомление о конфиденциальности',
|
integrationsPrivacyTitle: 'Уведомление о конфиденциальности',
|
||||||
integrationsPrivacyBody: 'Все интеграции на этой вкладке включаются <strong>по желанию</strong> и при активации отправляют данные во внешние сервисы или на ваш сервер Navidrome. Last.fm получает вашу историю прослушивания, Discord показывает текущую композицию в вашем профиле, Bandsintown опрашивается по артисту для получения дат гастролей, а «Сейчас играет» делится текущей композицией с другими пользователями вашего сервера Navidrome. Если вы не хотите ничего из этого, просто оставьте соответствующий раздел отключённым.',
|
integrationsPrivacyBody: 'Все интеграции на этой вкладке включаются <strong>по желанию</strong> и при активации отправляют данные во внешние сервисы или на ваш сервер Navidrome. Last.fm получает вашу историю прослушивания, Discord показывает текущую композицию в вашем профиле, Bandsintown опрашивается по артисту для получения дат гастролей, а «Сейчас играет» делится текущей композицией с другими пользователями вашего сервера Navidrome. Если вы не хотите ничего из этого, просто оставьте соответствующий раздел отключённым.',
|
||||||
homeCustomizerTitle: 'Главная',
|
homeCustomizerTitle: 'Для вас',
|
||||||
queueModeTitle: 'Режим отображения очереди',
|
queueModeTitle: 'Режим отображения очереди',
|
||||||
queueModeQueueSub: 'Показывает только предстоящие треки. Текущий трек остаётся в заголовке и исчезает из списка после воспроизведения.',
|
queueModeQueueSub: 'Показывает только предстоящие треки. Текущий трек остаётся в заголовке и исчезает из списка после воспроизведения.',
|
||||||
queueModePlaylistSub: 'Сохраняет всю очередь в списке, текущий трек выделен вверху; воспроизведённые треки остаются.',
|
queueModePlaylistSub: 'Сохраняет всю очередь в списке, текущий трек выделен вверху; воспроизведённые треки остаются.',
|
||||||
|
|||||||
@@ -14,5 +14,8 @@ export const home = {
|
|||||||
becauseYouLikeFor: '因为你听过 {{artist}}',
|
becauseYouLikeFor: '因为你听过 {{artist}}',
|
||||||
similarTo: '类似 {{artist}}',
|
similarTo: '类似 {{artist}}',
|
||||||
becauseYouLikeTracks_one: '{{count}} 首',
|
becauseYouLikeTracks_one: '{{count}} 首',
|
||||||
becauseYouLikeTracks_other: '{{count}} 首'
|
becauseYouLikeTracks_other: '{{count}} 首',
|
||||||
|
mainstageEmptyTitle: '你的主舞台是空的',
|
||||||
|
mainstageEmptyBody: '所有板块均已关闭。重新开启板块,或在侧边栏中隐藏主舞台。',
|
||||||
|
mainstageEmptyCta: '打开个性化设置'
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -322,7 +322,7 @@ export const settings = {
|
|||||||
aboutMaintainersLabel: '维护者',
|
aboutMaintainersLabel: '维护者',
|
||||||
integrationsPrivacyTitle: '隐私说明',
|
integrationsPrivacyTitle: '隐私说明',
|
||||||
integrationsPrivacyBody: '此标签页中的所有集成均为 <strong>选择加入</strong>,启用后会向外部服务或您的 Navidrome 服务器发送数据。Last.fm 接收您的收听历史,Discord 在您的个人资料中显示正在播放的曲目,Bandsintown 会按艺人查询以获取巡演日期,"正在播放" 分享会向您 Navidrome 服务器的其他用户公开您当前的曲目。如果您不需要这些功能,只需将相应部分保持停用即可。',
|
integrationsPrivacyBody: '此标签页中的所有集成均为 <strong>选择加入</strong>,启用后会向外部服务或您的 Navidrome 服务器发送数据。Last.fm 接收您的收听历史,Discord 在您的个人资料中显示正在播放的曲目,Bandsintown 会按艺人查询以获取巡演日期,"正在播放" 分享会向您 Navidrome 服务器的其他用户公开您当前的曲目。如果您不需要这些功能,只需将相应部分保持停用即可。',
|
||||||
homeCustomizerTitle: '首页',
|
homeCustomizerTitle: '主舞台',
|
||||||
queueModeTitle: '队列显示模式',
|
queueModeTitle: '队列显示模式',
|
||||||
queueModeQueueSub: '仅显示即将播放的曲目。当前曲目保留在标题栏中,播放完毕后从列表移除。',
|
queueModeQueueSub: '仅显示即将播放的曲目。当前曲目保留在标题栏中,播放完毕后从列表移除。',
|
||||||
queueModePlaylistSub: '在列表中保留整个队列,当前曲目在顶部高亮显示;已播放的曲目保留。',
|
queueModePlaylistSub: '在列表中保留整个队列,当前曲目在顶部高亮显示;已播放的曲目保留。',
|
||||||
|
|||||||
@@ -298,6 +298,11 @@ export default function Home() {
|
|||||||
mostPlayed.length === 0 &&
|
mostPlayed.length === 0 &&
|
||||||
recentlyPlayed.length === 0 &&
|
recentlyPlayed.length === 0 &&
|
||||||
starred.length === 0;
|
starred.length === 0;
|
||||||
|
// Every section toggled off in Settings → Personalisation → Mainstage. The
|
||||||
|
// page would otherwise be entirely blank, so surface a guided empty state
|
||||||
|
// pointing back at the toggles (or the option to hide Mainstage from the
|
||||||
|
// sidebar) instead of leaving the user on nothing.
|
||||||
|
const allSectionsHidden = homeSections.every(s => !s.visible);
|
||||||
return (
|
return (
|
||||||
<div className={`animate-fade-in${homeLiteArtworkFx ? ' home-lite-artwork' : ''}${homeFlatArtworkClip ? ' home-flat-artwork-clip' : ''}`}>
|
<div className={`animate-fade-in${homeLiteArtworkFx ? ' home-lite-artwork' : ''}${homeFlatArtworkClip ? ' home-flat-artwork-clip' : ''}`}>
|
||||||
{!loading && !perfFlags.disableMainstageHero && isVisible('hero') && <Hero albums={heroAlbums} />}
|
{!loading && !perfFlags.disableMainstageHero && isVisible('hero') && <Hero albums={heroAlbums} />}
|
||||||
@@ -307,6 +312,21 @@ export default function Home() {
|
|||||||
<div style={{ display: 'flex', justifyContent: 'center', padding: '4rem' }}>
|
<div style={{ display: 'flex', justifyContent: 'center', padding: '4rem' }}>
|
||||||
<div className="spinner" />
|
<div className="spinner" />
|
||||||
</div>
|
</div>
|
||||||
|
) : allSectionsHidden ? (
|
||||||
|
<div className="empty-state" style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '0.75rem' }}>
|
||||||
|
<div style={{ fontSize: 18, fontWeight: 600, color: 'var(--text-primary)' }}>
|
||||||
|
{t('home.mainstageEmptyTitle')}
|
||||||
|
</div>
|
||||||
|
<div style={{ maxWidth: 460 }}>{t('home.mainstageEmptyBody')}</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-primary"
|
||||||
|
style={{ marginTop: '0.5rem' }}
|
||||||
|
onClick={() => navigate('/settings', { state: { tab: 'personalisation' } })}
|
||||||
|
>
|
||||||
|
{t('home.mainstageEmptyCta')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
) : libraryEmpty ? (
|
) : libraryEmpty ? (
|
||||||
<div className="empty-state" style={{ padding: '4rem 1rem', textAlign: 'center' }}>
|
<div className="empty-state" style={{ padding: '4rem 1rem', textAlign: 'center' }}>
|
||||||
{t('common.libraryEmpty')}
|
{t('common.libraryEmpty')}
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { resolveStartRoute } from './sidebarNavReorder';
|
||||||
|
import { DEFAULT_SIDEBAR_ITEMS, type SidebarItemConfig } from '../../store/sidebarStore';
|
||||||
|
|
||||||
|
const hide = (items: SidebarItemConfig[], ...ids: string[]): SidebarItemConfig[] =>
|
||||||
|
items.map(i => (ids.includes(i.id) ? { ...i, visible: false } : i));
|
||||||
|
|
||||||
|
const only = (...ids: string[]): SidebarItemConfig[] =>
|
||||||
|
DEFAULT_SIDEBAR_ITEMS.map(i => ({ ...i, visible: ids.includes(i.id) }));
|
||||||
|
|
||||||
|
describe('resolveStartRoute', () => {
|
||||||
|
it('falls back to the first visible library item when Mainstage is hidden', () => {
|
||||||
|
const items = hide(DEFAULT_SIDEBAR_ITEMS, 'mainstage');
|
||||||
|
expect(resolveStartRoute(items, 'hub', false)).toBe('/new-releases');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('skips Mainstage ("/") even if it is still flagged visible', () => {
|
||||||
|
// Resolver is only consulted when Mainstage is hidden, but it must never
|
||||||
|
// hand back "/" — that would redirect the index route onto itself.
|
||||||
|
expect(resolveStartRoute(DEFAULT_SIDEBAR_ITEMS, 'hub', false)).toBe('/new-releases');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null when no library item is visible (caller renders empty Mainstage)', () => {
|
||||||
|
const items = DEFAULT_SIDEBAR_ITEMS.map(i => ({ ...i, visible: false }));
|
||||||
|
expect(resolveStartRoute(items, 'hub', false)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('honours sidebar order — first visible entry wins', () => {
|
||||||
|
expect(resolveStartRoute(only('favorites', 'artists'), 'hub', false)).toBe('/artists');
|
||||||
|
expect(resolveStartRoute(only('favorites'), 'hub', false)).toBe('/favorites');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('skips luckyMix when it is not available', () => {
|
||||||
|
const items = only('luckyMix', 'genres');
|
||||||
|
// separate mode surfaces luckyMix, but availability gate is off → next item
|
||||||
|
expect(resolveStartRoute(items, 'separate', false)).toBe('/genres');
|
||||||
|
expect(resolveStartRoute(items, 'separate', true)).toBe('/lucky-mix');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('respects randomNavMode hub/separate gating', () => {
|
||||||
|
// randomPicker (Build a Mix) only exists in hub mode; randomMix only in separate.
|
||||||
|
expect(resolveStartRoute(only('randomPicker'), 'hub', false)).toBe('/random');
|
||||||
|
expect(resolveStartRoute(only('randomPicker'), 'separate', false)).toBeNull();
|
||||||
|
expect(resolveStartRoute(only('randomMix'), 'separate', false)).toBe('/random/mix');
|
||||||
|
expect(resolveStartRoute(only('randomMix'), 'hub', false)).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -26,6 +26,31 @@ export function getSystemItemsForReorder(items: SidebarItemConfig[]): SidebarIte
|
|||||||
return items.filter(cfg => ALL_NAV_ITEMS[cfg.id]?.section === 'system');
|
return items.filter(cfg => ALL_NAV_ITEMS[cfg.id]?.section === 'system');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the route the app should open on "/" when the Mainstage entry is
|
||||||
|
* hidden from the sidebar. Mirrors the sidebar's own visible-library ordering
|
||||||
|
* (same filter + randomNavMode + luckyMix gating) and returns the first visible
|
||||||
|
* library item's route, skipping Mainstage itself ('/'). Returns null when no
|
||||||
|
* other library item is visible, so the caller can fall back to rendering the
|
||||||
|
* (empty) Mainstage rather than redirecting nowhere.
|
||||||
|
*/
|
||||||
|
export function resolveStartRoute(
|
||||||
|
items: SidebarItemConfig[],
|
||||||
|
randomNavMode: 'hub' | 'separate',
|
||||||
|
luckyMixAvailable: boolean,
|
||||||
|
): string | null {
|
||||||
|
const libraryConfigs = getLibraryItemsForReorder(items, randomNavMode).filter(cfg => {
|
||||||
|
if (!cfg.visible) return false;
|
||||||
|
if (cfg.id === 'luckyMix' && !luckyMixAvailable) return false;
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
for (const cfg of libraryConfigs) {
|
||||||
|
const to = ALL_NAV_ITEMS[cfg.id]?.to;
|
||||||
|
if (to && to !== '/') return to;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
/** Same entries as in Settings toggles — safe to hide via drag-out. */
|
/** Same entries as in Settings toggles — safe to hide via drag-out. */
|
||||||
export function isSidebarNavItemUserHideable(id: string): boolean {
|
export function isSidebarNavItemUserHideable(id: string): boolean {
|
||||||
return Boolean(ALL_NAV_ITEMS[id]) && !CONSERVED_SIDEBAR_NAV_IDS.has(id);
|
return Boolean(ALL_NAV_ITEMS[id]) && !CONSERVED_SIDEBAR_NAV_IDS.has(id);
|
||||||
|
|||||||
Reference in New Issue
Block a user