From db79d1a71ed5926e6ff308f03382cc45e26fcffa Mon Sep 17 00:00:00 2001 From: jeffvli Date: Tue, 12 May 2026 02:12:38 -0700 Subject: [PATCH] add album mode for autodj - add selection modes: similar, random - add autodj settings in playerbar popover --- docs/ENV_SETTINGS.md | 3 + settings.js.template | 3 + src/i18n/locales/en.json | 17 +- .../features/player/auto-dj/auto-dj-albums.ts | 202 ++++++++++++++++++ .../features/player/auto-dj/auto-dj-songs.ts | 164 ++++++++++++++ .../features/player/auto-dj/auto-dj-utils.ts | 28 +++ .../player/components/right-controls.tsx | 171 +++++++++++++-- .../player/components/shuffle-all-modal.tsx | 123 +++++++++-- .../features/player/hooks/use-auto-dj.ts | 193 ++++++----------- .../components/playback/auto-dj-settings.tsx | 101 ++++++++- src/renderer/global.d.ts | 3 + src/renderer/store/env-settings-overrides.ts | 15 ++ src/renderer/store/settings.store.ts | 55 ++++- 13 files changed, 906 insertions(+), 172 deletions(-) create mode 100644 src/renderer/features/player/auto-dj/auto-dj-albums.ts create mode 100644 src/renderer/features/player/auto-dj/auto-dj-songs.ts create mode 100644 src/renderer/features/player/auto-dj/auto-dj-utils.ts diff --git a/docs/ENV_SETTINGS.md b/docs/ENV_SETTINGS.md index 9dafea15c..3417cc76b 100644 --- a/docs/ENV_SETTINGS.md +++ b/docs/ENV_SETTINGS.md @@ -114,8 +114,11 @@ These variables override app settings **on first run** when no persisted setting | Setting path | Default | Env variable | Available values / Description | |-------------|---------|--------------|--------------------------------| +| `autoDJ.albumStrategy` | `similar` | `FS_AUTO_DJ_ALBUM_STRATEGY` | `similar` / `library_random`. | | `autoDJ.enabled` | `false` | `FS_AUTO_DJ_ENABLED` | `true` / `false`. | | `autoDJ.itemCount` | `5` | `FS_AUTO_DJ_ITEM_COUNT` | Number of items to add. | +| `autoDJ.mode` | `songs` | `FS_AUTO_DJ_MODE` | `songs` / `albums`. | +| `autoDJ.songStrategy` | `similar` | `FS_AUTO_DJ_SONG_STRATEGY` | `similar` / `library_random`. | | `autoDJ.timing` | `1` | `FS_AUTO_DJ_TIMING` | Timing value (number). | --- diff --git a/settings.js.template b/settings.js.template index 675924e56..a451e6d37 100644 --- a/settings.js.template +++ b/settings.js.template @@ -88,8 +88,11 @@ window.FS_LYRICS_TRANSLATION_API_KEY = "${FS_LYRICS_TRANSLATION_API_KEY}"; window.FS_LYRICS_TRANSLATION_TARGET_LANGUAGE = "${FS_LYRICS_TRANSLATION_TARGET_LANGUAGE}"; window.FS_LYRICS_ALIGNMENT = "${FS_LYRICS_ALIGNMENT}"; +window.FS_AUTO_DJ_ALBUM_STRATEGY = "${FS_AUTO_DJ_ALBUM_STRATEGY}"; window.FS_AUTO_DJ_ENABLED = "${FS_AUTO_DJ_ENABLED}"; window.FS_AUTO_DJ_ITEM_COUNT = "${FS_AUTO_DJ_ITEM_COUNT}"; +window.FS_AUTO_DJ_MODE = "${FS_AUTO_DJ_MODE}"; +window.FS_AUTO_DJ_SONG_STRATEGY = "${FS_AUTO_DJ_SONG_STRATEGY}"; window.FS_AUTO_DJ_TIMING = "${FS_AUTO_DJ_TIMING}"; window.FS_CSS_CONTENT = "${FS_CSS_CONTENT}"; diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index b3427c95d..932c3d15b 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -415,6 +415,11 @@ }, "shuffleAll": { "title": "Play random", + "input_kind_albums": "Albums", + "input_kind_songs": "Songs", + "input_kind": "Random picks", + "input_limit_albums": "How many albums?", + "input_limit_songs": "How many songs?", "input_genre": "$t(entity.genre, {\"count\": 1})", "input_limit": "How many songs?", "input_minYear": "From year", @@ -731,11 +736,19 @@ }, "setting": { "autoDJ": "Auto DJ", - "autoDJ_description": "Automatically add similar songs to the queue", "autoDJ_itemCount": "Item count", - "autoDJ_itemCount_description": "The number of items attempted to be added to the queue when auto DJ is enabled", + "autoDJ_itemCount_description": "The number of items attempted to be added to the queue", "autoDJ_timing": "Timing", "autoDJ_timing_description": "The number of songs remaining in the queue before auto DJ is triggered", + "autoDJ_mode": "Mode", + "autoDJ_mode_albums": "Albums", + "autoDJ_mode_description": "Choose to add either songs or entire albums to the queue", + "autoDJ_mode_songs": "Songs", + "autoDJ_enabled": "Enable Auto DJ", + "autoDJ_albumStrategy": "Album selection mode", + "autoDJ_songStrategy": "Song selection mode", + "autoDJ_strategy_option_library_random": "Random", + "autoDJ_strategy_option_similar": "Similar", "autosave": "Automatically save play queue", "autosave_description": "Enable automatically saving the play queue to your server. This is only possible when using Navidrome/Subsonic, and you cannot have a mixed play queue.", "autosaveCount": "Automatic play queue save frequency", diff --git a/src/renderer/features/player/auto-dj/auto-dj-albums.ts b/src/renderer/features/player/auto-dj/auto-dj-albums.ts new file mode 100644 index 000000000..bfdf501cf --- /dev/null +++ b/src/renderer/features/player/auto-dj/auto-dj-albums.ts @@ -0,0 +1,202 @@ +import type { QueryClient } from '@tanstack/react-query'; + +import { autoDjGenreIdsForSongGenre, autoDjPushUniqueAlbumIds } from './auto-dj-utils'; + +import { queryKeys } from '/@/renderer/api/query-keys'; +import { albumQueries } from '/@/renderer/features/albums/api/album-api'; +import { songsQueries } from '/@/renderer/features/songs/api/songs-api'; +import { AUTO_DJ_STRATEGY, type AutoDJStrategy } from '/@/renderer/store/settings.store'; +import { shuffle } from '/@/renderer/utils/shuffle'; +import { + AlbumListSort, + type QueueSong, + type ServerListItem, + SortOrder, +} from '/@/shared/types/domain-types'; + +export type AutoDjAlbumCollectArgs = { + albumStrategy: AutoDJStrategy; + currentSong: QueueSong; + itemCount: number; + musicFolderId: string | string[] | undefined; + queryClient: QueryClient; + queueAlbumIdSet: Set; + server: null | ServerListItem | undefined; + serverId: string; + trySimilarSongs: boolean; +}; + +export const runAutoDjAlbumIds = async (args: AutoDjAlbumCollectArgs): Promise => { + switch (args.albumStrategy) { + case AUTO_DJ_STRATEGY.LIBRARY_RANDOM: { + return collectAlbumsLibraryRandom(args); + } + default: { + return collectAlbumsSimilar(args); + } + } +}; + +const collectAlbumsLibraryRandom = async (args: AutoDjAlbumCollectArgs): Promise => { + const page = await args.queryClient.fetchQuery({ + ...albumQueries.list({ + query: { + limit: Math.max(args.itemCount, 1), + musicFolderId: args.musicFolderId, + sortBy: AlbumListSort.RANDOM, + sortOrder: SortOrder.ASC, + startIndex: 0, + }, + serverId: args.serverId, + }), + queryKey: queryKeys.player.fetch({ autoDjAlbumLibraryRandom: args.currentSong?.id }), + }); + + const ids = page.items.map((a) => a.id).filter((id) => id && !args.queueAlbumIdSet.has(id)); + return shuffle(ids).slice(0, args.itemCount); +}; + +const collectAlbumsSimilar = async (args: AutoDjAlbumCollectArgs): Promise => { + const targetAlbumCount = args.itemCount; + const candidateAlbumIds: string[] = []; + const seenAlbumCandidates = new Set(); + + if (args.trySimilarSongs && args.currentSong?.id) { + const similarSongsFromSimilarApi = await args.queryClient.fetchQuery({ + ...songsQueries.similar({ + query: { + count: args.itemCount * 4, + songId: args.currentSong.id, + }, + serverId: args.serverId, + }), + queryKey: queryKeys.player.fetch({ + similarSongAlbumDj: args.currentSong.id, + }), + }); + + autoDjPushUniqueAlbumIds( + candidateAlbumIds, + seenAlbumCandidates, + args.queueAlbumIdSet, + ...similarSongsFromSimilarApi.map((s) => s.albumId), + ); + } + + if (candidateAlbumIds.length < targetAlbumCount && args.currentSong && args.server) { + const genre = args.currentSong.genres?.[0]; + if (genre) { + const genreIds = autoDjGenreIdsForSongGenre(genre, args.server.type); + + const genreAlbums = await args.queryClient.fetchQuery({ + ...albumQueries.list({ + query: { + genreIds, + limit: 50, + musicFolderId: args.musicFolderId, + sortBy: AlbumListSort.RANDOM, + sortOrder: SortOrder.ASC, + startIndex: 0, + }, + serverId: args.serverId, + }), + queryKey: queryKeys.player.fetch({ + genreAlbumDj: genreIds, + song: args.currentSong.id, + }), + }); + + autoDjPushUniqueAlbumIds( + candidateAlbumIds, + seenAlbumCandidates, + args.queueAlbumIdSet, + ...genreAlbums.items.map((album) => album.id), + ); + + if (!args.trySimilarSongs) { + const randomAlbumMixCount = Math.max(1, Math.ceil(50 * 0.2)); + const randomAlbumsMix = await args.queryClient.fetchQuery({ + ...albumQueries.list({ + query: { + limit: randomAlbumMixCount, + musicFolderId: args.musicFolderId, + sortBy: AlbumListSort.RANDOM, + sortOrder: SortOrder.ASC, + startIndex: 0, + }, + serverId: args.serverId, + }), + queryKey: queryKeys.player.fetch({ + genreAlbumDjMixRandom: args.currentSong.id, + }), + }); + + autoDjPushUniqueAlbumIds( + candidateAlbumIds, + seenAlbumCandidates, + args.queueAlbumIdSet, + ...randomAlbumsMix.items.map((album) => album.id), + ); + } + } + } + + if (candidateAlbumIds.length < targetAlbumCount && args.currentSong) { + const albumArtist = args.currentSong.albumArtists?.[0]; + + if (albumArtist) { + const albumsByArtist = await args.queryClient.fetchQuery({ + ...albumQueries.list({ + query: { + artistIds: [albumArtist.id], + limit: 50, + musicFolderId: args.musicFolderId, + sortBy: AlbumListSort.RANDOM, + sortOrder: SortOrder.ASC, + startIndex: 0, + }, + serverId: args.serverId, + }), + queryKey: queryKeys.player.fetch({ + artistAlbumDj: albumArtist.id, + song: args.currentSong.id, + }), + }); + + autoDjPushUniqueAlbumIds( + candidateAlbumIds, + seenAlbumCandidates, + args.queueAlbumIdSet, + ...albumsByArtist.items.map((album) => album.id), + ); + } + } + + if (candidateAlbumIds.length < targetAlbumCount && args.currentSong) { + const randomAlbumsFallback = await args.queryClient.fetchQuery({ + ...albumQueries.list({ + query: { + limit: 80, + musicFolderId: args.musicFolderId, + sortBy: AlbumListSort.RANDOM, + sortOrder: SortOrder.ASC, + startIndex: 0, + }, + serverId: args.serverId, + }), + queryKey: queryKeys.player.fetch({ + fallbackAlbumDj: args.currentSong.id, + }), + }); + + autoDjPushUniqueAlbumIds( + candidateAlbumIds, + seenAlbumCandidates, + args.queueAlbumIdSet, + ...randomAlbumsFallback.items.map((album) => album.id), + ); + } + + const shuffledAlbums = shuffle(candidateAlbumIds); + return shuffledAlbums.slice(0, targetAlbumCount); +}; diff --git a/src/renderer/features/player/auto-dj/auto-dj-songs.ts b/src/renderer/features/player/auto-dj/auto-dj-songs.ts new file mode 100644 index 000000000..fd9f1565a --- /dev/null +++ b/src/renderer/features/player/auto-dj/auto-dj-songs.ts @@ -0,0 +1,164 @@ +import type { QueryClient } from '@tanstack/react-query'; + +import { queryKeys } from '/@/renderer/api/query-keys'; +import { songsQueries } from '/@/renderer/features/songs/api/songs-api'; +import { AUTO_DJ_STRATEGY, type AutoDJStrategy } from '/@/renderer/store/settings.store'; +import { shuffleInPlace } from '/@/renderer/utils/shuffle'; +import { + Played, + type QueueSong, + type ServerListItem, + Song, + SongListSort, + SortOrder, +} from '/@/shared/types/domain-types'; + +export type AutoDjSongCollectArgs = { + currentSong: QueueSong; + itemCount: number; + musicFolderId: string | string[] | undefined; + queryClient: QueryClient; + queueSongIdSet: Set; + server: null | ServerListItem | undefined; + serverId: string; + songStrategy: AutoDJStrategy; + trySimilarSongs: boolean; +}; + +export const runAutoDjSongs = async (args: AutoDjSongCollectArgs): Promise => { + switch (args.songStrategy) { + case AUTO_DJ_STRATEGY.LIBRARY_RANDOM: { + return collectSongsLibraryRandom(args); + } + default: { + return collectSongsSimilar(args); + } + } +}; + +const collectSongsLibraryRandom = async (args: AutoDjSongCollectArgs): Promise => { + const randomSongs = await args.queryClient.fetchQuery({ + ...songsQueries.random({ + query: { + limit: Math.max(args.itemCount * 3, 50), + played: Played.All, + }, + serverId: args.serverId, + }), + queryKey: queryKeys.player.fetch({ autoDjLibraryRandomSongs: args.currentSong.id }), + }); + + const pool = randomSongs.items.filter((song) => !args.queueSongIdSet.has(song.id)); + const shuffled = shuffleInPlace(pool); + return shuffled.slice(0, args.itemCount); +}; + +const collectSongsSimilar = async (args: AutoDjSongCollectArgs): Promise => { + let uniqueSimilarSongs: Song[] = []; + + if (args.trySimilarSongs) { + const similarSongs = await args.queryClient.fetchQuery({ + ...songsQueries.similar({ + query: { + count: args.itemCount, + songId: args.currentSong?.id, + }, + serverId: args.serverId, + }), + queryKey: queryKeys.player.fetch({ similarSongs: args.currentSong?.id }), + }); + + uniqueSimilarSongs = similarSongs.filter((song) => !args.queueSongIdSet.has(song.id)); + } + + if (uniqueSimilarSongs.length < args.itemCount) { + const genre = args.currentSong?.genres?.[0]; + + if (genre) { + const genreLimit = 50; + const genreSimilarSongs = await args.queryClient.fetchQuery({ + ...songsQueries.random({ + query: { + genre: genre.id, + limit: genreLimit, + played: Played.All, + }, + serverId: args.serverId, + }), + queryKey: queryKeys.player.fetch({ + genre, + similarSongs: args.currentSong?.id, + }), + }); + + const genreSongs = genreSimilarSongs.items.filter( + (song) => !args.queueSongIdSet.has(song.id), + ); + + if (!args.trySimilarSongs) { + const randomSongCount = Math.max(1, Math.ceil(genreLimit * 0.2)); + + const randomSongs = await args.queryClient.fetchQuery({ + ...songsQueries.random({ + query: { limit: randomSongCount, played: Played.All }, + serverId: args.serverId, + }), + }); + + const uniqueRandomSongs = randomSongs.items.filter( + (song) => !args.queueSongIdSet.has(song.id), + ); + + const randomSongsToAdd = uniqueRandomSongs.slice(0, randomSongCount); + uniqueSimilarSongs.push(...randomSongsToAdd, ...genreSongs); + } else { + uniqueSimilarSongs.push(...genreSongs); + } + } + } + + if (uniqueSimilarSongs.length < args.itemCount) { + const albumArtist = args.currentSong?.albumArtists?.[0]; + + if (albumArtist) { + const albumArtistSimilarSongs = await args.queryClient.fetchQuery({ + ...songsQueries.list({ + query: { + albumArtistIds: [albumArtist.id], + limit: 50, + sortBy: SongListSort.RANDOM, + sortOrder: SortOrder.ASC, + startIndex: 0, + }, + serverId: args.serverId, + }), + queryKey: queryKeys.player.fetch({ + albumArtist, + similarSongs: args.currentSong?.id, + }), + }); + + uniqueSimilarSongs.push( + ...albumArtistSimilarSongs.items.filter( + (song) => !args.queueSongIdSet.has(song.id), + ), + ); + } + } + + if (uniqueSimilarSongs.length < args.itemCount) { + const randomSongs = await args.queryClient.fetchQuery({ + ...songsQueries.random({ + query: { limit: 50, played: Played.All }, + serverId: args.serverId, + }), + }); + + uniqueSimilarSongs.push( + ...randomSongs.items.filter((song) => !args.queueSongIdSet.has(song.id)), + ); + } + + const shuffledSongs = shuffleInPlace(uniqueSimilarSongs); + return shuffledSongs.slice(0, args.itemCount); +}; diff --git a/src/renderer/features/player/auto-dj/auto-dj-utils.ts b/src/renderer/features/player/auto-dj/auto-dj-utils.ts new file mode 100644 index 000000000..0213451d3 --- /dev/null +++ b/src/renderer/features/player/auto-dj/auto-dj-utils.ts @@ -0,0 +1,28 @@ +import type { Genre } from '/@/shared/types/domain-types'; + +import { ServerType } from '/@/shared/types/domain-types'; + +export const autoDjPushUniqueAlbumIds = ( + accumulator: string[], + seenAlbums: Set, + queueAlbumIdSet: Set, + ...ids: (string | undefined)[] +) => { + for (const id of ids) { + if (!id || queueAlbumIdSet.has(id) || seenAlbums.has(id)) continue; + seenAlbums.add(id); + accumulator.push(id); + } +}; + +export const autoDjGenreIdsForSongGenre = (genre: Genre, serverType: ServerType): string[] => { + if (serverType === ServerType.JELLYFIN) { + return [genre.id]; + } + + if (serverType === ServerType.NAVIDROME || serverType === ServerType.SUBSONIC) { + return [genre.name]; + } + + return [genre.id]; +}; diff --git a/src/renderer/features/player/components/right-controls.tsx b/src/renderer/features/player/components/right-controls.tsx index a07f9a5a9..555da28a2 100644 --- a/src/renderer/features/player/components/right-controls.tsx +++ b/src/renderer/features/player/components/right-controls.tsx @@ -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 ( - + + + + + e.stopPropagation()} p="sm"> + + + + + {t('setting.autoDJ_enabled')} + + + setSettings({ + autoDJ: { enabled: e.currentTarget.checked }, + }) + } + /> + + + + setSettings({ + autoDJ: { + mode: value as 'albums' | 'songs', + }, + }) + } + value={settings.mode} + w="100%" + /> + { /> )} - + ); }; @@ -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: {}, diff --git a/src/renderer/features/player/hooks/use-auto-dj.ts b/src/renderer/features/player/hooks/use-auto-dj.ts index 864142d52..ee2fd782c 100644 --- a/src/renderer/features/player/hooks/use-auto-dj.ts +++ b/src/renderer/features/player/hooks/use-auto-dj.ts @@ -1,11 +1,12 @@ import { useQueryClient } from '@tanstack/react-query'; import React, { useEffect } from 'react'; -import { queryKeys } from '/@/renderer/api/query-keys'; import { eventEmitter } from '/@/renderer/events/event-emitter'; +import { runAutoDjAlbumIds } from '/@/renderer/features/player/auto-dj/auto-dj-albums'; +import { runAutoDjSongs } from '/@/renderer/features/player/auto-dj/auto-dj-songs'; import { useIsPlayerFetching, usePlayer } from '/@/renderer/features/player/context/player-context'; -import { songsQueries } from '/@/renderer/features/songs/api/songs-api'; import { + AUTO_DJ_STRATEGY, isShuffleEnabled, mapShuffledToQueueIndex, useAutoDJSettings, @@ -17,9 +18,8 @@ import { } from '/@/renderer/store'; import { LogCategory, logFn } from '/@/renderer/utils/logger'; import { logMsg } from '/@/renderer/utils/logger-message'; -import { shuffleInPlace } from '/@/renderer/utils/shuffle'; import { hasFeature } from '/@/shared/api/utils'; -import { Played, Song, SongListSort, SortOrder } from '/@/shared/types/domain-types'; +import { LibraryItem } from '/@/shared/types/domain-types'; import { ServerFeature } from '/@/shared/types/features-types'; import { Play } from '/@/shared/types/types'; @@ -34,6 +34,9 @@ export const useAutoDJ = () => { const hasSimilarSongsMusicFolder = hasFeature(server, ServerFeature.SIMILAR_SONGS_MUSIC_FOLDER); useEffect(() => { + const albumStrategy = settings.albumStrategy ?? AUTO_DJ_STRATEGY.SIMILAR; + const songStrategy = settings.songStrategy ?? AUTO_DJ_STRATEGY.SIMILAR; + const unsubscribe = usePlayerStoreBase.subscribe( (state) => { const queue = state.getQueue(); @@ -54,7 +57,6 @@ export const useAutoDJ = () => { return; } - // If no current song, don't autoplay if (!properties.song?.id) { return; } @@ -70,142 +72,76 @@ export const useAutoDJ = () => { try { const queue = usePlayerStore.getState().getQueue(); - const queueSongIdSet = new Set(queue.items.map((item) => item.id)); - let uniqueSimilarSongs: Song[] = []; const hasMusicFolder = server?.musicFolderId && server.musicFolderId.length > 0; + const musicFolderId = + hasMusicFolder && server?.musicFolderId ? server.musicFolderId : undefined; const trySimilarSongs = !hasMusicFolder || (hasMusicFolder && hasSimilarSongsMusicFolder); - // Skip similar songs fetch if a music folder is selected and does not support musicFolderId on similar songs - if (trySimilarSongs) { - // First, try to fetch similar songs based on the current song - const similarSongs = await queryClient.fetchQuery({ - ...songsQueries.similar({ - query: { - count: settings.itemCount, - songId: properties.song?.id, - }, - serverId, - }), - queryKey: queryKeys.player.fetch({ similarSongs: properties.song?.id }), + const runnerDepsBase = { + itemCount: settings.itemCount, + musicFolderId, + queryClient, + server, + serverId, + trySimilarSongs, + }; + + if (settings.mode === 'albums') { + if (!serverId) { + return; + } + + const queueAlbumIdSet = new Set( + queue.items + .map((item) => item.albumId) + .filter((id): id is string => Boolean(id)), + ); + + const albumsToAdd = await runAutoDjAlbumIds({ + ...runnerDepsBase, + albumStrategy, + currentSong: properties.song, + queueAlbumIdSet, }); - uniqueSimilarSongs = similarSongs.filter( - (song) => !queueSongIdSet.has(song.id), - ); - } - - // If not enough songs, try to fetch more similar songs based on the genre of the current song - if (uniqueSimilarSongs.length < settings.itemCount) { - const genre = properties.song?.genres?.[0]; - - if (genre) { - const genreLimit = 50; - const genreSimilarSongs = await queryClient.fetchQuery({ - ...songsQueries.random({ - query: { - genre: genre.id, - limit: genreLimit, - played: Played.All, - }, - serverId, - }), - queryKey: queryKeys.player.fetch({ - genre, - similarSongs: properties.song?.id, - }), - }); - - const genreSongs = genreSimilarSongs.items.filter( - (song) => !queueSongIdSet.has(song.id), - ); - - // If trySimilarSongs is false, add variation by mixing in random songs - if (!trySimilarSongs) { - // Calculate how many random songs we need: 20% or at least 1 - const randomSongCount = Math.max(1, Math.ceil(genreLimit * 0.2)); - - const randomSongs = await queryClient.fetchQuery({ - ...songsQueries.random({ - query: { limit: randomSongCount, played: Played.All }, - serverId, - }), - }); - - const uniqueRandomSongs = randomSongs.items.filter( - (song) => !queueSongIdSet.has(song.id), - ); - - // Add minimum required random songs for variation - const randomSongsToAdd = uniqueRandomSongs.slice( - 0, - randomSongCount, - ); - uniqueSimilarSongs.push(...randomSongsToAdd, ...genreSongs); - } else { - uniqueSimilarSongs.push(...genreSongs); - } - } - } - - // If not enough songs, try to fetch more similar songs based on the album artist of the current song - if (uniqueSimilarSongs.length < settings.itemCount) { - const albumArtist = properties.song?.albumArtists?.[0]; - - if (albumArtist) { - const albumArtistSimilarSongs = await queryClient.fetchQuery({ - ...songsQueries.list({ - query: { - albumArtistIds: [albumArtist.id], - limit: 50, - sortBy: SongListSort.RANDOM, - sortOrder: SortOrder.ASC, - startIndex: 0, - }, - serverId, - }), - queryKey: queryKeys.player.fetch({ - albumArtist, - similarSongs: properties.song?.id, - }), - }); - - uniqueSimilarSongs.push( - ...albumArtistSimilarSongs.items.filter( - (song) => !queueSongIdSet.has(song.id), - ), - ); - } - } - - // If not enough songs, just fetch fully random songs - if (uniqueSimilarSongs.length < settings.itemCount) { - const randomSongs = await queryClient.fetchQuery({ - ...songsQueries.random({ - query: { limit: 50, played: Played.All }, + if (albumsToAdd.length > 0) { + await player.addToQueueByFetch( serverId, - }), - }); + albumsToAdd, + LibraryItem.ALBUM, + Play.LAST, + ); - uniqueSimilarSongs.push( - ...randomSongs.items.filter((song) => !queueSongIdSet.has(song.id)), - ); + eventEmitter.emit('AUTODJ_QUEUE_ADDED', { + songCount: albumsToAdd.length, + }); + } + + return; } - // Shuffle the songs and then add to the queue - const shuffledSongs = shuffleInPlace(uniqueSimilarSongs); + if (!serverId) { + return; + } - // Splice the first itemCount songs and add to the queue - const songsToAdd = shuffledSongs.slice(0, settings.itemCount); + const queueSongIdSet = new Set(queue.items.map((item) => item.id)); - // Add to the end of the queue - player.addToQueueByData(songsToAdd, Play.LAST); - - // Emit event to trigger queue follow - eventEmitter.emit('AUTODJ_QUEUE_ADDED', { - songCount: songsToAdd.length, + const songsToAdd = await runAutoDjSongs({ + ...runnerDepsBase, + currentSong: properties.song, + queueSongIdSet, + songStrategy, }); + + if (songsToAdd.length > 0) { + player.addToQueueByData(songsToAdd, Play.LAST); + + eventEmitter.emit('AUTODJ_QUEUE_ADDED', { + songCount: songsToAdd.length, + }); + } } catch (error) { logFn.error(logMsg[LogCategory.PLAYER].autoPlayFailed, { category: LogCategory.PLAYER, @@ -229,7 +165,10 @@ export const useAutoDJ = () => { server, serverId, settings.enabled, + settings.albumStrategy, settings.itemCount, + settings.mode, + settings.songStrategy, settings.timing, ]); }; diff --git a/src/renderer/features/settings/components/playback/auto-dj-settings.tsx b/src/renderer/features/settings/components/playback/auto-dj-settings.tsx index 5e2afe059..c126a0113 100644 --- a/src/renderer/features/settings/components/playback/auto-dj-settings.tsx +++ b/src/renderer/features/settings/components/playback/auto-dj-settings.tsx @@ -1,23 +1,112 @@ -import { memo } from 'react'; +import { memo, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; import { SettingOption, SettingsSection, } from '/@/renderer/features/settings/components/settings-section'; -import { useAutoDJSettings, useSettingsStoreActions } from '/@/renderer/store/settings.store'; +import { + AUTO_DJ_MODE, + AUTO_DJ_STRATEGY, + type AutoDJStrategy, + useAutoDJSettings, + useSettingsStoreActions, +} from '/@/renderer/store/settings.store'; 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'; export const AutoDJSettings = memo(() => { const { t } = useTranslation(); const settings = useAutoDJSettings(); const { setSettings } = useSettingsStoreActions(); + 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 autoDJOptions: SettingOption[] = [ + { + control: ( + { + setSettings({ + autoDJ: { + mode: value as 'albums' | 'songs', + }, + }); + }} + size="sm" + value={settings.mode} + w="100%" + /> + ), + description: t('setting.autoDJ_mode_description'), + title: t('setting.autoDJ_mode'), + }, + { + control: ( + + value && + setSettings({ + autoDJ: { + albumStrategy: value as AutoDJStrategy, + }, + }) + } + value={settings.albumStrategy ?? AUTO_DJ_STRATEGY.SIMILAR} + w="100%" + /> + ), + description: '', + title: t('setting.autoDJ_albumStrategy'), + }, { control: ( { value={Number(settings.itemCount)} /> ), - description: t('setting.autoDJ_itemCount', { - context: 'description', - }), - title: t('setting.autoDJ_itemCount'), + description: itemLabels.description, + title: itemLabels.title, }, { control: ( diff --git a/src/renderer/global.d.ts b/src/renderer/global.d.ts index 06fe06b16..1e69d8c75 100644 --- a/src/renderer/global.d.ts +++ b/src/renderer/global.d.ts @@ -1,8 +1,11 @@ declare global { interface Window { ANALYTICS_DISABLED?: boolean | string; + FS_AUTO_DJ_ALBUM_STRATEGY?: string; FS_AUTO_DJ_ENABLED?: string; FS_AUTO_DJ_ITEM_COUNT?: string; + FS_AUTO_DJ_MODE?: string; + FS_AUTO_DJ_SONG_STRATEGY?: string; FS_AUTO_DJ_TIMING?: string; FS_CSS_CONTENT?: string; FS_CSS_ENABLED?: string; diff --git a/src/renderer/store/env-settings-overrides.ts b/src/renderer/store/env-settings-overrides.ts index 358fd13bb..792e0b901 100644 --- a/src/renderer/store/env-settings-overrides.ts +++ b/src/renderer/store/env-settings-overrides.ts @@ -111,6 +111,8 @@ const SIDE_QUEUE_TYPES = new Set(['sideDrawerQueue', 'sideQueue']); const SIDE_QUEUE_LAYOUTS = new Set(['horizontal', 'vertical']); const SIDEBAR_PLAYLIST_FOLDER_VIEWS = new Set(['navigation', 'single', 'tree']); const SIDEBAR_PLAYLIST_MODES = new Set(['compact', 'expanded']); +const AUTO_DJ_MODES = new Set(['albums', 'songs']); +const AUTO_DJ_STRATEGIES = new Set(['library_random', 'similar']); export type EnvSettingsOverrides = DeepPartial< Pick @@ -422,8 +424,21 @@ const ENV_SETTING_SPECS: EnvSettingSpec[] = [ path: ['lyrics', 'alignment'], type: 'enum', }, + { + enumSet: AUTO_DJ_STRATEGIES, + key: 'FS_AUTO_DJ_ALBUM_STRATEGY', + path: ['autoDJ', 'albumStrategy'], + type: 'enum', + }, { key: 'FS_AUTO_DJ_ENABLED', path: ['autoDJ', 'enabled'], type: 'bool' }, { key: 'FS_AUTO_DJ_ITEM_COUNT', path: ['autoDJ', 'itemCount'], type: 'num' }, + { enumSet: AUTO_DJ_MODES, key: 'FS_AUTO_DJ_MODE', path: ['autoDJ', 'mode'], type: 'enum' }, + { + enumSet: AUTO_DJ_STRATEGIES, + key: 'FS_AUTO_DJ_SONG_STRATEGY', + path: ['autoDJ', 'songStrategy'], + type: 'enum', + }, { key: 'FS_AUTO_DJ_TIMING', path: ['autoDJ', 'timing'], type: 'num' }, { key: 'FS_CSS_CONTENT', diff --git a/src/renderer/store/settings.store.ts b/src/renderer/store/settings.store.ts index 45e0f03b4..5f2981417 100644 --- a/src/renderer/store/settings.store.ts +++ b/src/renderer/store/settings.store.ts @@ -675,9 +675,28 @@ const QueryBuilderSettingsSchema = z.object({ tag: z.array(QueryBuilderCustomFieldSchema), }); +export const AUTO_DJ_MODE = { + ALBUMS: 'albums', + SONGS: 'songs', +} as const; + +export type AutoDJMode = (typeof AUTO_DJ_MODE)[keyof typeof AUTO_DJ_MODE]; + +export const AUTO_DJ_STRATEGY = { + LIBRARY_RANDOM: 'library_random', + SIMILAR: 'similar', +} as const; + +export type AutoDJStrategy = (typeof AUTO_DJ_STRATEGY)[keyof typeof AUTO_DJ_STRATEGY]; + +const autoDjStrategyEnum = z.enum(['similar', 'library_random']); + const AutoDJSettingsSchema = z.object({ + albumStrategy: autoDjStrategyEnum, enabled: z.boolean(), itemCount: z.number(), + mode: z.enum(['songs', 'albums']), + songStrategy: autoDjStrategyEnum, timing: z.number(), }); @@ -1091,8 +1110,11 @@ const platformDefaultWindowBarStyle: Platform = getPlatformDefaultWindowBarStyle const initialState: SettingsState = { autoDJ: { + albumStrategy: AUTO_DJ_STRATEGY.SIMILAR, enabled: false, itemCount: 5, + mode: 'songs', + songStrategy: AUTO_DJ_STRATEGY.SIMILAR, timing: 1, }, css: { @@ -2427,10 +2449,41 @@ export const useSettingsStore = createWithEqualityFn()( } } + if (version < 28) { + if (!state.autoDJ) { + state.autoDJ = { ...initialState.autoDJ }; + } + + if (state.autoDJ.mode !== 'albums' && state.autoDJ.mode !== 'songs') { + state.autoDJ.mode = initialState.autoDJ.mode; + } + + const normalizeAutoDjStrategy = (stored: unknown) => { + if (stored === 'library_random') { + return AUTO_DJ_STRATEGY.LIBRARY_RANDOM; + } + + if ( + stored === 'similar' || + stored === 'default' || + stored === 'similar_forward' + ) { + return AUTO_DJ_STRATEGY.SIMILAR; + } + + return initialState.autoDJ.songStrategy; + }; + + state.autoDJ.songStrategy = normalizeAutoDjStrategy(state.autoDJ.songStrategy); + state.autoDJ.albumStrategy = normalizeAutoDjStrategy( + state.autoDJ.albumStrategy, + ); + } + return persistedState; }, name: 'store_settings', - version: 27, + version: 28, }, ), );