Skip to content

Commit

Permalink
✨ feat: add multiselect mod
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 3b01425 commit 0ae7814
Show file tree
Hide file tree
Showing 10 changed files with 228 additions and 43 deletions.
2 changes: 2 additions & 0 deletions src/components/base/card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { forwardRef, MouseEventHandler, PropsWithChildren } from 'react';
interface CardProps extends PropsWithChildren {
className?: string;
onClick?: MouseEventHandler<HTMLDivElement>;
onClickCapture?: MouseEventHandler<HTMLDivElement>;
onContextMenu?: MouseEventHandler<HTMLDivElement>;
onDoubleClick?: MouseEventHandler<HTMLDivElement>;
}
Expand All @@ -13,6 +14,7 @@ export default forwardRef<HTMLDivElement, CardProps>(function Card (props, ref)
className={`rounded-1.5 border-(1 solid outline-pri) dark:border-outline-dark-pri bg-white bg-op-60 dark:bg-bg-dark-pri p-4 ${props.onClick ? 'hover:border-outline-sec dark:hover:border-outline-dark-sec' : ''} ${props.className ?? ''}`}
ref={ref}
onClick={props.onClick}
onClickCapture={props.onClickCapture}
onDoubleClick={props.onDoubleClick}
onContextMenu={props.onContextMenu}
>
Expand Down
4 changes: 2 additions & 2 deletions src/components/base/checkbox.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,15 +30,15 @@ const Checkbox = forwardRef<HTMLInputElement, CheckboxProps>(function Checkbox (
disabled={disabled}
ref={ref}
/>
<div className={`w-5 h-5 flex items-center justify-center border-(1 solid) rounded transition-all duration-200 ease-in-out
<div className={`w-4 h-4 flex items-center justify-center border-(1 solid) rounded transition-all duration-150 ease-in-out
${checked
? 'bg-fg-pri border-fg-pri dark:bg-fg-dark-pri dark:border-fg-dark-pri'
: 'bg-white border-outline-pri dark:bg-bg-dark-pri dark:border-outline-dark-pri'}
${disabled
? 'border-outline-ter dark:border-outline-dark-ter'
: 'hover:border-fg-pri dark:hover:border-fg-dark-pri'}`}>
<svg
className={`w-3 h-3 text-white dark:text-bg-dark-pri fill-current transition-opacity duration-200 ease-in-out
className={`w-2 h-2 text-white dark:text-bg-dark-pri fill-current transition-opacity duration-200 ease-in-out
${checked ? 'opacity-100' : 'opacity-0'}`}
viewBox='0 0 20 20'
>
Expand Down
1 change: 1 addition & 0 deletions src/components/now-playing.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,7 @@ export default function NowPlaying () {
</div>
)}
className='flex w-5 h-5'
placement='top-right'
trigger='click'
>
<span className='i-fluent:navigation-play-20-regular w-5 h-5 cursor-pointer' />
Expand Down
11 changes: 10 additions & 1 deletion src/components/song-item.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,15 @@ import { useCallback } from 'react';
import { useAtom } from 'jotai';
import { songlistsJotai } from '../jotais/library';
import * as player from '../utils/player';
import Checkbox from './base/checkbox';

interface SongItemProps {
song: AbstractSong<string>;
selectMode?: boolean;
select?: boolean;
onClick (song: AbstractSong<string>): void;
hideBg?: boolean;
onSelect? (checked: boolean): void;
}

export default function SongItem (props: SongItemProps) {
Expand Down Expand Up @@ -63,8 +67,13 @@ export default function SongItem (props: SongItemProps) {

return (
<Card onContextMenu={showContextMenu} onDoubleClick={() => {
if (props.selectMode) return;
props.onClick(props.song);
}} 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' : ''}`}>
}} onClickCapture={() => {
if (!props.selectMode) return;
props.onSelect?.(!props.select);
}} className={`flex flex-row items-center ${props.selectMode ? '' : '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.selectMode && (<Checkbox checked={props.select} onChange={props.onSelect} />)}
<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
12 changes: 11 additions & 1 deletion src/jotais/library.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { atom } from 'jotai';
import { scannedJotai, Song, storagesJotai } from './storage';
import sharedStore from './shared-store';
import { atomWithStorage } from 'jotai/utils';
import { backendStorage } from '../utils/local-utitity';

export const libraryJotai = atom<Song<string>[]>([]);
sharedStore.sub(scannedJotai, () => {
Expand Down Expand Up @@ -84,4 +85,13 @@ export interface Songlist {
songs: Song<string>[];
}

export const songlistsJotai = atomWithStorage<Songlist[]>('songlist', []);
export const songlistsJotai = atom<Songlist[]>([]);
backendStorage.get('songlists').then((songlists: Songlist[] | undefined) => {
if (!songlists) return;
sharedStore.set(songlistsJotai, songlists);
});

sharedStore.sub(songlistsJotai, () => {
const songlists = sharedStore.get(songlistsJotai);
backendStorage.set('songlists', songlists);
});
12 changes: 5 additions & 7 deletions src/pages/ncm.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import { focusAtom } from 'jotai-optics';
import { storagesConfigJotai } from '../jotais/settings';
import { useAtomValue } from 'jotai';
import { useAtomValue, WritableAtom } from 'jotai';
import { NCMConfig, NCMSonglist } from '../storages/ncm';
import Input from '../components/base/input';
import { useCallback, useEffect, useState } from 'react';
import { SetStateAction, useCallback, useEffect, useState } from 'react';
import { Virtuoso } from 'react-virtuoso';
import { nowPlayingBarJotai } from '../jotais/play';
import { storagesJotai, type Song as AbstractSong } from '../jotais/storage';
Expand All @@ -20,16 +20,17 @@ interface NCMProfile {
avatarUrl: string;
}

const ncmStorageConfigJotai = focusAtom(storagesConfigJotai, (optic) => optic.prop('ncm'));
const ncmStorageConfigJotai = focusAtom(storagesConfigJotai, (optic) => optic.prop('ncm')) as unknown as WritableAtom<NCMConfig, [SetStateAction<NCMConfig>], void>;
const ncmStorageJotai = focusAtom(storagesJotai, (optic) => optic.prop('ncm'));
const profileJotai = focusAtom(ncmStorageConfigJotai, (optic) => optic.prop('profile'));

export default function NCM () {
const ncmConfig = useAtomValue(ncmStorageConfigJotai) as NCMConfig;
const { instance: ncmInstance } = useAtomValue(ncmStorageJotai);
const barOpen = useAtomValue(nowPlayingBarJotai);

const [songlist, setSonglist] = useState<NCMSonglist[]>([]);
const [profile, setProfile] = useState<NCMProfile | null>(null);
const profile = useAtomValue(profileJotai);
const [searchText, setSearchText] = useState('');
const [searchResult, setSearchResult] = useState<AbstractSong<'ncm'>[]>([]);
const [hasMore, setHasMore] = useState(false);
Expand All @@ -46,9 +47,6 @@ export default function NCM () {
useEffect(() => {
if (ncmConfig.loggedIn) {
ncmInstance.getRemoteSonglist().then(setSonglist);
ncmInstance.getProfile().then(setProfile);
} else {
setProfile(null);
}
}, [ncmConfig, ncmInstance]);

Expand Down
9 changes: 9 additions & 0 deletions src/pages/settings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ const ncmStorageConfigJotai = focusAtom(storagesConfigJotai, (optic) => optic.pr
const ncmCookieJotai = focusAtom(ncmStorageConfigJotai, (optic) => optic.prop('cookie'));
const ncmLoggedInJotai = focusAtom(ncmStorageConfigJotai, (optic) => optic.prop('loggedIn'));
const ncmApiJotai = focusAtom(ncmStorageConfigJotai, (optic) => optic.prop('api'));
const ncmProfileJotai = focusAtom(ncmStorageConfigJotai, (optic) => optic.prop('profile'));
const localFoldersJotai = focusAtom(localStorageConfigJotai, (optic) => optic.prop('folders'));
const localAutoScanJotai = focusAtom(localStorageConfigJotai, (optic) => optic.prop('autoScanBehavior'));
const localStorageJotai = focusAtom(storagesJotai, (optic) => optic.prop('local'));
Expand All @@ -53,6 +54,7 @@ export default function Settings () {
const setNCMCookie = useSetAtom(ncmCookieJotai);
const [ncmLoggedIn, setNCMloggedIn] = useAtom(ncmLoggedInJotai);
const [streaming, setStreaming] = useAtom(streamingJotai);
const [ncmProfile, setNCMProfile] = useAtom(ncmProfileJotai);
const [ncmAPI, setNcmAPI] = useAtom(ncmApiJotai);
const barOpen = useAtomValue(nowPlayingBarJotai);

Expand Down Expand Up @@ -267,9 +269,16 @@ export default function Settings () {
<span className='grow-1'>
<FormattedMessage defaultMessage='163 Account' />
</span>
{ncmProfile && (
<div className='flex items-center gap-2'>
<img src={ncmProfile.avatarUrl} draggable={false} className="w-6 h-6 aspect-square rounded-full" />
<span className='font-size-sm'>{ncmProfile.nickname}</span>
</div>
)}
<Button onClick={() => {
if (ncmLoggedIn) {
setNCMCookie(undefined);
setNCMProfile(undefined);
setNCMloggedIn(false);
} else {
setNcmAuthModalOpen(true);
Expand Down
124 changes: 97 additions & 27 deletions src/pages/song.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useAtomValue } from 'jotai';
import { useAtom, useAtomValue } from 'jotai';
import { useState, useCallback, useEffect } from 'react';
import { Virtuoso } from 'react-virtuoso';
import Button from '../components/base/button';
Expand All @@ -11,8 +11,9 @@ 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';
import { libraryJotai, songlistsJotai } from '../jotais/library';
import { FormattedMessage, useIntl } from 'react-intl';
import { Menu, MenuItem, PredefinedMenuItem, Submenu } from '@tauri-apps/api/menu';

const sortOptions = [
{ value: 'default', label: 'Default' } as const,
Expand All @@ -22,12 +23,15 @@ const sortOptions = [
{ value: 'time_asc', label: 'Time' } as const
];

export default function Song () {
export default function SongPage () {
const _list = useAtomValue(libraryJotai);
const [list, setList] = useState(_list);
const barOpen = useAtomValue(nowPlayingBarJotai);
const scanned = useAtomValue(scannedJotai);
const [songlists, setSonglists] = useAtom(songlistsJotai);
const intl = useIntl();
const [multiselect, setMultiselect] = useState(false);
const [selected, setSelected] = useState<(number | string)[]>([]);
const [keyword, setKeyword] = useState('');
const [sortBy, setSortBy] = useState<SortOptions>('a-z');
const handleClickSong = useCallback((song: Song<string>) => {
Expand All @@ -42,6 +46,53 @@ export default function Song () {
player.addToPlaylist(...newList);
player.setCurrentSong(newList[0]);
}, [list]);
const handleSelect = useCallback((id: string | number, checked: boolean) => {
const newSelected = [...selected];
if (checked && !selected.includes(id)) newSelected.push(id);
else if (selected.includes(id)) {
newSelected.splice(selected.indexOf(id), 1);
}
setSelected(newSelected);
}, [selected]);
const handleMultiselectOperate = useCallback(async () => {
const menu = await Menu.new({
items: [
await MenuItem.new({
text: 'Add to playlist',
action: () => {
const songs = selected.map(id => _list.find(song => song.id === id)!);
player.clearPlaylist();
player.addToPlaylist(...songs);
player.setCurrentSong(songs[0]);
setMultiselect(false);
}
}),
await PredefinedMenuItem.new({
item: 'Separator'
}),
await Submenu.new({
text: 'Add to',
items: await Promise.all(songlists.map((songlist, index) => (MenuItem.new({
text: songlist.name,
action: () => {
const duplicatedSongs = songlist.songs.filter(song => selected.includes(song.id)).map(song => song.id);
const pureSelected = selected.filter(id => !duplicatedSongs.includes(id));
const newSonglist = {
...songlist,
songs: [...songlist.songs, ...pureSelected.map(id => _list.find(song => song.id === id)!)]
};
const newSonglists = [...songlists];
newSonglists[index] = newSonglist;
setSonglists(newSonglists);
setMultiselect(false);
}
}))))
})
]
});
menu.popup();
}, [selected, _list, songlists]);

useEffect(() => {
let ir: Song<string>[] = _list;
if (keyword.trim() !== '') {
Expand All @@ -56,40 +107,59 @@ export default function Song () {
<span className='color-text-pri font-size-3xl font-500'>
<FormattedMessage defaultMessage='Songs' />
</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' />
<FormattedMessage defaultMessage='Random' />
</Button>
{!scanned && (
<div className='flex items-center gap-2'>
<Spinner size='size-4' />
<span className='font-size-sm'>
<FormattedMessage defaultMessage='Scanning...' />
</span>
</div>
)}
<Input placeholder={intl.formatMessage({ defaultMessage: 'Search'})} value={keyword} onChange={(e) => {
setKeyword(e.target.value);
}} after={<span className='i-fluent:search-20-regular' />} className='m-l-auto' />
<span className='color-text-pri font-size-sm'>
<FormattedMessage defaultMessage='Sort By:' />
</span>
<Select position='left' options={sortOptions} onChange={option => {
setSortBy(option);
}} value={sortBy} />
<div className='flex flex-row items-center flex-wrap gap-2'>
<div className='flex 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' />
<FormattedMessage defaultMessage='Random' />
</Button>
<Button onClick={() => {
setMultiselect(!multiselect);
}} className='flex flex-row gap-2 items-center'>
{multiselect ? <><span className='i-fluent:dismiss-20-regular w-5 h-5' /><FormattedMessage defaultMessage='Cancel' /></> : <><span className='i-fluent:multiselect-20-regular w-5 h-5' /><FormattedMessage defaultMessage='Multiselect' /></>}
</Button>
{multiselect && (
<>
<span className='color-text-sec font-size-sm'>
<FormattedMessage defaultMessage='{total} Songs' values={{ total: selected.length }} />
</span>
<Button onClick={handleMultiselectOperate} disabled={selected.length < 1} className='flex items-center justify-center'>
<span className='i-fluent:wrench-20-regular w-5 h-5' />
</Button>
</>
)}
{!scanned && (
<div className='flex items-center gap-2'>
<Spinner size='size-4' />
<span className='font-size-sm'>
<FormattedMessage defaultMessage='Scanning...' />
</span>
</div>
)}
</div>
<div className='flex items-center gap-4 md:ml-auto'>
<Input placeholder={intl.formatMessage({ defaultMessage: 'Search' })} value={keyword} onChange={(e) => {
setKeyword(e.target.value);
}} after={<span className='i-fluent:search-20-regular' />} />
<span className='color-text-pri font-size-sm'>
<FormattedMessage defaultMessage='Sort By:' />
</span>
<Select position='left' options={sortOptions} onChange={option => {
setSortBy(option);
}} value={sortBy} />
</div>
</div>
</div>
{list.length > 0 ? (
<div className='h-[calc(100vh-204px)]'>
<div className='h-[calc(100vh-244px)] md:h-[calc(100vh-204px)]'>
<Virtuoso
computeItemKey={(i) => `${sortBy}-${i}`}
computeItemKey={(i) => `${sortBy}${i}`}
totalCount={barOpen ? list.length + 1 : list.length}
itemContent={(index) => {
if (index === list.length) {
return <div className='h-20' />;
}
const song = list[index];
return <SongItem song={song} onClick={handleClickSong} hideBg={!(index % 2)} />;
return <SongItem song={song} selectMode={multiselect} select={selected.includes(song.id)} onSelect={(checked) => handleSelect(song.id, checked)} onClick={handleClickSong} hideBg={!(index % 2)} />;
}}
/>
</div>
Expand Down
Loading

0 comments on commit 0ae7814

Please sign in to comment.