From e9e61b9a05eb120f948ed9eef0559c4c8debe909 Mon Sep 17 00:00:00 2001 From: Psychotoxical Date: Thu, 23 Apr 2026 18:41:46 +0200 Subject: [PATCH] refactor(lucky-mix): review follow-ups from Psychotoxical MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Drop the ~110 lines of dead full-screen overlay CSS that was never referenced in JSX (.lucky-mix-overlay*, .lucky-mix-cube*, full-screen @keyframes). Keep the .lucky-mix-pip* rules since the inline queue variant re-uses them. - Extract availability gate into hooks/useLuckyMixAvailable.ts (pure predicate + hook). Replaces five duplicated inline checks in Sidebar, MobileMoreOverlay, RandomLanding, Settings/SidebarCustomizer, and the internal re-check in buildAndPlayLuckyMix. - Add a cancel mechanic: * luckyMixStore gets a cancelRequested flag + cancel() method * LuckyMixCancelled sentinel is thrown from a bailIfCancelled() helper sprinkled between await boundaries in the build loop * catch-block swallows the sentinel silently (no toast, no error state) * auto-cancel subscription on playerStore detects manual user track changes (anything not in queuedIds) and flips cancelRequested so the finished mix does not later overwrite the user's choice * inline .queue-lucky-loading dice indicator is now a button — click triggers explicit cancel, hover fades the dice to signal the action - Restore the queue snapshot when the build fails before ever starting playback, so the user does not land in an empty player after an error. If playTrack already ran, leave current state alone (their track plus whatever was enqueued is more useful than a stale pre-click queue). - Rework locale labels to consistently carry the "Mix" suffix so the entry reads well next to "Random Mix" / "Random Albums" in the sidebar: DE Glücks-Mix, ES Mezcla Suerte, FR Mix Chance, NB Lykkemiks, NL Geluksmix, ZH 好运混音. EN stays "Lucky Mix", RU stays «Мне повезёт». Toast/settings/randomNavSplit copy aligned to the new names. New luckyMix.cancelTooltip key added in all 8 locales. AudioMuse gating stays — as Cuca confirmed, the feature's quality relies on a correct similar-track selection, so the requirement is intentional. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/components/MobileMoreOverlay.tsx | 6 +- src/components/QueuePanel.tsx | 10 ++- src/components/Sidebar.tsx | 8 +- src/hooks/useLuckyMixAvailable.ts | 40 ++++++++++ src/locales/de.ts | 17 +++-- src/locales/en.ts | 5 +- src/locales/es.ts | 17 +++-- src/locales/fr.ts | 17 +++-- src/locales/nb.ts | 17 +++-- src/locales/nl.ts | 17 +++-- src/locales/ru.ts | 3 +- src/locales/zh.ts | 17 +++-- src/pages/RandomLanding.tsx | 9 +-- src/pages/Settings.tsx | 7 +- src/store/luckyMixStore.ts | 14 +++- src/styles/components.css | 109 ++++----------------------- src/utils/luckyMix.ts | 95 +++++++++++++++++++++-- 17 files changed, 238 insertions(+), 170 deletions(-) create mode 100644 src/hooks/useLuckyMixAvailable.ts 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(); } }