Skip to content

Commit

Permalink
✨ feat: add album/artist/song page
Browse files Browse the repository at this point in the history
Signed-off-by: SimonShiki <[email protected]>
  • Loading branch information
SimonShiki committed Aug 10, 2024
1 parent 1d54ce8 commit 06bb27e
Show file tree
Hide file tree
Showing 13 changed files with 390 additions and 22 deletions.
20 changes: 20 additions & 0 deletions src/components/album-item.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className='flex flex-col gap-2 w-36 h-48 active:scale-95 border-(1 solid transparent) hover:border-outline-sec bg-black bg-op-0 hover:bg-op-5 rounded-lg p-1.5 transition-all' onClick={() => {
onClick(album);
}}>
<img draggable={false} src={album.cover} alt={album.name} className='w-36 h-36 aspect-square rounded-md border-(1 solid outline-sec)'/>
<div className='flex flex-col *:text-ellipsis text-nowrap *:overflow-hidden max-w-30'>
<span className='font-500'>{album.name}</span>
<span className='font-size-sm color-text-sec'>{album.songs.length} Songs</span>
</div>
</div>
);
}
20 changes: 20 additions & 0 deletions src/components/artist-item.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className='flex flex-col gap-2 w-36 h-48 active:scale-95 border-(1 solid transparent) hover:border-outline-sec bg-black bg-op-0 hover:bg-op-5 rounded-lg p-1.5 transition-all' onClick={() => {
onClick(artist);
}}>
<img draggable={false} src={artist.cover} alt={artist.name} className='w-36 h-36 aspect-square rounded-full border-(1 solid outline-sec)'/>
<div className='flex flex-col *:text-ellipsis text-nowrap *:overflow-hidden max-w-30'>
<span className='font-500'>{artist.name}</span>
<span className='font-size-sm color-text-sec'>{artist.songs.length} Songs</span>
</div>
</div>
);
}
2 changes: 1 addition & 1 deletion src/components/song-item.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ export default function SongItem (props: SongItemProps) {
return (
<Card onContextMenu={showContextMenu} onDoubleClick={() => {
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' : ''}`}>
<img draggable={false} src={props.song.cover ?? defaultCover} alt={props.song.name} className='rounded-md w-10 h-10' />
<div className='flex flex-col gap-1'>
<span className='color-text-pri font-size-sm font-500'>{props.song.name}</span>
Expand Down
79 changes: 79 additions & 0 deletions src/jotais/library.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import { atom } from 'jotai';
import { scannedJotai, Song, storagesJotai } from './storage';
import sharedStore from './shared-store';

export const libraryJotai = atom<Song<string>[]>([]);
sharedStore.sub(scannedJotai, () => {
const allScanned = sharedStore.get(scannedJotai);
const library = sharedStore.get(libraryJotai);
if (!allScanned || library.length > 0) return;

const list: Song<string>[] = [];
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<string>[];
}

export const albumsJotai = atom<Album[]>([]);
sharedStore.sub(libraryJotai, () => {
const songs = sharedStore.get(libraryJotai);
const albums: Record<string, Album> = {};

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<string>[];
}

export const artistsJotai = atom<Artist[]>([]);
sharedStore.sub(libraryJotai, () => {
const songs = sharedStore.get(libraryJotai);
const artists: Record<string, Artist> = {};

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));
});
6 changes: 4 additions & 2 deletions src/jotais/storage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,6 @@ export interface Song<From extends string> {
}

export interface AbstractStorage {
getSongList(): Promise<Song<StorageMeta['identifer']>[]>;
scan(): Promise<void>;
getMusicStream?(id: string | number): AsyncGenerator<ArrayLike<unknown>, void, unknown>;
getMusicBuffer?(id: string | number): Promise<ArrayBuffer>;
Expand Down Expand Up @@ -69,5 +68,8 @@ export const storageJotai = atom<Storages>({

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);
}
);
82 changes: 82 additions & 0 deletions src/pages/album.tsx
Original file line number Diff line number Diff line change
@@ -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<Album | null>(null);
const handleClickSong = useCallback((song: Song<string>) => {
if (!currentAlbum) return;
player.clearPlaylist();
player.addToPlaylist(...currentAlbum.songs);
player.setCurrentSong(song);
}, [currentAlbum]);

return (
<main className='flex flex-col h-[calc(100vh-100px)]'>
<div className='flex flex-col gap-4 pl-2 pt-4'>
<span className='color-text-pri font-size-3xl font-500'>Albums</span>
<div className='flex flex-row items-center gap-4'>
{!scanned && (
<div className='flex items-center gap-2'>
<Spinner size='size-4' />
<span className='font-size-sm'>Scanning...</span>
</div>
)}
</div>
</div>
{albums.length > 0 ? (
<div className='flex-1 relative overflow-hidden'>
<div className={`absolute inset-0 transition-all duration-300 ease-in-out ${currentAlbum ? 'translate-x-[-100%]' : 'translate-x-0'}`}>
<VirtuosoGrid
className='h-full'
listClassName='flex flex-wrap p-4'
totalCount={albums.length}
itemContent={(index) => {
const album = albums[index];
return <AlbumItem album={album} onClick={setCurrentAlbum} />;
}}
/>
</div>
<div className={`absolute inset-0 transition-all duration-300 ease-in-out ${currentAlbum ? 'translate-x-0' : 'translate-x-full'}`}>
{currentAlbum && (
<div className='flex flex-col h-full'>
<div className='flex items-center gap-4 p-4'>
<Button
size='sm'
className='flex items-center'
onClick={() => setCurrentAlbum(null)}
>
<span className='i-fluent:chevron-left-16-regular' />
Back
</Button>
<h2 className='text-xl font-semibold'>{currentAlbum.name}</h2>
</div>
<Virtuoso
className='flex-1'
totalCount={currentAlbum.songs.length}
itemContent={(index) => {
const song = currentAlbum.songs[index];
return <SongItem song={song} onClick={handleClickSong} hideBg={!(index % 2)} />;
}}
/>
</div>
)}
</div>
</div>
) : (
<div className='flex-1 flex justify-center items-center'>
<Spinner />
</div>
)}
</main>
);
}
82 changes: 82 additions & 0 deletions src/pages/artist.tsx
Original file line number Diff line number Diff line change
@@ -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<Artist | null>(null);
const handleClickSong = useCallback((song: Song<string>) => {
if (!currentArtist) return;
player.clearPlaylist();
player.addToPlaylist(...currentArtist.songs);
player.setCurrentSong(song);
}, [currentArtist]);

return (
<main className='flex flex-col h-[calc(100vh-100px)]'>
<div className='flex flex-col gap-4 pl-2 pt-4'>
<span className='color-text-pri font-size-3xl font-500'>Artists</span>
<div className='flex flex-row items-center gap-4'>
{!scanned && (
<div className='flex items-center gap-2'>
<Spinner size='size-4' />
<span className='font-size-sm'>Scanning...</span>
</div>
)}
</div>
</div>
{artists.length > 0 ? (
<div className='flex-1 relative overflow-hidden'>
<div className={`absolute inset-0 transition-all duration-300 ease-in-out ${currentArtist ? 'translate-x-[-100%]' : 'translate-x-0'}`}>
<VirtuosoGrid
className='h-full'
listClassName='flex flex-wrap p-4'
totalCount={artists.length}
itemContent={(index) => {
const artist = artists[index];
return <ArtistItem artist={artist} onClick={setCurrentArtist} />;
}}
/>
</div>
<div className={`absolute inset-0 transition-all duration-300 ease-in-out ${currentArtist ? 'translate-x-0' : 'translate-x-full'}`}>
{currentArtist && (
<div className='flex flex-col h-full'>
<div className='flex items-center gap-4 p-4'>
<Button
size='sm'
className='flex items-center'
onClick={() => setCurrentArtist(null)}
>
<span className='i-fluent:chevron-left-16-regular' />
Back
</Button>
<h2 className='text-xl font-semibold'>{currentArtist.name}</h2>
</div>
<Virtuoso
className='flex-1'
totalCount={currentArtist.songs.length}
itemContent={(index) => {
const song = currentArtist.songs[index];
return <SongItem song={song} onClick={handleClickSong} hideBg={!(index % 2)} />;
}}
/>
</div>
)}
</div>
</div>
) : (
<div className='flex-1 flex justify-center items-center'>
<Spinner />
</div>
)}
</main>
);
}
8 changes: 3 additions & 5 deletions src/pages/local.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -32,7 +32,7 @@ export default function Local () {
const scanned = useAtomValue(scannedJotai);
const [keyword, setKeyword] = useState('');
const [sortBy, setSortBy] = useState<SortOptions>('a-z');
const handleClickSong = useCallback((song: AbstractSong<'local'>) => {
const handleClickSong = useCallback((song: Song<'local'>) => {
player.clearPlaylist();
player.addToPlaylist(...list);
player.setCurrentSong(song);
Expand All @@ -57,9 +57,7 @@ export default function Local () {
<div className='flex flex-col gap-4 pl-2'>
<span className='color-text-pri font-size-3xl font-500'>Local</span>
<div className='flex flex-row items-center gap-4'>
<Button onClick={() => {
handleRandomPlay();
}} variant='primary' className='flex flex-row gap-2 items-center'><span className='i-fluent:arrow-shuffle-20-regular w-5 h-5' />Random</Button>
<Button onClick={handleRandomPlay} variant='primary' className='flex flex-row gap-2 items-center'><span className='i-fluent:arrow-shuffle-20-regular w-5 h-5' />Random</Button>
{!scanned && (
<div className='flex items-center gap-2'>
<Spinner size='size-4' />
Expand Down
Loading

0 comments on commit 06bb27e

Please sign in to comment.