add album mode for autodj

- add selection modes: similar, random
- add autodj settings in playerbar popover
This commit is contained in:
jeffvli
2026-05-12 02:12:38 -07:00
parent 22d37135ae
commit db79d1a71e
13 changed files with 906 additions and 172 deletions
@@ -1,5 +1,5 @@
import { t } from 'i18next';
import { useCallback, useEffect, useState, WheelEvent } from 'react';
import { useCallback, useEffect, useMemo, useState, WheelEvent } from 'react';
import { useTranslation } from 'react-i18next';
import { PopoverPlayQueue } from '/@/renderer/features/now-playing/components/popover-play-queue';
@@ -12,6 +12,9 @@ import { useCreateFavorite } from '/@/renderer/features/shared/mutations/create-
import { useDeleteFavorite } from '/@/renderer/features/shared/mutations/delete-favorite-mutation';
import { useHotkeys } from '/@/renderer/hooks/use-hotkeys';
import {
AUTO_DJ_MODE,
AUTO_DJ_STRATEGY,
type AutoDJStrategy,
useAppStoreActions,
useAutoDJSettings,
useCurrentServer,
@@ -34,7 +37,15 @@ import { ActionIcon } from '/@/shared/components/action-icon/action-icon';
import { Button } from '/@/shared/components/button/button';
import { Flex } from '/@/shared/components/flex/flex';
import { Group } from '/@/shared/components/group/group';
import { NumberInput } from '/@/shared/components/number-input/number-input';
import { Paper } from '/@/shared/components/paper/paper';
import { Popover } from '/@/shared/components/popover/popover';
import { Rating } from '/@/shared/components/rating/rating';
import { SegmentedControl } from '/@/shared/components/segmented-control/segmented-control';
import { Select } from '/@/shared/components/select/select';
import { Stack } from '/@/shared/components/stack/stack';
import { Switch } from '/@/shared/components/switch/switch';
import { Text } from '/@/shared/components/text/text';
import { useMediaQuery } from '/@/shared/hooks/use-media-query';
import { useThrottledCallback } from '/@/shared/hooks/use-throttled-callback';
import { useThrottledValue } from '/@/shared/hooks/use-throttled-value';
@@ -90,28 +101,148 @@ const AutoDJButton = () => {
const settings = useAutoDJSettings();
const { setSettings } = useSettingsStoreActions();
const toggleAutoDJ = () => {
setSettings({
autoDJ: {
...settings,
enabled: !settings.enabled,
const itemLabels = useMemo(() => {
return {
description: t('setting.autoDJ_itemCount_description'),
title: t('setting.autoDJ_itemCount'),
};
}, [t]);
const strategySelectData = useMemo(
() => [
{
label: t('setting.autoDJ_strategy_option_similar'),
value: AUTO_DJ_STRATEGY.SIMILAR,
},
});
};
{
label: t('setting.autoDJ_strategy_option_library_random'),
value: AUTO_DJ_STRATEGY.LIBRARY_RANDOM,
},
],
[t],
);
const strategyLabels =
settings.mode === AUTO_DJ_MODE.ALBUMS
? {
description: '',
title: t('setting.autoDJ_albumStrategy'),
}
: {
description: '',
title: t('setting.autoDJ_songStrategy'),
};
const strategyValue =
settings.mode === AUTO_DJ_MODE.ALBUMS
? (settings.albumStrategy ?? AUTO_DJ_STRATEGY.SIMILAR)
: (settings.songStrategy ?? AUTO_DJ_STRATEGY.SIMILAR);
return (
<Button
onClick={(e) => {
e.stopPropagation();
toggleAutoDJ();
}}
size="compact-xs"
style={{ color: settings.enabled ? 'var(--theme-colors-primary)' : undefined }}
uppercase
variant="transparent"
>
{t('setting.autoDJ')}
</Button>
<Popover position="top-end" withArrow>
<Popover.Target>
<Button
onClick={(e) => {
e.stopPropagation();
}}
size="compact-xs"
style={{ color: settings.enabled ? 'var(--theme-colors-primary)' : undefined }}
uppercase
variant="transparent"
>
{t('setting.autoDJ')}
</Button>
</Popover.Target>
<Popover.Dropdown maw={320} miw={260} onClick={(e) => e.stopPropagation()} p="sm">
<Stack gap="sm">
<Paper p="md" radius="md">
<Group align="center" gap="xs" justify="space-between" wrap="nowrap">
<Text fw={600} isNoSelect size="sm">
{t('setting.autoDJ_enabled')}
</Text>
<Switch
checked={settings.enabled}
onChange={(e) =>
setSettings({
autoDJ: { enabled: e.currentTarget.checked },
})
}
/>
</Group>
</Paper>
<SegmentedControl
data={[
{ label: t('setting.autoDJ_mode_songs'), value: AUTO_DJ_MODE.SONGS },
{
label: t('setting.autoDJ_mode_albums'),
value: AUTO_DJ_MODE.ALBUMS,
},
]}
onChange={(value) =>
setSettings({
autoDJ: {
mode: value as 'albums' | 'songs',
},
})
}
value={settings.mode}
w="100%"
/>
<Select
comboboxProps={{ withinPortal: false }}
data={strategySelectData}
description={strategyLabels.description}
label={strategyLabels.title}
onChange={(value) => {
if (!value) return;
setSettings({
autoDJ:
settings.mode === AUTO_DJ_MODE.ALBUMS
? { albumStrategy: value as AutoDJStrategy }
: { songStrategy: value as AutoDJStrategy },
});
}}
size="md"
value={strategyValue}
w="100%"
/>
<NumberInput
aria-label={itemLabels.title}
description={itemLabels.description}
hideControls={false}
label={itemLabels.title}
max={50}
min={1}
onChange={(e) =>
setSettings({
autoDJ: {
itemCount: Number(e),
},
})
}
size="md"
value={Number(settings.itemCount)}
/>
<NumberInput
aria-label={t('setting.autoDJ_timing')}
description={t('setting.autoDJ_timing_description')}
hideControls={false}
label={t('setting.autoDJ_timing')}
max={5}
min={1}
onChange={(e) =>
setSettings({
autoDJ: {
timing: Number(e),
},
})
}
size="md"
value={Number(settings.timing)}
/>
</Stack>
</Popover.Dropdown>
</Popover>
);
};
@@ -10,6 +10,7 @@ import { createWithEqualityFn } from 'zustand/traditional';
import i18n from '/@/i18n/i18n';
import { api } from '/@/renderer/api';
import { queryKeys } from '/@/renderer/api/query-keys';
import { albumQueries } from '/@/renderer/features/albums/api/album-api';
import { useGenreList } from '/@/renderer/features/genres/api/genres-api';
import { usePlayer } from '/@/renderer/features/player/context/player-context';
import { PlayButtonGroup } from '/@/renderer/features/shared/components/play-button-group';
@@ -18,9 +19,18 @@ import { Checkbox } from '/@/shared/components/checkbox/checkbox';
import { Divider } from '/@/shared/components/divider/divider';
import { Group } from '/@/shared/components/group/group';
import { NumberInput } from '/@/shared/components/number-input/number-input';
import { SegmentedControl } from '/@/shared/components/segmented-control/segmented-control';
import { Select } from '/@/shared/components/select/select';
import { Stack } from '/@/shared/components/stack/stack';
import { Played, RandomSongListQuery, ServerType } from '/@/shared/types/domain-types';
import {
AlbumListQuery,
AlbumListSort,
LibraryItem,
Played,
RandomSongListQuery,
ServerType,
SortOrder,
} from '/@/shared/types/domain-types';
import { Play } from '/@/shared/types/types';
interface ShuffleAllSlice extends RandomSongListQuery {
@@ -29,6 +39,7 @@ interface ShuffleAllSlice extends RandomSongListQuery {
};
enableMaxYear: boolean;
enableMinYear: boolean;
playbackKind: 'albums' | 'songs';
}
const useShuffleAllStore = createWithEqualityFn<ShuffleAllSlice>()(
@@ -42,16 +53,28 @@ const useShuffleAllStore = createWithEqualityFn<ShuffleAllSlice>()(
enableMaxYear: false,
enableMinYear: false,
genre: '',
limit: 100,
maxYear: 2020,
minYear: 2000,
musicFolder: '',
playbackKind: 'songs',
played: Played.All,
songCount: 100,
})),
{
merge: (persistedState, currentState) => merge(currentState, persistedState),
migrate: (persisted, version: number) => {
if (!persisted) {
return persisted;
}
if (version >= 2) {
return persisted;
}
return persisted;
},
name: 'store_shuffle_all',
version: 1,
version: 2,
},
),
);
@@ -66,13 +89,24 @@ export const useShuffleAllStoreActions = () => useShuffleAllStore((state) => sta
export const ShuffleAllContextModal = () => {
const server = useCurrentServer();
const { addToQueueByData } = usePlayer();
const { addToQueueByData, addToQueueByFetch } = usePlayer();
const { t } = useTranslation();
const { enableMaxYear, enableMinYear, genre, limit, maxYear, minYear, musicFolderId, played } =
useShuffleAllStore();
const {
enableMaxYear,
enableMinYear,
genre,
limit,
maxYear,
minYear,
musicFolderId,
playbackKind,
played,
} = useShuffleAllStore();
const { setStore } = useShuffleAllStoreActions();
const { isFetching, refetch } = useQuery({
const clampedLimit = Math.min(500, Math.max(1, limit || 100));
const { isFetching: isFetchingSongs, refetch: refetchSongs } = useQuery({
...randomFetchQuery({
query: {
genre: genre || undefined,
@@ -89,22 +123,75 @@ export const ShuffleAllContextModal = () => {
staleTime: 0,
});
const { isFetching: isFetchingAlbums, refetch: refetchAlbums } = useQuery({
...shuffleAlbumListQuery({
query: {
genreIds: genre ? [genre] : undefined,
limit: clampedLimit,
minYear: enableMinYear ? minYear || undefined : undefined,
musicFolderId: musicFolderId || undefined,
sortBy: AlbumListSort.RANDOM,
sortOrder: SortOrder.ASC,
startIndex: 0,
},
serverId: server.id,
}),
enabled: false,
gcTime: 0,
staleTime: 0,
});
const fetchTypeRef = useRef<Play>(null);
const handlePlay = async (playType: Play) => {
fetchTypeRef.current = playType;
const { data } = await refetch();
if (playbackKind === 'albums') {
const { data } = await refetchAlbums();
addToQueueByData(data?.items || [], playType);
addToQueueByFetch(
server.id,
data?.items.map((a) => a.id) ?? [],
LibraryItem.ALBUM,
playType,
);
} else {
const { data } = await refetchSongs();
addToQueueByData(data?.items || [], playType);
}
closeAllModals();
};
return (
<Stack gap="md">
<SegmentedControl
data={[
{
label: t('form.shuffleAll.input_kind_songs'),
value: 'songs',
},
{
label: t('form.shuffleAll.input_kind_albums'),
value: 'albums',
},
]}
onChange={(value) =>
setStore({
playbackKind: value as 'albums' | 'songs',
})
}
size="sm"
value={playbackKind}
w="100%"
/>
<NumberInput
label={t('form.shuffleAll.input_limit')}
label={
playbackKind === 'albums'
? t('form.shuffleAll.input_limit_albums')
: t('form.shuffleAll.input_limit_songs')
}
max={500}
min={1}
onChange={(e) => setStore({ limit: e ? Number(e) : 500 })}
@@ -127,6 +214,7 @@ export const ShuffleAllContextModal = () => {
value={minYear}
/>
<NumberInput
disabled={playbackKind === 'albums'}
label={t('form.shuffleAll.input_maxYear')}
max={2050}
min={1850}
@@ -134,6 +222,7 @@ export const ShuffleAllContextModal = () => {
rightSection={
<Checkbox
checked={enableMaxYear}
disabled={playbackKind === 'albums'}
onChange={(e) => setStore({ enableMaxYear: e.currentTarget.checked })}
style={{ marginRight: '0.5rem' }}
/>
@@ -144,7 +233,7 @@ export const ShuffleAllContextModal = () => {
<Suspense fallback={<Select data={[]} />}>
<GenreSelect />
</Suspense>
{server?.type === ServerType.JELLYFIN && (
{server?.type === ServerType.JELLYFIN && playbackKind === 'songs' && (
<Select
clearable
data={PLAYED_DATA}
@@ -156,10 +245,7 @@ export const ShuffleAllContextModal = () => {
/>
)}
<Divider />
<PlayButtonGroup
loading={(isFetching && fetchTypeRef.current) || false}
onPlay={handlePlay}
/>
<PlayButtonGroup loading={isFetchingSongs || isFetchingAlbums} onPlay={handlePlay} />
</Stack>
);
};
@@ -186,6 +272,13 @@ const randomFetchQuery = (args: {
});
};
const shuffleAlbumListQuery = (args: { query: AlbumListQuery; serverId: string }) => {
return albumQueries.list({
query: args.query,
serverId: args.serverId,
});
};
export const openShuffleAllModal = async () => {
openContextModal({
innerProps: {},