From 3f78031bcf029071c4fc6c0381ecd09dddc752ff Mon Sep 17 00:00:00 2001 From: Tanner Villarete Date: Tue, 21 Sep 2021 17:16:25 -0700 Subject: [PATCH] [Spotify]: Implement Search (#76) * Implement KeyboardInput component & hook * Add pluralize * DataFetcher: Add spotify search results fetcher * Search: Support artists/albums/playlists/songs for spotify * Update yarn.lock * Move search into Music, and gate behind conditional flag Search is only supported by Spotify for now. I'll work on getting Apple Music set up in a followup PR * Add default artist icon if no artwork available --- package.json | 2 + public/albums_icon.svg | 20 ++ public/artists_icon.svg | 12 ++ public/playlist_icon.svg | 19 ++ public/search_icon.svg | 11 + public/song_icon.svg | 31 +++ .../KeyboardWindowManager/index.tsx | 41 ++++ .../components/KeyboardInput.tsx | 200 ++++++++++++++++++ .../WindowManager/components/index.ts | 1 + src/components/WindowManager/index.tsx | 5 + src/components/views/AlbumsView/index.tsx | 19 +- src/components/views/ArtistsView/index.tsx | 25 ++- src/components/views/HomeView/index.tsx | 3 +- src/components/views/MusicView/index.tsx | 25 ++- src/components/views/PlaylistsView/index.tsx | 19 +- src/components/views/SearchView/index.tsx | 134 ++++++++++++ src/components/views/SongsView/index.tsx | 43 ++++ src/components/views/index.ts | 26 ++- src/hooks/audio/useAudioPlayer.tsx | 11 +- src/hooks/navigation/index.ts | 1 + src/hooks/navigation/useKeyboardInput.tsx | 98 +++++++++ src/hooks/spotify/useSpotifyDataFetcher.ts | 18 ++ src/hooks/utils/useDataFetcher.ts | 51 ++++- src/providers/WindowProvider.tsx | 6 + src/types/Ipod.API.d.ts | 7 + src/utils/conversion.ts | 12 ++ yarn.lock | 28 ++- 27 files changed, 826 insertions(+), 42 deletions(-) create mode 100644 public/albums_icon.svg create mode 100644 public/artists_icon.svg create mode 100644 public/playlist_icon.svg create mode 100644 public/search_icon.svg create mode 100644 public/song_icon.svg create mode 100644 src/components/WindowManager/KeyboardWindowManager/index.tsx create mode 100644 src/components/WindowManager/components/KeyboardInput.tsx create mode 100644 src/components/views/SearchView/index.tsx create mode 100644 src/components/views/SongsView/index.tsx create mode 100644 src/hooks/navigation/useKeyboardInput.tsx diff --git a/package.json b/package.json index 1f425b2..a7dbf02 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,7 @@ "framer-motion": "^4.0.3", "he": "^1.2.0", "long-press-event": "^2.4.4", + "pluralize": "^8.0.0", "prettier": "^2.3.1", "query-string": "^7.0.1", "react": "^17.0.1", @@ -48,6 +49,7 @@ "@types/he": "^1.1.1", "@types/jest": "24.0.19", "@types/node": "12.19.6", + "@types/pluralize": "^0.0.29", "@types/react": "17.0.1", "@types/react-dom": "^16.9.10", "@types/spotify-api": "^0.0.8", diff --git a/public/albums_icon.svg b/public/albums_icon.svg new file mode 100644 index 0000000..9d22732 --- /dev/null +++ b/public/albums_icon.svg @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/public/artists_icon.svg b/public/artists_icon.svg new file mode 100644 index 0000000..9da4709 --- /dev/null +++ b/public/artists_icon.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/public/playlist_icon.svg b/public/playlist_icon.svg new file mode 100644 index 0000000..b964cc7 --- /dev/null +++ b/public/playlist_icon.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/public/search_icon.svg b/public/search_icon.svg new file mode 100644 index 0000000..270b989 --- /dev/null +++ b/public/search_icon.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/public/song_icon.svg b/public/song_icon.svg new file mode 100644 index 0000000..95be003 --- /dev/null +++ b/public/song_icon.svg @@ -0,0 +1,31 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/components/WindowManager/KeyboardWindowManager/index.tsx b/src/components/WindowManager/KeyboardWindowManager/index.tsx new file mode 100644 index 0000000..178132c --- /dev/null +++ b/src/components/WindowManager/KeyboardWindowManager/index.tsx @@ -0,0 +1,41 @@ +import { AnimatePresence } from 'framer-motion'; +import { WindowOptions } from 'providers/WindowProvider'; +import styled from 'styled-components'; + +import KeyboardInput from '../components/KeyboardInput'; + +interface ContainerProps { + isHidden: boolean; +} + +const RootContainer = styled.div` + z-index: 4; + position: absolute; + height: 100%; + width: 100%; +`; + +interface Props { + windowStack: WindowOptions[]; +} + +const KeyboardWindowManager = ({ windowStack }: Props) => { + const isHidden = windowStack.length === 0; + + return ( + + + {windowStack.map((window, index) => ( + + ))} + + + ); +}; + +export default KeyboardWindowManager; diff --git a/src/components/WindowManager/components/KeyboardInput.tsx b/src/components/WindowManager/components/KeyboardInput.tsx new file mode 100644 index 0000000..9203a5a --- /dev/null +++ b/src/components/WindowManager/components/KeyboardInput.tsx @@ -0,0 +1,200 @@ +import { useCallback, useEffect, useMemo, useRef } from 'react'; + +import { popInAnimation } from 'animation'; +import { SelectableListOption } from 'components'; +import { WINDOW_TYPE } from 'components/views'; +import { motion } from 'framer-motion'; +import { useKeyboardInput, useMenuHideWindow, useScrollHandler } from 'hooks'; +import { WindowOptions } from 'providers/WindowProvider'; +import styled, { css } from 'styled-components'; +import { Unit } from 'utils/constants'; + +interface RootContainerProps { + index: number; +} + +/** Responsible for putting the window at the proper z-index. */ +export const RootContainer = styled(motion.div)` + z-index: ${(props) => props.index}; + position: absolute; + bottom: 12px; + left: 0; + right: 0; + display: flex; + align-items: center; + justify-content: center; +`; + +interface ContentTransitionContainerProps { + isHidden: boolean; +} + +/** Slides the view in from the bottom if it is at the top of the stack. */ +const ContentTransitionContainer = styled.div` + position: relative; + width: 95%; + padding: 12px 8px; + background: linear-gradient( + 180deg, + #ffffff 0%, + #a9a9a9 9.7%, + #232323 50.24%, + #181d1b 89.58% + ); + box-shadow: 0px 4px 5px rgba(0, 0, 0, 0.39); + border-radius: 8px; + color: white; +`; + +const OptionsContainer = styled.div` + display: flex; + background: linear-gradient(180deg, #000000 0%, rgba(102, 102, 102, 0) 100%); + border: 1px solid #454545; + box-sizing: border-box; + box-shadow: inset 0px 0px 3px #000000; + border-radius: 4px; + overflow: hidden; +`; + +const OptionText = styled.h3` + margin: 0; + line-height: 0.6; + padding: ${Unit.XS} ${Unit.XXS}; + font-size: 16px; + border-radius: 8px; + width: 20px; +`; + +const OptionContainer = styled.div<{ highlighted: boolean }>` + text-align: center; + + ${({ highlighted }) => + highlighted && + css` + background: linear-gradient(180deg, #8aebf7 0%, #258af9 100%); + border-radius: 4px; + `}; +`; + +const keyboardOptions = [ + { key: 'Enter', label: '✓' }, + { key: 'delete', label: '⌫' }, + { key: ' ', label: '␣' }, + { key: 'a', label: 'A' }, + { key: 'b', label: 'B' }, + { key: 'c', label: 'C' }, + { key: 'd', label: 'D' }, + { key: 'e', label: 'E' }, + { key: 'f', label: 'F' }, + { key: 'g', label: 'G' }, + { key: 'h', label: 'H' }, + { key: 'i', label: 'I' }, + { key: 'j', label: 'J' }, + { key: 'k', label: 'K' }, + { key: 'l', label: 'L' }, + { key: 'm', label: 'M' }, + { key: 'n', label: 'N' }, + { key: 'o', label: 'O' }, + { key: 'p', label: 'P' }, + { key: 'q', label: 'Q' }, + { key: 'r', label: 'R' }, + { key: 's', label: 'S' }, + { key: 't', label: 'T' }, + { key: 'u', label: 'U' }, + { key: 'v', label: 'V' }, + { key: 'w', label: 'W' }, + { key: 'x', label: 'X' }, + { key: 'y', label: 'Y' }, + { key: 'z', label: 'Z' }, + { key: '1', label: '1' }, + { key: '2', label: '2' }, + { key: '3', label: '3' }, + { key: '4', label: '4' }, + { key: '5', label: '5' }, + { key: '6', label: '6' }, + { key: '7', label: '7' }, + { key: '8', label: '8' }, + { key: '9', label: '9' }, + { key: '0', label: '0' }, +]; + +interface Props { + windowStack: WindowOptions[]; + index: number; + isHidden: boolean; +} + +const KeyboardInput = ({ windowStack, index, isHidden }: Props) => { + const windowOptions = windowStack[index]; + useMenuHideWindow(windowOptions.id); + const containerRef = useRef(null); + + if (windowOptions.type !== WINDOW_TYPE.KEYBOARD) { + throw new Error('Keyboard option not supplied'); + } + + useKeyboardInput({ + initialValue: windowOptions.initialValue, + readOnly: false, + }); + + const handleSelect = useCallback( + (key: string) => { + switch (key) { + default: + const inputEvent = new CustomEvent('input', { + detail: { + id: windowOptions.id, + key, + }, + }); + window.dispatchEvent(inputEvent); + break; + } + }, + [windowOptions.id] + ); + + const listOptions: SelectableListOption[] = useMemo(() => { + return keyboardOptions.map((option) => ({ + type: 'Action', + label: option.label, + onSelect: () => handleSelect(option.key), + })); + }, [handleSelect]); + + const [scrollIndex] = useScrollHandler(windowOptions.id, listOptions); + + /** Always make sure the selected item is within the screen's view. */ + useEffect(() => { + if (containerRef.current) { + const { children } = containerRef.current; + children[scrollIndex]?.scrollIntoView({ + block: 'nearest', + }); + } + }, [scrollIndex]); + + return ( + + + + {listOptions.map((option, i) => ( + + {option.label} + + ))} + + + + ); +}; + +export default KeyboardInput; diff --git a/src/components/WindowManager/components/index.ts b/src/components/WindowManager/components/index.ts index b8453cf..f78aa60 100644 --- a/src/components/WindowManager/components/index.ts +++ b/src/components/WindowManager/components/index.ts @@ -1,3 +1,4 @@ export { default as ActionSheet } from './ActionSheet'; export { default as Popup } from './Popup'; +export { default as KeyboardInput } from './KeyboardInput'; export { default as Window } from './Window'; diff --git a/src/components/WindowManager/index.tsx b/src/components/WindowManager/index.tsx index 8119d4b..f3d3efa 100644 --- a/src/components/WindowManager/index.tsx +++ b/src/components/WindowManager/index.tsx @@ -7,6 +7,7 @@ import { IpodEvent } from 'utils/events'; import ActionSheetWindowManager from './ActionSheetWindowManager'; import CoverFlowWindowManager from './CoverFlowWindowManager'; import FullScreenWindowManager from './FullScreenWindowManager'; +import KeyboardWindowManager from './KeyboardWindowManager'; import PopupWindowManager from './PopupWindowManager'; import SplitScreenWindowManager from './SplitScreenWindowManager'; @@ -38,6 +39,9 @@ const WindowManager = () => { const popupWindows = windowStack.filter( (window) => window.type === WINDOW_TYPE.POPUP ); + const keyboardWindows = windowStack.filter( + (window) => window.type === WINDOW_TYPE.KEYBOARD + ); const isReady = isConfigured && hasAppleDevToken; @@ -56,6 +60,7 @@ const WindowManager = () => { + ) : ( diff --git a/src/components/views/AlbumsView/index.tsx b/src/components/views/AlbumsView/index.tsx index 9836ac1..7fabdb5 100644 --- a/src/components/views/AlbumsView/index.tsx +++ b/src/components/views/AlbumsView/index.tsx @@ -11,25 +11,34 @@ import * as Utils from 'utils'; import ViewOptions, { AlbumView } from '../'; -const AlbumsView = () => { +interface Props { + albums?: IpodApi.Album[]; + inLibrary?: boolean; +} + +const AlbumsView = ({ albums, inLibrary = true }: Props) => { const { isAuthorized } = useSettings(); useMenuHideWindow(ViewOptions.albums.id); - const { data: albums, isLoading } = useDataFetcher({ + const { data: fetchedAlbums, isLoading } = useDataFetcher({ name: 'albums', + // Don't fetch if we're passed an initial array of albums + lazy: !!albums, }); const options: SelectableListOption[] = useMemo( () => - albums?.map((album) => ({ + (albums ?? fetchedAlbums)?.map((album) => ({ type: 'View', label: album.name, sublabel: album.artistName, imageUrl: Utils.getArtwork(50, album.artwork?.url), viewId: ViewOptions.album.id, - component: () => , + component: () => ( + + ), })) ?? [], - [albums] + [albums, fetchedAlbums, inLibrary] ); const [scrollIndex] = useScrollHandler(ViewOptions.albums.id, options); diff --git a/src/components/views/ArtistsView/index.tsx b/src/components/views/ArtistsView/index.tsx index 2112905..a555b20 100644 --- a/src/components/views/ArtistsView/index.tsx +++ b/src/components/views/ArtistsView/index.tsx @@ -7,25 +7,40 @@ import { useScrollHandler, useSettings, } from 'hooks'; +import * as Utils from 'utils'; import ViewOptions, { ArtistView } from '../'; -const ArtistsView = () => { +interface Props { + artists?: IpodApi.Artist[]; + inLibrary?: boolean; + showImages?: boolean; +} + +const ArtistsView = ({ + artists, + inLibrary = true, + showImages = false, +}: Props) => { useMenuHideWindow(ViewOptions.artists.id); const { isAuthorized } = useSettings(); - const { data: artists, isLoading } = useDataFetcher({ + const { data: fetchedArtists, isLoading } = useDataFetcher({ name: 'artists', + lazy: !!artists, }); const options: SelectableListOption[] = useMemo( () => - artists?.map((artist) => ({ + (artists ?? fetchedArtists)?.map((artist) => ({ type: 'View', label: artist.name, viewId: ViewOptions.artist.id, - component: () => , + imageUrl: showImages + ? Utils.getArtwork(50, artist.artwork?.url) ?? 'artists_icon.svg' + : '', + component: () => , })) ?? [], - [artists] + [artists, fetchedArtists, inLibrary, showImages] ); const [scrollIndex] = useScrollHandler(ViewOptions.artists.id, options); diff --git a/src/components/views/HomeView/index.tsx b/src/components/views/HomeView/index.tsx index 3dc5089..385cd5c 100644 --- a/src/components/views/HomeView/index.tsx +++ b/src/components/views/HomeView/index.tsx @@ -104,7 +104,8 @@ const HomeView = () => { const shouldShowNowPlaying = !!nowPlayingItem && activeView.id !== ViewOptions.nowPlaying.id && - activeView.id !== ViewOptions.coverFlow.id; + activeView.id !== ViewOptions.coverFlow.id && + activeView.id !== ViewOptions.keyboard.id; // Only show the now playing view if we're playing a song and not already on that view. if (shouldShowNowPlaying) { diff --git a/src/components/views/MusicView/index.tsx b/src/components/views/MusicView/index.tsx index 42be7eb..361cc12 100644 --- a/src/components/views/MusicView/index.tsx +++ b/src/components/views/MusicView/index.tsx @@ -1,6 +1,10 @@ import React, { useMemo } from 'react'; -import { SelectableList, SelectableListOption } from 'components'; +import { + getConditionalOption, + SelectableList, + SelectableListOption, +} from 'components'; import { PREVIEW } from 'components/previews'; import { AlbumsView, @@ -8,11 +12,18 @@ import { CoverFlowView, NowPlayingView, PlaylistsView, + SearchView, ViewOptions, } from 'components/views'; -import { useMenuHideWindow, useMusicKit, useScrollHandler } from 'hooks'; +import { + useMenuHideWindow, + useMusicKit, + useScrollHandler, + useSettings, +} from 'hooks'; const MusicView = () => { + const { service } = useSettings(); const { music } = useMusicKit(); useMenuHideWindow(ViewOptions.music.id); @@ -46,6 +57,14 @@ const MusicView = () => { component: () => , preview: PREVIEW.MUSIC, }, + // Search functionality is only supported with Spotify (for now...) + ...getConditionalOption(service === 'spotify', { + type: 'View', + label: 'Search', + viewId: ViewOptions.search.id, + component: () => , + preview: PREVIEW.MUSIC, + }), ]; if (music.isAuthorized && music.player?.nowPlayingItem?.isPlayable) { @@ -59,7 +78,7 @@ const MusicView = () => { } return arr; - }, [music.isAuthorized, music.player?.nowPlayingItem?.isPlayable]); + }, [music.isAuthorized, music.player?.nowPlayingItem?.isPlayable, service]); const [scrollIndex] = useScrollHandler(ViewOptions.music.id, options); diff --git a/src/components/views/PlaylistsView/index.tsx b/src/components/views/PlaylistsView/index.tsx index cea7d6d..e17926f 100644 --- a/src/components/views/PlaylistsView/index.tsx +++ b/src/components/views/PlaylistsView/index.tsx @@ -11,25 +11,34 @@ import * as Utils from 'utils'; import ViewOptions, { PlaylistView } from '../'; -const PlaylistsView = () => { +interface Props { + playlists?: IpodApi.Playlist[]; + inLibrary?: boolean; +} + +const PlaylistsView = ({ playlists, inLibrary = true }: Props) => { useMenuHideWindow(ViewOptions.playlists.id); const { isAuthorized } = useSettings(); - const { data, isLoading } = useDataFetcher({ + const { data: fetchedPlaylists, isLoading } = useDataFetcher< + IpodApi.Playlist[] + >({ name: 'playlists', }); const options: SelectableListOption[] = useMemo( () => - data?.map((playlist) => ({ + (playlists ?? fetchedPlaylists)?.map((playlist) => ({ type: 'View', label: playlist.name, sublabel: playlist.description || `By ${playlist.curatorName}`, imageUrl: playlist.artwork?.url, viewId: ViewOptions.playlist.id, - component: () => , + component: () => ( + + ), longPressOptions: Utils.getMediaOptions('playlist', playlist.id), })) ?? [], - [data] + [fetchedPlaylists, inLibrary, playlists] ); const [scrollIndex] = useScrollHandler(ViewOptions.playlists.id, options); diff --git a/src/components/views/SearchView/index.tsx b/src/components/views/SearchView/index.tsx new file mode 100644 index 0000000..c0d336b --- /dev/null +++ b/src/components/views/SearchView/index.tsx @@ -0,0 +1,134 @@ +import React, { useCallback, useMemo, useState } from 'react'; + +import { + AlbumsView, + ArtistsView, + AuthPrompt, + getConditionalOption, + PlaylistsView, + SelectableList, + SelectableListOption, +} from 'components'; +import { SongsView, ViewOptions } from 'components/views'; +import { + useDataFetcher, + useEffectOnce, + useKeyboardInput, + useMenuHideWindow, + useScrollHandler, + useSettings, +} from 'hooks'; +import pluralize from 'pluralize'; + +const SearchView = () => { + useMenuHideWindow(ViewOptions.search.id); + const { isAuthorized } = useSettings(); + const [searchTerm, setSearchTerm] = useState(''); + + const { + fetch, + data: searchResults, + isLoading, + } = useDataFetcher({ + name: 'search', + query: searchTerm, + lazy: true, + }); + + const handleEnterPress = useCallback(() => { + if (searchTerm) { + fetch(); + } + }, [fetch, searchTerm]); + + const { showKeyboard } = useKeyboardInput({ + onChange: (value) => setSearchTerm(value), + onEnterPress: handleEnterPress, + }); + + const options: SelectableListOption[] = useMemo(() => { + const artists = searchResults?.artists; + const albums = searchResults?.albums; + const songs = searchResults?.songs; + const playlists = searchResults?.playlists; + + const arr: SelectableListOption[] = [ + { + type: 'Action', + label: 'Search', + sublabel: searchTerm + ? `Results for: ${searchTerm}` + : 'Enter text to search', + imageUrl: 'search.svg', + onSelect: showKeyboard, + }, + ...getConditionalOption(!!artists?.length, { + type: 'View', + label: 'Artists', + viewId: ViewOptions.artists.id, + component: () => ( + + ), + imageUrl: 'artists_icon.svg', + sublabel: `${artists?.length} ${pluralize('artist', artists?.length)}`, + }), + ...getConditionalOption(!!albums?.length, { + type: 'View', + label: 'Albums', + viewId: ViewOptions.albums.id, + component: () => , + imageUrl: 'albums_icon.svg', + sublabel: `${albums?.length} ${pluralize('album', albums?.length)}`, + }), + ...getConditionalOption(!!songs?.length, { + type: 'View', + label: 'Songs', + viewId: ViewOptions.songs.id, + component: () => , + imageUrl: 'song_icon.svg', + sublabel: `${songs?.length} ${pluralize('song', songs?.length)}`, + }), + ...getConditionalOption(!!playlists?.length, { + type: 'View', + label: 'Playlists', + viewId: ViewOptions.playlists.id, + component: () => , + imageUrl: 'playlist_icon.svg', + sublabel: `${playlists?.length} ${pluralize( + 'playlist', + playlists?.length + )}`, + }), + ]; + + return arr; + }, [ + searchResults?.albums, + searchResults?.artists, + searchResults?.playlists, + searchResults?.songs, + searchTerm, + showKeyboard, + ]); + + useEffectOnce(() => { + if (isAuthorized) { + showKeyboard(); + } + }); + + const [scrollIndex] = useScrollHandler(ViewOptions.search.id, options); + + return isAuthorized ? ( + + ) : ( + + ); +}; + +export default SearchView; diff --git a/src/components/views/SongsView/index.tsx b/src/components/views/SongsView/index.tsx new file mode 100644 index 0000000..7d2a99e --- /dev/null +++ b/src/components/views/SongsView/index.tsx @@ -0,0 +1,43 @@ +import { useMemo } from 'react'; + +import { SelectableList, SelectableListOption } from 'components'; +import { ViewOptions } from 'components/views'; +import { useMenuHideWindow, useScrollHandler } from 'hooks'; +import * as Utils from 'utils'; + +interface Props { + songs: IpodApi.Song[]; +} + +const SongsView = ({ songs }: Props) => { + useMenuHideWindow(ViewOptions.songs.id); + + const options: SelectableListOption[] = useMemo( + () => + songs.map((song) => ({ + type: 'Song', + label: song.name, + sublabel: `${song.artistName} • ${song.albumName}`, + queueOptions: { + song, + startPosition: 0, + }, + imageUrl: Utils.getArtwork(50, song.artwork?.url), + showNowPlayingView: true, + longPressOptions: Utils.getMediaOptions('song', song.id), + })) ?? [], + [songs] + ); + + const [scrollIndex] = useScrollHandler(ViewOptions.songs.id, options); + + return ( + + ); +}; + +export default SongsView; diff --git a/src/components/views/index.ts b/src/components/views/index.ts index 880fd9c..8472f64 100644 --- a/src/components/views/index.ts +++ b/src/components/views/index.ts @@ -1,17 +1,19 @@ export { default as AboutView } from './AboutView'; +export { default as AlbumView } from './AlbumView'; +export { default as AlbumsView } from './AlbumsView'; +export { default as ArtistsView } from './ArtistsView'; +export { default as ArtistView } from './ArtistView'; +export { default as BrickGameView } from './BrickGameView'; export { default as CoverFlowView } from './CoverFlowView'; +export { default as GamesView } from './GamesView'; export { default as HomeView } from './HomeView'; export { default as MusicView } from './MusicView'; -export { default as ArtistsView } from './ArtistsView'; -export { default as ArtistView } from './ArtistView'; -export { default as AlbumsView } from './AlbumsView'; -export { default as AlbumView } from './AlbumView'; export { default as NowPlayingView } from './NowPlayingView'; -export { default as PlaylistsView } from './PlaylistsView'; export { default as PlaylistView } from './PlaylistView'; -export { default as GamesView } from './GamesView'; -export { default as BrickGameView } from './BrickGameView'; +export { default as PlaylistsView } from './PlaylistsView'; +export { default as SearchView } from './SearchView'; export { default as SettingsView } from './SettingsView'; +export { default as SongsView } from './SongsView'; export enum WINDOW_TYPE { SPLIT = 'SPLIT', @@ -19,6 +21,7 @@ export enum WINDOW_TYPE { COVER_FLOW = 'COVER_FLOW', ACTION_SHEET = 'ACTION_SHEET', POPUP = 'POPUP', + KEYBOARD = 'KEYBOARD', } type ViewOption = { @@ -40,6 +43,7 @@ export const ViewOptions: Record = { artist: { id: 'artist', title: 'Artist', type: WINDOW_TYPE.FULL }, albums: { id: 'albums', title: 'Albums', type: WINDOW_TYPE.FULL }, album: { id: 'album', title: 'Album', type: WINDOW_TYPE.FULL }, + songs: { id: 'songs', title: 'Songs', type: WINDOW_TYPE.FULL }, nowPlaying: { id: 'nowPlaying', title: 'Now Playing', @@ -47,6 +51,7 @@ export const ViewOptions: Record = { }, playlists: { id: 'playlists', title: 'Playlists', type: WINDOW_TYPE.FULL }, playlist: { id: 'playlist', title: 'Playlist', type: WINDOW_TYPE.FULL }, + search: { id: 'search', title: 'Search', type: WINDOW_TYPE.FULL }, brickGame: { id: 'brickGame', title: 'Brick', type: WINDOW_TYPE.FULL }, // CoverFlow view @@ -94,6 +99,13 @@ export const ViewOptions: Record = { title: 'Premium', type: WINDOW_TYPE.POPUP, }, + + // Keyboard + keyboard: { + id: 'keyboard', + title: 'Keyboard', + type: WINDOW_TYPE.KEYBOARD, + }, }; export default ViewOptions; diff --git a/src/hooks/audio/useAudioPlayer.tsx b/src/hooks/audio/useAudioPlayer.tsx index a602001..e1d82cf 100644 --- a/src/hooks/audio/useAudioPlayer.tsx +++ b/src/hooks/audio/useAudioPlayer.tsx @@ -6,7 +6,8 @@ import { useState, } from 'react'; -import { useEventListener, useMKEventListener } from 'hooks'; +import { ViewOptions } from 'components'; +import { useEventListener, useMKEventListener, useWindowContext } from 'hooks'; import * as ConversionUtils from 'utils/conversion'; import { IpodEvent } from 'utils/events'; @@ -51,6 +52,7 @@ interface Props { } export const AudioPlayerProvider = ({ children }: Props) => { + const { windowStack } = useWindowContext(); const { service, isSpotifyAuthorized, isAppleAuthorized } = useSettings(); const { spotifyPlayer, accessToken, deviceId } = useSpotifySDK(); const { music } = useMusicKit(); @@ -160,7 +162,10 @@ export const AudioPlayerProvider = ({ children }: Props) => { }, [music, service, spotifyPlayer]); const togglePlayPause = useCallback(async () => { - if (!nowPlayingItem) { + const activeWindow = windowStack[windowStack.length - 1]; + + // Don't toggle play/pause when using the on-screen keyboard. + if (!nowPlayingItem || activeWindow.id === ViewOptions.keyboard.id) { return; } @@ -180,7 +185,7 @@ export const AudioPlayerProvider = ({ children }: Props) => { default: throw new Error('Unable to play: service not specified'); } - }, [music, nowPlayingItem, service, spotifyPlayer]); + }, [music, nowPlayingItem, service, spotifyPlayer, windowStack]); const skipNext = useCallback(async () => { if (!nowPlayingItem) { diff --git a/src/hooks/navigation/index.ts b/src/hooks/navigation/index.ts index cc97136..c578561 100644 --- a/src/hooks/navigation/index.ts +++ b/src/hooks/navigation/index.ts @@ -1,3 +1,4 @@ export { default as useMenuHideWindow } from './useMenuHideWindow'; +export { default as useKeyboardInput } from './useKeyboardInput'; export { default as useScrollHandler } from './useScrollHandler'; export { default as useWindowContext } from './useWindowContext'; diff --git a/src/hooks/navigation/useKeyboardInput.tsx b/src/hooks/navigation/useKeyboardInput.tsx new file mode 100644 index 0000000..63f6076 --- /dev/null +++ b/src/hooks/navigation/useKeyboardInput.tsx @@ -0,0 +1,98 @@ +import { useCallback, useState } from 'react'; + +import { ViewOptions, WINDOW_TYPE } from 'components'; +import { useWindowContext } from 'hooks'; +import { useEventListener } from 'hooks/utils'; + +interface KeyboardInputHook { + value: string; + showKeyboard: () => void; +} + +interface Props { + initialValue?: string; + /** By default, we only allow dispatching of keypresses by the keyboard. + * This can be overridden by setting `readOnly` to false. */ + readOnly?: boolean; + onEnterPress?: () => void; + onChange?: (value: string) => void; +} + +const useKeyboardInput = ({ + initialValue = '', + readOnly = true, + onEnterPress = () => {}, + onChange = () => {}, +}: Props = {}): KeyboardInputHook => { + const { showWindow, hideWindow } = useWindowContext(); + const [value, setValue] = useState(initialValue); + + useEventListener('input', ({ detail }) => { + // TODO: Only trigger keyboard input for one screen at a time. + const { key } = detail; + + if (key === 'Enter') { + onEnterPress(); + hideWindow(ViewOptions.keyboard.id); + return; + } + + setValue((prevValue) => { + let newValue = prevValue; + + if (key === 'Backspace' || key === 'delete') { + newValue = prevValue.slice(0, -1); + } else if (key === ' ') { + newValue = `${prevValue} `; + } else { + newValue = `${prevValue}${key}`; + } + + onChange(newValue); + return newValue; + }); + }); + + const handleKeypress = useCallback( + ({ key }) => { + if (readOnly) { + return; + } + + const inputEvent = new CustomEvent('input', { + detail: { + key, + }, + }); + + window.dispatchEvent(inputEvent); + }, + [readOnly] + ); + + const handleKeydown = useCallback( + ({ key }) => { + if (key === 'Backspace') { + handleKeypress({ key }); + } + }, + [handleKeypress] + ); + + useEventListener('keypress', handleKeypress); + useEventListener('keydown', handleKeydown); + + const showKeyboard = useCallback(() => { + showWindow({ + id: ViewOptions.keyboard.id, + type: WINDOW_TYPE.KEYBOARD, + }); + }, [showWindow]); + + return { + value, + showKeyboard, + }; +}; + +export default useKeyboardInput; diff --git a/src/hooks/spotify/useSpotifyDataFetcher.ts b/src/hooks/spotify/useSpotifyDataFetcher.ts index 4c91a55..0fa3a30 100644 --- a/src/hooks/spotify/useSpotifyDataFetcher.ts +++ b/src/hooks/spotify/useSpotifyDataFetcher.ts @@ -132,6 +132,23 @@ const useSpotifyDataFetcher = () => { [accessToken] ); + const fetchSearchResults = useCallback( + async (query: string) => { + const response = await fetchSpotifyApi({ + endpoint: `search?q=${query}&type=track%2Cartist%2Calbum%2Cplaylist&limit=15`, + accessToken, + onError: (error) => { + throw new Error(error); + }, + }); + + if (response) { + return ConversionUtils.convertSpotifySearchResults(response); + } + }, + [accessToken] + ); + return { fetchAlbums, fetchAlbum, @@ -139,6 +156,7 @@ const useSpotifyDataFetcher = () => { fetchArtist, fetchPlaylists, fetchPlaylist, + fetchSearchResults, }; }; diff --git a/src/hooks/utils/useDataFetcher.ts b/src/hooks/utils/useDataFetcher.ts index b205d2d..f57b172 100644 --- a/src/hooks/utils/useDataFetcher.ts +++ b/src/hooks/utils/useDataFetcher.ts @@ -9,6 +9,8 @@ interface UserLibraryProps { interface CommonFetcherProps { name: string; + /** Data will not be fetched until the `fetch` function is called. */ + lazy?: boolean; } interface PlaylistsFetcherProps { @@ -40,6 +42,11 @@ interface ArtistFetcherProps extends UserLibraryProps { artworkSize?: number; } +interface SearchFetcherProps extends UserLibraryProps { + name: 'search'; + query: string; +} + type Props = CommonFetcherProps & ( | PlaylistsFetcherProps @@ -48,6 +55,7 @@ type Props = CommonFetcherProps & | AlbumFetcherProps | ArtistsFetcherProps | ArtistFetcherProps + | SearchFetcherProps ); const useDataFetcher = (props: Props) => { @@ -159,13 +167,30 @@ const useDataFetcher = (props: Props) => { [appleDataFetcher, service, spotifyDataFetcher] ); - const handleMount = useCallback(async () => { + const fetchSearchResults = useCallback( + async (options: SearchFetcherProps) => { + setIsLoading(true); + let searchResults: IpodApi.SearchResults | undefined; + + if (service === 'spotify') { + searchResults = await spotifyDataFetcher.fetchSearchResults( + options.query + ); + } + + setData(searchResults as TType); + setIsLoading(false); + }, + [service, spotifyDataFetcher] + ); + + const handleFetch = useCallback(async () => { setHasError(false); setIsLoading(true); switch (props.name) { case 'albums': - await fetchAlbums(); + fetchAlbums(); break; case 'album': await fetchAlbum(props); @@ -182,6 +207,9 @@ const useDataFetcher = (props: Props) => { case 'playlist': await fetchPlaylist(props); break; + case 'search': + await fetchSearchResults(props); + break; } setIsMounted(true); @@ -192,25 +220,40 @@ const useDataFetcher = (props: Props) => { fetchArtists, fetchPlaylist, fetchPlaylists, + fetchSearchResults, props, ]); useEffect(() => { + // Data fetching will be manually triggered when lazy is true. + if (props.lazy) { + setIsLoading(false); + return; + } + if ( !isMounted && ((service === 'apple' && isAppleAuthorized) || (service === 'spotify' && isSpotifyAuthorized)) ) { - handleMount(); + handleFetch(); } else { setIsLoading(false); } - }, [handleMount, isAppleAuthorized, isMounted, isSpotifyAuthorized, service]); + }, [ + handleFetch, + isAppleAuthorized, + isMounted, + isSpotifyAuthorized, + props.lazy, + service, + ]); return { isLoading, data, hasError, + fetch: handleFetch, }; }; diff --git a/src/providers/WindowProvider.tsx b/src/providers/WindowProvider.tsx index c815587..7af4480 100644 --- a/src/providers/WindowProvider.tsx +++ b/src/providers/WindowProvider.tsx @@ -39,12 +39,18 @@ type PopupViewOptionProps = { listOptions: SelectableListOption[]; }; +type KeyboardViewOptionProps = { + type: Views.WINDOW_TYPE.KEYBOARD; + initialValue?: string; +}; + export type WindowOptions = any> = SharedOptionProps & ( | ListViewOptionProps | ActionSheetViewOptionProps | PopupViewOptionProps + | KeyboardViewOptionProps ); interface WindowState { diff --git a/src/types/Ipod.API.d.ts b/src/types/Ipod.API.d.ts index e1594b9..def07e9 100644 --- a/src/types/Ipod.API.d.ts +++ b/src/types/Ipod.API.d.ts @@ -55,4 +55,11 @@ declare namespace IpodApi { playlistName?: string; previewUrl?: string; } + + interface SearchResults { + artists: Artist[]; + songs: Song[]; + albums: Album[]; + playlists: Playlist[]; + } } diff --git a/src/utils/conversion.ts b/src/utils/conversion.ts index 89e8258..c9f11cc 100644 --- a/src/utils/conversion.ts +++ b/src/utils/conversion.ts @@ -185,3 +185,15 @@ export const convertSpotifyMediaItem = ( url: track.uri, }; }; + +export const convertSpotifySearchResults = ( + results: SpotifyApi.SearchResponse +): IpodApi.SearchResults => { + return { + artists: results.artists?.items.map(convertSpotifyArtistFull) ?? [], + albums: results.albums?.items.map(convertSpotifyAlbumSimplified) ?? [], + songs: results.tracks?.items.map(convertSpotifySongFull) ?? [], + playlists: + results.playlists?.items.map(convertSpotifyPlaylistSimplified) ?? [], + }; +}; diff --git a/yarn.lock b/yarn.lock index be2ef0c..ddedb6f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1930,9 +1930,9 @@ integrity sha512-Klz949h02Gz2uZCMGwDUSDS1YBlTdDDgbWHi+81l29tQALUtvz4rAYi5uoVhE5Lagoq6DeqAUlbrHvW/mXDgdQ== "@types/node@*": - version "16.9.4" - resolved "https://registry.npmjs.org/@types/node/-/node-16.9.4.tgz#a12f0ee7847cf17a97f6fdf1093cb7a9af23cca4" - integrity sha512-KDazLNYAGIuJugdbULwFZULF9qQ13yNWEBFnfVpqlpgAAo6H/qnM9RjBgh0A0kmHf3XxAKLdN5mTIng9iUvVLA== + version "16.9.6" + resolved "https://registry.npmjs.org/@types/node/-/node-16.9.6.tgz#040a64d7faf9e5d9e940357125f0963012e66f04" + integrity sha512-YHUZhBOMTM3mjFkXVcK+WwAcYmyhe1wL4lfqNtzI0b3qAy7yuSetnM7QJazgE5PFmgVTNGiLOgRFfJMqW7XpSQ== "@types/node@12.19.6": version "12.19.6" @@ -1949,6 +1949,11 @@ resolved "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.0.tgz#2f8bb441434d163b35fb8ffdccd7138927ffb8c0" integrity sha512-//oorEZjL6sbPcKUaCdIGlIUeH26mgzimjBB77G6XRgnDl/L5wOnpyBGRe/Mmf5CVW3PwEBE1NjiMZ/ssFh4wA== +"@types/pluralize@^0.0.29": + version "0.0.29" + resolved "https://registry.npmjs.org/@types/pluralize/-/pluralize-0.0.29.tgz#6ffa33ed1fc8813c469b859681d09707eb40d03c" + integrity sha512-BYOID+l2Aco2nBik+iYS4SZX0Lf20KPILP5RGmM1IgzdwNdTs0eebiFriOPcej1sX9mLnSoiNte5zcFxssgpGA== + "@types/prettier@^2.0.0": version "2.3.2" resolved "https://registry.npmjs.org/@types/prettier/-/prettier-2.3.2.tgz#fc8c2825e4ed2142473b4a81064e6e081463d1b3" @@ -1972,9 +1977,9 @@ "@types/react" "^16" "@types/react@*": - version "17.0.22" - resolved "https://registry.npmjs.org/@types/react/-/react-17.0.22.tgz#c80d1d0e87fe953bae3ab273bef451dea1a6291b" - integrity sha512-kq/BMeaAVLJM6Pynh8C2rnr/drCK+/5ksH0ch9asz+8FW3DscYCIEFtCeYTFeIx/ubvOsMXmRfy7qEJ76gM96A== + version "17.0.24" + resolved "https://registry.npmjs.org/@types/react/-/react-17.0.24.tgz#7e1b3f78d0fc53782543f9bce6d949959a5880bd" + integrity sha512-eIpyco99gTH+FTI3J7Oi/OH8MZoFMJuztNRimDOJwH4iGIsKV2qkGnk4M9VzlaVWeEEWLWSQRy0FEA0Kz218cg== dependencies: "@types/prop-types" "*" "@types/scheduler" "*" @@ -4487,9 +4492,9 @@ ejs@^2.6.1: integrity sha512-7vmuyh5+kuUyJKePhQfRQBhXV5Ce+RnaeeQArKu1EAMpL3WbgMt5WG6uQZpEVvYSSsxMXRKOewtDk9RaTKXRlA== electron-to-chromium@^1.3.564, electron-to-chromium@^1.3.830: - version "1.3.845" - resolved "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.3.845.tgz#326d3be3ee5d2c065f689119d441c997f9fd41d8" - integrity sha512-y0RorqmExFDI4RjLEC6j365bIT5UAXf9WIRcknvSFHVhbC/dRnCgJnPA3DUUW6SCC85QGKEafgqcHJ6uPdEP1Q== + version "1.3.846" + resolved "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.3.846.tgz#a55fd59613dbcaed609e965e3e88f42b08c401d3" + integrity sha512-2jtSwgyiRzybHRxrc2nKI+39wH3AwQgn+sogQ+q814gv8hIFwrcZbV07Ea9f8AmK0ufPVZUvvAG1uZJ+obV4Jw== elliptic@^6.5.3: version "6.5.4" @@ -8339,6 +8344,11 @@ pkg-up@^2.0.0: dependencies: find-up "^2.1.0" +pluralize@^8.0.0: + version "8.0.0" + resolved "https://registry.npmjs.org/pluralize/-/pluralize-8.0.0.tgz#1a6fa16a38d12a1901e0320fa017051c539ce3b1" + integrity sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA== + pnp-webpack-plugin@1.6.4: version "1.6.4" resolved "https://registry.npmjs.org/pnp-webpack-plugin/-/pnp-webpack-plugin-1.6.4.tgz#c9711ac4dc48a685dabafc86f8b6dd9f8df84149"