diff --git a/src/components/MobileMoreOverlay.tsx b/src/components/MobileMoreOverlay.tsx
index f29882ca..850006a5 100644
--- a/src/components/MobileMoreOverlay.tsx
+++ b/src/components/MobileMoreOverlay.tsx
@@ -6,6 +6,7 @@ import { useSidebarStore } from '../store/sidebarStore';
import { useAuthStore } from '../store/authStore';
import { useOfflineStore } from '../store/offlineStore';
import { ALL_NAV_ITEMS } from '../config/navItems';
+import { useLuckyMixAvailable } from '../hooks/useLuckyMixAvailable';
const BOTTOM_NAV_ROUTES = new Set(['/', '/albums', '/now-playing']);
@@ -14,11 +15,10 @@ export default function MobileMoreOverlay({ onClose }: { onClose: () => void })
const sidebarItems = useSidebarStore(s => s.items);
const randomNavMode = useAuthStore(s => s.randomNavMode);
const serverId = useAuthStore(s => s.activeServerId ?? '');
- const showLuckyMixMenu = useAuthStore(s => s.showLuckyMixMenu);
- const audiomuseByServer = useAuthStore(s => s.audiomuseNavidromeByServer);
const offlineAlbums = useOfflineStore(s => s.albums);
const hasOfflineContent = Object.values(offlineAlbums).some(a => a.serverId === serverId);
- const luckyMixAvailable = randomNavMode === 'separate' && showLuckyMixMenu && Boolean(serverId && audiomuseByServer[serverId]);
+ const luckyMixBase = useLuckyMixAvailable();
+ const luckyMixAvailable = luckyMixBase && randomNavMode === 'separate';
const items = sidebarItems
.filter(cfg => {
diff --git a/src/components/QueuePanel.tsx b/src/components/QueuePanel.tsx
index 3ec41277..a9627f8a 100644
--- a/src/components/QueuePanel.tsx
+++ b/src/components/QueuePanel.tsx
@@ -730,7 +730,13 @@ export default function QueuePanel() {
{luckyRolling && isPlaying && (
-
+
+
)}
);
diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx
index 63ed4072..772763a5 100644
--- a/src/components/Sidebar.tsx
+++ b/src/components/Sidebar.tsx
@@ -27,6 +27,7 @@ import {
isSidebarNavItemUserHideable,
type SidebarNavDropTarget,
} from '../utils/sidebarNavReorder';
+import { useLuckyMixAvailable } from '../hooks/useLuckyMixAvailable';
const SIDEBAR_NAV_LONG_PRESS_MS = 1000;
const SIDEBAR_NAV_LONG_PRESS_MOVE_CANCEL_PX = 10;
@@ -68,9 +69,10 @@ export default function Sidebar({
const sidebarItems = useSidebarStore(s => s.items);
const setSidebarItems = useSidebarStore(s => s.setItems);
const randomNavMode = useAuthStore(s => s.randomNavMode);
- const showLuckyMixMenu = useAuthStore(s => s.showLuckyMixMenu);
- const audiomuseByServer = useAuthStore(s => s.audiomuseNavidromeByServer);
- const luckyMixAvailable = randomNavMode === 'separate' && showLuckyMixMenu && Boolean(serverId && audiomuseByServer[serverId]);
+ const luckyMixBase = useLuckyMixAvailable();
+ // Sidebar surfaces Lucky Mix as its own entry only in "separate" nav mode —
+ // in hub mode it lives inside the Build-a-Mix landing page instead.
+ const luckyMixAvailable = luckyMixBase && randomNavMode === 'separate';
const [libraryDropdownOpen, setLibraryDropdownOpen] = useState(false);
const [playlistsExpanded, setPlaylistsExpanded] = useState(false);
const playlistsRaw = usePlaylistStore(s => s.playlists);
diff --git a/src/hooks/useLuckyMixAvailable.ts b/src/hooks/useLuckyMixAvailable.ts
new file mode 100644
index 00000000..2c2af063
--- /dev/null
+++ b/src/hooks/useLuckyMixAvailable.ts
@@ -0,0 +1,40 @@
+import { useAuthStore } from '../store/authStore';
+
+/**
+ * Whether "Lucky Mix" should be exposed as a navigable menu/card entry.
+ *
+ * Single source of truth for the gate — previously this logic was inlined in
+ * Sidebar, MobileMoreOverlay, RandomLanding, Settings/SidebarCustomizer, and
+ * the sidebarNavReorder filter. All call sites share the same three-way
+ * predicate:
+ * 1. User hasn't hidden it via the Settings toggle.
+ * 2. AudioMuse is enabled for the active server (feature depends on
+ * audiomuse-backed similar-track quality).
+ * 3. An active server exists at all.
+ *
+ * Callers that additionally care about the "split vs hub" navigation mode
+ * should combine this with `randomNavMode === 'separate'` explicitly — that's
+ * an orthogonal UI placement concern, not an availability concern.
+ */
+export function isLuckyMixAvailable(args: {
+ activeServerId: string | null | undefined;
+ audiomuseByServer: Record;
+ showLuckyMixMenu: boolean;
+}): boolean {
+ const { activeServerId, audiomuseByServer, showLuckyMixMenu } = args;
+ if (!showLuckyMixMenu) return false;
+ if (!activeServerId) return false;
+ return Boolean(audiomuseByServer[activeServerId]);
+}
+
+/**
+ * React hook form — subscribes to the three authStore slices the predicate
+ * depends on, so any user-facing change (toggle flip, server switch, AudioMuse
+ * toggle on/off) re-renders the caller automatically.
+ */
+export function useLuckyMixAvailable(): boolean {
+ const activeServerId = useAuthStore(s => s.activeServerId);
+ const audiomuseByServer = useAuthStore(s => s.audiomuseNavidromeByServer);
+ const showLuckyMixMenu = useAuthStore(s => s.showLuckyMixMenu);
+ return isLuckyMixAvailable({ activeServerId, audiomuseByServer, showLuckyMixMenu });
+}
diff --git a/src/locales/de.ts b/src/locales/de.ts
index 62d2bb67..ce1874b1 100644
--- a/src/locales/de.ts
+++ b/src/locales/de.ts
@@ -31,7 +31,7 @@ export const deTranslation = {
expandPlaylists: 'Playlists ausklappen',
collapsePlaylists: 'Playlists einklappen',
more: 'Mehr',
- feelingLucky: 'Glück',
+ feelingLucky: 'Glücks-Mix',
},
home: {
hero: 'Featured',
@@ -284,7 +284,7 @@ export const deTranslation = {
mixByTracksDesc: 'Zufällige Auswahl aus deiner gesamten Mediathek',
mixByAlbums: 'Mix nach Alben',
mixByAlbumsDesc: 'Zufällige Alben für neue Entdeckungen',
- mixByLucky: 'Glück',
+ mixByLucky: 'Glücks-Mix',
mixByLuckyDesc: 'Smarter Instant Mix aus Top-Künstlern, Alben und Bewertungen',
},
randomAlbums: {
@@ -339,9 +339,10 @@ export const deTranslation = {
genreClickHint: 'Genre-Tag anklicken,\num es als Filter-Keyword hinzuzufügen.\nPrüft Genre, Titel, Album & Künstler.',
},
luckyMix: {
- done: '„Glück“ bereit: {{count}} Titel',
- failed: '„Glück“ konnte nicht erstellt werden. Bitte erneut versuchen.',
- unavailable: '„Glück“ ist für diesen Server nicht verfügbar.',
+ done: 'Glücks-Mix bereit: {{count}} Titel',
+ failed: 'Glücks-Mix konnte nicht erstellt werden. Bitte erneut versuchen.',
+ unavailable: 'Glücks-Mix ist für diesen Server nicht verfügbar.',
+ cancelTooltip: 'Glücks-Mix-Erstellung abbrechen',
},
albums: {
title: 'Alle Alben',
@@ -728,8 +729,8 @@ export const deTranslation = {
showChangelogOnUpdate: "'Was ist neu' bei Update anzeigen",
showChangelogOnUpdateDesc: 'Blendet nach einem Update einen dezenten Changelog-Banner über Now Playing ein. Klick öffnet die Release Notes, X blendet ihn aus.',
randomMixTitle: 'Zufallsmix-Blacklist',
- luckyMixMenuTitle: '„Glück“ im Menü anzeigen',
- luckyMixMenuDesc: 'Aktiviert „Glück“ im "Mix erstellen"-Hub und als separaten Menüeintrag bei getrennter Navigation. Sichtbar nur bei aktiviertem AudioMuse auf dem aktiven Server.',
+ luckyMixMenuTitle: 'Glücks-Mix im Menü anzeigen',
+ luckyMixMenuDesc: 'Aktiviert Glücks-Mix im "Mix erstellen"-Hub und als separaten Menüeintrag bei getrennter Navigation. Sichtbar nur bei aktiviertem AudioMuse auf dem aktiven Server.',
randomMixBlacklistTitle: 'Eigene Filter-Keywords',
randomMixBlacklistDesc: 'Songs werden ausgeschlossen, wenn ein Keyword auf Genre, Titel, Album oder Künstler zutrifft (aktiv wenn die Checkbox oben an ist).',
randomMixBlacklistPlaceholder: 'Keyword hinzufügen…',
@@ -765,7 +766,7 @@ export const deTranslation = {
sidebarDrag: 'Ziehen zum Umsortieren',
sidebarFixed: 'Immer sichtbar',
randomNavSplitTitle: 'Mix-Navigation aufteilen',
- randomNavSplitDesc: '"Zufallsmix", "Zufallsalben" und "Glück" als separate Sidebar-Einträge statt als "Mix erstellen"-Hub anzeigen.',
+ randomNavSplitDesc: '"Zufallsmix", "Zufallsalben" und "Glücks-Mix" als separate Sidebar-Einträge statt als "Mix erstellen"-Hub anzeigen.',
tabInput: 'Eingabe',
tabUsers: 'Benutzer',
shortcutsReset: 'Auf Standard zurücksetzen',
diff --git a/src/locales/en.ts b/src/locales/en.ts
index a54f307f..2103e92c 100644
--- a/src/locales/en.ts
+++ b/src/locales/en.ts
@@ -340,9 +340,10 @@ export const enTranslation = {
genreClickHint: 'Click a genre tag to add it\nas a filter keyword.\nMatches genre, title, album & artist.',
},
luckyMix: {
- done: 'Lucky mix ready: {{count}} tracks',
- failed: 'Could not build lucky mix. Try again.',
+ done: 'Lucky Mix ready: {{count}} tracks',
+ failed: 'Could not build Lucky Mix. Try again.',
unavailable: 'Lucky Mix is unavailable for this server.',
+ cancelTooltip: 'Cancel Lucky Mix build',
},
albums: {
title: 'All Albums',
diff --git a/src/locales/es.ts b/src/locales/es.ts
index 17243d7a..1373f94f 100644
--- a/src/locales/es.ts
+++ b/src/locales/es.ts
@@ -32,7 +32,7 @@ export const esTranslation = {
expandPlaylists: 'Expandir listas',
collapsePlaylists: 'Colapsar listas',
more: 'Más',
- feelingLucky: 'Suerte',
+ feelingLucky: 'Mezcla Suerte',
},
home: {
hero: 'Destacado',
@@ -285,7 +285,7 @@ export const esTranslation = {
mixByTracksDesc: 'Selección aleatoria de canciones de toda tu biblioteca',
mixByAlbums: 'Mezcla por Álbumes',
mixByAlbumsDesc: 'Álbumes aleatorios para tu próximo descubrimiento',
- mixByLucky: 'Suerte',
+ mixByLucky: 'Mezcla Suerte',
mixByLuckyDesc: 'Instant Mix inteligente con artistas, álbumes y valoraciones destacadas',
},
randomAlbums: {
@@ -340,9 +340,10 @@ export const esTranslation = {
genreClickHint: 'Click en una etiqueta de género para agregarla\ncomo palabra clave filtrada.\nBusca en género, título, álbum y artista.',
},
luckyMix: {
- done: 'Mezcla lista: {{count}} canciones',
- failed: 'No se pudo crear la mezcla. Inténtalo de nuevo.',
- unavailable: 'Suerte no está disponible para este servidor.',
+ done: 'Mezcla Suerte lista: {{count}} canciones',
+ failed: 'No se pudo crear la Mezcla Suerte. Inténtalo de nuevo.',
+ unavailable: 'Mezcla Suerte no está disponible para este servidor.',
+ cancelTooltip: 'Cancelar creación de Mezcla Suerte',
},
albums: {
title: 'Todos los Álbumes',
@@ -720,8 +721,8 @@ export const esTranslation = {
showChangelogOnUpdate: "Mostrar 'Novedades' al actualizar",
showChangelogOnUpdateDesc: 'Muestra un discreto banner de changelog encima de Now Playing tras una actualización. Clic abre las notas de la versión; X lo oculta.',
randomMixTitle: 'Lista negra de Mezcla Aleatoria',
- luckyMixMenuTitle: 'Mostrar Suerte en el menú',
- luckyMixMenuDesc: 'Activa Suerte en "Crear Mezcla" y como elemento de menú separado cuando la navegación dividida está activa. Solo visible cuando AudioMuse está activo en el servidor actual.',
+ luckyMixMenuTitle: 'Mostrar Mezcla Suerte en el menú',
+ luckyMixMenuDesc: 'Activa Mezcla Suerte en "Crear Mezcla" y como elemento de menú separado cuando la navegación dividida está activa. Solo visible cuando AudioMuse está activo en el servidor actual.',
randomMixBlacklistTitle: 'Palabras Clave de Filtro Personalizadas',
randomMixBlacklistDesc: 'Las canciones se excluyen cuando cualquier palabra clave coincide con su género, título, álbum o artista (activo cuando el checkbox de arriba está activado).',
randomMixBlacklistPlaceholder: 'Agregar palabra clave…',
@@ -758,7 +759,7 @@ export const esTranslation = {
sidebarDrag: 'Arrastra para reordenar',
sidebarFixed: 'Siempre visible',
randomNavSplitTitle: 'Dividir navegación Mix',
- randomNavSplitDesc: 'Mostrar "Mezcla Aleatoria", "Álbumes Aleatorios" y "Suerte" como entradas separadas en la barra lateral en lugar del hub "Crear Mezcla".',
+ randomNavSplitDesc: 'Mostrar "Mezcla Aleatoria", "Álbumes Aleatorios" y "Mezcla Suerte" como entradas separadas en la barra lateral en lugar del hub "Crear Mezcla".',
tabInput: 'Entrada',
tabUsers: 'Usuarios',
tabSystem: 'Sistema',
diff --git a/src/locales/fr.ts b/src/locales/fr.ts
index 1779c9b3..d64e30b6 100644
--- a/src/locales/fr.ts
+++ b/src/locales/fr.ts
@@ -31,7 +31,7 @@ export const frTranslation = {
expandPlaylists: 'Développer les playlists',
collapsePlaylists: 'Réduire les playlists',
more: 'Plus',
- feelingLucky: 'Chance',
+ feelingLucky: 'Mix Chance',
},
home: {
hero: 'En vedette',
@@ -284,7 +284,7 @@ export const frTranslation = {
mixByTracksDesc: 'Sélection aléatoire de titres depuis toute votre médiathèque',
mixByAlbums: 'Mix par albums',
mixByAlbumsDesc: 'Albums aléatoires pour vos prochaines découvertes',
- mixByLucky: 'Chance',
+ mixByLucky: 'Mix Chance',
mixByLuckyDesc: 'Instant Mix intelligent basé sur artistes, albums et notes élevées',
},
randomAlbums: {
@@ -339,9 +339,10 @@ export const frTranslation = {
genreClickHint: 'Cliquez sur un tag de genre pour l\'ajouter\ncomme mot-clé de filtre.\nCorrespond au genre, titre, album et artiste.',
},
luckyMix: {
- done: 'Mix prêt : {{count}} titres',
- failed: 'Impossible de créer le mix. Réessayez.',
- unavailable: 'Chance n\'est pas disponible pour ce serveur.',
+ done: 'Mix Chance prêt : {{count}} titres',
+ failed: 'Impossible de créer le Mix Chance. Réessayez.',
+ unavailable: 'Mix Chance n\'est pas disponible pour ce serveur.',
+ cancelTooltip: 'Annuler la création du Mix Chance',
},
albums: {
title: 'Tous les albums',
@@ -715,8 +716,8 @@ export const frTranslation = {
showChangelogOnUpdate: "Afficher 'Quoi de neuf' lors des mises à jour",
showChangelogOnUpdateDesc: "Affiche une bannière discrète du changelog au-dessus de Now Playing après une mise à jour. Un clic ouvre les notes de version, le X la masque.",
randomMixTitle: 'Liste noire du mix aléatoire',
- luckyMixMenuTitle: 'Afficher Chance dans le menu',
- luckyMixMenuDesc: 'Active Chance dans "Créer un mix" et comme entrée séparée quand la navigation est scindée. Visible uniquement si AudioMuse est actif sur le serveur courant.',
+ luckyMixMenuTitle: 'Afficher Mix Chance dans le menu',
+ luckyMixMenuDesc: 'Active Mix Chance dans "Créer un mix" et comme entrée séparée quand la navigation est scindée. Visible uniquement si AudioMuse est actif sur le serveur courant.',
randomMixBlacklistTitle: 'Mots-clés de filtre personnalisés',
randomMixBlacklistDesc: 'Les morceaux sont exclus si un mot-clé correspond à leur genre, titre, album ou artiste (actif quand la case ci-dessus est cochée).',
randomMixBlacklistPlaceholder: 'Ajouter un mot-clé…',
@@ -753,7 +754,7 @@ export const frTranslation = {
sidebarDrag: 'Glisser pour réorganiser',
sidebarFixed: 'Toujours visible',
randomNavSplitTitle: 'Diviser la navigation Mix',
- randomNavSplitDesc: 'Afficher "Mix Aléatoire", "Albums Aléatoires" et "Chance" comme entrées séparées dans la barre latérale plutôt que le hub "Créer un Mix".',
+ randomNavSplitDesc: 'Afficher "Mix Aléatoire", "Albums Aléatoires" et "Mix Chance" comme entrées séparées dans la barre latérale plutôt que le hub "Créer un Mix".',
tabInput: 'Entrée',
tabUsers: 'Utilisateurs',
shortcutsReset: 'Réinitialiser',
diff --git a/src/locales/nb.ts b/src/locales/nb.ts
index 3c8134e2..69967fab 100644
--- a/src/locales/nb.ts
+++ b/src/locales/nb.ts
@@ -31,7 +31,7 @@ export const nbTranslation = {
expandPlaylists: 'Utvid spillelister',
collapsePlaylists: 'Skjul spillelister',
more: 'Mer',
- feelingLucky: 'Lykke',
+ feelingLucky: 'Lykkemiks',
},
home: {
hero: 'Utvalgt',
@@ -284,7 +284,7 @@ export const nbTranslation = {
mixByTracksDesc: 'Tilfeldig utvalg av spor fra hele biblioteket ditt',
mixByAlbums: 'Miks etter album',
mixByAlbumsDesc: 'Tilfeldige album for nye oppdagelser',
- mixByLucky: 'Lykke',
+ mixByLucky: 'Lykkemiks',
mixByLuckyDesc: 'Smart Instant Mix fra toppartister, album og gode vurderinger',
},
randomAlbums: {
@@ -339,9 +339,10 @@ export const nbTranslation = {
genreClickHint: 'Klikk på en sjanger-tag for å legge den til\nsom et filternøkkelord.\nSamsvarer med sjanger, tittel, album og artist.',
},
luckyMix: {
- done: 'Miksen er klar: {{count}} spor',
- failed: 'Kunne ikke lage miksen. Prøv igjen.',
- unavailable: 'Lykke er ikke tilgjengelig for denne serveren.',
+ done: 'Lykkemiks klar: {{count}} spor',
+ failed: 'Kunne ikke lage Lykkemiksen. Prøv igjen.',
+ unavailable: 'Lykkemiks er ikke tilgjengelig for denne serveren.',
+ cancelTooltip: 'Avbryt Lykkemiks-bygging',
},
albums: {
title: 'Alle album',
@@ -714,8 +715,8 @@ export const nbTranslation = {
showChangelogOnUpdate: "Vis 'Hva er nytt' ved oppdatering til ny versjon",
showChangelogOnUpdateDesc: "Viser et diskret changelog-banner over Now Playing etter en oppdatering. Klikk åpner versjonsnotatene; X skjuler det.",
randomMixTitle: 'Svarteliste for tilfeldig miks',
- luckyMixMenuTitle: 'Vis Lykke i menyen',
- luckyMixMenuDesc: 'Aktiverer Lykke i "Lag en miks" og som eget menypunkt når delt navigasjon er aktiv. Vises bare når AudioMuse er aktiv på gjeldende server.',
+ luckyMixMenuTitle: 'Vis Lykkemiks i menyen',
+ luckyMixMenuDesc: 'Aktiverer Lykkemiks i "Lag en miks" og som eget menypunkt når delt navigasjon er aktiv. Vises bare når AudioMuse er aktiv på gjeldende server.',
randomMixBlacklistTitle: 'Egendefinerte filternøkkelord',
randomMixBlacklistDesc: 'Sanger ekskluderes når et nøkkelord samsvarer med sjanger, tittel, album eller artist (aktiv når avkrysningsboksen ovenfor er på).',
randomMixBlacklistPlaceholder: 'Legg til nøkkelord…',
@@ -752,7 +753,7 @@ export const nbTranslation = {
sidebarDrag: 'Dra for å endre rekkefølge',
sidebarFixed: 'Alltid synlig',
randomNavSplitTitle: 'Del Mix-navigasjon',
- randomNavSplitDesc: 'Vis "Tilfeldig miks", "Tilfeldige album" og "Lykke" som separate sidefeltsoppføringer i stedet for "Lag en miks"-huben.',
+ randomNavSplitDesc: 'Vis "Tilfeldig miks", "Tilfeldige album" og "Lykkemiks" som separate sidefeltsoppføringer i stedet for "Lag en miks"-huben.',
tabShortcuts: 'Snarveier',
tabUsers: 'Brukere',
tabSystem: 'System',
diff --git a/src/locales/nl.ts b/src/locales/nl.ts
index 215e0896..f32bcb3e 100644
--- a/src/locales/nl.ts
+++ b/src/locales/nl.ts
@@ -31,7 +31,7 @@ export const nlTranslation = {
expandPlaylists: 'Afspeellijsten uitklappen',
collapsePlaylists: 'Afspeellijsten inklappen',
more: 'Meer',
- feelingLucky: 'Geluk',
+ feelingLucky: 'Geluksmix',
},
home: {
hero: 'Uitgelicht',
@@ -283,7 +283,7 @@ export const nlTranslation = {
mixByTracksDesc: 'Willekeurige selectie uit je volledige mediatheek',
mixByAlbums: 'Mix op albums',
mixByAlbumsDesc: 'Willekeurige albums voor nieuwe ontdekkingen',
- mixByLucky: 'Geluk',
+ mixByLucky: 'Geluksmix',
mixByLuckyDesc: 'Slimme Instant Mix op basis van topartiesten, albums en hoge beoordelingen',
},
randomAlbums: {
@@ -338,9 +338,10 @@ export const nlTranslation = {
genreClickHint: 'Klik op een genre-tag om het\ntoe te voegen als filtertrefwoord.\nVergelijkt genre, titel, album & artiest.',
},
luckyMix: {
- done: 'Mix klaar: {{count}} nummers',
- failed: 'Kon de mix niet maken. Probeer opnieuw.',
- unavailable: 'Geluk is niet beschikbaar voor deze server.',
+ done: 'Geluksmix klaar: {{count}} nummers',
+ failed: 'Kon de Geluksmix niet maken. Probeer opnieuw.',
+ unavailable: 'Geluksmix is niet beschikbaar voor deze server.',
+ cancelTooltip: 'Geluksmix-opbouw annuleren',
},
albums: {
title: 'Alle albums',
@@ -714,8 +715,8 @@ export const nlTranslation = {
showChangelogOnUpdate: "'Wat is nieuw' tonen bij update",
showChangelogOnUpdateDesc: 'Toont een discrete changelog-banner boven Now Playing na een update. Klik opent de release-notities; X verbergt hem.',
randomMixTitle: 'Willekeurige mix-blacklist',
- luckyMixMenuTitle: 'Toon Geluk in menu',
- luckyMixMenuDesc: 'Schakelt Geluk in bij "Mix samenstellen" en als apart menu-item bij gesplitste navigatie. Alleen zichtbaar wanneer AudioMuse actief is op de huidige server.',
+ luckyMixMenuTitle: 'Toon Geluksmix in menu',
+ luckyMixMenuDesc: 'Schakelt Geluksmix in bij "Mix samenstellen" en als apart menu-item bij gesplitste navigatie. Alleen zichtbaar wanneer AudioMuse actief is op de huidige server.',
randomMixBlacklistTitle: 'Aangepaste filtertrefwoorden',
randomMixBlacklistDesc: 'Nummers worden uitgesloten als een trefwoord overeenkomt met hun genre, titel, album of artiest (actief wanneer het selectievakje hierboven is aangevinkt).',
randomMixBlacklistPlaceholder: 'Trefwoord toevoegen…',
@@ -752,7 +753,7 @@ export const nlTranslation = {
sidebarDrag: 'Slepen om te herordenen',
sidebarFixed: 'Altijd zichtbaar',
randomNavSplitTitle: 'Mix-navigatie splitsen',
- randomNavSplitDesc: 'Toon "Willekeurige mix", "Willekeurige albums" en "Geluk" als afzonderlijke zijbalkitems in plaats van de "Mix samenstellen"-hub.',
+ randomNavSplitDesc: 'Toon "Willekeurige mix", "Willekeurige albums" en "Geluksmix" als afzonderlijke zijbalkitems in plaats van de "Mix samenstellen"-hub.',
tabInput: 'Invoer',
tabUsers: 'Gebruikers',
shortcutsReset: 'Standaard herstellen',
diff --git a/src/locales/ru.ts b/src/locales/ru.ts
index ec94336b..c2b16385 100644
--- a/src/locales/ru.ts
+++ b/src/locales/ru.ts
@@ -359,8 +359,9 @@ export const ruTranslation = {
},
luckyMix: {
done: '«Мне повезёт» готово: {{count}} треков',
- failed: 'Не удалось собрать микс. Попробуйте ещё раз.',
+ failed: 'Не удалось собрать микс «Мне повезёт». Попробуйте ещё раз.',
unavailable: '«Мне повезёт» недоступен на этом сервере.',
+ cancelTooltip: 'Отменить сборку микса «Мне повезёт»',
},
albums: {
title: 'Все альбомы',
diff --git a/src/locales/zh.ts b/src/locales/zh.ts
index ac907158..e2f436b6 100644
--- a/src/locales/zh.ts
+++ b/src/locales/zh.ts
@@ -31,7 +31,7 @@ export const zhTranslation = {
expandPlaylists: '展开播放列表',
collapsePlaylists: '收起播放列表',
more: '更多',
- feelingLucky: '好运',
+ feelingLucky: '好运混音',
},
home: {
hero: '精选',
@@ -282,7 +282,7 @@ export const zhTranslation = {
mixByTracksDesc: '从整个媒体库随机选取曲目',
mixByAlbums: '按专辑混音',
mixByAlbumsDesc: '随机选取专辑,探索新音乐',
- mixByLucky: '好运',
+ mixByLucky: '好运混音',
mixByLuckyDesc: '基于高频艺人、专辑和高评分歌曲的智能 Instant Mix',
},
randomAlbums: {
@@ -337,9 +337,10 @@ export const zhTranslation = {
genreClickHint: '点击流派标签将其添加为过滤关键词。\\n匹配流派、标题、专辑和艺术家。',
},
luckyMix: {
- done: '幸运混音已就绪:{{count}} 首',
- failed: '生成混音失败,请重试。',
- unavailable: '此服务器不可用“好运”。',
+ done: '好运混音已就绪:{{count}} 首',
+ failed: '生成好运混音失败,请重试。',
+ unavailable: '当前服务器不支持好运混音。',
+ cancelTooltip: '取消生成好运混音',
},
albums: {
title: '全部专辑',
@@ -709,8 +710,8 @@ export const zhTranslation = {
showChangelogOnUpdate: '更新时显示"新功能"',
showChangelogOnUpdateDesc: '更新后在「正在播放」上方显示一个低调的更新日志横幅。点击打开发行说明,X 按钮关闭。',
randomMixTitle: '随机混音黑名单',
- luckyMixMenuTitle: '在菜单中显示“好运”',
- luckyMixMenuDesc: '在“创建混音”中启用“好运”,并在分离导航时作为独立菜单项显示。仅当当前服务器启用 AudioMuse 时可见。',
+ luckyMixMenuTitle: '在菜单中显示“好运混音”',
+ luckyMixMenuDesc: '在“创建混音”中启用“好运混音”,并在分离导航时作为独立菜单项显示。仅当当前服务器启用 AudioMuse 时可见。',
randomMixBlacklistTitle: '自定义过滤关键词',
randomMixBlacklistDesc: '当任何关键词匹配流派、标题、专辑或艺术家时,歌曲将被排除(当上方复选框开启时生效)。',
randomMixBlacklistPlaceholder: '添加关键词…',
@@ -747,7 +748,7 @@ export const zhTranslation = {
sidebarDrag: '拖动以重新排序',
sidebarFixed: '始终显示',
randomNavSplitTitle: '拆分混音导航',
- randomNavSplitDesc: '在侧边栏中将“随机混音”、“随机专辑”和“好运”显示为独立条目,而非“创建混音”合并入口。',
+ randomNavSplitDesc: '在侧边栏中将“随机混音”、“随机专辑”和“好运混音”显示为独立条目,而非“创建混音”合并入口。',
tabInput: '输入',
tabUsers: '用户',
tabSystem: '系统',
diff --git a/src/pages/RandomLanding.tsx b/src/pages/RandomLanding.tsx
index c010e2b3..da844fa2 100644
--- a/src/pages/RandomLanding.tsx
+++ b/src/pages/RandomLanding.tsx
@@ -2,7 +2,7 @@ import React from 'react';
import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { Shuffle, Dices, Sparkles } from 'lucide-react';
-import { useAuthStore } from '../store/authStore';
+import { useLuckyMixAvailable } from '../hooks/useLuckyMixAvailable';
interface MixCard {
icon: React.ElementType;
@@ -29,10 +29,9 @@ const CARDS: MixCard[] = [
export default function RandomLanding() {
const { t } = useTranslation();
const navigate = useNavigate();
- const activeServerId = useAuthStore(s => s.activeServerId);
- const showLuckyMixMenu = useAuthStore(s => s.showLuckyMixMenu);
- const audiomuseByServer = useAuthStore(s => s.audiomuseNavidromeByServer);
- const luckyMixAvailable = showLuckyMixMenu && Boolean(activeServerId && audiomuseByServer[activeServerId]);
+ // RandomLanding is only reachable in "hub" nav mode, so we don't need to
+ // gate on randomNavMode here — availability alone is enough.
+ const luckyMixAvailable = useLuckyMixAvailable();
const cards = luckyMixAvailable
? [
...CARDS,
diff --git a/src/pages/Settings.tsx b/src/pages/Settings.tsx
index 04a492f5..6e84416d 100644
--- a/src/pages/Settings.tsx
+++ b/src/pages/Settings.tsx
@@ -21,6 +21,7 @@ import { lastfmGetToken, lastfmAuthUrl, lastfmGetSession, lastfmGetUserInfo, Las
import LastfmIcon from '../components/LastfmIcon';
import CustomSelect from '../components/CustomSelect';
import SettingsSubSection from '../components/SettingsSubSection';
+import { useLuckyMixAvailable } from '../hooks/useLuckyMixAvailable';
import ThemePicker, { THEME_GROUPS } from '../components/ThemePicker';
import { useShallow } from 'zustand/react/shallow';
import { useAuthStore, ServerProfile, MIX_MIN_RATING_FILTER_MAX_STARS, type SeekbarStyle, type LyricsSourceId, type LyricsSourceConfig, type LoggingMode } from '../store/authStore';
@@ -4413,10 +4414,8 @@ function SidebarCustomizer() {
itemsRef.current = items;
const randomNavMode = useAuthStore(s => s.randomNavMode);
const setRandomNavMode = useAuthStore(s => s.setRandomNavMode);
- const activeServerId = useAuthStore(s => s.activeServerId);
- const audiomuseByServer = useAuthStore(s => s.audiomuseNavidromeByServer);
- const showLuckyMixMenu = useAuthStore(s => s.showLuckyMixMenu);
- const luckyMixAvailable = randomNavMode === 'separate' && showLuckyMixMenu && Boolean(activeServerId && audiomuseByServer[activeServerId]);
+ const luckyMixBase = useLuckyMixAvailable();
+ const luckyMixAvailable = luckyMixBase && randomNavMode === 'separate';
const libraryItems = items.filter(cfg => {
if (!ALL_NAV_ITEMS[cfg.id] || ALL_NAV_ITEMS[cfg.id].section !== 'library') return false;
diff --git a/src/store/luckyMixStore.ts b/src/store/luckyMixStore.ts
index 8b3727ec..2dc06c05 100644
--- a/src/store/luckyMixStore.ts
+++ b/src/store/luckyMixStore.ts
@@ -1,13 +1,23 @@
import { create } from 'zustand';
interface LuckyMixState {
+ /** True while `buildAndPlayLuckyMix` is actively assembling a mix. */
isRolling: boolean;
+ /**
+ * Set by `cancel()` — the build loop polls this between awaits and bails
+ * out silently when true. Reset to false on `start()` so a new build can
+ * run after a cancelled one.
+ */
+ cancelRequested: boolean;
start: () => void;
stop: () => void;
+ cancel: () => void;
}
export const useLuckyMixStore = create((set) => ({
isRolling: false,
- start: () => set({ isRolling: true }),
- stop: () => set({ isRolling: false }),
+ cancelRequested: false,
+ start: () => set({ isRolling: true, cancelRequested: false }),
+ stop: () => set({ isRolling: false, cancelRequested: false }),
+ cancel: () => set({ cancelRequested: true }),
}));
diff --git a/src/styles/components.css b/src/styles/components.css
index b2af37ae..7046a273 100644
--- a/src/styles/components.css
+++ b/src/styles/components.css
@@ -2427,68 +2427,7 @@
box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent) 25%, transparent);
}
-/* ─ Lucky Mix overlay (big rolling dice) ─ */
-.lucky-mix-overlay {
- position: fixed;
- inset: 0;
- z-index: 10050;
- display: flex;
- flex-direction: column;
- align-items: center;
- justify-content: center;
- gap: 12px;
- background: color-mix(in srgb, var(--ctp-mantle) 84%, transparent);
- backdrop-filter: blur(5px);
- -webkit-backdrop-filter: blur(5px);
- pointer-events: all;
-}
-
-.lucky-mix-overlay__dice {
- position: relative;
- width: 240px;
- height: 190px;
-}
-
-.lucky-mix-cube {
- position: absolute;
- width: 86px;
- height: 86px;
- border-radius: 18px;
- border: 2px solid color-mix(in srgb, #fff 70%, var(--accent) 30%);
- background: linear-gradient(
- 160deg,
- color-mix(in srgb, var(--accent) 72%, #fff 28%) 0%,
- color-mix(in srgb, var(--accent) 84%, #000 16%) 100%
- );
- box-shadow:
- 0 12px 28px rgba(0, 0, 0, 0.4),
- inset -8px -10px 18px rgba(0, 0, 0, 0.2),
- inset 6px 8px 16px rgba(255, 255, 255, 0.2);
-}
-
-.lucky-mix-cube--a {
- left: 10px;
- top: 74px;
- transform: rotate(-12deg);
- animation: luckyCubeA 980ms ease-in-out infinite;
-}
-
-.lucky-mix-cube--b {
- left: 76px;
- top: 18px;
- width: 98px;
- height: 98px;
- transform: rotate(9deg);
- animation: luckyCubeB 900ms ease-in-out infinite;
-}
-
-.lucky-mix-cube--c {
- left: 152px;
- top: 82px;
- transform: rotate(15deg);
- animation: luckyCubeC 1120ms ease-in-out infinite;
-}
-
+/* ─ Lucky Mix pips (shared by the inline queue-lucky-cube indicator) ─ */
.lucky-mix-pip {
position: absolute;
width: 14px;
@@ -2508,44 +2447,24 @@
transform: translate(-50%, -50%);
}
-.lucky-mix-overlay__title {
- font-family: var(--font-display);
- font-size: clamp(30px, 4.5vw, 52px);
- font-weight: 800;
- color: #fff;
- letter-spacing: 0.01em;
- text-shadow: 0 10px 32px rgba(0, 0, 0, 0.45);
-}
-
-.lucky-mix-overlay__phase {
- font-size: clamp(14px, 2vw, 18px);
- color: color-mix(in srgb, #fff 78%, var(--accent) 22%);
- font-weight: 600;
-}
-
-@keyframes luckyCubeA {
- 0% { transform: translate(0, 0) rotate(0deg) scale(1); }
- 50% { transform: translate(-7px, -14px) rotate(-20deg) scale(1.06); }
- 100% { transform: translate(0, 0) rotate(0deg) scale(1); }
-}
-
-@keyframes luckyCubeB {
- 0% { transform: translate(0, 0) rotate(0deg) scale(1.02); }
- 50% { transform: translate(6px, -18px) rotate(13deg) scale(1.09); }
- 100% { transform: translate(0, 0) rotate(0deg) scale(1.02); }
-}
-
-@keyframes luckyCubeC {
- 0% { transform: translate(0, 0) rotate(0deg) scale(1); }
- 50% { transform: translate(8px, -12px) rotate(17deg) scale(1.04); }
- 100% { transform: translate(0, 0) rotate(0deg) scale(1); }
-}
-
.queue-lucky-loading {
margin: 2px 10px 4px;
display: flex;
justify-content: center;
padding: 7px 10px;
+ background: transparent;
+ border: 1px solid transparent;
+ border-radius: 8px;
+ cursor: pointer;
+ width: calc(100% - 20px);
+ transition: background 140ms ease, border-color 140ms ease;
+}
+.queue-lucky-loading:hover {
+ background: color-mix(in srgb, var(--danger) 8%, transparent);
+ border-color: color-mix(in srgb, var(--danger) 28%, transparent);
+}
+.queue-lucky-loading:hover .queue-lucky-cube {
+ opacity: 0.55;
}
.queue-lucky-loading__dice {
diff --git a/src/utils/luckyMix.ts b/src/utils/luckyMix.ts
index 6c6fc876..b1acb89b 100644
--- a/src/utils/luckyMix.ts
+++ b/src/utils/luckyMix.ts
@@ -11,10 +11,23 @@ import {
import { invoke } from '@tauri-apps/api/core';
import i18n from '../i18n';
import { useAuthStore } from '../store/authStore';
-import { songToTrack, usePlayerStore } from '../store/playerStore';
+import { songToTrack, usePlayerStore, type Track } from '../store/playerStore';
import { useLuckyMixStore } from '../store/luckyMixStore';
+import { isLuckyMixAvailable } from '../hooks/useLuckyMixAvailable';
import { showToast } from './toast';
+/**
+ * Sentinel thrown inside the build loop when `useLuckyMixStore.cancelRequested`
+ * flips to true. The `catch` handler swallows it silently (no toast, no
+ * queue restore, no error state) — the user already moved on.
+ */
+class LuckyMixCancelled extends Error {
+ constructor() {
+ super('lucky-mix-cancelled');
+ this.name = 'LuckyMixCancelled';
+ }
+}
+
interface TopArtist {
id: string;
name: string;
@@ -141,28 +154,49 @@ export async function buildAndPlayLuckyMix(): Promise {
const albumDebug = (albums: SubsonicAlbum[]) =>
albums.map(a => ({ id: a.id, name: a.name, artist: a.artist, playCount: a.playCount ?? 0 }));
const activeServerId = auth.activeServerId;
- const audiomuseEnabled = Boolean(activeServerId && auth.audiomuseNavidromeByServer[activeServerId]);
+ const available = isLuckyMixAvailable({
+ activeServerId,
+ audiomuseByServer: auth.audiomuseNavidromeByServer,
+ showLuckyMixMenu: auth.showLuckyMixMenu,
+ });
logStep('init', {
activeServerId,
- audiomuseEnabled,
+ available,
showLuckyMixMenu: auth.showLuckyMixMenu,
libraryFilter: activeServerId ? (auth.musicLibraryFilterByServer[activeServerId] ?? 'all') : 'all',
});
- if (!auth.showLuckyMixMenu || !audiomuseEnabled) {
+ if (!available) {
logStep('abort_unavailable');
showToast(i18n.t('luckyMix.unavailable'), 4000, 'warning');
return;
}
+ // Snapshot the current queue *before* we prune — so if the build fails
+ // before we ever play a track, we can put it back the way it was instead
+ // of leaving the user with an empty player.
+ const playerStateBefore = usePlayerStore.getState();
+ const queueSnapshot: { queue: Track[]; queueIndex: number } = {
+ queue: [...playerStateBefore.queue],
+ queueIndex: playerStateBefore.queueIndex,
+ };
+
// Drop the old "upcoming" tail immediately so the queue UI does not show stale
// next tracks while the mix is still building (first playTrack may be delayed).
usePlayerStore.getState().pruneUpcomingToCurrent();
lucky.start();
+ // Per-run handles. Live outside the try so `finally`/`catch` can read
+ // `startedPlayback` (drives the queue-restore decision) and clean up the
+ // player-store subscription unconditionally.
+ let unsubPlayer: (() => void) | null = null;
+ let startedPlayback = false;
try {
const queuedIds = new Set();
let allSeedSongs: SubsonicSong[] = [];
- let startedPlayback = false;
+
+ const bailIfCancelled = () => {
+ if (useLuckyMixStore.getState().cancelRequested) throw new LuckyMixCancelled();
+ };
const reachedTarget = () => queuedIds.size >= MIX_TARGET_SIZE;
const isBlockedByRating = (song: SubsonicSong) => {
const rating = song.userRating ?? 0;
@@ -180,9 +214,25 @@ export async function buildAndPlayLuckyMix(): Promise {
song: songDebug([song])[0],
queuedCount: queuedIds.size,
});
+
+ // Auto-cancel: once we're playing, watch the player store. If the
+ // current track switches to something the user picked themselves (not
+ // in our queuedIds set), treat that as "user moved on" and cancel the
+ // build so we don't later overwrite their choice with our finalised mix.
+ if (!unsubPlayer) {
+ unsubPlayer = usePlayerStore.subscribe((state, prev) => {
+ const prevId = prev.currentTrack?.id ?? null;
+ const nextId = state.currentTrack?.id ?? null;
+ if (nextId === prevId) return;
+ if (!nextId) return;
+ if (queuedIds.has(nextId)) return;
+ useLuckyMixStore.getState().cancel();
+ });
+ }
};
const appendSongsToQueue = (songs: SubsonicSong[], reason: string): number => {
+ if (useLuckyMixStore.getState().cancelRequested) return 0;
if (reachedTarget()) return 0;
if (!songs.length) return 0;
const deduped = uniqueBySongId(songs).filter(s => !queuedIds.has(s.id) && !isBlockedByRating(s));
@@ -211,6 +261,7 @@ export async function buildAndPlayLuckyMix(): Promise {
};
const frequentAlbums = await fetchFrequentAlbumsPool();
+ bailIfCancelled();
const albumsWithPlays = frequentAlbums.filter(a => (a.playCount ?? 0) > 0);
logStep('fetch_frequent_albums', {
fetched: frequentAlbums.length,
@@ -224,6 +275,7 @@ export async function buildAndPlayLuckyMix(): Promise {
});
for (const artist of pickedArtists) {
+ bailIfCancelled();
const songs = await pickSongsForArtist(artist, 3);
allSeedSongs = uniqueAppend(allSeedSongs, songs);
const firstPlayable = songs.find(s => !isBlockedByRating(s));
@@ -241,6 +293,7 @@ export async function buildAndPlayLuckyMix(): Promise {
pickedAlbums: albumDebug(pickedAlbums),
});
for (const album of pickedAlbums) {
+ bailIfCancelled();
const songs = await pickSongsForAlbum(album.id, 3);
allSeedSongs = uniqueAppend(allSeedSongs, songs);
const firstPlayable = songs.find(s => !isBlockedByRating(s));
@@ -252,6 +305,7 @@ export async function buildAndPlayLuckyMix(): Promise {
});
}
+ bailIfCancelled();
const rated = await pickGoodRatedSongs(new Set(allSeedSongs.map(s => s.id)), 3);
logStep('pick_rated_songs_4plus_only', {
ratedPickedCount: rated.length,
@@ -267,6 +321,7 @@ export async function buildAndPlayLuckyMix(): Promise {
if (seeds.length < SEED_TARGET_SIZE) {
logStep('seed_fill_start', { target: SEED_TARGET_SIZE, before: seeds.length });
for (let i = 0; i < 10 && seeds.length < SEED_TARGET_SIZE; i++) {
+ bailIfCancelled();
const rnd = await filterSongsToActiveLibrary(await getRandomSongs(80));
const allowedRnd = rnd.filter(s => !isBlockedByRating(s));
seeds = uniqueAppend(seeds, allowedRnd);
@@ -296,6 +351,7 @@ export async function buildAndPlayLuckyMix(): Promise {
let similarRaw: SubsonicSong[] = [];
let similar: SubsonicSong[] = [];
for (let i = 0; i < seeds.length; i++) {
+ bailIfCancelled();
const seed = seeds[i];
const oneRaw = await getSimilarSongs(seed.id, 60).catch(() => [] as SubsonicSong[]);
const oneScoped = await filterSongsToActiveLibrary(oneRaw);
@@ -317,6 +373,7 @@ export async function buildAndPlayLuckyMix(): Promise {
});
for (let i = 0; i < 10 && pool.length < MIX_TARGET_SIZE; i++) {
+ bailIfCancelled();
const rnd = await filterSongsToActiveLibrary(await getRandomSongs(120));
pool = uniqueAppend(pool, rnd);
appendSongsToQueue(rnd, `pool-fill-${i + 1}`);
@@ -328,6 +385,7 @@ export async function buildAndPlayLuckyMix(): Promise {
if (reachedTarget()) break;
}
+ bailIfCancelled();
const finalSongs = sampleRandom(pool, MIX_TARGET_SIZE).filter(s => !queuedIds.has(s.id));
appendSongsToQueue(finalSongs, 'finalize-randomized');
logStep('final_queue_state', {
@@ -348,6 +406,18 @@ export async function buildAndPlayLuckyMix(): Promise {
}).catch(() => {});
}
} catch (err) {
+ // Cancellation is a user-initiated path, not an error. Silent teardown.
+ if (err instanceof LuckyMixCancelled) {
+ logStep('cancelled');
+ if (debugEnabled) {
+ console.debug('[psysonic][lucky-mix] full-steps', debugSteps);
+ void invoke('frontend_debug_log', {
+ scope: 'lucky-mix',
+ message: JSON.stringify({ step: 'full-steps', details: debugSteps }),
+ }).catch(() => {});
+ }
+ return;
+ }
console.error('[psysonic] lucky mix failed:', err);
logStep('failed', { error: String(err) });
if (debugEnabled) {
@@ -357,8 +427,23 @@ export async function buildAndPlayLuckyMix(): Promise {
message: JSON.stringify({ step: 'full-steps', details: debugSteps }),
}).catch(() => {});
}
+ // If we failed before ever calling playTrack, the queue-prune we did up
+ // front left the user with nothing. Restore the snapshot so they land
+ // back where they were pre-click instead of in an empty player.
+ // If playback did start, leave it alone — their current track plus
+ // whatever we managed to enqueue is more useful than the old queue.
+ if (!startedPlayback) {
+ usePlayerStore.setState({
+ queue: queueSnapshot.queue,
+ queueIndex: queueSnapshot.queueIndex,
+ });
+ logStep('queue_restored_after_failure', {
+ restoredCount: queueSnapshot.queue.length,
+ });
+ }
showToast(i18n.t('luckyMix.failed'), 5000, 'error');
} finally {
+ if (unsubPlayer) { try { unsubPlayer(); } catch { /* noop */ } }
useLuckyMixStore.getState().stop();
}
}