mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-22 06:25:41 +00:00
Merge branch 'main' into exp/orbit
This commit is contained in:
@@ -0,0 +1,449 @@
|
||||
import {
|
||||
filterSongsToActiveLibrary,
|
||||
getAlbum,
|
||||
getAlbumList,
|
||||
getRandomSongs,
|
||||
getSimilarSongs,
|
||||
getTopSongs,
|
||||
type SubsonicAlbum,
|
||||
type SubsonicSong,
|
||||
} from '../api/subsonic';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import i18n from '../i18n';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
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;
|
||||
totalPlays: number;
|
||||
}
|
||||
|
||||
const MOST_PLAYED_PAGE_SIZE = 100;
|
||||
const MOST_PLAYED_MAX_ALBUMS = 500;
|
||||
const MIX_TARGET_SIZE = 50;
|
||||
const SEED_TARGET_SIZE = 15;
|
||||
|
||||
function sampleRandom<T>(items: T[], count: number): T[] {
|
||||
if (count <= 0 || items.length === 0) return [];
|
||||
const arr = [...items];
|
||||
for (let i = arr.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
[arr[i], arr[j]] = [arr[j], arr[i]];
|
||||
}
|
||||
return arr.slice(0, Math.min(count, arr.length));
|
||||
}
|
||||
|
||||
function uniqueBySongId(items: SubsonicSong[]): SubsonicSong[] {
|
||||
const out: SubsonicSong[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const s of items) {
|
||||
if (!s?.id || seen.has(s.id)) continue;
|
||||
seen.add(s.id);
|
||||
out.push(s);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function uniqueAppend(base: SubsonicSong[], incoming: SubsonicSong[]): SubsonicSong[] {
|
||||
return uniqueBySongId([...base, ...incoming]);
|
||||
}
|
||||
|
||||
function deriveTopArtistsFromFrequentAlbums(albums: SubsonicAlbum[]): TopArtist[] {
|
||||
const map = new Map<string, TopArtist>();
|
||||
for (const a of albums) {
|
||||
const plays = a.playCount ?? 0;
|
||||
if (!a.artistId || !a.artist || plays <= 0) continue;
|
||||
const prev = map.get(a.artistId);
|
||||
if (prev) {
|
||||
prev.totalPlays += plays;
|
||||
continue;
|
||||
}
|
||||
map.set(a.artistId, { id: a.artistId, name: a.artist, totalPlays: plays });
|
||||
}
|
||||
return [...map.values()].sort((a, b) => b.totalPlays - a.totalPlays);
|
||||
}
|
||||
|
||||
async function fetchFrequentAlbumsPool(): Promise<SubsonicAlbum[]> {
|
||||
const out: SubsonicAlbum[] = [];
|
||||
let offset = 0;
|
||||
while (out.length < MOST_PLAYED_MAX_ALBUMS) {
|
||||
const page = await getAlbumList('frequent', MOST_PLAYED_PAGE_SIZE, offset);
|
||||
if (!page.length) break;
|
||||
out.push(...page);
|
||||
if (page.length < MOST_PLAYED_PAGE_SIZE) break;
|
||||
offset += MOST_PLAYED_PAGE_SIZE;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
async function pickSongsForArtist(artist: TopArtist, need: number): Promise<SubsonicSong[]> {
|
||||
const primary = uniqueBySongId(await filterSongsToActiveLibrary(await getTopSongs(artist.name)));
|
||||
if (primary.length >= need) return sampleRandom(primary, need);
|
||||
|
||||
const extra: SubsonicSong[] = [];
|
||||
for (let i = 0; i < 8 && primary.length + extra.length < need; i++) {
|
||||
const rnd = await filterSongsToActiveLibrary(await getRandomSongs(120));
|
||||
for (const s of rnd) {
|
||||
if (s.artistId === artist.id || s.artist === artist.name) {
|
||||
extra.push(s);
|
||||
}
|
||||
}
|
||||
}
|
||||
return sampleRandom(uniqueBySongId([...primary, ...extra]), need);
|
||||
}
|
||||
|
||||
async function pickSongsForAlbum(albumId: string, need: number): Promise<SubsonicSong[]> {
|
||||
const full = await getAlbum(albumId).catch(() => null);
|
||||
if (!full?.songs?.length) return [];
|
||||
const scopedSongs = await filterSongsToActiveLibrary(full.songs);
|
||||
return sampleRandom(uniqueBySongId(scopedSongs), need);
|
||||
}
|
||||
|
||||
async function pickGoodRatedSongs(existingIds: Set<string>, need: number): Promise<SubsonicSong[]> {
|
||||
const out: SubsonicSong[] = [];
|
||||
const push = (s: SubsonicSong) => {
|
||||
const r = s.userRating ?? 0;
|
||||
if (r < 4) return;
|
||||
if (existingIds.has(s.id)) return;
|
||||
if (out.some(x => x.id === s.id)) return;
|
||||
out.push(s);
|
||||
};
|
||||
|
||||
for (let i = 0; i < 14 && out.length < need; i++) {
|
||||
const rnd = await filterSongsToActiveLibrary(await getRandomSongs(120));
|
||||
rnd.forEach(push);
|
||||
}
|
||||
|
||||
return sampleRandom(out, need);
|
||||
}
|
||||
|
||||
export async function buildAndPlayLuckyMix(): Promise<void> {
|
||||
const lucky = useLuckyMixStore.getState();
|
||||
if (lucky.isRolling) return;
|
||||
const auth = useAuthStore.getState();
|
||||
const debugEnabled = auth.loggingMode === 'debug';
|
||||
const debugSteps: Array<{ step: string; details?: unknown }> = [];
|
||||
const logStep = (step: string, details?: unknown) => {
|
||||
if (!debugEnabled) return;
|
||||
const payload = { step, details };
|
||||
debugSteps.push(payload);
|
||||
console.debug('[psysonic][lucky-mix]', payload);
|
||||
void invoke('frontend_debug_log', {
|
||||
scope: 'lucky-mix',
|
||||
message: JSON.stringify(payload),
|
||||
}).catch(() => {});
|
||||
};
|
||||
const songDebug = (songs: SubsonicSong[]) =>
|
||||
songs.map(s => ({ id: s.id, title: s.title, artist: s.artist, rating: s.userRating ?? 0 }));
|
||||
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 available = isLuckyMixAvailable({
|
||||
activeServerId,
|
||||
audiomuseByServer: auth.audiomuseNavidromeByServer,
|
||||
showLuckyMixMenu: auth.showLuckyMixMenu,
|
||||
});
|
||||
logStep('init', {
|
||||
activeServerId,
|
||||
available,
|
||||
showLuckyMixMenu: auth.showLuckyMixMenu,
|
||||
libraryFilter: activeServerId ? (auth.musicLibraryFilterByServer[activeServerId] ?? 'all') : 'all',
|
||||
});
|
||||
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<string>();
|
||||
let allSeedSongs: SubsonicSong[] = [];
|
||||
|
||||
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;
|
||||
return rating === 1 || rating === 2;
|
||||
};
|
||||
|
||||
const startImmediatePlayback = (song: SubsonicSong, source: string) => {
|
||||
if (startedPlayback || !song?.id || isBlockedByRating(song)) return;
|
||||
startedPlayback = true;
|
||||
queuedIds.add(song.id);
|
||||
const track = songToTrack(song);
|
||||
usePlayerStore.getState().playTrack(track, [track], true);
|
||||
logStep('start_immediate_playback', {
|
||||
source,
|
||||
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));
|
||||
if (!deduped.length) return 0;
|
||||
|
||||
const candidates = [...deduped];
|
||||
if (!startedPlayback && candidates.length > 0) {
|
||||
const first = candidates.shift();
|
||||
if (first) startImmediatePlayback(first, reason);
|
||||
}
|
||||
|
||||
if (!candidates.length) return 0;
|
||||
const remaining = Math.max(0, MIX_TARGET_SIZE - queuedIds.size);
|
||||
if (remaining <= 0) return 0;
|
||||
const toAdd = sampleRandom(candidates, Math.min(remaining, candidates.length));
|
||||
if (!toAdd.length) return 0;
|
||||
toAdd.forEach(s => queuedIds.add(s.id));
|
||||
usePlayerStore.getState().enqueue(toAdd.map(songToTrack));
|
||||
logStep('append_queue_batch', {
|
||||
reason,
|
||||
added: toAdd.length,
|
||||
queuedCount: queuedIds.size,
|
||||
songs: songDebug(toAdd),
|
||||
});
|
||||
return toAdd.length;
|
||||
};
|
||||
|
||||
const frequentAlbums = await fetchFrequentAlbumsPool();
|
||||
bailIfCancelled();
|
||||
const albumsWithPlays = frequentAlbums.filter(a => (a.playCount ?? 0) > 0);
|
||||
logStep('fetch_frequent_albums', {
|
||||
fetched: frequentAlbums.length,
|
||||
withPlays: albumsWithPlays.length,
|
||||
});
|
||||
const topArtists = deriveTopArtistsFromFrequentAlbums(albumsWithPlays);
|
||||
const pickedArtists = sampleRandom(topArtists, 2);
|
||||
logStep('pick_top_artists', {
|
||||
topArtistsCount: topArtists.length,
|
||||
pickedArtists,
|
||||
});
|
||||
|
||||
for (const artist of pickedArtists) {
|
||||
bailIfCancelled();
|
||||
const songs = await pickSongsForArtist(artist, 3);
|
||||
allSeedSongs = uniqueAppend(allSeedSongs, songs);
|
||||
const firstPlayable = songs.find(s => !isBlockedByRating(s));
|
||||
if (firstPlayable) startImmediatePlayback(firstPlayable, `artist:${artist.name}`);
|
||||
logStep('pick_artist_songs', {
|
||||
artist,
|
||||
pickedCount: songs.length,
|
||||
songs: songDebug(songs),
|
||||
});
|
||||
}
|
||||
|
||||
const pickedAlbums = sampleRandom(albumsWithPlays, 2);
|
||||
logStep('pick_top_albums', {
|
||||
poolCount: albumsWithPlays.length,
|
||||
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));
|
||||
if (firstPlayable) startImmediatePlayback(firstPlayable, `album:${album.id}`);
|
||||
logStep('pick_album_songs', {
|
||||
albumId: album.id,
|
||||
pickedCount: songs.length,
|
||||
songs: songDebug(songs),
|
||||
});
|
||||
}
|
||||
|
||||
bailIfCancelled();
|
||||
const rated = await pickGoodRatedSongs(new Set(allSeedSongs.map(s => s.id)), 3);
|
||||
logStep('pick_rated_songs_4plus_only', {
|
||||
ratedPickedCount: rated.length,
|
||||
ratedSongs: songDebug(rated),
|
||||
});
|
||||
allSeedSongs = uniqueAppend(allSeedSongs, rated);
|
||||
let seeds = allSeedSongs.filter(s => !isBlockedByRating(s));
|
||||
logStep('seed_after_dedup', {
|
||||
seedCount: seeds.length,
|
||||
seeds: songDebug(seeds),
|
||||
});
|
||||
|
||||
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);
|
||||
const firstPlayable = allowedRnd[0];
|
||||
if (firstPlayable) startImmediatePlayback(firstPlayable, `seed-fill-batch:${i + 1}`);
|
||||
logStep('seed_fill_batch', {
|
||||
batch: i + 1,
|
||||
fetched: rnd.length,
|
||||
seedCount: seeds.length,
|
||||
});
|
||||
}
|
||||
seeds = seeds.slice(0, SEED_TARGET_SIZE);
|
||||
logStep('seed_fill_end', {
|
||||
finalSeedCount: seeds.length,
|
||||
seeds: songDebug(seeds),
|
||||
});
|
||||
}
|
||||
|
||||
if (seeds.length === 0) {
|
||||
throw new Error('no-seeds');
|
||||
}
|
||||
if (!startedPlayback) {
|
||||
const firstPlayableSeed = seeds.find(s => !isBlockedByRating(s));
|
||||
if (firstPlayableSeed) startImmediatePlayback(firstPlayableSeed, 'seed-fallback-first');
|
||||
}
|
||||
|
||||
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);
|
||||
similarRaw = uniqueAppend(similarRaw, oneRaw);
|
||||
similar = uniqueAppend(similar, oneScoped);
|
||||
appendSongsToQueue(oneScoped, `similar-seed-${i + 1}/${seeds.length}`);
|
||||
if (reachedTarget()) break;
|
||||
}
|
||||
const seedForPool = seeds.filter(() => Math.random() < 0.5);
|
||||
let pool = uniqueBySongId([...seedForPool, ...similar]);
|
||||
appendSongsToQueue(seedForPool, 'seed-50pct');
|
||||
logStep('instant_mix', {
|
||||
seedUsedForInstantMixCount: seeds.length,
|
||||
seedIncludedInPoolCount: seedForPool.length,
|
||||
seedIncludedInPool: songDebug(seedForPool),
|
||||
similarRawCount: similarRaw.length,
|
||||
similarScopedCount: similar.length,
|
||||
initialPoolCount: pool.length,
|
||||
});
|
||||
|
||||
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}`);
|
||||
logStep('pool_fill_batch', {
|
||||
batch: i + 1,
|
||||
fetched: rnd.length,
|
||||
poolCount: pool.length,
|
||||
});
|
||||
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', {
|
||||
poolCount: pool.length,
|
||||
queuedCount: queuedIds.size,
|
||||
queuedTarget: MIX_TARGET_SIZE,
|
||||
});
|
||||
if (queuedIds.size === 0) {
|
||||
throw new Error('empty-mix');
|
||||
}
|
||||
showToast(i18n.t('luckyMix.done', { count: queuedIds.size }), 3500, 'success');
|
||||
logStep('done', { queueCount: queuedIds.size });
|
||||
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(() => {});
|
||||
}
|
||||
} 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) {
|
||||
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(() => {});
|
||||
}
|
||||
// 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();
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
import { usePlayerStore } from '../store/playerStore';
|
||||
import { useWindowVisibility } from '../hooks/useWindowVisibility';
|
||||
|
||||
/** Remaining time until wall-clock `deadlineMs` (m:ss or h:mm:ss). */
|
||||
export function formatPlaybackScheduleRemaining(deadlineMs: number | null, nowMs: number): string {
|
||||
@@ -43,11 +44,15 @@ export function usePlaybackScheduleRemaining(): PlaybackScheduleInfo | null {
|
||||
: null;
|
||||
const deadlineMs = mode === 'pause' ? scheduledPauseAtMs : mode === 'start' ? scheduledResumeAtMs : null;
|
||||
const [nowMs, setNowMs] = useState(() => Date.now());
|
||||
const windowHidden = useWindowVisibility();
|
||||
useEffect(() => {
|
||||
if (deadlineMs == null) return;
|
||||
const id = window.setInterval(() => setNowMs(Date.now()), 500);
|
||||
if (deadlineMs == null || windowHidden) return;
|
||||
const id = window.setInterval(() => {
|
||||
if (document.hidden || window.__psyHidden) return;
|
||||
setNowMs(Date.now());
|
||||
}, 500);
|
||||
return () => window.clearInterval(id);
|
||||
}, [deadlineMs]);
|
||||
}, [deadlineMs, windowHidden]);
|
||||
if (mode == null || deadlineMs == null) return null;
|
||||
return { remaining: formatPlaybackScheduleRemaining(deadlineMs, nowMs), mode };
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ export function getLibraryItemsForReorder(
|
||||
): SidebarItemConfig[] {
|
||||
return items.filter(cfg => {
|
||||
if (!ALL_NAV_ITEMS[cfg.id] || ALL_NAV_ITEMS[cfg.id].section !== 'library') return false;
|
||||
if (randomNavMode === 'hub' && (cfg.id === 'randomMix' || cfg.id === 'randomAlbums')) return false;
|
||||
if (randomNavMode === 'hub' && (cfg.id === 'randomMix' || cfg.id === 'randomAlbums' || cfg.id === 'luckyMix')) return false;
|
||||
if (randomNavMode === 'separate' && cfg.id === 'randomPicker') return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user