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 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>
);

View File

@ -1,36 +1,76 @@
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 (
<Box
display='flex'
flexDirection='column'
justifyContent='center'
{...props}
>
<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}}
<>
<Dialog
PaperProps={{
component: 'form',
onSubmit: handleSubmit
}}
open={open}
onClose={() => setOpen(false)}
>
<Add sx={{scale:2}} />
</MotionBox>
<Typography textAlign='center'>
New playlist
</Typography>
</Box>
<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'
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 { HTMLMotionProps } from "framer-motion";
import React from "react";
import { MotionBox } from "./MotionComponents";
export default function Page(props: BoxProps) {
const theme = useTheme();
return (
<Box
minHeight='100%'
maxWidth='100%'
minHeight='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={{
backgroundColor: theme.palette.background.default,
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 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>

View File

@ -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>
);

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) {
if (index < this.i) this.setI((i) => i - 1);
this.setSongs((prevSongs) => {
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() {
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>

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;
};
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`);
}