mirror of
https://github.com/jeffvli/feishin.git
synced 2026-05-15 13:00:25 +02:00
add player autodj (#7)
This commit is contained in:
@@ -22,8 +22,8 @@ interface PlayerEvents {
|
||||
|
||||
interface PlayerEventsCallbacks {
|
||||
onCurrentSongChange?: (
|
||||
properties: { index: number; song: QueueSong | undefined },
|
||||
prev: { index: number; song: QueueSong | undefined },
|
||||
properties: { index: number; remaining: number; song: QueueSong | undefined },
|
||||
prev: { index: number; remaining: number; song: QueueSong | undefined },
|
||||
) => void;
|
||||
onPlayerMute?: (properties: { muted: boolean }, prev: { muted: boolean }) => void;
|
||||
onPlayerProgress?: (properties: { timestamp: number }, prev: { timestamp: number }) => void;
|
||||
|
||||
@@ -7,6 +7,7 @@ import { useDiscordRpc } from '/@/renderer/features/discord-rpc/use-discord-rpc'
|
||||
import { useMainPlayerListener } from '/@/renderer/features/player/audio-player/hooks/use-main-player-listener';
|
||||
import { MpvPlayer } from '/@/renderer/features/player/audio-player/mpv-player';
|
||||
import { WebPlayer } from '/@/renderer/features/player/audio-player/web-player';
|
||||
import { useAutoDJ } from '/@/renderer/features/player/hooks/use-auto-dj';
|
||||
import { useMediaSession } from '/@/renderer/features/player/hooks/use-media-session';
|
||||
import { useMPRIS } from '/@/renderer/features/player/hooks/use-mpris';
|
||||
import { usePlaybackHotkeys } from '/@/renderer/features/player/hooks/use-playback-hotkeys';
|
||||
@@ -44,6 +45,7 @@ export const AudioPlayers = () => {
|
||||
useMainPlayerListener();
|
||||
useMediaSession();
|
||||
usePlaybackHotkeys();
|
||||
useAutoDJ();
|
||||
|
||||
useEffect(() => {
|
||||
if (webAudio && 'AudioContext' in window) {
|
||||
|
||||
@@ -11,6 +11,7 @@ import { useDeleteFavorite } from '/@/renderer/features/shared/mutations/delete-
|
||||
import { useSetRating } from '/@/renderer/features/shared/mutations/set-rating-mutation';
|
||||
import {
|
||||
useAppStoreActions,
|
||||
useAutoDJSettings,
|
||||
useCurrentServer,
|
||||
useGeneralSettings,
|
||||
useHotkeySettings,
|
||||
@@ -19,9 +20,11 @@ import {
|
||||
usePlayerSong,
|
||||
usePlayerVolume,
|
||||
useSettingsStore,
|
||||
useSettingsStoreActions,
|
||||
useSidebarRightExpanded,
|
||||
} from '/@/renderer/store';
|
||||
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 { Rating } from '/@/shared/components/rating/rating';
|
||||
@@ -58,6 +61,7 @@ export const RightControls = () => {
|
||||
<Flex align="flex-end" direction="column" h="100%" px="1rem" py="0.5rem">
|
||||
<Group h="calc(100% / 3)">
|
||||
<RatingButton />
|
||||
<AutoDJButton />
|
||||
</Group>
|
||||
<Group align="center" gap="xs" wrap="nowrap">
|
||||
<PlayerConfig />
|
||||
@@ -70,6 +74,33 @@ export const RightControls = () => {
|
||||
);
|
||||
};
|
||||
|
||||
const AutoDJButton = () => {
|
||||
const { t } = useTranslation();
|
||||
const settings = useAutoDJSettings();
|
||||
const { setSettings } = useSettingsStoreActions();
|
||||
|
||||
const toggleAutoDJ = () => {
|
||||
setSettings({
|
||||
autoDJ: {
|
||||
...settings,
|
||||
enabled: !settings.enabled,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Button
|
||||
onClick={toggleAutoDJ}
|
||||
size="compact-xs"
|
||||
style={{ color: settings.enabled ? 'var(--theme-colors-primary)' : undefined }}
|
||||
uppercase
|
||||
variant="transparent"
|
||||
>
|
||||
{t('setting.autoDJ')}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
const QueueButton = () => {
|
||||
const { t } = useTranslation();
|
||||
const isSidebarRightExpanded = useSidebarRightExpanded();
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import { queryKeys } from '/@/renderer/api/query-keys';
|
||||
import { useIsPlayerFetching, usePlayer } from '/@/renderer/features/player/context/player-context';
|
||||
import { songsQueries } from '/@/renderer/features/songs/api/songs-api';
|
||||
import {
|
||||
isShuffleEnabled,
|
||||
mapShuffledToQueueIndex,
|
||||
useAutoDJSettings,
|
||||
useCurrentServerId,
|
||||
usePlayerStore,
|
||||
usePlayerStoreBase,
|
||||
} from '/@/renderer/store';
|
||||
import { LogCategory, logFn } from '/@/renderer/utils/logger';
|
||||
import { logMsg } from '/@/renderer/utils/logger-message';
|
||||
import { shuffleInPlace } from '/@/renderer/utils/shuffle';
|
||||
import { Played, SongListSort, SortOrder } from '/@/shared/types/domain-types';
|
||||
import { Play } from '/@/shared/types/types';
|
||||
|
||||
export const useAutoDJ = () => {
|
||||
const queryClient = useQueryClient();
|
||||
const serverId = useCurrentServerId();
|
||||
const player = usePlayer();
|
||||
const settings = useAutoDJSettings();
|
||||
const isFetching = useIsPlayerFetching();
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribe = usePlayerStoreBase.subscribe(
|
||||
(state) => {
|
||||
const queue = state.getQueue();
|
||||
let index = state.player.index;
|
||||
let remaining: number;
|
||||
|
||||
if (isShuffleEnabled(state)) {
|
||||
remaining = state.queue.shuffled.length - index - 1;
|
||||
index = mapShuffledToQueueIndex(index, state.queue.shuffled);
|
||||
} else {
|
||||
remaining = queue.items.slice(index + 1).length;
|
||||
}
|
||||
|
||||
return { index, remaining, song: queue.items[index] };
|
||||
},
|
||||
async (properties) => {
|
||||
if (!settings.enabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
// If the player is fetching, don't autoplay
|
||||
if (isFetching) {
|
||||
return;
|
||||
}
|
||||
|
||||
// If no current song, don't autoplay
|
||||
if (!properties.song?.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (properties.remaining >= settings.timing) {
|
||||
return;
|
||||
}
|
||||
|
||||
logFn.debug(logMsg[LogCategory.PLAYER].autoPlayTriggered, {
|
||||
category: LogCategory.PLAYER,
|
||||
meta: { remaining: properties.remaining, songId: properties.song?.id },
|
||||
});
|
||||
|
||||
try {
|
||||
// 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 queue = usePlayerStore.getState().getQueue();
|
||||
|
||||
const queueSongIdSet = new Set(queue.items.map((item) => item.id));
|
||||
const 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 genreSimilarSongs = await queryClient.fetchQuery({
|
||||
...songsQueries.random({
|
||||
query: {
|
||||
genre: genre.id,
|
||||
limit: 50,
|
||||
played: Played.All,
|
||||
},
|
||||
serverId,
|
||||
}),
|
||||
queryKey: queryKeys.player.fetch({
|
||||
genre,
|
||||
similarSongs: properties.song?.id,
|
||||
}),
|
||||
});
|
||||
|
||||
uniqueSimilarSongs.push(
|
||||
...genreSimilarSongs.items.filter(
|
||||
(song) => !queueSongIdSet.has(song.id),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 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 },
|
||||
serverId,
|
||||
}),
|
||||
});
|
||||
|
||||
uniqueSimilarSongs.push(
|
||||
...randomSongs.items.filter((song) => !queueSongIdSet.has(song.id)),
|
||||
);
|
||||
}
|
||||
|
||||
// Shuffle the songs and then add to the queue
|
||||
const shuffledSongs = shuffleInPlace(uniqueSimilarSongs);
|
||||
|
||||
// Splice the first itemCount songs and add to the queue
|
||||
const songsToAdd = shuffledSongs.slice(0, settings.itemCount);
|
||||
|
||||
// Add to the end of the queue
|
||||
player.addToQueueByData(songsToAdd, Play.LAST);
|
||||
} catch (error) {
|
||||
logFn.error(logMsg[LogCategory.PLAYER].autoPlayFailed, {
|
||||
category: LogCategory.PLAYER,
|
||||
meta: { error: (error as Error).message, songId: properties.song?.id },
|
||||
});
|
||||
}
|
||||
},
|
||||
{
|
||||
equalityFn: (a, b) => {
|
||||
return a.song?._uniqueId === b.song?._uniqueId;
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
return () => unsubscribe();
|
||||
}, [
|
||||
isFetching,
|
||||
player,
|
||||
queryClient,
|
||||
serverId,
|
||||
settings.enabled,
|
||||
settings.itemCount,
|
||||
settings.timing,
|
||||
]);
|
||||
};
|
||||
@@ -0,0 +1,72 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import {
|
||||
SettingOption,
|
||||
SettingsSection,
|
||||
} from '/@/renderer/features/settings/components/settings-section';
|
||||
import { useAutoDJSettings, useSettingsStoreActions } from '/@/renderer/store/settings.store';
|
||||
import { NumberInput } from '/@/shared/components/number-input/number-input';
|
||||
|
||||
export const AutoDJSettings = () => {
|
||||
const { t } = useTranslation();
|
||||
const settings = useAutoDJSettings();
|
||||
const { setSettings } = useSettingsStoreActions();
|
||||
|
||||
const autoDJOptions: SettingOption[] = [
|
||||
{
|
||||
control: (
|
||||
<NumberInput
|
||||
aria-label="Auto DJ item count"
|
||||
hideControls={false}
|
||||
max={50}
|
||||
min={1}
|
||||
onChange={(e) => {
|
||||
setSettings({
|
||||
autoDJ: {
|
||||
...settings,
|
||||
itemCount: Number(e),
|
||||
},
|
||||
});
|
||||
}}
|
||||
value={Number(settings.itemCount)}
|
||||
/>
|
||||
),
|
||||
description: t('setting.autoDJ_itemCount', {
|
||||
context: 'description',
|
||||
postProcess: 'sentenceCase',
|
||||
}),
|
||||
title: t('setting.autoDJ_itemCount', { postProcess: 'sentenceCase' }),
|
||||
},
|
||||
{
|
||||
control: (
|
||||
<NumberInput
|
||||
aria-label="Auto DJ timing"
|
||||
hideControls={false}
|
||||
max={5}
|
||||
min={1}
|
||||
onChange={(e) => {
|
||||
setSettings({
|
||||
autoDJ: {
|
||||
...settings,
|
||||
timing: Number(e),
|
||||
},
|
||||
});
|
||||
}}
|
||||
value={Number(settings.timing)}
|
||||
/>
|
||||
),
|
||||
description: t('setting.autoDJ_timing', {
|
||||
context: 'description',
|
||||
postProcess: 'sentenceCase',
|
||||
}),
|
||||
title: t('setting.autoDJ_timing', { postProcess: 'sentenceCase' }),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<SettingsSection
|
||||
options={autoDJOptions}
|
||||
title={t('setting.autoDJ', { postProcess: 'upperCase' })}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -2,6 +2,7 @@ import isElectron from 'is-electron';
|
||||
import { lazy, Suspense, useMemo } from 'react';
|
||||
|
||||
import { AudioSettings } from '/@/renderer/features/settings/components/playback/audio-settings';
|
||||
import { AutoDJSettings } from '/@/renderer/features/settings/components/playback/auto-dj-settings';
|
||||
import { PlayerFilterSettings } from '/@/renderer/features/settings/components/playback/player-filter-settings';
|
||||
import { TranscodeSettings } from '/@/renderer/features/settings/components/playback/transcode-settings';
|
||||
import { useSettingsStore } from '/@/renderer/store';
|
||||
@@ -34,6 +35,8 @@ export const PlaybackTab = () => {
|
||||
<TranscodeSettings />
|
||||
<Divider />
|
||||
<PlayerFilterSettings />
|
||||
<Divider />
|
||||
<AutoDJSettings />
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -4,7 +4,12 @@ import { api } from '/@/renderer/api';
|
||||
import { controller } from '/@/renderer/api/controller';
|
||||
import { queryKeys } from '/@/renderer/api/query-keys';
|
||||
import { QueryHookArgs } from '/@/renderer/lib/react-query';
|
||||
import { ListCountQuery, SimilarSongsQuery, SongListQuery } from '/@/shared/types/domain-types';
|
||||
import {
|
||||
ListCountQuery,
|
||||
RandomSongListQuery,
|
||||
SimilarSongsQuery,
|
||||
SongListQuery,
|
||||
} from '/@/shared/types/domain-types';
|
||||
|
||||
export const songsQueries = {
|
||||
list: (args: QueryHookArgs<SongListQuery>, imageSize?: number) => {
|
||||
@@ -36,13 +41,24 @@ export const songsQueries = {
|
||||
...args.options,
|
||||
});
|
||||
},
|
||||
random: (args: QueryHookArgs<RandomSongListQuery>) => {
|
||||
return queryOptions({
|
||||
queryFn: ({ signal }) => {
|
||||
return api.controller.getRandomSongList({
|
||||
apiClientProps: { serverId: args.serverId, signal },
|
||||
query: args.query,
|
||||
});
|
||||
},
|
||||
queryKey: queryKeys.songs.randomSongList(args.serverId, args.query),
|
||||
...args.options,
|
||||
});
|
||||
},
|
||||
similar: (args: QueryHookArgs<SimilarSongsQuery>) => {
|
||||
return queryOptions({
|
||||
queryFn: ({ signal }) => {
|
||||
return api.controller.getSimilarSongs({
|
||||
apiClientProps: { serverId: args.serverId, signal },
|
||||
query: {
|
||||
albumArtistIds: args.query.albumArtistIds,
|
||||
count: args.query.count ?? 50,
|
||||
songId: args.query.songId,
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user