a lot of things

This commit is contained in:
OfficialDakari 2024-10-12 20:19:44 +05:00
parent 137692f654
commit a32855ac46
11 changed files with 355 additions and 38 deletions

View File

@ -12,6 +12,7 @@ import ErrorScreen from "./pages/ErrorScreen";
import Account from "./pages/Account"; import Account from "./pages/Account";
import Player from "./components/Player"; import Player from "./components/Player";
import Register from "./pages/Register"; import Register from "./pages/Register";
import PlayerMaximized from "./pages/PlayerMaximized";
export default function createRouter() { export default function createRouter() {
const router = createRoutesFromElements( const router = createRoutesFromElements(
@ -47,6 +48,10 @@ export default function createRouter() {
path='/register' path='/register'
element={<Register />} element={<Register />}
/> />
<Route
path='/player'
element={<PlayerMaximized />}
/>
</Route> </Route>
</Route> </Route>
); );

View File

@ -1,36 +1,76 @@
import { Add } from "@mui/icons-material"; import { Add } from "@mui/icons-material";
import { Box, BoxProps, Theme, Typography, useTheme } from "@mui/material"; import { Box, BoxProps, Button, Dialog, DialogActions, DialogContent, DialogContentText, DialogTitle, TextField, Theme, Typography, useTheme } from "@mui/material";
import React from "react"; import React, { FormEventHandler, useState } from "react";
import { MotionBox } from "./MotionComponents"; import { MotionBox } from "./MotionComponents";
import { Variants } from "framer-motion"; import { Variants } from "framer-motion";
export default function AddPlaylistButton(props: BoxProps) { type AddPlaylistButtonProps = {
onCreate: (name: string) => void | any;
};
export default function AddPlaylistButton({ onCreate }: AddPlaylistButtonProps) {
const theme = useTheme(); const theme = useTheme();
const [open, setOpen] = useState(false);
const handleSubmit: FormEventHandler<HTMLFormElement> = (evt) => {
const playlistName = evt.currentTarget.elements.namedItem('playlistName') as HTMLInputElement;
evt.preventDefault();
const name = playlistName.value.trim();
if (name.length === 0) return;
onCreate(name);
setOpen(false);
};
return ( return (
<Box <>
display='flex' <Dialog
flexDirection='column' PaperProps={{
justifyContent='center' component: 'form',
{...props} onSubmit: handleSubmit
> }}
<MotionBox open={open}
width={120} onClose={() => setOpen(false)}
height={120}
display='flex'
justifyContent='center'
alignContent='center'
alignItems='center'
bgcolor='success.main'
color='success.contrastText'
borderRadius={theme.shape.borderRadius}
whileHover={{ borderRadius: '30%', scale: 0.8 }}
whileTap={{borderRadius: '40%', scale: 0.7, backgroundColor: theme.palette.info.main}}
> >
<Add sx={{scale:2}} /> <DialogTitle>
</MotionBox> New playlist
<Typography textAlign='center'> </DialogTitle>
New playlist <DialogContent>
</Typography> <DialogContentText>Enter name for your new playlist</DialogContentText>
</Box> <TextField
name='playlistName'
label='Playlist name'
fullWidth
/>
</DialogContent>
<DialogActions>
<Button onClick={() => setOpen(false)}>Cancel</Button>
<Button type='submit'>Create</Button>
</DialogActions>
</Dialog>
<Box
display='flex'
flexDirection='column'
justifyContent='center'
onClick={() => setOpen(true)}
>
<MotionBox
width={120}
height={120}
display='flex'
justifyContent='center'
alignContent='center'
alignItems='center'
bgcolor='success.main'
color='success.contrastText'
borderRadius={theme.shape.borderRadius}
whileHover={{ borderRadius: '30%', scale: 0.8 }}
whileTap={{ borderRadius: '40%', scale: 0.7, backgroundColor: theme.palette.info.main, rotate: '90deg' }}
>
<Add sx={{ scale: 2 }} />
</MotionBox>
<Typography textAlign='center'>
New playlist
</Typography>
</Box>
</>
); );
} }

View File

@ -1,13 +1,32 @@
import { Box, BoxProps, useTheme } from "@mui/material"; import { Box, BoxProps, useTheme } from "@mui/material";
import { HTMLMotionProps } from "framer-motion";
import React from "react"; import React from "react";
import { MotionBox } from "./MotionComponents";
export default function Page(props: BoxProps) { export default function Page(props: BoxProps) {
const theme = useTheme(); const theme = useTheme();
return ( return (
<Box <Box
minHeight='100%' minHeight='100%'
maxWidth='100%' maxWidth='100%'
sx={{
backgroundColor: theme.palette.background.default,
color: theme.palette.text.primary
}}
bgcolor='default'
{...props}
/>
);
}
export function MotionPage(props: BoxProps & HTMLMotionProps<'div'>) {
const theme = useTheme();
return (
<MotionBox
minHeight='100%'
maxWidth='100%'
sx={{ sx={{
backgroundColor: theme.palette.background.default, backgroundColor: theme.palette.background.default,
color: theme.palette.text.primary color: theme.palette.text.primary

View File

@ -1,24 +1,31 @@
import { BottomNavigation, BottomNavigationAction, Box, Button, IconButton, Theme, Typography, useTheme } from "@mui/material"; import { BottomNavigation, BottomNavigationAction, Box, Button, IconButton, Slider, Theme, Typography, useTheme } from "@mui/material";
import React, { useEffect, useMemo, useRef, useState } from "react"; import React, { useEffect, useMemo, useRef, useState } from "react";
import useQueue from "../hooks/useQueue"; import useQueue from "../hooks/useQueue";
import { getSongURL, Song } from "../utils/MusicServer"; import { getSongURL, Song } from "../utils/MusicServer";
import { Pause, PlayArrow, Repeat, RepeatOn, RepeatOne, RepeatOneOn, SkipNext, SkipPrevious } from "@mui/icons-material"; import { Menu, MoreHoriz, Pause, PlayArrow, Repeat, RepeatOn, RepeatOne, RepeatOneOn, SkipNext, SkipPrevious } from "@mui/icons-material";
import durationToStr from "../utils/duration"; import durationToStr from "../utils/duration";
import { MotionBox } from "./MotionComponents"; import { MotionBox } from "./MotionComponents";
import { Variants } from "framer-motion"; import { Variants } from "framer-motion";
import { useNavigate } from "react-router-dom";
const variants = (theme: Theme): Variants => ( const variants = (theme: Theme): Variants => (
{ {
initial: { initial: {
bottom: theme.spacing(-12) bottom: theme.spacing(-12),
opacity: 0.8
}, },
final: { final: {
bottom: theme.spacing(0) bottom: theme.spacing(0),
opacity: 0.8
},
hover: {
opacity: 1
} }
} }
); );
export default function Player() { export default function Player() {
const navigate = useNavigate();
const theme = useTheme(); const theme = useTheme();
const queue = useQueue(); const queue = useQueue();
const audioRef = useRef<HTMLAudioElement>(null); const audioRef = useRef<HTMLAudioElement>(null);
@ -28,6 +35,7 @@ export default function Player() {
const [canPlayPrev, setCanPlayPrev] = useState(false); const [canPlayPrev, setCanPlayPrev] = useState(false);
const [repeatOne, setRepeatOne] = useState(false); const [repeatOne, setRepeatOne] = useState(false);
const [currentTime, setCurrentTime] = useState(0); const [currentTime, setCurrentTime] = useState(0);
const songURL = useMemo( const songURL = useMemo(
() => getSongURL(song), () => getSongURL(song),
[song, queue] [song, queue]
@ -77,6 +85,13 @@ export default function Player() {
setCurrentTime(audio.currentTime); setCurrentTime(audio.currentTime);
}; };
const handleSlider = (evt: Event, value: number | number[]) => {
const v = typeof value === 'number' ? value : value[0];
setCurrentTime(v);
if (audioRef.current)
audioRef.current.currentTime = v;
};
useEffect(() => { useEffect(() => {
const intervalId = setInterval(() => { const intervalId = setInterval(() => {
updateCurrentPlaying(); updateCurrentPlaying();
@ -116,6 +131,7 @@ export default function Player() {
variants={variants(theme)} variants={variants(theme)}
initial='initial' initial='initial'
animate='final' animate='final'
whileHover='hover'
> >
<Box width='100%' height={theme.spacing(6)} p={2}> <Box width='100%' height={theme.spacing(6)} p={2}>
{song && songURL && ( {song && songURL && (
@ -123,9 +139,9 @@ export default function Player() {
)} )}
<Box display='flex' flexGrow={1} gap={2}> <Box display='flex' flexGrow={1} gap={2}>
<img src={song?.thumbnails[0].url} width={64} /> <img src={song?.thumbnails[0].url} width={64} />
<Box display='flex' flexDirection='column' flexGrow={1}> <Box display='flex' flexDirection='column' flexGrow={0} flexShrink={1}>
<Typography variant='h6' component='div'> <Typography variant='h6' component='div'>
{song?.name} {song?.name.slice(0, 16)}{song.name.length > 16 && '...'}
</Typography> </Typography>
<Box display='flex' gap={1}> <Box display='flex' gap={1}>
<Typography color='textSecondary'> <Typography color='textSecondary'>
@ -138,6 +154,13 @@ export default function Player() {
)} )}
</Box> </Box>
</Box> </Box>
<Box display='flex' flexGrow={1} flexShrink={0} alignSelf='center'>
<Slider
max={song.duration}
value={currentTime}
onChange={handleSlider}
/>
</Box>
<Typography <Typography
color='textDisabled' color='textDisabled'
component='div' component='div'
@ -172,6 +195,9 @@ export default function Player() {
<IconButton disabled={!canPlayNext} onClick={handleNext}> <IconButton disabled={!canPlayNext} onClick={handleNext}>
<SkipNext /> <SkipNext />
</IconButton> </IconButton>
<IconButton onClick={() => navigate('/player')}>
<Menu />
</IconButton>
</Box> </Box>
</Box> </Box>
</Box> </Box>

View File

@ -1,5 +1,5 @@
import { Box, IconButton, Skeleton, Typography, useTheme } from "@mui/material"; import { Box, IconButton, Skeleton, Typography, useTheme } from "@mui/material";
import React, { MouseEventHandler } from "react"; import React, { MouseEventHandler, ReactNode } from "react";
import { LazyLoadImage } from 'react-lazy-load-image-component'; import { LazyLoadImage } from 'react-lazy-load-image-component';
import { AddToQueue, Download as DownloadIcon, PlayArrow, PlaylistAdd } from '@mui/icons-material'; import { AddToQueue, Download as DownloadIcon, PlayArrow, PlaylistAdd } from '@mui/icons-material';
@ -11,6 +11,7 @@ type SongCardProps = {
album: string; album: string;
saveSong?: () => void; saveSong?: () => void;
playSong?: () => void; playSong?: () => void;
controls?: ReactNode;
}; };
export default function SongCard({ onClick, thumbnailUrl, artist, name, album }: SongCardProps) { export default function SongCard({ onClick, thumbnailUrl, artist, name, album }: SongCardProps) {
@ -33,7 +34,7 @@ export default function SongCard({ onClick, thumbnailUrl, artist, name, album }:
); );
} }
export function SearchSongCard({ onClick, thumbnailUrl, artist, name, album, saveSong, playSong }: SongCardProps) { export function SearchSongCard({ onClick, thumbnailUrl, artist, name, album, saveSong, playSong, controls }: SongCardProps) {
const theme = useTheme(); const theme = useTheme();
return ( return (
@ -82,6 +83,7 @@ export function SearchSongCard({ onClick, thumbnailUrl, artist, name, album, sav
<PlayArrow /> <PlayArrow />
</IconButton> </IconButton>
)} )}
{controls}
</Box> </Box>
</Box> </Box>
); );

View File

@ -0,0 +1,22 @@
import React, { useState } from "react";
import { addSong, getPlaylist, removeSong, Song } from "../utils/MusicServer";
export default function usePlaylist(id: string) {
const [songs, setSongs] = useState<Song[]>([]);
getPlaylist(id).then((playlist) => {
setSongs(playlist.songs);
});
return {
songs,
addSong(song: Song) {
setSongs((songs) => [...songs, song]);
addSong(id, song);
},
removeSong(song: Song) {
setSongs((songs) => songs.filter(x => x.videoId !== song.videoId));
removeSong(id, song);
}
};
}

View File

@ -49,6 +49,7 @@ export class Queue {
} }
removeSong(index: number) { removeSong(index: number) {
if (index < this.i) this.setI((i) => i - 1);
this.setSongs((prevSongs) => { this.setSongs((prevSongs) => {
return prevSongs.filter((_, i) => i !== index); // создаем новый массив без удаленного элемента return prevSongs.filter((_, i) => i !== index); // создаем новый массив без удаленного элемента
}); });

13
src/app/hooks/useSong.ts Normal file
View File

@ -0,0 +1,13 @@
import React, { useEffect, useState } from "react";
import { Song } from "../utils/MusicServer";
import useQueue from "./useQueue";
export default function useSong(): Song | undefined {
const queue = useQueue();
if (!queue) return undefined;
const [song, setSong] = useState<Song | undefined>();
useEffect(() => {
setSong(queue.getCurrent());
}, [queue]);
return song;
}

View File

@ -9,13 +9,17 @@ import AddPlaylistButton from "../components/AddPlaylistButton";
export default function Landing() { export default function Landing() {
const theme = useTheme(); const theme = useTheme();
const handleCreatePlaylist = (name: string) => {
console.log(`Creating playlist ${name}`);
};
return ( return (
<Page> <Page>
<AppHeader /> <AppHeader />
<PageRoot> <PageRoot>
<Typography variant='button'>Playlists</Typography> <Typography variant='button'>Playlists</Typography>
<Scroll display='flex' gap={theme.spacing(2)}> <Scroll display='flex' gap={theme.spacing(2)}>
<AddPlaylistButton /> <AddPlaylistButton onCreate={handleCreatePlaylist} />
</Scroll> </Scroll>
</PageRoot> </PageRoot>
</Page> </Page>

View File

@ -0,0 +1,138 @@
import React, { ReactNode, useEffect, useState } from "react";
import Page, { MotionPage, PageRoot } from "../components/Page";
import { Alert, AppBar, Box, CircularProgress, IconButton, Tab, Tabs, Toolbar, Typography } from "@mui/material";
import useSong from "../hooks/useSong";
import { MotionBox } from "../components/MotionComponents";
import useQueue from "../hooks/useQueue";
import { getLyrics } from "../utils/MusicServer";
import { SearchSongCard } from "../components/SongCard";
import { Close } from "@mui/icons-material";
function a11yProps(i: number) {
return {
id: `tab-control-${i}`,
'aria-controls': `tab-${i}`
};
}
function QueueTab() {
const queue = useQueue();
useEffect(() => {
}, [queue]);
return (
<MotionBox
initial={{ translateX: '-30%', opacity: 0.4 }}
animate={{ translateX: 0, opacity: 1 }}
gap={2}
p={3}
display='flex'
flexDirection='column'
>
{queue && queue.songs.length > 0 && queue.songs && (
queue.songs.map(
(song, i) => (
<SearchSongCard
album={song.album?.name}
artist={song.artist.name}
name={song.name}
thumbnailUrl={song.thumbnails[0].url}
controls={
<>
<IconButton
onClick={() => queue.removeSong(i)}
>
<Close />
</IconButton>
</>
}
/>
)
)
)}
</MotionBox>
);
}
function LyricsTab() {
const song = useSong();
const [lyrics, setLyrics] = useState<ReactNode[]>([]);
useEffect(() => {
if (!song) {
setLyrics([
<Alert severity="info">
There are no any songs in the queue.
</Alert>
]);
return;
}
setLyrics([
<CircularProgress />
]);
getLyrics(song).then(
lyrics => {
setLyrics(lyrics.map((line) => (
<Typography>
{line}
</Typography>
)));
}
).catch(
err => {
setLyrics([
<Alert severity='error'>
Could not load lyrics: {err.message}
</Alert>
]);
}
);
}, [song]);
return (
<MotionBox
initial={{ translateX: '30%', opacity: 0.4 }}
animate={{ translateX: 0, opacity: 1 }}
>
<Box p={3}>
{lyrics}
</Box>
</MotionBox>
);
}
export default function PlayerMaximized() {
const [selectedTab, setSelectedTab] = useState(0);
return (
<MotionPage
initial={{ scale: 0.7, opacity: 0.3 }}
animate={{ scale: 1, opacity: 1 }}
>
<AppBar position='sticky'>
<Toolbar>
<Typography variant='h6' component='div' flexGrow={1}>
Extera Music
</Typography>
<IconButton onClick={() => history.back()}>
<Close />
</IconButton>
</Toolbar>
</AppBar>
<PageRoot>
<Box sx={{ borderBottom: 1, borderColor: 'divider' }}>
<Tabs
value={selectedTab}
onChange={(evt, v) => setSelectedTab(v)}
>
<Tab label='Queue' {...a11yProps(0)} />
<Tab label='Lyrics' {...a11yProps(1)} />
</Tabs>
</Box>
{selectedTab === 0 && <QueueTab />}
{selectedTab === 1 && <LyricsTab />}
</PageRoot>
</MotionPage>
);
}

View File

@ -28,6 +28,11 @@ export type Song = {
album: Album; album: Album;
}; };
export type Playlist = {
owner: string;
songs: Song[];
};
async function doPost(p: string, json: any) { async function doPost(p: string, json: any) {
const jsonString = JSON.stringify(json); const jsonString = JSON.stringify(json);
const response = await fetch(API_BASE + p, { const response = await fetch(API_BASE + p, {
@ -47,6 +52,14 @@ async function doGet(p: string) {
return await response.json(); return await response.json();
} }
async function doDelete(p: string) {
const response = await fetch(API_BASE + p, {
method: 'DELETE'
});
if (!response.ok) throw await response.json();
return await response.json();
}
export async function searchSongs(q: string): Promise<Song[]> { export async function searchSongs(q: string): Promise<Song[]> {
return await doGet(`/music/search?q=${encodeURIComponent(q)}`); return await doGet(`/music/search?q=${encodeURIComponent(q)}`);
} }
@ -64,6 +77,40 @@ export async function login(username?: string, password?: string): Promise<strin
return (await doPost(`/auth/login`, {username, password})).token as string; return (await doPost(`/auth/login`, {username, password})).token as string;
} }
export async function getLyrics(song: Song): Promise<string[]> {
return await doGet(`/music/lyrics?id=${song.videoId}`);
}
export async function createPlaylist(name: string): Promise<string> {
const { id } = await doPost('/playlists/new', {
name
});
return id;
}
export async function getPlaylists(): Promise<Playlist[]> {
return await doGet('/playlists/list');
}
export async function getPlaylist(id: string): Promise<Playlist> {
return await doGet(`/playlists/${id}`);
}
export async function deletePlaylist(id: string) {
await doDelete(`/playlists/${id}`);
}
export async function addSong(playlistId: string, song: Song) {
await doPost(`/playlists/${playlistId}`, {
songId: song.videoId
});
}
export async function removeSong(playlistId: string, song: Song) {
await doDelete(`/playlists/${playlistId}/${song.videoId}`);
}
export function saveSong(song: Song) { export function saveSong(song: Song) {
saveAs(getSongURL(song)!, `${song.artist.name} - ${song.name}.mp3`); saveAs(getSongURL(song)!, `${song.artist.name} - ${song.name}.mp3`);
} }