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(); } }