updload
This commit is contained in:
1 parent
f4962cc138
commit
733c9b01a7
35 files changed
+4125
No files matched your search
@@ -0,0 +1 @@
|
|||||||
|
DATABASE_URL="file:./dev.db"
|
||||||
+46
@@ -0,0 +1,46 @@
|
|||||||
|
# dependencies
|
||||||
|
/node_modules
|
||||||
|
/.pnp
|
||||||
|
.pnp.js
|
||||||
|
|
||||||
|
# testing
|
||||||
|
/coverage
|
||||||
|
|
||||||
|
# next.js
|
||||||
|
/.next/
|
||||||
|
/out/
|
||||||
|
|
||||||
|
# production
|
||||||
|
/build
|
||||||
|
|
||||||
|
# misc
|
||||||
|
.DS_Store
|
||||||
|
*.pem
|
||||||
|
|
||||||
|
# debug
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
|
||||||
|
# local env files
|
||||||
|
.env*.local
|
||||||
|
.env
|
||||||
|
|
||||||
|
# vercel
|
||||||
|
.vercel
|
||||||
|
|
||||||
|
# typescript
|
||||||
|
*.tsbuildinfo
|
||||||
|
next-env.d.ts
|
||||||
|
|
||||||
|
# prisma
|
||||||
|
prisma/dev.db
|
||||||
|
prisma/dev.db-journal
|
||||||
|
|
||||||
|
# python
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*$py.class
|
||||||
|
.venv/
|
||||||
|
venv/
|
||||||
|
env/
|
||||||
+57
@@ -0,0 +1,57 @@
|
|||||||
|
# Build stage
|
||||||
|
FROM node:20-alpine AS builder
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Copy package files
|
||||||
|
COPY package*.json ./
|
||||||
|
COPY prisma ./prisma/
|
||||||
|
|
||||||
|
# Install dependencies
|
||||||
|
RUN npm ci
|
||||||
|
|
||||||
|
# Copy source code
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Generate Prisma Client
|
||||||
|
RUN npx prisma generate
|
||||||
|
|
||||||
|
# Build Next.js application
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# Production stage
|
||||||
|
FROM node:20-alpine AS runner
|
||||||
|
|
||||||
|
# Install Python and yt-dlp dependencies
|
||||||
|
RUN apk add --no-cache python3 py3-pip ffmpeg
|
||||||
|
|
||||||
|
# Install yt-dlp
|
||||||
|
RUN pip3 install --no-cache-dir yt-dlp --break-system-packages
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
ENV NODE_ENV production
|
||||||
|
ENV NEXT_TELEMETRY_DISABLED 1
|
||||||
|
|
||||||
|
# Create non-root user
|
||||||
|
RUN addgroup --system --gid 1001 nodejs
|
||||||
|
RUN adduser --system --uid 1001 nextjs
|
||||||
|
|
||||||
|
# Copy necessary files from builder
|
||||||
|
COPY --from=builder /app/public ./public
|
||||||
|
COPY --from=builder /app/.next/standalone ./
|
||||||
|
COPY --from=builder /app/.next/static ./.next/static
|
||||||
|
COPY --from=builder /app/prisma ./prisma
|
||||||
|
COPY --from=builder /app/node_modules/.prisma ./node_modules/.prisma
|
||||||
|
|
||||||
|
# Create database directory
|
||||||
|
RUN mkdir -p /app/prisma && chown -R nextjs:nodejs /app
|
||||||
|
|
||||||
|
USER nextjs
|
||||||
|
|
||||||
|
EXPOSE 3000
|
||||||
|
|
||||||
|
ENV PORT 3000
|
||||||
|
ENV HOSTNAME "0.0.0.0"
|
||||||
|
|
||||||
|
CMD ["node", "server.js"]
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
import { updateYtDlp } from '@/lib/ytdlp'
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const result = await updateYtDlp()
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
message: result
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error updating yt-dlp:', error)
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Failed to update yt-dlp' },
|
||||||
|
{ status: 500 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
import { prisma } from '@/lib/prisma'
|
||||||
|
|
||||||
|
export async function DELETE(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const { id } = await params
|
||||||
|
|
||||||
|
await prisma.playlist.delete({
|
||||||
|
where: { id },
|
||||||
|
})
|
||||||
|
|
||||||
|
console.log('[API] Playlist deleted:', id)
|
||||||
|
return NextResponse.json({ success: true })
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[API] Error deleting playlist:', error)
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Failed to delete playlist' },
|
||||||
|
{ status: 500 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const { id } = await params
|
||||||
|
|
||||||
|
const playlist = await prisma.playlist.findUnique({
|
||||||
|
where: { id },
|
||||||
|
include: {
|
||||||
|
playlistSongs: {
|
||||||
|
include: {
|
||||||
|
song: true,
|
||||||
|
},
|
||||||
|
orderBy: {
|
||||||
|
addedAt: 'asc',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!playlist) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Playlist not found' },
|
||||||
|
{ status: 404 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(playlist)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[API] Error fetching playlist:', error)
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Failed to fetch playlist' },
|
||||||
|
{ status: 500 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
import { prisma } from '@/lib/prisma'
|
||||||
|
|
||||||
|
export async function DELETE(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ id: string; songId: string }> }
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const { id, songId } = await params
|
||||||
|
|
||||||
|
await prisma.playlistSong.delete({
|
||||||
|
where: {
|
||||||
|
playlistId_songId: {
|
||||||
|
playlistId: id,
|
||||||
|
songId: songId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
console.log('[API] Song removed from playlist')
|
||||||
|
return NextResponse.json({ success: true })
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[API] Error removing song from playlist:', error)
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Failed to remove song from playlist' },
|
||||||
|
{ status: 500 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
import { prisma } from '@/lib/prisma'
|
||||||
|
|
||||||
|
export async function POST(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const { id } = await params
|
||||||
|
const { songId } = await request.json()
|
||||||
|
|
||||||
|
if (!songId) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'songId is required' },
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if already in playlist
|
||||||
|
const existing = await prisma.playlistSong.findUnique({
|
||||||
|
where: {
|
||||||
|
playlistId_songId: {
|
||||||
|
playlistId: id,
|
||||||
|
songId: songId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Song already in playlist' },
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const playlistSong = await prisma.playlistSong.create({
|
||||||
|
data: {
|
||||||
|
playlistId: id,
|
||||||
|
songId: songId,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
console.log('[API] Song added to playlist:', playlistSong.id)
|
||||||
|
return NextResponse.json(playlistSong)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[API] Error adding song to playlist:', error)
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Failed to add song to playlist' },
|
||||||
|
{ status: 500 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
import { prisma } from '@/lib/prisma'
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
const playlists = await prisma.playlist.findMany({
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
include: {
|
||||||
|
playlistSongs: {
|
||||||
|
include: {
|
||||||
|
song: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return NextResponse.json(playlists)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[API] Error fetching playlists:', error)
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Failed to fetch playlists' },
|
||||||
|
{ status: 500 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const { name } = await request.json()
|
||||||
|
|
||||||
|
if (!name) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Name is required' },
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const playlist = await prisma.playlist.create({
|
||||||
|
data: { name },
|
||||||
|
})
|
||||||
|
|
||||||
|
console.log('[API] Playlist created:', playlist.id)
|
||||||
|
return NextResponse.json(playlist)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[API] Error creating playlist:', error)
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Failed to create playlist' },
|
||||||
|
{ status: 500 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
import { prisma } from '@/lib/prisma'
|
||||||
|
|
||||||
|
export async function DELETE(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const { id } = await params
|
||||||
|
|
||||||
|
// Instead of deleting, mark as not in library
|
||||||
|
// This keeps the song in playlists but removes it from main library
|
||||||
|
await prisma.song.update({
|
||||||
|
where: { id },
|
||||||
|
data: { isInLibrary: false },
|
||||||
|
})
|
||||||
|
|
||||||
|
console.log('[API] Song removed from library:', id)
|
||||||
|
return NextResponse.json({ success: true })
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[API] Error removing song from library:', error)
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Failed to remove song from library' },
|
||||||
|
{ status: 500 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
import { prisma } from '@/lib/prisma'
|
||||||
|
import { getYoutubeMetadata } from '@/lib/ytdlp'
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const { url } = await request.json()
|
||||||
|
|
||||||
|
if (!url) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'URL is required' },
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('[API] Adding song from URL:', url)
|
||||||
|
|
||||||
|
// Get metadata from YouTube using yt-dlp
|
||||||
|
const metadata = await getYoutubeMetadata(url)
|
||||||
|
console.log('[API] Got metadata:', metadata)
|
||||||
|
|
||||||
|
// Check if song already exists
|
||||||
|
const existingSong = await prisma.song.findUnique({
|
||||||
|
where: { youtubeId: metadata.id },
|
||||||
|
})
|
||||||
|
|
||||||
|
if (existingSong) {
|
||||||
|
console.log('[API] Song already exists:', existingSong.id)
|
||||||
|
|
||||||
|
// If song was previously removed from library, restore it
|
||||||
|
if (!existingSong.isInLibrary) {
|
||||||
|
const updatedSong = await prisma.song.update({
|
||||||
|
where: { id: existingSong.id },
|
||||||
|
data: { isInLibrary: true },
|
||||||
|
})
|
||||||
|
console.log('[API] Song restored to library:', updatedSong.id)
|
||||||
|
return NextResponse.json(updatedSong)
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(existingSong)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create new song in database
|
||||||
|
const song = await prisma.song.create({
|
||||||
|
data: {
|
||||||
|
youtubeId: metadata.id,
|
||||||
|
title: metadata.title,
|
||||||
|
channelName: metadata.channel,
|
||||||
|
thumbnail: metadata.thumbnail,
|
||||||
|
duration: metadata.duration,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
console.log('[API] Song created successfully:', song.id)
|
||||||
|
return NextResponse.json(song)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[API] Error adding song:', error)
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Failed to add song' },
|
||||||
|
{ status: 500 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
const songs = await prisma.song.findMany({
|
||||||
|
where: { isInLibrary: true },
|
||||||
|
orderBy: { addedAt: 'desc' },
|
||||||
|
})
|
||||||
|
|
||||||
|
return NextResponse.json(songs)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching songs:', error)
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Failed to fetch songs' },
|
||||||
|
{ status: 500 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
import { prisma } from '@/lib/prisma'
|
||||||
|
import { getStreamUrl } from '@/lib/ytdlp'
|
||||||
|
import { streamCache } from '@/lib/cache'
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const { id } = await params
|
||||||
|
|
||||||
|
// Get song from database
|
||||||
|
const song = await prisma.song.findUnique({
|
||||||
|
where: { id },
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!song) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Song not found' },
|
||||||
|
{ status: 404 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check cache first
|
||||||
|
let streamUrl = streamCache.get(song.youtubeId)
|
||||||
|
|
||||||
|
if (!streamUrl) {
|
||||||
|
console.log('[Stream] Cache miss for:', song.youtubeId)
|
||||||
|
// Get dynamic stream URL using yt-dlp
|
||||||
|
streamUrl = await getStreamUrl(song.youtubeId)
|
||||||
|
// Cache the URL
|
||||||
|
streamCache.set(song.youtubeId, streamUrl)
|
||||||
|
} else {
|
||||||
|
console.log('[Stream] Cache hit for:', song.youtubeId)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Redirect to the stream URL
|
||||||
|
return NextResponse.redirect(streamUrl)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error streaming song:', error)
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Failed to get stream URL' },
|
||||||
|
{ status: 500 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
|
@import "./slider.css";
|
||||||
|
|
||||||
|
@keyframes slide-in-right {
|
||||||
|
from {
|
||||||
|
transform: translateX(100%);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: translateX(0);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate-slide-in-right {
|
||||||
|
animation: slide-in-right 0.3s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8" ?>
|
||||||
|
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||||
|
<svg width="200pt" height="200pt" viewBox="0 0 200 200" version="1.1" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<g id="#00c940ff">
|
||||||
|
<path fill="#00c940" opacity="1.00" d=" M 27.04 5.04 C 42.60 5.04 58.17 5.02 73.74 5.05 C 73.71 44.03 73.75 83.01 73.72 121.99 C 73.97 132.23 81.03 141.88 90.73 145.21 C 101.34 149.27 114.35 145.06 120.59 135.58 C 126.46 127.26 126.57 115.42 120.92 106.97 C 115.95 99.10 106.37 94.50 97.12 95.41 C 90.14 95.98 83.59 99.64 79.21 105.06 C 79.06 87.26 79.26 69.45 79.11 51.64 C 95.20 46.71 113.00 47.82 128.44 54.47 C 146.30 62.02 160.82 77.08 167.73 95.20 C 171.30 104.94 173.54 115.35 172.75 125.78 C 171.23 143.38 163.85 160.72 151.04 173.08 C 137.49 186.70 118.24 194.67 98.98 194.01 C 81.75 194.27 64.71 187.48 51.83 176.16 C 36.34 162.74 27.03 142.50 27.04 122.00 C 27.03 83.01 27.04 44.03 27.04 5.04 Z" />
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 986 B |
@@ -0,0 +1,29 @@
|
|||||||
|
import type { Metadata } from 'next'
|
||||||
|
import { ToastProvider } from '@/components/Toast'
|
||||||
|
import { PlayerProvider } from '@/components/PlayerContext'
|
||||||
|
import GlobalPlayer from '@/components/GlobalPlayer'
|
||||||
|
import './globals.css'
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: 'Bitify - Self-hosted youtube music player',
|
||||||
|
description: 'Self-hosted YouTube music streaming app',
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function RootLayout({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<html lang="en" className="dark" suppressHydrationWarning>
|
||||||
|
<body className="bg-black text-white h-screen overflow-hidden" suppressHydrationWarning>
|
||||||
|
<ToastProvider>
|
||||||
|
<PlayerProvider>
|
||||||
|
{children}
|
||||||
|
<GlobalPlayer />
|
||||||
|
</PlayerProvider>
|
||||||
|
</ToastProvider>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
)
|
||||||
|
}
|
||||||
+167
@@ -0,0 +1,167 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import Navbar from '@/components/Navbar'
|
||||||
|
import Header from '@/components/Header'
|
||||||
|
import SongCard from '@/components/SongCard'
|
||||||
|
import { useToast } from '@/components/Toast'
|
||||||
|
import { usePlayer } from '@/components/PlayerContext'
|
||||||
|
|
||||||
|
interface Song {
|
||||||
|
id: string
|
||||||
|
youtubeId: string
|
||||||
|
title: string
|
||||||
|
channelName: string
|
||||||
|
thumbnail: string
|
||||||
|
duration: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Playlist {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Home() {
|
||||||
|
const [songs, setSongs] = useState<Song[]>([])
|
||||||
|
const [playlists, setPlaylists] = useState<Playlist[]>([])
|
||||||
|
const [isLoading, setIsLoading] = useState(false)
|
||||||
|
const { showToast } = useToast()
|
||||||
|
const { playSong } = usePlayer()
|
||||||
|
|
||||||
|
// Fetch songs and playlists on mount
|
||||||
|
useEffect(() => {
|
||||||
|
fetchSongs()
|
||||||
|
fetchPlaylists()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const fetchPlaylists = async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/playlists')
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json()
|
||||||
|
setPlaylists(data)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching playlists:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchSongs = async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/songs')
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json()
|
||||||
|
setSongs(data)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching songs:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleAddSong = async (url: string) => {
|
||||||
|
setIsLoading(true)
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/songs', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ url }),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
const newSong = await response.json()
|
||||||
|
setSongs((prev) => [newSong, ...prev])
|
||||||
|
showToast('Song added successfully', 'success')
|
||||||
|
} else {
|
||||||
|
showToast('Failed to add song. Please check the URL.', 'error')
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error adding song:', error)
|
||||||
|
showToast('An error occurred while adding the song.', 'error')
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handlePlaySong = (song: Song) => {
|
||||||
|
const index = songs.findIndex((s) => s.id === song.id)
|
||||||
|
playSong(song, index, songs, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDeleteSong = async (songId: string) => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/songs/${songId}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
})
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
// Remove song from state
|
||||||
|
setSongs((prev) => prev.filter((s) => s.id !== songId))
|
||||||
|
showToast('Song deleted', 'success')
|
||||||
|
} else {
|
||||||
|
showToast('Error deleting song', 'error')
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error deleting song:', error)
|
||||||
|
showToast('Error deleting song', 'error')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleAddToPlaylist = async (songId: string, playlistId: string) => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/playlists/${playlistId}/songs`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ songId }),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
showToast('Song added to playlist', 'success')
|
||||||
|
} else {
|
||||||
|
const data = await response.json()
|
||||||
|
showToast(data.error || 'Error adding song to playlist', 'error')
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error adding to playlist:', error)
|
||||||
|
showToast('Error adding song to playlist', 'error')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-screen overflow-hidden">
|
||||||
|
<Navbar />
|
||||||
|
<Header onAddSong={handleAddSong} isLoading={isLoading} />
|
||||||
|
|
||||||
|
<main className="flex-1 overflow-y-auto pb-36 sm:pb-32 p-3 sm:p-6">
|
||||||
|
{songs.length === 0 ? (
|
||||||
|
<div className="flex items-center justify-center h-full">
|
||||||
|
<div className="text-center">
|
||||||
|
<h2 className="text-2xl font-bold text-gray-400 mb-2">
|
||||||
|
No songs yet
|
||||||
|
</h2>
|
||||||
|
<p className="text-gray-500">
|
||||||
|
Add your first song by pasting a YouTube URL
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-1 xs:grid-cols-2 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 2xl:grid-cols-6 gap-4 sm:gap-6">
|
||||||
|
{songs.map((song) => (
|
||||||
|
<SongCard
|
||||||
|
key={song.id}
|
||||||
|
song={song}
|
||||||
|
onPlay={handlePlaySong}
|
||||||
|
onDelete={handleDeleteSong}
|
||||||
|
playlists={playlists}
|
||||||
|
onAddToPlaylist={handleAddToPlaylist}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,313 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import { Plus, Trash2, Music, Play } from 'lucide-react'
|
||||||
|
import Navbar from '@/components/Navbar'
|
||||||
|
import SongCard from '@/components/SongCard'
|
||||||
|
import { useToast } from '@/components/Toast'
|
||||||
|
import { usePlayer } from '@/components/PlayerContext'
|
||||||
|
|
||||||
|
interface Song {
|
||||||
|
id: string
|
||||||
|
youtubeId: string
|
||||||
|
title: string
|
||||||
|
channelName: string
|
||||||
|
thumbnail: string
|
||||||
|
duration: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PlaylistSong {
|
||||||
|
id: string
|
||||||
|
song: Song
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Playlist {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
playlistSongs: PlaylistSong[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PlaylistsPage() {
|
||||||
|
const [playlists, setPlaylists] = useState<Playlist[]>([])
|
||||||
|
const [selectedPlaylist, setSelectedPlaylist] = useState<Playlist | null>(null)
|
||||||
|
const [newPlaylistName, setNewPlaylistName] = useState('')
|
||||||
|
const [showCreateForm, setShowCreateForm] = useState(false)
|
||||||
|
const [playlistSongs, setPlaylistSongs] = useState<Song[]>([])
|
||||||
|
const { showToast } = useToast()
|
||||||
|
const { playSong, isPlaylist, isShuffled, toggleShuffle } = usePlayer()
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchPlaylists()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (selectedPlaylist) {
|
||||||
|
const songs = selectedPlaylist.playlistSongs.map((ps) => ps.song)
|
||||||
|
setPlaylistSongs(songs)
|
||||||
|
}
|
||||||
|
}, [selectedPlaylist])
|
||||||
|
|
||||||
|
const fetchPlaylists = async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/playlists')
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json()
|
||||||
|
setPlaylists(data)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching playlists:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCreatePlaylist = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
if (!newPlaylistName.trim()) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/playlists', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ name: newPlaylistName }),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
const playlist = await response.json()
|
||||||
|
setPlaylists((prev) => [{ ...playlist, playlistSongs: [] }, ...prev])
|
||||||
|
setNewPlaylistName('')
|
||||||
|
setShowCreateForm(false)
|
||||||
|
showToast('Playlist created', 'success')
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error creating playlist:', error)
|
||||||
|
showToast('Error creating playlist', 'error')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDeletePlaylist = async (playlistId: string) => {
|
||||||
|
if (!confirm('¿Eliminar esta playlist?')) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/playlists/${playlistId}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
})
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
setPlaylists((prev) => prev.filter((p) => p.id !== playlistId))
|
||||||
|
if (selectedPlaylist?.id === playlistId) {
|
||||||
|
setSelectedPlaylist(null)
|
||||||
|
}
|
||||||
|
showToast('Playlist deleted', 'success')
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error deleting playlist:', error)
|
||||||
|
showToast('Error deleting playlist', 'error')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleRemoveSongFromPlaylist = async (songId: string) => {
|
||||||
|
if (!selectedPlaylist) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(
|
||||||
|
`/api/playlists/${selectedPlaylist.id}/songs/${songId}`,
|
||||||
|
{
|
||||||
|
method: 'DELETE',
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
// Update selected playlist
|
||||||
|
const updatedPlaylistSongs = selectedPlaylist.playlistSongs.filter(
|
||||||
|
(ps) => ps.song.id !== songId
|
||||||
|
)
|
||||||
|
const updatedPlaylist = {
|
||||||
|
...selectedPlaylist,
|
||||||
|
playlistSongs: updatedPlaylistSongs,
|
||||||
|
}
|
||||||
|
setSelectedPlaylist(updatedPlaylist)
|
||||||
|
|
||||||
|
// Update playlists list
|
||||||
|
setPlaylists((prev) =>
|
||||||
|
prev.map((p) =>
|
||||||
|
p.id === selectedPlaylist.id ? updatedPlaylist : p
|
||||||
|
)
|
||||||
|
)
|
||||||
|
showToast('Song removed from playlist', 'success')
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error removing song from playlist:', error)
|
||||||
|
showToast('Error removing song from playlist', 'error')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handlePlaySong = (song: Song) => {
|
||||||
|
const index = playlistSongs.findIndex((s) => s.id === song.id)
|
||||||
|
playSong(song, index, playlistSongs, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handlePlayPlaylist = (playlist: Playlist) => {
|
||||||
|
const songs = playlist.playlistSongs.map((ps) => ps.song)
|
||||||
|
if (songs.length > 0) {
|
||||||
|
setPlaylistSongs(songs)
|
||||||
|
playSong(songs[0], 0, songs, true)
|
||||||
|
setSelectedPlaylist(playlist)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-screen overflow-hidden">
|
||||||
|
<Navbar />
|
||||||
|
|
||||||
|
<div className="flex-1 flex flex-col lg:flex-row overflow-hidden">
|
||||||
|
{/* Playlists sidebar */}
|
||||||
|
<div className="w-full lg:w-80 border-b lg:border-b-0 lg:border-r border-gray-800 overflow-y-auto p-3 sm:p-6">
|
||||||
|
<div className="mb-4">
|
||||||
|
{!showCreateForm ? (
|
||||||
|
<button
|
||||||
|
onClick={() => setShowCreateForm(true)}
|
||||||
|
className="w-full cursor-pointer flex items-center justify-center gap-2 px-4 py-3 bg-green-500 text-white rounded-lg hover:bg-green-600 transition-colors"
|
||||||
|
>
|
||||||
|
<Plus size={20} />
|
||||||
|
New Playlist
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<form onSubmit={handleCreatePlaylist} className="space-y-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={newPlaylistName}
|
||||||
|
onChange={(e) => setNewPlaylistName(e.target.value)}
|
||||||
|
placeholder="Playlist name..."
|
||||||
|
className="w-full px-4 py-2 bg-gray-800 text-white rounded-lg focus:outline-none focus:ring-2 focus:ring-green-500"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="flex-1 cursor-pointer px-4 py-2 bg-green-500 text-white rounded-lg hover:bg-green-600 transition-colors"
|
||||||
|
>
|
||||||
|
Create
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setShowCreateForm(false)
|
||||||
|
setNewPlaylistName('')
|
||||||
|
}}
|
||||||
|
className="flex-1 cursor-pointer px-4 py-2 bg-gray-700 text-white rounded-lg hover:bg-gray-600 transition-colors"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
{playlists.length === 0 ? (
|
||||||
|
<p className="text-gray-500 text-center py-4">
|
||||||
|
No playlists
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
playlists.map((playlist) => (
|
||||||
|
<div
|
||||||
|
key={playlist.id}
|
||||||
|
className={`flex items-center justify-between p-3 rounded-lg transition-colors group ${
|
||||||
|
selectedPlaylist?.id === playlist.id
|
||||||
|
? 'bg-gray-700'
|
||||||
|
: 'bg-gray-800 hover:bg-gray-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
onClick={() => setSelectedPlaylist(playlist)}
|
||||||
|
className="flex-1 min-w-0 cursor-pointer"
|
||||||
|
>
|
||||||
|
<h3 className="text-white font-semibold truncate">
|
||||||
|
{playlist.name}
|
||||||
|
</h3>
|
||||||
|
<p className="text-gray-400 text-sm">
|
||||||
|
{playlist.playlistSongs.length} song
|
||||||
|
{playlist.playlistSongs.length !== 1 ? 's' : ''}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{playlist.playlistSongs.length > 0 && (
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
handlePlayPlaylist(playlist)
|
||||||
|
}}
|
||||||
|
className="p-2 cursor-pointer bg-green-500 rounded-full hover:bg-green-600 transition-colors opacity-100"
|
||||||
|
title="Play playlist"
|
||||||
|
>
|
||||||
|
<Play size={18} fill="white" className="text-white ml-0.5" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
handleDeletePlaylist(playlist.id)
|
||||||
|
}}
|
||||||
|
className="p-2 cursor-pointer text-gray-400 hover:text-red-500 transition-colors"
|
||||||
|
title="Delete playlist"
|
||||||
|
>
|
||||||
|
<Trash2 size={18} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Playlist content */}
|
||||||
|
<main className="flex-1 overflow-y-auto pb-36 sm:pb-32 p-3 sm:p-6">
|
||||||
|
{!selectedPlaylist ? (
|
||||||
|
<div className="flex items-center justify-center h-full">
|
||||||
|
<div className="text-center">
|
||||||
|
<Music size={64} className="mx-auto text-gray-600 mb-4" />
|
||||||
|
<h2 className="text-2xl font-bold text-gray-400 mb-2">
|
||||||
|
Select a playlist
|
||||||
|
</h2>
|
||||||
|
<p className="text-gray-500">
|
||||||
|
Or create a new one to start
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : selectedPlaylist.playlistSongs.length === 0 ? (
|
||||||
|
<div className="flex items-center justify-center h-full">
|
||||||
|
<div className="text-center">
|
||||||
|
<Music size={64} className="mx-auto text-gray-600 mb-4" />
|
||||||
|
<h2 className="text-2xl font-bold text-gray-400 mb-2">
|
||||||
|
Empty playlist
|
||||||
|
</h2>
|
||||||
|
<p className="text-gray-500">
|
||||||
|
Add songs from the home page
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div>
|
||||||
|
<h2 className="text-2xl font-bold text-white mb-6">
|
||||||
|
{selectedPlaylist.name}
|
||||||
|
</h2>
|
||||||
|
<div className="grid grid-cols-1 xs:grid-cols-2 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 gap-4 sm:gap-6">
|
||||||
|
{selectedPlaylist.playlistSongs.map((ps) => (
|
||||||
|
<SongCard
|
||||||
|
key={ps.id}
|
||||||
|
song={ps.song}
|
||||||
|
onPlay={handlePlaySong}
|
||||||
|
onDelete={handleRemoveSongFromPlaylist}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
/* Custom slider styles for PlayerBar */
|
||||||
|
input[type='range'] {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
appearance: none;
|
||||||
|
background: transparent;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Webkit (Chrome, Safari, Edge) */
|
||||||
|
input[type='range']::-webkit-slider-thumb {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
appearance: none;
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #ffffff;
|
||||||
|
cursor: pointer;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.2s;
|
||||||
|
position: relative;
|
||||||
|
z-index: 20;
|
||||||
|
border: 2px solid #ffffff;
|
||||||
|
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
|
||||||
|
margin-top: -6.5px; /* (14px - 1px) / 2 = centra el thumb de 14px con la barra de 1px */
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type='range']:hover::-webkit-slider-thumb,
|
||||||
|
input[type='range']:active::-webkit-slider-thumb {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type='range']::-webkit-slider-runnable-track {
|
||||||
|
background: transparent;
|
||||||
|
height: 1px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Firefox */
|
||||||
|
input[type='range']::-moz-range-thumb {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #ffffff;
|
||||||
|
cursor: pointer;
|
||||||
|
border: 2px solid #ffffff;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.2s;
|
||||||
|
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type='range']:hover::-moz-range-thumb,
|
||||||
|
input[type='range']:active::-moz-range-thumb {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type='range']::-moz-range-track {
|
||||||
|
background: transparent;
|
||||||
|
height: 1px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { usePlayer } from './PlayerContext'
|
||||||
|
import PlayerBar from './PlayerBar'
|
||||||
|
|
||||||
|
export default function GlobalPlayer() {
|
||||||
|
const { currentSong, currentIndex, playlist, isPlaylist, isShuffled, playNext, playPrevious, toggleShuffle } = usePlayer()
|
||||||
|
|
||||||
|
const nextSong = currentIndex < playlist.length - 1 ? playlist[currentIndex + 1] : null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PlayerBar
|
||||||
|
currentSong={currentSong}
|
||||||
|
onNext={currentIndex < playlist.length - 1 ? playNext : undefined}
|
||||||
|
onPrevious={currentIndex > 0 ? playPrevious : undefined}
|
||||||
|
isPlaylist={isPlaylist}
|
||||||
|
isShuffled={isShuffled}
|
||||||
|
onToggleShuffle={isPlaylist ? toggleShuffle : undefined}
|
||||||
|
nextSong={nextSong}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { Plus } from 'lucide-react'
|
||||||
|
|
||||||
|
interface HeaderProps {
|
||||||
|
onAddSong: (url: string) => void
|
||||||
|
isLoading?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Header({ onAddSong, isLoading }: HeaderProps) {
|
||||||
|
const [url, setUrl] = useState('')
|
||||||
|
|
||||||
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
if (url.trim()) {
|
||||||
|
onAddSong(url)
|
||||||
|
setUrl('')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<header className="bg-gradient-to-b from-gray-900 to-black border-b border-gray-800 p-3 sm:p-6 flex-shrink-0">
|
||||||
|
<div className="max-w-7xl mx-auto">
|
||||||
|
<form onSubmit={handleSubmit} className="flex flex-col sm:flex-row gap-2 sm:gap-3">
|
||||||
|
<div className="relative flex-1">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={url}
|
||||||
|
onChange={(e) => setUrl(e.target.value)}
|
||||||
|
placeholder="Paste YouTube URL here..."
|
||||||
|
className="w-full bg-gray-800 text-white px-3 py-2 sm:px-4 sm:py-3 rounded-lg focus:outline-none focus:ring-2 focus:ring-green-500 placeholder-gray-500 text-sm sm:text-base"
|
||||||
|
disabled={isLoading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={!url.trim() || isLoading}
|
||||||
|
className="px-4 py-2 sm:px-6 sm:py-3 bg-green-500 text-white rounded-lg hover:bg-green-600 disabled:bg-gray-700 disabled:text-gray-500 transition-colors font-medium whitespace-nowrap text-sm sm:text-base"
|
||||||
|
>
|
||||||
|
{isLoading ? 'Adding...' : 'Add Song'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { usePathname } from 'next/navigation'
|
||||||
|
import Link from 'next/link'
|
||||||
|
import { Home, ListMusic, Github, Star } from 'lucide-react'
|
||||||
|
|
||||||
|
export default function Navbar() {
|
||||||
|
const pathname = usePathname()
|
||||||
|
|
||||||
|
const isActive = (path: string) => pathname === path
|
||||||
|
|
||||||
|
return (
|
||||||
|
<nav className="bg-gray-900 border-b border-gray-800 sticky top-0 z-40 flex-shrink-0">
|
||||||
|
<div className="px-3 sm:px-6 lg:px-8">
|
||||||
|
<div className="flex items-center justify-between h-14 sm:h-16">
|
||||||
|
{/* Logo */}
|
||||||
|
<div className="flex items-center gap-4 sm:gap-8">
|
||||||
|
<Link href="/" className="flex items-center gap-2">
|
||||||
|
|
||||||
|
<svg className="w-auto h-8" viewBox="0 0 422 189" version="1.1" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<g id="#00c940ff">
|
||||||
|
<path fill="#00c940" opacity="1.00" d=" M 0.00 0.00 L 46.72 0.00 C 46.75 39.02 46.74 78.03 46.72 117.05 C 46.99 128.33 55.58 138.66 66.59 141.08 C 76.60 143.52 87.86 139.21 93.57 130.61 C 99.13 122.70 99.58 111.60 94.71 103.26 C 90.12 94.93 80.52 89.75 71.05 90.35 C 63.71 90.64 56.84 94.45 52.17 100.03 C 52.12 82.24 52.24 64.44 52.11 46.65 C 68.19 41.69 85.99 42.83 101.43 49.46 C 118.40 56.65 132.38 70.59 139.64 87.53 C 145.34 101.19 147.90 116.65 144.09 131.17 C 140.50 149.07 129.77 165.35 114.85 175.85 C 104.18 183.40 91.46 188.03 78.42 189.00 L 68.38 189.00 C 50.50 188.17 33.20 180.08 20.74 167.29 C 8.23 154.64 0.86 137.27 0.00 119.54 L 0.00 0.00 Z" />
|
||||||
|
</g>
|
||||||
|
<g id="#ffffffff">
|
||||||
|
<path fill="#ffffff" opacity="1.00" d=" M 156.14 77.98 C 156.93 77.25 157.66 76.11 158.93 76.38 C 163.19 76.33 167.45 76.40 171.71 76.37 C 172.11 76.77 172.91 77.57 173.31 77.97 C 173.32 104.99 173.31 132.01 173.31 159.03 C 172.91 159.43 172.10 160.23 171.69 160.63 C 167.43 160.60 163.16 160.67 158.89 160.62 C 157.62 160.90 156.91 159.70 156.13 158.97 C 156.28 139.77 156.10 120.58 156.22 101.38 C 159.90 98.52 163.49 95.55 167.11 92.63 C 163.59 91.90 158.71 94.27 156.14 90.99 C 156.21 86.66 156.21 82.32 156.14 77.98 Z" />
|
||||||
|
<path fill="#ffffff" opacity="1.00" d=" M 183.29 76.34 C 205.47 76.39 227.65 76.40 249.82 76.33 C 250.41 76.89 251.00 77.44 251.59 78.00 C 251.55 82.34 251.55 86.67 251.59 91.00 C 251.00 91.56 250.41 92.11 249.82 92.67 C 241.55 92.58 233.27 92.65 225.00 92.63 C 225.02 114.75 224.95 136.88 225.04 159.01 C 224.67 159.37 223.94 160.09 223.58 160.45 C 218.87 160.87 214.11 160.52 209.38 160.60 C 209.00 160.20 208.25 159.39 207.87 158.98 C 207.87 140.29 207.88 121.60 207.87 102.90 C 207.41 101.35 208.92 100.63 209.85 99.79 C 212.84 97.43 215.80 95.02 218.76 92.63 C 206.93 92.64 195.09 92.60 183.26 92.65 C 182.83 92.25 181.98 91.45 181.56 91.05 C 181.56 86.68 181.56 82.31 181.55 77.94 C 181.99 77.54 182.86 76.74 183.29 76.34 Z" />
|
||||||
|
<path fill="#ffffff" opacity="1.00" d=" M 260.14 77.96 C 260.94 77.30 261.63 76.11 262.87 76.38 C 267.14 76.33 271.41 76.40 275.68 76.36 C 276.09 76.76 276.91 77.56 277.31 77.96 C 277.31 104.99 277.31 132.01 277.31 159.04 C 276.90 159.44 276.08 160.24 275.67 160.64 C 271.41 160.59 267.15 160.67 262.89 160.62 C 261.62 160.87 260.90 159.72 260.13 158.98 C 260.27 139.78 260.10 120.58 260.22 101.39 C 263.89 98.52 267.49 95.56 271.11 92.63 C 267.58 91.89 262.70 94.29 260.13 90.98 C 260.22 86.64 260.20 82.30 260.14 77.96 Z" />
|
||||||
|
<path fill="#ffffff" opacity="1.00" d=" M 290.15 77.98 C 290.96 77.12 291.79 76.11 293.13 76.37 C 310.24 76.39 327.35 76.32 344.46 76.41 C 344.83 76.81 345.57 77.60 345.94 78.00 C 345.94 82.33 345.94 86.66 345.94 90.99 C 345.57 91.39 344.83 92.19 344.46 92.59 C 332.08 92.67 319.70 92.61 307.33 92.62 C 307.31 99.87 307.31 107.13 307.33 114.38 C 316.53 114.37 325.73 114.37 334.93 114.37 C 336.14 114.13 336.85 115.30 337.67 115.95 C 337.60 120.32 337.60 124.68 337.67 129.04 C 336.85 129.67 336.17 130.85 334.98 130.63 C 325.76 130.63 316.54 130.63 307.32 130.62 C 307.30 140.09 307.32 149.56 307.31 159.04 C 306.91 159.44 306.10 160.24 305.69 160.64 C 301.42 160.59 297.15 160.67 292.89 160.62 C 291.44 160.85 290.02 159.40 290.19 157.98 C 290.20 139.12 290.16 120.26 290.21 101.40 C 293.86 98.50 297.49 95.57 301.11 92.63 C 297.57 91.93 292.85 94.19 290.16 91.09 C 290.20 86.72 290.21 82.35 290.15 77.98 Z" />
|
||||||
|
<path fill="#ffffff" opacity="1.00" d=" M 352.18 76.36 C 357.51 76.42 362.85 76.28 368.18 76.45 C 374.01 87.23 380.01 97.91 385.92 108.66 C 391.86 97.92 397.84 87.21 403.70 76.43 C 409.03 76.30 414.35 76.41 419.67 76.36 C 420.14 77.18 420.61 78.01 421.08 78.83 C 412.23 94.94 403.32 111.01 394.61 127.18 C 394.34 137.43 394.59 147.70 394.50 157.95 C 394.72 159.20 393.57 159.87 392.87 160.67 C 388.23 160.60 383.59 160.60 378.95 160.67 C 378.26 159.86 377.14 159.12 377.37 157.88 C 377.41 147.02 377.30 136.15 377.43 125.30 C 378.52 121.80 380.84 118.81 382.44 115.51 C 378.51 115.37 374.60 115.22 370.68 115.11 C 364.02 103.00 357.40 90.86 350.74 78.75 C 351.22 77.95 351.70 77.16 352.18 76.36 Z" />
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
{/* Navigation Links - Desktop */}
|
||||||
|
<div className="hidden sm:flex items-center gap-4">
|
||||||
|
<Link
|
||||||
|
href="/"
|
||||||
|
className={`flex items-center gap-2 px-4 py-2 rounded-lg transition-colors ${
|
||||||
|
isActive('/')
|
||||||
|
? 'bg-gray-800 text-white'
|
||||||
|
: 'text-gray-400 hover:text-white hover:bg-gray-800'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Home size={20} />
|
||||||
|
<span className="font-medium">Home</span>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<Link
|
||||||
|
href="/playlists"
|
||||||
|
className={`flex items-center gap-2 px-4 py-2 rounded-lg transition-colors ${
|
||||||
|
isActive('/playlists')
|
||||||
|
? 'bg-gray-800 text-white'
|
||||||
|
: 'text-gray-400 hover:text-white hover:bg-gray-800'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<ListMusic size={20} />
|
||||||
|
<span className="font-medium">Playlists</span>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* GitHub Stars Badge */}
|
||||||
|
<a
|
||||||
|
href="https://github.com/vercel/next.js"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="flex items-center gap-1.5 px-2 sm:px-4 py-1.5 sm:py-2 bg-gray-800 hover:bg-gray-700 rounded-lg transition-colors text-gray-300 hover:text-white"
|
||||||
|
>
|
||||||
|
<Github size={16} className="sm:w-5 sm:h-5" />
|
||||||
|
<span className="text-xs sm:text-sm font-medium hidden xs:inline">Star</span>
|
||||||
|
<Star size={12} className="fill-yellow-400 text-yellow-400 sm:w-4 sm:h-4" />
|
||||||
|
<span className="text-xs sm:text-sm font-semibold">120k</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Mobile Navigation */}
|
||||||
|
<div className="sm:hidden flex gap-2 pb-2">
|
||||||
|
<Link
|
||||||
|
href="/"
|
||||||
|
className={`flex-1 flex items-center justify-center gap-2 px-3 py-2 rounded-lg transition-colors ${
|
||||||
|
isActive('/')
|
||||||
|
? 'bg-gray-800 text-white'
|
||||||
|
: 'text-gray-400 hover:text-white hover:bg-gray-800'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Home size={16} />
|
||||||
|
<span className="text-xs font-medium">Home</span>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<Link
|
||||||
|
href="/playlists"
|
||||||
|
className={`flex-1 flex items-center justify-center gap-2 px-3 py-2 rounded-lg transition-colors ${
|
||||||
|
isActive('/playlists')
|
||||||
|
? 'bg-gray-800 text-white'
|
||||||
|
: 'text-gray-400 hover:text-white hover:bg-gray-800'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<ListMusic size={16} />
|
||||||
|
<span className="text-xs font-medium">Playlists</span>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,324 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState, useRef, useEffect } from 'react'
|
||||||
|
import Image from 'next/image'
|
||||||
|
import { Play, Pause, SkipBack, SkipForward, Volume2, VolumeX, Shuffle } from 'lucide-react'
|
||||||
|
|
||||||
|
interface Song {
|
||||||
|
id: string
|
||||||
|
youtubeId: string
|
||||||
|
title: string
|
||||||
|
channelName: string
|
||||||
|
thumbnail: string
|
||||||
|
duration: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PlayerBarProps {
|
||||||
|
currentSong: Song | null
|
||||||
|
onNext?: () => void
|
||||||
|
onPrevious?: () => void
|
||||||
|
isPlaylist?: boolean
|
||||||
|
isShuffled?: boolean
|
||||||
|
onToggleShuffle?: () => void
|
||||||
|
nextSong?: Song | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PlayerBar({
|
||||||
|
currentSong,
|
||||||
|
onNext,
|
||||||
|
onPrevious,
|
||||||
|
isPlaylist = false,
|
||||||
|
isShuffled = false,
|
||||||
|
onToggleShuffle,
|
||||||
|
nextSong
|
||||||
|
}: PlayerBarProps) {
|
||||||
|
const [isPlaying, setIsPlaying] = useState(false)
|
||||||
|
const [currentTime, setCurrentTime] = useState(0)
|
||||||
|
const [duration, setDuration] = useState(0)
|
||||||
|
const [volume, setVolume] = useState(1)
|
||||||
|
const [isMuted, setIsMuted] = useState(false)
|
||||||
|
const audioRef = useRef<HTMLAudioElement>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (currentSong && audioRef.current) {
|
||||||
|
const audio = audioRef.current
|
||||||
|
|
||||||
|
// Reset state when changing songs
|
||||||
|
setCurrentTime(0)
|
||||||
|
setDuration(0)
|
||||||
|
setIsPlaying(false)
|
||||||
|
|
||||||
|
audio.src = `/api/stream/${currentSong.id}`
|
||||||
|
audio.load() // Force reload to ensure metadata is loaded
|
||||||
|
|
||||||
|
// Try to get duration after a short delay to ensure metadata is loaded
|
||||||
|
const checkDuration = () => {
|
||||||
|
if (audio.duration && !isNaN(audio.duration) && isFinite(audio.duration)) {
|
||||||
|
setDuration(audio.duration)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check duration immediately and after load
|
||||||
|
const loadedHandler = () => {
|
||||||
|
checkDuration()
|
||||||
|
audio.play().then(() => {
|
||||||
|
setIsPlaying(true)
|
||||||
|
}).catch(err => console.error('Error playing:', err))
|
||||||
|
}
|
||||||
|
|
||||||
|
audio.addEventListener('loadedmetadata', loadedHandler)
|
||||||
|
|
||||||
|
// Fallback if loadedmetadata doesn't fire
|
||||||
|
const timeoutId = setTimeout(() => {
|
||||||
|
checkDuration()
|
||||||
|
if (audio.paused) {
|
||||||
|
audio.play().then(() => {
|
||||||
|
setIsPlaying(true)
|
||||||
|
}).catch(err => console.error('Error playing:', err))
|
||||||
|
}
|
||||||
|
}, 100)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
audio.removeEventListener('loadedmetadata', loadedHandler)
|
||||||
|
clearTimeout(timeoutId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [currentSong])
|
||||||
|
|
||||||
|
// Pre-load next song
|
||||||
|
useEffect(() => {
|
||||||
|
if (nextSong) {
|
||||||
|
// Pre-fetch the stream URL to populate cache
|
||||||
|
fetch(`/api/stream/${nextSong.id}`, { method: 'HEAD' }).catch(() => {})
|
||||||
|
}
|
||||||
|
}, [nextSong])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const audio = audioRef.current
|
||||||
|
if (!audio) return
|
||||||
|
|
||||||
|
const updateTime = () => setCurrentTime(audio.currentTime)
|
||||||
|
const updateDuration = () => {
|
||||||
|
if (audio.duration && !isNaN(audio.duration) && isFinite(audio.duration)) {
|
||||||
|
setDuration(audio.duration)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const handleEnded = () => {
|
||||||
|
setIsPlaying(false)
|
||||||
|
if (onNext) onNext()
|
||||||
|
}
|
||||||
|
const handleCanPlay = () => {
|
||||||
|
// Also update duration when audio can play
|
||||||
|
updateDuration()
|
||||||
|
}
|
||||||
|
|
||||||
|
audio.addEventListener('timeupdate', updateTime)
|
||||||
|
audio.addEventListener('loadedmetadata', updateDuration)
|
||||||
|
audio.addEventListener('durationchange', updateDuration)
|
||||||
|
audio.addEventListener('canplay', handleCanPlay)
|
||||||
|
audio.addEventListener('ended', handleEnded)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
audio.removeEventListener('timeupdate', updateTime)
|
||||||
|
audio.removeEventListener('loadedmetadata', updateDuration)
|
||||||
|
audio.removeEventListener('durationchange', updateDuration)
|
||||||
|
audio.removeEventListener('canplay', handleCanPlay)
|
||||||
|
audio.removeEventListener('ended', handleEnded)
|
||||||
|
}
|
||||||
|
}, [onNext])
|
||||||
|
|
||||||
|
const togglePlay = () => {
|
||||||
|
if (audioRef.current) {
|
||||||
|
if (isPlaying) {
|
||||||
|
audioRef.current.pause()
|
||||||
|
} else {
|
||||||
|
audioRef.current.play()
|
||||||
|
}
|
||||||
|
setIsPlaying(!isPlaying)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSeek = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const time = parseFloat(e.target.value)
|
||||||
|
setCurrentTime(time)
|
||||||
|
if (audioRef.current) {
|
||||||
|
audioRef.current.currentTime = time
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleVolumeChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const vol = parseFloat(e.target.value)
|
||||||
|
setVolume(vol)
|
||||||
|
if (audioRef.current) {
|
||||||
|
audioRef.current.volume = vol
|
||||||
|
}
|
||||||
|
if (vol === 0) {
|
||||||
|
setIsMuted(true)
|
||||||
|
} else {
|
||||||
|
setIsMuted(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggleMute = () => {
|
||||||
|
if (audioRef.current) {
|
||||||
|
if (isMuted) {
|
||||||
|
audioRef.current.volume = volume
|
||||||
|
setIsMuted(false)
|
||||||
|
} else {
|
||||||
|
audioRef.current.volume = 0
|
||||||
|
setIsMuted(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatTime = (time: number): string => {
|
||||||
|
if (isNaN(time)) return '0:00'
|
||||||
|
const mins = Math.floor(time / 60)
|
||||||
|
const secs = Math.floor(time % 60)
|
||||||
|
return `${mins}:${secs.toString().padStart(2, '0')}`
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!currentSong) {
|
||||||
|
return (
|
||||||
|
<div className="fixed bottom-0 left-0 right-0 bg-gray-900 border-t border-gray-800 p-4">
|
||||||
|
<div className="flex items-center justify-center text-gray-500">
|
||||||
|
Select a song to start playing
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<audio ref={audioRef} />
|
||||||
|
<div className="fixed bottom-0 left-0 right-0 bg-gray-900 border-t border-gray-800 p-2 sm:p-4">
|
||||||
|
<div className="flex flex-col gap-2 sm:gap-3">
|
||||||
|
{/* Song Info - Always on top in mobile */}
|
||||||
|
<div className="flex items-center gap-3 w-full">
|
||||||
|
<div className="relative w-12 h-12 sm:w-14 sm:h-14 rounded-md overflow-hidden flex-shrink-0">
|
||||||
|
<Image
|
||||||
|
src={currentSong.thumbnail}
|
||||||
|
alt={currentSong.title}
|
||||||
|
fill
|
||||||
|
className="object-cover"
|
||||||
|
unoptimized
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="text-white text-sm font-semibold truncate">
|
||||||
|
{currentSong.title}
|
||||||
|
</p>
|
||||||
|
<p className="text-gray-400 text-xs truncate">
|
||||||
|
{currentSong.channelName}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Volume - Desktop only, on the right */}
|
||||||
|
<div className="hidden sm:flex items-center gap-2">
|
||||||
|
<button onClick={toggleMute} className="text-gray-400 hover:text-white">
|
||||||
|
{isMuted || volume === 0 ? (
|
||||||
|
<VolumeX size={20} />
|
||||||
|
) : (
|
||||||
|
<Volume2 size={20} />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
<div className="w-24 relative h-5 flex items-center group">
|
||||||
|
<div className="absolute w-full h-1 bg-gray-600 rounded-full" />
|
||||||
|
<div
|
||||||
|
className="absolute h-1 bg-white rounded-full pointer-events-none"
|
||||||
|
style={{ width: `${(isMuted ? 0 : volume) * 100}%` }}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min="0"
|
||||||
|
max="1"
|
||||||
|
step="0.01"
|
||||||
|
value={isMuted ? 0 : volume}
|
||||||
|
onChange={handleVolumeChange}
|
||||||
|
className="absolute w-full h-5 appearance-none bg-transparent cursor-pointer z-10"
|
||||||
|
style={{
|
||||||
|
background: 'transparent'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Controls - Centered */}
|
||||||
|
<div className="flex flex-col items-center gap-2 w-full">
|
||||||
|
<div className="flex items-center gap-4 justify-center">
|
||||||
|
{isPlaylist && onToggleShuffle && (
|
||||||
|
<button
|
||||||
|
onClick={onToggleShuffle}
|
||||||
|
className={`transition-colors ${
|
||||||
|
isShuffled
|
||||||
|
? 'text-green-500 hover:text-green-400'
|
||||||
|
: 'text-gray-400 hover:text-white'
|
||||||
|
}`}
|
||||||
|
title={isShuffled ? 'Disable shuffle' : 'Enable shuffle'}
|
||||||
|
>
|
||||||
|
<Shuffle size={18} className="sm:w-5 sm:h-5" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={onPrevious}
|
||||||
|
className="text-gray-400 hover:text-white transition-colors disabled:opacity-50"
|
||||||
|
disabled={!onPrevious}
|
||||||
|
>
|
||||||
|
<SkipBack size={20} className="sm:w-6 sm:h-6" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={togglePlay}
|
||||||
|
className="bg-white rounded-full p-2 sm:p-2.5 hover:scale-110 transition-transform"
|
||||||
|
>
|
||||||
|
{isPlaying ? (
|
||||||
|
<Pause size={20} fill="black" className="text-black sm:w-6 sm:h-6" />
|
||||||
|
) : (
|
||||||
|
<Play size={20} fill="black" className="text-black ml-0.5 sm:w-6 sm:h-6" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={onNext}
|
||||||
|
className="text-gray-400 hover:text-white transition-colors disabled:opacity-50"
|
||||||
|
disabled={!onNext}
|
||||||
|
>
|
||||||
|
<SkipForward size={20} className="sm:w-6 sm:h-6" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Progress Bar */}
|
||||||
|
<div className="w-full flex items-center gap-2">
|
||||||
|
<span className="text-xs text-gray-400 w-8 sm:w-10 text-right">
|
||||||
|
{formatTime(currentTime)}
|
||||||
|
</span>
|
||||||
|
<div className="flex-1 relative h-5 flex items-center group">
|
||||||
|
<div className="absolute w-full h-1 bg-gray-600 rounded-full" />
|
||||||
|
<div
|
||||||
|
className="absolute h-1 bg-green-500 rounded-full pointer-events-none"
|
||||||
|
style={{ width: `${duration > 0 ? (currentTime / duration) * 100 : 0}%` }}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min="0"
|
||||||
|
max={duration || 0}
|
||||||
|
value={currentTime}
|
||||||
|
onChange={handleSeek}
|
||||||
|
className="absolute w-full h-5 appearance-none bg-transparent cursor-pointer z-10"
|
||||||
|
style={{
|
||||||
|
background: 'transparent'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span className="text-xs text-gray-400 w-8 sm:w-10">
|
||||||
|
{formatTime(duration)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { createContext, useContext, useState, ReactNode } from 'react'
|
||||||
|
|
||||||
|
interface Song {
|
||||||
|
id: string
|
||||||
|
youtubeId: string
|
||||||
|
title: string
|
||||||
|
channelName: string
|
||||||
|
thumbnail: string
|
||||||
|
duration: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PlayerContextType {
|
||||||
|
currentSong: Song | null
|
||||||
|
currentIndex: number
|
||||||
|
playlist: Song[]
|
||||||
|
isPlaylist: boolean
|
||||||
|
isShuffled: boolean
|
||||||
|
playSong: (song: Song, index?: number, songList?: Song[], fromPlaylist?: boolean) => void
|
||||||
|
playNext: () => void
|
||||||
|
playPrevious: () => void
|
||||||
|
toggleShuffle: () => void
|
||||||
|
setPlaylist: (songs: Song[]) => void
|
||||||
|
clearPlayer: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const PlayerContext = createContext<PlayerContextType | undefined>(undefined)
|
||||||
|
|
||||||
|
export function PlayerProvider({ children }: { children: ReactNode }) {
|
||||||
|
const [currentSong, setCurrentSong] = useState<Song | null>(null)
|
||||||
|
const [currentIndex, setCurrentIndex] = useState<number>(-1)
|
||||||
|
const [playlist, setPlaylistState] = useState<Song[]>([])
|
||||||
|
const [isPlaylist, setIsPlaylist] = useState(false)
|
||||||
|
const [isShuffled, setIsShuffled] = useState(false)
|
||||||
|
const [originalOrder, setOriginalOrder] = useState<Song[]>([])
|
||||||
|
|
||||||
|
const playSong = (song: Song, index: number = 0, songList: Song[] = [], fromPlaylist: boolean = false) => {
|
||||||
|
setCurrentSong(song)
|
||||||
|
setCurrentIndex(index)
|
||||||
|
setPlaylistState(songList)
|
||||||
|
setIsPlaylist(fromPlaylist)
|
||||||
|
if (fromPlaylist) {
|
||||||
|
setOriginalOrder(songList)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const playNext = () => {
|
||||||
|
if (currentIndex < playlist.length - 1) {
|
||||||
|
const nextIndex = currentIndex + 1
|
||||||
|
setCurrentSong(playlist[nextIndex])
|
||||||
|
setCurrentIndex(nextIndex)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const playPrevious = () => {
|
||||||
|
if (currentIndex > 0) {
|
||||||
|
const prevIndex = currentIndex - 1
|
||||||
|
setCurrentSong(playlist[prevIndex])
|
||||||
|
setCurrentIndex(prevIndex)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggleShuffle = () => {
|
||||||
|
if (!currentSong || !isPlaylist) return
|
||||||
|
|
||||||
|
if (!isShuffled) {
|
||||||
|
// Enable shuffle
|
||||||
|
const currentSongData = playlist[currentIndex]
|
||||||
|
const otherSongs = playlist.filter((_, i) => i !== currentIndex)
|
||||||
|
|
||||||
|
// Shuffle other songs
|
||||||
|
for (let i = otherSongs.length - 1; i > 0; i--) {
|
||||||
|
const j = Math.floor(Math.random() * (i + 1))
|
||||||
|
;[otherSongs[i], otherSongs[j]] = [otherSongs[j], otherSongs[i]]
|
||||||
|
}
|
||||||
|
|
||||||
|
const shuffledPlaylist = [currentSongData, ...otherSongs]
|
||||||
|
setPlaylistState(shuffledPlaylist)
|
||||||
|
setCurrentIndex(0)
|
||||||
|
setIsShuffled(true)
|
||||||
|
} else {
|
||||||
|
// Disable shuffle - restore original order
|
||||||
|
const currentSongId = currentSong.id
|
||||||
|
const originalIndex = originalOrder.findIndex(s => s.id === currentSongId)
|
||||||
|
setPlaylistState(originalOrder)
|
||||||
|
setCurrentIndex(originalIndex)
|
||||||
|
setIsShuffled(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const setPlaylist = (songs: Song[]) => {
|
||||||
|
setPlaylistState(songs)
|
||||||
|
}
|
||||||
|
|
||||||
|
const clearPlayer = () => {
|
||||||
|
setCurrentSong(null)
|
||||||
|
setCurrentIndex(-1)
|
||||||
|
setPlaylistState([])
|
||||||
|
setIsPlaylist(false)
|
||||||
|
setIsShuffled(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PlayerContext.Provider
|
||||||
|
value={{
|
||||||
|
currentSong,
|
||||||
|
currentIndex,
|
||||||
|
playlist,
|
||||||
|
isPlaylist,
|
||||||
|
isShuffled,
|
||||||
|
playSong,
|
||||||
|
playNext,
|
||||||
|
playPrevious,
|
||||||
|
toggleShuffle,
|
||||||
|
setPlaylist,
|
||||||
|
clearPlayer,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</PlayerContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function usePlayer() {
|
||||||
|
const context = useContext(PlayerContext)
|
||||||
|
if (context === undefined) {
|
||||||
|
throw new Error('usePlayer must be used within a PlayerProvider')
|
||||||
|
}
|
||||||
|
return context
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { Home, ListMusic } from 'lucide-react'
|
||||||
|
import Link from 'next/link'
|
||||||
|
|
||||||
|
export default function Sidebar() {
|
||||||
|
return (
|
||||||
|
<aside className="w-64 bg-black border-r border-gray-800 p-6 flex flex-col">
|
||||||
|
<div className="mb-8">
|
||||||
|
<h1 className="text-2xl font-bold text-white">Bitify</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<nav className="flex flex-col gap-4">
|
||||||
|
<Link
|
||||||
|
href="/"
|
||||||
|
className="flex items-center gap-3 text-gray-300 hover:text-white transition-colors"
|
||||||
|
>
|
||||||
|
<Home size={24} />
|
||||||
|
<span>Home</span>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<Link
|
||||||
|
href="/playlists"
|
||||||
|
className="flex items-center gap-3 text-gray-300 hover:text-white transition-colors"
|
||||||
|
>
|
||||||
|
<ListMusic size={24} />
|
||||||
|
<span>Playlists</span>
|
||||||
|
</Link>
|
||||||
|
</nav>
|
||||||
|
</aside>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState } from 'react'
|
||||||
|
import Image from 'next/image'
|
||||||
|
import { Play, Trash2, ListPlus } from 'lucide-react'
|
||||||
|
|
||||||
|
interface Song {
|
||||||
|
id: string
|
||||||
|
youtubeId: string
|
||||||
|
title: string
|
||||||
|
channelName: string
|
||||||
|
thumbnail: string
|
||||||
|
duration: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Playlist {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SongCardProps {
|
||||||
|
song: Song
|
||||||
|
onPlay: (song: Song) => void
|
||||||
|
onDelete: (songId: string) => void
|
||||||
|
playlists?: Playlist[]
|
||||||
|
onAddToPlaylist?: (songId: string, playlistId: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDuration(seconds: number): string {
|
||||||
|
const mins = Math.floor(seconds / 60)
|
||||||
|
const secs = seconds % 60
|
||||||
|
return `${mins}:${secs.toString().padStart(2, '0')}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SongCard({ song, onPlay, onDelete, playlists, onAddToPlaylist }: SongCardProps) {
|
||||||
|
const [showPlaylistMenu, setShowPlaylistMenu] = useState(false)
|
||||||
|
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
|
||||||
|
|
||||||
|
const handleDelete = (e: React.MouseEvent) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
setShowDeleteConfirm(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const confirmDelete = (e: React.MouseEvent) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
onDelete(song.id)
|
||||||
|
setShowDeleteConfirm(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
const cancelDelete = (e: React.MouseEvent) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
setShowDeleteConfirm(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleAddToPlaylist = (e: React.MouseEvent, playlistId: string) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
if (onAddToPlaylist) {
|
||||||
|
onAddToPlaylist(song.id, playlistId)
|
||||||
|
}
|
||||||
|
setShowPlaylistMenu(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
const togglePlaylistMenu = (e: React.MouseEvent) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
setShowPlaylistMenu(!showPlaylistMenu)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-gray-800 rounded-lg p-4 hover:bg-gray-700 transition-colors cursor-pointer group relative">
|
||||||
|
{/* Delete confirmation modal */}
|
||||||
|
{showDeleteConfirm && (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50"
|
||||||
|
onClick={cancelDelete}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="bg-gray-900 rounded-lg p-6 max-w-sm w-full mx-4 border border-gray-700"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<h3 className="text-white text-lg font-semibold mb-2">Delete song?</h3>
|
||||||
|
<p className="text-gray-400 mb-6">Are you sure you want to delete "{song.title}"?</p>
|
||||||
|
<div className="flex gap-3 justify-end">
|
||||||
|
<button
|
||||||
|
onClick={cancelDelete}
|
||||||
|
className="px-4 py-2 cursor-pointer bg-gray-700 text-white rounded-lg hover:bg-gray-600 transition-colors"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={confirmDelete}
|
||||||
|
className="px-4 py-2 cursor-pointer bg-red-500 text-white rounded-lg hover:bg-red-600 transition-colors"
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="absolute top-2 right-2 z-10 flex gap-2">
|
||||||
|
{playlists && playlists.length > 0 && onAddToPlaylist && (
|
||||||
|
<div className="relative">
|
||||||
|
<button
|
||||||
|
onClick={togglePlaylistMenu}
|
||||||
|
className="bg-green-500 cursor-pointer rounded-full p-1.5 sm:opacity-0 sm:group-hover:opacity-100 transition-opacity hover:bg-green-600"
|
||||||
|
title="Add to playlist"
|
||||||
|
>
|
||||||
|
<ListPlus size={16} className="text-white" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{showPlaylistMenu && (
|
||||||
|
<div className="absolute right-0 mt-2 w-48 bg-gray-900 border border-gray-700 rounded-lg shadow-lg py-1 max-h-60 overflow-y-auto z-20">
|
||||||
|
{playlists.map((playlist) => (
|
||||||
|
<button
|
||||||
|
key={playlist.id}
|
||||||
|
onClick={(e) => handleAddToPlaylist(e, playlist.id)}
|
||||||
|
className="w-full cursor-pointer text-left px-4 py-2 text-sm text-white hover:bg-gray-800 transition-colors"
|
||||||
|
>
|
||||||
|
{playlist.name}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={handleDelete}
|
||||||
|
className="bg-red-500 cursor-pointer rounded-full p-1.5 sm:opacity-0 sm:group-hover:opacity-100 transition-opacity hover:bg-red-600"
|
||||||
|
title="Delete song"
|
||||||
|
>
|
||||||
|
<Trash2 size={16} className="text-white" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="relative mb-4 w-full aspect-video rounded-md overflow-hidden bg-gray-900">
|
||||||
|
<Image
|
||||||
|
src={song.thumbnail}
|
||||||
|
alt={song.title}
|
||||||
|
fill
|
||||||
|
className="object-cover"
|
||||||
|
unoptimized
|
||||||
|
/>
|
||||||
|
<div className="absolute inset-0 bg-transparent bg-opacity-0 group-hover:bg-opacity-40 transition-all flex items-center justify-center">
|
||||||
|
<button
|
||||||
|
onClick={() => onPlay(song)}
|
||||||
|
className="opacity-0 cursor-pointer group-hover:opacity-100 transition-opacity bg-green-500 rounded-full p-3 hover:bg-green-600 hover:scale-110 transform"
|
||||||
|
>
|
||||||
|
<Play size={24} fill="white" className="text-white ml-0.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3 className="text-white font-semibold mb-1 truncate" title={song.title}>
|
||||||
|
{song.title}
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<p className="text-gray-400 text-sm truncate flex-1" title={song.channelName}>
|
||||||
|
{song.channelName}
|
||||||
|
</p>
|
||||||
|
<span className="text-gray-500 text-xs ml-2">
|
||||||
|
{formatDuration(song.duration)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { createContext, useContext, useState, ReactNode } from 'react'
|
||||||
|
import { X, CheckCircle, AlertCircle, Info } from 'lucide-react'
|
||||||
|
|
||||||
|
type ToastType = 'success' | 'error' | 'info'
|
||||||
|
|
||||||
|
interface Toast {
|
||||||
|
id: number
|
||||||
|
message: string
|
||||||
|
type: ToastType
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ToastContextType {
|
||||||
|
showToast: (message: string, type?: ToastType) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const ToastContext = createContext<ToastContextType | undefined>(undefined)
|
||||||
|
|
||||||
|
export function ToastProvider({ children }: { children: ReactNode }) {
|
||||||
|
const [toasts, setToasts] = useState<Toast[]>([])
|
||||||
|
|
||||||
|
const showToast = (message: string, type: ToastType = 'info') => {
|
||||||
|
const id = Date.now()
|
||||||
|
setToasts((prev) => [...prev, { id, message, type }])
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
setToasts((prev) => prev.filter((toast) => toast.id !== id))
|
||||||
|
}, 3000)
|
||||||
|
}
|
||||||
|
|
||||||
|
const removeToast = (id: number) => {
|
||||||
|
setToasts((prev) => prev.filter((toast) => toast.id !== id))
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ToastContext.Provider value={{ showToast }}>
|
||||||
|
{children}
|
||||||
|
<div className="fixed bottom-24 right-6 z-50 flex flex-col gap-2">
|
||||||
|
{toasts.map((toast) => (
|
||||||
|
<div
|
||||||
|
key={toast.id}
|
||||||
|
className={`flex items-center gap-3 px-4 py-3 rounded-lg shadow-lg backdrop-blur-sm animate-slide-in-right ${
|
||||||
|
toast.type === 'success'
|
||||||
|
? 'bg-green-500/90 text-white'
|
||||||
|
: toast.type === 'error'
|
||||||
|
? 'bg-red-500/90 text-white'
|
||||||
|
: 'bg-blue-500/90 text-white'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{toast.type === 'success' && <CheckCircle size={20} />}
|
||||||
|
{toast.type === 'error' && <AlertCircle size={20} />}
|
||||||
|
{toast.type === 'info' && <Info size={20} />}
|
||||||
|
<p className="flex-1">{toast.message}</p>
|
||||||
|
<button
|
||||||
|
onClick={() => removeToast(toast.id)}
|
||||||
|
className="hover:opacity-70 transition-opacity"
|
||||||
|
>
|
||||||
|
<X size={18} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</ToastContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useToast() {
|
||||||
|
const context = useContext(ToastContext)
|
||||||
|
if (!context) {
|
||||||
|
throw new Error('useToast must be used within ToastProvider')
|
||||||
|
}
|
||||||
|
return context
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
version: '3.8'
|
||||||
|
|
||||||
|
services:
|
||||||
|
bitify:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
container_name: bitify
|
||||||
|
ports:
|
||||||
|
- "3000:3000"
|
||||||
|
volumes:
|
||||||
|
# Persist database
|
||||||
|
- ./prisma:/app/prisma
|
||||||
|
# Optional: persist yt-dlp cache
|
||||||
|
- yt-dlp-cache:/home/nextjs/.cache/yt-dlp
|
||||||
|
environment:
|
||||||
|
- NODE_ENV=production
|
||||||
|
- DATABASE_URL=file:/app/prisma/dev.db
|
||||||
|
restart: unless-stopped
|
||||||
|
command: sh -c "npx prisma db push && node server.js"
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
yt-dlp-cache:
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
interface CacheEntry {
|
||||||
|
url: string
|
||||||
|
timestamp: number
|
||||||
|
}
|
||||||
|
|
||||||
|
class StreamCache {
|
||||||
|
private cache = new Map<string, CacheEntry>()
|
||||||
|
private cacheDuration = 3600000 // 1 hour in milliseconds
|
||||||
|
|
||||||
|
set(youtubeId: string, url: string) {
|
||||||
|
this.cache.set(youtubeId, {
|
||||||
|
url,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
get(youtubeId: string): string | null {
|
||||||
|
const entry = this.cache.get(youtubeId)
|
||||||
|
if (!entry) return null
|
||||||
|
|
||||||
|
// Check if cache is still valid
|
||||||
|
if (Date.now() - entry.timestamp > this.cacheDuration) {
|
||||||
|
this.cache.delete(youtubeId)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return entry.url
|
||||||
|
}
|
||||||
|
|
||||||
|
clear() {
|
||||||
|
this.cache.clear()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const streamCache = new StreamCache()
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { PrismaClient } from '@prisma/client'
|
||||||
|
|
||||||
|
const globalForPrisma = globalThis as unknown as {
|
||||||
|
prisma: PrismaClient | undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
export const prisma = globalForPrisma.prisma ?? new PrismaClient()
|
||||||
|
|
||||||
|
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma
|
||||||
+148
@@ -0,0 +1,148 @@
|
|||||||
|
import { spawn } from 'child_process'
|
||||||
|
import { join } from 'path'
|
||||||
|
|
||||||
|
// Detect yt-dlp command based on environment
|
||||||
|
const getYtDlpCommand = () => {
|
||||||
|
// In development on Windows with venv
|
||||||
|
if (process.platform === 'win32' && process.env.NODE_ENV !== 'production') {
|
||||||
|
return join(process.cwd(), '.venv', 'Scripts', 'yt-dlp.exe')
|
||||||
|
}
|
||||||
|
// In production or Unix systems
|
||||||
|
return 'yt-dlp'
|
||||||
|
}
|
||||||
|
|
||||||
|
const YT_DLP = getYtDlpCommand()
|
||||||
|
|
||||||
|
// Helper function to execute yt-dlp with arguments
|
||||||
|
async function executeYtDlp(args: string[], timeout: number = 30000): Promise<string> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
console.log('[yt-dlp] Executing:', YT_DLP, 'with args:', args)
|
||||||
|
const childProcess = spawn(YT_DLP, args)
|
||||||
|
let stdout = ''
|
||||||
|
let stderr = ''
|
||||||
|
let isResolved = false
|
||||||
|
|
||||||
|
// Set timeout
|
||||||
|
const timeoutId = setTimeout(() => {
|
||||||
|
if (!isResolved) {
|
||||||
|
console.error('[yt-dlp] Timeout reached, killing process')
|
||||||
|
childProcess.kill()
|
||||||
|
reject(new Error('Process timeout after ' + timeout + 'ms'))
|
||||||
|
}
|
||||||
|
}, timeout)
|
||||||
|
|
||||||
|
childProcess.stdout.on('data', (data) => {
|
||||||
|
stdout += data.toString()
|
||||||
|
})
|
||||||
|
|
||||||
|
childProcess.stderr.on('data', (data) => {
|
||||||
|
stderr += data.toString()
|
||||||
|
})
|
||||||
|
|
||||||
|
childProcess.on('close', (code) => {
|
||||||
|
if (isResolved) return
|
||||||
|
isResolved = true
|
||||||
|
clearTimeout(timeoutId)
|
||||||
|
|
||||||
|
console.log('[yt-dlp] Exit code:', code)
|
||||||
|
if (stderr) console.log('[yt-dlp] Stderr:', stderr)
|
||||||
|
if (code === 0) {
|
||||||
|
console.log('[yt-dlp] Success, output length:', stdout.length)
|
||||||
|
resolve(stdout)
|
||||||
|
} else {
|
||||||
|
console.error('[yt-dlp] Error:', stderr || `Process exited with code ${code}`)
|
||||||
|
reject(new Error(stderr || `Process exited with code ${code}`))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
childProcess.on('error', (error) => {
|
||||||
|
if (isResolved) return
|
||||||
|
isResolved = true
|
||||||
|
clearTimeout(timeoutId)
|
||||||
|
console.error('[yt-dlp] Spawn error:', error)
|
||||||
|
reject(error)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface YtDlpMetadata {
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
channel: string
|
||||||
|
thumbnail: string
|
||||||
|
duration: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getYoutubeMetadata(url: string): Promise<YtDlpMetadata> {
|
||||||
|
try {
|
||||||
|
console.log('[getYoutubeMetadata] Fetching metadata for URL:', url)
|
||||||
|
const stdout = await executeYtDlp([
|
||||||
|
'--dump-json',
|
||||||
|
'--no-warnings',
|
||||||
|
'--no-playlist', // Only extract the video, not the playlist
|
||||||
|
'--flat-playlist', // Don't extract playlist info
|
||||||
|
url
|
||||||
|
], 60000) // 60 second timeout
|
||||||
|
|
||||||
|
const data = JSON.parse(stdout)
|
||||||
|
console.log('[getYoutubeMetadata] Successfully parsed metadata for:', data.title)
|
||||||
|
console.log('[getYoutubeMetadata] Available thumbnails:', data.thumbnails?.length || 0)
|
||||||
|
|
||||||
|
// Get best thumbnail URL - prefer high quality thumbnails
|
||||||
|
let thumbnail = ''
|
||||||
|
if (data.thumbnails && Array.isArray(data.thumbnails) && data.thumbnails.length > 0) {
|
||||||
|
// Find the highest quality thumbnail
|
||||||
|
const bestThumbnail = data.thumbnails
|
||||||
|
.filter((t: any) => t.url)
|
||||||
|
.sort((a: any, b: any) => {
|
||||||
|
const widthA = a.width || 0
|
||||||
|
const widthB = b.width || 0
|
||||||
|
return widthB - widthA
|
||||||
|
})[0]
|
||||||
|
thumbnail = bestThumbnail?.url || data.thumbnails[0]?.url || ''
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback to standard YouTube thumbnail URLs
|
||||||
|
if (!thumbnail && data.id) {
|
||||||
|
thumbnail = `https://i.ytimg.com/vi/${data.id}/hqdefault.jpg`
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('[getYoutubeMetadata] Selected thumbnail URL:', thumbnail)
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: data.id,
|
||||||
|
title: data.title,
|
||||||
|
channel: data.channel || data.uploader || 'Unknown',
|
||||||
|
thumbnail,
|
||||||
|
duration: data.duration || 0,
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[getYoutubeMetadata] Error:', error)
|
||||||
|
throw new Error(`Failed to get metadata: ${error}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getStreamUrl(youtubeId: string): Promise<string> {
|
||||||
|
try {
|
||||||
|
const stdout = await executeYtDlp([
|
||||||
|
'-g',
|
||||||
|
'-f',
|
||||||
|
'bestaudio',
|
||||||
|
`https://www.youtube.com/watch?v=${youtubeId}`
|
||||||
|
])
|
||||||
|
|
||||||
|
return stdout.trim()
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(`Failed to get stream URL: ${error}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateYtDlp(): Promise<string> {
|
||||||
|
try {
|
||||||
|
const stdout = await executeYtDlp(['-U'])
|
||||||
|
return stdout.trim()
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(`Failed to update yt-dlp: ${error}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
/** @type {import('next').NextConfig} */
|
||||||
|
const nextConfig = {
|
||||||
|
images: {
|
||||||
|
remotePatterns: [
|
||||||
|
{
|
||||||
|
protocol: 'https',
|
||||||
|
hostname: 'i.ytimg.com',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
protocol: 'https',
|
||||||
|
hostname: 'img.youtube.com',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
protocol: 'https',
|
||||||
|
hostname: 'i9.ytimg.com',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
protocol: 'https',
|
||||||
|
hostname: '**.ytimg.com',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
experimental: {
|
||||||
|
serverActions: {
|
||||||
|
bodySizeLimit: '2mb',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
output: 'standalone',
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = nextConfig
|
||||||
Generated
+1800
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,30 @@
|
|||||||
|
{
|
||||||
|
"name": "bitify",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"dev": "next dev",
|
||||||
|
"build": "prisma generate && next build",
|
||||||
|
"start": "next start",
|
||||||
|
"lint": "next lint",
|
||||||
|
"prisma:generate": "prisma generate",
|
||||||
|
"prisma:push": "prisma db push"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@prisma/client": "^5.22.0",
|
||||||
|
"lucide-react": "^0.454.0",
|
||||||
|
"next": "^15.0.3",
|
||||||
|
"react": "^18.3.1",
|
||||||
|
"react-dom": "^18.3.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@tailwindcss/postcss": "^4.0.0",
|
||||||
|
"@types/node": "^22.9.0",
|
||||||
|
"@types/react": "^18.3.12",
|
||||||
|
"@types/react-dom": "^18.3.1",
|
||||||
|
"postcss": "^8.4.49",
|
||||||
|
"prisma": "^5.22.0",
|
||||||
|
"tailwindcss": "^4.0.0",
|
||||||
|
"typescript": "^5.6.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
const config = {
|
||||||
|
plugins: {
|
||||||
|
"@tailwindcss/postcss": {},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
// This is your Prisma schema file,
|
||||||
|
// learn more about it in the docs: https://pris.ly/d/prisma-schema
|
||||||
|
|
||||||
|
generator client {
|
||||||
|
provider = "prisma-client-js"
|
||||||
|
}
|
||||||
|
|
||||||
|
datasource db {
|
||||||
|
provider = "sqlite"
|
||||||
|
url = "file:./dev.db"
|
||||||
|
}
|
||||||
|
|
||||||
|
model Song {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
youtubeId String @unique
|
||||||
|
title String
|
||||||
|
channelName String
|
||||||
|
thumbnail String
|
||||||
|
duration Int // in seconds
|
||||||
|
addedAt DateTime @default(now())
|
||||||
|
isInLibrary Boolean @default(true) // If false, only exists in playlists
|
||||||
|
|
||||||
|
playlistSongs PlaylistSong[]
|
||||||
|
}
|
||||||
|
|
||||||
|
model Playlist {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
name String
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
playlistSongs PlaylistSong[]
|
||||||
|
}
|
||||||
|
|
||||||
|
model PlaylistSong {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
playlistId String
|
||||||
|
songId String
|
||||||
|
addedAt DateTime @default(now())
|
||||||
|
|
||||||
|
playlist Playlist @relation(fields: [playlistId], references: [id], onDelete: Cascade)
|
||||||
|
song Song @relation(fields: [songId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@unique([playlistId, songId])
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2017",
|
||||||
|
"lib": ["dom", "dom.iterable", "esnext"],
|
||||||
|
"allowJs": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"strict": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"module": "esnext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"jsx": "preserve",
|
||||||
|
"incremental": true,
|
||||||
|
"plugins": [
|
||||||
|
{
|
||||||
|
"name": "next"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./*"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||||
|
"exclude": ["node_modules"]
|
||||||
|
}
|
||||||
Reference in new issue
Block a user