Compare commits

...

12 Commits

Author SHA1 Message Date
jeffvli 011f260e94 Bump to v0.1.1 2023-05-21 20:17:18 -07:00
jeffvli e937425f4f Fix shuffled queue set by double click 2023-05-21 20:14:22 -07:00
jeffvli bc2624bffd Add fullscreen player toggle tooltip to sidebar (#114) 2023-05-21 19:47:46 -07:00
jeffvli 4f21c26e5d Fix double play trigger (maybe) 2023-05-21 19:44:32 -07:00
jeffvli e6a4ce2e64 Set global media hotkeys enabled by default 2023-05-21 19:43:42 -07:00
jeffvli 5b98238b3a Prevent clicking on disabled sidebar items 2023-05-21 19:39:40 -07:00
jeffvli d96c0d547a Hide search results when not on home page 2023-05-21 19:37:45 -07:00
jeffvli 3c62de8347 Fix all playlist actions 2023-05-21 18:20:46 -07:00
jeffvli 07d4dc37b5 Hide fetch notification if error 2023-05-21 18:19:43 -07:00
jeffvli 64c5f25d18 Fix JF playlist controller 2023-05-21 18:19:02 -07:00
jeffvli 098e86b1f4 Fix ND playlist controller 2023-05-21 18:15:47 -07:00
jeffvli adc3e421f6 Increase size of create playlist modal 2023-05-21 17:53:43 -07:00
26 changed files with 227 additions and 65 deletions
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "feishin",
"version": "0.1.0",
"version": "0.1.1",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "feishin",
"version": "0.1.0",
"version": "0.1.1",
"hasInstallScript": true,
"license": "GPL-3.0",
"dependencies": {
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "feishin",
"productName": "Feishin",
"description": "Feishin music server",
"version": "0.1.0",
"version": "0.1.1",
"scripts": {
"build": "concurrently \"npm run build:main\" \"npm run build:renderer\"",
"build:main": "cross-env NODE_ENV=production TS_NODE_TRANSPILE_ONLY=true webpack --config ./.erb/configs/webpack.config.main.prod.ts",
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "feishin",
"version": "0.1.0",
"version": "0.1.1",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "feishin",
"version": "0.1.0",
"version": "0.1.1",
"hasInstallScript": true,
"license": "GPL-3.0",
"dependencies": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "feishin",
"version": "0.1.0",
"version": "0.1.1",
"description": "",
"main": "./dist/main/main.js",
"author": {
-2
View File
@@ -87,8 +87,6 @@ ipcMain.on('player-set-queue', async (_event, data: PlayerData, pause?: boolean)
if (pause) {
await getMpvInstance()?.pause();
} else {
await getMpvInstance()?.play();
}
});
+1 -1
View File
@@ -273,7 +273,7 @@ const createWindow = async () => {
const globalMediaKeysEnabled = store.get('global_media_hotkeys') as boolean;
if (globalMediaKeysEnabled) {
if (globalMediaKeysEnabled !== false) {
enableMediaKeys(mainWindow);
}
+5 -3
View File
@@ -6,16 +6,18 @@ import qs from 'qs';
import { toast } from '/@/renderer/components';
import { ServerListItem } from '/@/renderer/types';
import omitBy from 'lodash/omitBy';
import { z } from 'zod';
const c = initContract();
export const contract = c.router({
addToPlaylist: {
body: jfType._parameters.addToPlaylist,
body: z.null(),
method: 'POST',
path: 'playlists/:id/items',
query: jfType._parameters.addToPlaylist,
responses: {
200: jfType._response.addToPlaylist,
204: jfType._response.addToPlaylist,
400: jfType._response.error,
},
},
@@ -190,7 +192,7 @@ export const contract = c.router({
removeFromPlaylist: {
body: null,
method: 'DELETE',
path: 'items/:id',
path: 'playlists/:id/items',
query: jfType._parameters.removeFromPlaylist,
responses: {
200: jfType._response.removeFromPlaylist,
@@ -403,16 +403,17 @@ const addToPlaylist = async (args: AddToPlaylistArgs): Promise<AddToPlaylistResp
}
const res = await jfApiClient(apiClientProps).addToPlaylist({
body: {
Ids: body.songId,
UserId: apiClientProps?.server?.userId,
},
body: null,
params: {
id: query.id,
},
query: {
Ids: body.songId,
UserId: apiClientProps.server?.userId,
},
});
if (res.status !== 200) {
if (res.status !== 204) {
throw new Error('Failed to add to playlist');
}
@@ -434,7 +435,7 @@ const removeFromPlaylist = async (
},
});
if (res.status !== 200) {
if (res.status !== 204) {
throw new Error('Failed to remove from playlist');
}
@@ -431,7 +431,7 @@ const removeFromPlaylist = async (
id: query.id,
},
query: {
ids: query.songId,
id: query.songId,
},
});
@@ -314,7 +314,7 @@ const removeFromPlaylist = z.object({
});
const removeFromPlaylistParameters = z.object({
ids: z.array(z.string()),
id: z.array(z.string()),
});
export const ndType = {
@@ -195,7 +195,7 @@ export const ContextMenuProvider = ({ children }: ContextMenuProviderProps) => {
const handleDeletePlaylist = useCallback(() => {
for (const item of ctx.data) {
deletePlaylistMutation?.mutate(
{ query: { id: item.id } },
{ query: { id: item.id }, serverId: item.serverId },
{
onError: (err) => {
toast.error({
@@ -432,6 +432,7 @@ export const ContextMenuProvider = ({ children }: ContextMenuProviderProps) => {
id: ctx.context.playlistId,
songId,
},
serverId: ctx.data?.[0]?.serverId,
},
{
onError: (err) => {
@@ -465,6 +466,7 @@ export const ContextMenuProvider = ({ children }: ContextMenuProviderProps) => {
}, [
ctx.context?.playlistId,
ctx.context?.tableRef,
ctx.data,
ctx.dataNodes,
removeFromPlaylistMutation,
serverType,
@@ -140,7 +140,7 @@ export const LeftControls = () => {
onClick={handleToggleFullScreenPlayer}
>
<Tooltip
label="Open fullscreen player"
label="Toggle fullscreen player"
openDelay={500}
>
{currentSong?.imageUrl ? (
@@ -112,6 +112,10 @@ export const useHandlePlayQueueAdd = () => {
return null;
}
clearTimeout(timeoutIds.current[fetchId] as ReturnType<typeof setTimeout>);
delete timeoutIds.current[fetchId];
toast.hide(fetchId);
return toast.error({
message: err.message,
title: 'Play queue add failed',
@@ -213,7 +213,11 @@ export const PlaylistDetailSongListContent = ({ tableRef }: PlaylistDetailConten
const handleRowDoubleClick = (e: RowDoubleClickedEvent<QueueSong>) => {
if (!e.data) return;
handlePlayQueueAdd?.({
byData: [e.data],
byItemType: {
id: [playlistId],
type: LibraryItem.PLAYLIST,
},
initialSongId: e.data.id,
playType: playButtonBehavior,
});
};
@@ -2,6 +2,7 @@ import { useCallback, ChangeEvent, MutableRefObject, MouseEvent } from 'react';
import { IDatasource } from '@ag-grid-community/core';
import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/lib/agGridReact';
import { Flex, Group, Stack } from '@mantine/core';
import { closeAllModals, openModal } from '@mantine/modals';
import { useQueryClient } from '@tanstack/react-query';
import {
RiSortAsc,
@@ -18,7 +19,16 @@ import {
import { api } from '/@/renderer/api';
import { queryKeys } from '/@/renderer/api/query-keys';
import { LibraryItem, PlaylistSongListQuery, SongListSort, SortOrder } from '/@/renderer/api/types';
import { DropdownMenu, Button, Slider, MultiSelect, Switch, Text } from '/@/renderer/components';
import {
DropdownMenu,
Button,
Slider,
MultiSelect,
Switch,
Text,
ConfirmModal,
toast,
} from '/@/renderer/components';
import { usePlayQueueAdd } from '/@/renderer/features/player';
import { useContainerQuery } from '/@/renderer/hooks';
import {
@@ -34,6 +44,8 @@ import { ListDisplayType, ServerType, Play, TableColumn } from '/@/renderer/type
import { usePlaylistDetail } from '/@/renderer/features/playlists/queries/playlist-detail-query';
import { useParams } from 'react-router';
import { SONG_TABLE_COLUMNS } from '/@/renderer/components/virtual-table';
import { openUpdatePlaylistModal } from '/@/renderer/features/playlists/components/update-playlist-form';
import { useDeletePlaylist } from '/@/renderer/features/playlists/mutations/delete-playlist-mutation';
const FILTERS = {
jellyfin: [
@@ -238,6 +250,40 @@ export const PlaylistDetailSongListHeaderFilters = ({
});
};
const deletePlaylistMutation = useDeletePlaylist({});
const handleDeletePlaylist = useCallback(() => {
if (!detailQuery.data) return;
deletePlaylistMutation?.mutate(
{ query: { id: detailQuery.data.id }, serverId: detailQuery.data.id },
{
onError: (err) => {
toast.error({
message: err.message,
title: 'Error deleting playlist',
});
},
onSuccess: () => {
toast.success({
message: `Playlist has been deleted`,
});
},
},
);
closeAllModals();
}, [deletePlaylistMutation, detailQuery.data]);
const openDeletePlaylistModal = () => {
openModal({
children: (
<ConfirmModal onConfirm={handleDeletePlaylist}>
<Text>Are you sure you want to delete this playlist?</Text>
</ConfirmModal>
),
title: 'Delete playlist(s)',
});
};
return (
<Flex justify="space-between">
<Group
@@ -320,16 +366,20 @@ export const PlaylistDetailSongListHeaderFilters = ({
</DropdownMenu.Item>
<DropdownMenu.Divider />
<DropdownMenu.Item
disabled
icon={<RiEditFill />}
onClick={() => handlePlay(Play.LAST)}
onClick={() =>
openUpdatePlaylistModal({
playlist: detailQuery.data!,
server: server!,
})
}
>
Edit playlist
</DropdownMenu.Item>
<DropdownMenu.Item
disabled
icon={<RiDeleteBinFill />}
onClick={() => handlePlay(Play.LAST)}
onClick={openDeletePlaylistModal}
>
Delete playlist
</DropdownMenu.Item>
@@ -184,6 +184,10 @@ export const PlaylistListHeaderFilters = ({ tableRef }: PlaylistListHeaderFilter
setTable({ rowHeight: e });
};
const handleRefresh = () => {
tableRef?.current?.api?.purgeInfiniteCache();
};
return (
<Flex justify="space-between">
<Group
@@ -246,7 +250,12 @@ export const PlaylistListHeaderFilters = ({ tableRef }: PlaylistListHeaderFilter
</Button>
</DropdownMenu.Target>
<DropdownMenu.Dropdown>
<DropdownMenu.Item icon={<RiRefreshLine />}>Refresh</DropdownMenu.Item>
<DropdownMenu.Item
icon={<RiRefreshLine />}
onClick={handleRefresh}
>
Refresh
</DropdownMenu.Item>
</DropdownMenu.Dropdown>
</DropdownMenu>
</Group>
@@ -25,7 +25,7 @@ export const PlaylistListHeader = ({ itemCount, tableRef }: PlaylistListHeaderPr
onClose: () => {
tableRef?.current?.api?.purgeInfiniteCache();
},
size: server?.type === ServerType?.NAVIDROME ? 'lg' : 'sm',
size: server?.type === ServerType?.NAVIDROME ? 'xl' : 'sm',
title: 'Create Playlist',
});
};
@@ -9,9 +9,15 @@ interface SaveAsPlaylistFormProps {
body: Partial<CreatePlaylistBody>;
onCancel: () => void;
onSuccess: (data: CreatePlaylistResponse) => void;
serverId: string | undefined;
}
export const SaveAsPlaylistForm = ({ body, onSuccess, onCancel }: SaveAsPlaylistFormProps) => {
export const SaveAsPlaylistForm = ({
body,
serverId,
onSuccess,
onCancel,
}: SaveAsPlaylistFormProps) => {
const mutation = useCreatePlaylist({});
const server = useCurrentServer();
@@ -31,7 +37,7 @@ export const SaveAsPlaylistForm = ({ body, onSuccess, onCancel }: SaveAsPlaylist
const handleSubmit = form.onSubmit((values) => {
mutation.mutate(
{ body: values },
{ body: values, serverId },
{
onError: (err) => {
toast.error({ message: err.message, title: 'Error creating playlist' });
@@ -1,8 +1,22 @@
import { Group, Stack } from '@mantine/core';
import { useForm } from '@mantine/form';
import { ServerType, UpdatePlaylistBody, UpdatePlaylistQuery, User } from '/@/renderer/api/types';
import { openModal, closeAllModals } from '@mantine/modals';
import { api } from '/@/renderer/api';
import { queryKeys } from '/@/renderer/api/query-keys';
import {
PlaylistDetailResponse,
ServerListItem,
ServerType,
SortOrder,
UpdatePlaylistBody,
UpdatePlaylistQuery,
User,
UserListQuery,
UserListSort,
} from '/@/renderer/api/types';
import { Button, Select, Switch, TextInput, toast } from '/@/renderer/components';
import { useUpdatePlaylist } from '/@/renderer/features/playlists/mutations/update-playlist-mutation';
import { queryClient } from '/@/renderer/lib/react-query';
import { useCurrentServer } from '/@/renderer/store';
interface UpdatePlaylistFormProps {
@@ -103,3 +117,49 @@ export const UpdatePlaylistForm = ({ users, query, body, onCancel }: UpdatePlayl
</form>
);
};
export const openUpdatePlaylistModal = async (args: {
playlist: PlaylistDetailResponse;
server: ServerListItem;
}) => {
const { playlist, server } = args;
const query: UserListQuery = {
sortBy: UserListSort.NAME,
sortOrder: SortOrder.ASC,
startIndex: 0,
};
if (!server) return;
const users = await queryClient.fetchQuery({
queryFn: ({ signal }) =>
api.controller.getUserList({ apiClientProps: { server, signal }, query }),
queryKey: queryKeys.users.list(server?.id || '', query),
});
openModal({
children: (
<UpdatePlaylistForm
body={{
_custom: {
navidrome: {
owner: playlist?.owner || undefined,
ownerId: playlist?.ownerId || undefined,
public: playlist?.public || false,
rules: playlist?.rules || undefined,
sync: playlist?.sync || undefined,
},
},
comment: playlist?.description || undefined,
genres: playlist?.genres,
name: playlist?.name,
}}
query={{ id: playlist?.id }}
users={users?.items}
onCancel={closeAllModals}
/>
),
title: 'Edit playlist',
});
};
@@ -57,6 +57,7 @@ const PlaylistDetailSongListRoute = () => {
comment: detailQuery?.data?.description || '',
name: detailQuery?.data?.name,
},
serverId: detailQuery?.data?.serverId,
},
{
onSuccess: (data) => {
@@ -64,13 +65,19 @@ const PlaylistDetailSongListRoute = () => {
navigate(generatePath(AppRoute.PLAYLISTS_DETAIL_SONGS, { playlistId: data?.id || '' }), {
replace: true,
});
deletePlaylistMutation.mutate({ query: { id: playlistId } });
deletePlaylistMutation.mutate({
query: { id: playlistId },
serverId: detailQuery?.data?.serverId,
});
},
},
);
};
const handleSaveAs = (filter: Record<string, any>) => {
const handleSaveAs = (
filter: Record<string, any>,
extraFilters: { limit?: number; sortBy?: string; sortOrder?: string },
) => {
openModal({
children: (
<SaveAsPlaylistForm
@@ -82,8 +89,9 @@ const PlaylistDetailSongListRoute = () => {
public: detailQuery?.data?.public || false,
rules: {
...filter,
order: 'desc',
sort: 'year',
limit: extraFilters.limit || undefined,
order: extraFilters.sortOrder || 'desc',
sort: extraFilters.sortBy || 'dateAdded',
},
sync: detailQuery?.data?.sync || false,
},
@@ -91,6 +99,7 @@ const PlaylistDetailSongListRoute = () => {
comment: detailQuery?.data?.description || '',
name: detailQuery?.data?.name,
}}
serverId={detailQuery?.data?.serverId}
onCancel={closeAllModals}
onSuccess={(data) =>
navigate(generatePath(AppRoute.PLAYLISTS_DETAIL_SONGS, { playlistId: data?.id || '' }))
@@ -45,7 +45,7 @@ export const CommandPalette = ({ modalProps }: CommandPaletteProps) => {
}, []);
const { data, isLoading } = useSearch({
options: { enabled: debouncedQuery !== '' && query !== '' },
options: { enabled: isHome && debouncedQuery !== '' && query !== '' },
query: {
albumArtistLimit: 4,
albumArtistStartIndex: 0,
@@ -58,9 +58,9 @@ export const CommandPalette = ({ modalProps }: CommandPaletteProps) => {
serverId: server?.id,
});
const showAlbumGroup = Boolean(query && data && data?.albums?.length > 0);
const showArtistGroup = Boolean(query && data && data?.albumArtists?.length > 0);
const showTrackGroup = Boolean(query && data && data?.songs?.length > 0);
const showAlbumGroup = isHome && Boolean(query && data && data?.albums?.length > 0);
const showArtistGroup = isHome && Boolean(query && data && data?.albumArtists?.length > 0);
const showTrackGroup = isHome && Boolean(query && data && data?.songs?.length > 0);
const handlePlayQueueAdd = usePlayQueueAdd();
@@ -238,7 +238,7 @@ export const CommandPalette = ({ modalProps }: CommandPaletteProps) => {
p="0.5rem"
>
<Group position="apart">
<Command.Loading>{isLoading && query !== '' && <Spinner />}</Command.Loading>
<Command.Loading>{isHome && isLoading && query !== '' && <Spinner />}</Command.Loading>
<Group spacing="sm">
<Kbd size="md">ESC</Kbd>
<Kbd size="md"></Kbd>
@@ -33,7 +33,7 @@ export const HomeCommands = ({
openModal({
children: <CreatePlaylistForm onCancel={() => closeAllModals()} />,
size: server?.type === ServerType?.NAVIDROME ? 'lg' : 'sm',
size: server?.type === ServerType?.NAVIDROME ? 'xl' : 'sm',
title: 'Create Playlist',
});
}, [handleClose, server?.type]);
@@ -11,6 +11,8 @@ const Container = styled(Flex)<{ $active?: boolean; $disabled?: boolean }>`
border-right: var(--sidebar-border);
cursor: ${(props) => (props.$disabled ? 'default' : 'pointer')};
opacity: ${(props) => props.$disabled && 0.6};
user-select: ${(props) => (props.$disabled ? 'none' : 'initial')};
pointer-events: ${(props) => (props.$disabled ? 'none' : 'all')};
svg {
fill: ${(props) => (props.$active ? 'var(--primary-color)' : 'var(--sidebar-fg)')};
@@ -2,7 +2,7 @@ import { MouseEvent } from 'react';
import { Stack, Accordion, Center, Group, Divider, Box } from '@mantine/core';
import { closeAllModals, openModal } from '@mantine/modals';
import { AnimatePresence, motion } from 'framer-motion';
import { Button, MotionStack, Spinner } from '/@/renderer/components';
import { Button, MotionStack, Spinner, Tooltip } from '/@/renderer/components';
import {
RiAddFill,
RiAlbumFill,
@@ -95,7 +95,7 @@ export const Sidebar = () => {
openModal({
children: <CreatePlaylistForm onCancel={() => closeAllModals()} />,
size: server?.type === ServerType?.NAVIDROME ? 'lg' : 'sm',
size: server?.type === ServerType?.NAVIDROME ? 'xl' : 'sm',
title: 'Create Playlist',
});
};
@@ -288,19 +288,24 @@ export const Sidebar = () => {
transition={{ duration: 0.3, ease: 'easeInOut' }}
onClick={expandFullScreenPlayer}
>
{upsizedImageUrl ? (
<SidebarImage
loading="eager"
src={upsizedImageUrl}
/>
) : (
<Center sx={{ background: 'var(--placeholder-bg)', height: '100%' }}>
<RiDiscLine
color="var(--placeholder-fg)"
size={50}
<Tooltip
label="Toggle fullscreen player"
openDelay={500}
>
{upsizedImageUrl ? (
<SidebarImage
loading="eager"
src={upsizedImageUrl}
/>
</Center>
)}
) : (
<Center sx={{ background: 'var(--placeholder-bg)', height: '100%' }}>
<RiDiscLine
color="var(--placeholder-fg)"
size={50}
/>
</Center>
)}
</Tooltip>
<Button
compact
opacity={0.8}
+18 -8
View File
@@ -107,21 +107,31 @@ export const usePlayerStore = create<PlayerSlice>()(
if (playType === Play.NOW) {
if (get().shuffle === PlayerShuffle.TRACK) {
const shuffledSongs = shuffle(queueSongs);
const index = initialIndex || 0;
const initialSongUniqueId = queueSongs[index].uniqueId;
const initialSongIndex = shuffledSongs.findIndex(
(song) => song.uniqueId === initialSongUniqueId,
const initialSong = queueSongs[index];
const queueCopy = [...queueSongs];
// Splice the initial song from the queue
queueCopy.splice(index, 1);
const shuffledSongIndicesWithoutInitial = shuffle(queueCopy).map(
(song) => song.uniqueId,
);
// Add the initial song to the start of the shuffled queue
const shuffledSongIndices = [
initialSong.uniqueId,
...shuffledSongIndicesWithoutInitial,
];
set((state) => {
state.queue.shuffled = shuffledSongs.map((song) => song.uniqueId);
state.queue.shuffled = shuffledSongIndices;
state.queue.default = queueSongs;
state.current.time = 0;
state.current.player = 1;
state.current.index = initialSongIndex;
state.current.index = 0;
state.current.shuffledIndex = 0;
state.current.song = shuffledSongs[initialSongIndex];
state.current.song = initialSong;
});
} else {
const index = initialIndex || 0;
@@ -849,7 +859,7 @@ export const usePlayerStore = create<PlayerSlice>()(
},
name: 'store_player',
partialize: (state) => {
const notPersisted = ['queue', 'current'];
const notPersisted = ['queue', 'current', 'entry'];
return Object.fromEntries(
Object.entries(state).filter(([key]) => !notPersisted.includes(key)),
);
+1 -1
View File
@@ -162,7 +162,7 @@ const initialState: SettingsState = {
volumeMute: { allowGlobal: true, hotkey: '', isGlobal: false },
volumeUp: { allowGlobal: true, hotkey: '', isGlobal: false },
},
globalMediaHotkeys: false,
globalMediaHotkeys: true,
},
playback: {
audioDeviceId: undefined,