diff --git a/web/src/components/InlineModal.tsx b/web/src/components/InlineModal.tsx new file mode 100644 index 000000000..e7fedcd17 --- /dev/null +++ b/web/src/components/InlineModal.tsx @@ -0,0 +1,89 @@ +import { Collapse, List } from '@mui/material'; +import { PlexMedia } from '@tunarr/types/plex'; +import { usePrevious } from '@uidotdev/usehooks'; +import { memo, useEffect, useRef, useState } from 'react'; +import { getEstimatedModalHeight } from '../helpers/inlineModalUtil'; +import useStore from '../store'; +import PlexGridItem from './channel_config/PlexGridItem'; + +type InlineModalProps = { + modalIndex: number; + modalChildren?: PlexMedia[]; + open?: boolean; +}; + +function InlineModal(props: InlineModalProps) { + const { modalChildren, modalIndex, open } = props; + const [containerWidth, setContainerWidth] = useState(0); + const [itemWidth, setItemWidth] = useState(0); + const previousData = usePrevious(props); + const ref = useRef(null); + const gridItemRef = useRef(null); + const darkMode = useStore((state) => state.theme.darkMode); + const modalHeight = getEstimatedModalHeight( + containerWidth, + itemWidth, + modalChildren?.length || 0, + ); + + useEffect(() => { + if ( + ref.current && + previousData && + previousData.modalChildren !== modalChildren + ) { + const containerWidth = ref?.current?.offsetWidth || 0; + const itemWidth = gridItemRef?.current?.offsetWidth || 0; + + setItemWidth(itemWidth); + setContainerWidth(containerWidth); + } + }, [ref, modalChildren, gridItemRef]); + + return ( + + + darkMode ? theme.palette.grey[800] : theme.palette.grey[400], + padding: '0', + paddingTop: 2, + minHeight: modalHeight, + }} + ref={ref} + > + {modalChildren?.map( + (child: PlexMedia, idx: number, arr: PlexMedia[]) => ( + + ), + )} + + + ); +} + +export default memo(InlineModal); diff --git a/web/src/components/TabPanel.tsx b/web/src/components/TabPanel.tsx new file mode 100644 index 000000000..99bd74947 --- /dev/null +++ b/web/src/components/TabPanel.tsx @@ -0,0 +1,51 @@ +import { Grid } from '@mui/material'; +import { ForwardedRef, forwardRef } from 'react'; +import useStore from '../store'; + +type TabPanelProps = { + children?: React.ReactNode; + index: number; + value: number; + ref?: React.RefObject; +}; + +const CustomTabPanel = forwardRef( + (props: TabPanelProps, ref: ForwardedRef) => { + const { children, value, index, ...other } = props; + + const viewType = useStore((state) => state.theme.programmingSelectorView); + + return ( + + ); + }, +); + +export default CustomTabPanel; diff --git a/web/src/components/channel_config/PlexGridItem.tsx b/web/src/components/channel_config/PlexGridItem.tsx index 03c57e0c1..e5da5193c 100644 --- a/web/src/components/channel_config/PlexGridItem.tsx +++ b/web/src/components/channel_config/PlexGridItem.tsx @@ -1,18 +1,10 @@ +import { CheckCircle, RadioButtonUnchecked } from '@mui/icons-material'; import { - CheckCircle, - ExpandLess, - ExpandMore, - RadioButtonUnchecked, -} from '@mui/icons-material'; -import { - Collapse, + Fade, + Grid, IconButton, ImageListItem, ImageListItemBar, - List, - ListItemButton, - ListItemIcon, - Skeleton, } from '@mui/material'; import { PlexChildMediaApiType, @@ -20,9 +12,17 @@ import { isPlexCollection, isTerminalItem, } from '@tunarr/types/plex'; -import { filter } from 'lodash-es'; -import React, { MouseEvent, useCallback, useEffect, useState } from 'react'; -import { prettyItemDuration } from '../../helpers/util.ts'; +import _, { filter, isNaN } from 'lodash-es'; +import React, { + ForwardedRef, + MouseEvent, + forwardRef, + useCallback, + useEffect, + useState, +} from 'react'; +import { useIntersectionObserver } from 'usehooks-ts'; +import { forPlexMedia, prettyItemDuration } from '../../helpers/util.ts'; import { usePlexTyped } from '../../hooks/plexHooks.ts'; import useStore from '../../store/index.ts'; import { @@ -38,131 +38,174 @@ export interface PlexGridItemProps { index?: number; length?: number; parent?: string; + moveModal?: CallableFunction; + modalChildren?: CallableFunction; + modalIsPending?: CallableFunction; + modalIndex?: number; + onClick?: any; + ref?: React.RefObject; } -export function PlexGridItem(props: PlexGridItemProps) { - const server = useStore((s) => s.currentServer!); // We have to have a server at this point - const darkMode = useStore((state) => state.theme.darkMode); - const [open, setOpen] = useState(false); - const { item } = props; - const hasChildren = !isTerminalItem(item); - const childPath = isPlexCollection(item) ? 'collections' : 'metadata'; - const { isPending, data: children } = usePlexTyped>( - server.name, - `/library/${childPath}/${props.item.ratingKey}/children`, - hasChildren && open, - ); - const selectedServer = useStore((s) => s.currentServer); - const selectedMedia = useStore((s) => - filter(s.selectedMedia, (p): p is PlexSelectedMedia => p.type === 'plex'), - ); - const selectedMediaIds = selectedMedia.map((item) => item.guid); +const PlexGridItem = forwardRef( + ( + props: PlexGridItemProps, + ref: ForwardedRef, + ) => { + const server = useStore((s) => s.currentServer!); // We have to have a server at this point + const darkMode = useStore((state) => state.theme.darkMode); + const [open, setOpen] = useState(false); + const { item, style, moveModal, modalChildren } = props; + const hasChildren = !isTerminalItem(item); + const childPath = isPlexCollection(item) ? 'collections' : 'metadata'; + const { isPending, data: children } = usePlexTyped< + PlexChildMediaApiType + >( + server.name, + `/library/${childPath}/${props.item.ratingKey}/children`, + hasChildren && open, + ); + const selectedServer = useStore((s) => s.currentServer); + const selectedMedia = useStore((s) => + filter(s.selectedMedia, (p): p is PlexSelectedMedia => p.type === 'plex'), + ); + const selectedMediaIds = selectedMedia.map((item) => item.guid); + + const handleClick = () => { + setOpen(!open); - const handleClick = () => { - setOpen(!open); - }; + if (moveModal) { + moveModal(); - useEffect(() => { - if (children) { - addKnownMediaForServer(server.name, children.Metadata, item.guid); - } - }, [item.guid, server.name, children]); + if (children && modalChildren) { + modalChildren(children.Metadata); + } + } + }; + + useEffect(() => { + if (props.modalIsPending) { + props.modalIsPending(isPending); + } + }, [isPending]); - const handleItem = useCallback( - (e: MouseEvent) => { - e.stopPropagation(); + useEffect(() => { + if (children) { + addKnownMediaForServer(server.name, children.Metadata, item.guid); - if (selectedMediaIds.includes(item.guid)) { - removePlexSelectedMedia(selectedServer!.name, [item.guid]); - } else { - addPlexSelectedMedia(selectedServer!.name, [item]); + if (children.Metadata.length > 0 && !!modalChildren) { + modalChildren(children.Metadata); + } } - }, - [item, selectedServer, selectedMediaIds], - ); + }, [item.guid, server.name, children]); + + const handleItem = useCallback( + (e: MouseEvent) => { + e.stopPropagation(); - const renderChildren = () => { - return isPending ? ( - - ) : ( - - {children?.Metadata.map((child, idx, arr) => ( - - ))} - + if (selectedMediaIds.includes(item.guid)) { + removePlexSelectedMedia(selectedServer!.name, [item.guid]); + } else { + addPlexSelectedMedia(selectedServer!.name, [item]); + } + }, + [item, selectedServer, selectedMediaIds], ); - }; - return ( - - {hasChildren ? ( - - {hasChildren && ( - - {open ? : } - - )} - - - ) : ( - handleItem(e)} + const { isIntersecting: isInViewport, ref: imageContainerRef } = + useIntersectionObserver({ + threshold: 0, + rootMargin: '0px', + freezeOnceVisible: true, + }); + + const extractChildCount = forPlexMedia({ + season: (s) => s.leafCount, + show: (s) => s.childCount, + collection: (s) => parseInt(s.childCount), + }); + + return ( + + - {item.title} - {prettyItemDuration(item.duration)}} - position="below" - actionIcon={ - handleItem(e)} - > - {selectedMediaIds.includes(item.guid) ? ( - - ) : ( - - )} - + + props.modalIndex === props.index + ? darkMode + ? theme.palette.grey[800] + : theme.palette.grey[400] + : 'transparent', + transition: 'background-color 350ms linear !important', + ...style, + }} + onClick={ + hasChildren + ? handleClick + : (event: MouseEvent) => handleItem(event) } - actionPosition="right" - /> - - )} - - {renderChildren()} - - - ); -} + ref={ref} + > + {isInViewport && ( // To do: Eventually turn this itno isNearViewport so images load before they hit the viewport + + )} + {`${ + !isNaN(extractChildCount(item)) && extractChildCount(item) + } items`} + ) : ( + {prettyItemDuration(item.duration)} + ) + } + position="below" + actionIcon={ + ) => + handleItem(event) + } + > + {selectedMediaIds.includes(item.guid) ? ( + + ) : ( + + )} + + } + actionPosition="right" + /> + + + + ); + }, +); + +export default PlexGridItem; diff --git a/web/src/components/channel_config/PlexListItem.tsx b/web/src/components/channel_config/PlexListItem.tsx index e6749c86c..ebc4f25a6 100644 --- a/web/src/components/channel_config/PlexListItem.tsx +++ b/web/src/components/channel_config/PlexListItem.tsx @@ -130,7 +130,7 @@ export function PlexListItem(props: PlexListItemProps) { return ( - + {hasChildren && ( {open ? : } )} @@ -143,7 +143,7 @@ export function PlexListItem(props: PlexListItemProps) { : `Add ${plexTypeString(item)}`} - + {renderChildren()} diff --git a/web/src/components/channel_config/PlexProgrammingSelector.tsx b/web/src/components/channel_config/PlexProgrammingSelector.tsx index 89adbfc5f..452280903 100644 --- a/web/src/components/channel_config/PlexProgrammingSelector.tsx +++ b/web/src/components/channel_config/PlexProgrammingSelector.tsx @@ -1,25 +1,20 @@ -import { ExpandLess, ExpandMore } from '@mui/icons-material'; import Clear from '@mui/icons-material/Clear'; import GridView from '@mui/icons-material/GridView'; import Search from '@mui/icons-material/Search'; import ViewList from '@mui/icons-material/ViewList'; import { Box, - Collapse, Divider, Grow, IconButton, InputAdornment, LinearProgress, - List, - ListItemButton, - ListItemIcon, - ListItemText, Stack, + Tab, + Tabs, TextField, ToggleButton, ToggleButtonGroup, - Typography, } from '@mui/material'; import { DataTag, useInfiniteQuery, useQuery } from '@tanstack/react-query'; import { @@ -27,13 +22,18 @@ import { PlexLibraryMovies, PlexLibraryMusic, PlexLibraryShows, + PlexMedia, PlexMovie, PlexMusicArtist, PlexTvShow, } from '@tunarr/types/plex'; import { chain, first, isEmpty, isNil, isUndefined, map } from 'lodash-es'; -import { Fragment, useCallback, useEffect, useState } from 'react'; +import React, { useCallback, useEffect, useRef, useState } from 'react'; import { useIntersectionObserver } from 'usehooks-ts'; +import { + firstItemInNextRow, + getImagesPerRow, +} from '../../helpers/inlineModalUtil'; import { toggle } from '../../helpers/util'; import { fetchPlexPath, usePlex } from '../../hooks/plexHooks'; import { usePlexServerSettings } from '../../hooks/settingsHooks'; @@ -42,10 +42,19 @@ import useStore from '../../store'; import { addKnownMediaForServer } from '../../store/programmingSelector/actions'; import { setProgrammingSelectorViewState } from '../../store/themeEditor/actions'; import { ProgramSelectorViewType } from '../../types'; +import InlineModal from '../InlineModal'; +import CustomTabPanel from '../TabPanel'; import ConnectPlex from '../settings/ConnectPlex'; -import { PlexGridItem } from './PlexGridItem'; +import PlexGridItem from './PlexGridItem'; import { PlexListItem } from './PlexListItem'; +function a11yProps(index: number) { + return { + id: `simple-tab-${index}`, + 'aria-controls': `simple-tabpanel-${index}`, + }; +} + export default function PlexProgrammingSelector() { const { data: plexServers } = usePlexServerSettings(); const selectedServer = useStore((s) => s.currentServer); @@ -53,11 +62,89 @@ export default function PlexProgrammingSelector() { s.currentLibrary?.type === 'plex' ? s.currentLibrary : null, ); const viewType = useStore((state) => state.theme.programmingSelectorView); - + const [tabValue, setTabValue] = useState(0); + const [modalChildren, setModalChildren] = useState([]); + const [rowSize, setRowSize] = useState(16); + const [modalIndex, setModalIndex] = useState(-1); + const [modalIsPending, setModalIsPending] = useState(true); const [searchBarOpen, setSearchBarOpen] = useState(false); const [search, debounceSearch, setSearch] = useDebouncedState('', 300); - const [collectionsOpen, setCollectionsOpen] = useState(false); const [scrollParams, setScrollParams] = useState({ limit: 0, max: -1 }); + const containerRef = useRef(null); + const imageRef = useRef(null); + const libraryImageRef = useRef(null); + const libraryContainerRef = useRef(null); + + const handleResize = () => { + if (tabValue === 0) { + const libraryContainerWidth = + libraryContainerRef?.current?.offsetWidth || 0; + setRowSize(getImagesPerRow(libraryContainerWidth, 160)); // to do: remove magic number + } else { + // Collections initial load + const containerWidth = containerRef?.current?.offsetWidth || 0; + const itemWidth = imageRef?.current?.offsetWidth || 0; + + setRowSize(getImagesPerRow(containerWidth, itemWidth)); + } + }; + + useEffect(() => { + if (viewType === 'grid') { + const handleResizeEvent = () => handleResize(); + handleResize(); // Call initially to set width + window.addEventListener('resize', handleResizeEvent); + + // Cleanup function to remove event listener + return () => window.removeEventListener('resize', handleResizeEvent); + } + }, []); + + useEffect(() => { + if (viewType === 'grid') { + const containerWidth = containerRef?.current?.offsetWidth || 0; + const itemWidth = imageRef?.current?.offsetWidth || 0; + + setRowSize(getImagesPerRow(containerWidth, itemWidth)); + } + }, [containerRef, imageRef, modalChildren]); + + useEffect(() => { + setModalIndex(-1); + handleModalChildren([]); + }, [tabValue]); + + const handleChange = (_: React.SyntheticEvent, newValue: number) => { + setTabValue(newValue); + }; + + const handleMoveModal = useCallback( + (index: number) => { + console.log('TEST'); + if (index === modalIndex) { + handleModalChildren([]); + setModalIndex(-1); + } else { + handleModalChildren([]); + setModalIndex(index); + } + }, + [modalIndex], + ); + + const handleModalChildren = useCallback( + (children: PlexMedia[]) => { + setModalChildren(children); + }, + [modalChildren], + ); + + const handleModalIsPending = useCallback( + (isPending: boolean) => { + setModalIsPending(isPending); + }, + [modalIsPending], + ); const { data: directoryChildren } = usePlex( selectedServer?.name ?? '', @@ -81,7 +168,7 @@ export default function PlexProgrammingSelector() { setViewType(newFormats); }; - const { data: collectionsData } = useQuery({ + const { isLoading: isCollectionLoading, data: collectionsData } = useQuery({ queryKey: [ 'plex', selectedServer?.name, @@ -97,6 +184,13 @@ export default function PlexProgrammingSelector() { enabled: !isNil(selectedServer) && !isNil(selectedLibrary), }); + useEffect(() => { + // When switching between Libraries, if a collection doesn't exist switch back to 'Library' tab + if (!collectionsData && !isCollectionLoading && tabValue === 1) { + setTabValue(0); + } + }, [collectionsData, isCollectionLoading]); + const { isLoading: searchLoading, data: searchData } = useInfiniteQuery({ queryKey: [ 'plex-search', @@ -112,7 +206,7 @@ export default function PlexProgrammingSelector() { queryFn: ({ pageParam }) => { const plexQuery = new URLSearchParams({ 'Plex-Container-Start': pageParam.toString(), - 'Plex-Container-Size': '10', + 'Plex-Container-Size': (rowSize * 4).toString(), }); if (!isNil(debounceSearch) && !isEmpty(debounceSearch)) { @@ -129,20 +223,22 @@ export default function PlexProgrammingSelector() { )(); }, getNextPageParam: (res, _, last) => { - if (res.size < 10) { + if (res.size < rowSize * 4) { return null; } - return last + 10; + return last + rowSize * 4; }, }); useEffect(() => { if (searchData) { + handleResize(); // Call initially to set rowSize + // We're using this as an analogue for detecting the start of a new 'query' if (searchData.pages.length === 1) { setScrollParams({ - limit: 16, + limit: rowSize * 4, max: first(searchData.pages)!.size, }); } @@ -158,14 +254,14 @@ export default function PlexProgrammingSelector() { addKnownMediaForServer(selectedServer.name, allMedia); } } - }, [selectedServer, searchData, setScrollParams]); + }, [selectedServer, searchData, setScrollParams, rowSize]); const { ref } = useIntersectionObserver({ onChange: (_, entry) => { if (entry.isIntersecting && scrollParams.limit < scrollParams.max) { setScrollParams(({ limit: prevLimit, max }) => ({ max, - limit: prevLimit + 10, + limit: prevLimit + rowSize * 4, })); } }, @@ -175,40 +271,48 @@ export default function PlexProgrammingSelector() { const renderListItems = () => { const elements: JSX.Element[] = []; - if (collectionsData && collectionsData.size > 0) { + if (collectionsData && collectionsData.size > 0 && tabValue === 1) { elements.push( - - setCollectionsOpen(toggle)} - dense - sx={ - viewType === 'grid' ? { display: 'block', width: '100%' } : null - } - > - - {collectionsOpen ? : } - - - - - {map(collectionsData.Metadata, (item) => - viewType === 'list' ? ( - - ) : ( - - ), - )} - - , + + {map(collectionsData.Metadata, (item, index: number) => + viewType === 'list' ? ( + + ) : ( + + + handleMoveModal(index)} + modalChildren={(children: PlexMedia[]) => + handleModalChildren(children) + } + modalIsPending={(isPending: boolean) => + handleModalIsPending(isPending) + } + ref={imageRef} + /> + + ), + )} + , ); } @@ -219,16 +323,34 @@ export default function PlexProgrammingSelector() { .flatten() .take(scrollParams.limit) .value(); + elements.push( - ...map(items, (item: PlexMovie | PlexTvShow | PlexMusicArtist) => { - return viewType === 'list' ? ( - - ) : ( - - ); - }), + + {map( + items, + (item: PlexMovie | PlexTvShow | PlexMusicArtist, idx: number) => { + return viewType === 'list' ? ( + + ) : ( + + ); + }, + )} + , ); } + return elements; }; @@ -317,28 +439,21 @@ export default function PlexProgrammingSelector() { marginTop: 1, }} /> - {!searchLoading && ( - t.palette.grey[700]}> - {first(searchData?.pages)?.size} Items - - )} - - {renderListItems()} -
-
+ + + + {collectionsData && collectionsData.size > 0 && ( + + )} + + + {renderListItems()} +
)} diff --git a/web/src/helpers/inlineModalUtil.ts b/web/src/helpers/inlineModalUtil.ts new file mode 100644 index 000000000..3354ab8f5 --- /dev/null +++ b/web/src/helpers/inlineModalUtil.ts @@ -0,0 +1,89 @@ +import { PlexMedia } from '@tunarr/types/plex'; + +export function getImagesPerRow( + containerWidth: number, + imageWidth: number, +): number { + return Math.floor(containerWidth / imageWidth); +} + +// Estimate the modal height to prevent div collapse while new modal images load +export function getEstimatedModalHeight( + containerWidth: number, + imageContainerWidth: number, + listSize: number, +): number { + // Exit with defaults if container & image width are not provided + if (containerWidth === 0 || imageContainerWidth === 0) { + return 294; //default modal height for 1 row + } + + const columns = getImagesPerRow(containerWidth, imageContainerWidth); + + // Magic Numbers + // to do: eventually grab this data via refs just in case it changes in the future + const inlineModalTopPadding = 16; + const imageContainerXPadding = 8; + const listItemBarContainerHeight = 54; + + const imagewidth = imageContainerWidth - imageContainerXPadding * 2; // 16px padding on each item + const heightPerImage = (3 * imagewidth) / 2; // Movie Posters are 2:3 + const heightPerItem = + heightPerImage + listItemBarContainerHeight + imageContainerXPadding; // 54px + + const rows = listSize < columns ? 1 : Math.ceil(listSize / columns); + //This is min-height so we only need to adjust it for visible rows since we + //use interesectionObserver to load them in + const maxRows = rows >= 3 ? 3 : rows; + + return Math.ceil(maxRows * heightPerItem + inlineModalTopPadding); // 16px padding added to top +} + +export function firstItemInNextRow( + modalIndex: number, + itemsPerRow: number, + numberOfItems: number, +): number { + // Calculate the row number of the current item + const rowNumber = Math.floor(modalIndex / itemsPerRow); + + // Modal is closed or collection has no data, exit + if (modalIndex === -1 || numberOfItems === 0) { + return -1; + } + + // If the item clicked is on the last row and the last row isn't full, adjust where modal is inserted + // for now the final rows modal will be inserted above these items + const numberOfItemsLastRow = numberOfItems % itemsPerRow; + + if ( + modalIndex >= numberOfItems - numberOfItemsLastRow && + numberOfItemsLastRow < itemsPerRow + ) { + return numberOfItems - numberOfItemsLastRow; + } + + // If the current item is not in the last row, return the index of the first item in the next row + if ( + rowNumber <= + (modalIndex > 0 ? modalIndex : 1) / // Account for modalIndex = 0 + itemsPerRow + ) { + return (rowNumber + 1) * itemsPerRow; + } + + // Otherwise, return -1 to indicate modal is closed + return -1; +} + +export function extractLastIndexes(arr: PlexMedia[], x: number): number[] { + if (x > arr.length) { + return arr.map((_, i) => i); // Return all indexes if x is too large + } + + // Extract the last x elements + const lastElements = arr.slice(-x); + + // Return last X indexes in new array + return lastElements.map((_) => arr.indexOf(_)); +} diff --git a/web/src/hooks/programming_controls/useReleaseDateSort.ts b/web/src/hooks/programming_controls/useReleaseDateSort.ts index 8a4a52d13..d8ea14798 100644 --- a/web/src/hooks/programming_controls/useReleaseDateSort.ts +++ b/web/src/hooks/programming_controls/useReleaseDateSort.ts @@ -30,6 +30,7 @@ export const sortPrograms = ( } else { n = sortOrder === 'asc' ? Number.MAX_VALUE : Number.MAX_VALUE; } + return n; });