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"