Browse Source

Add pagination to album page (#190)

* Remove albumSlice from store
- Merge into viewSlice

* Update pagination settings
- Add pagination settings for album
- Add server-side checkbox toggle for jellyfin

* Change album endpoints for pagination

* Add pagination for grid-view component

* Add center loader component (matches list default)

* Add pagination to album page (list-view)
- Update all links to the album page to properly handle pagination

* Match list view paginator with grid

* Add hooks for grid/list view scroll

* Add list/grid scroll hooks to album list page

* Remove image loading transition

* Split query key to separate useEffect

* Add scroll top to music list pagination
master
Jeff 3 years ago
committed by GitHub
parent
commit
c349f189af
No known key found for this signature in database GPG Key ID: 4AEE18F83AFDEB23
  1. 61
      src/__tests__/App.test.tsx
  2. 18
      src/api/api.ts
  3. 15
      src/api/jellyfinApi.ts
  4. 2
      src/components/card/Card.tsx
  5. 57
      src/components/dashboard/Dashboard.tsx
  6. 18
      src/components/library/AdvancedFilters.tsx
  7. 292
      src/components/library/AlbumList.tsx
  8. 25
      src/components/library/AlbumView.tsx
  9. 28
      src/components/library/ArtistView.tsx
  10. 7
      src/components/library/GenreList.tsx
  11. 71
      src/components/library/MusicList.tsx
  12. 12
      src/components/loader/CenterLoader.tsx
  13. 87
      src/components/settings/ConfigPanels/LookAndFeelConfig.tsx
  14. 11
      src/components/shared/Paginator.tsx
  15. 16
      src/components/shared/setDefaultSettings.ts
  16. 116
      src/components/viewtypes/GridViewType.tsx
  17. 19
      src/components/viewtypes/ListViewTable.tsx
  18. 2
      src/hooks/useAdvancedFilter.ts
  19. 16
      src/hooks/useGridScroll.ts
  20. 16
      src/hooks/useListScroll.ts
  21. 2
      src/i18n/locales/de.json
  22. 2
      src/i18n/locales/en.json
  23. 116
      src/redux/albumSlice.ts
  24. 2
      src/redux/store.ts
  25. 128
      src/redux/viewSlice.ts
  26. 9
      src/shared/mockSettings.ts
  27. 1
      src/types.ts

61
src/__tests__/App.test.tsx

@ -12,7 +12,6 @@ import { ConfigPage } from '../redux/configSlice';
import { FolderSelection } from '../redux/folderSlice';
import { FavoritePage } from '../redux/favoriteSlice';
import App from '../App';
import { AlbumPage } from '../redux/albumSlice';
import { Server } from '../types';
import { ArtistPage } from '../redux/artistSlice';
import { View } from '../redux/viewSlice';
@ -416,34 +415,6 @@ const favoriteState: FavoritePage = {
},
};
const albumState: AlbumPage = {
active: {
filter: 'random',
},
advancedFilters: {
enabled: false,
nav: 'filters',
properties: {
starred: false,
genre: {
list: [],
type: 'and',
},
artist: {
list: [],
type: 'and',
},
year: {
from: 0,
to: 0,
},
sort: {
type: 'asc',
},
},
},
};
const artistState: ArtistPage = {
active: {
list: {
@ -460,6 +431,37 @@ const artistState: ArtistPage = {
};
const viewState: View = {
album: {
filter: 'alphabeticalByName',
sort: {
type: 'asc',
},
advancedFilters: {
enabled: false,
nav: 'filters',
properties: {
starred: false,
genre: {
list: [],
type: 'and',
},
artist: {
list: [],
type: 'and',
},
year: {
from: 0,
to: 0,
},
},
},
pagination: {
serverSide: true,
recordsPerPage: 19,
activePage: 147,
pages: 285,
},
},
music: {
filter: 'random',
sort: {
@ -482,7 +484,6 @@ const mockInitialState = {
folder: folderState,
config: configState,
favorite: favoriteState,
album: albumState,
artist: artistState,
view: viewState,
};

18
src/api/api.ts

@ -167,6 +167,13 @@ const getStreamUrl = (id: string, useLegacyAuth: boolean) => {
);
};
const normalizeAPIResult = (items: any, totalRecordCount?: number) => {
return {
data: items,
totalRecordCount,
};
};
const normalizeSong = (item: any) => {
return {
id: item.id,
@ -363,7 +370,11 @@ export const getAlbums = async (
if (!res.data.albumList2.album || res.data.albumList2.album.length === 0) {
// Flatten and return once there are no more albums left
const flattenedAlbums = _.flatten(recursiveData);
return (flattenedAlbums || []).map((entry: any) => normalizeAlbum(entry));
return normalizeAPIResult(
(flattenedAlbums || []).map((entry: any) => normalizeAlbum(entry)),
flattenedAlbums.length
);
}
// On every iteration, push the existing combined album array and increase the offset
@ -385,7 +396,10 @@ export const getAlbums = async (
}
const { data } = await api.get(`/getAlbumList2`, { params: options });
return (data.albumList2.album || []).map((entry: any) => normalizeAlbum(entry));
return normalizeAPIResult(
(data.albumList2.album || []).map((entry: any) => normalizeAlbum(entry)),
data.albumList2.album.length
);
};
export const getRandomSongs = async (options: {

15
src/api/jellyfinApi.ts

@ -414,23 +414,30 @@ export const getAlbums = async (options: {
},
});
return (data.Items || []).map((entry: any) => normalizeAlbum(entry));
return normalizeAPIResult(
(data.Items || []).map((entry: any) => normalizeAlbum(entry)),
data.TotalRecordCount
);
}
const { data } = await jellyfinApi.get(`/users/${auth.username}/items`, {
params: {
fields: 'Genres, DateCreated, ChildCount, ParentId',
genres: !sortType ? options.type : undefined,
includeItemTypes: 'MusicAlbum',
limit: options.size,
startIndex: options.offset,
parentId: options.musicFolderId,
recursive: true,
sortBy: sortType!.replacement,
sortOrder: sortType!.sortOrder,
sortBy: sortType ? sortType!.replacement : 'SortName',
sortOrder: sortType ? sortType!.sortOrder : 'Ascending',
},
});
return (data.Items || []).map((entry: any) => normalizeAlbum(entry));
return normalizeAPIResult(
(data.Items || []).map((entry: any) => normalizeAlbum(entry)),
data.TotalRecordCount
);
};
export const getSongs = async (options: {

2
src/components/card/Card.tsx

@ -276,7 +276,7 @@ const Card = ({
alt="img"
effect="opacity"
cardsize={size}
visibleByDefault={notVisibleByDefault ? false : cacheImages}
visibleByDefault={!notVisibleByDefault}
afterLoad={() => {
if (cacheImages) {
cacheImage(

57
src/components/dashboard/Dashboard.tsx

@ -9,9 +9,9 @@ import GenericPageHeader from '../layout/GenericPageHeader';
import ScrollingMenu from '../scrollingmenu/ScrollingMenu';
import { useAppDispatch, useAppSelector } from '../../redux/hooks';
import { setStar } from '../../redux/playQueueSlice';
import { setActive } from '../../redux/albumSlice';
import { apiController } from '../../api/controller';
import { Server } from '../../types';
import { Item, Server } from '../../types';
import { setFilter, setPagination } from '../../redux/viewSlice';
const Dashboard = () => {
const { t } = useTranslation();
@ -20,7 +20,6 @@ const Dashboard = () => {
const queryClient = useQueryClient();
const folder = useAppSelector((state) => state.folder);
const config = useAppSelector((state) => state.config);
const album = useAppSelector((state) => state.album);
const [musicFolder, setMusicFolder] = useState({ loaded: false, id: undefined });
useEffect(() => {
@ -100,33 +99,33 @@ const Dashboard = () => {
});
dispatch(setStar({ id: [rowData.id], type: 'star' }));
queryClient.setQueryData(['recentAlbums', musicFolder.id], (oldData: any) => {
const starredIndices = _.keys(_.pickBy(oldData, { id: rowData.id }));
const starredIndices = _.keys(_.pickBy(oldData.data, { id: rowData.id }));
starredIndices.forEach((index) => {
oldData[index].starred = Date.now();
oldData.data[index].starred = Date.now();
});
return oldData;
});
queryClient.setQueryData(['newestAlbums', musicFolder.id], (oldData: any) => {
const starredIndices = _.keys(_.pickBy(oldData, { id: rowData.id }));
const starredIndices = _.keys(_.pickBy(oldData.data, { id: rowData.id }));
starredIndices.forEach((index) => {
oldData[index].starred = Date.now();
oldData.data[index].starred = Date.now();
});
return oldData;
});
queryClient.setQueryData(['randomAlbums', musicFolder.id], (oldData: any) => {
const starredIndices = _.keys(_.pickBy(oldData, { id: rowData.id }));
const starredIndices = _.keys(_.pickBy(oldData.data, { id: rowData.id }));
starredIndices.forEach((index) => {
oldData[index].starred = Date.now();
oldData.data[index].starred = Date.now();
});
return oldData;
});
queryClient.setQueryData(['frequentAlbums', musicFolder.id], (oldData: any) => {
const starredIndices = _.keys(_.pickBy(oldData, { id: rowData.id }));
const starredIndices = _.keys(_.pickBy(oldData.data, { id: rowData.id }));
starredIndices.forEach((index) => {
oldData[index].starred = Date.now();
oldData.data[index].starred = Date.now();
});
return oldData;
@ -139,33 +138,33 @@ const Dashboard = () => {
});
dispatch(setStar({ id: [rowData.id], type: 'unstar' }));
queryClient.setQueryData(['recentAlbums', musicFolder.id], (oldData: any) => {
const starredIndices = _.keys(_.pickBy(oldData, { id: rowData.id }));
const starredIndices = _.keys(_.pickBy(oldData.data, { id: rowData.id }));
starredIndices.forEach((index) => {
oldData[index].starred = undefined;
oldData.data[index].starred = undefined;
});
return oldData;
});
queryClient.setQueryData(['newestAlbums', musicFolder.id], (oldData: any) => {
const starredIndices = _.keys(_.pickBy(oldData, { id: rowData.id }));
const starredIndices = _.keys(_.pickBy(oldData.data, { id: rowData.id }));
starredIndices.forEach((index) => {
oldData[index].starred = undefined;
oldData.data[index].starred = undefined;
});
return oldData;
});
queryClient.setQueryData(['randomAlbums', musicFolder.id], (oldData: any) => {
const starredIndices = _.keys(_.pickBy(oldData, { id: rowData.id }));
const starredIndices = _.keys(_.pickBy(oldData.data, { id: rowData.id }));
starredIndices.forEach((index) => {
oldData[index].starred = undefined;
oldData.data[index].starred = undefined;
});
return oldData;
});
queryClient.setQueryData(['frequentAlbums', musicFolder.id], (oldData: any) => {
const starredIndices = _.keys(_.pickBy(oldData, { id: rowData.id }));
const starredIndices = _.keys(_.pickBy(oldData.data, { id: rowData.id }));
starredIndices.forEach((index) => {
oldData[index].starred = undefined;
oldData.data[index].starred = undefined;
});
return oldData;
@ -188,7 +187,7 @@ const Dashboard = () => {
<ScrollingMenu
noScrollbar
title={t('Recently Played')}
data={config.serverType === Server.Jellyfin ? recentAlbums.data : recentAlbums}
data={recentAlbums.data}
cardTitle={{
prefix: '/library/album',
property: 'title',
@ -201,7 +200,8 @@ const Dashboard = () => {
}}
cardSize={config.lookAndFeel.gridView.cardSize}
onClickTitle={() => {
dispatch(setActive({ ...album.active, filter: 'recent' }));
dispatch(setFilter({ listType: Item.Album, data: 'recent' }));
dispatch(setPagination({ listType: Item.Album, data: { activePage: 1 } }));
setTimeout(() => {
history.push(`/library/album?sortType=recent`);
}, 50);
@ -213,7 +213,7 @@ const Dashboard = () => {
<ScrollingMenu
title={t('Recently Added')}
noScrollbar
data={newestAlbums}
data={newestAlbums.data}
cardTitle={{
prefix: '/library/album',
property: 'title',
@ -226,7 +226,8 @@ const Dashboard = () => {
}}
cardSize={config.lookAndFeel.gridView.cardSize}
onClickTitle={() => {
dispatch(setActive({ ...album.active, filter: 'newest' }));
dispatch(setFilter({ listType: Item.Album, data: 'newest' }));
dispatch(setPagination({ listType: Item.Album, data: { activePage: 1 } }));
setTimeout(() => {
history.push(`/library/album?sortType=newest`);
}, 50);
@ -238,7 +239,7 @@ const Dashboard = () => {
<ScrollingMenu
title={t('Random')}
noScrollbar
data={randomAlbums}
data={randomAlbums.data}
cardTitle={{
prefix: '/library/album',
property: 'title',
@ -251,7 +252,8 @@ const Dashboard = () => {
}}
cardSize={config.lookAndFeel.gridView.cardSize}
onClickTitle={() => {
dispatch(setActive({ ...album.active, filter: 'random' }));
dispatch(setFilter({ listType: Item.Album, data: 'random' }));
dispatch(setPagination({ listType: Item.Album, data: { activePage: 1 } }));
setTimeout(() => {
history.push(`/library/album?sortType=random`);
}, 50);
@ -263,7 +265,7 @@ const Dashboard = () => {
<ScrollingMenu
noScrollbar
title={t('Most Played')}
data={config.serverType === Server.Jellyfin ? frequentAlbums.data : frequentAlbums}
data={frequentAlbums.data}
cardTitle={{
prefix: '/library/album',
property: 'title',
@ -276,7 +278,8 @@ const Dashboard = () => {
}}
cardSize={config.lookAndFeel.gridView.cardSize}
onClickTitle={() => {
dispatch(setActive({ ...album.active, filter: 'frequent' }));
dispatch(setFilter({ listType: Item.Album, data: 'frequent' }));
dispatch(setPagination({ listType: Item.Album, data: { activePage: 1 } }));
setTimeout(() => {
history.push(`/library/album?sortType=frequent`);
}, 50);

18
src/components/library/AdvancedFilters.tsx

@ -4,6 +4,7 @@ import { useTranslation } from 'react-i18next';
import { ButtonToolbar, ControlLabel, Divider, FlexboxGrid, RadioGroup } from 'rsuite';
import styled from 'styled-components';
import { useAppDispatch } from '../../redux/hooks';
import { Item } from '../../types';
import {
StyledButton,
StyledCheckbox,
@ -154,7 +155,7 @@ const AdvancedFilters = ({ filteredData, originalData, filter, setAdvancedFilter
defaultChecked={filter.enabled}
checked={filter.enabled}
onChange={(e: boolean) => {
dispatch(setAdvancedFilters({ filter: 'enabled', value: e }));
dispatch(setAdvancedFilters({ listType: Item.Album, filter: 'enabled', value: e }));
}}
/>
</FlexboxGrid.Item>
@ -166,6 +167,7 @@ const AdvancedFilters = ({ filteredData, originalData, filter, setAdvancedFilter
onChange={(_v: any, e: boolean) => {
dispatch(
setAdvancedFilters({
listType: Item.Album,
filter: 'starred',
value: e,
})
@ -186,6 +188,7 @@ const AdvancedFilters = ({ filteredData, originalData, filter, setAdvancedFilter
onClick={() => {
dispatch(
setAdvancedFilters({
listType: Item.Album,
filter: 'genre',
value: { ...filter.properties.genre, list: [] },
})
@ -202,7 +205,11 @@ const AdvancedFilters = ({ filteredData, originalData, filter, setAdvancedFilter
defaultValue={filter.properties.genre.type}
onChange={(e: string) => {
dispatch(
setAdvancedFilters({ filter: 'genre', value: { ...filter.properties.genre, type: e } })
setAdvancedFilters({
listType: Item.Album,
filter: 'genre',
value: { ...filter.properties.genre, type: e },
})
);
}}
>
@ -245,6 +252,7 @@ const AdvancedFilters = ({ filteredData, originalData, filter, setAdvancedFilter
onChange={(e: string[]) => {
dispatch(
setAdvancedFilters({
listType: Item.Album,
filter: 'genre',
value: { ...filter.properties.genre, list: e },
})
@ -265,6 +273,7 @@ const AdvancedFilters = ({ filteredData, originalData, filter, setAdvancedFilter
onClick={() => {
dispatch(
setAdvancedFilters({
listType: Item.Album,
filter: 'artist',
value: { ...filter.properties.artist, list: [] },
})
@ -282,6 +291,7 @@ const AdvancedFilters = ({ filteredData, originalData, filter, setAdvancedFilter
onChange={(e: string) => {
dispatch(
setAdvancedFilters({
listType: Item.Album,
filter: 'artist',
value: { ...filter.properties.artist, type: e },
})
@ -327,6 +337,7 @@ const AdvancedFilters = ({ filteredData, originalData, filter, setAdvancedFilter
onChange={(e: string[]) => {
dispatch(
setAdvancedFilters({
listType: Item.Album,
filter: 'artist',
value: { ...filter.properties.artist, list: e },
})
@ -351,6 +362,7 @@ const AdvancedFilters = ({ filteredData, originalData, filter, setAdvancedFilter
onClick={() => {
dispatch(
setAdvancedFilters({
listType: Item.Album,
filter: 'year',
value: { from: 0, to: 0 },
})
@ -375,6 +387,7 @@ const AdvancedFilters = ({ filteredData, originalData, filter, setAdvancedFilter
onChange={(e: number) => {
dispatch(
setAdvancedFilters({
listType: Item.Album,
filter: 'year',
value: { ...filter.properties.year, from: Number(e) },
})
@ -394,6 +407,7 @@ const AdvancedFilters = ({ filteredData, originalData, filter, setAdvancedFilter
onChange={(e: number) => {
dispatch(
setAdvancedFilters({
listType: Item.Album,
filter: 'year',
value: { ...filter.properties.year, to: Number(e) },
})

292
src/components/library/AlbumList.tsx

@ -11,7 +11,6 @@ import ListViewType from '../viewtypes/ListViewType';
import useSearchQuery from '../../hooks/useSearchQuery';
import GenericPageHeader from '../layout/GenericPageHeader';
import GenericPage from '../layout/GenericPage';
import PageLoader from '../loader/PageLoader';
import { useAppDispatch, useAppSelector } from '../../redux/hooks';
import {
toggleSelected,
@ -27,7 +26,6 @@ import {
StyledTag,
} from '../shared/styled';
import { FilterButton, RefreshButton } from '../shared/ToolbarButtons';
import { setActive, setAdvancedFilters } from '../../redux/albumSlice';
import { setSearchQuery } from '../../redux/miscSlice';
import { apiController } from '../../api/controller';
import { Item, Server } from '../../types';
@ -35,6 +33,9 @@ import AdvancedFilters from './AdvancedFilters';
import useAdvancedFilter from '../../hooks/useAdvancedFilter';
import ColumnSort from '../shared/ColumnSort';
import useColumnSort from '../../hooks/useColumnSort';
import { setFilter, setPagination, setAdvancedFilters, setColumnSort } from '../../redux/viewSlice';
import useGridScroll from '../../hooks/useGridScroll';
import useListScroll from '../../hooks/useListScroll';
export const ALBUM_SORT_TYPES = [
{ label: i18next.t('A-Z (Name)'), value: 'alphabeticalByName', role: i18next.t('Default') },
@ -51,43 +52,65 @@ const AlbumList = () => {
const history = useHistory();
const queryClient = useQueryClient();
const folder = useAppSelector((state) => state.folder);
const album = useAppSelector((state) => state.album);
const config = useAppSelector((state) => state.config);
const misc = useAppSelector((state) => state.misc);
const view = useAppSelector((state) => state.view);
const [isRefreshing, setIsRefreshing] = useState(false);
const [sortTypes, setSortTypes] = useState<any[]>([]);
const [viewType, setViewType] = useState(settings.getSync('albumViewType'));
const [musicFolder, setMusicFolder] = useState(undefined);
const [musicFolder, setMusicFolder] = useState({ loaded: false, id: undefined });
const albumFilterPickerContainerRef = useRef(null);
const [isRefresh, setIsRefresh] = useState(false);
const [currentQueryKey, setCurrentQueryKey] = useState<any>(['albumList']);
const gridRef = useRef<any>();
const listRef = useRef<any>();
const { gridScroll } = useGridScroll(gridRef);
const { listScroll } = useListScroll(listRef);
useEffect(() => {
if (folder.applied.albums) {
setMusicFolder(folder.musicFolder);
setMusicFolder({ loaded: true, id: folder.musicFolder });
} else {
setMusicFolder({ loaded: true, id: undefined });
}
}, [folder.applied.albums, folder.musicFolder]);
useEffect(() => {
if (config.serverType === Server.Subsonic || !view.album.pagination.serverSide) {
// Client-side paging won't require a separate key for the active page
setCurrentQueryKey(['albumList', view.album.filter, musicFolder.id]);
} else {
setCurrentQueryKey(['albumList', view.album.filter, view.album.pagination, musicFolder.id]);
}
}, [folder]);
}, [config.serverType, musicFolder.id, view.album.filter, view.album.pagination]);
const { isLoading, isError, data: albums, error }: any = useQuery(
['albumList', album.active.filter, musicFolder],
currentQueryKey,
() =>
album.active.filter === 'random'
view.album.filter === 'random' ||
(view.album.pagination.recordsPerPage !== 0 && view.album.pagination.serverSide)
? apiController({
serverType: config.serverType,
endpoint: 'getAlbums',
args:
config.serverType === Server.Subsonic
? {
type: 'random',
size: 100,
type: view.album.filter,
size: 500,
offset: 0,
musicFolderId: musicFolder,
recursive: view.album.filter !== 'random',
}
: {
type: 'random',
size: 100,
offset: 0,
type: view.album.filter,
size:
view.album.pagination.recordsPerPage === 0
? 100
: view.album.pagination.recordsPerPage,
offset:
(view.album.pagination.activePage - 1) * view.album.pagination.recordsPerPage,
recursive: false,
musicFolderId: musicFolder,
musicFolderId: musicFolder.id,
},
})
: apiController({
@ -96,23 +119,31 @@ const AlbumList = () => {
args:
config.serverType === Server.Subsonic
? {
type: album.active.filter,
type: view.album.filter,
size: 500,
offset: 0,
musicFolderId: musicFolder,
musicFolderId: musicFolder.id,
recursive: true,
}
: {
type: album.active.filter,
type: view.album.filter,
recursive: true,
musicFolderId: musicFolder,
musicFolderId: musicFolder.id,
},
}),
{
cacheTime: 3600000, // Stay in cache for 1 hour
staleTime: Infinity, // Only allow manual refresh
cacheTime:
view.album.pagination.recordsPerPage !== 0 && config.serverType === Server.Jellyfin
? 600000
: Infinity,
staleTime:
view.album.pagination.recordsPerPage !== 0 && config.serverType === Server.Jellyfin
? 600000
: Infinity,
enabled: currentQueryKey !== ['albumList'] && musicFolder.loaded,
}
);
const { data: genres }: any = useQuery(['genreList'], async () => {
const res = await apiController({
serverType: config.serverType,
@ -131,7 +162,7 @@ const AlbumList = () => {
});
});
const searchedData = useSearchQuery(misc.searchQuery, albums, [
const searchedData = useSearchQuery(misc.searchQuery, albums?.data, [
'title',
'artist',
'genre',
@ -145,18 +176,36 @@ const AlbumList = () => {
byGenreData,
byStarredData,
byYearData,
} = useAdvancedFilter(albums, album.advancedFilters);
} = useAdvancedFilter(albums?.data, view.album.advancedFilters);
const { sortColumns, sortedData } = useColumnSort(
filteredData,
Item.Album,
album.advancedFilters.properties.sort
);
const { sortColumns, sortedData } = useColumnSort(filteredData, Item.Album, view.album.sort);
useEffect(() => {
setSortTypes(_.compact(_.concat(ALBUM_SORT_TYPES, genres)));
}, [genres]);
useEffect(() => {
if (albums?.data && sortedData?.length) {
const pages =
Math.floor(
(view.album.pagination.serverSide && config.serverType === Server.Jellyfin
? albums?.totalRecordCount
: sortedData?.length) / view.album.pagination.recordsPerPage
) + 1;
if (pages && view.album.pagination.pages !== pages) {
dispatch(
setPagination({
listType: Item.Album,
data: {
pages,
},
})
);
}
}
}, [albums, config.serverType, dispatch, sortedData?.length, view.album.pagination]);
let timeout: any = null;
const handleRowClick = (e: any, rowData: any, tableData: any) => {
if (timeout === null) {
@ -194,10 +243,10 @@ const AlbumList = () => {
endpoint: 'star',
args: { id: rowData.id, type: 'album' },
});
queryClient.setQueryData(['albumList', album.active.filter, musicFolder], (oldData: any) => {
const starredIndices = _.keys(_.pickBy(oldData, { id: rowData.id }));
queryClient.setQueryData(['albumList', view.album.filter, musicFolder.id], (oldData: any) => {
const starredIndices = _.keys(_.pickBy(oldData.data, { id: rowData.id }));
starredIndices.forEach((index) => {
oldData[index].starred = Date.now();
oldData.data[index].starred = Date.now();
});
return oldData;
@ -208,10 +257,10 @@ const AlbumList = () => {
endpoint: 'unstar',
args: { id: rowData.id, type: 'album' },
});
queryClient.setQueryData(['albumList', album.active.filter, musicFolder], (oldData: any) => {
const starredIndices = _.keys(_.pickBy(oldData, { id: rowData.id }));
queryClient.setQueryData(['albumList', view.album.filter, musicFolder.id], (oldData: any) => {
const starredIndices = _.keys(_.pickBy(oldData.data, { id: rowData.id }));
starredIndices.forEach((index) => {
oldData[index].starred = undefined;
oldData.data[index].starred = undefined;
});
return oldData;
@ -226,10 +275,10 @@ const AlbumList = () => {
args: { ids: [rowData.id], rating: e },
});
queryClient.setQueryData(['albumList', album.active.filter, musicFolder], (oldData: any) => {
const ratedIndices = _.keys(_.pickBy(oldData, { id: rowData.id }));
queryClient.setQueryData(['albumList', view.album.filter, musicFolder.id], (oldData: any) => {
const ratedIndices = _.keys(_.pickBy(oldData.data, { id: rowData.id }));
ratedIndices.forEach((index) => {
oldData[index].userRating = e;
oldData.data[index].userRating = e;
});
return oldData;
@ -256,8 +305,8 @@ const AlbumList = () => {
container={() => albumFilterPickerContainerRef.current}
size="sm"
width={180}
defaultValue={album.active.filter}
value={album.active.filter}
defaultValue={view.album.filter}
value={view.album.filter}
groupBy="role"
data={sortTypes || ALBUM_SORT_TYPES}
disabledItemValues={
@ -266,17 +315,18 @@ const AlbumList = () => {
cleanable={false}
placeholder={t('Sort Type')}
onChange={async (value: string) => {
setIsRefresh(true);
await queryClient.cancelQueries([
'albumList',
album.active.filter,
musicFolder,
view.album.filter,
musicFolder.id,
]);
dispatch(setSearchQuery(''));
dispatch(setActive({ ...album.active, filter: value }));
dispatch(setFilter({ listType: Item.Album, data: value }));
dispatch(setPagination({ listType: Item.Album, data: { activePage: 1 } }));
localStorage.setItem('scroll_grid_albumList', '0');
localStorage.setItem('scroll_list_albumList', '0');
setIsRefresh(false);
gridScroll(0);
listScroll(0);
}}
/>
<RefreshButton onClick={handleRefresh} size="sm" loading={isRefreshing} />
@ -293,8 +343,12 @@ const AlbumList = () => {
speaker={
<StyledPopover width="275px" opacity={0.97}>
<Nav
activeKey={album.advancedFilters.nav}
onSelect={(e) => dispatch(setAdvancedFilters({ filter: 'nav', value: e }))}
activeKey={view.album.advancedFilters.nav}
onSelect={(e) =>
dispatch(
setAdvancedFilters({ listType: Item.Album, filter: 'nav', value: e })
)
}
justified
appearance="tabs"
>
@ -302,7 +356,7 @@ const AlbumList = () => {
<StyledNavItem eventKey="sort">Sort</StyledNavItem>
</Nav>
<br />
{album.advancedFilters.nav === 'filters' && (
{view.album.advancedFilters.nav === 'filters' && (
<AdvancedFilters
filteredData={{
filteredData,
@ -312,26 +366,26 @@ const AlbumList = () => {
byStarredData,
byYearData,
}}
originalData={albums}
filter={album.advancedFilters}
originalData={albums?.data}
filter={view.album.advancedFilters}
setAdvancedFilters={setAdvancedFilters}
/>
)}
{album.advancedFilters.nav === 'sort' && (
{view.album.advancedFilters.nav === 'sort' && (
<ColumnSort
sortColumns={sortColumns}
sortColumn={album.advancedFilters.properties.sort.column}
sortType={album.advancedFilters.properties.sort.type}
sortColumn={view.album.sort.column}
sortType={view.album.sort.type}
disabledItemValues={
config.serverType === Server.Jellyfin ? ['playCount', 'userRating'] : []
}
clearSortType={() =>
dispatch(
setAdvancedFilters({
filter: 'sort',
value: {
...album.advancedFilters.properties.sort,
setColumnSort({
listType: Item.Album,
data: {
...view.album.sort,
column: undefined,
},
})
@ -339,17 +393,17 @@ const AlbumList = () => {
}
setSortType={(e: string) =>
dispatch(
setAdvancedFilters({
filter: 'sort',
value: { ...album.advancedFilters.properties.sort, type: e },
setColumnSort({
listType: Item.Album,
data: { ...view.album.sort, type: e },
})
)
}
setSortColumn={(e: string) =>
dispatch(
setAdvancedFilters({
filter: 'sort',
value: { ...album.advancedFilters.properties.sort, column: e },
setColumnSort({
listType: Item.Album,
data: { ...view.album.sort, column: e },
})
)
}
@ -361,7 +415,7 @@ const AlbumList = () => {
<FilterButton
size="sm"
appearance={
album.advancedFilters.enabled || album.advancedFilters.properties.sort.column
view.album.advancedFilters.enabled || view.album.sort.column
? 'primary'
: 'subtle'
}
@ -376,11 +430,21 @@ const AlbumList = () => {
/>
}
>
{isLoading && <PageLoader />}
{isError && <div>Error: {error}</div>}
{!isLoading && !isError && sortedData?.length > 0 && viewType === 'list' && (
{!isError && viewType === 'list' && (
<ListViewType
data={misc.searchQuery !== '' ? searchedData : sortedData}
ref={listRef}
data={
misc.searchQuery !== ''
? searchedData
: (config.serverType === Server.Subsonic || !view.album.pagination.serverSide) &&
view.album.pagination.recordsPerPage !== 0
? sortedData?.slice(
(view.album.pagination.activePage - 1) * view.album.pagination.recordsPerPage,
view.album.pagination.activePage * view.album.pagination.recordsPerPage
)
: sortedData
}
tableColumns={config.lookAndFeel.listView.album.columns}
rowHeight={config.lookAndFeel.listView.album.rowHeight}
fontSize={config.lookAndFeel.listView.album.fontSize}
@ -401,16 +465,67 @@ const AlbumList = () => {
'deletePlaylist',
'viewInFolder',
]}
loading={isLoading}
handleFavorite={handleRowFavorite}
initialScrollOffset={Number(localStorage.getItem('scroll_list_albumList'))}
onScroll={(scrollIndex: number) => {
localStorage.setItem('scroll_list_albumList', String(Math.abs(scrollIndex)));
}}
paginationProps={
view.album.pagination.recordsPerPage !== 0 && {
disabled: misc.searchQuery !== '' ? true : null,
pages: view.album.pagination.pages,
activePage: view.album.pagination.activePage,
maxButtons: 3,
prev: true,
next: true,
ellipsis: true,
boundaryLinks: true,
startIndex:
view.album.pagination.recordsPerPage * (view.album.pagination.activePage - 1) + 1,
endIndex: view.album.pagination.recordsPerPage * view.album.pagination.activePage,
handleGoToButton: (e: number) => {
localStorage.setItem('scroll_list_albumList', '0');
dispatch(
setPagination({
listType: Item.Album,
data: {
activePage: e,
},
})
);
},
onSelect: async (e: number) => {
localStorage.setItem('scroll_list_albumList', '0');
await queryClient.cancelQueries(['albumList'], { active: true });
dispatch(
setPagination({
listType: Item.Album,
data: {
activePage: e,
},
})
);
listScroll(0);
},
}
}
/>
)}
{!isLoading && !isError && sortedData?.length > 0 && viewType === 'grid' && (
{!isError && viewType === 'grid' && (
<GridViewType
data={misc.searchQuery !== '' ? searchedData : sortedData}
gridRef={gridRef}
data={
misc.searchQuery !== ''
? searchedData
: (config.serverType === Server.Subsonic || !view.album.pagination.serverSide) &&
view.album.pagination.recordsPerPage !== 0
? sortedData?.slice(
(view.album.pagination.activePage - 1) * view.album.pagination.recordsPerPage,
view.album.pagination.activePage * view.album.pagination.recordsPerPage
)
: sortedData
}
cardTitle={{
prefix: '/library/album',
property: 'title',
@ -430,7 +545,46 @@ const AlbumList = () => {
onScroll={(scrollIndex: number) => {
localStorage.setItem('scroll_grid_albumList', String(scrollIndex));
}}
refresh={isRefresh}
loading={isLoading}
paginationProps={
view.album.pagination.recordsPerPage !== 0 && {
disabled: misc.searchQuery !== '' ? true : null,
pages: view.album.pagination.pages,
activePage: view.album.pagination.activePage,
maxButtons: 3,
prev: true,
next: true,
ellipsis: true,
boundaryLinks: true,
startIndex:
view.album.pagination.recordsPerPage * (view.album.pagination.activePage - 1) + 1,
endIndex: view.album.pagination.recordsPerPage * view.album.pagination.activePage,
handleGoToButton: (e: number) => {
localStorage.setItem('scroll_grid_albumList', '0');
dispatch(
setPagination({
listType: Item.Album,
data: {
activePage: e,
},
})
);
},
onSelect: async (e: number) => {
localStorage.setItem('scroll_grid_albumList', '0');
await queryClient.cancelQueries(['albumList']);
dispatch(
setPagination({
listType: Item.Album,
data: {
activePage: e,
},
})
);
gridScroll(0);
},
}
}
/>
)}
</GenericPage>

25
src/components/library/AlbumView.tsx

@ -53,16 +53,16 @@ import {
StyledPopover,
StyledTagLink,
} from '../shared/styled';
import { setActive } from '../../redux/albumSlice';
import {
BlurredBackground,
BlurredBackgroundWrapper,
PageHeaderSubtitleDataLine,
} from '../layout/styled';
import { apiController } from '../../api/controller';
import { Artist, Genre, Server } from '../../types';
import { Artist, Genre, Item, Server } from '../../types';
import { setPlaylistRate } from '../../redux/playlistSlice';
import Card from '../card/Card';
import { setFilter, setPagination } from '../../redux/viewSlice';
interface AlbumParams {
id: string;
@ -72,7 +72,6 @@ const AlbumView = ({ ...rest }: any) => {
const { t } = useTranslation();
const dispatch = useAppDispatch();
const misc = useAppSelector((state) => state.misc);
const album = useAppSelector((state) => state.album);
const config = useAppSelector((state) => state.config);
const history = useHistory();
const queryClient = useQueryClient();
@ -399,7 +398,15 @@ const AlbumView = ({ ...rest }: any) => {
tabIndex={0}
onClick={() => {
if (!rest.isModal) {
dispatch(setActive({ ...album.active, filter: d.title }));
dispatch(
setFilter({
listType: Item.Album,
data: d.title,
})
);
dispatch(
setPagination({ listType: Item.Album, data: { activePage: 1 } })
);
localStorage.setItem('scroll_list_albumList', '0');
localStorage.setItem('scroll_grid_albumList', '0');
setTimeout(() => {
@ -411,7 +418,15 @@ const AlbumView = ({ ...rest }: any) => {
if (e.key === ' ' || e.key === 'Enter') {
e.preventDefault();
if (!rest.isModal) {
dispatch(setActive({ ...album.active, filter: d.title }));
dispatch(
setFilter({
listType: Item.Album,
data: d.title,
})
);
dispatch(
setPagination({ listType: Item.Album, data: { activePage: 1 } })
);
localStorage.setItem('scroll_list_albumList', '0');
localStorage.setItem('scroll_grid_albumList', '0');
setTimeout(() => {

28
src/components/library/ArtistView.tsx

@ -62,7 +62,7 @@ import ScrollingMenu from '../scrollingmenu/ScrollingMenu';
import useColumnSort from '../../hooks/useColumnSort';
import { setPlaylistRate } from '../../redux/playlistSlice';
import CustomTooltip from '../shared/CustomTooltip';
import { setActive } from '../../redux/albumSlice';
import { setFilter, setPagination } from '../../redux/viewSlice';
const fac = new FastAverageColor();
@ -77,7 +77,6 @@ const ArtistView = ({ ...rest }: any) => {
const history = useHistory();
const location = useLocation();
const misc = useAppSelector((state) => state.misc);
const album = useAppSelector((state) => state.album);
const config = useAppSelector((state) => state.config);
const folder = useAppSelector((state) => state.folder);
const [viewType, setViewType] = useState(settings.getSync('albumViewType') || 'list');
@ -609,7 +608,16 @@ const ArtistView = ({ ...rest }: any) => {
tabIndex={0}
onClick={() => {
if (!rest.isModal) {
dispatch(setActive({ ...album.active, filter: d.title }));
dispatch(
setFilter({
listType: Item.Album,
data: d.title,
})
);
dispatch(
setPagination({ listType: Item.Album, data: { activePage: 1 } })
);
localStorage.setItem('scroll_list_albumList', '0');
localStorage.setItem('scroll_grid_albumList', '0');
setTimeout(() => {
@ -621,7 +629,19 @@ const ArtistView = ({ ...rest }: any) => {
if (e.key === ' ' || e.key === 'Enter') {
e.preventDefault();
if (!rest.isModal) {
dispatch(setActive({ ...album.active, filter: d.title }));
dispatch(
setFilter({
listType: Item.Album,
data: d.title,
})
);
dispatch(
setPagination({
listType: Item.Album,
data: { activePage: 1 },
})
);
localStorage.setItem('scroll_list_albumList', '0');
localStorage.setItem('scroll_grid_albumList', '0');
setTimeout(() => {

7
src/components/library/GenreList.tsx

@ -15,16 +15,16 @@ import {
toggleRangeSelected,
toggleSelected,
} from '../../redux/multiSelectSlice';
import { setActive } from '../../redux/albumSlice';
import { apiController } from '../../api/controller';
import { StyledTag } from '../shared/styled';
import { setFilter, setPagination } from '../../redux/viewSlice';
import { Item } from '../../types';
const GenreList = () => {
const { t } = useTranslation();
const dispatch = useAppDispatch();
const history = useHistory();
const config = useAppSelector((state) => state.config);
const album = useAppSelector((state) => state.album);
const misc = useAppSelector((state) => state.misc);
const folder = useAppSelector((state) => state.folder);
const { isLoading, isError, data: genres, error }: any = useQuery(['genrePageList'], async () => {
@ -56,7 +56,8 @@ const GenreList = () => {
const handleRowDoubleClick = (rowData: any) => {
window.clearTimeout(timeout);
timeout = null;
dispatch(setActive({ ...album.active, filter: rowData.title }));
dispatch(setFilter({ listType: Item.Album, data: rowData.title }));
dispatch(setPagination({ listType: Item.Album, data: { activePage: 1 } }));
localStorage.setItem('scroll_list_albumList', '0');
localStorage.setItem('scroll_grid_albumList', '0');
dispatch(clearSelected());

71
src/components/library/MusicList.tsx

@ -20,16 +20,17 @@ import { StyledInputPicker, StyledInputPickerContainer, StyledTag } from '../sha
import { RefreshButton } from '../shared/ToolbarButtons';
import { setSearchQuery } from '../../redux/miscSlice';
import { apiController } from '../../api/controller';
import { Item } from '../../types';
import { Item, Server } from '../../types';
import useColumnSort from '../../hooks/useColumnSort';
import { fixPlayer2Index, setPlayQueueByRowClick, setStar } from '../../redux/playQueueSlice';
import { setFilter, setPagination } from '../../redux/viewSlice';
import { setStatus } from '../../redux/playerSlice';
import useListScroll from '../../hooks/useListScroll';
// prettier-ignore
export const MUSIC_SORT_TYPES = [
{ label: i18next.t('A-Z (Name)'), value: 'alphabeticalByName', role: i18next.t('Default') },
{ label: i18next.t('A-Z (Album)'), value: 'alphabeticalByAlbum', role: i18next.t('Default') },
// eslint-disable-next-line prettier/prettier
{ label: i18next.t('A-Z (Album Artist)'), value: 'alphabeticalByArtist', role: i18next.t('Default') },
{ label: i18next.t('A-Z (Artist)'), value: 'alphabeticalByTrackArtist', replacement: 'Artist' },
{ label: i18next.t('Most Played'), value: 'frequent', role: i18next.t('Default') },
@ -50,32 +51,30 @@ const MusicList = () => {
const [isRefreshing, setIsRefreshing] = useState(false);
const [sortTypes, setSortTypes] = useState<any[]>([]);
const [musicFolder, setMusicFolder] = useState({ loaded: false, id: undefined });
const musicFilterPickerContainerRef = useRef(null);
const [currentQueryKey, setCurrentQueryKey] = useState<any>(['musicList']);
const listRef = useRef<any>();
const { listScroll } = useListScroll(listRef);
useEffect(() => {
if (folder.applied.music) {
setMusicFolder({ loaded: true, id: folder.musicFolder });
} else {
setMusicFolder({ loaded: true, id: undefined });
}
}, [folder.applied.music, folder.musicFolder]);
useEffect(() => {
setCurrentQueryKey([
'musicList',
view.music.filter,
view.music.pagination.activePage,
musicFolder.id,
]);
}, [
folder.applied.music,
folder.musicFolder,
musicFolder.id,
view.music.filter,
view.music.pagination.activePage,
]);
}, [musicFolder.id, view.music.filter, view.music.pagination.activePage]);
const { isLoading, isError, data: musicData, error }: any = useQuery(
const { isLoading, isError, data: songs, error }: any = useQuery(
currentQueryKey,
() =>
view.music.filter === 'random' || view.music.pagination.recordsPerPage !== 0
@ -116,32 +115,52 @@ const MusicList = () => {
cacheTime: view.music.pagination.recordsPerPage !== 0 ? 600000 : Infinity,
staleTime: view.music.pagination.recordsPerPage !== 0 ? 600000 : Infinity,
enabled: currentQueryKey !== ['musicList'] && musicFolder.loaded,
onSuccess: (e) => {
dispatch(
setPagination({
listType: Item.Music,
data: {
pages: Math.floor(e.totalRecordCount / view.music.pagination.recordsPerPage) + 1,
},
})
);
},
}
);
const searchedData = useSearchQuery(misc.searchQuery, musicData?.data, [
const searchedData = useSearchQuery(misc.searchQuery, songs?.data, [
'title',
'artist',
'genre',
'year',
]);
const { sortedData } = useColumnSort(musicData?.data, Item.Album, view.music.sort);
const { sortedData } = useColumnSort(songs?.data, Item.Album, view.music.sort);
useEffect(() => {
setSortTypes(MUSIC_SORT_TYPES);
}, []);
useEffect(() => {
if (songs?.data && sortedData?.length) {
const pages =
Math.floor(
(view.music.pagination.serverSide && config.serverType === Server.Jellyfin
? songs?.totalRecordCount
: sortedData?.length) / view.music.pagination.recordsPerPage
) + 1;
if (pages && view.music.pagination.pages !== pages) {
dispatch(
setPagination({
listType: Item.Music,
data: {
pages,
},
})
);
}
}
}, [
songs,
config.serverType,
dispatch,
sortedData?.length,
view.music.pagination.serverSide,
view.music.pagination.recordsPerPage,
view.music.pagination.pages,
]);
let timeout: any = null;
const handleRowClick = (e: any, rowData: any, tableData: any) => {
if (timeout === null) {
@ -165,7 +184,7 @@ const MusicList = () => {
dispatch(clearSelected());
dispatch(
setPlayQueueByRowClick({
entries: musicData.data,
entries: songs.data,
currentIndex: rowData.rowIndex,
currentSongId: rowData.id,
uniqueSongId: rowData.uniqueId,
@ -244,7 +263,7 @@ const MusicList = () => {
<>
{t('Songs')}{' '}
<StyledTag style={{ verticalAlign: 'middle', cursor: 'default' }}>
{musicData?.totalRecordCount || '...'}
{songs?.totalRecordCount || '...'}
</StyledTag>
</>
}
@ -286,6 +305,7 @@ const MusicList = () => {
{isError && <div>Error: {error}</div>}
{!isError && (
<ListViewType
ref={listRef}
data={misc.searchQuery !== '' ? searchedData : sortedData}
tableColumns={config.lookAndFeel.listView.music.columns}
rowHeight={config.lookAndFeel.listView.music.rowHeight}
@ -348,6 +368,7 @@ const MusicList = () => {
},
})
);
listScroll(0);
},
}
}

12
src/components/loader/CenterLoader.tsx

@ -0,0 +1,12 @@
import React from 'react';
import { Loader } from 'rsuite';
const CenterLoader = () => {
return (
<div style={{ height: '100%' }}>
<Loader style={{ top: '50%', left: '50%', position: 'absolute' }} />
</div>
);
};
export default CenterLoader;

87
src/components/settings/ConfigPanels/LookAndFeelConfig.tsx

@ -5,7 +5,7 @@ import settings from 'electron-settings';
import { Nav, Icon, RadioGroup, Whisper } from 'rsuite';
import { WhisperInstance } from 'rsuite/lib/Whisper';
import { Trans, useTranslation } from 'react-i18next';
import { ConfigPanel } from '../styled';
import { ConfigOptionDescription, ConfigPanel } from '../styled';
import {
StyledInputPicker,
StyledNavItem,
@ -18,6 +18,7 @@ import {
StyledToggle,
StyledButton,
StyledPopover,
StyledCheckbox,
} from '../../shared/styled';
import ListViewConfig from './ListViewConfig';
import { Fonts } from '../Fonts';
@ -571,27 +572,81 @@ export const PaginationConfigPanel = ({ bordered }: any) => {
const { t } = useTranslation();
const dispatch = useAppDispatch();
const view = useAppSelector((state) => state.view);
const config = useAppSelector((state) => state.config);
return (
<ConfigPanel header={t('Pagination')} bordered={bordered}>
<ConfigOption
name={t('Items per page (Songs)')}
description={t(
<ConfigOptionDescription>
{t(
'The number of items that will be retrieved per page. Setting this to 0 will disable pagination.'
)}
</ConfigOptionDescription>
<ConfigOption
name={t('Items per page (Songs)')}
option={
<StyledInputNumber
defaultValue={view.music.pagination.recordsPerPage}
step={1}
min={0}
width={125}
onChange={(e: number) => {
dispatch(
setPagination({ listType: Item.Music, data: { recordsPerPage: Number(e) } })
);
settings.setSync('pagination.music', Number(e));
}}
/>
<>
<StyledInputNumber
defaultValue={view.music.pagination.recordsPerPage}
step={1}
min={0}
width={125}
onChange={(e: number) => {
dispatch(
setPagination({
listType: Item.Music,
data: { activePage: 1, recordsPerPage: Number(e) },
})
);
settings.setSync('pagination.music.recordsPerPage', Number(e));
}}
/>
{config.serverType === Server.Jellyfin && (
<StyledCheckbox
defaultChecked={settings.getSync('pagination.music.serverSide')}
checked={view.music.pagination.serverSide}
onChange={(_v: any, e: boolean) => {
settings.setSync('pagination.music.serverSide', e);
dispatch(setPagination({ listType: Item.Music, data: { serverSide: e } }));
}}
>
{t('Server-side')}
</StyledCheckbox>
)}
</>
}
/>
<ConfigOption
name={t('Items per page (Albums)')}
option={
<>
<StyledInputNumber
defaultValue={view.album.pagination.recordsPerPage}
step={1}
min={0}
width={125}
onChange={(e: number) => {
dispatch(
setPagination({
listType: Item.Album,
data: { activePage: 1, recordsPerPage: Number(e) },
})
);
settings.setSync('pagination.album.recordsPerPage', Number(e));
}}
/>
{config.serverType === Server.Jellyfin && (
<StyledCheckbox
defaultChecked={settings.getSync('pagination.album.serverSide')}
checked={view.album.pagination.serverSide}
onChange={(_v: any, e: boolean) => {
settings.setSync('pagination.album.serverSide', e);
dispatch(setPagination({ listType: Item.Album, data: { serverSide: e } }));
}}
>
{t('Server-side')}
</StyledCheckbox>
)}
</>
}
/>
</ConfigPanel>

11
src/components/shared/Paginator.tsx

@ -11,7 +11,16 @@ import {
const Paginator = ({ startIndex, endIndex, handleGoToButton, children, ...rest }: any) => {
return (
<>
<FlexboxGrid justify="space-between" style={{ paddingLeft: '10px', paddingTop: '10px' }}>
<FlexboxGrid
justify="space-between"
style={{
paddingLeft: '10px',
paddingTop: '15px',
width: rest.bottom && '100%',
bottom: '0px',
position: rest.bottom && 'absolute',
}}
>
<FlexboxGrid.Item style={{ alignSelf: 'center' }}>
{children}
<SecondaryTextWrapper subtitle="true">

16
src/components/shared/setDefaultSettings.ts

@ -131,8 +131,20 @@ const setDefaultSettings = (force: boolean) => {
settings.setSync('musicFolder.music', true);
}
if (force || !settings.hasSync('pagination.music')) {
settings.setSync('pagination.music', 50);
if (force || !settings.hasSync('pagination.music.recordsPerPage')) {
settings.setSync('pagination.music.recordsPerPage', 50);
}
if (force || !settings.hasSync('pagination.music.serverSide')) {
settings.setSync('pagination.music.serverSide', true);
}
if (force || !settings.hasSync('pagination.album.recordsPerPage')) {
settings.setSync('pagination.album.recordsPerPage', 50);
}
if (force || !settings.hasSync('pagination.album.serverSide')) {
settings.setSync('pagination.album.serverSide', false);
}
if (force || !settings.hasSync('volume')) {

116
src/components/viewtypes/GridViewType.tsx

@ -1,11 +1,13 @@
// Referenced from: https://codesandbox.io/s/jjkz5y130w?file=/index.js:700-703
import React, { createRef, useEffect, useMemo, useState } from 'react';
import React, { useEffect, useMemo, useState } from 'react';
import settings from 'electron-settings';
import { FixedSizeList as List } from 'react-window';
import AutoSizer from 'react-virtualized-auto-sizer';
import Card from '../card/Card';
import 'react-virtualized/styles.css';
import { useAppSelector } from '../../redux/hooks';
import Paginator from '../shared/Paginator';
import CenterLoader from '../loader/CenterLoader';
const GridCard = ({ data, index, style }: any) => {
const { cardHeight, cardWidth, columnCount, gapSize, itemCount } = data;
@ -93,14 +95,13 @@ function ListWrapper({
musicFolderId,
initialScrollOffset,
onScroll,
refresh,
gridRef,
}: any) {
const cardHeight = size + 55;
const cardWidth = size;
// How many cards can we show per row, given the current width?
const columnCount = Math.floor((width - gapSize + 3) / (cardWidth + gapSize + 2));
const rowCount = Math.ceil(itemCount / columnCount);
const gridRef = createRef<any>();
const itemData = useMemo(
() => ({
@ -141,28 +142,25 @@ function ListWrapper({
]
);
useEffect(() => {
if (refresh) {
gridRef.current.scrollTo(0);
}
}, [gridRef, refresh]);
return (
<List
ref={gridRef}
className="List"
height={height}
itemCount={rowCount}
itemSize={cardHeight + gapSize}
width={width}
itemData={itemData}
initialScrollOffset={initialScrollOffset || 0}
onScroll={({ scrollOffset }) => {
onScroll(scrollOffset);
}}
>
{GridCard}
</List>
<>
<List
ref={gridRef}
className="List"
height={height}
itemCount={rowCount}
itemSize={cardHeight + gapSize}
width={width}
itemData={itemData}
initialScrollOffset={initialScrollOffset || 0}
onScroll={({ scrollOffset }) => {
onScroll(scrollOffset);
}}
overscanCount={4}
>
{GridCard}
</List>
</>
);
}
@ -176,7 +174,9 @@ const GridViewType = ({
handleFavorite,
initialScrollOffset,
onScroll,
refresh,
paginationProps,
loading,
gridRef,
}: any) => {
const cacheImages = Boolean(settings.getSync('cacheImages'));
const misc = useAppSelector((state) => state.misc);
@ -191,30 +191,48 @@ const GridViewType = ({
}, [folder]);
return (
<AutoSizer>
{({ height, width }: any) => (
<ListWrapper
height={height}
itemCount={data?.length}
width={width}
data={data}
cardTitle={cardTitle}
cardSubtitle={cardSubtitle}
playClick={playClick}
size={size}
gapSize={config.lookAndFeel.gridView.gapSize}
alignment={config.lookAndFeel.gridView.alignment}
cacheType={cacheType}
cacheImages={cacheImages}
cachePath={misc.imageCachePath}
handleFavorite={handleFavorite}
musicFolderId={musicFolder}
initialScrollOffset={initialScrollOffset}
onScroll={onScroll || (() => {})}
refresh={refresh}
/>
)}
</AutoSizer>
<>
<AutoSizer>
{({ height, width }: any) => (
<>
{data?.length ? (
<ListWrapper
height={
height - (paginationProps && paginationProps?.recordsPerPage !== 0 ? 45 : 0)
}
itemCount={data?.length}
width={width}
data={data}
cardTitle={cardTitle}
cardSubtitle={cardSubtitle}
playClick={playClick}
size={size}
gapSize={config.lookAndFeel.gridView.gapSize}
alignment={config.lookAndFeel.gridView.alignment}
cacheType={cacheType}
cacheImages={cacheImages}
cachePath={misc.imageCachePath}
handleFavorite={handleFavorite}
musicFolderId={musicFolder}
initialScrollOffset={initialScrollOffset}
onScroll={onScroll || (() => {})}
paginationProps={paginationProps}
loading={loading}
gridRef={gridRef}
/>
) : (
<CenterLoader />
)}
{paginationProps && paginationProps?.recordsPerPage !== 0 && (
<div style={{ height: data?.length ? '45px' : height, width, position: 'relative' }}>
<Paginator {...paginationProps} bottom="true" />
</div>
)}
</>
)}
</AutoSizer>
</>
);
};

19
src/components/viewtypes/ListViewTable.tsx

@ -57,11 +57,11 @@ import {
setPageSort,
setPlaybackFilter,
} from '../../redux/configSlice';
import { setActive } from '../../redux/albumSlice';
import { setStatus } from '../../redux/playerSlice';
import { GenericItem } from '../../types';
import { GenericItem, Item } from '../../types';
import { CoverArtWrapper } from '../layout/styled';
import Paginator from '../shared/Paginator';
import { setFilter, setPagination } from '../../redux/viewSlice';
const StyledTable = styled(Table)<{ rowHeight: number; $isDragging: boolean }>`
.rs-table-row.selected {
@ -132,7 +132,6 @@ const ListViewTable = ({
const configState = useAppSelector((state) => state.config);
const playQueue = useAppSelector((state) => state.playQueue);
const multiSelect = useAppSelector((state) => state.multiSelect);
const album = useAppSelector((state) => state.album);
const [sortColumn, setSortColumn] = useState<any>();
const [sortType, setSortType] = useState<any>();
const [sortedData, setSortedData] = useState(data);
@ -682,7 +681,7 @@ const ListViewTable = ({
effect="opacity"
width={rowHeight - 10}
height={rowHeight - 10}
visibleByDefault={cacheImages.enabled}
visibleByDefault
afterLoad={() => {
if (cacheImages.enabled) {
cacheImage(
@ -862,7 +861,7 @@ const ListViewTable = ({
effect="opacity"
width={rowHeight - 10}
height={rowHeight - 10}
visibleByDefault={cacheImages.enabled}
visibleByDefault
afterLoad={() => {
if (cacheImages.enabled) {
cacheImage(
@ -955,11 +954,15 @@ const ListViewTable = ({
onClick={(e: any) => {
if (!e.ctrlKey && !e.shiftKey) {
dispatch(
setActive({
...album.active,
filter: genre.title,
setFilter({ listType: Item.Album, data: genre.title })
);
dispatch(
setPagination({
listType: Item.Album,
data: { activePage: 1 },
})
);
localStorage.setItem('scroll_list_albumList', '0');
localStorage.setItem('scroll_grid_albumList', '0');
setTimeout(() => {

2
src/hooks/useAdvancedFilter.ts

@ -1,6 +1,6 @@
import { useState, useEffect } from 'react';
import _ from 'lodash';
import { AdvancedFilters } from '../redux/albumSlice';
import { AdvancedFilters } from '../redux/viewSlice';
const useAdvancedFilter = (data: any[], filters: AdvancedFilters) => {
const [filteredData, setFilteredData] = useState<any[]>([]);

16
src/hooks/useGridScroll.ts

@ -0,0 +1,16 @@
import { useCallback } from 'react';
const useGridScroll = (ref: any) => {
const gridScroll = useCallback(
(position: number) => {
setTimeout(() => {
ref?.current?.scrollTo(position);
});
},
[ref]
);
return { gridScroll };
};
export default useGridScroll;

16
src/hooks/useListScroll.ts

@ -0,0 +1,16 @@
import { useCallback } from 'react';
const useListScroll = (ref: any) => {
const listScroll = useCallback(
(position: number) => {
setTimeout(() => {
ref?.current?.table?.current?.scrollTop(position);
});
},
[ref]
);
return { listScroll };
};
export default useListScroll;

2
src/i18n/locales/de.json

@ -148,6 +148,7 @@
"If your audio files are not playing properly or are not in a supported web streaming format, you will need to enable this (requires app restart).": "Wenn Audiodateien nicht richtig abgespielt werden oder nicht in einem unterstützten Webstreaming-Format vorliegen, aktiviere diese Funktion (Neustart der App erforderlich).",
"Images": "Bilder",
"Integrates with Discord's rich presence to display the currently playing song as your status.": "Integriert sich in Discords Rich Presence um den aktuell gespielten Song als Status anzuzeigen.",
"Items per page (Albums)": "Items per page (Albums)",
"Items per page (Songs)": "Elemente pro Seite (Songs)",
"Language": "Sprache",
"Latest Albums ": "Neuste Alben ",
@ -264,6 +265,7 @@
"Send player updates to your server. This is required by servers such as Jellyfin and Navidrome to track play counts and use external services such as Last.fm.": "Sende Player-Updates an den Server. Dies wird von Servern wie Jellyfin und Navidrome vorrausgesetzt, um die Wiedergaben zu zählen und externe Dienste wie Last.fm zu nutzen.",
"Server": "Server",
"Server type": "Server Art",
"Server-side": "Server-side",
"Session expired. Logging out.": "Sitzung abgelaufen. Abmeldung läuft.",
"Set rating": "Bewertung festlegen",
"Sets a dynamic background based on the currently playing song.": "Legt einen dynamischen Hintergrund fest, der auf dem aktuell gespielten Lied basiert.",

2
src/i18n/locales/en.json

@ -148,6 +148,7 @@
"If your audio files are not playing properly or are not in a supported web streaming format, you will need to enable this (requires app restart).": "If your audio files are not playing properly or are not in a supported web streaming format, you will need to enable this (requires app restart).",
"Images": "Images",
"Integrates with Discord's rich presence to display the currently playing song as your status.": "Integrates with Discord's rich presence to display the currently playing song as your status.",
"Items per page (Albums)": "Items per page (Albums)",
"Items per page (Songs)": "Items per page (Songs)",
"Language": "Language",
"Latest Albums ": "Latest Albums ",
@ -264,6 +265,7 @@
"Send player updates to your server. This is required by servers such as Jellyfin and Navidrome to track play counts and use external services such as Last.fm.": "Send player updates to your server. This is required by servers such as Jellyfin and Navidrome to track play counts and use external services such as Last.fm.",
"Server": "Server",
"Server type": "Server type",
"Server-side": "Server-side",
"Session expired. Logging out.": "Session expired. Logging out.",
"Set rating": "Set rating",
"Sets a dynamic background based on the currently playing song.": "Sets a dynamic background based on the currently playing song.",

116
src/redux/albumSlice.ts

@ -1,116 +0,0 @@
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
import settings from 'electron-settings';
import { mockSettings } from '../shared/mockSettings';
import { Sort } from '../types';
const parsedSettings = process.env.NODE_ENV === 'test' ? mockSettings : settings.getSync();
export interface AlbumPage {
active: {
filter: string;
};
advancedFilters: AdvancedFilters;
}
export interface AdvancedFilters {
enabled: boolean;
nav: 'filters' | 'sort';
properties: {
starred: boolean;
genre: {
list: any[];
type: 'and' | 'or';
};
artist: {
list: any[];
type: 'and' | 'or';
};
year: {
from: number;
to: number;
};
sort: Sort;
};
}
const initialState: AlbumPage = {
active: {
filter:
parsedSettings.serverType === 'jellyfin' &&
['frequent', 'recent'].includes(String(parsedSettings.albumSortDefault))
? 'random'
: String(parsedSettings.albumSortDefault),
},
advancedFilters: {
enabled: false,
nav: 'filters',
properties: {
starred: false,
genre: {
list: [],
type: 'and',
},
artist: {
list: [],
type: 'and',
},
year: {
from: 0,
to: 0,
},
sort: {
column: undefined,
type: 'asc',
},
},
},
};
const albumSlice = createSlice({
name: 'album',
initialState,
reducers: {
setActive: (state, action: PayloadAction<any>) => {
state.active = action.payload;
},
setAdvancedFilters: (
state,
action: PayloadAction<{
filter: 'enabled' | 'starred' | 'genre' | 'artist' | 'year' | 'sort' | 'nav';
value: any;
}>
) => {
if (action.payload.filter === 'enabled') {
state.advancedFilters.enabled = action.payload.value;
}
if (action.payload.filter === 'starred') {
state.advancedFilters.properties.starred = action.payload.value;
}
if (action.payload.filter === 'genre') {
state.advancedFilters.properties.genre = action.payload.value;
}
if (action.payload.filter === 'artist') {
state.advancedFilters.properties.artist = action.payload.value;
}
if (action.payload.filter === 'year') {
state.advancedFilters.properties.year = action.payload.value;
}
if (action.payload.filter === 'sort') {
state.advancedFilters.properties.sort = action.payload.value;
}
if (action.payload.filter === 'nav') {
state.advancedFilters.nav = action.payload.value;
}
},
},
});
export const { setActive, setAdvancedFilters } = albumSlice.actions;
export default albumSlice.reducer;

2
src/redux/store.ts

@ -8,7 +8,6 @@ import playlistReducer from './playlistSlice';
import folderReducer from './folderSlice';
import configReducer from './configSlice';
import favoriteReducer from './favoriteSlice';
import albumReducer from './albumSlice';
import artistReducer from './artistSlice';
import viewReducer from './viewSlice';
@ -22,7 +21,6 @@ export const store = configureStore<PlayQueue | any>({
folder: folderReducer,
config: configReducer,
favorite: favoriteReducer,
album: albumReducer,
artist: artistReducer,
view: viewReducer,
},

128
src/redux/viewSlice.ts

@ -5,7 +5,33 @@ import { Item, Sort, Pagination } from '../types';
const parsedSettings: any = process.env.NODE_ENV === 'test' ? mockSettings : settings.getSync();
export interface AdvancedFilters {
enabled: boolean;
nav: 'filters' | 'sort';
properties: {
starred: boolean;
genre: {
list: any[];
type: 'and' | 'or';
};
artist: {
list: any[];
type: 'and' | 'or';
};
year: {
from: number;
to: number;
};
};
}
export interface View {
album: {
filter: string;
sort: Sort;
advancedFilters: AdvancedFilters;
pagination: Pagination;
};
music: {
filter: string;
sort: Sort;
@ -13,7 +39,43 @@ export interface View {
};
}
const initialState: any = {
const initialState: View = {
album: {
filter:
parsedSettings.serverType === 'jellyfin' &&
['frequent', 'recent'].includes(String(parsedSettings.albumSortDefault))
? 'random'
: String(parsedSettings.albumSortDefault),
sort: {
column: undefined,
type: 'asc',
},
advancedFilters: {
enabled: false,
nav: 'filters',
properties: {
starred: false,
genre: {
list: [],
type: 'and',
},
artist: {
list: [],
type: 'and',
},
year: {
from: 0,
to: 0,
},
},
},
pagination: {
serverSide: parsedSettings.pagination.album.serverSide,
recordsPerPage: parsedSettings.pagination.album.recordsPerPage,
activePage: 1,
pages: 1,
},
},
music: {
filter: String(parsedSettings.musicSortDefault) || 'random',
sort: {
@ -21,7 +83,8 @@ const initialState: any = {
type: 'asc',
},
pagination: {
recordsPerPage: parsedSettings.pagination.music,
serverSide: parsedSettings.pagination.music.serverSide,
recordsPerPage: parsedSettings.pagination.music.recordsPerPage,
activePage: 1,
pages: 1,
},
@ -33,18 +96,75 @@ const viewSlice = createSlice({
initialState,
reducers: {
setFilter: (state, action: PayloadAction<{ listType: Item; data: any }>) => {
if (action.payload.listType === Item.Album) {
state.album.filter = action.payload.data;
}
if (action.payload.listType === Item.Music) {
state.music.filter = action.payload.data;
}
},
setAdvancedFilters: (
state,
action: PayloadAction<{
listType: Item;
filter: 'enabled' | 'starred' | 'genre' | 'artist' | 'year' | 'nav';
value: any;
}>
) => {
if (action.payload.listType === Item.Album) {
if (action.payload.filter === 'enabled') {
state.album.advancedFilters.enabled = action.payload.value;
}
if (action.payload.filter === 'starred') {
state.album.advancedFilters.properties.starred = action.payload.value;
}
if (action.payload.filter === 'genre') {
state.album.advancedFilters.properties.genre = action.payload.value;
}
if (action.payload.filter === 'artist') {
state.album.advancedFilters.properties.artist = action.payload.value;
}
if (action.payload.filter === 'year') {
state.album.advancedFilters.properties.year = action.payload.value;
}
if (action.payload.filter === 'nav') {
state.album.advancedFilters.nav = action.payload.value;
}
}
},
setColumnSort: (state, action: PayloadAction<{ listType: Item; data: any }>) => {
if (action.payload.listType === Item.Album) {
state.album.sort = action.payload.data;
}
},
setPagination: (
state,
action: PayloadAction<{
listType: Item;
data: { enabled?: boolean; activePage?: number; pages?: number; recordsPerPage?: number };
data: {
enabled?: boolean;
activePage?: number;
pages?: number;
recordsPerPage?: number;
serverSide?: boolean;
};
}>
) => {
if (action.payload.listType === Item.Album) {
state.album.pagination = {
...state.album.pagination,
...action.payload.data,
};
}
if (action.payload.listType === Item.Music) {
state.music.pagination = {
...state.music.pagination,
@ -55,5 +175,5 @@ const viewSlice = createSlice({
},
});
export const { setFilter, setPagination } = viewSlice.actions;
export const { setFilter, setAdvancedFilters, setColumnSort, setPagination } = viewSlice.actions;
export default viewSlice.reducer;

9
src/shared/mockSettings.ts

@ -26,7 +26,14 @@ export const mockSettings = {
scrobble: false,
transcode: false,
pagination: {
music: 100,
music: {
recordsPerPage: 50,
serverSide: true,
},
album: {
recordsPerPage: 50,
serverSide: false,
},
},
playbackFilters: [
{

1
src/types.ts

@ -189,5 +189,6 @@ export interface Sort {
export interface Pagination {
pages?: number;
activePage?: number;
serverSide?: boolean;
recordsPerPage: number;
}

Loading…
Cancel
Save