refactor shuffle all modal for styles and loading state

This commit is contained in:
jeffvli
2025-11-26 14:58:10 -08:00
parent 6a8e55ce15
commit ffa9d165f2
8 changed files with 229 additions and 181 deletions
+11
View File
@@ -311,6 +311,17 @@
"expireInvalid": "expiration must be in the future",
"createFailed": "failed to create share (is sharing enabled?)"
},
"shuffleAll": {
"title": "play random",
"input_genre": "$t(entity.genre_one)",
"input_limit": "how many songs?",
"input_minYear": "from year",
"input_maxYear": "to year",
"input_played": "play filter",
"input_played_optionAll": "all tracks",
"input_played_optionUnplayed": "only unplayed tracks",
"input_played_optionPlayed": "only played tracks"
},
"updateServer": {
"success": "server updated successfully",
"title": "update server"
@@ -1,9 +1,8 @@
import { useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import styles from './center-controls.module.css';
import { PlayButton, PlayerButton } from '/@/renderer/features/player/components/player-button';
import { MainPlayButton, PlayerButton } from '/@/renderer/features/player/components/player-button';
import { PlayerbarSlider } from '/@/renderer/features/player/components/playerbar-slider';
import { openShuffleAllModal } from '/@/renderer/features/player/components/shuffle-all-modal';
import { usePlayer } from '/@/renderer/features/player/context/player-context';
@@ -135,7 +134,7 @@ const CenterPlayButton = () => {
const { mediaTogglePlayPause } = usePlayer();
return (
<PlayButton
<MainPlayButton
disabled={currentSong?.id === undefined}
isPaused={status === PlayerStatus.PAUSED}
onClick={mediaTogglePlayPause}
@@ -229,19 +228,14 @@ const RepeatButton = () => {
const ShuffleAllButton = () => {
const { t } = useTranslation();
const queryClient = useQueryClient();
const buttonSize = useSettingsStore((state) => state.general.buttonSize);
return (
<PlayerButton
icon={<Icon fill="default" icon="mediaRandom" size={buttonSize} />}
onClick={() =>
openShuffleAllModal({
queryClient,
})
}
onClick={() => openShuffleAllModal()}
tooltip={{
label: t('player.playRandom', { postProcess: 'sentenceCase' }),
label: t('form.shuffleAll.title', { postProcess: 'sentenceCase' }),
openDelay: 0,
}}
variant="tertiary"
@@ -3,7 +3,7 @@ import { useTranslation } from 'react-i18next';
import styles from './mobile-fullscreen-player.module.css';
import { PlayButton, PlayerButton } from '/@/renderer/features/player/components/player-button';
import { MainPlayButton, PlayerButton } from '/@/renderer/features/player/components/player-button';
import { usePlayer } from '/@/renderer/features/player/context/player-context';
import { usePlayerStatus } from '/@/renderer/store';
import { Icon } from '/@/shared/components/icon/icon';
@@ -50,7 +50,7 @@ export const MobileFullscreenPlayerControls = memo(
}}
variant="tertiary"
/>
<PlayButton
<MainPlayButton
disabled={currentSongId === undefined}
isPaused={status === PlayerStatus.PAUSED}
onClick={mediaTogglePlayPause}
@@ -7,7 +7,7 @@ import { generatePath, Link } from 'react-router';
import styles from './mobile-playerbar.module.css';
import { ContextMenuController } from '/@/renderer/features/context-menu/context-menu-controller';
import { PlayButton, PlayerButton } from '/@/renderer/features/player/components/player-button';
import { MainPlayButton, PlayerButton } from '/@/renderer/features/player/components/player-button';
import { usePlayer } from '/@/renderer/features/player/context/player-context';
import { AppRoute } from '/@/renderer/router/routes';
import {
@@ -35,7 +35,7 @@ export const MobilePlayerbar = () => {
const { setStore } = useFullScreenPlayerStoreActions();
const currentSong = usePlayerSong();
const status = usePlayerStatus();
const { mediaTogglePlayPause, mediaNext, mediaPrevious } = usePlayer();
const { mediaNext, mediaPrevious, mediaTogglePlayPause } = usePlayer();
const title = currentSong?.name;
const artists = currentSong?.artists;
const isSongDefined = Boolean(currentSong?.id);
@@ -203,7 +203,7 @@ export const MobilePlayerbar = () => {
}}
variant="tertiary"
/>
<PlayButton
<MainPlayButton
disabled={currentSong?.id === undefined}
isPaused={status === PlayerStatus.PAUSED}
onClick={(e) => {
@@ -61,7 +61,7 @@ interface PlayButtonProps extends Omit<ActionIconProps, 'icon' | 'variant'> {
isPaused?: boolean;
}
export const PlayButton = forwardRef<HTMLButtonElement, PlayButtonProps>(
export const MainPlayButton = forwardRef<HTMLButtonElement, PlayButtonProps>(
({ isPaused, onClick, ...props }: PlayButtonProps, ref) => {
const playerStateClass = isPaused
? PlaybackSelectors.playerStatePaused
@@ -1,7 +1,7 @@
import { closeAllModals, openModal } from '@mantine/modals';
import { QueryClient } from '@tanstack/react-query';
import { closeAllModals, openContextModal } from '@mantine/modals';
import { queryOptions, useQuery } from '@tanstack/react-query';
import merge from 'lodash/merge';
import { useMemo } from 'react';
import { Suspense, useMemo, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { persist } from 'zustand/middleware';
import { immer } from 'zustand/middleware/immer';
@@ -10,25 +10,17 @@ import { createWithEqualityFn } from 'zustand/traditional';
import i18n from '/@/i18n/i18n';
import { api } from '/@/renderer/api';
import { queryKeys } from '/@/renderer/api/query-keys';
import { useGenreList } from '/@/renderer/features/genres/api/genres-api';
import { usePlayer } from '/@/renderer/features/player/context/player-context';
import { useAuthStore } from '/@/renderer/store';
import { Button } from '/@/shared/components/button/button';
import { PlayButton } from '/@/renderer/features/shared/components/play-button';
import { useCurrentServer } from '/@/renderer/store';
import { Checkbox } from '/@/shared/components/checkbox/checkbox';
import { Divider } from '/@/shared/components/divider/divider';
import { Group } from '/@/shared/components/group/group';
import { Icon } from '/@/shared/components/icon/icon';
import { NumberInput } from '/@/shared/components/number-input/number-input';
import { Select } from '/@/shared/components/select/select';
import { Stack } from '/@/shared/components/stack/stack';
import {
GenreListResponse,
GenreListSort,
Played,
RandomSongListQuery,
ServerListItem,
ServerType,
SortOrder,
} from '/@/shared/types/domain-types';
import { Played, RandomSongListQuery, ServerType } from '/@/shared/types/domain-types';
import { Play } from '/@/shared/types/types';
interface ShuffleAllSlice extends RandomSongListQuery {
@@ -72,46 +64,204 @@ const PLAYED_DATA: { label: string; value: Played }[] = [
export const useShuffleAllStoreActions = () => useShuffleAllStore((state) => state.actions);
interface ShuffleAllModalProps {
genres: GenreListResponse | undefined;
queryClient: QueryClient;
server: null | ServerListItem;
}
export const ShuffleAllModal = ({ genres, queryClient, server }: ShuffleAllModalProps) => {
export const ShuffleAllContextModal = () => {
const server = useCurrentServer();
const { addToQueueByData } = usePlayer();
const { t } = useTranslation();
const { enableMaxYear, enableMinYear, genre, limit, maxYear, minYear, musicFolderId, played } =
useShuffleAllStore();
const { setStore } = useShuffleAllStoreActions();
const handlePlay = async (playType: Play) => {
const res = await queryClient.fetchQuery({
gcTime: 0,
queryFn: ({ signal }) =>
api.controller.getRandomSongList({
apiClientProps: {
serverId: server?.id || '',
signal,
},
query: {
genre: genre || undefined,
limit,
maxYear: enableMaxYear ? maxYear || undefined : undefined,
minYear: enableMinYear ? minYear || undefined : undefined,
musicFolderId: musicFolderId || undefined,
played,
},
}),
queryKey: queryKeys.songs.randomSongList(server?.id),
staleTime: 0,
});
const { isFetching, refetch } = useQuery({
...randomFetchQuery({
query: {
genre: genre || undefined,
limit: limit || 100,
maxYear: enableMaxYear ? maxYear || undefined : undefined,
minYear: enableMinYear ? minYear || undefined : undefined,
musicFolderId: musicFolderId || undefined,
played,
},
serverId: server.id,
}),
enabled: false,
gcTime: 0,
staleTime: 0,
});
addToQueueByData(res?.items || [], playType);
const fetchTypeRef = useRef<Play>(null);
const handlePlay = async (playType: Play) => {
fetchTypeRef.current = playType;
const { data } = await refetch();
addToQueueByData(data?.items || [], playType);
closeAllModals();
};
const isLoadingNext =
isFetching &&
(fetchTypeRef.current === Play.NEXT || fetchTypeRef.current === Play.NEXT_SHUFFLE);
const isLoadingLast =
isFetching &&
(fetchTypeRef.current === Play.LAST || fetchTypeRef.current === Play.LAST_SHUFFLE);
const isLoadingNow = isFetching && fetchTypeRef.current === Play.NOW;
return (
<Stack gap="md">
<NumberInput
label={t('form.shuffleAll.input_limit', { postProcess: 'sentenceCase' })}
max={500}
min={1}
onChange={(e) => setStore({ limit: e ? Number(e) : 500 })}
required
value={limit}
/>
<Group grow>
<NumberInput
label={t('form.shuffleAll.input_minYear', { postProcess: 'sentenceCase' })}
max={2050}
min={1850}
onChange={(e) => setStore({ minYear: e ? Number(e) : 0 })}
rightSection={
<Checkbox
checked={enableMinYear}
onChange={(e) => setStore({ enableMinYear: e.currentTarget.checked })}
style={{ marginRight: '0.5rem' }}
/>
}
value={minYear}
/>
<NumberInput
label={t('form.shuffleAll.input_maxYear', { postProcess: 'sentenceCase' })}
max={2050}
min={1850}
onChange={(e) => setStore({ maxYear: e ? Number(e) : 0 })}
rightSection={
<Checkbox
checked={enableMaxYear}
onChange={(e) => setStore({ enableMaxYear: e.currentTarget.checked })}
style={{ marginRight: '0.5rem' }}
/>
}
value={maxYear}
/>
</Group>
<Suspense fallback={<Select data={[]} />}>
<GenreSelect />
</Suspense>
{server?.type === ServerType.JELLYFIN && (
<Select
clearable
data={PLAYED_DATA}
label={t('form.shuffleAll.input_played', { postProcess: 'sentenceCase' })}
onChange={(e) => {
setStore({ played: e as Played });
}}
value={played}
/>
)}
<Divider />
<Group align="center" gap="md" justify="center" w="100%">
<PlayButton
icon="mediaPlayNext"
isSecondary
loading={isLoadingNext}
onClick={() => handlePlay(Play.NEXT)}
onLongPress={() => handlePlay(Play.NEXT_SHUFFLE)}
/>
<PlayButton
fill
loading={isLoadingNow}
onClick={() => handlePlay(Play.NOW)}
onLongPress={() => handlePlay(Play.SHUFFLE)}
/>
<PlayButton
icon="mediaPlayLast"
isSecondary
loading={isLoadingLast}
onClick={() => handlePlay(Play.LAST)}
onLongPress={() => handlePlay(Play.LAST_SHUFFLE)}
/>
</Group>
{/* <Group grow>
<Button
disabled={!limit || isFetching}
leftSection={<Icon icon="mediaPlayNext" />}
loading={fetchTypeRef.current === Play.NEXT && isFetching}
onClick={() => handlePlay(Play.NEXT)}
type="submit"
variant="default"
>
{t('player.addNext', { postProcess: 'sentenceCase' })}
</Button>
<Button
disabled={!limit || isFetching}
leftSection={<Icon icon="mediaPlayLast" />}
loading={fetchTypeRef.current === Play.LAST && isFetching}
onClick={() => handlePlay(Play.LAST)}
type="submit"
variant="default"
>
{t('player.addLast', { postProcess: 'sentenceCase' })}
</Button>
</Group>
<Button
disabled={!limit || isFetching}
leftSection={<Icon icon="mediaPlay" />}
loading={fetchTypeRef.current === Play.NOW && isFetching}
onClick={() => handlePlay(Play.NOW)}
type="submit"
variant="filled"
>
{t('player.play', { postProcess: 'sentenceCase' })}
</Button> */}
</Stack>
);
};
const randomFetchQuery = (args: {
query: {
genre?: string;
limit: number;
maxYear?: number;
minYear?: number;
musicFolderId?: string | string[];
played: Played;
};
serverId: string;
}) => {
return queryOptions({
queryFn: async ({ signal }) => {
return api.controller.getRandomSongList({
apiClientProps: { serverId: args.serverId, signal },
query: args.query,
});
},
queryKey: queryKeys.player.fetch(),
});
};
export const openShuffleAllModal = async () => {
openContextModal({
innerProps: {},
modalKey: 'shuffleAll',
size: 'sm',
title: i18n.t('player.playRandom', { postProcess: 'sentenceCase' }) as string,
});
};
const GenreSelect = () => {
const { t } = useTranslation();
const server = useCurrentServer();
const { genre } = useShuffleAllStore();
const { data: genres } = useGenreList();
const { setStore } = useShuffleAllStoreActions();
const genreData = useMemo(() => {
if (!genres) return [];
@@ -125,125 +275,16 @@ export const ShuffleAllModal = ({ genres, queryClient, server }: ShuffleAllModal
value,
};
});
}, [genres, server?.type]);
}, [genres, server.type]);
return (
<Stack gap="md">
<NumberInput
label="How many tracks?"
max={500}
min={1}
onChange={(e) => setStore({ limit: e ? Number(e) : 500 })}
required
value={limit}
/>
<Group grow>
<NumberInput
label="From year"
max={2050}
min={1850}
onChange={(e) => setStore({ minYear: e ? Number(e) : 0 })}
rightSection={
<Checkbox
checked={enableMinYear}
onChange={(e) => setStore({ enableMinYear: e.currentTarget.checked })}
style={{ marginRight: '0.5rem' }}
/>
}
value={minYear}
/>
<NumberInput
label="To year"
max={2050}
min={1850}
onChange={(e) => setStore({ maxYear: e ? Number(e) : 0 })}
rightSection={
<Checkbox
checked={enableMaxYear}
onChange={(e) => setStore({ enableMaxYear: e.currentTarget.checked })}
style={{ marginRight: '0.5rem' }}
/>
}
value={maxYear}
/>
</Group>
<Select
clearable
data={genreData}
label="Genre"
onChange={(e) => setStore({ genre: e || '' })}
value={genre}
/>
{server?.type === ServerType.JELLYFIN && (
<Select
clearable
data={PLAYED_DATA}
label="Play filter"
onChange={(e) => {
setStore({ played: e as Played });
}}
value={played}
/>
)}
<Divider />
<Group grow>
<Button
disabled={!limit}
leftSection={<Icon icon="mediaPlayLast" />}
onClick={() => handlePlay(Play.LAST)}
type="submit"
variant="default"
>
{t('player.addLast', { postProcess: 'sentenceCase' })}
</Button>
<Button
disabled={!limit}
leftSection={<Icon icon="mediaPlayNext" />}
onClick={() => handlePlay(Play.NEXT)}
type="submit"
variant="default"
>
{t('player.addNext', { postProcess: 'sentenceCase' })}
</Button>
</Group>
<Button
disabled={!limit}
leftSection={<Icon icon="mediaPlay" />}
onClick={() => handlePlay(Play.NOW)}
type="submit"
variant="filled"
>
{t('player.play', { postProcess: 'sentenceCase' })}
</Button>
</Stack>
<Select
clearable
data={genreData}
label={t('form.shuffleAll.input_genre', { postProcess: 'sentenceCase' })}
onChange={(e) => setStore({ genre: e || '' })}
searchable
value={genre}
/>
);
};
export const openShuffleAllModal = async (props: Pick<ShuffleAllModalProps, 'queryClient'>) => {
const server = useAuthStore.getState().currentServer;
const genres = await props.queryClient.fetchQuery({
gcTime: 1000 * 60 * 5,
queryFn: ({ signal }) =>
api.controller.getGenreList({
apiClientProps: {
serverId: server?.id || '',
signal,
},
query: {
sortBy: GenreListSort.NAME,
sortOrder: SortOrder.ASC,
startIndex: 0,
},
}),
queryKey: queryKeys.genres.list(server?.id),
staleTime: 1000 * 60 * 60 * 4,
});
openModal({
children: <ShuffleAllModal genres={genres} server={server} {...props} />,
size: 'sm',
title: i18n.t('player.playRandom', { postProcess: 'sentenceCase' }) as string,
});
};
@@ -4,7 +4,7 @@ import { SettingsContent } from '/@/renderer/features/settings/components/settin
import { SettingsHeader } from '/@/renderer/features/settings/components/settings-header';
import { SettingSearchContext } from '/@/renderer/features/settings/context/search-context';
export const SettingsModal = () => {
export const SettingsContextModal = () => {
const [search, setSearch] = useState('');
return (
+4 -2
View File
@@ -1,8 +1,9 @@
import { lazy, Suspense } from 'react';
import { HashRouter, Route, Routes } from 'react-router';
import { ShuffleAllContextModal } from '/@/renderer/features/player/components/shuffle-all-modal';
import { AddToPlaylistContextModal } from '/@/renderer/features/playlists/components/add-to-playlist-context-modal';
import { SettingsModal } from '/@/renderer/features/settings/components/settings-modal';
import { SettingsContextModal } from '/@/renderer/features/settings/components/settings-modal';
import { RouterErrorBoundary } from '/@/renderer/features/shared/components/router-error-boundary';
import { ShareItemContextModal } from '/@/renderer/features/sharing/components/share-item-context-modal';
import { ResponsiveLayout } from '/@/renderer/layouts/responsive-layout';
@@ -76,8 +77,9 @@ export const AppRouter = () => {
modals={{
addToPlaylist: AddToPlaylistContextModal,
base: BaseContextModal,
settings: SettingsModal,
settings: SettingsContextModal,
shareItem: ShareItemContextModal,
shuffleAll: ShuffleAllContextModal,
}}
>
<RouterErrorBoundary>