feat(random-mix): playlist size selector + filter panel layout cleanup (#445)

* 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
This commit is contained in:
Frank Stellmacher
2026-05-03 19:57:08 +02:00
committed by GitHub
parent 1799e90e04
commit 3b4d54431b
13 changed files with 173 additions and 29 deletions
+21
View File
@@ -220,6 +220,8 @@ interface AuthState {
mixMinRatingAlbum: number;
/** 0 = ignore; artist rating from payload / nested OpenSubsonic fields or `getArtist`. */
mixMinRatingArtist: number;
/** Random Mix target list size (50, 75, 100, 125, or 150). */
randomMixSize: number;
/** Show "Lucky Mix" as a regular sidebar/menu item. */
showLuckyMixMenu: boolean;
@@ -346,6 +348,7 @@ interface AuthState {
setMixMinRatingSong: (v: number) => void;
setMixMinRatingAlbum: (v: number) => void;
setMixMinRatingArtist: (v: number) => void;
setRandomMixSize: (v: number) => void;
setShowLuckyMixMenu: (v: boolean) => void;
setMusicFolders: (folders: Array<{ id: string; name: string }>) => void;
setMusicLibraryFilter: (folderId: 'all' | string) => void;
@@ -373,6 +376,21 @@ function clampMixFilterMinStars(v: number): number {
return Math.max(0, Math.min(MIX_MIN_RATING_FILTER_MAX_STARS, Math.round(v)));
}
export const RANDOM_MIX_SIZE_OPTIONS: readonly number[] = [50, 75, 100, 125, 150];
function clampRandomMixSize(v: number): number {
if (!Number.isFinite(v)) return 50;
// Snap to the nearest allowed option so a tampered persisted value can't break the picker.
let nearest = RANDOM_MIX_SIZE_OPTIONS[0];
let bestDelta = Math.abs(v - nearest);
for (const opt of RANDOM_MIX_SIZE_OPTIONS) {
const d = Math.abs(v - opt);
if (d < bestDelta) { nearest = opt; bestDelta = d; }
}
return nearest;
}
function clampSkipStarThreshold(v: number): number {
if (!Number.isFinite(v)) return 3;
return Math.max(1, Math.min(99, Math.round(v)));
@@ -468,6 +486,7 @@ export const useAuthStore = create<AuthState>()(
mixMinRatingSong: 0,
mixMinRatingAlbum: 0,
mixMinRatingArtist: 0,
randomMixSize: 50,
showLuckyMixMenu: true,
randomNavMode: 'hub',
musicFolders: [],
@@ -662,6 +681,7 @@ export const useAuthStore = create<AuthState>()(
setMixMinRatingSong: (v) => set({ mixMinRatingSong: clampMixFilterMinStars(v) }),
setMixMinRatingAlbum: (v) => set({ mixMinRatingAlbum: clampMixFilterMinStars(v) }),
setMixMinRatingArtist: (v) => set({ mixMinRatingArtist: clampMixFilterMinStars(v) }),
setRandomMixSize: (v) => set({ randomMixSize: clampRandomMixSize(v) }),
setShowLuckyMixMenu: (v) => set({ showLuckyMixMenu: v }),
setRandomNavMode: (v) => set({ randomNavMode: v }),
@@ -848,6 +868,7 @@ export const useAuthStore = create<AuthState>()(
mixMinRatingSong: clampMixFilterMinStars(state.mixMinRatingSong as number),
mixMinRatingAlbum: clampMixFilterMinStars(state.mixMinRatingAlbum as number),
mixMinRatingArtist: clampMixFilterMinStars(state.mixMinRatingArtist as number),
randomMixSize: clampRandomMixSize(state.randomMixSize as number),
skipStarManualSkipCountsByKey: sanitizeSkipStarCounts(
(state as { skipStarManualSkipCountsByKey?: unknown }).skipStarManualSkipCountsByKey,
),