This commit is contained in:
bitc0de committed 2025-12-17 13:56:50 +01:00
1 parent f4962cc138
commit 733c9b01a7
35 files changed
+4125

No files matched your search

+1
View File
@@ -0,0 +1 @@
DATABASE_URL="file:./dev.db"
+46
View File
@@ -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
View File
@@ -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"]
+19
View File
@@ -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 }
)
}
}
+62
View File
@@ -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 }
)
}
}
+52
View File
@@ -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 }
)
}
}
+51
View File
@@ -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 }
)
}
}
+27
View File
@@ -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 }
)
}
}
+80
View File
@@ -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 }
)
}
}
+47
View File
@@ -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 }
)
}
}
+18
View File
@@ -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;
}
+7
View File
@@ -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

+29
View File
@@ -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
View File
@@ -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>
)
}
+313
View File
@@ -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>
)
}
+61
View File
@@ -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;
}
+22
View File
@@ -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}
/>
)
}
+47
View File
@@ -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>
)
}
+107
View File
@@ -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>
)
}
+324
View File
@@ -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>
</>
)
}
+131
View File
@@ -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
}
+32
View File
@@ -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>
)
}
+168
View File
@@ -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>
)
}
+74
View File
@@ -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
}
+23
View File
@@ -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:
+35
View File
@@ -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()
+9
View File
@@ -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
View File
@@ -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}`)
}
}
+31
View File
@@ -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
+1800
View File
File diff suppressed because it is too large. Load diff
+30
View File
@@ -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"
}
}
+7
View File
@@ -0,0 +1,7 @@
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;
+44
View File
@@ -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])
}
+27
View File
@@ -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"]
}