From 3b4d54431b299df3fa97eb852be01a7f30e29c69 Mon Sep 17 00:00:00 2001 From: Frank Stellmacher <171614930+Psychotoxical@users.noreply.github.com> Date: Sun, 3 May 2026 19:57:08 +0200 Subject: [PATCH] feat(random-mix): playlist size selector + filter panel layout cleanup (#445) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(random-mix): playlist size selector + filter panel layout cleanup Adds a 5-button playlist-size picker (50/75/100/125/150) at the top of the Random Mix filter panel, persisted via authStore. Clicking a size immediately reruns the current mix (genre-scoped or All Songs) at the new size — no second click on Remix needed. Filter panel layout cleaned up: - Two sub-sections "MIX SETTINGS" and "EXCLUSIONS" with a divider between them so the panel reads cleanly with the new size row. - Larger panel-level headers (FILTERS / GENRE MIX) so the hierarchy panel-title > sub-section is visually unambiguous. - Italic muted note under MIX SETTINGS calling out that large mix sizes may return fewer unique tracks if the server's random pool runs short — sets honest expectations instead of users wondering why a 150 request returned ~126. fetchRandomMixSongsUntilFull now scales batch size, max-batch ceiling and dup-streak budget with target size; when no Settings-level mix filter is active, the first call asks for the full target so a 150 mix can finish in a single round-trip on most libraries. The loop falls through to top up with deduped follow-up calls if the server returns fewer than requested. * docs(changelog): add #445 Random Mix playlist size selector entry * chore(credits): add #445 to Psychotoxical contributions --- CHANGELOG.md | 11 ++++++ src/locales/de.ts | 4 ++ src/locales/en.ts | 4 ++ src/locales/es.ts | 4 ++ src/locales/fr.ts | 4 ++ src/locales/nb.ts | 4 ++ src/locales/nl.ts | 4 ++ src/locales/ru.ts | 4 ++ src/locales/zh.ts | 4 ++ src/pages/RandomMix.tsx | 61 +++++++++++++++++++++++------ src/pages/Settings.tsx | 1 + src/store/authStore.ts | 21 ++++++++++ src/utils/mixRatingFilter.ts | 76 ++++++++++++++++++++++++++++-------- 13 files changed, 173 insertions(+), 29 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0597a402..b009c1bb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -166,6 +166,17 @@ Every song card across the app whose rating is greater than zero now shows a sma Backed by an opt-in 60 s in-memory cache for `ndListSongs` (used only by the new rail; paginated browsing is unaffected). The cache is cleared automatically when you rate a track, switch server, or click the rail's reroll button. +### Random Mix — Playlist Size Selector and Filter Panel Cleanup + +**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#445](https://github.com/Psychotoxical/psysonic/pull/445), prompted by Foxhunter-de in discussion [#442](https://github.com/Psychotoxical/psysonic/discussions/442)** + +Random Mix can now build longer mixes. A new playlist-size picker (50 / 75 / 100 / 125 / 150) sits at the top of the filter panel. Clicking a size button immediately reloads the current mix at the new count — Genre Mix and All Songs both honored, no extra Remix click needed. The choice is persisted across restarts. + +The filter panel layout was tightened up at the same time: two sub-headings (**MIX SETTINGS** and **EXCLUSIONS**) with a divider between them, and a slightly larger panel-level header so the visual hierarchy reads cleanly. A small italic note below MIX SETTINGS explains that large mix sizes may return fewer unique tracks if the server's random pool runs short. + +Under the hood, `fetchRandomMixSongsUntilFull` now scales batch size, max-batch ceiling and dup-streak budget with the requested target — so a 150-track mix can finish in a single round-trip on most libraries instead of stalling out at ~120. + + ## Fixed - **Settings → Audio no longer blanks the app on macOS** *(Issue [#382](https://github.com/Psychotoxical/psysonic/issues/382), PR [#384](https://github.com/Psychotoxical/psysonic/pull/384), by [@Psychotoxical](https://github.com/Psychotoxical))*: Fixed a macOS-only crash where opening Settings → Audio could turn the whole app into a blank window. The Equalizer canvas now waits until it has valid layout dimensions before drawing, and redraws automatically once the section is visible. diff --git a/src/locales/de.ts b/src/locales/de.ts index 05cc4901..7a75f294 100644 --- a/src/locales/de.ts +++ b/src/locales/de.ts @@ -323,6 +323,10 @@ export const deTranslation = { favoriteRemove: 'Aus Favoriten entfernen', play: 'Abspielen', trackGenre: 'Genre', + mixSettingsHeader: 'Mix-Einstellungen', + exclusionsHeader: 'Ausschlüsse', + filterPanelInexactSizeNote: 'Die Mix-Größe ist ein Ziel — bei großen Anfragen kann der Server weniger eindeutige Tracks liefern, wenn sein Zufallspool erschöpft ist.', + mixSize: 'Playlistgröße', excludeAudiobooks: 'Hörbücher & Hörspiele ausschließen', excludeAudiobooksDesc: 'Prüft Keywords gegen Genre, Titel, Album und Künstler — z. B. Hörbuch, Audiobook, Spoken Word, …', genreBlocked: 'Keyword gesperrt', diff --git a/src/locales/en.ts b/src/locales/en.ts index 01742c75..0690e140 100644 --- a/src/locales/en.ts +++ b/src/locales/en.ts @@ -325,6 +325,10 @@ export const enTranslation = { favoriteRemove: 'Remove from Favorites', play: 'Play', trackGenre: 'Genre', + mixSettingsHeader: 'Mix Settings', + exclusionsHeader: 'Exclusions', + filterPanelInexactSizeNote: 'Mix size is a target — large requests may return fewer unique tracks if the server\'s random pool runs short.', + mixSize: 'Playlist size', excludeAudiobooks: 'Exclude audiobooks & radio plays', excludeAudiobooksDesc: 'Matches keywords against genre, title, album, and artist — e.g. Hörbuch, Audiobook, Spoken Word, …', genreBlocked: 'Keyword blocked', diff --git a/src/locales/es.ts b/src/locales/es.ts index fad31d29..9404008c 100644 --- a/src/locales/es.ts +++ b/src/locales/es.ts @@ -324,6 +324,10 @@ export const esTranslation = { favoriteRemove: 'Quitar de Favoritos', play: 'Reproducir', trackGenre: 'Género', + mixSettingsHeader: 'Ajustes del mix', + exclusionsHeader: 'Exclusiones', + filterPanelInexactSizeNote: 'El tamaño del mix es un objetivo — las solicitudes grandes pueden devolver menos pistas únicas si el grupo aleatorio del servidor se agota.', + mixSize: 'Tamaño de lista', excludeAudiobooks: 'Excluir audiolibros y radioteatros', excludeAudiobooksDesc: 'Busca palabras clave en género, título, álbum y artista — ej. Hörbuch, Audiobook, Spoken Word, …', genreBlocked: 'Palabra clave bloqueada', diff --git a/src/locales/fr.ts b/src/locales/fr.ts index c5dc98b8..0438ff9f 100644 --- a/src/locales/fr.ts +++ b/src/locales/fr.ts @@ -323,6 +323,10 @@ export const frTranslation = { favoriteRemove: 'Retirer des favoris', play: 'Lire', trackGenre: 'Genre', + mixSettingsHeader: 'Réglages du mix', + exclusionsHeader: 'Exclusions', + filterPanelInexactSizeNote: 'La taille du mix est une cible — les grandes requêtes peuvent renvoyer moins de morceaux uniques si le pool aléatoire du serveur s\'épuise.', + mixSize: 'Taille de liste', excludeAudiobooks: 'Exclure les livres audio et pièces radiophoniques', excludeAudiobooksDesc: 'Correspond aux mots-clés dans le genre, le titre, l\'album et l\'artiste — ex. Hörbuch, Audiobook, Spoken Word, …', genreBlocked: 'Mot-clé bloqué', diff --git a/src/locales/nb.ts b/src/locales/nb.ts index 721af647..a8fe3111 100644 --- a/src/locales/nb.ts +++ b/src/locales/nb.ts @@ -323,6 +323,10 @@ export const nbTranslation = { favoriteRemove: 'Fjern fra favoritter', play: 'Spill', trackGenre: 'Sjanger', + mixSettingsHeader: 'Miks-innstillinger', + exclusionsHeader: 'Ekskluderinger', + filterPanelInexactSizeNote: 'Miks-størrelsen er et mål — store forespørsler kan returnere færre unike spor hvis serverens tilfeldige pool tar slutt.', + mixSize: 'Listestørrelse', excludeAudiobooks: 'Ekskluder lydbøker og hørespill', excludeAudiobooksDesc: 'Samsvarer nøkkelord mot sjanger, tittel, album og artist - f.eks. Hørespill, Lydbok, Spoken Word, …', genreBlocked: 'Nøkkelord blokkert', diff --git a/src/locales/nl.ts b/src/locales/nl.ts index 900e6bc5..3b265784 100644 --- a/src/locales/nl.ts +++ b/src/locales/nl.ts @@ -322,6 +322,10 @@ export const nlTranslation = { favoriteRemove: 'Uit favorieten verwijderen', play: 'Afspelen', trackGenre: 'Genre', + mixSettingsHeader: 'Mixinstellingen', + exclusionsHeader: 'Uitsluitingen', + filterPanelInexactSizeNote: 'De mixgrootte is een doel — grote verzoeken kunnen minder unieke nummers opleveren als de willekeurige pool van de server opraakt.', + mixSize: 'Lijstgrootte', excludeAudiobooks: 'Luisterboeken en hoorspelen uitsluiten', excludeAudiobooksDesc: 'Vergelijkt trefwoorden met genre, titel, album en artiest — bijv. Hörbuch, Audiobook, Spoken Word, …', genreBlocked: 'Trefwoord geblokkeerd', diff --git a/src/locales/ru.ts b/src/locales/ru.ts index e8c6d2b7..ff00606f 100644 --- a/src/locales/ru.ts +++ b/src/locales/ru.ts @@ -340,6 +340,10 @@ export const ruTranslation = { favoriteRemove: 'Убрать из избранного', play: 'Играть', trackGenre: 'Жанр', + mixSettingsHeader: 'Настройки микса', + exclusionsHeader: 'Исключения', + filterPanelInexactSizeNote: 'Размер микса — это цель. При больших запросах сервер может вернуть меньше уникальных треков, если его случайный пул иссякает.', + mixSize: 'Размер списка', excludeAudiobooks: 'Исключать аудиокниги и радиоспектакли', excludeAudiobooksDesc: 'По ключевым словам в жанре, названии, альбоме и исполнителе — например Hörbuch, Audiobook, Spoken Word и т. п.', diff --git a/src/locales/zh.ts b/src/locales/zh.ts index 4f103ebd..d691f8ef 100644 --- a/src/locales/zh.ts +++ b/src/locales/zh.ts @@ -321,6 +321,10 @@ export const zhTranslation = { favoriteRemove: '从收藏中移除', play: '播放', trackGenre: '流派', + mixSettingsHeader: '混音设置', + exclusionsHeader: '排除项', + filterPanelInexactSizeNote: '混音大小是一个目标 — 当服务器的随机池耗尽时,较大的请求可能会返回较少的唯一曲目。', + mixSize: '列表大小', excludeAudiobooks: '排除有声读物和广播剧', excludeAudiobooksDesc: '根据流派、标题、专辑和艺术家匹配关键词 — 例如 Hörbuch、Audiobook、Spoken Word 等', genreBlocked: '关键词已屏蔽', diff --git a/src/pages/RandomMix.tsx b/src/pages/RandomMix.tsx index 9935ada6..05aee1b7 100644 --- a/src/pages/RandomMix.tsx +++ b/src/pages/RandomMix.tsx @@ -2,7 +2,7 @@ import React, { useEffect, useMemo, useState } from 'react'; import { getGenres, SubsonicSong, SubsonicGenre, star, unstar } from '../api/subsonic'; import { usePlayerStore, songToTrack } from '../store/playerStore'; import { usePreviewStore } from '../store/previewStore'; -import { useAuthStore } from '../store/authStore'; +import { useAuthStore, RANDOM_MIX_SIZE_OPTIONS } from '../store/authStore'; import { Play, RefreshCw, ChevronDown, ChevronRight, ChevronUp, Heart, Square } from 'lucide-react'; import { useTranslation } from 'react-i18next'; import { useDragDrop } from '../contexts/DragDropContext'; @@ -56,6 +56,8 @@ export default function RandomMix() { mixMinRatingSong, mixMinRatingAlbum, mixMinRatingArtist, + randomMixSize, + setRandomMixSize, } = useAuthStore(); const mixRatingCfg = useMemo( @@ -88,10 +90,10 @@ export default function RandomMix() { const [genreMixLoading, setGenreMixLoading] = useState(false); const [genreMixComplete, setGenreMixComplete] = useState(false); - const fetchSongs = () => { + const fetchSongs = (overrideSize?: number) => { setLoading(true); setSongs([]); - fetchRandomMixSongsUntilFull(getMixMinRatingsConfigFromAuth()) + fetchRandomMixSongsUntilFull(getMixMinRatingsConfigFromAuth(), { targetSize: overrideSize ?? randomMixSize }) .then(list => { setSongs(list); const st = new Set(); @@ -163,7 +165,7 @@ export default function RandomMix() { } }; - const loadGenreMix = async (genre: string) => { + const loadGenreMix = async (genre: string, overrideSize?: number) => { setGenreMixLoading(true); setGenreMixComplete(false); setGenreMixSongs([]); @@ -171,6 +173,7 @@ export default function RandomMix() { const list = await fetchRandomMixSongsUntilFull(getMixMinRatingsConfigFromAuth(), { genre, timeout: 45000, + targetSize: overrideSize ?? randomMixSize, }); setGenreMixSongs(list); } catch {} @@ -195,7 +198,7 @@ export default function RandomMix() {
) : ( -
+
{t('randomMix.filterPanelTitle')}
)} {(!isMobile || filtersExpanded) && (
-

+ {/* ── Mix Settings ── */} +

+ {t('randomMix.mixSettingsHeader')} +
+ +

+ {t('randomMix.filterPanelInexactSizeNote')} +

+ +
+ {t('randomMix.mixSize')} + {RANDOM_MIX_SIZE_OPTIONS.map(n => ( + + ))} +
+ + {/* ── divider ── */} +
+ + {/* ── Exclusions ── */} +
+ {t('randomMix.exclusionsHeader')} +
+ +

{t('randomMix.filterPanelDesc')}

-