├── index.d.ts ├── .eslintrc.json ├── bun.lockb ├── public ├── og.png ├── og-alt.png ├── favicon-16x16.png ├── favicon-32x32.png ├── twitter-image.png ├── mstile-150x150.png ├── apple-touch-icon.png ├── android-chrome-192x192.png ├── android-chrome-512x512.png ├── browserconfig.xml └── manifest.json ├── src ├── app │ ├── favicon.ico │ ├── api │ │ ├── feed │ │ │ └── route.ts │ │ ├── search │ │ │ └── route.ts │ │ └── bookDetails │ │ │ └── route.ts │ ├── page.tsx │ ├── support │ │ └── page.tsx │ ├── [bookId] │ │ └── page.tsx │ ├── globals.css │ └── layout.tsx ├── resources │ └── upi.png ├── Components │ ├── DonateButton.tsx │ ├── Loader.tsx │ ├── Navbar.tsx │ ├── Dashboard │ │ ├── Dashboard.tsx │ │ ├── Feed.tsx │ │ └── SearchBooks.tsx │ ├── Toast.tsx │ ├── GoogleAnalytics.tsx │ ├── Book │ │ ├── CurrentPlayingBook.tsx │ │ └── Book.tsx │ ├── Player.tsx │ └── AudioController.tsx ├── utils │ └── utilFunctions.ts └── zustand │ └── state.tsx ├── .env ├── postcss.config.js ├── .github └── FUNDING.yml ├── next.config.mjs ├── .gitignore ├── tsconfig.json ├── LICENSE.md ├── package.json ├── tailwind.config.js └── README.md /index.d.ts: -------------------------------------------------------------------------------- 1 | declare module "react-icons/*" -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "next/core-web-vitals" 3 | } 4 | -------------------------------------------------------------------------------- /bun.lockb: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/anonthedev/booksuno/HEAD/bun.lockb -------------------------------------------------------------------------------- /public/og.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/anonthedev/booksuno/HEAD/public/og.png -------------------------------------------------------------------------------- /public/og-alt.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/anonthedev/booksuno/HEAD/public/og-alt.png -------------------------------------------------------------------------------- /src/app/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/anonthedev/booksuno/HEAD/src/app/favicon.ico -------------------------------------------------------------------------------- /src/resources/upi.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/anonthedev/booksuno/HEAD/src/resources/upi.png -------------------------------------------------------------------------------- /.env: -------------------------------------------------------------------------------- 1 | NEXT_PUBLIC_GOOGLE_ANALYTICS = "G-H1WV63ZKMM" 2 | NEXT_PUBLIC_GOOGLE_ANALYTICS2 = "G-GRR0CDQQDE" -------------------------------------------------------------------------------- /public/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/anonthedev/booksuno/HEAD/public/favicon-16x16.png -------------------------------------------------------------------------------- /public/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/anonthedev/booksuno/HEAD/public/favicon-32x32.png -------------------------------------------------------------------------------- /public/twitter-image.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/anonthedev/booksuno/HEAD/public/twitter-image.png -------------------------------------------------------------------------------- /public/mstile-150x150.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/anonthedev/booksuno/HEAD/public/mstile-150x150.png -------------------------------------------------------------------------------- /public/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/anonthedev/booksuno/HEAD/public/apple-touch-icon.png -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /public/android-chrome-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/anonthedev/booksuno/HEAD/public/android-chrome-192x192.png -------------------------------------------------------------------------------- /public/android-chrome-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/anonthedev/booksuno/HEAD/public/android-chrome-512x512.png -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: anonthedev 4 | buy_me_a_coffee: anonthedev 5 | -------------------------------------------------------------------------------- /src/Components/DonateButton.tsx: -------------------------------------------------------------------------------- 1 | import Link from 'next/link'; 2 | 3 | export default function DonateButton(){ 4 | return ( 5 | 6 | Buy me a drink! 7 | 8 | ) 9 | }; -------------------------------------------------------------------------------- /public/browserconfig.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | #da532c 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /src/app/api/feed/route.ts: -------------------------------------------------------------------------------- 1 | import { NextRequest, NextResponse } from "next/server"; 2 | import axios from "axios"; 3 | 4 | export async function GET(req: NextRequest, res: NextResponse) { 5 | const resp = await axios.get("https://librivox.org/api/feed/audiobooks"); 6 | 7 | return NextResponse.json(resp.data); 8 | } 9 | -------------------------------------------------------------------------------- /src/app/api/search/route.ts: -------------------------------------------------------------------------------- 1 | import axios from "axios"; 2 | import { NextRequest, NextResponse } from "next/server"; 3 | 4 | export async function GET(req: NextRequest, res: NextResponse){ 5 | const filter = req.nextUrl.searchParams.get("filter")!; 6 | const query = req.nextUrl.searchParams.get("query")!; 7 | const resp = await axios.get(`https://librivox.org/api/feed/audiobooks/${filter}/%5E${query.split(" ")[0]}`) 8 | return NextResponse.json(resp.data) 9 | } 10 | -------------------------------------------------------------------------------- /src/Components/Loader.tsx: -------------------------------------------------------------------------------- 1 | export default function Loader() { 2 | return ( 3 |
4 | 5 | 6 | 7 | 8 |
9 | ) 10 | } 11 | -------------------------------------------------------------------------------- /next.config.mjs: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | const nextConfig = { 3 | swcMinify: true, 4 | images: { 5 | formats: ["image/avif", "image/webp"], 6 | remotePatterns: [ 7 | { 8 | protocol: "https", 9 | hostname: "cdn.buymeacoffee.com", 10 | port: "", 11 | pathname: "/buttons/v2/**", 12 | }, 13 | ], 14 | }, 15 | }; 16 | 17 | import withPWAInit from '@ducanh2912/next-pwa' 18 | 19 | const withPWA = withPWAInit({ 20 | dest: 'public', 21 | register: true, 22 | reloadOnOnline: true, 23 | 24 | }) 25 | 26 | 27 | export default withPWA(nextConfig); 28 | -------------------------------------------------------------------------------- /src/utils/utilFunctions.ts: -------------------------------------------------------------------------------- 1 | import sanitizeHtml from 'sanitize-html'; 2 | export function trimString(string: string, trimTill: number): string { 3 | return string.length > trimTill 4 | ? string.slice(0, -(string.length - trimTill)) + "..." 5 | : string; 6 | } 7 | 8 | export function sanitizeAndReturnString(inputString: string | null): string { 9 | // Check if inputString is null or undefined, and return an empty string if so 10 | if (inputString === null || inputString === undefined) { 11 | return ''; 12 | } 13 | 14 | // Sanitize the inputString using sanitize-html 15 | const sanitizedString = sanitizeHtml(inputString); 16 | 17 | return sanitizedString; 18 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | # /node_modules 5 | /.pnp 6 | .pnp.js 7 | node_modules 8 | # testing 9 | /coverage 10 | 11 | # next.js 12 | /.next/ 13 | /out/ 14 | 15 | # production 16 | /build 17 | 18 | # misc 19 | .DS_Store 20 | *.pem 21 | 22 | # debug 23 | npm-debug.log* 24 | yarn-debug.log* 25 | yarn-error.log* 26 | 27 | # local env files 28 | .env*.local 29 | 30 | # vercel 31 | .vercel 32 | 33 | # typescript 34 | *.tsbuildinfo 35 | next-env.d.ts 36 | 37 | #PWA files 38 | **/public/sw.js 39 | **/public/workbox-*.js 40 | **/public/worker-*.js 41 | **/public/sw.js.map 42 | **/public/workbox-*.js.map 43 | **/public/worker-*.js.map -------------------------------------------------------------------------------- /src/Components/Navbar.tsx: -------------------------------------------------------------------------------- 1 | import Link from "next/link"; 2 | import DonateButton from "./DonateButton"; 3 | import { FaGithub } from "react-icons/fa"; 4 | 5 | export default function Navbar() { 6 | return ( 7 |
8 | 9 | booksuno. 10 | 11 |
12 | 13 | 14 | 15 | 16 | 17 |
18 |
19 | ); 20 | } 21 | -------------------------------------------------------------------------------- /src/app/page.tsx: -------------------------------------------------------------------------------- 1 | import Dashboard from "@/Components/Dashboard/Dashboard"; 2 | 3 | export default function Page() { 4 | return ( 5 | <> 6 |
7 |
8 |
9 | 10 |
11 |
12 |
13 | 14 |
15 |
16 | 17 |
18 |
19 | 20 | ) 21 | } 22 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": ["dom", "dom.iterable", "esnext", "WebWorker"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "strict": true, 8 | "forceConsistentCasingInFileNames": true, 9 | "noEmit": true, 10 | "esModuleInterop": true, 11 | "module": "esnext", 12 | "moduleResolution": "node", 13 | "resolveJsonModule": true, 14 | "isolatedModules": true, 15 | "jsx": "preserve", 16 | "incremental": true, 17 | "plugins": [ 18 | { 19 | "name": "next" 20 | } 21 | ], 22 | "paths": { 23 | "@/*": ["./src/*"] 24 | } 25 | }, 26 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts", "src/server/index.ks", "src/Components/DonateButton.tsx"], 27 | "exclude": ["node_modules"] 28 | } 29 | -------------------------------------------------------------------------------- /src/app/support/page.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable @next/next/no-img-element */ 2 | import UPIQR from "@/resources/upi.png" 3 | 4 | export default function Page() { 5 | return ( 6 |
7 |
8 | UPI QR Code 9 |
10 | OR 11 |
12 | 13 | Buy Me A Coffee 19 | 20 |
21 |
22 | ) 23 | } -------------------------------------------------------------------------------- /src/Components/Dashboard/Dashboard.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import Feed from "./Feed"; 4 | import SearchBooks from "./SearchBooks"; 5 | import DonateButton from "../DonateButton"; 6 | import CurrentPlayingBook from "../Book/CurrentPlayingBook"; 7 | import Navbar from "../Navbar"; 8 | 9 | export default function Dashboard() { 10 | return ( 11 |
12 |
13 | 14 |
15 | 16 | 17 |
18 |
19 | {/*
20 | 21 |
*/} 22 |
23 | ) 24 | } 25 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | Copyright 2024 anon 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 8 | 9 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "booksuno", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev", 7 | "build": "next build", 8 | "start": "next start", 9 | "lint": "next lint" 10 | }, 11 | "dependencies": { 12 | "@ducanh2912/next-pwa": "^10.2.5", 13 | "@mebtte/react-media-session": "^1.1.2", 14 | "@next/third-parties": "^14.1.3", 15 | "@types/node": "20.5.0", 16 | "@types/react": "18.2.20", 17 | "@types/react-dom": "18.2.7", 18 | "autoprefixer": "10.4.15", 19 | "axios": "^1.5.0", 20 | "eslint": "8.47.0", 21 | "eslint-config-next": "13.4.16", 22 | "next": "^13.5.6", 23 | "postcss": "^8.4.38", 24 | "react": "18.2.0", 25 | "react-dom": "18.2.0", 26 | "react-icons": "^4.10.1", 27 | "sanitize-html": "^2.13.0", 28 | "tailwindcss": "3.3.3", 29 | "typescript": "5.1.6", 30 | "xmldom": "^0.6.0", 31 | "zustand": "^4.4.1" 32 | }, 33 | "devDependencies": { 34 | "@types/sanitize-html": "^2.11.0", 35 | "@types/xmldom": "^0.1.34", 36 | "encoding": "^0.1.13" 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | module.exports = { 3 | content: [ 4 | "./src/pages/**/*.{js,ts,jsx,tsx}", 5 | "./src/Components/**/*.{js,ts,jsx,tsx}", 6 | "./src/app/**/*.{js,ts,jsx,tsx}", 7 | ], 8 | theme: { 9 | extend: { 10 | screens: { 11 | xl: { max: "1279px" }, 12 | // => @media (max-width: 1279px) { ... } 13 | 14 | lg: { max: "1023px" }, 15 | // => @media (max-width: 1023px) { ... } 16 | 17 | md: { max: "767px" }, 18 | // => @media (max-width: 767px) { ... } 19 | 20 | sm: { max: "639px" }, 21 | // => @media (max-width: 639px) { ... } 22 | }, 23 | backgroundImage: { 24 | "gradient-radial": "radial-gradient(var(--tw-gradient-stops))", 25 | "gradient-conic": 26 | "conic-gradient(from 180deg at 50% 50%, var(--tw-gradient-stops))", 27 | }, 28 | fontFamily: { 29 | gloock: ["var(--font-gloock)"], 30 | golos: ["var(--font-golos)"], 31 | raleway: ["var(--font-raleway)"] 32 | } 33 | }, 34 | }, 35 | plugins: [], 36 | }; 37 | -------------------------------------------------------------------------------- /src/Components/Toast.tsx: -------------------------------------------------------------------------------- 1 | import { AiOutlineClose } from "react-icons/ai" 2 | import { useEffect, useState } from "react" 3 | 4 | export default function Toast( 5 | { toast, type }: 6 | { toast: string, type: "success" | "error" | "" }) { 7 | const [showToast, setShowToast] = useState(true) 8 | 9 | return ( 10 |
11 |
12 | 13 | ⓘ {toast} 14 | 15 | 20 |
21 |
22 | ) 23 | } 24 | -------------------------------------------------------------------------------- /public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "booksuno - listen to audiobooks for free", 3 | "short_name": "booksuno", 4 | "icons": [ 5 | { 6 | "src": "/android-chrome-192x192.png", 7 | "sizes": "192x192", 8 | "type": "image/png", 9 | "purpose": "any maskable" 10 | }, 11 | { 12 | "src": "/android-chrome-512x512.png", 13 | "sizes": "512x512", 14 | "type": "image/png" 15 | }, 16 | { 17 | "src": "/apple-touch-icon.png", 18 | "sizes": "180x180", 19 | "type": "image/png" 20 | }, 21 | { 22 | "src": "/favicon-32x32.png", 23 | "sizes": "32x32", 24 | "type": "image/png" 25 | }, 26 | { 27 | "src": "/favicon-16x16.png", 28 | "sizes": "16x16", 29 | "type": "image/png" 30 | }, 31 | { 32 | "src": "/og.png", 33 | "sizes": "800x600", 34 | "type": "image/png" 35 | } 36 | ], 37 | "id": "zex", 38 | "categories": ["free audiobooks", "audiobooks"], 39 | "lang": "en-US", 40 | "theme_color": "#121212", 41 | "background_color": "#121212", 42 | "start_url": "/", 43 | "display": "standalone", 44 | "orientation": "portrait" 45 | } 46 | -------------------------------------------------------------------------------- /src/app/[bookId]/page.tsx: -------------------------------------------------------------------------------- 1 | import Book from "@/Components/Book/Book"; 2 | 3 | export default function page({ params }: { params: { bookId: string } }) { 4 | return ( 5 | <> 6 |
7 |
8 |
9 | 10 |
11 |
12 | {/*
13 | 14 |
*/} 15 |
16 | 17 |
18 |
19 | 20 |
21 | {/*
22 | 23 |
*/} 24 |
25 | 26 | ) 27 | } 28 | -------------------------------------------------------------------------------- /src/Components/GoogleAnalytics.tsx: -------------------------------------------------------------------------------- 1 | import Script from "next/script"; 2 | 3 | const GoogleAnalytics = ({ ga_id, ga_id2 }: { ga_id: string, ga_id2: string }) => ( 4 | <> 5 | 10 | 22 | 27 | 39 | 40 | ); 41 | export default GoogleAnalytics; 42 | -------------------------------------------------------------------------------- /src/app/globals.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | *{ 6 | padding: 0; 7 | margin: 0; 8 | /* outline: 1px solid red; */ 9 | } 10 | 11 | audio::-webkit-media-controls-enclosure { 12 | border-radius: 0; 13 | } 14 | 15 | ::-webkit-scrollbar { 16 | width: 5px; 17 | } 18 | 19 | /* Track */ 20 | ::-webkit-scrollbar-track { 21 | background: transparent; 22 | border-radius: 10px; 23 | } 24 | 25 | /* Handle */ 26 | ::-webkit-scrollbar-thumb { 27 | background: #4b4b4b; 28 | border-radius: 10px; 29 | } 30 | 31 | /* Handle on hover */ 32 | ::-webkit-scrollbar-thumb:hover { 33 | background: #3a3a3a; 34 | } 35 | 36 | input[type=range]{ 37 | accent-color: #eab308; 38 | } 39 | 40 | body { 41 | overflow-x: hidden; 42 | background-color: black; 43 | color: white; 44 | } 45 | 46 | select{ 47 | /* box-sizing: border-box; */ 48 | -webkit-appearance: none; 49 | appearance: none; 50 | -moz-appearance: none; 51 | background-image: url('https://www.svgrepo.com/show/80156/down-arrow.svg'); 52 | background-repeat: no-repeat; 53 | background-size: 8px 8px; 54 | background-position: right 8px center; 55 | } 56 | 57 | @media only screen and (max-width: 1023px){ 58 | body{ 59 | background-color: #121212; 60 | } 61 | } -------------------------------------------------------------------------------- /src/zustand/state.tsx: -------------------------------------------------------------------------------- 1 | import { create } from 'zustand' 2 | 3 | export const useAudioURL = create((set) => ({ 4 | globalAudioURL: "", 5 | isPlaying: false, 6 | duration: 0, 7 | audioInfo: { audioName: "", audioAuthor: "", bookId: null , audioIndex: null}, 8 | updateGlobalAudioURL: (newURL: string) => set({ globalAudioURL: newURL }), 9 | updateIsPlaying: (play: boolean) => set({ isPlaying: play }), 10 | updateAudioInfo: (info: any) => set({ audioInfo: { audioName: info.audioName, audioAuthor: info.audioAuthor, bookId: info.bookId, audioIndex: info.audioIndex } }), 11 | updateDuration: (newDuration: number) => set({ duration: newDuration }) 12 | })) 13 | 14 | export const useSearchInputFocus = create((set) => ({ 15 | searchInputFocused: null, 16 | updateSearchInputFocused: (value: boolean) => set({ searchInputFocused: value }) 17 | })) 18 | 19 | export const useUserDetails = create((set) => ({ 20 | userDetails: null, 21 | updateUserDetails: (details: any) => set({ userDetails: details }), 22 | })) 23 | 24 | export const useBookInfo = create((set) => ({ 25 | bookInfo: null, 26 | updateBookInfo: (details: any) => set({ bookInfo: details }) 27 | })) 28 | 29 | export const useCurrentBookInfo = create((set) => ({ 30 | currentBookInfo: null, 31 | updateCurrentBookInfo: (details: any) => set({ currentBookInfo: details }) 32 | })) 33 | 34 | export const useSearch = create((set) => ({ 35 | searchResults: null, 36 | updateSearchResults: (results: any) => set({ searchResults: results }) 37 | })) -------------------------------------------------------------------------------- /src/app/api/bookDetails/route.ts: -------------------------------------------------------------------------------- 1 | import { NextRequest, NextResponse } from "next/server"; 2 | import axios from "axios"; 3 | import { DOMParser } from "xmldom"; 4 | import { sanitizeAndReturnString } from '@/utils/utilFunctions'; 5 | 6 | export async function GET(req: NextRequest, res: NextResponse) { 7 | const id = req.nextUrl.searchParams.get("bookId")!; 8 | const resp = await axios.get(`https://librivox.org/rss/${id}`); 9 | 10 | try { 11 | const parser = new DOMParser(); 12 | const xmlDoc = parser.parseFromString(resp.data, "text/xml"); 13 | 14 | const bookTitle = xmlDoc.getElementsByTagName("title")[0].textContent; 15 | const bookDesc = sanitizeAndReturnString(xmlDoc.getElementsByTagName("description")[0].textContent); 16 | const bookId = id 17 | 18 | const episodes = xmlDoc.getElementsByTagName("item"); 19 | const epInfo = Array.from(episodes).map((episode) => { 20 | const epTitle = episode.getElementsByTagName("title")[0].textContent 21 | const epURL = episode.getElementsByTagName("media:content")[0].getAttribute("url") 22 | const epDurationNode = episode.getElementsByTagName("itunes:duration")[0] 23 | const epDuration = epDurationNode.textContent?.trim() 24 | return { epTitle, epURL, epDuration }; 25 | }); 26 | 27 | return NextResponse.json({ 28 | bookTitle, 29 | bookDesc, 30 | bookId, 31 | episodes: epInfo 32 | }); 33 | } catch (error) { 34 | console.error("Error fetching or parsing RSS data:", error); 35 | } 36 | 37 | } 38 | -------------------------------------------------------------------------------- /src/Components/Dashboard/Feed.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import axios from "axios" 4 | import { useMemo, useState } from "react" 5 | import Link from "next/link" 6 | import Loader from "../Loader" 7 | import { FaArrowRight } from "react-icons/fa6"; 8 | import { PiWaveformBold } from "react-icons/pi"; 9 | import { useCurrentBookInfo } from "@/zustand/state" 10 | 11 | export default function Feed() { 12 | const [feed, setFeed] = useState() 13 | const [loading, setLoading] = useState() 14 | const { currentBookInfo } = useCurrentBookInfo((state: any) => state) 15 | 16 | useMemo(() => { 17 | setLoading(true) 18 | axios.get("/api/feed") 19 | .then((resp): void => { 20 | setFeed(resp.data) 21 | }) 22 | .then(() => { setLoading(false) }) 23 | .catch((err) => { console.log(err) }) 24 | }, []) 25 | 26 | return ( 27 |
28 |

You might like

29 |
30 | {!loading ? feed && feed.books.length > 0 && feed.books.map((book: any) => ( 31 |
32 | 33 | 34 |
35 |

{book.title.length > 40 ? book.title.slice(0, -(book.title.length - 40)) + "..." : book.title}

36 | {book.authors.length > 0 && book.authors.map((author: any) => (

{author.first_name + " " + author.last_name}

))} 37 |
38 | 39 | 40 | 41 | 42 |
43 | )) :
44 | 45 |

Loading...

46 |
} 47 |
48 |
49 | ) 50 | } 51 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 |

3 | 4 | Logo 5 | 6 | 7 |

booksuno.

8 | 9 |

10 | A website to listen to audiobooks for free. 11 |
12 |
13 | View Demo 14 | . 15 | Report Bug 16 | . 17 | Request Feature 18 |

19 |

20 | 21 | 22 | 23 | ## Table Of Contents 24 | 25 | * [About the Project](#about-the-project) 26 | * [Built With](#built-with) 27 | * [Getting Started](#getting-started) 28 | * [Prerequisites](#prerequisites) 29 | * [Installation](#installation) 30 | * [Contributing](#contributing) 31 | * [License](#license) 32 | * [Authors](#authors) 33 | * [Acknowledgements](#acknowledgements) 34 | 35 | ## About The Project 36 | 37 | Booksuno is a website to listen to audiobooks for free. It uses Librivox API to get the audiobooks. 38 | 39 | ## Built With 40 | 41 | Booksuno is built with Next.js, Typescript, Tailwind and Zustand. 42 | 43 | ## Getting Started 44 | 45 | This is an example of how you may give instructions on setting up your project locally. 46 | To get a local copy up and running follow these simple example steps. 47 | 48 | ### Prerequisites 49 | 50 | This is an example of how to list things you need to use the software and how to install them. 51 | 52 | * npm 53 | 54 | ```sh 55 | npm install npm@latest -g 56 | ``` 57 | 58 | ### Installation 59 | 60 | 1. Clone the repo 61 | ```sh 62 | git clone https://github.com/anonthedev/booksuno.git 63 | ``` 64 | 65 | 2. Install NPM packages 66 | 67 | ```sh 68 | npm install 69 | ``` 70 | 71 | You **do not** need an API key to use Librivox API. 72 | 73 | ## Contributing 74 | 75 | Contributions are what make the open source community such an amazing place to be learn, inspire, and create. Any contributions you make are **greatly appreciated**. 76 | * If you have suggestions for adding or removing projects, feel free to [open an issue](https://github.com/anonthedev/booksuno/issues/new) to discuss it. 77 | * According to the discussion, create a pull request after you have added the required feature. 78 | 79 | ### Creating A Pull Request 80 | 81 | 1. Fork the Project 82 | 2. Create your Feature Branch (`git checkout -b feature/AmazingFeature`) 83 | 3. Commit your Changes (`git commit -m 'Add some AmazingFeature'`) 84 | 4. Push to the Branch (`git push origin feature/AmazingFeature`) 85 | 5. Open a Pull Request 86 | 87 | Please **DO NOT** make bullsh*t PRs. 88 | 89 | ## License 90 | 91 | Distributed under the MIT License. See [LICENSE](https://github.com/anonthedev/booksuno/blob/main/LICENSE.md) for more information. 92 | 93 | ## Authors 94 | 95 | * [**anon**](https://github.com/anonthedev/) - *developer* - *Built the whole thing till now.* 96 | 97 | ## Acknowledgements 98 | 99 | * [anon](https://github.com/anonthedev) 100 | * [Librivox](https://librivox.org/) 101 | -------------------------------------------------------------------------------- /src/app/layout.tsx: -------------------------------------------------------------------------------- 1 | import "./globals.css"; 2 | import { Gloock, Golos_Text, Raleway } from "next/font/google"; 3 | import Player from "@/Components/Player"; 4 | import { Metadata } from "next/types"; 5 | import { GoogleAnalytics } from '@next/third-parties/google' 6 | import CurrentPlayingBook from "@/Components/Book/CurrentPlayingBook"; 7 | 8 | const APP_NAME = "booksuno"; 9 | const APP_DEFAULT_TITLE = "booksuno"; 10 | const APP_TITLE_TEMPLATE = "%s - booksuno"; 11 | const APP_DESCRIPTION = "Discover a world of literary delights with booksuno! Dive into an extensive collection of free audiobooks, offering limitless listening pleasure. Immerse yourself in captivating stories, anytime, anywhere"; 12 | const APP_URL = "https://booksuno.xyz" 13 | 14 | export const metadata: Metadata = { 15 | manifest: "/manifest.json", 16 | applicationName: APP_NAME, 17 | authors: [{name:"Anon 2.0", url: "https://twitter.com/anonthedev"}], 18 | title: { 19 | default: APP_DEFAULT_TITLE, 20 | template: APP_TITLE_TEMPLATE, 21 | }, 22 | themeColor: "#121212", 23 | creator: "Anon 2.0", 24 | robots: "index, follow", 25 | description: APP_DESCRIPTION, 26 | appleWebApp: { 27 | capable: true, 28 | statusBarStyle: "black-translucent", 29 | title: APP_DEFAULT_TITLE, 30 | startupImage:[ 31 | { 32 | url: 'https://booksuno/apple-touch-icon.png', 33 | }, 34 | ] 35 | // startUpImage: [], 36 | }, 37 | formatDetection: { 38 | telephone: false, 39 | }, 40 | 41 | openGraph: { 42 | type: "website", 43 | siteName: APP_NAME, 44 | title: { 45 | default: APP_DEFAULT_TITLE, 46 | template: APP_TITLE_TEMPLATE, 47 | }, 48 | url: APP_URL, 49 | locale: 'en_IN', 50 | description: APP_DESCRIPTION, 51 | images:[ 52 | { 53 | url: 'https://booksuno.xyz/og.png', 54 | width: 800, 55 | height: 600, 56 | }, 57 | { 58 | url: 'https://booksuno.xyz/og-alt.png', 59 | width: 1800, 60 | height: 1600, 61 | }, 62 | ] 63 | }, 64 | twitter: { 65 | card: "summary", 66 | creator: "Anon 2.0", 67 | title: { 68 | default: APP_DEFAULT_TITLE, 69 | template: APP_TITLE_TEMPLATE, 70 | }, 71 | description: APP_DESCRIPTION, 72 | images:[ 73 | { 74 | url: 'https://booksuno.xyz/og.png', 75 | width: 800, 76 | height: 600, 77 | }, 78 | ], 79 | site: APP_URL, 80 | }, 81 | }; 82 | 83 | const gloock = Gloock({ 84 | variable: "--font-gloock", 85 | subsets: ["latin"], 86 | display: "swap", 87 | weight: ["400"], 88 | }); 89 | 90 | const golos = Golos_Text({ 91 | variable: "--font-golos", 92 | subsets: ["latin"], 93 | display: "swap", 94 | weight: ["400", "500", "600", "700", "800", "900"], 95 | }); 96 | 97 | const raleway = Raleway({ 98 | variable: "--font-raleway", 99 | subsets: ["latin"], 100 | display: "swap", 101 | }); 102 | 103 | export default function RootLayout({ 104 | children, 105 | }: { 106 | children: React.ReactNode; 107 | }) { 108 | return ( 109 | 110 | {/* 111 | 112 | */} 113 | 114 | {process.env.NEXT_PUBLIC_GOOGLE_ANALYTICS2 && process.env.NEXT_PUBLIC_GOOGLE_ANALYTICS ? ( 115 | <> 116 | 118 | 120 | 121 | ) : null} 122 |
123 |
124 | {children} 125 |
126 |
127 | 128 |
129 |
130 |
131 | 132 |
133 | 134 | 135 | ); 136 | } 137 | -------------------------------------------------------------------------------- /src/Components/Book/CurrentPlayingBook.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { useAudioURL, useCurrentBookInfo } from "@/zustand/state" 4 | import axios from "axios" 5 | import { useEffect, useState } from "react" 6 | import { FaPlay, FaPause } from "react-icons/fa" 7 | import Loader from "../Loader" 8 | import { trimString } from "@/utils/utilFunctions" 9 | import { usePathname } from 'next/navigation' 10 | 11 | export default function CurrentPlayingBook() { 12 | const [reload, setReload] = useState() 13 | const { audioInfo, updateGlobalAudioURL, globalAudioURL, updateAudioInfo, isPlaying, updateIsPlaying } = useAudioURL((state: any) => state) 14 | const { currentBookInfo, updateCurrentBookInfo } = useCurrentBookInfo((state: any) => state) 15 | const [loading, setLoading] = useState() 16 | 17 | const pathname = usePathname() 18 | 19 | async function getCurrentBookInfo() { 20 | await axios.get(`/api/bookDetails?bookId=${audioInfo.bookId}`) 21 | .then((resp) => { updateCurrentBookInfo(resp.data); setReload(false) }) 22 | .then(() => { setLoading(false) }) 23 | .catch((err) => { console.log(err); setReload(true) }) 24 | } 25 | 26 | useEffect(() => { 27 | if (audioInfo.bookId && currentBookInfo.bookId !== pathname.slice(1)) { 28 | setLoading(true) 29 | getCurrentBookInfo() 30 | } 31 | }, [audioInfo.bookId, currentBookInfo]) 32 | 33 | if (loading) { 34 | return ( 35 |
36 | 37 |

Loading...

38 |
39 | ) 40 | } else if (!audioInfo.bookId) { 41 | return ( 42 |
43 |

Please play some audiobooks

44 |
45 | ) 46 | } else if (reload) { 47 | return ( 48 |
49 | 50 |
51 | ) 52 | } 53 | 54 | return ( 55 |
56 | {currentBookInfo && 57 |
58 |

{currentBookInfo.bookTitle}

59 |

60 |
61 | {currentBookInfo.episodes.length > 0 && currentBookInfo.episodes.map((episode: any, index: number) => ( 62 |
63 |
64 | {isPlaying && globalAudioURL === episode.epURL ? 65 | { 69 | updateIsPlaying(false) 70 | }} 71 | /> : { 75 | updateGlobalAudioURL(episode.epURL) 76 | updateAudioInfo({ 77 | audioName: episode.epTitle, 78 | audioAuthor: "", 79 | bookId: audioInfo.bookId, 80 | audioIndex: index, 81 | }) 82 | updateIsPlaying(true) 83 | }} />} 84 | {index + 1}. 85 | {trimString(episode.epTitle, 20)} 86 |
87 | {episode.epDuration} 88 |
89 | ))} 90 |
91 |
92 | } 93 |
94 | ) 95 | } 96 | -------------------------------------------------------------------------------- /src/Components/Book/Book.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable @next/next/no-img-element */ 2 | "use client" 3 | 4 | import axios from "axios" 5 | import { useEffect, useState } from "react" 6 | import Loader from "../Loader" 7 | import { useAudioURL, useBookInfo, useCurrentBookInfo } from "@/zustand/state" 8 | import { FaPlay, FaPause } from "react-icons/fa" 9 | import Navbar from "../Navbar" 10 | 11 | export default function Book({ id }: { id: string }) { 12 | const [loading, setLoading] = useState() 13 | const { updateGlobalAudioURL, globalAudioURL, updateAudioInfo, isPlaying, updateIsPlaying } = useAudioURL((state: any) => state) 14 | const { updateBookInfo, bookInfo } = useBookInfo((state: any) => state) 15 | const { updateCurrentBookInfo, currentBookInfo } = useCurrentBookInfo((state: any) => state) 16 | 17 | useEffect(() => { 18 | setLoading(true) 19 | axios.get(`/api/bookDetails?bookId=${id}`) 20 | .then((resp): void => { 21 | updateBookInfo(resp.data) 22 | }) 23 | .then(() => { 24 | setLoading(false) 25 | }) 26 | }, [id]) 27 | 28 | if (loading) { 29 | return ( 30 |
31 | 32 |

Loading...

33 |
34 | ) 35 | } 36 | 37 | return ( 38 |
39 |
40 | 41 |
42 | {bookInfo && 43 |
44 |

{bookInfo.bookTitle}

45 |

46 |
47 | {bookInfo.episodes.length > 0 && bookInfo.episodes.map((episode: any, index: number) => ( 48 |
49 |
50 | {isPlaying && globalAudioURL === episode.epURL ? 51 | { 55 | updateIsPlaying(false) 56 | }} 57 | /> : { 61 | updateGlobalAudioURL(episode.epURL) 62 | updateAudioInfo({ 63 | audioName: episode.epTitle, 64 | audioAuthor: "", 65 | bookId: id, 66 | audioIndex: index, 67 | }) 68 | updateIsPlaying(true) 69 | updateCurrentBookInfo(bookInfo) 70 | }} />} 71 | {index + 1}. 72 | {episode.epTitle.length > 60 ? episode.epTitle.slice(0, -(episode.epTitle.length - 60)) + "..." : episode.epTitle} 73 | {episode.epTitle.length > 40 ? episode.epTitle.slice(0, -(episode.epTitle.length - 40)) + "..." : episode.epTitle} 74 |
75 | {episode.epDuration} 76 |
77 | ))} 78 |
79 |
80 | } 81 |
82 |
83 | {/*
84 | 85 |
*/} 86 |
87 | ) 88 | } 89 | -------------------------------------------------------------------------------- /src/Components/Player.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { useAudioURL, useCurrentBookInfo, useSearchInputFocus } from "@/zustand/state" 4 | import React, { useState, useRef, useEffect } from 'react'; 5 | import AudioController from './AudioController'; 6 | 7 | export default function AudioPlayer() { 8 | const { globalAudioURL, isPlaying, updateIsPlaying, updateDuration, updateGlobalAudioURL, updateAudioInfo, audioInfo } = useAudioURL((state: any) => state) 9 | const { currentBookInfo } = useCurrentBookInfo((state: any) => state) 10 | const { searchInputFocused } = useSearchInputFocus((state: any) => state) 11 | 12 | const windowAvailable = typeof window !== "undefined" 13 | 14 | const audioRef = useRef(null); 15 | const [currentTime, setCurrentTime] = useState(0); 16 | const [duration, setDuration] = useState(0); 17 | const [canPlay, setCanPlay] = useState(false); 18 | 19 | useEffect(() => { 20 | if (globalAudioURL) { 21 | setCanPlay(false) 22 | updateIsPlaying(true) 23 | updateDuration(audioRef.current?.duration) 24 | audioRef.current?.play() 25 | } 26 | }, [globalAudioURL]) 27 | 28 | useEffect(() => { 29 | if (isPlaying === true) { 30 | audioRef.current?.play() 31 | } 32 | else if (isPlaying === false) { 33 | audioRef.current?.pause() 34 | } 35 | }, [isPlaying]) 36 | 37 | useEffect(() => { 38 | const audioElement = audioRef.current!; 39 | 40 | const handleTimeUpdate = () => { 41 | setCurrentTime(audioElement.currentTime); 42 | }; 43 | 44 | const handleLoadedMetadata = () => { 45 | setDuration(audioElement.duration); 46 | }; 47 | 48 | audioElement.addEventListener('timeupdate', handleTimeUpdate); 49 | audioElement.addEventListener('loadedmetadata', handleLoadedMetadata); 50 | 51 | return () => { 52 | audioElement.removeEventListener('timeupdate', handleTimeUpdate); 53 | audioElement.removeEventListener('loadedmetadata', handleLoadedMetadata); 54 | }; 55 | }, []); 56 | 57 | // useEffect(() => { 58 | // if (windowAvailable && 'mediaSession' in navigator && currentBookInfo) { 59 | // navigator.mediaSession.metadata = new window.MediaMetadata({ 60 | // title: currentBookInfo.bookTitle, 61 | // artist: "", 62 | // // artwork: [{ src: globalAudioURL, sizes: '96x96', type: 'image/png' }], 63 | // }); 64 | 65 | // navigator.mediaSession.setActionHandler('play', togglePlay); 66 | 67 | // navigator.mediaSession.setActionHandler('pause', togglePause); 68 | 69 | // navigator.mediaSession.setActionHandler('seekbackward', () => { 70 | // audioRef.current!.currentTime -= 10; 71 | // setCurrentTime(audioRef.current!.currentTime) 72 | // }); 73 | 74 | // navigator.mediaSession.setActionHandler('seekforward', () => { 75 | // audioRef.current!.currentTime += 10; 76 | // setCurrentTime(audioRef.current!.currentTime) 77 | // }); 78 | 79 | // navigator.mediaSession.setActionHandler('seekto', (event) => { 80 | // if (event.fastSeek && 'fastSeek' in audioRef.current!) { 81 | // audioRef.current.fastSeek(event.seekTime!); 82 | // setCurrentTime(event.seekTime!) 83 | // } else { 84 | // audioRef.current!.currentTime = event.seekTime!; 85 | // setCurrentTime(event.seekTime!) 86 | // } 87 | // }); 88 | // } 89 | // }, [currentBookInfo, windowAvailable]); 90 | 91 | // useEffect(() => { 92 | // console.log(searchInputFocused) 93 | // if (globalAudioURL && !searchInputFocused) { 94 | // document.onkeydown = (e) => { 95 | // if (e.isComposing || e.key === " " || e.key === "Space Bar" || e.code === "Space") { 96 | // e.preventDefault() 97 | // updateIsPlaying(!isPlaying) 98 | // } 99 | // } 100 | // } 101 | // }) 102 | 103 | const handleNextAudio = () => { 104 | if (audioInfo.audioIndex < currentBookInfo.episodes.length - 1) { 105 | const nextAudio = currentBookInfo.episodes[audioInfo.audioIndex + 1] 106 | // console.log(nextAudio) 107 | updateGlobalAudioURL(nextAudio.epURL) 108 | updateAudioInfo({ 109 | audioName: nextAudio.epTitle, 110 | audioAuthor: "", 111 | bookId: audioInfo.bookId, 112 | audioIndex: audioInfo.audioIndex + 1, 113 | }) 114 | } else { 115 | updateIsPlaying(false) 116 | } 117 | } 118 | 119 | const handlePrevAudio = () => { 120 | if (audioInfo.audioIndex > 0) { 121 | const prevAudio = currentBookInfo.episodes[audioInfo.audioIndex - 1] 122 | updateGlobalAudioURL(prevAudio.epURL) 123 | updateAudioInfo({ 124 | audioName: prevAudio.epTitle, 125 | audioAuthor: "", 126 | bookId: audioInfo.bookId, 127 | audioIndex: audioInfo.audioIndex - 1, 128 | }) 129 | } 130 | } 131 | 132 | function togglePlay() { 133 | updateIsPlaying(true); 134 | audioRef.current?.play(); 135 | }; 136 | 137 | function togglePause() { 138 | updateIsPlaying(false) 139 | audioRef.current?.pause() 140 | } 141 | 142 | const handleVolumeChange = (volume: number) => { 143 | audioRef.current!.volume = volume; 144 | }; 145 | 146 | const handleSeek = (time: number) => { 147 | audioRef.current!.currentTime = time; 148 | setCurrentTime(time); 149 | }; 150 | 151 | return ( 152 |
153 |
167 | ); 168 | }; -------------------------------------------------------------------------------- /src/Components/Dashboard/SearchBooks.tsx: -------------------------------------------------------------------------------- 1 | import { useState, FormEvent, useEffect } from "react" 2 | import axios from "axios" 3 | import { useSearch, useSearchInputFocus, useCurrentBookInfo } from "@/zustand/state" 4 | import { FaChevronDown } from "react-icons/fa" 5 | import Link from "next/link" 6 | import { PiWaveformBold } from "react-icons/pi" 7 | import Toast from "../Toast" 8 | 9 | export default function SearchBooks() { 10 | const [searching, setSearching] = useState(false) 11 | const [searchQuery, setSearchQuery] = useState("") 12 | const [collaspeResults, setCollaspeResults] = useState(false) 13 | const [showToast, setShowToast] = useState(false) 14 | 15 | const specialCharsRegex = /[^\w\s]|_/g; 16 | 17 | const [searchFilter, setSearchFilter] = useState("title") 18 | 19 | const { updateSearchResults, searchResults } = useSearch((state: any) => state) 20 | const { updateSearchInputFocused } = useSearchInputFocus((state: any) => state) 21 | const { currentBookInfo } = useCurrentBookInfo((state: any) => state) 22 | 23 | useEffect(() => { 24 | if (showToast) { 25 | const closeToast = setTimeout(() => { 26 | setShowToast(false) 27 | }, 3000) 28 | return () => { 29 | clearTimeout(closeToast) 30 | } 31 | } 32 | }, [showToast]) 33 | 34 | async function handleSubmit(e: FormEvent) { 35 | e.preventDefault() 36 | if (searchQuery !== "") { 37 | setSearching(true) 38 | await axios.get(`/api/search?filter=${searchFilter}&query=${searchQuery}`) 39 | .then((results) => { 40 | if (results.data.error !== undefined) { 41 | setSearching(false) 42 | updateSearchResults([]) 43 | setShowToast(true) 44 | } else { 45 | if (searchQuery.split(" ").length > 1) { 46 | const filteredResults: any[] = []; 47 | results.data.books.forEach((book: any, index: number) => { 48 | const title = book.title.toLowerCase().replace(specialCharsRegex, "") 49 | if (title.includes(searchQuery.toLowerCase().replace(specialCharsRegex, ""))) { 50 | filteredResults.push(book); 51 | } 52 | }) 53 | if (filteredResults.length > 0) { 54 | updateSearchResults(filteredResults) 55 | } else { 56 | updateSearchResults([]) 57 | setShowToast(true) 58 | } 59 | } else { 60 | updateSearchResults(results.data.books) 61 | } 62 | } 63 | }) 64 | .then(() => { 65 | setSearching(false) 66 | setCollaspeResults(false) 67 | }) 68 | .catch(() => { 69 | setSearching(false) 70 | updateSearchResults([]) 71 | setShowToast(true) 72 | }) 73 | } 74 | } 75 | 76 | return ( 77 |
78 |

Search audiobooks

79 | 80 |
{ handleSubmit(e) }} 81 | className="w-full flex flex-row gap-2 md:flex-col"> 82 | { updateSearchInputFocused(true) }} 84 | onBlur={() => { updateSearchInputFocused(false) }} 85 | className="text-white border-[1px] border-gray-500 bg-transparent w-1/2 md:w-full px-4 py-2 rounded-md focus:outline-none" 86 | placeholder={searchFilter === "title" ? "Enter book name" : searchFilter === "author" ? "Enter author's last name" : searchFilter === "genre" ? "Enter genre" : "Enter book name"} 87 | type="text" 88 | onChange={(e) => { setSearchQuery(e.target.value) }} 89 | /> 90 |
91 | 96 | 97 |
98 |
99 | 100 | {searchResults && searchResults.length > 0 &&
{ setCollaspeResults(!collaspeResults) }}> 101 | 102 | {collaspeResults ? "Expand search results" : "Collapse search Results"} 103 | {/*
*/} 104 |
} 105 | 106 | {searchResults && searchResults.length > 0 && searchResults.map((book: any) => ( 107 |
108 | 110 | {/* */} 111 | 112 |
113 |

{book.title.length > 40 ? book.title.slice(0, -(book.title.length - 40)) + "..." : book.title}

114 | {book.authors.length > 0 && book.authors.map((author: any) => (

{author.first_name + " " + author.last_name}

))} 115 |
116 | 117 |
)) 118 | } 119 | {showToast && } 120 |
121 | ) 122 | } 123 | -------------------------------------------------------------------------------- /src/Components/AudioController.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import React, { useState, useEffect, MouseEventHandler } from 'react'; 4 | import { FaPlay, FaPause, FaVolumeUp } from "react-icons/fa" 5 | import { IoPlaySkipForward, IoPlaySkipBack } from "react-icons/io5"; 6 | import { useAudioURL, useCurrentBookInfo, useBookInfo } from '@/zustand/state'; 7 | import Toast from './Toast'; 8 | import Loader from './Loader'; 9 | import Link from 'next/link'; 10 | import { trimString } from '@/utils/utilFunctions'; 11 | 12 | interface propType { 13 | onPlay: MouseEventHandler; 14 | onPause: MouseEventHandler; 15 | isPlaying: boolean; 16 | onVolumeChange: Function; 17 | onSeek: Function; 18 | currentTime: number; 19 | duration: number; 20 | canPlay: boolean; 21 | } 22 | 23 | export default function AudioController({ onPlay, onPause, isPlaying, onVolumeChange, onSeek, currentTime, duration, canPlay }: propType) { 24 | const [volume, setVolume] = useState(100); 25 | const [isSeeking, setIsSeeking] = useState(false); 26 | const [showToast, setShowToast] = useState(false); 27 | const { audioInfo, globalAudioURL, updateGlobalAudioURL, updateAudioInfo, updateIsPlaying } = useAudioURL((state: any) => state) 28 | const { currentBookInfo } = useCurrentBookInfo((state: any) => state) 29 | const { bookInfo } = useBookInfo((state: any) => state) 30 | 31 | useEffect(() => { 32 | setVolume(100); 33 | }, [isPlaying]); 34 | 35 | useEffect(() => { 36 | if (showToast) { 37 | const closeToast = setTimeout(() => { 38 | setShowToast(false) 39 | }, 3000) 40 | return () => { 41 | clearTimeout(closeToast) 42 | } 43 | } 44 | }, [showToast]) 45 | 46 | function handleVolumeChange(e: any) { 47 | const newVolume = e.target.value; 48 | setVolume(newVolume); 49 | onVolumeChange(newVolume / 100); 50 | }; 51 | 52 | function handleSeek(e: any) { 53 | const newTime = e.target.value; 54 | onSeek(newTime); 55 | }; 56 | 57 | const formatTime = (time: number) => { 58 | if (time === 0) { 59 | return "--:--" 60 | } 61 | const minutes = Math.floor(time / 60); 62 | const seconds = Math.floor(time % 60); 63 | return `${minutes}:${seconds < 10 ? '0' : ''}${seconds}`; 64 | }; 65 | 66 | const handleNextAudio = () => { 67 | if (audioInfo.audioIndex < currentBookInfo.episodes.length - 1) { 68 | const nextAudio = currentBookInfo.episodes[audioInfo.audioIndex + 1] 69 | // console.log(nextAudio) 70 | updateGlobalAudioURL(nextAudio.epURL) 71 | updateAudioInfo({ 72 | audioName: nextAudio.epTitle, 73 | audioAuthor: "", 74 | bookId: audioInfo.bookId, 75 | audioIndex: audioInfo.audioIndex + 1, 76 | }) 77 | } 78 | } 79 | 80 | const handlePrevAudio = () => { 81 | if (audioInfo.audioIndex > 0) { 82 | const prevAudio = currentBookInfo.episodes[audioInfo.audioIndex - 1] 83 | updateGlobalAudioURL(prevAudio.epURL) 84 | updateAudioInfo({ 85 | audioName: prevAudio.epTitle, 86 | audioAuthor: "", 87 | bookId: audioInfo.bookId, 88 | audioIndex: audioInfo.audioIndex - 1, 89 | }) 90 | } 91 | } 92 | 93 | return ( 94 |
95 |
98 |
99 |
100 | {currentBookInfo && trimString(currentBookInfo.bookTitle, 20) + " > " + audioInfo.audioName} 101 |
102 |
103 |
104 | 0 ? '#ffffff' : 'gray'} /> 108 | {isPlaying && canPlay 109 | ? 110 | : !isPlaying ? { 114 | setShowToast(true) 115 | }} 116 | className={`cursor-pointer`} 117 | color={globalAudioURL ? '#ffffff' : 'gray'} /> 118 | : isPlaying && !canPlay 119 | ? 120 | : null 121 | } 122 | 126 |
127 |
128 | {formatTime(currentTime)} 129 | setIsSeeking(true)} 135 | onMouseUp={(e) => { setIsSeeking(false); handleSeek(e); }} 136 | onChange={handleSeek} 137 | className={`h-[2px] ${!globalAudioURL ? "accent-gray-400" : "accent-yellow-500"} w-72 outline-none border-none lg:hidden`} 138 | /> 139 | {formatTime(duration)} 140 |
141 |
142 |
143 | 144 | 152 |
153 |
154 | setIsSeeking(true)} 160 | onMouseUp={(e) => { setIsSeeking(false); handleSeek(e); }} 161 | onChange={handleSeek} 162 | className="hidden lg:block h-[2px] bg-gray-500 accent-yellow-500 rounded-md" 163 | /> 164 |
165 | {showToast && } 166 |
167 | ); 168 | }; --------------------------------------------------------------------------------