diff --git a/src/apps/experimental/components/library/filter/FilterButton.tsx b/src/apps/experimental/components/library/filter/FilterButton.tsx new file mode 100644 index 0000000000..4831180e64 --- /dev/null +++ b/src/apps/experimental/components/library/filter/FilterButton.tsx @@ -0,0 +1,457 @@ +import { BaseItemKind } from '@jellyfin/sdk/lib/generated-client/models/base-item-kind'; +import React, { FC, useCallback } from 'react'; +import ArrowForwardIosSharpIcon from '@mui/icons-material/ArrowForwardIosSharp'; +import Box from '@mui/material/Box'; +import FilterListIcon from '@mui/icons-material/FilterList'; +import Popover from '@mui/material/Popover'; +import MuiAccordion, { AccordionProps } from '@mui/material/Accordion'; +import MuiAccordionDetails from '@mui/material/AccordionDetails'; +import MuiAccordionSummary, { + AccordionSummaryProps +} from '@mui/material/AccordionSummary'; +import IconButton from '@mui/material/IconButton'; +import { styled } from '@mui/material/styles'; +import Typography from '@mui/material/Typography'; + +import { useGetQueryFiltersLegacy, useGetStudios } from 'hooks/useFetchItems'; +import globalize from 'scripts/globalize'; + +import FiltersFeatures from './FiltersFeatures'; +import FiltersGenres from './FiltersGenres'; +import FiltersOfficialRatings from './FiltersOfficialRatings'; +import FiltersEpisodesStatus from './FiltersEpisodesStatus'; +import FiltersSeriesStatus from './FiltersSeriesStatus'; +import FiltersStatus from './FiltersStatus'; +import FiltersStudios from './FiltersStudios'; +import FiltersTags from './FiltersTags'; +import FiltersVideoTypes from './FiltersVideoTypes'; +import FiltersYears from './FiltersYears'; + +import { LibraryViewSettings } from 'types/library'; +import { LibraryTab } from 'types/libraryTab'; + +const Accordion = styled((props: AccordionProps) => ( + +))(({ theme }) => ({ + border: `1px solid ${theme.palette.divider}`, + '&:not(:last-child)': { + borderBottom: 0 + }, + '&:before': { + display: 'none' + } +})); + +const AccordionSummary = styled((props: AccordionSummaryProps) => ( + } + {...props} + /> +))(({ theme }) => ({ + backgroundColor: + theme.palette.mode === 'dark' ? + 'rgba(255, 255, 255, .05)' : + 'rgba(0, 0, 0, .03)', + '& .MuiAccordionSummary-expandIconWrapper.Mui-expanded': { + transform: 'rotate(90deg)' + }, + '& .MuiAccordionSummary-content': { + marginLeft: theme.spacing(1) + } +})); + +const AccordionDetails = styled(MuiAccordionDetails)(({ theme }) => ({ + padding: theme.spacing(2), + borderTop: '1px solid rgba(0, 0, 0, .125)' +})); + +interface FilterButtonProps { + parentId: string | null | undefined; + itemType: BaseItemKind; + viewType: LibraryTab; + libraryViewSettings: LibraryViewSettings; + setLibraryViewSettings: React.Dispatch< + React.SetStateAction + >; +} + +const FilterButton: FC = ({ + parentId, + itemType, + viewType, + libraryViewSettings, + setLibraryViewSettings +}) => { + const [anchorEl, setAnchorEl] = React.useState(null); + const [expanded, setExpanded] = React.useState(false); + const open = Boolean(anchorEl); + const id = open ? 'filter-popover' : undefined; + + const { data } = useGetQueryFiltersLegacy(parentId, itemType); + const { data: studios } = useGetStudios(parentId, itemType); + + const handleChange = + (panel: string) => + (event: React.SyntheticEvent, newExpanded: boolean) => { + setExpanded(newExpanded ? panel : false); + }; + + const handleClick = useCallback((event: React.MouseEvent) => { + setAnchorEl(event.currentTarget); + }, []); + + const handleClose = useCallback(() => { + setAnchorEl(null); + }, []); + + const isFiltersLegacyEnabled = () => { + return ( + viewType === LibraryTab.Movies + || viewType === LibraryTab.Series + || viewType === LibraryTab.Albums + || viewType === LibraryTab.AlbumArtists + || viewType === LibraryTab.Artists + || viewType === LibraryTab.Songs + || viewType === LibraryTab.Episodes + ); + }; + + const isFiltersStudiosEnabled = () => { + return ( + viewType === LibraryTab.Movies + || viewType === LibraryTab.Series + ); + }; + + const isFiltersFeaturesEnabled = () => { + return ( + viewType === LibraryTab.Movies + || viewType === LibraryTab.Series + || viewType === LibraryTab.Episodes + ); + }; + + const isFiltersVideoTypesEnabled = () => { + return ( + viewType === LibraryTab.Movies + || viewType === LibraryTab.Episodes + ); + }; + + const isFiltersSeriesStatusEnabled = () => { + return viewType === LibraryTab.Series; + }; + + const isFiltersEpisodesStatusEnabled = () => { + return viewType === LibraryTab.Episodes; + }; + + return ( + + + + + + + + + {globalize.translate('Filters')} + + + + + + + {isFiltersSeriesStatusEnabled() && ( + <> + + + + {globalize.translate('HeaderSeriesStatus')} + + + + + + + + )} + {isFiltersEpisodesStatusEnabled() && ( + <> + + + + {globalize.translate('HeaderEpisodesStatus')} + + + + + + + + )} + {isFiltersFeaturesEnabled() && ( + <> + + + + {globalize.translate('Features')} + + + + + + + + )} + + {isFiltersVideoTypesEnabled() && ( + <> + + + + {globalize.translate('HeaderVideoType')} + + + + + + + + )} + + {isFiltersLegacyEnabled() && ( + <> + {data?.Genres && data?.Genres?.length > 0 && ( + + + + {globalize.translate('Genres')} + + + + + + + )} + + {data?.OfficialRatings + && data?.OfficialRatings?.length > 0 && ( + + + + {globalize.translate( + 'HeaderParentalRatings' + )} + + + + + + + )} + + {data?.Tags && data?.Tags.length > 0 && ( + + + + {globalize.translate('Tags')} + + + + + + + )} + + {data?.Years && data?.Years?.length > 0 && ( + + + + {globalize.translate('HeaderYears')} + + + + + + + )} + + )} + {isFiltersStudiosEnabled() && ( + <> + + + + {globalize.translate('Studios')} + + + + + + + + )} + + + ); +}; + +export default FilterButton; diff --git a/src/apps/experimental/components/library/filter/FiltersEpisodesStatus.tsx b/src/apps/experimental/components/library/filter/FiltersEpisodesStatus.tsx new file mode 100644 index 0000000000..8360b2b5a8 --- /dev/null +++ b/src/apps/experimental/components/library/filter/FiltersEpisodesStatus.tsx @@ -0,0 +1,75 @@ +import React, { FC, useCallback } from 'react'; +import FormGroup from '@mui/material/FormGroup'; +import FormControlLabel from '@mui/material/FormControlLabel'; +import Checkbox from '@mui/material/Checkbox'; +import globalize from 'scripts/globalize'; +import { LibraryViewSettings } from 'types/library'; + +const episodesStatusOptions = [ + { label: 'OptionSpecialEpisode', value: 'ParentIndexNumber' }, + { label: 'OptionMissingEpisode', value: 'IsMissing' }, + { label: 'OptionUnairedEpisode', value: 'IsUnaired' } +]; + +interface FiltersEpisodesStatusProps { + libraryViewSettings: LibraryViewSettings; + setLibraryViewSettings: React.Dispatch>; +} + +const FiltersEpisodesStatus: FC = ({ + libraryViewSettings, + setLibraryViewSettings +}) => { + const onFiltersEpisodesStatusChange = useCallback( + (event: React.ChangeEvent) => { + event.preventDefault(); + const value = String(event.target.value); + const existingValue = libraryViewSettings?.Filters?.EpisodesStatus; + + if (existingValue?.includes(value)) { + const newValue = existingValue?.filter( + (prevState: string) => prevState !== value + ); + setLibraryViewSettings((prevState) => ({ + ...prevState, + StartIndex: 0, + Filters: { ...prevState.Filters, EpisodesStatus: newValue } + })); + } else { + setLibraryViewSettings((prevState) => ({ + ...prevState, + StartIndex: 0, + Filters: { + ...prevState.Filters, + EpisodesStatus: [...(existingValue ?? []), value] + } + })); + } + }, + [setLibraryViewSettings, libraryViewSettings?.Filters?.EpisodesStatus] + ); + + return ( + + {episodesStatusOptions.map((filter) => ( + + } + label={globalize.translate(filter.label)} + /> + ))} + + ); +}; + +export default FiltersEpisodesStatus; diff --git a/src/apps/experimental/components/library/filter/FiltersFeatures.tsx b/src/apps/experimental/components/library/filter/FiltersFeatures.tsx new file mode 100644 index 0000000000..5140350460 --- /dev/null +++ b/src/apps/experimental/components/library/filter/FiltersFeatures.tsx @@ -0,0 +1,81 @@ +import React, { FC, useCallback } from 'react'; +import FormGroup from '@mui/material/FormGroup'; +import FormControlLabel from '@mui/material/FormControlLabel'; +import Checkbox from '@mui/material/Checkbox'; +import globalize from 'scripts/globalize'; +import { LibraryViewSettings } from 'types/library'; + +const featuresOptions = [ + { label: 'Subtitles', value: 'HasSubtitles' }, + { label: 'Trailers', value: 'HasTrailer' }, + { label: 'Extras', value: 'HasSpecialFeature' }, + { label: 'ThemeSongs', value: 'HasThemeSong' }, + { label: 'ThemeVideos', value: 'HasThemeVideo' } +]; + +interface FiltersFeaturesProps { + libraryViewSettings: LibraryViewSettings; + setLibraryViewSettings: React.Dispatch< + React.SetStateAction + >; +} + +const FiltersFeatures: FC = ({ + libraryViewSettings, + setLibraryViewSettings +}) => { + const onFiltersFeaturesChange = useCallback( + (event: React.ChangeEvent) => { + event.preventDefault(); + const value = String(event.target.value); + const existingValue = + libraryViewSettings?.Filters?.Features; + + if (existingValue?.includes(value)) { + const newValue = existingValue?.filter( + (prevState: string) => prevState !== value + ); + setLibraryViewSettings((prevState) => ({ + ...prevState, + StartIndex: 0, + Filters: { ...prevState.Filters, Features: newValue } + })); + } else { + setLibraryViewSettings((prevState) => ({ + ...prevState, + StartIndex: 0, + Filters: { + ...prevState.Filters, + Features: [...(existingValue ?? []), value] + } + })); + } + }, + [setLibraryViewSettings, libraryViewSettings?.Filters?.Features] + ); + + return ( + + {featuresOptions + .map((filter) => ( + + } + label={globalize.translate(filter.label)} + /> + ))} + + ); +}; + +export default FiltersFeatures; diff --git a/src/apps/experimental/components/library/filter/FiltersGenres.tsx b/src/apps/experimental/components/library/filter/FiltersGenres.tsx new file mode 100644 index 0000000000..bd76f41e55 --- /dev/null +++ b/src/apps/experimental/components/library/filter/FiltersGenres.tsx @@ -0,0 +1,71 @@ +import type { QueryFiltersLegacy } from '@jellyfin/sdk/lib/generated-client'; +import React, { FC, useCallback } from 'react'; +import FormGroup from '@mui/material/FormGroup'; +import FormControlLabel from '@mui/material/FormControlLabel'; +import Checkbox from '@mui/material/Checkbox'; +import { LibraryViewSettings } from 'types/library'; + +interface FiltersGenresProps { + filtes?: QueryFiltersLegacy; + libraryViewSettings: LibraryViewSettings; + setLibraryViewSettings: React.Dispatch>; +} + +const FiltersGenres: FC = ({ + filtes, + libraryViewSettings, + setLibraryViewSettings +}) => { + const onFiltersGenresChange = useCallback( + (event: React.ChangeEvent) => { + event.preventDefault(); + const value = String(event.target.value); + const existingValue = libraryViewSettings?.Filters?.Genres; + + if (existingValue?.includes(value)) { + const newValue = existingValue?.filter( + (prevState: string) => prevState !== value + ); + setLibraryViewSettings((prevState) => ({ + ...prevState, + StartIndex: 0, + Filters: { ...prevState.Filters, Genres: newValue } + })); + } else { + setLibraryViewSettings((prevState) => ({ + ...prevState, + StartIndex: 0, + Filters: { + ...prevState.Filters, + Genres: [...(existingValue ?? []), value] + } + })); + } + }, + [setLibraryViewSettings, libraryViewSettings?.Filters?.Genres] + ); + + return ( + + {filtes?.Genres?.map((filter) => ( + + } + label={filter} + /> + ))} + + ); +}; + +export default FiltersGenres; diff --git a/src/apps/experimental/components/library/filter/FiltersOfficialRatings.tsx b/src/apps/experimental/components/library/filter/FiltersOfficialRatings.tsx new file mode 100644 index 0000000000..0476f8688d --- /dev/null +++ b/src/apps/experimental/components/library/filter/FiltersOfficialRatings.tsx @@ -0,0 +1,71 @@ +import type { QueryFiltersLegacy } from '@jellyfin/sdk/lib/generated-client'; +import React, { FC, useCallback } from 'react'; +import FormGroup from '@mui/material/FormGroup'; +import FormControlLabel from '@mui/material/FormControlLabel'; +import Checkbox from '@mui/material/Checkbox'; +import { LibraryViewSettings } from 'types/library'; + +interface FiltersOfficialRatingsProps { + filtes?: QueryFiltersLegacy; + libraryViewSettings: LibraryViewSettings; + setLibraryViewSettings: React.Dispatch>; +} + +const FiltersOfficialRatings: FC = ({ + filtes, + libraryViewSettings, + setLibraryViewSettings +}) => { + const onFiltersOfficialRatingsChange = useCallback( + (event: React.ChangeEvent) => { + event.preventDefault(); + const value = String(event.target.value); + const existingValue = libraryViewSettings?.Filters?.OfficialRatings; + + if (existingValue?.includes(value)) { + const newValue = existingValue?.filter( + (prevState: string) => prevState !== value + ); + setLibraryViewSettings((prevState) => ({ + ...prevState, + StartIndex: 0, + Filters: { ...prevState.Filters, OfficialRatings: newValue } + })); + } else { + setLibraryViewSettings((prevState) => ({ + ...prevState, + StartIndex: 0, + Filters: { + ...prevState.Filters, + OfficialRatings: [...(existingValue ?? []), value] + } + })); + } + }, + [setLibraryViewSettings, libraryViewSettings?.Filters?.OfficialRatings] + ); + + return ( + + {filtes?.OfficialRatings?.map((filter) => ( + + } + label={filter} + /> + ))} + + ); +}; + +export default FiltersOfficialRatings; diff --git a/src/apps/experimental/components/library/filter/FiltersSeriesStatus.tsx b/src/apps/experimental/components/library/filter/FiltersSeriesStatus.tsx new file mode 100644 index 0000000000..3420c8c7a0 --- /dev/null +++ b/src/apps/experimental/components/library/filter/FiltersSeriesStatus.tsx @@ -0,0 +1,74 @@ +import React, { FC, useCallback } from 'react'; +import FormGroup from '@mui/material/FormGroup'; +import FormControlLabel from '@mui/material/FormControlLabel'; +import Checkbox from '@mui/material/Checkbox'; +import globalize from 'scripts/globalize'; +import { LibraryViewSettings } from 'types/library'; +import { SeriesStatus } from '@jellyfin/sdk/lib/generated-client'; + +const statusFiltersOptions = [ + { label: 'Continuing', value: SeriesStatus.Continuing }, + { label: 'Ended', value: SeriesStatus.Ended }, + { label: 'Unreleased', value: SeriesStatus.Unreleased } +]; + +interface FiltersSeriesStatusProps { + libraryViewSettings: LibraryViewSettings; + setLibraryViewSettings: React.Dispatch>; +} + +const FiltersSeriesStatus: FC = ({ + libraryViewSettings, + setLibraryViewSettings +}) => { + const onFiltersSeriesStatusChange = useCallback( + (event: React.ChangeEvent) => { + event.preventDefault(); + const value = event.target.value as SeriesStatus; + const existingValue = libraryViewSettings?.Filters?.SeriesStatus; + + if (existingValue?.includes(value)) { + const newValue = existingValue?.filter( + (prevState: SeriesStatus) => prevState !== value + ); + setLibraryViewSettings((prevState) => ({ + ...prevState, + StartIndex: 0, + Filters: { ...prevState.Filters, SeriesStatus: newValue } + })); + } else { + setLibraryViewSettings((prevState) => ({ + ...prevState, + StartIndex: 0, + Filters: { + ...prevState.Filters, + SeriesStatus: [...(existingValue ?? []), value] + } + })); + } + }, + [setLibraryViewSettings, libraryViewSettings?.Filters?.SeriesStatus] + ); + + return ( + + {statusFiltersOptions.map((filter) => ( + + } + label={globalize.translate(filter.label)} + /> + ))} + + ); +}; + +export default FiltersSeriesStatus; diff --git a/src/apps/experimental/components/library/filter/FiltersStatus.tsx b/src/apps/experimental/components/library/filter/FiltersStatus.tsx new file mode 100644 index 0000000000..72e3139199 --- /dev/null +++ b/src/apps/experimental/components/library/filter/FiltersStatus.tsx @@ -0,0 +1,97 @@ +import React, { FC, useCallback } from 'react'; +import FormGroup from '@mui/material/FormGroup'; +import FormControlLabel from '@mui/material/FormControlLabel'; +import Checkbox from '@mui/material/Checkbox'; +import globalize from 'scripts/globalize'; +import { LibraryViewSettings } from 'types/library'; +import { ItemFilter } from '@jellyfin/sdk/lib/generated-client'; +import { LibraryTab } from 'types/libraryTab'; + +const statusFiltersOptions = [ + { label: 'Played', value: ItemFilter.IsPlayed }, + { label: 'Unplayed', value: ItemFilter.IsUnplayed }, + { label: 'Favorite', value: ItemFilter.IsFavorite }, + { label: 'ContinueWatching', value: ItemFilter.IsResumable } +]; + +interface FiltersStatusProps { + viewType: LibraryTab; + libraryViewSettings: LibraryViewSettings; + setLibraryViewSettings: React.Dispatch>; +} + +const FiltersStatus: FC = ({ + viewType, + libraryViewSettings, + setLibraryViewSettings +}) => { + const onFiltersStatusChange = useCallback( + (event: React.ChangeEvent) => { + event.preventDefault(); + const value = event.target.value as ItemFilter; + const existingValue = libraryViewSettings?.Filters?.Status; + + if (existingValue?.includes(value)) { + const newValue = existingValue?.filter( + (prevState: ItemFilter) => prevState !== value + ); + setLibraryViewSettings((prevState) => ({ + ...prevState, + StartIndex: 0, + Filters: { ...prevState.Filters, Status: newValue } + })); + } else { + setLibraryViewSettings((prevState) => ({ + ...prevState, + StartIndex: 0, + Filters: { + ...prevState.Filters, + Status: [...(existingValue ?? []), value] + } + })); + } + }, + [setLibraryViewSettings, libraryViewSettings?.Filters?.Status] + ); + + const getVisibleFiltersStatus = () => { + const visibleFiltersStatus: ItemFilter[] = [ItemFilter.IsFavorite]; + + if ( + viewType !== LibraryTab.Albums + && viewType !== LibraryTab.Artists + && viewType !== LibraryTab.AlbumArtists + && viewType !== LibraryTab.Songs + ) { + visibleFiltersStatus.push(ItemFilter.IsUnplayed); + visibleFiltersStatus.push(ItemFilter.IsPlayed); + visibleFiltersStatus.push(ItemFilter.IsResumable); + } + + return visibleFiltersStatus; + }; + + return ( + + {statusFiltersOptions + .filter((filter) => getVisibleFiltersStatus().includes(filter.value)) + .map((filter) => ( + + } + label={globalize.translate(filter.label)} + /> + ))} + + ); +}; + +export default FiltersStatus; diff --git a/src/apps/experimental/components/library/filter/FiltersStudios.tsx b/src/apps/experimental/components/library/filter/FiltersStudios.tsx new file mode 100644 index 0000000000..da2b80c3a9 --- /dev/null +++ b/src/apps/experimental/components/library/filter/FiltersStudios.tsx @@ -0,0 +1,71 @@ +import type { BaseItemDtoQueryResult } from '@jellyfin/sdk/lib/generated-client'; +import React, { FC, useCallback } from 'react'; +import FormGroup from '@mui/material/FormGroup'; +import FormControlLabel from '@mui/material/FormControlLabel'; +import Checkbox from '@mui/material/Checkbox'; +import { LibraryViewSettings } from 'types/library'; + +interface FiltersStudiosProps { + filtes?: BaseItemDtoQueryResult; + libraryViewSettings: LibraryViewSettings; + setLibraryViewSettings: React.Dispatch>; +} + +const FiltersStudios: FC = ({ + filtes, + libraryViewSettings, + setLibraryViewSettings +}) => { + const onFiltersStudiosChange = useCallback( + (event: React.ChangeEvent) => { + event.preventDefault(); + const value = String(event.target.value); + const existingValue = libraryViewSettings?.Filters?.StudioIds; + + if (existingValue?.includes(value)) { + const newValue = existingValue?.filter( + (prevState: string) => prevState !== value + ); + setLibraryViewSettings((prevState) => ({ + ...prevState, + StartIndex: 0, + Filters: { ...prevState.Filters, StudioIds: newValue } + })); + } else { + setLibraryViewSettings((prevState) => ({ + ...prevState, + StartIndex: 0, + Filters: { + ...prevState.Filters, + StudioIds: [...(existingValue ?? []), value] + } + })); + } + }, + [setLibraryViewSettings, libraryViewSettings?.Filters?.StudioIds] + ); + + return ( + + {filtes?.Items?.map((filter) => ( + + } + label={filter.Name} + /> + ))} + + ); +}; + +export default FiltersStudios; diff --git a/src/apps/experimental/components/library/filter/FiltersTags.tsx b/src/apps/experimental/components/library/filter/FiltersTags.tsx new file mode 100644 index 0000000000..11b96533e2 --- /dev/null +++ b/src/apps/experimental/components/library/filter/FiltersTags.tsx @@ -0,0 +1,71 @@ +import type { QueryFiltersLegacy } from '@jellyfin/sdk/lib/generated-client'; +import React, { FC, useCallback } from 'react'; +import FormGroup from '@mui/material/FormGroup'; +import FormControlLabel from '@mui/material/FormControlLabel'; +import Checkbox from '@mui/material/Checkbox'; +import { LibraryViewSettings } from 'types/library'; + +interface FiltersTagsProps { + filtes?: QueryFiltersLegacy; + libraryViewSettings: LibraryViewSettings; + setLibraryViewSettings: React.Dispatch>; +} + +const FiltersTags: FC = ({ + filtes, + libraryViewSettings, + setLibraryViewSettings +}) => { + const onFiltersTagsChange = useCallback( + (event: React.ChangeEvent) => { + event.preventDefault(); + const value = String(event.target.value); + const existingValue = libraryViewSettings?.Filters?.Tags; + + if (existingValue?.includes(value)) { + const newValue = existingValue?.filter( + (prevState: string) => prevState !== value + ); + setLibraryViewSettings((prevState) => ({ + ...prevState, + StartIndex: 0, + Filters: { ...prevState.Filters, Tags: newValue } + })); + } else { + setLibraryViewSettings((prevState) => ({ + ...prevState, + StartIndex: 0, + Filters: { + ...prevState.Filters, + Tags: [...(existingValue ?? []), value] + } + })); + } + }, + [setLibraryViewSettings, libraryViewSettings?.Filters?.Tags] + ); + + return ( + + {filtes?.Tags?.map((filter) => ( + + } + label={filter} + /> + ))} + + ); +}; + +export default FiltersTags; diff --git a/src/apps/experimental/components/library/filter/FiltersVideoTypes.tsx b/src/apps/experimental/components/library/filter/FiltersVideoTypes.tsx new file mode 100644 index 0000000000..3aa99cb074 --- /dev/null +++ b/src/apps/experimental/components/library/filter/FiltersVideoTypes.tsx @@ -0,0 +1,131 @@ +import React, { FC, useCallback } from 'react'; +import FormGroup from '@mui/material/FormGroup'; +import FormControlLabel from '@mui/material/FormControlLabel'; +import Checkbox from '@mui/material/Checkbox'; +import { LibraryViewSettings } from 'types/library'; +import { VideoType } from '@jellyfin/sdk/lib/generated-client'; +import globalize from 'scripts/globalize'; + +const videoTypesOptions = [ + { label: 'DVD', value: VideoType.Dvd }, + { label: 'Blu-ray', value: VideoType.BluRay }, + { label: 'ISO', value: VideoType.Iso } +]; + +interface FiltersVideoTypesProps { + libraryViewSettings: LibraryViewSettings; + setLibraryViewSettings: React.Dispatch>; +} + +const FiltersVideoTypes: FC = ({ + libraryViewSettings, + setLibraryViewSettings +}) => { + const onFiltersVideoTypesChange = useCallback( + (event: React.ChangeEvent) => { + event.preventDefault(); + const value = event.target.value as VideoType; + const existingValue = libraryViewSettings?.Filters?.VideoTypes; + + if (existingValue?.includes(value)) { + const newValue = existingValue?.filter( + (prevState: VideoType) => prevState !== value + ); + setLibraryViewSettings((prevState) => ({ + ...prevState, + StartIndex: 0, + Filters: { ...prevState.Filters, VideoTypes: newValue } + })); + } else { + setLibraryViewSettings((prevState) => ({ + ...prevState, + StartIndex: 0, + Filters: { + ...prevState.Filters, + VideoTypes: [...(existingValue ?? []), value] + } + })); + } + }, + [setLibraryViewSettings, libraryViewSettings?.Filters?.VideoTypes] + ); + + const handleChange = useCallback( + (event: React.ChangeEvent) => { + const name = event.target.name; + + setLibraryViewSettings((prevState) => ({ + ...prevState, + [name]: event.target.checked + })); + }, + [setLibraryViewSettings] + ); + + return ( + + + } + label={globalize.translate('SD')} + /> + + } + label={globalize.translate('HD')} + /> + + } + label={globalize.translate('4K')} + /> + + } + label={globalize.translate('3D')} + /> + {videoTypesOptions + .map((filter) => ( + + } + label={filter.label} + /> + ))} + + ); +}; + +export default FiltersVideoTypes; diff --git a/src/apps/experimental/components/library/filter/FiltersYears.tsx b/src/apps/experimental/components/library/filter/FiltersYears.tsx new file mode 100644 index 0000000000..35f1505008 --- /dev/null +++ b/src/apps/experimental/components/library/filter/FiltersYears.tsx @@ -0,0 +1,71 @@ +import type { QueryFiltersLegacy } from '@jellyfin/sdk/lib/generated-client'; +import React, { FC, useCallback } from 'react'; +import FormGroup from '@mui/material/FormGroup'; +import FormControlLabel from '@mui/material/FormControlLabel'; +import Checkbox from '@mui/material/Checkbox'; +import { LibraryViewSettings } from 'types/library'; + +interface FiltersYearsProps { + filtes?: QueryFiltersLegacy; + libraryViewSettings: LibraryViewSettings; + setLibraryViewSettings: React.Dispatch>; +} + +const FiltersYears: FC = ({ + filtes, + libraryViewSettings, + setLibraryViewSettings +}) => { + const onFiltersYearsChange = useCallback( + (event: React.ChangeEvent) => { + event.preventDefault(); + const value = Number(event.target.value); + const existingValue = libraryViewSettings?.Filters?.Years; + + if (existingValue?.includes(value)) { + const newValue = existingValue?.filter( + (prevState: number) => prevState !== value + ); + setLibraryViewSettings((prevState) => ({ + ...prevState, + StartIndex: 0, + Filters: { ...prevState.Filters, Years: newValue } + })); + } else { + setLibraryViewSettings((prevState) => ({ + ...prevState, + StartIndex: 0, + Filters: { + ...prevState.Filters, + Years: [...(existingValue ?? []), value] + } + })); + } + }, + [setLibraryViewSettings, libraryViewSettings?.Filters?.Years] + ); + + return ( + + {filtes?.Years?.map((filter) => ( + + } + label={filter} + /> + ))} + + ); +}; + +export default FiltersYears; diff --git a/src/hooks/useFetchItems.ts b/src/hooks/useFetchItems.ts index fcc2b2125c..26ff408a5b 100644 --- a/src/hooks/useFetchItems.ts +++ b/src/hooks/useFetchItems.ts @@ -7,9 +7,11 @@ import { ItemFields } from '@jellyfin/sdk/lib/generated-client/models/item-field import { ItemFilter } from '@jellyfin/sdk/lib/generated-client/models/item-filter'; import { SortOrder } from '@jellyfin/sdk/lib/generated-client/models/sort-order'; import { ItemSortBy } from '@jellyfin/sdk/lib/models/api/item-sort-by'; +import { getFilterApi } from '@jellyfin/sdk/lib/utils/api/filter-api'; import { getGenresApi } from '@jellyfin/sdk/lib/utils/api/genres-api'; import { getItemsApi } from '@jellyfin/sdk/lib/utils/api/items-api'; import { getMoviesApi } from '@jellyfin/sdk/lib/utils/api/movies-api'; +import { getStudiosApi } from '@jellyfin/sdk/lib/utils/api/studios-api'; import { getTvShowsApi } from '@jellyfin/sdk/lib/utils/api/tv-shows-api'; import { getUserLibraryApi } from '@jellyfin/sdk/lib/utils/api/user-library-api'; import { useQuery } from '@tanstack/react-query'; @@ -19,7 +21,7 @@ import { Sections, SectionsViewType } from 'types/suggestionsSections'; const fetchGetItem = async ( currentApi: JellyfinApiContext, - parentId?: string | null, + parentId: string | null | undefined, options?: AxiosRequestConfig ) => { const { api, user } = currentApi; @@ -37,7 +39,7 @@ const fetchGetItem = async ( } }; -export const useGetItem = (parentId?: string | null) => { +export const useGetItem = (parentId: string | null | undefined) => { const currentApi = useApi(); return useQuery({ queryKey: ['Item', parentId], @@ -83,7 +85,7 @@ export const useGetItems = (parametersOptions: ItemsApiGetItemsRequest) => { const fetchGetMovieRecommendations = async ( currentApi: JellyfinApiContext, - parentId?: string | null, + parentId: string | null | undefined, options?: AxiosRequestConfig ) => { const { api, user } = currentApi; @@ -108,7 +110,7 @@ const fetchGetMovieRecommendations = async ( } }; -export const useGetMovieRecommendations = (parentId?: string | null) => { +export const useGetMovieRecommendations = (parentId: string | null | undefined) => { const currentApi = useApi(); return useQuery({ queryKey: ['MovieRecommendations', parentId], @@ -121,7 +123,7 @@ export const useGetMovieRecommendations = (parentId?: string | null) => { const fetchGetItemsBySuggestionsType = async ( currentApi: JellyfinApiContext, sections: Sections, - parentId?: string | null, + parentId: string | null | undefined, options?: AxiosRequestConfig ) => { const { api, user } = currentApi; @@ -234,7 +236,7 @@ const fetchGetItemsBySuggestionsType = async ( export const useGetItemsBySectionType = ( sections: Sections, - parentId?: string | null + parentId: string | null | undefined ) => { const currentApi = useApi(); return useQuery({ @@ -253,7 +255,7 @@ export const useGetItemsBySectionType = ( const fetchGetGenres = async ( currentApi: JellyfinApiContext, itemType: BaseItemKind, - parentId?: string | null, + parentId: string | null | undefined, options?: AxiosRequestConfig ) => { const { api, user } = currentApi; @@ -275,7 +277,7 @@ const fetchGetGenres = async ( } }; -export const useGetGenres = (itemType: BaseItemKind, parentId?: string | null) => { +export const useGetGenres = (itemType: BaseItemKind, parentId: string | null | undefined) => { const currentApi = useApi(); return useQuery({ queryKey: ['Genres', parentId], @@ -284,3 +286,78 @@ export const useGetGenres = (itemType: BaseItemKind, parentId?: string | null) = enabled: !!parentId }); }; + +const fetchGetStudios = async ( + currentApi: JellyfinApiContext, + parentId: string | null | undefined, + itemType: BaseItemKind, + options?: AxiosRequestConfig +) => { + const { api, user } = currentApi; + if (api && user?.Id) { + const response = await getStudiosApi(api).getStudios( + { + userId: user.Id, + includeItemTypes: [itemType], + fields: [ + ItemFields.DateCreated, + ItemFields.PrimaryImageAspectRatio + ], + enableImageTypes: [ImageType.Thumb], + parentId: parentId ?? undefined, + enableTotalRecordCount: false + }, + { + signal: options?.signal + } + ); + return response.data; + } +}; + +export const useGetStudios = (parentId: string | null | undefined, itemType: BaseItemKind) => { + const currentApi = useApi(); + return useQuery({ + queryKey: ['Studios', parentId, itemType], + queryFn: ({ signal }) => + fetchGetStudios(currentApi, parentId, itemType, { signal }), + enabled: !!parentId + }); +}; + +const fetchGetQueryFiltersLegacy = async ( + currentApi: JellyfinApiContext, + parentId: string | null | undefined, + itemType: BaseItemKind, + options?: AxiosRequestConfig +) => { + const { api, user } = currentApi; + if (api && user?.Id) { + const response = await getFilterApi(api).getQueryFiltersLegacy( + { + userId: user.Id, + parentId: parentId ?? undefined, + includeItemTypes: [itemType] + }, + { + signal: options?.signal + } + ); + return response.data; + } +}; + +export const useGetQueryFiltersLegacy = ( + parentId: string | null | undefined, + itemType: BaseItemKind +) => { + const currentApi = useApi(); + return useQuery({ + queryKey: ['QueryFiltersLegacy', parentId, itemType], + queryFn: ({ signal }) => + fetchGetQueryFiltersLegacy(currentApi, parentId, itemType, { + signal + }), + enabled: !!parentId + }); +}; diff --git a/src/strings/en-us.json b/src/strings/en-us.json index 5cc2db0ca6..5ef2b71fe0 100644 --- a/src/strings/en-us.json +++ b/src/strings/en-us.json @@ -369,6 +369,7 @@ "HeaderEditImages": "Edit Images", "HeaderEnabledFields": "Enabled Fields", "HeaderEnabledFieldsHelp": "Uncheck a field to lock it and prevent its data from being changed.", + "HeaderEpisodesStatus": "Episodes Status", "HeaderError": "Error", "HeaderExternalIds": "External IDs", "HeaderFeatureAccess": "Feature access", diff --git a/src/types/library.ts b/src/types/library.ts index 1c04dae30e..5144bf7429 100644 --- a/src/types/library.ts +++ b/src/types/library.ts @@ -1,3 +1,39 @@ +import type { ItemFilter } from '@jellyfin/sdk/lib/generated-client/models/item-filter'; +import type { VideoType } from '@jellyfin/sdk/lib/generated-client/models/video-type'; +import type { SortOrder } from '@jellyfin/sdk/lib/generated-client/models/sort-order'; +import type { SeriesStatus } from '@jellyfin/sdk/lib/generated-client/models/series-status'; +import { ItemSortBy } from '@jellyfin/sdk/lib/models/api/item-sort-by'; + export interface LibraryViewProps { parentId: string | null; } + +interface Filters { + Features?: string[]; + Genres?: string[]; + OfficialRatings?: string[]; + Status?: ItemFilter[]; + EpisodesStatus?: string[]; + SeriesStatus?: SeriesStatus[]; + StudioIds?: string[]; + Tags?: string[]; + VideoTypes?: VideoType[]; + Years?: number[]; +} + +export interface LibraryViewSettings { + SortBy: ItemSortBy; + SortOrder: SortOrder; + StartIndex: number; + CardLayout: boolean; + ImageType: string; + ShowTitle: boolean; + ShowYear?: boolean; + Filters?: Filters; + IsSD?: boolean; + IsHD?: boolean; + Is4K?: boolean; + Is3D?: boolean; + NameLessThan?: string | null; + NameStartsWith?: string | null; +}