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 Player from "./components/Player";
|
||||
import Register from "./pages/Register";
|
||||
import PlayerMaximized from "./pages/PlayerMaximized";
|
||||
|
||||
export default function createRouter() {
|
||||
const router = createRoutesFromElements(
|
||||
|
|
@ -47,6 +48,10 @@ export default function createRouter() {
|
|||
path='/register'
|
||||
element={<Register />}
|
||||
/>
|
||||
<Route
|
||||
path='/player'
|
||||
element={<PlayerMaximized />}
|
||||
/>
|
||||
</Route>
|
||||
</Route>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,17 +1,56 @@
|
|||
import { Add } from "@mui/icons-material";
|
||||
import { Box, BoxProps, Theme, Typography, useTheme } from "@mui/material";
|
||||
import React from "react";
|
||||
import { Box, BoxProps, Button, Dialog, DialogActions, DialogContent, DialogContentText, DialogTitle, TextField, Theme, Typography, useTheme } from "@mui/material";
|
||||
import React, { FormEventHandler, useState } from "react";
|
||||
import { MotionBox } from "./MotionComponents";
|
||||
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 [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 (
|
||||
<>
|
||||
<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
|
||||
display='flex'
|
||||
flexDirection='column'
|
||||
justifyContent='center'
|
||||
{...props}
|
||||
onClick={() => setOpen(true)}
|
||||
>
|
||||
<MotionBox
|
||||
width={120}
|
||||
|
|
@ -24,13 +63,14 @@ export default function AddPlaylistButton(props: BoxProps) {
|
|||
color='success.contrastText'
|
||||
borderRadius={theme.shape.borderRadius}
|
||||
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>
|
||||
<Typography textAlign='center'>
|
||||
New playlist
|
||||
</Typography>
|
||||
</Box>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,5 +1,7 @@
|
|||
import { Box, BoxProps, useTheme } from "@mui/material";
|
||||
import { HTMLMotionProps } from "framer-motion";
|
||||
import React from "react";
|
||||
import { MotionBox } from "./MotionComponents";
|
||||
|
||||
export default function Page(props: BoxProps) {
|
||||
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) {
|
||||
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 useQueue from "../hooks/useQueue";
|
||||
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 { MotionBox } from "./MotionComponents";
|
||||
import { Variants } from "framer-motion";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
const variants = (theme: Theme): Variants => (
|
||||
{
|
||||
initial: {
|
||||
bottom: theme.spacing(-12)
|
||||
bottom: theme.spacing(-12),
|
||||
opacity: 0.8
|
||||
},
|
||||
final: {
|
||||
bottom: theme.spacing(0)
|
||||
bottom: theme.spacing(0),
|
||||
opacity: 0.8
|
||||
},
|
||||
hover: {
|
||||
opacity: 1
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
export default function Player() {
|
||||
const navigate = useNavigate();
|
||||
const theme = useTheme();
|
||||
const queue = useQueue();
|
||||
const audioRef = useRef<HTMLAudioElement>(null);
|
||||
|
|
@ -28,6 +35,7 @@ export default function Player() {
|
|||
const [canPlayPrev, setCanPlayPrev] = useState(false);
|
||||
const [repeatOne, setRepeatOne] = useState(false);
|
||||
const [currentTime, setCurrentTime] = useState(0);
|
||||
|
||||
const songURL = useMemo(
|
||||
() => getSongURL(song),
|
||||
[song, queue]
|
||||
|
|
@ -77,6 +85,13 @@ export default function Player() {
|
|||
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(() => {
|
||||
const intervalId = setInterval(() => {
|
||||
updateCurrentPlaying();
|
||||
|
|
@ -116,6 +131,7 @@ export default function Player() {
|
|||
variants={variants(theme)}
|
||||
initial='initial'
|
||||
animate='final'
|
||||
whileHover='hover'
|
||||
>
|
||||
<Box width='100%' height={theme.spacing(6)} p={2}>
|
||||
{song && songURL && (
|
||||
|
|
@ -123,9 +139,9 @@ export default function Player() {
|
|||
)}
|
||||
<Box display='flex' flexGrow={1} gap={2}>
|
||||
<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'>
|
||||
{song?.name}
|
||||
{song?.name.slice(0, 16)}{song.name.length > 16 && '...'}
|
||||
</Typography>
|
||||
<Box display='flex' gap={1}>
|
||||
<Typography color='textSecondary'>
|
||||
|
|
@ -138,6 +154,13 @@ export default function Player() {
|
|||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
<Box display='flex' flexGrow={1} flexShrink={0} alignSelf='center'>
|
||||
<Slider
|
||||
max={song.duration}
|
||||
value={currentTime}
|
||||
onChange={handleSlider}
|
||||
/>
|
||||
</Box>
|
||||
<Typography
|
||||
color='textDisabled'
|
||||
component='div'
|
||||
|
|
@ -172,6 +195,9 @@ export default function Player() {
|
|||
<IconButton disabled={!canPlayNext} onClick={handleNext}>
|
||||
<SkipNext />
|
||||
</IconButton>
|
||||
<IconButton onClick={() => navigate('/player')}>
|
||||
<Menu />
|
||||
</IconButton>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
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 { AddToQueue, Download as DownloadIcon, PlayArrow, PlaylistAdd } from '@mui/icons-material';
|
||||
|
||||
|
|
@ -11,6 +11,7 @@ type SongCardProps = {
|
|||
album: string;
|
||||
saveSong?: () => void;
|
||||
playSong?: () => void;
|
||||
controls?: ReactNode;
|
||||
};
|
||||
|
||||
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();
|
||||
|
||||
return (
|
||||
|
|
@ -82,6 +83,7 @@ export function SearchSongCard({ onClick, thumbnailUrl, artist, name, album, sav
|
|||
<PlayArrow />
|
||||
</IconButton>
|
||||
)}
|
||||
{controls}
|
||||
</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) {
|
||||
if (index < this.i) this.setI((i) => i - 1);
|
||||
this.setSongs((prevSongs) => {
|
||||
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() {
|
||||
const theme = useTheme();
|
||||
|
||||
const handleCreatePlaylist = (name: string) => {
|
||||
console.log(`Creating playlist ${name}`);
|
||||
};
|
||||
|
||||
return (
|
||||
<Page>
|
||||
<AppHeader />
|
||||
<PageRoot>
|
||||
<Typography variant='button'>Playlists</Typography>
|
||||
<Scroll display='flex' gap={theme.spacing(2)}>
|
||||
<AddPlaylistButton />
|
||||
<AddPlaylistButton onCreate={handleCreatePlaylist} />
|
||||
</Scroll>
|
||||
</PageRoot>
|
||||
</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;
|
||||
};
|
||||
|
||||
export type Playlist = {
|
||||
owner: string;
|
||||
songs: Song[];
|
||||
};
|
||||
|
||||
async function doPost(p: string, json: any) {
|
||||
const jsonString = JSON.stringify(json);
|
||||
const response = await fetch(API_BASE + p, {
|
||||
|
|
@ -47,6 +52,14 @@ async function doGet(p: string) {
|
|||
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[]> {
|
||||
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;
|
||||
}
|
||||
|
||||
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) {
|
||||
saveAs(getSongURL(song)!, `${song.artist.name} - ${song.name}.mp3`);
|
||||
}
|
||||
Loading…
Reference in New Issue