From 733c9b01a7e270b38551e03d26f5c4ddf1ed015e Mon Sep 17 00:00:00 2001 From: bitc0de Date: Wed, 17 Dec 2025 13:56:50 +0100 Subject: [PATCH] updload --- .env.example | 1 + .gitignore | 46 + Dockerfile | 57 + app/api/admin/update/route.ts | 19 + app/api/playlists/[id]/route.ts | 62 + .../playlists/[id]/songs/[songId]/route.ts | 29 + app/api/playlists/[id]/songs/route.ts | 52 + app/api/playlists/route.ts | 51 + app/api/songs/[id]/route.ts | 27 + app/api/songs/route.ts | 80 + app/api/stream/[id]/route.ts | 47 + app/globals.css | 18 + app/icon.svg | 7 + app/layout.tsx | 29 + app/page.tsx | 167 ++ app/playlists/page.tsx | 313 +++ app/slider.css | 61 + components/GlobalPlayer.tsx | 22 + components/Header.tsx | 47 + components/Navbar.tsx | 107 + components/PlayerBar.tsx | 324 +++ components/PlayerContext.tsx | 131 ++ components/Sidebar.tsx | 32 + components/SongCard.tsx | 168 ++ components/Toast.tsx | 74 + docker-compose.yml | 23 + lib/cache.ts | 35 + lib/prisma.ts | 9 + lib/ytdlp.ts | 148 ++ next.config.js | 31 + package-lock.json | 1800 +++++++++++++++++ package.json | 30 + postcss.config.mjs | 7 + prisma/schema.prisma | 44 + tsconfig.json | 27 + 35 files changed, 4125 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 app/api/admin/update/route.ts create mode 100644 app/api/playlists/[id]/route.ts create mode 100644 app/api/playlists/[id]/songs/[songId]/route.ts create mode 100644 app/api/playlists/[id]/songs/route.ts create mode 100644 app/api/playlists/route.ts create mode 100644 app/api/songs/[id]/route.ts create mode 100644 app/api/songs/route.ts create mode 100644 app/api/stream/[id]/route.ts create mode 100644 app/globals.css create mode 100644 app/icon.svg create mode 100644 app/layout.tsx create mode 100644 app/page.tsx create mode 100644 app/playlists/page.tsx create mode 100644 app/slider.css create mode 100644 components/GlobalPlayer.tsx create mode 100644 components/Header.tsx create mode 100644 components/Navbar.tsx create mode 100644 components/PlayerBar.tsx create mode 100644 components/PlayerContext.tsx create mode 100644 components/Sidebar.tsx create mode 100644 components/SongCard.tsx create mode 100644 components/Toast.tsx create mode 100644 docker-compose.yml create mode 100644 lib/cache.ts create mode 100644 lib/prisma.ts create mode 100644 lib/ytdlp.ts create mode 100644 next.config.js create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 postcss.config.mjs create mode 100644 prisma/schema.prisma create mode 100644 tsconfig.json diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..d91f958 --- /dev/null +++ b/.env.example @@ -0,0 +1 @@ +DATABASE_URL="file:./dev.db" diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..bca9b18 --- /dev/null +++ b/.gitignore @@ -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/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..eb541a6 --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/app/api/admin/update/route.ts b/app/api/admin/update/route.ts new file mode 100644 index 0000000..ac55b83 --- /dev/null +++ b/app/api/admin/update/route.ts @@ -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 } + ) + } +} diff --git a/app/api/playlists/[id]/route.ts b/app/api/playlists/[id]/route.ts new file mode 100644 index 0000000..adce2e3 --- /dev/null +++ b/app/api/playlists/[id]/route.ts @@ -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 } + ) + } +} diff --git a/app/api/playlists/[id]/songs/[songId]/route.ts b/app/api/playlists/[id]/songs/[songId]/route.ts new file mode 100644 index 0000000..7e5f1c3 --- /dev/null +++ b/app/api/playlists/[id]/songs/[songId]/route.ts @@ -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 } + ) + } +} diff --git a/app/api/playlists/[id]/songs/route.ts b/app/api/playlists/[id]/songs/route.ts new file mode 100644 index 0000000..b4b942e --- /dev/null +++ b/app/api/playlists/[id]/songs/route.ts @@ -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 } + ) + } +} diff --git a/app/api/playlists/route.ts b/app/api/playlists/route.ts new file mode 100644 index 0000000..acb885c --- /dev/null +++ b/app/api/playlists/route.ts @@ -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 } + ) + } +} diff --git a/app/api/songs/[id]/route.ts b/app/api/songs/[id]/route.ts new file mode 100644 index 0000000..cbac529 --- /dev/null +++ b/app/api/songs/[id]/route.ts @@ -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 } + ) + } +} diff --git a/app/api/songs/route.ts b/app/api/songs/route.ts new file mode 100644 index 0000000..8009c1a --- /dev/null +++ b/app/api/songs/route.ts @@ -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 } + ) + } +} diff --git a/app/api/stream/[id]/route.ts b/app/api/stream/[id]/route.ts new file mode 100644 index 0000000..780d030 --- /dev/null +++ b/app/api/stream/[id]/route.ts @@ -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 } + ) + } +} diff --git a/app/globals.css b/app/globals.css new file mode 100644 index 0000000..033aef5 --- /dev/null +++ b/app/globals.css @@ -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; +} + diff --git a/app/icon.svg b/app/icon.svg new file mode 100644 index 0000000..97d2b2d --- /dev/null +++ b/app/icon.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/app/layout.tsx b/app/layout.tsx new file mode 100644 index 0000000..3dad083 --- /dev/null +++ b/app/layout.tsx @@ -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 ( + + + + + {children} + + + + + + ) +} diff --git a/app/page.tsx b/app/page.tsx new file mode 100644 index 0000000..6d63a7c --- /dev/null +++ b/app/page.tsx @@ -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([]) + const [playlists, setPlaylists] = useState([]) + 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 ( +
+ +
+ +
+ {songs.length === 0 ? ( +
+
+

+ No songs yet +

+

+ Add your first song by pasting a YouTube URL +

+
+
+ ) : ( +
+ {songs.map((song) => ( + + ))} +
+ )} +
+
+ ) +} diff --git a/app/playlists/page.tsx b/app/playlists/page.tsx new file mode 100644 index 0000000..f83aa92 --- /dev/null +++ b/app/playlists/page.tsx @@ -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([]) + const [selectedPlaylist, setSelectedPlaylist] = useState(null) + const [newPlaylistName, setNewPlaylistName] = useState('') + const [showCreateForm, setShowCreateForm] = useState(false) + const [playlistSongs, setPlaylistSongs] = useState([]) + 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 ( +
+ + +
+ {/* Playlists sidebar */} +
+
+ {!showCreateForm ? ( + + ) : ( +
+ 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 + /> +
+ + +
+
+ )} +
+ +
+ {playlists.length === 0 ? ( +

+ No playlists +

+ ) : ( + playlists.map((playlist) => ( +
+
setSelectedPlaylist(playlist)} + className="flex-1 min-w-0 cursor-pointer" + > +

+ {playlist.name} +

+

+ {playlist.playlistSongs.length} song + {playlist.playlistSongs.length !== 1 ? 's' : ''} +

+
+
+ {playlist.playlistSongs.length > 0 && ( + + )} + +
+
+ )) + )} +
+
+ + {/* Playlist content */} +
+ {!selectedPlaylist ? ( +
+
+ +

+ Select a playlist +

+

+ Or create a new one to start +

+
+
+ ) : selectedPlaylist.playlistSongs.length === 0 ? ( +
+
+ +

+ Empty playlist +

+

+ Add songs from the home page +

+
+
+ ) : ( +
+

+ {selectedPlaylist.name} +

+
+ {selectedPlaylist.playlistSongs.map((ps) => ( + + ))} +
+
+ )} +
+
+
+ ) +} diff --git a/app/slider.css b/app/slider.css new file mode 100644 index 0000000..a00b5b8 --- /dev/null +++ b/app/slider.css @@ -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; +} + diff --git a/components/GlobalPlayer.tsx b/components/GlobalPlayer.tsx new file mode 100644 index 0000000..f48e16e --- /dev/null +++ b/components/GlobalPlayer.tsx @@ -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 ( + 0 ? playPrevious : undefined} + isPlaylist={isPlaylist} + isShuffled={isShuffled} + onToggleShuffle={isPlaylist ? toggleShuffle : undefined} + nextSong={nextSong} + /> + ) +} diff --git a/components/Header.tsx b/components/Header.tsx new file mode 100644 index 0000000..b7bcd2c --- /dev/null +++ b/components/Header.tsx @@ -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 ( +
+
+
+
+ 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} + /> +
+ +
+
+
+ ) +} diff --git a/components/Navbar.tsx b/components/Navbar.tsx new file mode 100644 index 0000000..9ef2cb7 --- /dev/null +++ b/components/Navbar.tsx @@ -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 ( + + ) +} diff --git a/components/PlayerBar.tsx b/components/PlayerBar.tsx new file mode 100644 index 0000000..2c51097 --- /dev/null +++ b/components/PlayerBar.tsx @@ -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(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) => { + const time = parseFloat(e.target.value) + setCurrentTime(time) + if (audioRef.current) { + audioRef.current.currentTime = time + } + } + + const handleVolumeChange = (e: React.ChangeEvent) => { + 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 ( +
+
+ Select a song to start playing +
+
+ ) + } + + return ( + <> +