├── 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 |
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 | book
suno.
10 |
11 |
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 |
12 |
13 |
14 |
15 |
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 |

9 |
10 | OR
11 |
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 |
12 | {/* */}
15 |
16 |
17 |
18 |
21 | {/* */}
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 |
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 |
130 |
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 |
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 | {/* */}
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 |
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 | };
--------------------------------------------------------------------------------