diff --git a/package-lock.json b/package-lock.json index b1d29a9..d78d821 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,11 +13,14 @@ "@emotion/styled": "^11.13.0", "@esbuild-plugins/node-globals-polyfill": "^0.2.3", "@mui/icons-material": "^6.1.2", + "@mui/lab": "^6.0.0-beta.11", "@mui/material": "^6.1.2", "@rollup/plugin-inject": "^5.0.5", "@vanilla-extract/css": "^1.16.0", "@vanilla-extract/vite-plugin": "^4.0.16", "@vitejs/plugin-react": "^4.3.2", + "file-saver": "^2.0.5", + "framer-motion": "^11.11.8", "react": "^18.3.1", "react-dom": "^18.3.1", "react-lazy-load-image-component": "^1.6.2", @@ -28,6 +31,7 @@ "vite-plugin-top-level-await": "^1.4.4" }, "devDependencies": { + "@types/file-saver": "^2.0.7", "@types/react": "^18.3.11", "@types/react-dom": "^18.3.0", "@types/react-lazy-load-image-component": "^1.6.4", @@ -2319,6 +2323,44 @@ "node": ">=12" } }, + "node_modules/@floating-ui/core": { + "version": "1.6.8", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.6.8.tgz", + "integrity": "sha512-7XJ9cPU+yI2QeLS+FCSlqNFZJq8arvswefkZrYI1yQBbftw6FyrZOxYSh+9S7z7TpeWlRt9zJ5IhM1WIL334jA==", + "license": "MIT", + "dependencies": { + "@floating-ui/utils": "^0.2.8" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.6.11", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.6.11.tgz", + "integrity": "sha512-qkMCxSR24v2vGkhYDo/UzxfJN3D4syqSjyuTFz6C7XcpU1pASPRieNI0Kj5VP3/503mOfYiGY891ugBX1GlABQ==", + "license": "MIT", + "dependencies": { + "@floating-ui/core": "^1.6.0", + "@floating-ui/utils": "^0.2.8" + } + }, + "node_modules/@floating-ui/react-dom": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.2.tgz", + "integrity": "sha512-06okr5cgPzMNBy+Ycse2A6udMi4bqwW/zgBF/rwjcNqWkyr82Mcg8b0vjX8OJpZFy/FKjJmw6wV7t44kK6kW7A==", + "license": "MIT", + "dependencies": { + "@floating-ui/dom": "^1.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.8", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.8.tgz", + "integrity": "sha512-kym7SodPp8/wloecOpcmSnWJsK7M0E5Wg8UcFA+uO4B9s5d0ywXOEro/8HM9x0rW+TljRzul/14UYz3TleT3ig==", + "license": "MIT" + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.5", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.5.tgz", @@ -2377,10 +2419,72 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@mui/base": { + "version": "5.0.0-beta.58", + "resolved": "https://registry.npmjs.org/@mui/base/-/base-5.0.0-beta.58.tgz", + "integrity": "sha512-P0E7ZrxOuyYqBvVv9w8k7wm+Xzx/KRu+BGgFcR2htTsGCpJNQJCSUXNUZ50MUmSU9hzqhwbQWNXhV1MBTl6F7A==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.25.0", + "@floating-ui/react-dom": "^2.1.1", + "@mui/types": "^7.2.15", + "@mui/utils": "6.0.0-rc.0", + "@popperjs/core": "^2.11.8", + "clsx": "^2.1.1", + "prop-types": "^15.8.1" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@types/react": "^17.0.0 || ^18.0.0", + "react": "^17.0.0 || ^18.0.0", + "react-dom": "^17.0.0 || ^18.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@mui/base/node_modules/@mui/utils": { + "version": "6.0.0-rc.0", + "resolved": "https://registry.npmjs.org/@mui/utils/-/utils-6.0.0-rc.0.tgz", + "integrity": "sha512-tBp0ILEXDL0bbDDT8PnZOjCqSm5Dfk2N0Z45uzRw+wVl6fVvloC9zw8avl+OdX1Bg3ubs/ttKn8nRNv17bpM5A==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.25.0", + "@mui/types": "^7.2.15", + "@types/prop-types": "^15.7.12", + "clsx": "^2.1.1", + "prop-types": "^15.8.1", + "react-is": "^18.3.1" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@mui/core-downloads-tracker": { - "version": "6.1.2", - "resolved": "https://registry.npmjs.org/@mui/core-downloads-tracker/-/core-downloads-tracker-6.1.2.tgz", - "integrity": "sha512-1oE4U38/TtzLWRYWEm/m70dUbpcvBx0QvDVg6NtpOmSNQC1Mbx0X/rNvYDdZnn8DIsAiVQ+SZ3am6doSswUQ4g==", + "version": "6.1.3", + "resolved": "https://registry.npmjs.org/@mui/core-downloads-tracker/-/core-downloads-tracker-6.1.3.tgz", + "integrity": "sha512-ajMUgdfhTb++rwqj134Cq9f4SRN8oXUqMRnY72YBnXiXai3olJLLqETheRlq3MM8wCKrbq7g6j7iWL1VvP44VQ==", "license": "MIT", "funding": { "type": "opencollective", @@ -2413,17 +2517,62 @@ } } }, - "node_modules/@mui/material": { - "version": "6.1.2", - "resolved": "https://registry.npmjs.org/@mui/material/-/material-6.1.2.tgz", - "integrity": "sha512-5TtHeAVX9D5d2LYfB1GAUn29BcVETVsrQ76Dwb2SpAfQGW3JVy4deJCAd0RrIkI3eEUrsl0E4xuBdreszxdTTg==", + "node_modules/@mui/lab": { + "version": "6.0.0-beta.11", + "resolved": "https://registry.npmjs.org/@mui/lab/-/lab-6.0.0-beta.11.tgz", + "integrity": "sha512-IoYzxAepMs0gnQ2tTMokEd8Bmqt+To/8HQyzjrQCbYZmKyYR/6aK3wm3Y5NpfSLuBo1UrkeXWyKsHeRcHreGdQ==", "license": "MIT", "dependencies": { "@babel/runtime": "^7.25.6", - "@mui/core-downloads-tracker": "^6.1.2", - "@mui/system": "^6.1.2", - "@mui/types": "^7.2.17", - "@mui/utils": "^6.1.2", + "@mui/base": "5.0.0-beta.58", + "@mui/system": "^6.1.3", + "@mui/types": "^7.2.18", + "@mui/utils": "^6.1.3", + "clsx": "^2.1.1", + "prop-types": "^15.8.1" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@emotion/react": "^11.5.0", + "@emotion/styled": "^11.3.0", + "@mui/material": "^6.1.3", + "@mui/material-pigment-css": "^6.1.3", + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/react": { + "optional": true + }, + "@emotion/styled": { + "optional": true + }, + "@mui/material-pigment-css": { + "optional": true + }, + "@types/react": { + "optional": true + } + } + }, + "node_modules/@mui/material": { + "version": "6.1.3", + "resolved": "https://registry.npmjs.org/@mui/material/-/material-6.1.3.tgz", + "integrity": "sha512-loV5MBoMKLrK80JeWINmQ1A4eWoLv51O2dBPLJ260IAhupkB3Wol8lEQTEvvR2vO3o6xRHuXe1WaQEP6N3riqg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.25.6", + "@mui/core-downloads-tracker": "^6.1.3", + "@mui/system": "^6.1.3", + "@mui/types": "^7.2.18", + "@mui/utils": "^6.1.3", "@popperjs/core": "^2.11.8", "@types/react-transition-group": "^4.4.11", "clsx": "^2.1.1", @@ -2442,7 +2591,7 @@ "peerDependencies": { "@emotion/react": "^11.5.0", "@emotion/styled": "^11.3.0", - "@mui/material-pigment-css": "^6.1.2", + "@mui/material-pigment-css": "^6.1.3", "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", "react": "^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0" @@ -2463,13 +2612,13 @@ } }, "node_modules/@mui/private-theming": { - "version": "6.1.2", - "resolved": "https://registry.npmjs.org/@mui/private-theming/-/private-theming-6.1.2.tgz", - "integrity": "sha512-S8WcjZdNdi++8UhrrY8Lton5h/suRiQexvdTfdcPAlbajlvgM+kx+uJstuVIEyTb3gMkxzIZep87knZ0tqcR0g==", + "version": "6.1.3", + "resolved": "https://registry.npmjs.org/@mui/private-theming/-/private-theming-6.1.3.tgz", + "integrity": "sha512-XK5OYCM0x7gxWb/WBEySstBmn+dE3YKX7U7jeBRLm6vHU5fGUd7GiJWRirpivHjOK9mRH6E1MPIVd+ze5vguKQ==", "license": "MIT", "dependencies": { "@babel/runtime": "^7.25.6", - "@mui/utils": "^6.1.2", + "@mui/utils": "^6.1.3", "prop-types": "^15.8.1" }, "engines": { @@ -2490,13 +2639,14 @@ } }, "node_modules/@mui/styled-engine": { - "version": "6.1.2", - "resolved": "https://registry.npmjs.org/@mui/styled-engine/-/styled-engine-6.1.2.tgz", - "integrity": "sha512-uKOfWkR23X39xj7th2nyTcCHqInTAXtUnqD3T5qRVdJcOPvu1rlgTleTwJC/FJvWZJBU6ieuTWDhbcx5SNViHQ==", + "version": "6.1.3", + "resolved": "https://registry.npmjs.org/@mui/styled-engine/-/styled-engine-6.1.3.tgz", + "integrity": "sha512-i4yh9m+eMZE3cNERpDhVr6Wn73Yz6C7MH0eE2zZvw8d7EFkIJlCQNZd1xxGZqarD2DDq2qWHcjIOucWGhxACtA==", "license": "MIT", "dependencies": { "@babel/runtime": "^7.25.6", "@emotion/cache": "^11.13.1", + "@emotion/serialize": "^1.3.2", "@emotion/sheet": "^1.4.0", "csstype": "^3.1.3", "prop-types": "^15.8.1" @@ -2523,16 +2673,16 @@ } }, "node_modules/@mui/system": { - "version": "6.1.2", - "resolved": "https://registry.npmjs.org/@mui/system/-/system-6.1.2.tgz", - "integrity": "sha512-mzW7F1ZMIYS1aLON48Nrk9c65OrVEVQ+R4lUcTWs1lCSul0VGK23eo4dmY0NX5PS7Oe4xz3P5B9tQZZ7SYgxcg==", + "version": "6.1.3", + "resolved": "https://registry.npmjs.org/@mui/system/-/system-6.1.3.tgz", + "integrity": "sha512-ILaD9UsLTBLjMcep3OumJMXh1PYr7aqnkHm/L47bH46+YmSL1zWAX6tWG8swEQROzW2GvYluEMp5FreoxOOC6w==", "license": "MIT", "dependencies": { "@babel/runtime": "^7.25.6", - "@mui/private-theming": "^6.1.2", - "@mui/styled-engine": "^6.1.2", - "@mui/types": "^7.2.17", - "@mui/utils": "^6.1.2", + "@mui/private-theming": "^6.1.3", + "@mui/styled-engine": "^6.1.3", + "@mui/types": "^7.2.18", + "@mui/utils": "^6.1.3", "clsx": "^2.1.1", "csstype": "^3.1.3", "prop-types": "^15.8.1" @@ -2563,9 +2713,9 @@ } }, "node_modules/@mui/types": { - "version": "7.2.17", - "resolved": "https://registry.npmjs.org/@mui/types/-/types-7.2.17.tgz", - "integrity": "sha512-oyumoJgB6jDV8JFzRqjBo2daUuHpzDjoO/e3IrRhhHo/FxJlaVhET6mcNrKHUq2E+R+q3ql0qAtvQ4rfWHhAeQ==", + "version": "7.2.18", + "resolved": "https://registry.npmjs.org/@mui/types/-/types-7.2.18.tgz", + "integrity": "sha512-uvK9dWeyCJl/3ocVnTOS6nlji/Knj8/tVqVX03UVTpdmTJYu/s4jtDd9Kvv0nRGE0CUSNW1UYAci7PYypjealg==", "license": "MIT", "peerDependencies": { "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0" @@ -2577,13 +2727,13 @@ } }, "node_modules/@mui/utils": { - "version": "6.1.2", - "resolved": "https://registry.npmjs.org/@mui/utils/-/utils-6.1.2.tgz", - "integrity": "sha512-6+B1YZ8cCBWD1fc3RjqpclF9UA0MLUiuXhyCO+XowD/Z2ku5IlxeEhHHlgglyBWFGMu4kib4YU3CDsG5/zVjJQ==", + "version": "6.1.3", + "resolved": "https://registry.npmjs.org/@mui/utils/-/utils-6.1.3.tgz", + "integrity": "sha512-4JBpLkjprlKjN10DGb1aiy/ii9TKbQ601uSHtAmYFAS879QZgAD7vRnv/YBE4iBbc7NXzFgbQMCOFrupXWekIA==", "license": "MIT", "dependencies": { "@babel/runtime": "^7.25.6", - "@mui/types": "^7.2.17", + "@mui/types": "^7.2.18", "@types/prop-types": "^15.7.13", "clsx": "^2.1.1", "prop-types": "^15.8.1", @@ -3268,6 +3418,13 @@ "integrity": "sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw==", "license": "MIT" }, + "node_modules/@types/file-saver": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/file-saver/-/file-saver-2.0.7.tgz", + "integrity": "sha512-dNKVfHd/jk0SkR/exKGj2ggkB45MAkzvWCaqLUUgkyjITkGNzH8H+yUwr+BLJUBjZOe9w8X3wgmXhZDRg1ED6A==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/history": { "version": "4.7.11", "resolved": "https://registry.npmjs.org/@types/history/-/history-4.7.11.tgz", @@ -4397,6 +4554,12 @@ } } }, + "node_modules/file-saver": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/file-saver/-/file-saver-2.0.5.tgz", + "integrity": "sha512-P9bmyZ3h/PRG+Nzga+rbdI4OEpNDzAVyy74uVO9ATgzLK6VtAsYybF/+TOCvrc0MO793d6+42lLyZTw7/ArVzA==", + "license": "MIT" + }, "node_modules/filelist": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.4.tgz", @@ -4470,6 +4633,31 @@ "is-callable": "^1.1.3" } }, + "node_modules/framer-motion": { + "version": "11.11.8", + "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-11.11.8.tgz", + "integrity": "sha512-mnGQNEoz99GtFXBBPw+Ag5K4FcfP5XrXxrxHz+iE4Lmg7W3sf2gKmGuvfkZCW/yIfcdv5vJd6KiSPETH1Pw68Q==", + "license": "MIT", + "dependencies": { + "tslib": "^2.4.0" + }, + "peerDependencies": { + "@emotion/is-prop-valid": "*", + "react": "^18.0.0", + "react-dom": "^18.0.0" + }, + "peerDependenciesMeta": { + "@emotion/is-prop-valid": { + "optional": true + }, + "react": { + "optional": true + }, + "react-dom": { + "optional": true + } + } + }, "node_modules/fs-extra": { "version": "11.2.0", "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.2.0.tgz", @@ -6904,7 +7092,6 @@ "version": "2.7.0", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.7.0.tgz", "integrity": "sha512-gLXCKdN1/j47AiHiOkJN69hJmcbGTHI0ImLmbYLHykhgeN0jVGola9yVjFgzCUklsZQMW55o+dW7IXv3RCXDzA==", - "devOptional": true, "license": "0BSD" }, "node_modules/type-fest": { diff --git a/package.json b/package.json index 7afda4f..e6002bb 100644 --- a/package.json +++ b/package.json @@ -14,11 +14,14 @@ "@emotion/styled": "^11.13.0", "@esbuild-plugins/node-globals-polyfill": "^0.2.3", "@mui/icons-material": "^6.1.2", + "@mui/lab": "^6.0.0-beta.11", "@mui/material": "^6.1.2", "@rollup/plugin-inject": "^5.0.5", "@vanilla-extract/css": "^1.16.0", "@vanilla-extract/vite-plugin": "^4.0.16", "@vitejs/plugin-react": "^4.3.2", + "file-saver": "^2.0.5", + "framer-motion": "^11.11.8", "react": "^18.3.1", "react-dom": "^18.3.1", "react-lazy-load-image-component": "^1.6.2", @@ -29,6 +32,7 @@ "vite-plugin-top-level-await": "^1.4.4" }, "devDependencies": { + "@types/file-saver": "^2.0.7", "@types/react": "^18.3.11", "@types/react-dom": "^18.3.0", "@types/react-lazy-load-image-component": "^1.6.4", diff --git a/src/app/App.tsx b/src/app/App.tsx index c44a46d..51c8fe5 100644 --- a/src/app/App.tsx +++ b/src/app/App.tsx @@ -1,7 +1,9 @@ -import React from "react"; +import React, { useMemo, useState } from "react"; import { Router, RouterProvider } from "react-router-dom"; import createRouter from "./Router"; import { createTheme, ThemeProvider } from "@mui/material"; +import { Queue, QueueContextProvider } from "./hooks/useQueue"; +import { Song } from "./utils/MusicServer"; export default function App() { const router = createRouter(); @@ -11,9 +13,20 @@ export default function App() { } }); + const [songs, setSongs] = useState([]); + const [i, setI] = useState(0); + const [repeat, setRepeat] = useState(false); + + const queue = useMemo(() => new Queue(songs, setSongs, i, setI, repeat, setRepeat), [songs, i, repeat]); + + //@ts-ignore + globalThis.queue = queue; + return ( - + + + ); } \ No newline at end of file diff --git a/src/app/Router.tsx b/src/app/Router.tsx index e2489d8..eede8a2 100644 --- a/src/app/Router.tsx +++ b/src/app/Router.tsx @@ -5,38 +5,47 @@ import { Outlet, Route } from 'react-router-dom'; -import AppHeader from "./components/AppHeader"; -import { Box, useTheme } from "@mui/material"; import Landing from "./pages/Landing"; import Page from "./components/Page"; import Search from "./pages/Search"; +import ErrorScreen from "./pages/ErrorScreen"; +import Account from "./pages/Account"; +import Player from "./components/Player"; +import Register from "./pages/Register"; export default function createRouter() { - const theme = useTheme(); const router = createRoutesFromElements( - + + + + } + > + } > - - - - } + element={} /> - } + element={} + /> + } + /> + } /> diff --git a/src/app/components/AddPlaylistButton.tsx b/src/app/components/AddPlaylistButton.tsx new file mode 100644 index 0000000..f13ccdb --- /dev/null +++ b/src/app/components/AddPlaylistButton.tsx @@ -0,0 +1,36 @@ +import { Add } from "@mui/icons-material"; +import { Box, BoxProps, Theme, Typography, useTheme } from "@mui/material"; +import React from "react"; +import { MotionBox } from "./MotionComponents"; +import { Variants } from "framer-motion"; + +export default function AddPlaylistButton(props: BoxProps) { + const theme = useTheme(); + return ( + + + + + + New playlist + + + ); +} \ No newline at end of file diff --git a/src/app/components/AppHeader.tsx b/src/app/components/AppHeader.tsx index bf946ad..d2059b1 100644 --- a/src/app/components/AppHeader.tsx +++ b/src/app/components/AppHeader.tsx @@ -2,6 +2,7 @@ import React from "react"; import { AppBar, IconButton, InputBaseProps, Toolbar, Typography } from '@mui/material'; import { SearchContainer, SearchIcon, SearchIconWrapper, SearchInputBase } from "./Search"; import { useNavigate } from "react-router-dom"; +import { Close, Person } from "@mui/icons-material"; type AppHeaderProps = { showSearch?: boolean; @@ -10,21 +11,37 @@ type AppHeaderProps = { export default function AppHeader({ showSearch, inputProps }: AppHeaderProps) { const navTo = useNavigate(); return ( - + {showSearch ? ( - + <> + + navTo('/', {replace: true})} + size='large' + edge='end' + > + + + ) : ( <> Extera Music + navTo('/account')} + size='large' + edge='end' + > + + navTo('/search')} size='large' diff --git a/src/app/components/MotionComponents.tsx b/src/app/components/MotionComponents.tsx new file mode 100644 index 0000000..915bdf1 --- /dev/null +++ b/src/app/components/MotionComponents.tsx @@ -0,0 +1,9 @@ +import React, { forwardRef } from "react"; +import {motion} from 'framer-motion'; +import { Box, BoxProps } from "@mui/material"; + +const BoxComponent = forwardRef( + (props: BoxProps, ref) => +); + +export const MotionBox = motion.create(BoxComponent); \ No newline at end of file diff --git a/src/app/components/Page.tsx b/src/app/components/Page.tsx index adb1bbc..77286c7 100644 --- a/src/app/components/Page.tsx +++ b/src/app/components/Page.tsx @@ -1,24 +1,32 @@ -import { Box, useTheme } from "@mui/material"; +import { Box, BoxProps, useTheme } from "@mui/material"; import React from "react"; -export default function Page(props: React.PropsWithChildren) { +export default function Page(props: BoxProps) { const theme = useTheme(); return ( ); } -export function PageRoot(props: React.PropsWithChildren) { +export function PageRoot(props: BoxProps) { + const theme = useTheme(); + return ( ); diff --git a/src/app/components/Player.tsx b/src/app/components/Player.tsx new file mode 100644 index 0000000..d74a8d3 --- /dev/null +++ b/src/app/components/Player.tsx @@ -0,0 +1,180 @@ +import { BottomNavigation, BottomNavigationAction, Box, Button, IconButton, 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 durationToStr from "../utils/duration"; +import { MotionBox } from "./MotionComponents"; +import { Variants } from "framer-motion"; + +const variants = (theme: Theme): Variants => ( + { + initial: { + bottom: theme.spacing(-12) + }, + final: { + bottom: theme.spacing(0) + } + } +); + +export default function Player() { + const theme = useTheme(); + const queue = useQueue(); + const audioRef = useRef(null); + const [song, setSong] = useState(); + const [paused, setPaused] = useState(audioRef.current?.paused || false); + const [canPlayNext, setCanPlayNext] = useState(false); + const [canPlayPrev, setCanPlayPrev] = useState(false); + const [repeatOne, setRepeatOne] = useState(false); + const [currentTime, setCurrentTime] = useState(0); + const songURL = useMemo( + () => getSongURL(song), + [song, queue] + ); + + const togglePlaying = () => { + const audio = audioRef.current; + if (!audio) return; + if (audio.paused) audio.play(); + else audio.pause(); + + setPaused(audio.paused); + }; + + const handlePrev = () => { + queue?.prev(); + }; + + const handleNext = () => { + if (repeatOne) { + const audio = audioRef.current; + if (audio) { + audio.currentTime = 0; + audio.play(); + } + return; + } + queue?.next(); + }; + + const handleRepeat = () => { + if (!queue) return console.error('No queue bruh'); + console.log(`handleRepeat`, queue.repeat, repeatOne); + if (!queue.repeat && !repeatOne) { + queue.setRepeat(true); + } else if (queue.repeat && !repeatOne) { + queue.setRepeat(false); + setRepeatOne(true); + } else if (repeatOne) { + setRepeatOne(false); + } + }; + + const updateCurrentPlaying = () => { + const audio = audioRef.current; + if (!audio) return; + setCurrentTime(audio.currentTime); + }; + + useEffect(() => { + const intervalId = setInterval(() => { + updateCurrentPlaying(); + }, 500); + + return () => { + clearInterval(intervalId); + }; + }, [audioRef]); + + useEffect(() => { + setSong(queue?.getCurrent()); + console.log(`Song updated. ${songURL}`, song); + console.log(`Queue updated.`, queue); + }, [queue!.i]); + + useEffect(() => { + setSong(queue?.getCurrent()); + console.log(`Song updated. ${songURL}`, song); + }, [queue!.songs]); + + useEffect(() => { + setCanPlayNext(queue?.canPlayNext() || false); + setCanPlayPrev(queue?.canPlayPrev() || false); + }, [queue!.songs, queue!.i, queue!.repeat]); + + return song && ( + + + {song && songURL && ( + + + ); +} \ No newline at end of file diff --git a/src/app/components/Search.jsx b/src/app/components/Search.jsx index 00b3934..f8463db 100644 --- a/src/app/components/Search.jsx +++ b/src/app/components/Search.jsx @@ -29,12 +29,14 @@ const SearchIconWrapper = styled('div')(({ theme }) => ({ const StyledInputBase = styled(InputBase)(({ theme }) => ({ color: 'inherit', width: '100%', + flexGrow: 1, '& .MuiInputBase-input': { padding: theme.spacing(1, 1, 1, 0), // vertical padding + font size from searchIcon paddingLeft: `calc(1em + ${theme.spacing(4)})`, transition: theme.transitions.create('width'), width: '100%', + flexGrow: 1, [theme.breakpoints.up('md')]: { width: '20ch', }, diff --git a/src/app/components/SongCard.tsx b/src/app/components/SongCard.tsx index 259a777..7a05e6f 100644 --- a/src/app/components/SongCard.tsx +++ b/src/app/components/SongCard.tsx @@ -1,6 +1,7 @@ -import { Box, Skeleton, Typography, useTheme } from "@mui/material"; +import { Box, IconButton, Skeleton, Typography, useTheme } from "@mui/material"; import React, { MouseEventHandler } from "react"; import { LazyLoadImage } from 'react-lazy-load-image-component'; +import { AddToQueue, Download as DownloadIcon, PlayArrow, PlaylistAdd } from '@mui/icons-material'; type SongCardProps = { onClick?: MouseEventHandler; @@ -8,6 +9,8 @@ type SongCardProps = { artist: string; name: string; album: string; + saveSong?: () => void; + playSong?: () => void; }; export default function SongCard({ onClick, thumbnailUrl, artist, name, album }: SongCardProps) { @@ -30,7 +33,7 @@ export default function SongCard({ onClick, thumbnailUrl, artist, name, album }: ); } -export function SearchSongCard({ onClick, thumbnailUrl, artist, name, album }: SongCardProps) { +export function SearchSongCard({ onClick, thumbnailUrl, artist, name, album, saveSong, playSong }: SongCardProps) { const theme = useTheme(); return ( @@ -54,13 +57,31 @@ export function SearchSongCard({ onClick, thumbnailUrl, artist, name, album }: S {name} - - {artist} - + + + {artist} + + + {album} + + + + + {saveSong && ( + + + + )} + {playSong && ( + + + + )} ); diff --git a/src/app/hooks/useAccount.ts b/src/app/hooks/useAccount.ts new file mode 100644 index 0000000..f87a881 --- /dev/null +++ b/src/app/hooks/useAccount.ts @@ -0,0 +1,21 @@ +import React, { useMemo } from "react"; + +type AccountData = { + username?: string; + token?: string; +}; +export default function useAccount(): AccountData { + const username = useMemo( + () => localStorage.username, + [localStorage] + ); + const token = useMemo( + () => localStorage.token, + [localStorage] + ); + + return { + username, + token + }; +} \ No newline at end of file diff --git a/src/app/hooks/useAlive.ts b/src/app/hooks/useAlive.ts new file mode 100644 index 0000000..604191f --- /dev/null +++ b/src/app/hooks/useAlive.ts @@ -0,0 +1,15 @@ +import { useCallback, useEffect, useRef } from 'react'; + +export const useAlive = (): (() => boolean) => { + const aliveRef = useRef(true); + + useEffect(() => { + aliveRef.current = true; + return () => { + aliveRef.current = false; + }; + }, []); + + const alive = useCallback(() => aliveRef.current, []); + return alive; +}; \ No newline at end of file diff --git a/src/app/hooks/useAsyncCallback.ts b/src/app/hooks/useAsyncCallback.ts new file mode 100644 index 0000000..fa37dab --- /dev/null +++ b/src/app/hooks/useAsyncCallback.ts @@ -0,0 +1,99 @@ +import { useCallback, useRef, useState } from 'react'; +import { flushSync } from 'react-dom'; +import { useAlive } from './useAlive'; + +export enum AsyncStatus { + Idle = 'idle', + Loading = 'loading', + Success = 'success', + Error = 'error', +} + +export type AsyncIdle = { + status: AsyncStatus.Idle; +}; + +export type AsyncLoading = { + status: AsyncStatus.Loading; +}; + +export type AsyncSuccess = { + status: AsyncStatus.Success; + data: D; +}; + +export type AsyncError = { + status: AsyncStatus.Error; + error: E; +}; + +export type AsyncState = AsyncIdle | AsyncLoading | AsyncSuccess | AsyncError; + +export type AsyncCallback = (...args: TArgs) => Promise; + +export const useAsyncCallback = ( + asyncCallback: AsyncCallback +): [AsyncState, AsyncCallback] => { + const [state, setState] = useState>({ + status: AsyncStatus.Idle, + }); + const alive = useAlive(); + + // Tracks the request number. + // If two or more requests are made subsequently + // we will throw all old request's response after they resolved. + const reqNumberRef = useRef(0); + + const callback: AsyncCallback = useCallback( + async (...args) => { + queueMicrotask(() => { + // Warning: flushSync was called from inside a lifecycle method. + // React cannot flush when React is already rendering. + // Consider moving this call to a scheduler task or micro task. + flushSync(() => { + // flushSync because + // https://github.com/facebook/react/issues/26713#issuecomment-1872085134 + setState({ + status: AsyncStatus.Loading, + }); + }); + }); + + reqNumberRef.current += 1; + + const currentReqNumber = reqNumberRef.current; + try { + const data = await asyncCallback(...args); + if (currentReqNumber !== reqNumberRef.current) { + throw new Error('AsyncCallbackHook: Request replaced!'); + } + if (alive()) { + queueMicrotask(() => { + setState({ + status: AsyncStatus.Success, + data, + }); + }); + } + return data; + } catch (e) { + if (currentReqNumber !== reqNumberRef.current) { + throw new Error('AsyncCallbackHook: Request replaced!'); + } + + if (alive()) { + queueMicrotask(() => { + setState({ + status: AsyncStatus.Error, + error: e as TError, + }); + }); + } + throw e; + } + }, + [asyncCallback, alive] + ); + + return [state, callback]; +}; \ No newline at end of file diff --git a/src/app/hooks/useForceUpdate.ts b/src/app/hooks/useForceUpdate.ts new file mode 100644 index 0000000..dda71f4 --- /dev/null +++ b/src/app/hooks/useForceUpdate.ts @@ -0,0 +1,6 @@ +import { useReducer } from "react"; + +export default function useForceUpdate() { + const [reducer, dispatch] = useReducer(x => x + 1, 0); + return [reducer, dispatch]; +} \ No newline at end of file diff --git a/src/app/hooks/useQueue.ts b/src/app/hooks/useQueue.ts new file mode 100644 index 0000000..e9c6860 --- /dev/null +++ b/src/app/hooks/useQueue.ts @@ -0,0 +1,93 @@ +import React, { createContext, useContext, useEffect } from "react"; +import { Song } from "../utils/MusicServer"; +import useForceUpdate from "./useForceUpdate"; + +export class Queue { + + constructor( + songs: Song[], + setSongs: React.Dispatch>, + i: number, + setI: React.Dispatch>, + repeat: boolean, + setRepeat: React.Dispatch> + ) { + this.songs = songs; + this.setSongs = setSongs; + this.i = i; + this.setI = setI; + this.repeat = repeat; + this.setRepeat = setRepeat; + } + + songs: Song[]; + setSongs: React.Dispatch>; + i: number; + private setI: React.Dispatch>; + + repeat: boolean; + setRepeat: React.Dispatch>; + + shuffle() { + this.songs = this.songs.sort(() => Math.random() - 0.5); + } + + getCurrent(): Song | undefined { + const current = this.songs[this.i]; + return current; + } + + getSongs(): Song[] { + return this.songs; + } + + addSong(song: Song) { + this.setSongs((prevSongs) => { + console.log(`Song added`, song); + return [...prevSongs, song]; // создаем новый массив + }); + } + + removeSong(index: number) { + this.setSongs((prevSongs) => { + return prevSongs.filter((_, i) => i !== index); // создаем новый массив без удаленного элемента + }); + } + + prev(): Song | undefined { + if (this.i === 0) { + if (!this.repeat) return undefined; + this.setI(this.songs.length - 1); + return this.songs[this.i]; + } + this.setI((i) => i - 1); + return this.songs[this.i]; + } + + next(): Song | undefined { + if (this.i === this.songs.length - 1) { + if (!this.repeat) return undefined; + this.setI(0); + return this.songs[this.i]; + } + this.setI((i) => i + 1); + return this.songs[this.i]; + } + + canPlayNext() { + return this.i !== this.songs.length - 1 || this.repeat || false; + } + + canPlayPrev() { + return this.i !== 0 || this.repeat || false; + } + +} + +const QueueContext = createContext(null); +export const QueueContextProvider = QueueContext.Provider; + +export default function useQueue(): Queue | null { + const queue = useContext(QueueContext); + return queue; +} \ No newline at end of file diff --git a/src/app/pages/Account.tsx b/src/app/pages/Account.tsx new file mode 100644 index 0000000..8ccc5b1 --- /dev/null +++ b/src/app/pages/Account.tsx @@ -0,0 +1,102 @@ +import React, { FormEventHandler, useCallback, useMemo } from "react"; +import useAccount from "../hooks/useAccount"; +import { Alert, AppBar, Box, Button, Card, DialogActions, Divider, Link, TextField, Typography, useTheme } from "@mui/material"; +import Page, { PageRoot } from "../components/Page"; +import { useNavigate } from "react-router-dom"; +import { AsyncStatus, useAsyncCallback } from "../hooks/useAsyncCallback"; +import { login } from "../utils/MusicServer"; +import { LoadingButton } from "@mui/lab"; +import AppHeader from "../components/AppHeader"; + +function Login() { + const theme = useTheme(); + const navigate = useNavigate(); + + const [loginState, loginAccount] = useAsyncCallback( + useCallback( + (uname: string, passwd: string) => login(uname, passwd), + [] + ) + ); + + const handleSubmit: FormEventHandler = async (evt) => { + evt.preventDefault(); + const usernameInput = evt.currentTarget.elements.item(0) as HTMLInputElement; + const passwordInput = evt.currentTarget.elements.item(2) as HTMLInputElement; + const [username, password] = [usernameInput.value, passwordInput.value]; + + const token = await loginAccount(username, password); + localStorage.token = token; + localStorage.username = username; + + navigate('/', { replace: true }); + }; + + return ( + + + + + Use existing account + +
+ + + navigate('/register')} underline="hover"> + Don't have an account yet? + + {loginState.status === AsyncStatus.Error && ( + + An error has occured, logging in failed. + + )} + + + + Continue + + + +
+
+
+ ); +} + +export default function Account() { + const { token, username } = useAccount(); + + return ( + + {token && } + {token ? ( + + + You are logged in as {username} + + + ) : ( + + )} + + ); +} \ No newline at end of file diff --git a/src/app/pages/ErrorScreen.tsx b/src/app/pages/ErrorScreen.tsx new file mode 100644 index 0000000..cf8bdc7 --- /dev/null +++ b/src/app/pages/ErrorScreen.tsx @@ -0,0 +1,18 @@ +import { Button, Dialog, DialogActions, DialogContent, DialogContentText, DialogTitle } from "@mui/material"; +import React from "react"; + +export default function ErrorScreen() { + return ( + + Hey check this out + + + A critical error has occured. See F12 Console for more details. + + + + + + + ); +} \ No newline at end of file diff --git a/src/app/pages/Landing.tsx b/src/app/pages/Landing.tsx index 776ba0a..9e56c84 100644 --- a/src/app/pages/Landing.tsx +++ b/src/app/pages/Landing.tsx @@ -2,30 +2,22 @@ import { Box, Typography, useTheme } from "@mui/material"; import React from "react"; import LoadingCard from "../components/LoadingCard"; import Scroll from "../components/Scroll"; +import Page, { PageRoot } from "../components/Page"; +import AppHeader from "../components/AppHeader"; +import AddPlaylistButton from "../components/AddPlaylistButton"; export default function Landing() { const theme = useTheme(); return ( - - Trending - - - - - - - - - - - - - - + + + + Playlists + + + + + ); } \ No newline at end of file diff --git a/src/app/pages/Register.tsx b/src/app/pages/Register.tsx new file mode 100644 index 0000000..fb1090e --- /dev/null +++ b/src/app/pages/Register.tsx @@ -0,0 +1,79 @@ +import React, { FormEventHandler, useCallback } from "react"; +import { PageRoot } from "../components/Page"; +import { Alert, Box, Button, Card, DialogActions, Link, TextField, Typography, useTheme } from "@mui/material"; +import { useNavigate } from "react-router-dom"; +import { register } from "../utils/MusicServer"; +import { AsyncStatus, useAsyncCallback } from "../hooks/useAsyncCallback"; +import { LoadingButton } from '@mui/lab'; + +export default function Register() { + const theme = useTheme(); + const navigate = useNavigate(); + + const [registerState, registerAccount] = useAsyncCallback( + useCallback( + (uname: string, passwd: string) => register(uname, passwd), + [] + ) + ); + + const handleSubmit: FormEventHandler = async (evt) => { + evt.preventDefault(); + const usernameInput = evt.currentTarget.elements.item(0) as HTMLInputElement; + const passwordInput = evt.currentTarget.elements.item(2) as HTMLInputElement; + const [username, password] = [usernameInput.value, passwordInput.value]; + + const token = await registerAccount(username, password); + localStorage.token = token; + localStorage.username = username; + + navigate('/', { replace: true }); + }; + + return ( + + + + + Create a new account + +
+ + + navigate('/account')} underline="hover"> + Already have an account? + + {registerState.status === AsyncStatus.Error && ( + + An error has occured, registration failed. + + )} + + + + Continue + + + +
+
+
+ ); +} \ No newline at end of file diff --git a/src/app/pages/Search.tsx b/src/app/pages/Search.tsx index 44325ca..d9927ac 100644 --- a/src/app/pages/Search.tsx +++ b/src/app/pages/Search.tsx @@ -3,13 +3,17 @@ import { PageRoot } from "../components/Page"; import AppHeader from "../components/AppHeader"; import repeatArray from "../utils/repeatArray"; import { LoadingSearchSongCard } from "../components/LoadingCard"; -import { searchSongs } from "../utils/MusicServer"; -import { Box } from "@mui/material"; +import { saveSong, searchSongs } from "../utils/MusicServer"; +import { Box, IconButton, Snackbar } from "@mui/material"; import { SearchSongCard } from "../components/SongCard"; +import useQueue from "../hooks/useQueue"; +import { Close } from "@mui/icons-material"; export default function Search() { const inputRef = useRef(null); const [results, setResults] = useState([]); + const queue = useQueue(); + const [snackbar, setSnackbar] = useState(null); const onKeyUp = async (evt: KeyboardEvent) => { if (evt.key !== 'Enter') return; const q = inputRef.current?.value; @@ -26,7 +30,11 @@ export default function Search() { artist={song.artist.name} name={song.name} thumbnailUrl={song.thumbnails[0].url} - onClick={() => alert(song.name)} + saveSong={() => saveSong(song)} + playSong={() => { + queue?.addSong(song); + setSnackbar(`Added "${song.name}" to queue.`); + }} /> ); } @@ -52,6 +60,21 @@ export default function Search() { {results}
+ setSnackbar(null)} + message={snackbar} + action={ + setSnackbar(null)} + > + + + } + /> ); } \ No newline at end of file diff --git a/src/app/utils/MusicServer.ts b/src/app/utils/MusicServer.ts index da5625e..720a10b 100644 --- a/src/app/utils/MusicServer.ts +++ b/src/app/utils/MusicServer.ts @@ -1,20 +1,24 @@ -type Artist = { +import { saveAs } from 'file-saver'; + +const API_BASE = `http://localhost:33223`; + +export type Artist = { name: string; artistId: string; }; -type Thumbnail = { +export type Thumbnail = { url: string; width: number; height: number; }; -type Album = { +export type Album = { name: string; albumId: string; }; -type Song = { +export type Song = { type: 'SONG'; videoId: string; name: string; @@ -26,7 +30,7 @@ type Song = { async function doPost(p: string, json: any) { const jsonString = JSON.stringify(json); - const response = await fetch(`http://localhost:33223${p}`, { + const response = await fetch(API_BASE + p, { headers: { 'Content-Type': 'application/json' }, @@ -38,11 +42,28 @@ async function doPost(p: string, json: any) { } async function doGet(p: string) { - const response = await fetch(`http://localhost:33223${p}`); + const response = await fetch(API_BASE + p); if (!response.ok) throw await response.json(); return await response.json(); } export async function searchSongs(q: string): Promise { return await doGet(`/music/search?q=${encodeURIComponent(q)}`); +} + +export function getSongURL(song?: Song) { + if (!song) return null; + return `${API_BASE}/music/file?id=${song.videoId}`; +} + +export async function register(username?: string, password?: string): Promise { + return (await doPost(`/auth/register`, {username, password})).token as string; +} + +export async function login(username?: string, password?: string): Promise { + return (await doPost(`/auth/login`, {username, password})).token as string; +} + +export function saveSong(song: Song) { + saveAs(getSongURL(song)!, `${song.artist.name} - ${song.name}.mp3`); } \ No newline at end of file diff --git a/src/app/utils/duration.ts b/src/app/utils/duration.ts new file mode 100644 index 0000000..3763265 --- /dev/null +++ b/src/app/utils/duration.ts @@ -0,0 +1,5 @@ +export default function durationToStr(t: number) { + const min = Math.floor(t / 60); + const sec = Math.floor(t % 60); + return `${min}:${`0${sec}`.slice(-2)}`; +} \ No newline at end of file diff --git a/src/index.scss b/src/index.scss index 4e41b69..3205d63 100644 --- a/src/index.scss +++ b/src/index.scss @@ -1,3 +1,18 @@ body { margin: 0; + height: 100%; + overflow: auto; +} + +html { + height: 100%; + overflow: auto; +} + +body, #root { + overscroll-behavior-y: none; +} + +#root { + height: 100%; } \ No newline at end of file