Skip to content

Commit

Permalink
✨ feat: add songlist
Browse files Browse the repository at this point in the history
Signed-off-by: SimonShiki <[email protected]>
  • Loading branch information
SimonShiki committed Aug 11, 2024
1 parent c4c227d commit 3b01425
Show file tree
Hide file tree
Showing 8 changed files with 216 additions and 19 deletions.
21 changes: 18 additions & 3 deletions src/components/song-item.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@ import defaultCover from '../assets/default-cover.png';
import Card from './base/card';
import { IconMenuItem, Menu, MenuItem, NativeIcon, PredefinedMenuItem, Submenu } from '@tauri-apps/api/menu';
import { useCallback } from 'react';
import { useAtom } from 'jotai';
import { songlistsJotai } from '../jotais/library';
import * as player from '../utils/player';

interface SongItemProps {
song: AbstractSong<string>;
Expand All @@ -11,13 +14,14 @@ interface SongItemProps {
}

export default function SongItem (props: SongItemProps) {
const [songlists, setSonglists] = useAtom(songlistsJotai);
const showContextMenu = useCallback(async () => {
const menu = await Menu.new({
items: [
await MenuItem.new({
text: 'Play',
action: () => {
props.onClick(props.song);
player.setCurrentSong(props.song);
}
}),
await MenuItem.new({
Expand All @@ -31,7 +35,18 @@ export default function SongItem (props: SongItemProps) {
}),
await Submenu.new({
text: 'Add to',
items: []
items: await Promise.all(songlists.map((songlist, index) => (MenuItem.new({
text: songlist.name,
action: () => {
const newSonglist = {
...songlist,
songs: [...songlist.songs, props.song]
};
const newSonglists = [...songlists];
newSonglists[index] = newSonglist;
setSonglists(newSonglists);
}
}))))
}),
await IconMenuItem.new({
icon: NativeIcon.Remove,
Expand All @@ -44,7 +59,7 @@ export default function SongItem (props: SongItemProps) {
]
});
menu.popup();
}, []);
}, [songlists]);

return (
<Card onContextMenu={showContextMenu} onDoubleClick={() => {
Expand Down
46 changes: 40 additions & 6 deletions src/components/songlist-item.tsx
Original file line number Diff line number Diff line change
@@ -1,23 +1,57 @@
import { FormattedMessage } from 'react-intl';
import defaultCover from '../assets/default-cover.png';
import Card from './base/card';
import { Menu, MenuItem } from '@tauri-apps/api/menu';
import { useCallback } from 'react';

interface SonglistItemProps {
id: string | number;
index?: number;
total?: number;
name: string;
cover?: string;
onClick? (id: string | number, index?: number): void;
onClick (id: string | number, index?: number): void;
onPlayAll (id: string | number, index?: number): void;
onDelete? (id: string | number, index?: number): void;
hideBg?: boolean;
}

export default function SonglistItem (props: SonglistItemProps) {

const showContextMenu = useCallback(async () => {
const items = [
await MenuItem.new({
text: 'Play',
action: () => {
props.onPlayAll(props.id, props.index);
}
})
];
if (props.onDelete) {
items.push(
await MenuItem.new({
text: 'Delete',
action: () => {
props.onDelete!(props.id, props.index);
}
})
);
}
const menu = await Menu.new({items});
menu.popup();
}, [props.id, props.onPlayAll, props.onDelete, props.index]);
return (
<Card onDoubleClick={() => {
props.onClick?.(props.id, props.index);
}} 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' : ''}`}>
<Card onContextMenu={showContextMenu} onClick={() => {
props.onClick(props.id, props.index);
}} className={`!hover:border-outline-pri 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.cover ?? defaultCover} alt={props.name} className='rounded-md w-10 h-10' />
<span className='color-text-pri font-size-sm font-500'>{props.name}</span>
<div className='flex flex-col *:text-ellipsis text-nowrap *:overflow-hidden'>
<span className='color-text-pri font-size-sm font-500'>{props.name}</span>
{props.total !== undefined ? (
<span className='font-size-sm color-text-sec'>
<FormattedMessage defaultMessage='{total} Songs' values={{ total: props.total }} />
</span>
) : null}
</div>
</Card>
);
}
8 changes: 8 additions & 0 deletions src/jotais/library.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { atom } from 'jotai';
import { scannedJotai, Song, storagesJotai } from './storage';
import sharedStore from './shared-store';
import { atomWithStorage } from 'jotai/utils';

export const libraryJotai = atom<Song<string>[]>([]);
sharedStore.sub(scannedJotai, () => {
Expand Down Expand Up @@ -77,3 +78,10 @@ sharedStore.sub(libraryJotai, () => {

sharedStore.set(artistsJotai, Object.values(artists));
});

export interface Songlist {
name: string;
songs: Song<string>[];
}

export const songlistsJotai = atomWithStorage<Songlist[]>('songlist', []);
7 changes: 0 additions & 7 deletions src/jotais/playlist.ts

This file was deleted.

2 changes: 1 addition & 1 deletion src/pages/album.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import * as player from '../utils/player';
import Button from '../components/base/button';
import { FormattedMessage } from 'react-intl';

export default function AlbumPage() {
export default function AlbumPage () {
const scanned = useAtomValue(scannedJotai);
const albums = useAtomValue(albumsJotai);
const [currentAlbum, setCurrentAlbum] = useState<Album | null>(null);
Expand Down
2 changes: 1 addition & 1 deletion src/pages/ncm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -197,7 +197,7 @@ export default function NCM () {
return <div className="h-20" />;
}
const list = songlist[index];
return <SonglistItem id={list.id} index={index} name={list.name} cover={list.coverImgUrl} onClick={handleClickRemoteSonglist} hideBg={!(index % 2)} />;
return <SonglistItem id={list.id} total={list.trackCount} index={index} name={list.name} cover={list.coverImgUrl} onClick={handleClickRemoteSonglist} hideBg={!(index % 2)} />;
}}
/>
) : (
Expand Down
145 changes: 145 additions & 0 deletions src/pages/songlist.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
import { useAtom, useAtomValue } from 'jotai';
import { scannedJotai, Song } from '../jotais/storage';
import Spinner from '../components/base/spinner';
import { Virtuoso } from 'react-virtuoso';
import { Songlist, songlistsJotai } from '../jotais/library';
import { useCallback, useState } from 'react';
import SongItem from '../components/song-item';
import * as player from '../utils/player';
import { FormattedMessage, useIntl } from 'react-intl';
import SonglistItem from '../components/songlist-item';
import Button from '../components/base/button';
import Tooltip from '../components/base/tooltip';
import Input from '../components/base/input';

export default function SonglistPage () {
const scanned = useAtomValue(scannedJotai);
const intl = useIntl();
const [songlists, setSonglists] = useAtom(songlistsJotai);
const [songlistName, setSonglistName] = useState('');
const [currentSonglist, setCurrentSonglist] = useState<Songlist | null>(null);
const handleClickSong = useCallback((song: Song<string>) => {
if (!currentSonglist) return;
player.clearPlaylist();
player.addToPlaylist(...currentSonglist.songs);
player.setCurrentSong(song);
}, [currentSonglist]);
const handleClickSonglist = useCallback((index: number) => {
const songlist = songlists[index];
if (!songlist.songs.length) return;
player.clearPlaylist();
player.addToPlaylist(...songlist.songs);
player.setCurrentSong(songlist.songs[0]);
}, [songlists]);
const handleCreateSonglist = useCallback<React.FormEventHandler<HTMLFormElement>>((e) => {
e.preventDefault();
e.stopPropagation();
setSonglists([...songlists, {
name: songlistName,
songs: []
}]);
setSonglistName('');
}, [songlists, songlistName]);
const handleDeleteSonglist = useCallback((index: number) => {
const newSonglists = [...songlists];
newSonglists.splice(index, 1);
setSonglists(newSonglists);
}, [songlists]);

return (
<main className='flex flex-col h-[calc(100vh-100px)]'>
<div className='flex flex-col gap-4 pl-2 pt-4 pb-4'>
<span className='color-text-pri font-size-3xl font-500'>
<FormattedMessage defaultMessage='Songlists' />
</span>
<div className='flex flex-row items-center gap-4'>
<Tooltip trigger='click' tooltipClassName='!bg-transparent p-4 backdrop-blur-xl translate-z-0' placement='right' content={(
<form className='flex flex-col items-end gap-2' onSubmit={handleCreateSonglist} autoComplete='off'>
<Input value={songlistName} onChange={(e) => {
setSonglistName(e.target.value);
}} placeholder={intl.formatMessage({ defaultMessage: 'Name'})} />
<Button variant='primary'>
<FormattedMessage defaultMessage='Add' />
</Button>
</form>
)}>
<Button variant='primary' className='flex items-center'>
<span className='i-fluent:add-24-regular size-4' />
<FormattedMessage defaultMessage='Add' />
</Button>
</Tooltip>
{!scanned && (
<div className='flex items-center gap-2'>
<Spinner size='size-4' />
<span className='font-size-sm'>
<FormattedMessage defaultMessage='Scanning...' />
</span>
</div>
)}
</div>
</div>
{songlists.length > 0 ? (
<div className='flex-1 relative overflow-hidden'>
<div className={`absolute inset-0 transition-all duration-300 ease-in-out ${currentSonglist ? 'translate-x-[-100%]' : 'translate-x-0'}`}>
<Virtuoso
className='h-full'
totalCount={songlists.length}
itemContent={(index) => {
const songlist = songlists[index];
return (
<SonglistItem
name={songlist.name}
total={songlist.songs.length}
id={index}
cover={songlist.songs[0]?.cover}
hideBg={!(index % 2)}
onDelete={handleDeleteSonglist}
onPlayAll={handleClickSonglist}
onClick={(i: number) => {
setCurrentSonglist(songlists[i]);
}}
/>
);
}}
/>
</div>
<div className={`absolute inset-0 transition-all duration-300 ease-in-out ${currentSonglist ? 'translate-x-0' : 'translate-x-full'}`}>
{currentSonglist && (
<div className='flex flex-col h-full'>
<div className='flex items-center gap-4 px-4'>
<Button
size='sm'
className='flex items-center'
onClick={() => setCurrentSonglist(null)}
>
<span className='i-fluent:chevron-left-16-regular' />
<FormattedMessage defaultMessage='Back' />
</Button>
<h2 className='text-xl font-semibold'>{currentSonglist.name}</h2>
</div>
<Virtuoso
className='flex-1'
totalCount={currentSonglist.songs.length}
itemContent={(index) => {
const song = currentSonglist.songs[index];
return <SongItem song={song} onClick={handleClickSong} hideBg={!(index % 2)} />;
}}
/>
</div>
)}
</div>
</div>
) : (
scanned ? (
<div className='flex-1 flex justify-center items-center'>
<span className='color-text-sec'>Empty</span>
</div>
) : (
<div className='flex-1 flex justify-center items-center'>
<Spinner />
</div>
)
)}
</main>
);
}
4 changes: 3 additions & 1 deletion src/router.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import NotFound from './pages/404';
import Settings from './pages/settings';
import NCM from './pages/ncm';
import Album from './pages/album';
import Songlist from './pages/songlist';
import Artist from './pages/artist';

const router = createBrowserRouter([
Expand All @@ -19,7 +20,8 @@ const router = createBrowserRouter([
{ path: 'artist', element: <Artist /> },
{ path: 'local', element: <Local /> },
{ path: 'ncm', element: <NCM /> },
{ path: 'settings', element: <Settings />}
{ path: 'settings', element: <Settings />},
{ path: 'songlist', element: <Songlist /> },
],
},
]);
Expand Down

0 comments on commit 3b01425

Please sign in to comment.