├── .env
├── src
├── vite-env.d.ts
├── reducers
│ ├── audioReducer.ts
│ ├── searchReducer.ts
│ ├── recommendationsReducer.ts
│ ├── playingReducer.ts
│ ├── libraryReducer.ts
│ └── recentReducer.ts
├── components
│ ├── Pages
│ │ ├── Home
│ │ │ ├── Header.tsx
│ │ │ ├── Section.tsx
│ │ │ └── index.tsx
│ │ ├── Library
│ │ │ └── index.tsx
│ │ └── Search
│ │ │ └── index.tsx
│ ├── Music
│ │ ├── AudioSound.tsx
│ │ ├── SearchResult.tsx
│ │ ├── MusicCard.tsx
│ │ ├── SavedMusic.tsx
│ │ └── RecentMusic.tsx
│ ├── Navigation
│ │ ├── RecentMusicList.tsx
│ │ ├── Mobile.tsx
│ │ └── Sidebar.tsx
│ └── NowPlaying
│ │ ├── Mobile
│ │ ├── Footer.tsx
│ │ └── Page.tsx
│ │ └── Desktop
│ │ ├── Footer.tsx
│ │ └── Page.tsx
├── main.tsx
├── services
│ ├── search.ts
│ ├── library.ts
│ ├── recent.ts
│ └── recommendations.ts
├── store.ts
├── hooks
│ └── index.ts
├── types.ts
├── context
│ └── AudioContext.tsx
├── App.tsx
└── index.css
├── images
├── image.png
├── Desktop.png
└── phone-search.png
├── public
└── favicon.ico
├── postcss.config.js
├── api
├── search.ts
├── types.ts
├── routes
│ └── search.ts
└── services
│ └── search.ts
├── api.app.ts
├── vite.config.ts
├── api.devserver.ts
├── vercel.json
├── tsconfig.node.json
├── .gitignore
├── .eslintrc.cjs
├── tailwind.config.js
├── index.html
├── tsconfig.json
├── README.md
├── LICENSE
├── package.json
└── tsconfig.tsnode.json
/.env:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/vite-env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 |
--------------------------------------------------------------------------------
/images/image.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/code/app-podz/main/images/image.png
--------------------------------------------------------------------------------
/images/Desktop.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/code/app-podz/main/images/Desktop.png
--------------------------------------------------------------------------------
/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/code/app-podz/main/public/favicon.ico
--------------------------------------------------------------------------------
/images/phone-search.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/code/app-podz/main/images/phone-search.png
--------------------------------------------------------------------------------
/postcss.config.js:
--------------------------------------------------------------------------------
1 | export default {
2 | plugins: {
3 | tailwindcss: {},
4 | autoprefixer: {},
5 | },
6 | }
7 |
--------------------------------------------------------------------------------
/api/search.ts:
--------------------------------------------------------------------------------
1 | import app from "../api.app.js";
2 | import searchRouter from "./routes/search.js";
3 |
4 | app.use("/api/search", searchRouter);
5 |
6 | export default app;
7 |
--------------------------------------------------------------------------------
/api.app.ts:
--------------------------------------------------------------------------------
1 | import express from "express";
2 | import cors from "cors";
3 |
4 | const app = express();
5 | app.use(cors());
6 | app.use(express.json());
7 |
8 | export default app;
9 |
--------------------------------------------------------------------------------
/vite.config.ts:
--------------------------------------------------------------------------------
1 | import { defineConfig } from 'vite'
2 | import react from '@vitejs/plugin-react-swc'
3 |
4 | // https://vitejs.dev/config/
5 | export default defineConfig({
6 | plugins: [react()],
7 | })
8 |
--------------------------------------------------------------------------------
/api.devserver.ts:
--------------------------------------------------------------------------------
1 | import app from "./api.app.js";
2 | import searchRouter from "./api/routes/search.js";
3 |
4 | app.use("/api/search", searchRouter);
5 |
6 | app.listen(3001, () => {
7 | console.log("Server running at http://localhost:3001");
8 | });
9 |
--------------------------------------------------------------------------------
/vercel.json:
--------------------------------------------------------------------------------
1 | {
2 | "routes": [
3 | { "src": "/search", "dest": "/", "status": 200 },
4 | { "src": "/", "dest": "/", "status": 200 },
5 | { "src": "/library", "dest": "/", "status": 200 },
6 | { "src": "/api", "dest": "/api", "status": 200 }
7 | ]
8 | }
9 |
--------------------------------------------------------------------------------
/tsconfig.node.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "composite": true,
4 | "skipLibCheck": true,
5 | "module": "ESNext",
6 | "moduleResolution": "bundler",
7 | "allowSyntheticDefaultImports": true
8 | },
9 | "include": ["vite.config.ts"]
10 | }
11 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Logs
2 | logs
3 | *.log
4 | npm-debug.log*
5 | yarn-debug.log*
6 | yarn-error.log*
7 | pnpm-debug.log*
8 | lerna-debug.log*
9 |
10 | node_modules
11 | dist
12 | dist-ssr
13 | *.local
14 |
15 | # Editor directories and files
16 | .vscode/*
17 | !.vscode/extensions.json
18 | .idea
19 | .DS_Store
20 | *.suo
21 | *.ntvs*
22 | *.njsproj
23 | *.sln
24 | *.sw?
25 |
--------------------------------------------------------------------------------
/api/types.ts:
--------------------------------------------------------------------------------
1 | export interface SearchResult {
2 | id: string;
3 | title: string;
4 | author: string;
5 | image: string;
6 | duration: number;
7 | }
8 |
9 | export interface SearchResponse {
10 | id: string;
11 | title: {
12 | text: string;
13 | };
14 | author: {
15 | name: string;
16 | };
17 | thumbnails: {
18 | url: string;
19 | }[];
20 | duration: {
21 | seconds: number;
22 | };
23 | }
24 |
--------------------------------------------------------------------------------
/src/reducers/audioReducer.ts:
--------------------------------------------------------------------------------
1 | import { createSlice } from "@reduxjs/toolkit";
2 |
3 | const initialState = {
4 | isPlaying: true,
5 | };
6 |
7 | const audioSlice = createSlice({
8 | name: "audio",
9 | initialState,
10 | reducers: {
11 | setIsPlaying(state, action) {
12 | return { ...state, isPlaying: action.payload };
13 | },
14 | },
15 | });
16 |
17 | export const { setIsPlaying } = audioSlice.actions;
18 |
19 | export default audioSlice.reducer;
20 |
--------------------------------------------------------------------------------
/src/components /Pages/Home/Header.tsx:
--------------------------------------------------------------------------------
1 | import { RefObject } from "react";
2 |
3 | const Header = ({ innerRef }: { innerRef: RefObject }) => {
4 | return (
5 |
6 |
Hey, User!
7 |
8 | Check out this week's fresh releases.
9 |
10 |
11 | );
12 | };
13 | export default Header;
14 |
--------------------------------------------------------------------------------
/api/routes/search.ts:
--------------------------------------------------------------------------------
1 | import { Router } from "express";
2 | import searchService from "../services/search.js";
3 |
4 | const searchRouter = Router();
5 |
6 | searchRouter.get("/", async (req, res) => {
7 | if (!(typeof req.query.query === "string")) return;
8 | const results = await searchService.search(req.query.query);
9 | if ("error" in results) return res.json(results);
10 | if ("single" in req.query) return res.json(results[0]);
11 | res.json(results);
12 | });
13 |
14 | export default searchRouter;
15 |
--------------------------------------------------------------------------------
/.eslintrc.cjs:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | root: true,
3 | env: { browser: true, es2020: true },
4 | extends: [
5 | 'eslint:recommended',
6 | 'plugin:@typescript-eslint/recommended',
7 | 'plugin:react-hooks/recommended',
8 | ],
9 | ignorePatterns: ['dist', '.eslintrc.cjs'],
10 | parser: '@typescript-eslint/parser',
11 | plugins: ['react-refresh'],
12 | rules: {
13 | 'react-refresh/only-export-components': [
14 | 'warn',
15 | { allowConstantExport: true },
16 | ],
17 | },
18 | }
19 |
--------------------------------------------------------------------------------
/src/main.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import ReactDOM from "react-dom/client";
3 | import App from "./App.tsx";
4 | import { BrowserRouter as Router } from "react-router-dom";
5 | import "./index.css";
6 | import store from "./store.ts";
7 | import { Provider } from "react-redux";
8 |
9 | ReactDOM.createRoot(document.getElementById("root")!).render(
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 | );
18 |
--------------------------------------------------------------------------------
/src/components /Music/AudioSound.tsx:
--------------------------------------------------------------------------------
1 | import { useAppSelector } from "../../hooks";
2 | import { useAudioContext } from "../../context/AudioContext";
3 |
4 | const AudioSound = () => {
5 | const playing = useAppSelector((state) => state.playing);
6 | const { audioRef } = useAudioContext();
7 |
8 | return (
9 |
16 | );
17 | };
18 | export default AudioSound;
19 |
--------------------------------------------------------------------------------
/src/components /Pages/Home/Section.tsx:
--------------------------------------------------------------------------------
1 | import { SectionProps } from "../../../types";
2 |
3 | const Section = (props: SectionProps) => {
4 | if (!props) return null;
5 | return (
6 |
7 |
8 | {props.title}
9 |
10 |
13 | {props.children}
14 |
15 |
16 | );
17 | };
18 |
19 | export default Section;
20 |
--------------------------------------------------------------------------------
/tailwind.config.js:
--------------------------------------------------------------------------------
1 | /** @type {import('tailwindcss').Config} */
2 | export default {
3 | content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}"],
4 | theme: {
5 | extend: {
6 | fontFamily: {
7 | "custom-manrope": "Manrope",
8 | },
9 | colors: {
10 | "custom-vibrant-blue": "#1B6AE3",
11 | "custom-neutrals-offwhite": "#FFF9EF",
12 | "custom-card-artist": "#898989",
13 | },
14 | height: {
15 | screen: ["100vh /* fallback for Opera, IE and etc. */", "100dvh"],
16 | },
17 | },
18 | },
19 | plugins: [],
20 | };
21 |
--------------------------------------------------------------------------------
/src/components /Pages/Library/index.tsx:
--------------------------------------------------------------------------------
1 | import { useAppSelector } from "../../../hooks";
2 | import SavedMusic from "../../Music/SavedMusic";
3 |
4 | const Library = () => {
5 | const library = useAppSelector((state) => state.library);
6 |
7 | return (
8 |
9 |
14 |
15 | {library.map((liked) => (
16 |
17 | ))}
18 |
19 |
20 | );
21 | };
22 | export default Library;
23 |
--------------------------------------------------------------------------------
/src/reducers/searchReducer.ts:
--------------------------------------------------------------------------------
1 | import { Dispatch, createSlice } from "@reduxjs/toolkit";
2 | import searchService from "../services/search";
3 |
4 | const initialState: [] = [];
5 |
6 | const searchSlice = createSlice({
7 | name: "search",
8 | initialState,
9 | reducers: {
10 | setSearchResults(_state, action) {
11 | return action.payload;
12 | },
13 | },
14 | });
15 |
16 | export const { setSearchResults } = searchSlice.actions;
17 |
18 | export const search = (query: string) => {
19 | return async (dispatch: Dispatch) => {
20 | const searchResults = await searchService.search(query);
21 | dispatch(setSearchResults(searchResults));
22 | };
23 | };
24 |
25 | export default searchSlice.reducer;
26 |
--------------------------------------------------------------------------------
/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
13 | Podz
14 |
15 |
16 |
17 |
18 |
19 |
20 |
--------------------------------------------------------------------------------
/src/services/search.ts:
--------------------------------------------------------------------------------
1 | import axios, { CancelTokenSource } from "axios";
2 | import { SearchResult } from "../types";
3 |
4 | let cancelToken: CancelTokenSource;
5 |
6 | const search = async (query: string, filter?: { single: boolean }) => {
7 |
8 | if (typeof cancelToken != typeof undefined) {
9 | cancelToken.cancel("Operation canceled due to new request.");
10 | }
11 |
12 | cancelToken = axios.CancelToken.source();
13 |
14 |
15 | let url =
16 | import.meta.env.MODE === "development"
17 | ? `http://localhost:3001/api/search/?query=${query}`
18 | : `/api/search/?query=${query}`;
19 | if (filter?.single) url = `${url}&single`;
20 | const result = await axios.get(url, { cancelToken: cancelToken.token });
21 | return result.data;
22 | };
23 |
24 | export default { search };
25 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "ES2020",
4 | "useDefineForClassFields": true,
5 | "lib": ["ES2020", "DOM", "DOM.Iterable"],
6 | "module": "ESNext",
7 | "skipLibCheck": true,
8 |
9 | /* Bundler mode */
10 | "moduleResolution": "bundler",
11 | "allowImportingTsExtensions": true,
12 | "resolveJsonModule": true,
13 | "isolatedModules": true,
14 | "noEmit": true,
15 | "jsx": "react-jsx",
16 |
17 | /* Linting */
18 | "strict": true,
19 | "noUnusedLocals": true,
20 | "noUnusedParameters": true,
21 | "noFallthroughCasesInSwitch": true
22 | },
23 | "include": ["src", "api"],
24 | "references": [{ "path": "./tsconfig.node.json" }],
25 | "extends": [
26 | "@tsconfig/recommended/tsconfig.json",
27 | "@tsconfig/vite-react/tsconfig.json"
28 | ]
29 | }
30 |
--------------------------------------------------------------------------------
/src/services/library.ts:
--------------------------------------------------------------------------------
1 | import { SearchResult } from "../types";
2 |
3 | const getParsedLibrary = (): SearchResult[] | void => {
4 | const library = localStorage.getItem("library");
5 | if (library) return JSON.parse(library);
6 | new Error("Problem getting tasks");
7 | };
8 |
9 | const saveLibrary = (library: SearchResult[]) => {
10 | localStorage.setItem("library", JSON.stringify(library));
11 | };
12 |
13 | const deleteInLibrary = (id: string): void => {
14 | let parsedLibrary = getParsedLibrary();
15 | if (!parsedLibrary) return;
16 | parsedLibrary = parsedLibrary.filter((liked) => liked.id !== id);
17 | saveLibrary(parsedLibrary);
18 | };
19 |
20 | const addInLibrary = (playing: SearchResult): SearchResult[] | void => {
21 | let parsedLibrary = getParsedLibrary();
22 | if (!parsedLibrary) return;
23 | parsedLibrary = parsedLibrary.concat(playing);
24 | saveLibrary(parsedLibrary);
25 | return parsedLibrary;
26 | };
27 |
28 | export default { deleteInLibrary, addInLibrary, getParsedLibrary };
29 |
--------------------------------------------------------------------------------
/src/store.ts:
--------------------------------------------------------------------------------
1 | import { configureStore } from "@reduxjs/toolkit";
2 | import recommendationsReducer from "./reducers/recommendationsReducer";
3 | import searchReducer from "./reducers/searchReducer";
4 | import playingReducer from "./reducers/playingReducer";
5 | import audioReducer from "./reducers/audioReducer";
6 | import libraryReducer from "./reducers/libraryReducer";
7 | import recentReducer from "./reducers/recentReducer";
8 |
9 | const store = configureStore({
10 | reducer: {
11 | recommendations: recommendationsReducer,
12 | search: searchReducer,
13 | playing: playingReducer,
14 | audio: audioReducer,
15 | library: libraryReducer,
16 | recent: recentReducer,
17 | },
18 | });
19 |
20 | // Infer the `RootState` and `AppDispatch` types from the store itself
21 | export type RootState = ReturnType;
22 | // Inferred type: {posts: PostsState, comments: CommentsState, users: UsersState}
23 | export type AppDispatch = typeof store.dispatch;
24 |
25 | export default store;
26 |
--------------------------------------------------------------------------------
/src/services/recent.ts:
--------------------------------------------------------------------------------
1 | import { SearchResult } from "../types";
2 |
3 | const getParsedRecent = (): SearchResult[] | void => {
4 | const recent = localStorage.getItem("recent");
5 | if (recent) return JSON.parse(recent);
6 | new Error("Problem getting tasks");
7 | };
8 |
9 | const saveRecent = (recent: SearchResult[]) => {
10 | localStorage.setItem("recent", JSON.stringify(recent));
11 | };
12 |
13 | const addInRecent = (playing: SearchResult): SearchResult[] | void => {
14 | let parsedRecent = getParsedRecent();
15 | if (!parsedRecent) return;
16 | parsedRecent = parsedRecent.concat(playing);
17 | if (parsedRecent.length > 20) parsedRecent = parsedRecent.slice(-20);
18 | saveRecent(parsedRecent);
19 | return parsedRecent;
20 | };
21 |
22 | const isPresent = (playing: SearchResult): boolean | void => {
23 | const parsedRecent = getParsedRecent();
24 | if (!parsedRecent) return;
25 | return parsedRecent.some((recent) => recent.id === playing.id);
26 | };
27 |
28 | export default { addInRecent, getParsedRecent, isPresent };
29 |
--------------------------------------------------------------------------------
/src/hooks/index.ts:
--------------------------------------------------------------------------------
1 | import { TypedUseSelectorHook, useDispatch, useSelector } from "react-redux";
2 | import type { RootState, AppDispatch } from "../store";
3 | import { SearchResult } from "../types";
4 | import { likeSong, unlikeSong } from "../reducers/libraryReducer";
5 |
6 | export const useAppDispatch: () => AppDispatch = useDispatch;
7 | export const useAppSelector: TypedUseSelectorHook = useSelector;
8 | export const useFavorite = (playing: SearchResult) => {
9 | const dispatch = useAppDispatch();
10 |
11 | const like = () => {
12 | dispatch(likeSong(playing));
13 | };
14 |
15 | const unlike = () => {
16 | dispatch(unlikeSong(playing.id));
17 | };
18 |
19 | return [like, unlike];
20 | };
21 |
22 | export const useConvertToTime = () => {
23 | return (total: number) => {
24 | const minutes = Math.floor(total / 60);
25 | const seconds = Math.floor(total) % 60;
26 | const padTo2Digits = (num: number) => num.toString().padStart(2, "0");
27 | return `${padTo2Digits(minutes)}:${padTo2Digits(seconds)}`;
28 | };
29 | };
30 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
Podz
9 |
10 |
11 | Music Player with Youtube as a source
12 |
13 |
14 | View Demo
15 |
16 |
17 |
18 | Podz is a music player inspired by the work of **Ruby Montalvo** in [Dribbble](https://dribbble.com/shots/22211302-Daily-UI-009-Music-Player) . This project uses Youtube as a source of audio, and Spotify for featured playlists. It can be used by both mobile and desktop users.
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 | ## License
27 |
28 | Distributed under the MIT License. See `LICENSE` for more information.
29 |
30 |
31 | ## Acknowledgements
32 |
33 | * Almira Ruby Montalvo `Design`
34 |
35 |
36 |
--------------------------------------------------------------------------------
/api/services/search.ts:
--------------------------------------------------------------------------------
1 | import { Innertube } from "youtubei.js";
2 | import { SearchResponse, SearchResult } from "../types";
3 |
4 | const search = async (
5 | query: string
6 | ): Promise => {
7 | const youtube = await Innertube.create();
8 | const musicResults = await youtube.search(query, {
9 | type: "video",
10 | sort_by: "relevance",
11 | });
12 | if (!musicResults.results) return { error: "No results found" };
13 | const newResults = musicResults.results as unknown as SearchResponse[];
14 | return newResults
15 | .filter(
16 | (result) =>
17 | result.title?.text &&
18 | result.thumbnails?.[0].url &&
19 | result.author?.name &&
20 | result.title?.text !== "Shorts"
21 | )
22 | .map((result) => {
23 | return {
24 | id: result.id,
25 | title: result.title.text,
26 | author: result.author.name,
27 | image: result.thumbnails[0].url,
28 | duration: result.duration.seconds,
29 | };
30 | });
31 | };
32 |
33 | export default { search };
34 |
--------------------------------------------------------------------------------
/src/reducers/recommendationsReducer.ts:
--------------------------------------------------------------------------------
1 | import { Dispatch, createSlice } from "@reduxjs/toolkit";
2 | import recommendationsService from "../services/recommendations";
3 |
4 | const initialState: [] = [];
5 |
6 | const recommendationsSlice = createSlice({
7 | name: "recommendations",
8 | initialState,
9 | reducers: {
10 | setRecommendations(_state, action) {
11 | return action.payload;
12 | },
13 | },
14 | });
15 |
16 | export const { setRecommendations } = recommendationsSlice.actions;
17 |
18 | export const initializeRecommendations = () => {
19 | return async (dispatch: Dispatch) => {
20 | const { access_token } = await recommendationsService.loginSpotify();
21 | recommendationsService.setToken(access_token);
22 | const phRecommendations = await recommendationsService.getRecommendations(
23 | "PH"
24 | );
25 | const usRecommendations = await recommendationsService.getRecommendations(
26 | "US"
27 | );
28 | dispatch(setRecommendations([...phRecommendations, ...usRecommendations]));
29 | };
30 | };
31 |
32 | export default recommendationsSlice.reducer;
33 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2023 Alec Blance
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/src/components /Music/SearchResult.tsx:
--------------------------------------------------------------------------------
1 | import { useAppDispatch } from "../../hooks";
2 | import { setIsPlaying } from "../../reducers/audioReducer";
3 | import { setPlaying } from "../../reducers/playingReducer";
4 | import { insertRecent } from "../../reducers/recentReducer";
5 | import { SearchResult } from "../../types";
6 |
7 | const SearchResult = ({ track }: { track: SearchResult }) => {
8 | const title = track.title;
9 | const artist = track.author;
10 | const image = track.image;
11 | const dispatch = useAppDispatch();
12 |
13 | const play = () => {
14 | dispatch(setPlaying(track));
15 | dispatch(setIsPlaying(true));
16 | dispatch(insertRecent(track));
17 | };
18 |
19 | return (
20 |
24 |
30 |
31 |
{title}
32 |
{artist}
33 |
34 |
35 | );
36 | };
37 | export default SearchResult;
38 |
--------------------------------------------------------------------------------
/src/reducers/playingReducer.ts:
--------------------------------------------------------------------------------
1 | import { createSlice } from "@reduxjs/toolkit";
2 | import { RecommendationsTracks, SearchResult } from "../types";
3 | import searchService from "../services/search";
4 | import { insertRecentHome } from "./recentReducer";
5 | import { AppDispatch, RootState } from "../store";
6 |
7 | const initialState: SearchResult = {
8 | id: "",
9 | title: "",
10 | author: "",
11 | image: "",
12 | duration: 0,
13 | };
14 |
15 | const playingSlice = createSlice({
16 | name: "playing",
17 | initialState,
18 | reducers: {
19 | setPlaying(_state, action) {
20 | return action.payload;
21 | },
22 | setPlayingImage(state, action) {
23 | return { ...state, image: action.payload };
24 | },
25 | },
26 | });
27 |
28 | export const { setPlaying, setPlayingImage } = playingSlice.actions;
29 |
30 | export const playFromHome = (track: RecommendationsTracks) => {
31 | return async (dispatch: AppDispatch, getState: () => RootState) => {
32 | const { playing } = getState();
33 | if (playing.image === track.imageUrl) return;
34 | const result = await searchService.search(
35 | `${track.name} ${track.artistName.join(", ")}`,
36 | { single: true }
37 | );
38 | dispatch(setPlaying(result));
39 | dispatch(setPlayingImage(track.imageUrl));
40 | dispatch(insertRecentHome(result as SearchResult, track.imageUrl));
41 | };
42 | };
43 |
44 | export default playingSlice.reducer;
45 |
--------------------------------------------------------------------------------
/src/reducers/libraryReducer.ts:
--------------------------------------------------------------------------------
1 | import { Dispatch, createSlice } from "@reduxjs/toolkit";
2 | import { SearchResult } from "../types";
3 | import libraryService from "../services/library";
4 |
5 | const initialState: SearchResult[] = [];
6 |
7 | const librarySlice = createSlice({
8 | name: "library",
9 | initialState,
10 | reducers: {
11 | addFavorite(state, action) {
12 | return state.concat(action.payload);
13 | },
14 | removeFavorite(state, action) {
15 | return state.filter((liked) => liked.id !== action.payload);
16 | },
17 | setLibrary(_state, action) {
18 | return action.payload;
19 | },
20 | },
21 | });
22 |
23 | export const { addFavorite, removeFavorite, setLibrary } = librarySlice.actions;
24 |
25 | export const initializeLibrary = () => {
26 | return async (dispatch: Dispatch) => {
27 | const library = libraryService.getParsedLibrary();
28 | library
29 | ? dispatch(setLibrary(library))
30 | : localStorage.setItem("library", "[]");
31 | };
32 | };
33 |
34 | export const likeSong = (playing: SearchResult) => {
35 | return async (dispatch: Dispatch) => {
36 | libraryService.addInLibrary(playing);
37 | dispatch(addFavorite(playing));
38 | };
39 | };
40 |
41 | export const unlikeSong = (id: string) => {
42 | return async (dispatch: Dispatch) => {
43 | libraryService.deleteInLibrary(id);
44 | dispatch(removeFavorite(id));
45 | };
46 | };
47 |
48 | export default librarySlice.reducer;
49 |
--------------------------------------------------------------------------------
/src/components /Music/MusicCard.tsx:
--------------------------------------------------------------------------------
1 | import { useAppDispatch } from "../../hooks";
2 | import { setIsPlaying } from "../../reducers/audioReducer";
3 | import { playFromHome } from "../../reducers/playingReducer";
4 | import { RecommendationsTracks } from "../../types";
5 |
6 | const MusicCard = ({ track }: { track: RecommendationsTracks }) => {
7 | const title = track.name;
8 | const artist = track.artistName.join(", ");
9 | const image = track.imageUrl;
10 | const dispatch = useAppDispatch();
11 |
12 | const play = () => {
13 | dispatch(playFromHome(track));
14 | dispatch(setIsPlaying(true));
15 | };
16 |
17 | return (
18 |
22 |
23 | {image ? (
24 |
29 | ) : (
30 |
{image}
31 | )}
32 |
33 |
34 | {title}
35 |
36 |
37 | {artist}
38 |
39 |
40 |
41 |
42 | );
43 | };
44 |
45 | export default MusicCard;
46 |
--------------------------------------------------------------------------------
/src/types.ts:
--------------------------------------------------------------------------------
1 | import React from "react";
2 |
3 | export interface MusicCardProps {
4 | title: string;
5 | artist: string;
6 | image?: string;
7 | }
8 |
9 | export interface SectionProps {
10 | children: React.ReactNode;
11 | title: string;
12 | className?: string;
13 | }
14 |
15 | export interface SpotifyClientCredentials {
16 | access_token: string;
17 | token_type: string;
18 | expires_in: 3600;
19 | }
20 |
21 | export interface RecommendationsResponse {
22 | playlists: {
23 | items: [
24 | {
25 | id: string;
26 | name: string;
27 | }
28 | ];
29 | };
30 | }
31 |
32 | export interface Recommendations {
33 | name: string;
34 | tracks: RecommendationsTracks[];
35 | id: string;
36 | }
37 |
38 | export interface RecommendationsTracksResponse {
39 | items: [
40 | {
41 | track: {
42 | name: string;
43 | album: {
44 | images: [
45 | {
46 | url: string;
47 | }
48 | ];
49 | id: string;
50 | };
51 | artists: [
52 | {
53 | name: string;
54 | }
55 | ];
56 | };
57 | }
58 | ];
59 | }
60 |
61 | export interface RecommendationsTracks {
62 | name: string;
63 | imageUrl: string;
64 | artistName: string[];
65 | id: string;
66 | }
67 |
68 | export interface SearchResult {
69 | id: string;
70 | title: string;
71 | author: string;
72 | image?: string;
73 | duration: number;
74 | }
75 |
--------------------------------------------------------------------------------
/src/components /Navigation/RecentMusicList.tsx:
--------------------------------------------------------------------------------
1 | import { useRef, useState, useEffect, memo } from "react";
2 | import { useAppDispatch, useAppSelector } from "../../hooks";
3 | import RecentMusic from "../Music/RecentMusic";
4 | import { setRecentLimit } from "../../reducers/recentReducer";
5 |
6 | const RecentMusicList = memo(() => {
7 | const recent = useAppSelector((state) => state.recent.recentMusic);
8 | const sectionRef = useRef(null);
9 | const [limit, setLimit] = useState(20);
10 | const dispatch = useAppDispatch();
11 |
12 | const getLimit = (height: number) => {
13 | const shouldBeLimit = Math.floor(height / 60);
14 | return height % 60 ? shouldBeLimit - 1 : shouldBeLimit;
15 | };
16 |
17 | useEffect(() => {
18 | const section = sectionRef.current;
19 | if (!section) return;
20 | const limit = getLimit(section.offsetHeight);
21 | setLimit(limit);
22 | dispatch(setRecentLimit(limit));
23 | window.addEventListener("resize", () => {
24 | const onChangeLimit = getLimit(section.offsetHeight);
25 | setLimit(onChangeLimit);
26 | dispatch(setRecentLimit(onChangeLimit));
27 | });
28 | }, [sectionRef, dispatch]);
29 |
30 | return (
31 |
32 | {recent
33 | .slice()
34 | .reverse()
35 | .slice(0, limit)
36 | .map((played) => (
37 |
38 | ))}
39 |
40 | );
41 | });
42 | export default RecentMusicList;
43 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "muzica",
3 | "private": true,
4 | "version": "0.0.0",
5 | "type": "module",
6 | "scripts": {
7 | "dev": "vite",
8 | "build": "tsc && vite build",
9 | "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
10 | "preview": "vite preview",
11 | "tsc:ts-node-config": "npm exec -- tsc --showConfig > tsconfig.tsnode.json",
12 | "api": "NODE_ENV=development npm exec -- ts-node --project tsconfig.tsnode.json --esm --swc ./api.devserver.ts",
13 | "build:api": "npm run tsc:ts-node-config && npm run api"
14 | },
15 | "dependencies": {
16 | "@reduxjs/toolkit": "^1.9.5",
17 | "axios": "^1.5.0",
18 | "buffer": "^6.0.3",
19 | "cors": "^2.8.5",
20 | "express": "^4.18.2",
21 | "framer-motion": "^10.16.4",
22 | "react": "^18.2.0",
23 | "react-dom": "^18.2.0",
24 | "react-redux": "^8.1.2",
25 | "react-responsive": "^9.0.2",
26 | "react-router-dom": "^6.15.0",
27 | "youtubei.js": "^6.4.0"
28 | },
29 | "devDependencies": {
30 | "@tsconfig/recommended": "^1.0.2",
31 | "@tsconfig/vite-react": "^2.0.0",
32 | "@types/cors": "^2.8.14",
33 | "@types/express": "^4.17.17",
34 | "@types/react": "^18.2.15",
35 | "@types/react-dom": "^18.2.7",
36 | "@types/react-redux": "^7.1.26",
37 | "@typescript-eslint/eslint-plugin": "^6.0.0",
38 | "@typescript-eslint/parser": "^6.0.0",
39 | "@vitejs/plugin-react-swc": "^3.3.2",
40 | "autoprefixer": "^10.4.15",
41 | "eslint": "^8.45.0",
42 | "eslint-plugin-react-hooks": "^4.6.0",
43 | "eslint-plugin-react-refresh": "^0.4.3",
44 | "postcss": "^8.4.29",
45 | "tailwindcss": "^3.3.3",
46 | "typescript": "^5.0.2",
47 | "vite": "^4.4.5"
48 | }
49 | }
50 |
--------------------------------------------------------------------------------
/src/context/AudioContext.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | createContext,
3 | createRef,
4 | useCallback,
5 | useContext,
6 | useEffect,
7 | useState,
8 | } from "react";
9 | import { useAppDispatch } from "../hooks";
10 | import { setIsPlaying } from "../reducers/audioReducer";
11 |
12 | interface AudioType {
13 | audioRef: React.RefObject;
14 | pause: () => void;
15 | play: () => void;
16 | updateAudioTime: (time: number) => void;
17 | }
18 |
19 | const audioRef = createRef();
20 |
21 | const AudioContext = createContext({ audioRef } as AudioType);
22 |
23 | export const AudioContextProvider = (props: { children: React.ReactNode }) => {
24 | const [isPresent, setIsPresent] = useState(false);
25 | const dispatch = useAppDispatch();
26 |
27 | const pause = useCallback(() => {
28 | audioRef.current?.pause();
29 | dispatch(setIsPlaying(false));
30 | }, [dispatch]);
31 |
32 | const play = () => {
33 | audioRef.current?.play();
34 | dispatch(setIsPlaying(true));
35 | };
36 |
37 | const updateAudioTime = (time: number) => {
38 | if (!audioRef.current) return;
39 | audioRef.current.currentTime = time;
40 | };
41 |
42 | const reset = useCallback(() => {
43 | if (!audioRef.current) return;
44 | audioRef.current.load();
45 | pause();
46 | }, [pause]);
47 |
48 | useEffect(() => {
49 | if (!audioRef.current) return;
50 | setIsPresent(true);
51 | audioRef.current.onended = reset;
52 | }, [dispatch, reset]);
53 |
54 | if (!isPresent) return;
55 |
56 | return (
57 |
58 | {props.children}
59 |
60 | );
61 | };
62 |
63 | // eslint-disable-next-line react-refresh/only-export-components
64 | export const useAudioContext = () => useContext(AudioContext);
65 |
--------------------------------------------------------------------------------
/src/components /Pages/Search/index.tsx:
--------------------------------------------------------------------------------
1 | import { useEffect, useState } from "react";
2 | import { SearchResult as SearchResultType } from "../../../types";
3 | import SearchResult from "../../Music/SearchResult";
4 | import { useAppDispatch, useAppSelector } from "../../../hooks";
5 | import { search } from "../../../reducers/searchReducer";
6 |
7 | const Search = () => {
8 | const [searchQuery, setSearchQuery] = useState("");
9 | const dispatch = useAppDispatch();
10 | const searchResults: SearchResultType[] = useAppSelector(
11 | (state) => state.search
12 | );
13 |
14 | useEffect(() => {
15 | if (!searchQuery) return;
16 | dispatch(search(searchQuery));
17 | }, [searchQuery, dispatch]);
18 |
19 | return (
20 |
21 |
45 |
46 | {searchResults.map((result) => (
47 |
48 | ))}
49 |
50 |
51 | );
52 | };
53 | export default Search;
54 |
--------------------------------------------------------------------------------
/src/components /Music/SavedMusic.tsx:
--------------------------------------------------------------------------------
1 | import { useAppDispatch, useFavorite } from "../../hooks";
2 | import { setIsPlaying } from "../../reducers/audioReducer";
3 | import { setPlaying } from "../../reducers/playingReducer";
4 | import { insertRecent } from "../../reducers/recentReducer";
5 | import { SearchResult } from "../../types";
6 |
7 | const SavedMusic = ({ track }: { track: SearchResult }) => {
8 | const [, unlike] = useFavorite(track);
9 | const dispatch = useAppDispatch();
10 |
11 | const play = () => {
12 | dispatch(setPlaying(track));
13 | dispatch(setIsPlaying(true));
14 | dispatch(insertRecent(track));
15 | };
16 | return (
17 |
18 |
19 |
23 |
24 |
{track.title}
25 |
26 | {track.author}
27 |
28 |
29 |
30 |
31 |
38 |
39 |
40 |
41 | );
42 | };
43 | export default SavedMusic;
44 |
--------------------------------------------------------------------------------
/src/App.tsx:
--------------------------------------------------------------------------------
1 | import Home from "./components /Pages/Home";
2 | import MobileNavigation from "./components /Navigation/Mobile";
3 | import { Route, Routes } from "react-router-dom";
4 | import Search from "./components /Pages/Search";
5 | import Library from "./components /Pages/Library";
6 | import { useMediaQuery } from "react-responsive";
7 | import Sidebar from "./components /Navigation/Sidebar";
8 | import Footer from "./components /NowPlaying/Desktop/Footer";
9 | import { useEffect } from "react";
10 | import { initializeRecommendations } from "./reducers/recommendationsReducer";
11 | import { useAppDispatch } from "./hooks";
12 | import AudioSound from "./components /Music/AudioSound";
13 | import { AudioContextProvider } from "./context/AudioContext";
14 | import { initializeLibrary } from "./reducers/libraryReducer";
15 | import { initializeRecent } from "./reducers/recentReducer";
16 |
17 | const App = () => {
18 | const dispatch = useAppDispatch();
19 | const isLaptopScreen = useMediaQuery({ query: "(min-width: 1024px)" });
20 |
21 | useEffect(() => {
22 | dispatch(initializeRecommendations());
23 | dispatch(initializeLibrary());
24 | dispatch(initializeRecent());
25 | }, [dispatch]);
26 |
27 | return (
28 |
29 |
30 | {isLaptopScreen &&
}
31 |
36 |
37 | } />
38 | } />
39 | } />
40 |
41 |
42 | {!isLaptopScreen &&
}
43 |
44 | {isLaptopScreen && (
45 |
46 |
47 |
48 | )}
49 |
50 |
51 | );
52 | };
53 | export default App;
54 |
--------------------------------------------------------------------------------
/src/index.css:
--------------------------------------------------------------------------------
1 | @tailwind base;
2 | @tailwind components;
3 | @tailwind utilities;
4 |
5 | @layer base {
6 | html {
7 | -webkit-tap-highlight-color: transparent;
8 | }
9 | }
10 |
11 | .scrollbar-hide::-webkit-scrollbar {
12 | display: none;
13 | }
14 |
15 | /* For IE, Edge and Firefox */
16 | .scrollbar-hide {
17 | -ms-overflow-style: none; /* IE and Edge */
18 | scrollbar-width: none; /* Firefox */
19 | }
20 |
21 | .slider:not(:hover)::-webkit-slider-thumb {
22 | appearance: none;
23 | width: 0;
24 | }
25 |
26 | .slider:not(:hover) {
27 | overflow: hidden;
28 | }
29 |
30 | .background {
31 | background-color: #19212e;
32 | background-image: url("data:image/svg+xml,%3Csvg width='180' height='180' viewBox='0 0 180 180' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M81.28 88H68.413l19.298 19.298L81.28 88zm2.107 0h13.226L90 107.838 83.387 88zm15.334 0h12.866l-19.298 19.298L98.72 88zm-32.927-2.207L73.586 78h32.827l.5.5 7.294 7.293L115.414 87l-24.707 24.707-.707.707L64.586 87l1.207-1.207zm2.62.207L74 80.414 79.586 86H68.414zm16 0L90 80.414 95.586 86H84.414zm16 0L106 80.414 111.586 86h-11.172zm-8-6h11.173L98 85.586 92.414 80zM82 85.586L87.586 80H76.414L82 85.586zM17.414 0L.707 16.707 0 17.414V0h17.414zM4.28 0L0 12.838V0h4.28zm10.306 0L2.288 12.298 6.388 0h8.198zM180 17.414L162.586 0H180v17.414zM165.414 0l12.298 12.298L173.612 0h-8.198zM180 12.838L175.72 0H180v12.838zM0 163h16.413l.5.5 7.294 7.293L25.414 172l-8 8H0v-17zm0 10h6.613l-2.334 7H0v-7zm14.586 7l7-7H8.72l-2.333 7h8.2zM0 165.414L5.586 171H0v-5.586zM10.414 171L16 165.414 21.586 171H10.414zm-8-6h11.172L8 170.586 2.414 165zM180 163h-16.413l-7.794 7.793-1.207 1.207 8 8H180v-17zm-14.586 17l-7-7h12.865l2.333 7h-8.2zM180 173h-6.613l2.334 7H180v-7zm-21.586-2l5.586-5.586 5.586 5.586h-11.172zM180 165.414L174.414 171H180v-5.586zm-8 5.172l5.586-5.586h-11.172l5.586 5.586zM152.933 25.653l1.414 1.414-33.94 33.942-1.416-1.416 33.943-33.94zm1.414 127.28l-1.414 1.414-33.942-33.94 1.416-1.416 33.94 33.943zm-127.28 1.414l-1.414-1.414 33.94-33.942 1.416 1.416-33.943 33.94zm-1.414-127.28l1.414-1.414 33.942 33.94-1.416 1.416-33.94-33.943zM0 85c2.21 0 4 1.79 4 4s-1.79 4-4 4v-8zm180 0c-2.21 0-4 1.79-4 4s1.79 4 4 4v-8zM94 0c0 2.21-1.79 4-4 4s-4-1.79-4-4h8zm0 180c0-2.21-1.79-4-4-4s-4 1.79-4 4h8z' fill='%231b6ae3' fill-opacity='0.3' fillRule='evenodd'/%3E%3C/svg%3E");
33 | }
34 |
--------------------------------------------------------------------------------
/src/components /Pages/Home/index.tsx:
--------------------------------------------------------------------------------
1 | import Header from "./Header";
2 | import MusicCard from "../../Music/MusicCard";
3 | import Section from "./Section";
4 | import { useAppSelector } from "../../../hooks";
5 | import { Recommendations } from "../../../types";
6 | import { useEffect, useRef, useState } from "react";
7 | import { useMediaQuery } from "react-responsive";
8 |
9 | const Home = () => {
10 | const recommendations: Recommendations[] = useAppSelector(
11 | (state) => state.recommendations
12 | );
13 | const sectionRef = useRef(null);
14 | const isLaptopScreen = useMediaQuery({ query: "(min-width: 1024px)" });
15 | const [limit, setLimit] = useState(20);
16 |
17 | const getLimit = (width: number) => {
18 | const shouldBeLimit = Math.floor(width / 144);
19 | return width % 144 ? shouldBeLimit - 1 : shouldBeLimit;
20 | };
21 |
22 | useEffect(() => {
23 | const section = sectionRef.current;
24 | if (!section) return;
25 | setLimit(getLimit(section.offsetWidth));
26 | window.addEventListener("resize", () => {
27 | setLimit(getLimit(section.offsetWidth));
28 | });
29 | }, [sectionRef]);
30 |
31 | return isLaptopScreen ? (
32 |
33 |
34 | {recommendations.map((recommendation) => {
35 | return (
36 |
41 | {recommendation.tracks.slice(0, limit).map((track) => (
42 |
46 | ))}
47 |
48 | );
49 | })}
50 |
51 | ) : (
52 |
53 |
54 | {recommendations.map((recommendation) => (
55 |
56 | {recommendation.tracks.map((track) => (
57 |
61 | ))}
62 |
63 | ))}
64 |
65 | );
66 | };
67 | export default Home;
68 |
--------------------------------------------------------------------------------
/src/services/recommendations.ts:
--------------------------------------------------------------------------------
1 | import {
2 | Recommendations,
3 | RecommendationsResponse,
4 | RecommendationsTracks,
5 | RecommendationsTracksResponse,
6 | SpotifyClientCredentials,
7 | } from "../types";
8 | import { Buffer } from "buffer";
9 | import axios from "axios";
10 |
11 | let token: string | null = null;
12 |
13 | const setToken = (userToken: string) => {
14 | token = `Bearer ${userToken}`;
15 | };
16 |
17 | const loginSpotify = async (): Promise => {
18 | const client_id = import.meta.env.VITE_CLIENT_ID;
19 | const client_secret = import.meta.env.VITE_CLIENT_SECRET;
20 | const data = { grant_type: "client_credentials" };
21 | const options = {
22 | method: "POST",
23 | headers: {
24 | Authorization: `Basic ${Buffer.from(
25 | `${client_id}:${client_secret}`
26 | ).toString("base64")}`,
27 | "content-type": "application/x-www-form-urlencoded",
28 | },
29 | data: data,
30 | url: "https://accounts.spotify.com/api/token",
31 | };
32 | const result = await axios(options);
33 |
34 | return result.data;
35 | };
36 |
37 | const getTracks = async (id: string): Promise => {
38 | const config = {
39 | headers: { Authorization: token },
40 | };
41 | const result = await axios.get(
42 | `https://api.spotify.com/v1/playlists/${id}/tracks?market=PH&fields=items%28track%28name%2Calbum%28images%2Cid%28url%29%29%2Cartists%28name%29%29%29&limit=10`,
43 | config
44 | );
45 |
46 | return result.data.items
47 | .filter((track) => track.track !== null)
48 | .map((track) => ({
49 | name: track.track.name,
50 | imageUrl: track.track.album.images[0].url,
51 | artistName: track.track.artists.map((artist) => artist.name),
52 | id: track.track.album.id,
53 | }));
54 | };
55 |
56 | const getRecommendations = async (
57 | country: string
58 | ): Promise => {
59 | const config = {
60 | headers: { Authorization: token },
61 | };
62 | const result = await axios.get(
63 | `https://api.spotify.com/v1/browse/featured-playlists?country=${country}&limit=5`,
64 | config
65 | );
66 | return await Promise.all(
67 | result.data.playlists.items.map(async (playlist) => ({
68 | name: playlist.name,
69 | tracks: await getTracks(playlist.id),
70 | id: playlist.id,
71 | }))
72 | );
73 | };
74 |
75 | export default { loginSpotify, setToken, getRecommendations };
76 |
--------------------------------------------------------------------------------
/tsconfig.tsnode.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "es2020",
4 | "module": "esnext",
5 | "strict": true,
6 | "esModuleInterop": false,
7 | "skipLibCheck": true,
8 | "forceConsistentCasingInFileNames": true,
9 | "useDefineForClassFields": true,
10 | "lib": [
11 | "es2020",
12 | "dom",
13 | "dom.iterable"
14 | ],
15 | "allowJs": false,
16 | "allowSyntheticDefaultImports": true,
17 | "moduleResolution": "bundler",
18 | "resolveJsonModule": true,
19 | "isolatedModules": true,
20 | "noEmit": true,
21 | "jsx": "react-jsx",
22 | "allowImportingTsExtensions": true,
23 | "noUnusedLocals": true,
24 | "noUnusedParameters": true,
25 | "noFallthroughCasesInSwitch": true
26 | },
27 | "references": [
28 | {
29 | "path": "./tsconfig.node.json"
30 | }
31 | ],
32 | "files": [
33 | "./src/App.tsx",
34 | "./src/main.tsx",
35 | "./src/store.ts",
36 | "./src/types.ts",
37 | "./src/vite-env.d.ts",
38 | "./src/components /Music/AudioSound.tsx",
39 | "./src/components /Music/MusicCard.tsx",
40 | "./src/components /Music/RecentMusic.tsx",
41 | "./src/components /Music/SavedMusic.tsx",
42 | "./src/components /Music/SearchResult.tsx",
43 | "./src/components /Navigation/Mobile.tsx",
44 | "./src/components /Navigation/Sidebar.tsx",
45 | "./src/components /NowPlaying/Desktop/Footer.tsx",
46 | "./src/components /NowPlaying/Desktop/Page.tsx",
47 | "./src/components /NowPlaying/Mobile/Footer.tsx",
48 | "./src/components /NowPlaying/Mobile/Page.tsx",
49 | "./src/components /Pages/Home/Header.tsx",
50 | "./src/components /Pages/Home/Section.tsx",
51 | "./src/components /Pages/Home/index.tsx",
52 | "./src/components /Pages/Library/index.tsx",
53 | "./src/components /Pages/Search/index.tsx",
54 | "./src/context/AudioContext.tsx",
55 | "./src/hooks/index.ts",
56 | "./src/reducers/audioReducer.ts",
57 | "./src/reducers/playingReducer.ts",
58 | "./src/reducers/recommendationsReducer.ts",
59 | "./src/reducers/searchReducer.ts",
60 | "./src/services/recommendations.ts",
61 | "./src/services/search.ts",
62 | "./api/search.ts",
63 | "./api/types.ts",
64 | "./api/routes/search.ts",
65 | "./api/services/search.ts"
66 | ],
67 | "include": [
68 | "src",
69 | "api"
70 | ]
71 | }
72 |
--------------------------------------------------------------------------------
/src/components /Music/RecentMusic.tsx:
--------------------------------------------------------------------------------
1 | import { useAppDispatch, useAppSelector } from "../../hooks";
2 | import { setIsPlaying } from "../../reducers/audioReducer";
3 | import { setPlaying } from "../../reducers/playingReducer";
4 | import { SearchResult } from "../../types";
5 |
6 | const RecentMusic = ({ played }: { played: SearchResult }) => {
7 | const playing = useAppSelector((state) => state.playing.id);
8 | const dispatch = useAppDispatch();
9 |
10 | const play = () => {
11 | dispatch(setPlaying(played));
12 | dispatch(setIsPlaying(true));
13 | };
14 |
15 | return (
16 |
20 |
24 |
25 |
{played.title}
26 |
27 | {played.author}
28 |
29 |
30 |
31 | {playing === played.id && (
32 |
40 |
44 |
51 |
52 | )}
53 |
54 |
55 | );
56 | };
57 | export default RecentMusic;
58 |
--------------------------------------------------------------------------------
/src/reducers/recentReducer.ts:
--------------------------------------------------------------------------------
1 | import { Dispatch, createSlice } from "@reduxjs/toolkit";
2 | import { SearchResult } from "../types";
3 | import recentService from "../services/recent";
4 |
5 | const initialState: { recentMusic: SearchResult[]; recentNumber: number } = {
6 | recentMusic: [],
7 | recentNumber: 0,
8 | };
9 |
10 | const recentSlice = createSlice({
11 | name: "recent",
12 | initialState,
13 | reducers: {
14 | setRecent(state, action) {
15 | if (!state.recentNumber) return;
16 | if (state.recentNumber < action.payload.length) {
17 | return {
18 | ...state,
19 | recentMusic: action.payload.slice(-state.recentNumber),
20 | };
21 | }
22 | return { ...state, recentMusic: action.payload };
23 | },
24 | addRecent(state, action) {
25 | let recentMusic = state.recentMusic;
26 | if (state.recentNumber < state.recentMusic.length) {
27 | recentMusic = recentMusic.slice(-state.recentNumber);
28 | }
29 | if (recentMusic.some((recent) => recent.id === action.payload.id))
30 | return { ...state, recentMusic: state.recentMusic };
31 | const recentMusicCopy = recentMusic.concat();
32 | if (
33 | state.recentNumber &&
34 | state.recentNumber === state.recentMusic.length
35 | ) {
36 | recentMusicCopy.shift();
37 | }
38 | return {
39 | ...state,
40 | recentMusic: recentMusicCopy.concat(action.payload),
41 | };
42 | },
43 | setRecentImage(state, action) {
44 | return {
45 | ...state,
46 | recentMusic: state.recentMusic.map((recent) =>
47 | recent.id === action.payload.id
48 | ? { ...recent, image: action.payload.image }
49 | : recent
50 | ),
51 | };
52 | },
53 | setRecentLimit(state, action) {
54 | return { ...state, recentNumber: action.payload };
55 | },
56 | },
57 | });
58 |
59 | export const { setRecent, addRecent, setRecentImage, setRecentLimit } =
60 | recentSlice.actions;
61 |
62 | export const insertRecent = (playing: SearchResult) => {
63 | return async (dispatch: Dispatch) => {
64 | dispatch(addRecent(playing));
65 | if (recentService.isPresent(playing)) return;
66 | recentService.addInRecent(playing);
67 | };
68 | };
69 |
70 | export const insertRecentHome = (result: SearchResult, image: string) => {
71 | return async (dispatch: Dispatch) => {
72 | dispatch(addRecent(result));
73 | dispatch(setRecentImage({ image, id: result.id }));
74 | if (recentService.isPresent(result)) return;
75 | recentService.addInRecent({ ...result, image });
76 | };
77 | };
78 |
79 | export const initializeRecent = () => {
80 | return async (dispatch: Dispatch) => {
81 | const recent = recentService.getParsedRecent();
82 | recent ? dispatch(setRecent(recent)) : localStorage.setItem("recent", "[]");
83 | };
84 | };
85 |
86 | export default recentSlice.reducer;
87 |
--------------------------------------------------------------------------------
/src/components /Navigation/Mobile.tsx:
--------------------------------------------------------------------------------
1 | import { memo } from "react";
2 | import PlayingNow from "../NowPlaying/Mobile/Footer";
3 | import { Link, useMatch } from "react-router-dom";
4 | import { AudioContextProvider } from "../../context/AudioContext";
5 |
6 | const MobileNavigation = memo(() => {
7 | const isHome = useMatch("/");
8 | const isSearch = useMatch("/search");
9 | const isLibrary = useMatch("/library");
10 |
11 | return (
12 |
13 |
14 |
15 |
16 |
17 |
18 |
28 |
33 |
34 |
35 |
36 |
46 |
51 |
52 |
53 |
54 |
64 |
69 |
70 |
71 |
72 |
73 | );
74 | });
75 | export default MobileNavigation;
76 |
--------------------------------------------------------------------------------
/src/components /NowPlaying/Mobile/Footer.tsx:
--------------------------------------------------------------------------------
1 | import { memo, useState } from "react";
2 | import Page from "./Page";
3 | import { useAppSelector, useFavorite } from "../../../hooks";
4 | import { useAudioContext } from "../../../context/AudioContext";
5 |
6 | const Footer = memo(() => {
7 | const [isPageVisible, setIsPageVisible] = useState(false);
8 | const playing = useAppSelector((state) => state.playing);
9 | const { pause, play } = useAudioContext();
10 | const isPlaying = useAppSelector((state) => state.audio.isPlaying);
11 | const isLiked = useAppSelector((state) =>
12 | state.library.some((liked) => liked.id === playing.id)
13 | );
14 | const [like, unlike] = useFavorite(playing);
15 |
16 | onpopstate = () => {
17 | if (!isPageVisible) return;
18 | setIsPageVisible(false);
19 | history.go(1);
20 | };
21 |
22 | if (!playing.id) return null;
23 |
24 | return (
25 | <>
26 |
27 |
setIsPageVisible(true)}
30 | >
31 |
39 |
40 |
{playing.title}
41 |
42 | {playing.author}
43 |
44 |
45 |
46 |
47 |
48 | {isLiked ? (
49 |
56 |
57 |
58 | ) : (
59 |
68 |
73 |
74 | )}
75 | {isPlaying ? (
76 |
83 |
90 |
95 |
96 | ) : (
97 |
104 |
111 |
116 |
117 | )}
118 |
119 |
120 |
125 | >
126 | );
127 | });
128 | export default Footer;
129 |
--------------------------------------------------------------------------------
/src/components /NowPlaying/Mobile/Page.tsx:
--------------------------------------------------------------------------------
1 | import { motion } from "framer-motion";
2 | import { SearchResult } from "../../../types";
3 | import { useAudioContext } from "../../../context/AudioContext";
4 | import { ChangeEvent, memo, useEffect, useRef } from "react";
5 | import { useAppSelector, useConvertToTime, useFavorite } from "../../../hooks";
6 |
7 | const Page = memo(
8 | ({
9 | isPageVisible,
10 | setIsPageVisible,
11 | playing,
12 | }: {
13 | isPageVisible: boolean;
14 | setIsPageVisible: React.Dispatch>;
15 | playing: SearchResult;
16 | }) => {
17 | const inputRef = useRef(null);
18 | const { audioRef, play, pause, updateAudioTime } = useAudioContext();
19 | const isPlaying = useAppSelector((state) => state.audio.isPlaying);
20 | const isLiked = useAppSelector((state) =>
21 | state.library.some((liked) => liked.id === playing.id)
22 | );
23 | const time = useRef(null);
24 | const [like, unlike] = useFavorite(playing);
25 | const hide = { top: "100dvh" };
26 | const show = { top: "0" };
27 | const convertToTime = useConvertToTime();
28 |
29 | useEffect(() => {
30 | const currentAudio = audioRef.current;
31 | const currentInput = inputRef.current;
32 | const currentTime = time.current;
33 | if (!(currentAudio && currentInput && currentTime)) return;
34 | currentAudio.ontimeupdate = () => {
35 | currentInput.value = currentAudio.currentTime.toString();
36 | const time = convertToTime(currentAudio.currentTime);
37 | currentTime.innerText = time;
38 | };
39 | }, [audioRef, convertToTime]);
40 |
41 | return (
42 |
48 |
49 |
setIsPageVisible(false)}
55 | >
56 |
61 |
62 |
63 |
73 |
74 |
75 |
76 |
77 | {playing.title}
78 |
79 |
80 | {playing.author}
81 |
82 |
83 |
84 | {isLiked ? (
85 |
92 |
93 |
94 | ) : (
95 |
104 |
109 |
110 | )}
111 |
112 |
113 |
114 |
) =>
121 | updateAudioTime(parseInt(e.target.value))
122 | }
123 | />
124 |
125 |
126 |
127 | {convertToTime(playing.duration)}
128 |
129 |
130 |
131 |
132 | {isPlaying ? (
133 |
140 |
147 |
152 |
153 | ) : (
154 |
161 |
168 |
173 |
174 | )}
175 |
176 |
177 |
178 | );
179 | }
180 | );
181 | export default Page;
182 |
--------------------------------------------------------------------------------
/src/components /NowPlaying/Desktop/Footer.tsx:
--------------------------------------------------------------------------------
1 | import { ChangeEvent, RefObject, useEffect, useRef, useState } from "react";
2 | import Page from "./Page";
3 | import { useAppSelector, useConvertToTime, useFavorite } from "../../../hooks";
4 | import { useAudioContext } from "../../../context/AudioContext";
5 |
6 | const Footer = () => {
7 | const [isPageVisible, setIsPageVisible] = useState(false);
8 | const playing = useAppSelector((state) => state.playing);
9 | const inputRef = useRef(null);
10 | const { audioRef, updateAudioTime, play, pause } = useAudioContext();
11 | const pageRef = useRef<{
12 | input: RefObject;
13 | time: RefObject;
14 | }>(null);
15 | const isPlaying = useAppSelector((state) => state.audio.isPlaying);
16 | const isLiked = useAppSelector((state) =>
17 | state.library.some((liked) => liked.id === playing.id)
18 | );
19 | const [like, unlike] = useFavorite(playing);
20 | const convertToTime = useConvertToTime();
21 |
22 | onpopstate = () => {
23 | if (!isPageVisible) return;
24 | setIsPageVisible(false);
25 | history.go(1);
26 | };
27 |
28 | useEffect(() => {
29 | const currentAudio = audioRef.current;
30 | const currentInput = inputRef.current;
31 | const pageCurrentInput = pageRef.current?.input.current;
32 | const pageCurrentTime = pageRef.current?.time.current;
33 | if (!(currentAudio && playing.id)) return;
34 | currentAudio.ontimeupdate = () => {
35 | if (!(currentInput && pageCurrentInput && pageCurrentTime)) return;
36 | const currentTime = currentAudio.currentTime.toString();
37 | currentInput.value = currentTime;
38 | pageCurrentInput.value = currentTime;
39 | pageCurrentTime.innerText = convertToTime(parseInt(currentTime));
40 | };
41 | }, [audioRef, convertToTime, playing.id]);
42 |
43 | return (
44 |
45 | {playing.id && (
46 |
) =>
53 | updateAudioTime(parseInt(e.target.value))
54 | }
55 | ref={inputRef}
56 | />
57 | )}
58 |
59 |
60 |
61 | {playing.id && (
62 | <>
63 |
setIsPageVisible(true)}
66 | >
67 |
75 |
76 |
77 | {playing.title}
78 |
79 |
80 | {playing.author}
81 |
82 |
83 |
84 |
85 | {isLiked ? (
86 |
93 |
94 |
95 | ) : (
96 |
105 |
110 |
111 | )}
112 |
113 | >
114 | )}
115 |
116 |
117 | {isPlaying && playing.id ? (
118 |
125 |
132 |
137 |
138 | ) : (
139 |
146 |
153 |
158 |
159 | )}
160 |
161 |
162 | {/*
170 |
175 |
176 |
*/}
177 |
178 |
179 |
186 |
187 | );
188 | };
189 | export default Footer;
190 |
--------------------------------------------------------------------------------
/src/components /NowPlaying/Desktop/Page.tsx:
--------------------------------------------------------------------------------
1 | import { motion } from "framer-motion";
2 | import { SearchResult } from "../../../types";
3 | import {
4 | ChangeEvent,
5 | RefObject,
6 | forwardRef,
7 | useImperativeHandle,
8 | useRef,
9 | } from "react";
10 | import { useAudioContext } from "../../../context/AudioContext";
11 | import { useAppSelector, useConvertToTime, useFavorite } from "../../../hooks";
12 |
13 | const Page = forwardRef<
14 | { input: RefObject; time: RefObject },
15 | {
16 | isPageVisible: boolean;
17 | setIsPageVisible: React.Dispatch>;
18 | playing: SearchResult;
19 | isPlaying: boolean;
20 | }
21 | >(({ isPageVisible, setIsPageVisible, playing, isPlaying }, ref) => {
22 | const { updateAudioTime, play, pause } = useAudioContext();
23 | const isLiked = useAppSelector((state) =>
24 | state.library.some((liked) => liked.id === playing.id)
25 | );
26 | const [like, unlike] = useFavorite(playing);
27 | const convertToTime = useConvertToTime();
28 | const inputRef = useRef(null);
29 | const time = useRef(null);
30 |
31 | const hide = {
32 | top: "100dvh",
33 | };
34 |
35 | const show = {
36 | top: "0",
37 | };
38 |
39 | useImperativeHandle(ref, () => ({
40 | input: inputRef,
41 | time: time,
42 | }));
43 |
44 | return (
45 |
51 |
52 |
setIsPageVisible(false)}
58 | >
59 |
64 |
65 |
66 |
67 |
68 |
76 |
77 |
{playing.title}
78 |
79 | {playing.author}
80 |
81 |
82 |
83 |
84 |
88 |
) =>
94 | updateAudioTime(parseInt(e.target.value))
95 | }
96 | ref={inputRef}
97 | />
98 |
99 | {convertToTime(playing.duration)}
100 |
101 |
102 |
103 |
104 | {isLiked ? (
105 |
112 |
113 |
114 | ) : (
115 |
124 |
129 |
130 | )}
131 |
132 |
133 | {isPlaying && playing.id ? (
134 |
141 |
148 |
153 |
154 | ) : (
155 |
162 |
169 |
174 |
175 | )}
176 |
177 |
178 | {/*
186 |
191 |
192 |
*/}
193 |
194 |
195 |
196 |
197 | );
198 | });
199 | export default Page;
200 |
--------------------------------------------------------------------------------
/src/components /Navigation/Sidebar.tsx:
--------------------------------------------------------------------------------
1 | import { Link, useMatch } from "react-router-dom";
2 | import RecentMusicList from "./RecentMusicList";
3 |
4 | const Sidebar = () => {
5 | const isHome = useMatch("/");
6 | const isLibrary = useMatch("/library");
7 | const isSearch = useMatch("/search");
8 |
9 | return (
10 |
11 |
12 |
20 |
26 |
32 |
36 |
37 |
38 | Podz
39 |
40 |
41 |
42 |
48 |
58 |
63 |
64 | Home
65 |
66 |
72 |
82 |
87 |
88 | Library
89 |
90 |
96 |
106 |
111 |
112 | Search
113 |
114 |
115 |
116 |
117 |
125 |
129 |
130 |
Your Recent Podz
131 |
132 |
133 |
134 |
135 | );
136 | };
137 | export default Sidebar;
138 |
--------------------------------------------------------------------------------