From 06bb27ebc322b1fa04026a911491cd64cdc869dc Mon Sep 17 00:00:00 2001 From: SimonShiki Date: Sat, 10 Aug 2024 20:15:36 +0800 Subject: [PATCH] :sparkles: feat: add album/artist/song page Signed-off-by: SimonShiki --- src/components/album-item.tsx | 20 ++++++++ src/components/artist-item.tsx | 20 ++++++++ src/components/song-item.tsx | 2 +- src/jotais/library.ts | 79 +++++++++++++++++++++++++++++ src/jotais/storage.ts | 6 ++- src/pages/album.tsx | 82 +++++++++++++++++++++++++++++++ src/pages/artist.tsx | 82 +++++++++++++++++++++++++++++++ src/pages/local.tsx | 8 ++- src/pages/song.tsx | 90 +++++++++++++++++++++++++++++++++- src/router.tsx | 4 ++ src/storages/local.ts | 7 +-- src/storages/ncm.ts | 7 +-- src/utils/player.ts | 5 ++ 13 files changed, 390 insertions(+), 22 deletions(-) create mode 100644 src/components/album-item.tsx create mode 100644 src/components/artist-item.tsx create mode 100644 src/jotais/library.ts create mode 100644 src/pages/album.tsx create mode 100644 src/pages/artist.tsx diff --git a/src/components/album-item.tsx b/src/components/album-item.tsx new file mode 100644 index 0000000..d7c992d --- /dev/null +++ b/src/components/album-item.tsx @@ -0,0 +1,20 @@ +import { Album } from '../jotais/library'; + +interface AlbumItemProps { + album: Album; + onClick (album: Album): void; +} + +export default function AlbumItem ({album, onClick}: AlbumItemProps) { + return ( +
{ + onClick(album); + }}> + {album.name} +
+ {album.name} + {album.songs.length} Songs +
+
+ ); +} diff --git a/src/components/artist-item.tsx b/src/components/artist-item.tsx new file mode 100644 index 0000000..5174aaf --- /dev/null +++ b/src/components/artist-item.tsx @@ -0,0 +1,20 @@ +import { Artist } from '../jotais/library'; + +interface ArtistItemProps { + artist: Artist; + onClick (artist: Artist): void; +} + +export default function ArtistItem({ artist, onClick }: ArtistItemProps) { + return ( +
{ + onClick(artist); + }}> + {artist.name} +
+ {artist.name} + {artist.songs.length} Songs +
+
+ ); +} diff --git a/src/components/song-item.tsx b/src/components/song-item.tsx index 582e15a..da4c270 100644 --- a/src/components/song-item.tsx +++ b/src/components/song-item.tsx @@ -49,7 +49,7 @@ export default function SongItem (props: SongItemProps) { return ( { props.onClick(props.song); - }} className={`flex flex-row items-center py-2 gap-2 hover:!bg-black cursor-pointer hover:!bg-op-5 transition-colors ${props.hideBg ? '!border-none !bg-transparent' : ''}`}> + }} className={`flex flex-row items-center active:scale-99 py-2 gap-2 hover:!bg-black cursor-pointer hover:!bg-op-5 transition-all ${props.hideBg ? '!border-none !bg-transparent' : ''}`}> {props.song.name}
{props.song.name} diff --git a/src/jotais/library.ts b/src/jotais/library.ts new file mode 100644 index 0000000..c268bdc --- /dev/null +++ b/src/jotais/library.ts @@ -0,0 +1,79 @@ +import { atom } from 'jotai'; +import { scannedJotai, Song, storagesJotai } from './storage'; +import sharedStore from './shared-store'; + +export const libraryJotai = atom[]>([]); +sharedStore.sub(scannedJotai, () => { + const allScanned = sharedStore.get(scannedJotai); + const library = sharedStore.get(libraryJotai); + if (!allScanned || library.length > 0) return; + + const list: Song[] = []; + const storages = sharedStore.get(storagesJotai); + for (const storageId in storages) { + const { songList } = storages[storageId]; + list.push(...songList); + } + + sharedStore.set(libraryJotai, list); +}); + +export interface Album { + id: number; + cover: string; + name: string; + songs: Song[]; +} + +export const albumsJotai = atom([]); +sharedStore.sub(libraryJotai, () => { + const songs = sharedStore.get(libraryJotai); + const albums: Record = {}; + + for (let i = 0; i < songs.length; i++) { + const song = songs[i]; + const albumName = song.album; + if (!albumName) continue; + if (!albums[albumName]) { + albums[albumName] = { + id: i + 1, + cover: song.cover || '', + name: albumName, + songs: [] + }; + } + albums[albumName].songs.push(song); + } + + sharedStore.set(albumsJotai, Object.values(albums)); +}); + +export interface Artist { + id: number; + cover: string; + name: string; + songs: Song[]; +} + +export const artistsJotai = atom([]); +sharedStore.sub(libraryJotai, () => { + const songs = sharedStore.get(libraryJotai); + const artists: Record = {}; + + for (let i = 0; i < songs.length; i++) { + const song = songs[i]; + const artistName = song.artist; + if (!artistName) continue; + if (!artists[artistName]) { + artists[artistName] = { + id: i + 1, + cover: song.cover || '', + name: artistName, + songs: [] + }; + } + artists[artistName].songs.push(song); + } + + sharedStore.set(artistsJotai, Object.values(artists)); +}); diff --git a/src/jotais/storage.ts b/src/jotais/storage.ts index af05738..12306f4 100644 --- a/src/jotais/storage.ts +++ b/src/jotais/storage.ts @@ -17,7 +17,6 @@ export interface Song { } export interface AbstractStorage { - getSongList(): Promise[]>; scan(): Promise; getMusicStream?(id: string | number): AsyncGenerator, void, unknown>; getMusicBuffer?(id: string | number): Promise; @@ -69,5 +68,8 @@ export const storageJotai = atom({ export const storagesJotai = focusAtom(storageJotai, (optic) => optic.prop('storages')); export const scannedJotai = atom( - (get) => Object.values(get(storagesJotai)).every(storage => !storage) + (get) => { + const storages = get(storagesJotai); + return (Object.values(storages) as StorageMeta[]).every(storage => storage.scanned); + } ); diff --git a/src/pages/album.tsx b/src/pages/album.tsx new file mode 100644 index 0000000..2154c6c --- /dev/null +++ b/src/pages/album.tsx @@ -0,0 +1,82 @@ +import { useAtomValue } from 'jotai'; +import { scannedJotai, Song } from '../jotais/storage'; +import Spinner from '../components/base/spinner'; +import { Virtuoso, VirtuosoGrid } from 'react-virtuoso'; +import { albumsJotai, Album } from '../jotais/library'; +import AlbumItem from '../components/album-item'; +import { useCallback, useState } from 'react'; +import SongItem from '../components/song-item'; +import * as player from '../utils/player'; +import Button from '../components/base/button'; + +export default function AlbumPage() { + const scanned = useAtomValue(scannedJotai); + const albums = useAtomValue(albumsJotai); + const [currentAlbum, setCurrentAlbum] = useState(null); + const handleClickSong = useCallback((song: Song) => { + if (!currentAlbum) return; + player.clearPlaylist(); + player.addToPlaylist(...currentAlbum.songs); + player.setCurrentSong(song); + }, [currentAlbum]); + + return ( +
+
+ Albums +
+ {!scanned && ( +
+ + Scanning... +
+ )} +
+
+ {albums.length > 0 ? ( +
+
+ { + const album = albums[index]; + return ; + }} + /> +
+
+ {currentAlbum && ( +
+
+ +

{currentAlbum.name}

+
+ { + const song = currentAlbum.songs[index]; + return ; + }} + /> +
+ )} +
+
+ ) : ( +
+ +
+ )} +
+ ); +} diff --git a/src/pages/artist.tsx b/src/pages/artist.tsx new file mode 100644 index 0000000..7712a61 --- /dev/null +++ b/src/pages/artist.tsx @@ -0,0 +1,82 @@ +import { useAtomValue } from 'jotai'; +import { scannedJotai, Song } from '../jotais/storage'; +import Spinner from '../components/base/spinner'; +import { Virtuoso, VirtuosoGrid } from 'react-virtuoso'; +import { Artist, artistsJotai } from '../jotais/library'; +import { useCallback, useState } from 'react'; +import SongItem from '../components/song-item'; +import * as player from '../utils/player'; +import Button from '../components/base/button'; +import ArtistItem from '../components/artist-item'; + +export default function ArtistPage () { + const scanned = useAtomValue(scannedJotai); + const artists = useAtomValue(artistsJotai); + const [currentArtist, setCurrentArtist] = useState(null); + const handleClickSong = useCallback((song: Song) => { + if (!currentArtist) return; + player.clearPlaylist(); + player.addToPlaylist(...currentArtist.songs); + player.setCurrentSong(song); + }, [currentArtist]); + + return ( +
+
+ Artists +
+ {!scanned && ( +
+ + Scanning... +
+ )} +
+
+ {artists.length > 0 ? ( +
+
+ { + const artist = artists[index]; + return ; + }} + /> +
+
+ {currentArtist && ( +
+
+ +

{currentArtist.name}

+
+ { + const song = currentArtist.songs[index]; + return ; + }} + /> +
+ )} +
+
+ ) : ( +
+ +
+ )} +
+ ); +} diff --git a/src/pages/local.tsx b/src/pages/local.tsx index 174fa2c..6b73be4 100644 --- a/src/pages/local.tsx +++ b/src/pages/local.tsx @@ -5,7 +5,7 @@ import Button from '../components/base/button'; import Select from '../components/base/select'; import Input from '../components/base/input'; import { Virtuoso } from 'react-virtuoso'; -import type { Song as AbstractSong, Song } from '../jotais/storage'; +import type { Song } from '../jotais/storage'; import { focusAtom } from 'jotai-optics'; import Spinner from '../components/base/spinner'; import * as player from '../utils/player'; @@ -32,7 +32,7 @@ export default function Local () { const scanned = useAtomValue(scannedJotai); const [keyword, setKeyword] = useState(''); const [sortBy, setSortBy] = useState('a-z'); - const handleClickSong = useCallback((song: AbstractSong<'local'>) => { + const handleClickSong = useCallback((song: Song<'local'>) => { player.clearPlaylist(); player.addToPlaylist(...list); player.setCurrentSong(song); @@ -57,9 +57,7 @@ export default function Local () {
Local
- + {!scanned && (
diff --git a/src/pages/song.tsx b/src/pages/song.tsx index cbcd843..21aba16 100644 --- a/src/pages/song.tsx +++ b/src/pages/song.tsx @@ -1,7 +1,93 @@ +import { useAtomValue } from 'jotai'; +import { useState, useCallback, useEffect } from 'react'; +import { Virtuoso } from 'react-virtuoso'; +import Button from '../components/base/button'; +import Input from '../components/base/input'; +import Select from '../components/base/select'; +import Spinner from '../components/base/spinner'; +import SongItem from '../components/song-item'; +import { nowPlayingBarJotai } from '../jotais/play'; +import * as player from '../utils/player'; +import { scannedJotai } from '../jotais/storage'; +import type { Song } from '../jotais/storage'; +import { SortOptions, filterSongList, sortSongList } from '../utils/sort'; +import { libraryJotai } from '../jotais/library'; + +const sortOptions = [ + { value: 'default', label: 'Default' } as const, + { value: 'a-z', label: 'A - Z' } as const, + { value: 'z-a', label: 'Z - A' } as const, + { value: 'time_desc', label: 'Time (Reversed)' } as const, + { value: 'time_asc', label: 'Time' } as const +]; + export default function Song () { + const _list = useAtomValue(libraryJotai); + const [list, setList] = useState(_list); + const barOpen = useAtomValue(nowPlayingBarJotai); + const scanned = useAtomValue(scannedJotai); + const [keyword, setKeyword] = useState(''); + const [sortBy, setSortBy] = useState('a-z'); + const handleClickSong = useCallback((song: Song) => { + player.clearPlaylist(); + player.addToPlaylist(...list); + player.setCurrentSong(song); + }, [list]); + const handleRandomPlay = useCallback(() => { + const newList = [...list]; + player.clearPlaylist(); + player.shuffleNewSongs(newList, newList.length); + player.addToPlaylist(...newList); + player.setCurrentSong(newList[0]); + }, [list]); + useEffect(() => { + let ir: Song[] = _list; + if (keyword.trim() !== '') { + ir = filterSongList(ir, keyword); + } + ir = sortSongList(ir, sortBy); + setList(ir); + }, [_list, keyword, sortBy]); return ( -
- 123 +
+
+ Songs +
+ + {!scanned && ( +
+ + Scanning... +
+ )} + { + setKeyword(e.target.value); + }} after={} className='m-l-auto' /> + Sort By: +