|
|
@@ -1,11 +1,20 @@
|
|
|
-import { useDebounce } from '@react-hook/debounce';
|
|
|
-import React, { useContext, useEffect } from 'react';
|
|
|
+import { useDebounce, useDebounceCallback } from '@react-hook/debounce';
|
|
|
+import React, {
|
|
|
+ CSSProperties,
|
|
|
+ useCallback,
|
|
|
+ useContext,
|
|
|
+ useEffect,
|
|
|
+ useMemo,
|
|
|
+ useRef,
|
|
|
+ useState,
|
|
|
+} from 'react';
|
|
|
+import { FixedSizeNodeData, FixedSizeTree as Tree, TreeWalker, TreeWalkerValue } from 'react-vtree';
|
|
|
+import { NodeComponentProps, NodePublicState } from 'react-vtree/dist/es/Tree';
|
|
|
|
|
|
import { useArtistsAlbumsAndSongs } from '../../../../hooks/fetch/artists';
|
|
|
-import { Song } from '../../../../types/songs';
|
|
|
-import { namedMemo } from '../../../../utils/component';
|
|
|
import { artistAlbumsLoaded, artistSongsLoaded } from '../actions';
|
|
|
import { CmusUIDispatchContext, CmusUIStateContext } from '../reducer';
|
|
|
+import { CmusUIState } from '../types';
|
|
|
|
|
|
import * as Styled from './artists.styles';
|
|
|
|
|
|
@@ -13,43 +22,120 @@ type Props = {
|
|
|
active: boolean;
|
|
|
};
|
|
|
|
|
|
-type PropsArtist = {
|
|
|
- artist: string;
|
|
|
- albums?: string[];
|
|
|
- songs?: Song[];
|
|
|
+type TreeNode = {
|
|
|
+ name: string;
|
|
|
+ id: string;
|
|
|
+ focused: boolean;
|
|
|
active: boolean;
|
|
|
- parentActive: boolean;
|
|
|
- expanded: boolean;
|
|
|
- activeAlbum: string | null;
|
|
|
+ shouldBeOpen?: boolean;
|
|
|
+ children?: TreeNode[];
|
|
|
};
|
|
|
|
|
|
-const Artist = namedMemo<PropsArtist>(
|
|
|
- 'Artist',
|
|
|
- ({ artist, albums, active, parentActive, expanded, activeAlbum }) => (
|
|
|
- <Styled.ArtistRow key={artist}>
|
|
|
- <Styled.ArtistTitle active={active} parentActive={parentActive}>
|
|
|
- <span>{artist || 'Unknown Artist'}</span>
|
|
|
+type TreeMeta = {
|
|
|
+ node: TreeNode;
|
|
|
+};
|
|
|
+
|
|
|
+type TreeData = FixedSizeNodeData &
|
|
|
+ Omit<TreeNode, 'children'> & {
|
|
|
+ isArtist: boolean;
|
|
|
+ };
|
|
|
+
|
|
|
+function useTreeWalker(
|
|
|
+ { artists, artistAlbums, library: { activeArtist, activeAlbum, expandedArtists } }: CmusUIState,
|
|
|
+ focused: boolean,
|
|
|
+): { treeWalker: TreeWalker<TreeData, TreeMeta>; haveData: boolean } {
|
|
|
+ const treeNodes = useMemo<TreeNode[]>(
|
|
|
+ () =>
|
|
|
+ artists.map<TreeNode>((artist) => ({
|
|
|
+ name: artist,
|
|
|
+ id: artist,
|
|
|
+ focused,
|
|
|
+ active: activeArtist === artist,
|
|
|
+ shouldBeOpen: expandedArtists.includes(artist),
|
|
|
+ children:
|
|
|
+ artistAlbums[artist]?.map<TreeNode>((album) => ({
|
|
|
+ name: album,
|
|
|
+ id: `${artist}-${album}`,
|
|
|
+ focused,
|
|
|
+ active: activeArtist === artist && activeAlbum === album,
|
|
|
+ })) ?? undefined,
|
|
|
+ })),
|
|
|
+ [artists, artistAlbums, focused, activeArtist, activeAlbum, expandedArtists],
|
|
|
+ );
|
|
|
+
|
|
|
+ const getNodeData = useCallback(
|
|
|
+ (node: TreeNode, isArtist: boolean): TreeWalkerValue<TreeData, TreeMeta> => ({
|
|
|
+ data: {
|
|
|
+ id: node.id,
|
|
|
+ name: node.name,
|
|
|
+ focused: node.focused,
|
|
|
+ active: node.active,
|
|
|
+ shouldBeOpen: node.shouldBeOpen,
|
|
|
+ isOpenByDefault: !!node.shouldBeOpen,
|
|
|
+ isArtist,
|
|
|
+ },
|
|
|
+ node,
|
|
|
+ }),
|
|
|
+ [],
|
|
|
+ );
|
|
|
+
|
|
|
+ const treeWalker = useMemo<TreeWalker<TreeData, TreeMeta>>(
|
|
|
+ () =>
|
|
|
+ // eslint-disable-next-line @typescript-eslint/explicit-function-return-type
|
|
|
+ function* treeWalkerGenerator() {
|
|
|
+ for (let i = 0; i < treeNodes.length; i += 1) {
|
|
|
+ yield getNodeData(treeNodes[i], true);
|
|
|
+ }
|
|
|
+
|
|
|
+ while (true) {
|
|
|
+ const parent = yield;
|
|
|
+
|
|
|
+ if (parent?.node.children) {
|
|
|
+ for (let i = 0; i < parent.node.children.length; i += 1) {
|
|
|
+ yield getNodeData(parent.node.children[i], false);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ },
|
|
|
+ [treeNodes, getNodeData],
|
|
|
+ );
|
|
|
+
|
|
|
+ return { treeWalker, haveData: treeNodes.length > 0 };
|
|
|
+}
|
|
|
+
|
|
|
+const Node: React.FC<NodeComponentProps<TreeData, NodePublicState<TreeData>>> = ({
|
|
|
+ data: { name, isArtist, focused, active, shouldBeOpen },
|
|
|
+ isOpen,
|
|
|
+ setOpen,
|
|
|
+ style,
|
|
|
+}) => {
|
|
|
+ useEffect(() => {
|
|
|
+ if (!!isOpen !== !!shouldBeOpen) {
|
|
|
+ setOpen(!!shouldBeOpen);
|
|
|
+ }
|
|
|
+ }, [isOpen, shouldBeOpen, setOpen]);
|
|
|
+
|
|
|
+ if (isArtist) {
|
|
|
+ return (
|
|
|
+ <Styled.ArtistTitle active={active} parentActive={focused} style={style as CSSProperties}>
|
|
|
+ <span>{name || 'Unknown Artist'}</span>
|
|
|
</Styled.ArtistTitle>
|
|
|
- {expanded && (
|
|
|
- <Styled.ArtistAlbums>
|
|
|
- {albums?.map((album) => (
|
|
|
- <Styled.AlbumTitle key={album} active={active && album === activeAlbum}>
|
|
|
- {album || 'Unknown Album'}
|
|
|
- </Styled.AlbumTitle>
|
|
|
- ))}
|
|
|
- </Styled.ArtistAlbums>
|
|
|
- )}
|
|
|
- </Styled.ArtistRow>
|
|
|
- ),
|
|
|
-);
|
|
|
+ );
|
|
|
+ }
|
|
|
+
|
|
|
+ return (
|
|
|
+ <Styled.AlbumTitle active={active} parentActive={focused} style={style as CSSProperties}>
|
|
|
+ <span>{name || 'Unknown Album'}</span>
|
|
|
+ </Styled.AlbumTitle>
|
|
|
+ );
|
|
|
+};
|
|
|
|
|
|
export const Artists: React.FC<Props> = ({ active }) => {
|
|
|
const dispatchUI = useContext(CmusUIDispatchContext);
|
|
|
+ const state = useContext(CmusUIStateContext);
|
|
|
const {
|
|
|
- artists,
|
|
|
- artistAlbums,
|
|
|
- library: { expandedArtists, activeArtist, activeAlbum },
|
|
|
- } = useContext(CmusUIStateContext);
|
|
|
+ library: { activeArtist, expandedArtists },
|
|
|
+ } = state;
|
|
|
|
|
|
const [debouncedActiveArtist, setDebouncedActiveArtist] = useDebounce(activeArtist, 100);
|
|
|
useEffect(() => {
|
|
|
@@ -72,19 +158,41 @@ export const Artists: React.FC<Props> = ({ active }) => {
|
|
|
}
|
|
|
}, [dispatchUI, songs]);
|
|
|
|
|
|
+ const ref = useRef<HTMLDivElement>(null);
|
|
|
+ const [windowDimensions, setWindowDimensions] = useState<{ width: number; height: number }>({
|
|
|
+ width: 0,
|
|
|
+ height: 0,
|
|
|
+ });
|
|
|
+ const onResize = useCallback(() => {
|
|
|
+ setWindowDimensions({
|
|
|
+ width: ref.current?.offsetWidth ?? 0,
|
|
|
+ height: ref.current?.offsetHeight ?? 0,
|
|
|
+ });
|
|
|
+ }, []);
|
|
|
+ const resizeHandler = useDebounceCallback(onResize, 100);
|
|
|
+
|
|
|
+ useEffect(() => {
|
|
|
+ onResize();
|
|
|
+ window.addEventListener('resize', resizeHandler);
|
|
|
+ return (): void => {
|
|
|
+ window.removeEventListener('resize', resizeHandler);
|
|
|
+ };
|
|
|
+ }, [onResize, resizeHandler]);
|
|
|
+
|
|
|
+ const { treeWalker, haveData } = useTreeWalker(state, active);
|
|
|
+
|
|
|
return (
|
|
|
- <Styled.Container>
|
|
|
- {artists.map((artist) => (
|
|
|
- <Artist
|
|
|
- key={artist}
|
|
|
- artist={artist}
|
|
|
- albums={artistAlbums[artist]}
|
|
|
- active={artist === activeArtist}
|
|
|
- parentActive={active}
|
|
|
- expanded={expandedArtists.includes(artist)}
|
|
|
- activeAlbum={activeAlbum}
|
|
|
- />
|
|
|
- ))}
|
|
|
+ <Styled.Container ref={ref}>
|
|
|
+ {haveData && (
|
|
|
+ <Tree
|
|
|
+ treeWalker={treeWalker}
|
|
|
+ itemSize={16}
|
|
|
+ width={windowDimensions.width}
|
|
|
+ height={windowDimensions.height}
|
|
|
+ >
|
|
|
+ {Node}
|
|
|
+ </Tree>
|
|
|
+ )}
|
|
|
</Styled.Container>
|
|
|
);
|
|
|
};
|