Forráskód Böngészése

feat(initial-ui-demo): switch between artists and play songs

Fela Maslen 5 éve
szülő
commit
a070b6ea52

+ 32 - 40
gmus/src/components/ui/cmus/artists/index.tsx

@@ -1,9 +1,10 @@
 import { useDebounce } from '@react-hook/debounce';
-import React, { useCallback, useContext, useEffect, useState } from 'react';
+import React, { useCallback, useContext, useEffect, useMemo, useState } from 'react';
 
 import { useArtistsAlbumsAndSongs } from '../../../../hooks/fetch/artists';
-import { Keys, useKeyBinding, useVerticalScrollBindings } from '../../../../hooks/vim';
+import { BoundAction, Key, useVerticalScrollBindings } from '../../../../hooks/vim';
 import { Song } from '../../../../types/songs';
+import { scrollThroughItems } from '../../../../utils/delta';
 import { LibraryDispatchContext, LibraryStateContext } from '../context';
 
 import * as Styled from './styles';
@@ -17,27 +18,17 @@ type PropsArtist = {
   albums?: string[];
   songs?: Song[];
   active: boolean;
+  parentActive: boolean;
   expanded: boolean;
   activeAlbum: string | null;
 };
 
-type ArtistKeyBindingsProps = {
-  artist: string;
-  toggleExpanded: (artist: string) => void;
-};
-
-const ArtistKeyBindings: React.FC<ArtistKeyBindingsProps> = ({ artist, toggleExpanded }) => {
-  const toggleMeExpanded = useCallback(() => toggleExpanded(artist), [artist, toggleExpanded]);
-  useKeyBinding(Keys.Space, toggleMeExpanded);
-
-  return null;
-};
-
 const Artist: React.FC<PropsArtist> = ({
   artist,
   albums,
   songs,
   active,
+  parentActive,
   expanded,
   activeAlbum,
 }) => {
@@ -45,13 +36,19 @@ const Artist: React.FC<PropsArtist> = ({
 
   useEffect(() => {
     if (active) {
-      dispatch((last) => ({ ...last, visibleSongs: songs ?? [] }));
+      dispatch((last) => ({
+        ...last,
+        visibleSongs: songs ?? [],
+        activeSong: songs?.[0]?.id ?? null,
+      }));
     }
   }, [dispatch, songs, active]);
 
   return (
     <Styled.ArtistRow key={artist}>
-      <Styled.ArtistTitle active={active}>{artist || 'Unknown Artist'}</Styled.ArtistTitle>
+      <Styled.ArtistTitle active={active} parentActive={parentActive}>
+        {artist || 'Unknown Artist'}
+      </Styled.ArtistTitle>
       {expanded && (
         <Styled.ArtistAlbums>
           {albums?.map((album) => (
@@ -65,15 +62,18 @@ const Artist: React.FC<PropsArtist> = ({
   );
 };
 
-export const Artists: React.FC<Props> = () => {
+export const Artists: React.FC<Props> = ({ active }) => {
   const dispatch = useContext(LibraryDispatchContext);
   const { artists, activeArtist, activeAlbum } = useContext(LibraryStateContext);
 
   const [expandedArtists, setExpandedArtists] = useState<Record<string, boolean>>({});
 
-  const toggleArtistExpanded = useCallback(
-    (artist: string) => setExpandedArtists((last) => ({ ...last, [artist]: !last[artist] })),
-    [],
+  const toggleActiveArtistExpanded = useCallback(
+    () =>
+      setExpandedArtists((last) =>
+        activeArtist ? { ...last, [activeArtist]: !last[activeArtist] } : last,
+      ),
+    [activeArtist],
   );
 
   const [debouncedActiveArtist, setDebouncedActiveArtist] = useDebounce(activeArtist, 100);
@@ -102,34 +102,25 @@ export const Artists: React.FC<Props> = () => {
     (delta: number) =>
       dispatch((last) => ({
         ...last,
-        activeArtist:
-          last.artists[
-            Math.max(
-              0,
-              Math.min(
-                last.artists.length - 1,
-                last.artists.findIndex((compare) => compare === last.activeArtist) + delta,
-              ),
-            )
-          ],
+        activeSong: null,
+        activeArtist: scrollThroughItems(
+          last.artists,
+          (compare) => compare === last.activeArtist,
+          delta,
+        ),
       })),
     [dispatch],
   );
 
-  const scrollDown = useCallback(() => scrollDelta(1), [scrollDelta]);
-  const scrollUp = useCallback(() => scrollDelta(-1), [scrollDelta]);
+  const boundActions = useMemo<BoundAction[]>(
+    () => [{ binding: Key.Toggle, action: toggleActiveArtistExpanded }],
+    [toggleActiveArtistExpanded],
+  );
 
-  useVerticalScrollBindings(scrollDown, scrollUp);
+  useVerticalScrollBindings(active, scrollDelta, boundActions);
 
   return (
     <Styled.Container>
-      {activeArtist && (
-        <ArtistKeyBindings
-          key={activeArtist}
-          artist={activeArtist}
-          toggleExpanded={toggleArtistExpanded}
-        />
-      )}
       {artists.map((artist) => (
         <Artist
           key={artist}
@@ -137,6 +128,7 @@ export const Artists: React.FC<Props> = () => {
           albums={albumsCache[artist]}
           songs={songsCache[artist]}
           active={artist === activeArtist}
+          parentActive={active}
           expanded={!!expandedArtists[artist]}
           activeAlbum={activeAlbum}
         />

+ 1 - 0
gmus/src/components/ui/cmus/context.ts

@@ -7,6 +7,7 @@ export const initialLibraryState: LibraryState = {
   artists: [],
   activeArtist: null,
   activeAlbum: null,
+  activeSong: null,
   modeWindow: ModeWindow.ArtistList,
   visibleSongs: [],
 };

+ 6 - 4
gmus/src/components/ui/cmus/index.tsx

@@ -1,6 +1,7 @@
 import React, { useCallback, useMemo, useState } from 'react';
 import {
   BoundAction,
+  Key,
   KeyPressedContext,
   useMultiKeyBinding,
   useVimBindings,
@@ -22,11 +23,12 @@ export const CmusUIProvider: UIProviderComponent = () => {
 
   const setLibrary = useCallback(() => setView(View.Library), []);
 
-  const boundActions = useMemo<BoundAction[]>(() => [{ binding: '1', action: setLibrary }], [
-    setLibrary,
-  ]);
+  const boundActions = useMemo<BoundAction[]>(
+    () => [{ binding: Key.ModeLibrary, action: setLibrary }],
+    [setLibrary],
+  );
 
-  useMultiKeyBinding(boundActions);
+  useMultiKeyBinding(true, boundActions);
 
   return (
     <KeyPressedContext.Provider value={lastKeyPress}>

+ 26 - 3
gmus/src/components/ui/cmus/shared/layout.ts

@@ -11,10 +11,33 @@ export const FlexColumn = styled(FlexRow)`
   flex-flow: column;
 `;
 
-export const ActiveHighlightRow = styled(FlexRow)<{ active?: boolean; highlight?: boolean }>`
-  background: ${({ active }): string => (active ? colors.selected.background : 'none')};
-  color: ${({ highlight }): string => (highlight ? colors.active.color : colors.foreground)};
+export const ActiveHighlightRow = styled(FlexRow)<{
+  active?: boolean;
+  highlight?: boolean;
+  parentActive?: boolean;
+}>`
+  background: ${({ active, parentActive }): string => {
+    if (active) {
+      if (parentActive) {
+        return colors.selected.background;
+      }
+      return colors.selected.inactive;
+    }
+    return 'none';
+  }};
+
+  color: ${({ active, highlight, parentActive }): string => {
+    if (highlight) {
+      return colors.active.color;
+    }
+    if (active && !parentActive) {
+      return colors.background;
+    }
+    return colors.foreground;
+  }};
+
   font-weight: ${({ active, highlight }): CSSProperties['fontWeight'] =>
     active || highlight ? 'bold' : 'normal'};
+
   width: 100%;
 `;

+ 39 - 5
gmus/src/components/ui/cmus/songs/index.tsx

@@ -1,6 +1,10 @@
-import React, { useContext } from 'react';
+import React, { useCallback, useContext, useMemo } from 'react';
+import { stateSet } from '../../../../actions';
+import { DispatchContext } from '../../../../context/state';
+import { BoundAction, Key, useVerticalScrollBindings } from '../../../../hooks/vim';
+import { scrollThroughItems } from '../../../../utils/delta';
 
-import { LibraryStateContext } from '../context';
+import { LibraryDispatchContext, LibraryStateContext } from '../context';
 
 import * as Styled from './styles';
 
@@ -8,13 +12,43 @@ type Props = {
   active: boolean;
 };
 
-export const Songs: React.FC<Props> = () => {
-  const { visibleSongs: songs } = useContext(LibraryStateContext);
+export const Songs: React.FC<Props> = ({ active }) => {
+  const dispatchGlobal = useContext(DispatchContext);
+
+  const dispatchLibrary = useContext(LibraryDispatchContext);
+  const { visibleSongs: songs, activeSong } = useContext(LibraryStateContext);
+
+  const playActiveSong = useCallback(() => {
+    dispatchGlobal(
+      stateSet({
+        playing: true,
+        songId: activeSong,
+      }),
+    );
+  }, [dispatchGlobal, activeSong]);
+
+  const scrollDelta = useCallback(
+    (delta: number) => {
+      dispatchLibrary((last) => ({
+        ...last,
+        activeSong: scrollThroughItems(songs, (compare) => compare.id === last.activeSong, delta)
+          .id,
+      }));
+    },
+    [dispatchLibrary, songs],
+  );
+
+  const boundActions = useMemo<BoundAction[]>(
+    () => [{ binding: Key.Activate, action: playActiveSong }],
+    [playActiveSong],
+  );
+
+  useVerticalScrollBindings(active, scrollDelta, boundActions);
 
   return (
     <Styled.Container>
       {songs.map((song) => (
-        <Styled.Song key={song.id}>
+        <Styled.Song key={song.id} active={song.id === activeSong} parentActive={active}>
           {song.track} - {song.title || 'Untitled Track'}
         </Styled.Song>
       ))}

+ 3 - 2
gmus/src/components/ui/cmus/types.ts

@@ -1,14 +1,15 @@
 import { Song } from '../../../types/songs';
 
 export enum ModeWindow {
-  ArtistList,
-  SongList,
+  ArtistList = 'ArtistList',
+  SongList = 'SongList',
 }
 
 export type LibraryState = {
   artists: string[];
   activeArtist: string | null;
   activeAlbum: string | null;
+  activeSong: number | null;
   modeWindow: ModeWindow;
   visibleSongs: Song[];
 };

+ 1 - 0
gmus/src/components/ui/cmus/variables.ts

@@ -6,6 +6,7 @@ export const colors = {
   foreground: rgb(255, 255, 255),
   selected: {
     background: rgb(0, 0, 180),
+    inactive: rgb(210, 210, 210),
   },
   active: {
     color: rgb(255, 255, 130),

+ 3 - 3
gmus/src/components/ui/cmus/views/library.tsx

@@ -1,14 +1,14 @@
 import React, { useCallback, useEffect, useState } from 'react';
 
 import { useArtists } from '../../../../hooks/fetch/artists';
-import { useKeyBinding } from '../../../../hooks/vim';
+import { Key, useKeyBinding } from '../../../../hooks/vim';
 
 import { Artists } from '../artists';
 import { initialLibraryState, LibraryDispatchContext, LibraryStateContext } from '../context';
 import { Songs } from '../songs';
 import { LibraryState, ModeWindow } from '../types';
 
-const modeWindows: ModeWindow[] = Object.values(ModeWindow) as ModeWindow[];
+const modeWindows: ModeWindow[] = Object.values(ModeWindow);
 
 export const ViewLibrary: React.FC = () => {
   const [state, dispatch] = useState<LibraryState>(initialLibraryState);
@@ -20,7 +20,7 @@ export const ViewLibrary: React.FC = () => {
     }));
   }, []);
 
-  useKeyBinding('Tab', switchModeWindow);
+  useKeyBinding(true, Key.Switch, switchModeWindow);
 
   const { artists } = useArtists();
   useEffect(() => {

+ 27 - 15
gmus/src/hooks/vim.ts

@@ -1,10 +1,13 @@
-import { createContext, useContext, useEffect, useMemo, useState } from 'react';
+import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react';
 
 export const KeyPressedContext = createContext<string | null>(null);
 
-export enum Keys {
-  Space = ' ',
+export enum Key {
   Down = 'j',
+  Activate = 'Enter',
+  ModeLibrary = '1',
+  Switch = 'Tab',
+  Toggle = ' ',
   Up = 'k',
 }
 
@@ -12,6 +15,7 @@ export function useVimBindings(): string | null {
   const [state, dispatch] = useState<string | null>(null);
   useEffect(() => {
     const listener = (event: KeyboardEvent): void => {
+      event.preventDefault();
       dispatch(event.key);
       dispatch(null);
     };
@@ -24,36 +28,44 @@ export function useVimBindings(): string | null {
   return state;
 }
 
-export function useKeyBinding(binding: string, action: () => void): void {
+export function useKeyBinding(active: boolean, binding: Key, action: () => void): void {
   const lastKeyPress = useContext(KeyPressedContext);
   useEffect(() => {
-    if (lastKeyPress === binding) {
+    if (active && lastKeyPress === binding) {
       action();
     }
-  }, [binding, action, lastKeyPress]);
+  }, [active, binding, action, lastKeyPress]);
 }
 
 export type BoundAction = {
-  binding: string;
+  binding: Key;
   action: () => void;
 };
 
-export function useMultiKeyBinding(boundActions: BoundAction[]): void {
+export function useMultiKeyBinding(active: boolean, boundActions: BoundAction[]): void {
   const lastKeyPress = useContext(KeyPressedContext);
   useEffect(() => {
-    if (lastKeyPress) {
+    if (active && lastKeyPress) {
       boundActions.find(({ binding }) => binding === lastKeyPress)?.action();
     }
-  }, [boundActions, lastKeyPress]);
+  }, [active, boundActions, lastKeyPress]);
 }
 
-export function useVerticalScrollBindings(scrollDown: () => void, scrollUp: () => void): void {
+export function useVerticalScrollBindings(
+  active: boolean,
+  scrollDelta: (delta: number) => unknown,
+  additionalBindings: BoundAction[] = [],
+): void {
+  const scrollDown = useCallback(() => scrollDelta(1), [scrollDelta]);
+  const scrollUp = useCallback(() => scrollDelta(-1), [scrollDelta]);
+
   const boundActions = useMemo<BoundAction[]>(
     () => [
-      { binding: Keys.Down, action: scrollDown },
-      { binding: Keys.Up, action: scrollUp },
+      { binding: Key.Down, action: scrollDown },
+      { binding: Key.Up, action: scrollUp },
+      ...additionalBindings,
     ],
-    [scrollDown, scrollUp],
+    [scrollDown, scrollUp, additionalBindings],
   );
-  useMultiKeyBinding(boundActions);
+  useMultiKeyBinding(active, boundActions);
 }

+ 7 - 0
gmus/src/utils/delta.ts

@@ -0,0 +1,7 @@
+export function scrollThroughItems<T>(
+  items: T[],
+  predicate: (item: T) => boolean,
+  delta: number,
+): T {
+  return items[Math.max(0, Math.min(items.length - 1, items.findIndex(predicate) + delta))];
+}