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