a lot of things
This commit is contained in:
parent
137692f654
commit
a32855ac46
|
|
@ -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>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,56 @@
|
||||||
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 (
|
||||||
|
<>
|
||||||
|
<Dialog
|
||||||
|
PaperProps={{
|
||||||
|
component: 'form',
|
||||||
|
onSubmit: handleSubmit
|
||||||
|
}}
|
||||||
|
open={open}
|
||||||
|
onClose={() => setOpen(false)}
|
||||||
|
>
|
||||||
|
<DialogTitle>
|
||||||
|
New playlist
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogContentText>Enter name for your new playlist</DialogContentText>
|
||||||
|
<TextField
|
||||||
|
name='playlistName'
|
||||||
|
label='Playlist name'
|
||||||
|
fullWidth
|
||||||
|
/>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button onClick={() => setOpen(false)}>Cancel</Button>
|
||||||
|
<Button type='submit'>Create</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
<Box
|
<Box
|
||||||
display='flex'
|
display='flex'
|
||||||
flexDirection='column'
|
flexDirection='column'
|
||||||
justifyContent='center'
|
justifyContent='center'
|
||||||
{...props}
|
onClick={() => setOpen(true)}
|
||||||
>
|
>
|
||||||
<MotionBox
|
<MotionBox
|
||||||
width={120}
|
width={120}
|
||||||
|
|
@ -24,13 +63,14 @@ export default function AddPlaylistButton(props: BoxProps) {
|
||||||
color='success.contrastText'
|
color='success.contrastText'
|
||||||
borderRadius={theme.shape.borderRadius}
|
borderRadius={theme.shape.borderRadius}
|
||||||
whileHover={{ borderRadius: '30%', scale: 0.8 }}
|
whileHover={{ borderRadius: '30%', scale: 0.8 }}
|
||||||
whileTap={{borderRadius: '40%', scale: 0.7, backgroundColor: theme.palette.info.main}}
|
whileTap={{ borderRadius: '40%', scale: 0.7, backgroundColor: theme.palette.info.main, rotate: '90deg' }}
|
||||||
>
|
>
|
||||||
<Add sx={{scale:2}} />
|
<Add sx={{ scale: 2 }} />
|
||||||
</MotionBox>
|
</MotionBox>
|
||||||
<Typography textAlign='center'>
|
<Typography textAlign='center'>
|
||||||
New playlist
|
New playlist
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
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();
|
||||||
|
|
@ -18,6 +20,23 @@ export default function Page(props: BoxProps) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function MotionPage(props: BoxProps & HTMLMotionProps<'div'>) {
|
||||||
|
const theme = useTheme();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<MotionBox
|
||||||
|
minHeight='100%'
|
||||||
|
maxWidth='100%'
|
||||||
|
sx={{
|
||||||
|
backgroundColor: theme.palette.background.default,
|
||||||
|
color: theme.palette.text.primary
|
||||||
|
}}
|
||||||
|
bgcolor='default'
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function PageRoot(props: BoxProps) {
|
export function PageRoot(props: BoxProps) {
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -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); // создаем новый массив без удаленного элемента
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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`);
|
||||||
}
|
}
|
||||||
Loading…
Reference in New Issue