Prechádzať zdrojové kódy

feat: group songs by album

Fela Maslen 5 rokov pred
rodič
commit
15fddabff7

+ 2 - 0
gmus-web/package.json

@@ -59,6 +59,7 @@
     "@testing-library/react": "^11.1.0",
     "@testing-library/user-event": "^12.1.10",
     "@types/jest": "^26.0.15",
+    "@types/lodash": "^4.14.165",
     "@types/nock": "^11.1.0",
     "@types/node": "^12.0.0",
     "@types/pluralize": "^0.0.29",
@@ -73,6 +74,7 @@
     "husky": "^4.3.6",
     "jest-websocket-mock": "^2.2.0",
     "lint-staged": "^10.5.3",
+    "lodash": "^4.17.20",
     "mock-socket": "^9.0.3",
     "nanoid": "^3.1.20",
     "nock": "^13.0.5",

+ 9 - 5
gmus-web/src/components/ui/cmus/utils/scroll.ts

@@ -133,13 +133,17 @@ export function getArtistAlbumScrollIndex(
 }
 
 export function getSongScrollIndex(
-  filteredSongs: Pick<Song, 'id'>[],
+  filteredSongs: Pick<Song, 'id' | 'album'>[],
   activeSongId: number | null,
 ): number {
-  if (activeSongId === null) {
+  const songIndex = filteredSongs.findIndex(({ id }) => id === activeSongId);
+  if (songIndex === -1) {
     return -1;
   }
-  return filteredSongs.findIndex(({ id }) => id === activeSongId);
+  const numUniqueAlbums = Array.from(
+    new Set(filteredSongs.slice(0, songIndex + 1).map(({ album }) => album)),
+  ).length;
+  return songIndex + 2 * numUniqueAlbums;
 }
 
 export const lineHeight = 16;
@@ -164,9 +168,9 @@ export function useAutoJumpyScroll(ref: RefObject<HTMLDivElement>, scrollIndex:
     if (linesBefore < 0 || linesAfter < 0) {
       ref.current.scrollTop = Math.max(0, (scrollIndex - 1) * lineHeight);
     } else if (linesAfter < scrollThresholdLines) {
-      ref.current.scrollTop += lineHeight;
+      ref.current.scrollTop += (scrollThresholdLines - linesAfter) * lineHeight;
     } else if (linesBefore < scrollThresholdLines) {
-      ref.current.scrollTop -= lineHeight;
+      ref.current.scrollTop -= (scrollThresholdLines - linesBefore) * lineHeight;
     }
   }, [scrollIndex]);
   /* eslint-enable react-hooks/exhaustive-deps, no-param-reassign */

+ 32 - 0
gmus-web/src/components/ui/cmus/views/songs.styles.ts

@@ -0,0 +1,32 @@
+import { rem } from 'polished';
+import styled from 'styled-components';
+
+import { ActiveHighlightRow, FlexList } from '../styled/layout';
+import { colors } from '../styled/variables';
+
+export const Container = styled(FlexList)`
+  border-left: 1px solid ${colors.border};
+  flex: 2 0 0;
+  flex-flow: column;
+  height: 100%;
+`;
+
+export const Song = styled(ActiveHighlightRow)``;
+
+export const Separator = styled(ActiveHighlightRow)`
+  height: ${rem(26)};
+  margin-top: ${rem(6)};
+
+  :not(:first-child) {
+    border-top: 1px dashed ${colors.selected.inactive};
+  }
+`;
+
+export const SeparatorText = styled.span`
+  box-sizing: border-box;
+  color: ${colors.selected.inactive};
+  font-style: italic;
+  height: ${rem(26)};
+  padding: ${rem(4)} 0 ${rem(6)} 0;
+  width: 100%;
+`;

+ 0 - 13
gmus-web/src/components/ui/cmus/views/songs.styles.tsx

@@ -1,13 +0,0 @@
-import styled from 'styled-components';
-
-import { ActiveHighlightRow, FlexList } from '../styled/layout';
-import { colors } from '../styled/variables';
-
-export const Container = styled(FlexList)`
-  border-left: 1px solid ${colors.border};
-  flex: 2 0 0;
-  flex-flow: column;
-  height: 100%;
-`;
-
-export const Song = styled(ActiveHighlightRow)``;

+ 47 - 16
gmus-web/src/components/ui/cmus/views/songs.tsx

@@ -1,6 +1,7 @@
-import React, { CSSProperties, useContext, useMemo, useRef } from 'react';
+import groupBy from 'lodash/groupBy';
+import React, { CSSProperties, useCallback, useContext, useMemo, useRef } from 'react';
 import AutoSizer from 'react-virtualized-auto-sizer';
-import { FixedSizeList as List } from 'react-window';
+import { VariableSizeList as List } from 'react-window';
 import { StateContext } from '../../../../context/state';
 
 import { Song } from '../../../../types';
@@ -24,12 +25,28 @@ type SongData = {
   highlight: boolean;
 };
 
-const itemKey = (index: number, data: SongData[]): number => data[index].song.id;
+type Separator = {
+  album: string;
+};
+
+type ItemData = (SongData | Separator) & { id: number };
+
+const isSeparator = (item: ItemData | Separator): item is Separator => !Reflect.has(item, 'song');
+
+const itemKey = (index: number, data: ItemData[]): number => data[index].id;
 
-const Row = namedMemo<{ index: number; data: SongData[]; style: CSSProperties }>(
+const Row = namedMemo<{ index: number; data: ItemData[]; style: CSSProperties }>(
   'Song',
   ({ index, data, style }) => {
-    const { song, active, parentActive, highlight } = data[index];
+    const item = data[index];
+    if (isSeparator(item)) {
+      return (
+        <Styled.Separator style={style}>
+          <Styled.SeparatorText>{item.album || 'Unknown Album'}</Styled.SeparatorText>
+        </Styled.Separator>
+      );
+    }
+    const { song, active, parentActive, highlight } = item;
     return (
       <Styled.Song active={active} parentActive={parentActive} style={style} highlight={highlight}>
         <NoWrapFill>
@@ -46,19 +63,32 @@ export const Songs: React.FC<Props> = ({ active: parentActive }) => {
   const { songId: playingSongId } = globalState.player;
 
   const state = useContext(CmusUIStateContext);
-  const { activeArtist, activeSongId } = state.library;
+  const { activeArtist, activeAlbum, activeSongId } = state.library;
 
   const filteredSongs = getFilteredSongs(state);
 
-  const itemData = useMemo<SongData[]>(
-    () =>
-      filteredSongs.map<SongData>((song) => ({
-        song,
-        active: song.id === activeSongId,
-        parentActive,
-        highlight: song.id === playingSongId,
-      })),
-    [parentActive, activeSongId, playingSongId, filteredSongs],
+  const itemData = useMemo<ItemData[]>(() => {
+    const allSongs = filteredSongs.map<SongData & { id: number }>((song) => ({
+      id: song.id,
+      song,
+      active: song.id === activeSongId,
+      parentActive,
+      highlight: song.id === playingSongId,
+    }));
+
+    if (activeAlbum !== null) {
+      return allSongs;
+    }
+
+    return Object.entries(groupBy(allSongs, ({ song }) => song.album)).reduce<ItemData[]>(
+      (last, [album, group], index) => [...last, { id: -index, album }, ...group],
+      [],
+    );
+  }, [parentActive, activeSongId, playingSongId, filteredSongs, activeAlbum]);
+
+  const getItemSize = useCallback(
+    (index: number): number => lineHeight * (isSeparator(itemData[index]) ? 2 : 1),
+    [itemData],
   );
 
   const windowRef = useRef<HTMLDivElement>(null);
@@ -79,11 +109,12 @@ export const Songs: React.FC<Props> = ({ active: parentActive }) => {
       <AutoSizer>
         {({ height, width }): React.ReactElement => (
           <List
+            key={`${activeArtist}-${activeAlbum}`}
             outerRef={windowRef}
             height={height}
             width={width}
             itemCount={itemData.length}
-            itemSize={lineHeight}
+            itemSize={getItemSize}
             itemKey={itemKey}
             itemData={itemData}
           >

+ 5 - 0
gmus-web/yarn.lock

@@ -1893,6 +1893,11 @@
   resolved "https://registry.yarnpkg.com/@types/json5/-/json5-0.0.29.tgz#ee28707ae94e11d2b827bcbe5270bcea7f3e71ee"
   integrity sha1-7ihweulOEdK4J7y+UnC86n8+ce4=
 
+"@types/lodash@^4.14.165":
+  version "4.14.165"
+  resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.14.165.tgz#74d55d947452e2de0742bad65270433b63a8c30f"
+  integrity sha512-tjSSOTHhI5mCHTy/OOXYIhi2Wt1qcbHmuXD1Ha7q70CgI/I71afO4XtLb/cVexki1oVYchpul/TOuu3Arcdxrg==
+
 "@types/minimatch@*":
   version "3.0.3"
   resolved "https://registry.yarnpkg.com/@types/minimatch/-/minimatch-3.0.3.tgz#3dca0e3f33b200fc7d1139c0cd96c1268cadfd9d"