feat: v1.1.0 - Major UI/UX improvements and new features
- Remove confirmation dialogs for song deletion (faster workflow) - Add bulk playlist creation from YouTube playlist URLs - Display playlist tags on song cards in home page - Enable moving songs between playlists - Fix mobile UI: action buttons now appear above player bar - Add bulk song selection and deletion from home page - Improve loading states and user feedback - Update documentation and CI/CD workflow - Fix all toast messages to English
This commit is contained in:
1 parent
84ae14bfb5
commit
706d808b83
10 files changed
+1604
-245
No files matched your search
@@ -2,6 +2,8 @@ name: Build and Push Docker Image
|
|||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
tags:
|
tags:
|
||||||
- 'v*.*.*'
|
- 'v*.*.*'
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|||||||
@@ -14,26 +14,28 @@ Self-hosted YouTube music streaming application built with Next.js.
|
|||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
- 🎵 Stream music from YouTube without downloading files
|
- Stream music from YouTube without downloading files
|
||||||
- 🎨 Beautiful dark mode interface
|
- Beautiful dark mode interface
|
||||||
- 📱 Fully responsive design (mobile & desktop)
|
- Fully responsive design (mobile & desktop)
|
||||||
- 🎮 Full playback controls (play, pause, next, previous, shuffle)
|
- Full playback controls (play, pause, next, previous, shuffle)
|
||||||
- 🔊 Volume control with visual feedback
|
- Volume control with visual feedback
|
||||||
- 📊 Progress bar with seeking and time display
|
- Progress bar with seeking and time display
|
||||||
- 🎼 Create and manage playlists
|
- Create and manage playlists
|
||||||
- 🌐 Global player - music continues playing across page navigation
|
- Global player - music continues playing across page navigation
|
||||||
- 🔔 Toast notifications for user feedback
|
- Toast notifications for user feedback
|
||||||
- � Simple JSON file storage - no database setup required
|
- Simple JSON file storage - no database setup required
|
||||||
- 🐳 Easy Docker deployment
|
- Easy Docker deployment
|
||||||
|
## Recent Updates
|
||||||
|
|
||||||
## Tech Stack
|
### v1.1.0 - Latest Improvements
|
||||||
|
|
||||||
|
- ✅ **Direct song deletion**: Removed confirmation dialogs when deleting songs from home page for faster workflow
|
||||||
|
- ✅ **Bulk playlist creation**: Add entire playlists directly from YouTube playlist URLs
|
||||||
|
- ✅ **Song tags**: Display playlist tags on song cards in the home page for better organization
|
||||||
|
- ✅ **Cross-playlist song movement**: Move songs between different playlists with a single click
|
||||||
|
- ✅ **Mobile UI improvements**: Song action buttons now appear above the player bar on mobile devices
|
||||||
|
- ✅ **Bulk song selection**: Select and delete multiple songs at once from the home page for efficient management
|
||||||
|
|
||||||
- **Frontend & Backend**: Next.js 15 (App Router)
|
|
||||||
- **Styling**: Tailwind CSS v4
|
|
||||||
- **Storage**: JSON file-based database
|
|
||||||
- **Audio Streaming**: yt-dlp
|
|
||||||
- **Icons**: Lucide React
|
|
||||||
- **Containerization**: Docker
|
|
||||||
|
|
||||||
## Prerequisites
|
## Prerequisites
|
||||||
|
|
||||||
@@ -48,12 +50,27 @@ Self-hosted YouTube music streaming application built with Next.js.
|
|||||||
npm install
|
npm install
|
||||||
```
|
```
|
||||||
|
|
||||||
2. **Install yt-dlp:**
|
2. **Create Python virtual environment:**
|
||||||
|
```bash
|
||||||
|
python -m venv .venv
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Activate the virtual environment:**
|
||||||
|
- **Linux/macOS:**
|
||||||
|
```bash
|
||||||
|
source .venv/bin/activate
|
||||||
|
```
|
||||||
|
- **Windows:**
|
||||||
|
```bash
|
||||||
|
.venv\Scripts\activate
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **Install yt-dlp:**
|
||||||
```bash
|
```bash
|
||||||
pip install yt-dlp
|
pip install yt-dlp
|
||||||
```
|
```
|
||||||
|
|
||||||
3. **Run the development server:**
|
5. **Run the development server:**
|
||||||
```bash
|
```bash
|
||||||
npm run dev
|
npm run dev
|
||||||
```
|
```
|
||||||
@@ -127,59 +144,6 @@ If you prefer to build the Docker image yourself:
|
|||||||
5. **Shuffle mode**: Toggle shuffle to randomize playback order
|
5. **Shuffle mode**: Toggle shuffle to randomize playback order
|
||||||
6. **Navigate freely**: Music continues playing as you browse different pages
|
6. **Navigate freely**: Music continues playing as you browse different pages
|
||||||
|
|
||||||
## API Endpoints
|
|
||||||
|
|
||||||
- `POST /api/songs` - Add a new song from YouTube URL
|
|
||||||
- `GET /api/songs` - Get all songs from library
|
|
||||||
- `DELETE /api/songs` - Remove a song from library
|
|
||||||
- `GET /api/stream/[id]` - Get streaming URL for a song
|
|
||||||
- `GET /api/playlists` - Get all playlists
|
|
||||||
- `POST /api/playlists` - Create a new playlist
|
|
||||||
- `GET /api/playlists/[id]` - Get a specific playlist with songs
|
|
||||||
- `PUT /api/playlists/[id]` - Update playlist name
|
|
||||||
- `DELETE /api/playlists/[id]` - Delete a playlist
|
|
||||||
- `POST /api/playlists/[id]/songs` - Add a song to playlist
|
|
||||||
- `DELETE /api/playlists/[id]/songs` - Remove a song from playlist
|
|
||||||
- `POST /api/admin/update` - Update yt-dlp to the latest version
|
|
||||||
|
|
||||||
## Project Structure
|
|
||||||
|
|
||||||
```
|
|
||||||
bitify/
|
|
||||||
├── app/
|
|
||||||
│ ├── api/
|
|
||||||
│ │ ├── admin/update/
|
|
||||||
│ │ ├── playlists/
|
|
||||||
│ │ │ └── [id]/
|
|
||||||
│ │ ├── songs/
|
|
||||||
│ │ └── stream/[id]/
|
|
||||||
│ ├── playlists/
|
|
||||||
│ │ ├── [id]/
|
|
||||||
│ │ └── page.tsx
|
|
||||||
│ ├── globals.css
|
|
||||||
│ ├── icon.svg
|
|
||||||
│ ├── layout.tsx
|
|
||||||
│ ├── page.tsx
|
|
||||||
│ └── slider.css
|
|
||||||
├── components/
|
|
||||||
│ ├── GlobalPlayer.tsx
|
|
||||||
│ ├── Header.tsx
|
|
||||||
│ ├── Navbar.tsx
|
|
||||||
│ ├── PlayerBar.tsx
|
|
||||||
│ ├── PlayerContext.tsx
|
|
||||||
│ ├── SongCard.tsx
|
|
||||||
│ └── Toast.tsx
|
|
||||||
├── lib/
|
|
||||||
│ ├── db.ts
|
|
||||||
│ └── ytdlp.ts
|
|
||||||
├── data/
|
|
||||||
│ └── db.json (created automatically)
|
|
||||||
├── docker-compose.yml
|
|
||||||
├── Dockerfile
|
|
||||||
├── next.config.js
|
|
||||||
├── package.json
|
|
||||||
└── tsconfig.json
|
|
||||||
```
|
|
||||||
|
|
||||||
## Data Storage
|
## Data Storage
|
||||||
|
|
||||||
@@ -207,6 +171,3 @@ The file is created automatically on first run. To backup your library, simply c
|
|||||||
|
|
||||||
MIT
|
MIT
|
||||||
|
|
||||||
## Author
|
|
||||||
|
|
||||||
Built with ❤️ using Next.js and yt-dlp
|
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server'
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
import { db } from '@/lib/db'
|
import { db } from '@/lib/db'
|
||||||
|
import { getYoutubePlaylistMetadata } from '@/lib/ytdlp'
|
||||||
|
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
try {
|
try {
|
||||||
@@ -26,7 +27,7 @@ export async function GET() {
|
|||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const { name } = await request.json()
|
const { name, youtubeUrl } = await request.json()
|
||||||
|
|
||||||
if (!name) {
|
if (!name) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
@@ -36,9 +37,58 @@ export async function POST(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const playlist = db.createPlaylist(name)
|
const playlist = db.createPlaylist(name)
|
||||||
|
let addedSongs = 0
|
||||||
|
|
||||||
|
// If YouTube URL is provided, fetch and add songs from the playlist
|
||||||
|
if (youtubeUrl) {
|
||||||
|
try {
|
||||||
|
console.log('[API] Fetching YouTube playlist:', youtubeUrl)
|
||||||
|
const playlistSongs = await getYoutubePlaylistMetadata(youtubeUrl)
|
||||||
|
|
||||||
|
for (const songData of playlistSongs) {
|
||||||
|
try {
|
||||||
|
// Check if song already exists
|
||||||
|
let song = db.getSongByYoutubeId(songData.id)
|
||||||
|
|
||||||
|
if (!song) {
|
||||||
|
// Create new song
|
||||||
|
song = db.createSong({
|
||||||
|
youtubeId: songData.id,
|
||||||
|
title: songData.title,
|
||||||
|
channelName: songData.channel,
|
||||||
|
thumbnail: songData.thumbnail,
|
||||||
|
duration: songData.duration,
|
||||||
|
})
|
||||||
|
console.log('[API] Created song:', song.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add song to playlist
|
||||||
|
const playlistSong = db.addSongToPlaylist(playlist.id, song.id)
|
||||||
|
if (playlistSong) {
|
||||||
|
addedSongs++
|
||||||
|
console.log('[API] Added song to playlist:', playlistSong.id)
|
||||||
|
}
|
||||||
|
} catch (songError) {
|
||||||
|
console.error('[API] Error adding song to playlist:', songError)
|
||||||
|
// Continue with other songs
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[API] Added ${addedSongs} songs to playlist`)
|
||||||
|
} catch (playlistError) {
|
||||||
|
console.error('[API] Error fetching YouTube playlist:', playlistError)
|
||||||
|
// Don't fail the entire request, just log the error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
console.log('[API] Playlist created:', playlist.id)
|
console.log('[API] Playlist created:', playlist.id)
|
||||||
return NextResponse.json(playlist)
|
return NextResponse.json({
|
||||||
|
playlist: {
|
||||||
|
...playlist,
|
||||||
|
playlistSongs: addedSongs > 0 ? db.getPlaylistSongs(playlist.id) : []
|
||||||
|
},
|
||||||
|
addedSongs
|
||||||
|
})
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[API] Error creating playlist:', error)
|
console.error('[API] Error creating playlist:', error)
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
|
|||||||
+146
-6
@@ -1,6 +1,7 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
|
import { Trash2, CheckSquare, Square, X } from 'lucide-react'
|
||||||
import Navbar from '@/components/Navbar'
|
import Navbar from '@/components/Navbar'
|
||||||
import Header from '@/components/Header'
|
import Header from '@/components/Header'
|
||||||
import SongCard from '@/components/SongCard'
|
import SongCard from '@/components/SongCard'
|
||||||
@@ -14,6 +15,7 @@ interface Song {
|
|||||||
channelName: string
|
channelName: string
|
||||||
thumbnail: string
|
thumbnail: string
|
||||||
duration: number
|
duration: number
|
||||||
|
playlists?: Playlist[]
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Playlist {
|
interface Playlist {
|
||||||
@@ -25,27 +27,52 @@ export default function Home() {
|
|||||||
const [songs, setSongs] = useState<Song[]>([])
|
const [songs, setSongs] = useState<Song[]>([])
|
||||||
const [playlists, setPlaylists] = useState<Playlist[]>([])
|
const [playlists, setPlaylists] = useState<Playlist[]>([])
|
||||||
const [isLoading, setIsLoading] = useState(false)
|
const [isLoading, setIsLoading] = useState(false)
|
||||||
|
const [isSelectionMode, setIsSelectionMode] = useState(false)
|
||||||
|
const [selectedSongs, setSelectedSongs] = useState<Set<string>>(new Set())
|
||||||
|
const [isDeletingSelected, setIsDeletingSelected] = useState(false)
|
||||||
const { showToast } = useToast()
|
const { showToast } = useToast()
|
||||||
const { playSong } = usePlayer()
|
const { playSong } = usePlayer()
|
||||||
|
|
||||||
// Fetch songs and playlists on mount
|
// Fetch songs and playlists on mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchSongs()
|
fetchData()
|
||||||
fetchPlaylists()
|
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
const fetchData = async () => {
|
||||||
|
await Promise.all([fetchSongs(), fetchPlaylists()])
|
||||||
|
}
|
||||||
|
|
||||||
const fetchPlaylists = async () => {
|
const fetchPlaylists = async () => {
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/playlists')
|
const response = await fetch('/api/playlists')
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
const data = await response.json()
|
const data = await response.json()
|
||||||
setPlaylists(data)
|
setPlaylists(data)
|
||||||
|
// Update songs with playlist information
|
||||||
|
updateSongsWithPlaylists(data)
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error fetching playlists:', error)
|
console.error('Error fetching playlists:', error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const updateSongsWithPlaylists = (playlistData: any[]) => {
|
||||||
|
setSongs(prevSongs =>
|
||||||
|
prevSongs.map(song => {
|
||||||
|
const songPlaylists = playlistData
|
||||||
|
.filter(playlist =>
|
||||||
|
playlist.playlistSongs?.some((ps: any) => ps.song.id === song.id)
|
||||||
|
)
|
||||||
|
.map(playlist => ({ id: playlist.id, name: playlist.name }))
|
||||||
|
|
||||||
|
return {
|
||||||
|
...song,
|
||||||
|
playlists: songPlaylists
|
||||||
|
}
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
const fetchSongs = async () => {
|
const fetchSongs = async () => {
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/songs')
|
const response = await fetch('/api/songs')
|
||||||
@@ -108,6 +135,61 @@ export default function Home() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Selection mode functions
|
||||||
|
const toggleSelectionMode = () => {
|
||||||
|
setIsSelectionMode(!isSelectionMode)
|
||||||
|
setSelectedSongs(new Set())
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggleSongSelection = (songId: string) => {
|
||||||
|
const newSelected = new Set(selectedSongs)
|
||||||
|
if (newSelected.has(songId)) {
|
||||||
|
newSelected.delete(songId)
|
||||||
|
} else {
|
||||||
|
newSelected.add(songId)
|
||||||
|
}
|
||||||
|
setSelectedSongs(newSelected)
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectAllSongs = () => {
|
||||||
|
if (selectedSongs.size === songs.length) {
|
||||||
|
setSelectedSongs(new Set())
|
||||||
|
} else {
|
||||||
|
setSelectedSongs(new Set(songs.map(song => song.id)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleteSelectedSongs = async () => {
|
||||||
|
if (selectedSongs.size === 0) return
|
||||||
|
|
||||||
|
setIsDeletingSelected(true)
|
||||||
|
try {
|
||||||
|
const deletePromises = Array.from(selectedSongs).map(songId =>
|
||||||
|
fetch(`/api/songs/${songId}`, { method: 'DELETE' })
|
||||||
|
)
|
||||||
|
|
||||||
|
const results = await Promise.all(deletePromises)
|
||||||
|
const successfulDeletes = results.filter(r => r.ok).length
|
||||||
|
|
||||||
|
if (successfulDeletes > 0) {
|
||||||
|
setSongs(prev => prev.filter(song => !selectedSongs.has(song.id)))
|
||||||
|
showToast(`${successfulDeletes} song${successfulDeletes !== 1 ? 's' : ''} deleted`, 'success')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (successfulDeletes < selectedSongs.size) {
|
||||||
|
showToast(`Failed to delete ${selectedSongs.size - successfulDeletes} song${selectedSongs.size - successfulDeletes !== 1 ? 's' : ''}`, 'error')
|
||||||
|
}
|
||||||
|
|
||||||
|
setSelectedSongs(new Set())
|
||||||
|
setIsSelectionMode(false)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error deleting selected songs:', error)
|
||||||
|
showToast('Error deleting songs', 'error')
|
||||||
|
} finally {
|
||||||
|
setIsDeletingSelected(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const handleAddToPlaylist = async (songId: string, playlistId: string) => {
|
const handleAddToPlaylist = async (songId: string, playlistId: string) => {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`/api/playlists/${playlistId}/songs`, {
|
const response = await fetch(`/api/playlists/${playlistId}/songs`, {
|
||||||
@@ -120,6 +202,8 @@ export default function Home() {
|
|||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
showToast('Song added to playlist', 'success')
|
showToast('Song added to playlist', 'success')
|
||||||
|
// Refresh playlists to update song-playlist relationships
|
||||||
|
await fetchPlaylists()
|
||||||
} else {
|
} else {
|
||||||
const data = await response.json()
|
const data = await response.json()
|
||||||
showToast(data.error || 'Error adding song to playlist', 'error')
|
showToast(data.error || 'Error adding song to playlist', 'error')
|
||||||
@@ -134,9 +218,48 @@ export default function Home() {
|
|||||||
<div className="flex flex-col h-screen overflow-hidden">
|
<div className="flex flex-col h-screen overflow-hidden">
|
||||||
<Navbar />
|
<Navbar />
|
||||||
<Header onAddSong={handleAddSong} isLoading={isLoading} />
|
<Header onAddSong={handleAddSong} isLoading={isLoading} />
|
||||||
|
|
||||||
|
{/* Selection mode bar */}
|
||||||
|
{isSelectionMode && (
|
||||||
|
<div className="bg-gray-800 border-b border-gray-700 px-4 py-3 flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<button
|
||||||
|
onClick={toggleSelectionMode}
|
||||||
|
disabled={isDeletingSelected}
|
||||||
|
className="flex items-center gap-2 px-3 py-1.5 text-sm bg-gray-700 hover:bg-gray-600 disabled:bg-gray-800 disabled:cursor-not-allowed text-white rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
<X size={16} />
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<span className="text-white text-sm">
|
||||||
|
{selectedSongs.size} of {songs.length} selected
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={selectAllSongs}
|
||||||
|
disabled={isDeletingSelected}
|
||||||
|
className="flex items-center gap-2 px-3 py-1.5 text-sm bg-gray-700 hover:bg-gray-600 disabled:bg-gray-800 disabled:cursor-not-allowed text-white rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
{selectedSongs.size === songs.length ? <CheckSquare size={16} /> : <Square size={16} />}
|
||||||
|
{selectedSongs.size === songs.length ? 'Deselect All' : 'Select All'}
|
||||||
|
</button>
|
||||||
|
{selectedSongs.size > 0 && (
|
||||||
|
<button
|
||||||
|
onClick={deleteSelectedSongs}
|
||||||
|
disabled={isDeletingSelected}
|
||||||
|
className="flex items-center gap-2 px-3 py-1.5 text-sm bg-red-600 hover:bg-red-700 disabled:bg-red-800 disabled:cursor-not-allowed text-white rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
<Trash2 size={16} />
|
||||||
|
{isDeletingSelected ? 'Deleting...' : `Delete (${selectedSongs.size})`}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<main className="flex-1 overflow-y-auto pb-36 sm:pb-32 p-3 sm:p-6">
|
<main className="flex-1 overflow-y-auto pb-36 sm:pb-32 p-3 sm:p-6">
|
||||||
{songs.length === 0 ? (
|
{songs.length === 0 ? (
|
||||||
<div className="flex items-center justify-center h-full">
|
<div className="flex items-center justify-center h-full">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<h2 className="text-2xl font-bold text-gray-400 mb-2">
|
<h2 className="text-2xl font-bold text-gray-400 mb-2">
|
||||||
@@ -148,8 +271,21 @@ export default function Home() {
|
|||||||
</div>
|
</div>
|
||||||
</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">
|
<div>
|
||||||
{songs.map((song) => (
|
{!isSelectionMode && (
|
||||||
|
<div className="mb-4">
|
||||||
|
<button
|
||||||
|
onClick={toggleSelectionMode}
|
||||||
|
className="flex items-center gap-2 bg-red-600 hover:bg-red-700 text-white px-4 py-2 rounded-lg transition-colors"
|
||||||
|
title="Select multiple songs"
|
||||||
|
>
|
||||||
|
<CheckSquare size={18} />
|
||||||
|
<span className="text-sm font-medium">Select songs</span>
|
||||||
|
</button>
|
||||||
|
</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
|
<SongCard
|
||||||
key={song.id}
|
key={song.id}
|
||||||
song={song}
|
song={song}
|
||||||
@@ -157,8 +293,12 @@ export default function Home() {
|
|||||||
onDelete={handleDeleteSong}
|
onDelete={handleDeleteSong}
|
||||||
playlists={playlists}
|
playlists={playlists}
|
||||||
onAddToPlaylist={handleAddToPlaylist}
|
onAddToPlaylist={handleAddToPlaylist}
|
||||||
|
isSelectionMode={isSelectionMode}
|
||||||
|
isSelected={selectedSongs.has(song.id)}
|
||||||
|
onToggleSelection={toggleSongSelection}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
+102
-10
@@ -1,7 +1,7 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import { Plus, Trash2, Music, Play } from 'lucide-react'
|
import { Plus, Trash2, Music, Play, Menu, X } from 'lucide-react'
|
||||||
import Navbar from '@/components/Navbar'
|
import Navbar from '@/components/Navbar'
|
||||||
import SongCard from '@/components/SongCard'
|
import SongCard from '@/components/SongCard'
|
||||||
import { useToast } from '@/components/Toast'
|
import { useToast } from '@/components/Toast'
|
||||||
@@ -31,8 +31,11 @@ export default function PlaylistsPage() {
|
|||||||
const [playlists, setPlaylists] = useState<Playlist[]>([])
|
const [playlists, setPlaylists] = useState<Playlist[]>([])
|
||||||
const [selectedPlaylist, setSelectedPlaylist] = useState<Playlist | null>(null)
|
const [selectedPlaylist, setSelectedPlaylist] = useState<Playlist | null>(null)
|
||||||
const [newPlaylistName, setNewPlaylistName] = useState('')
|
const [newPlaylistName, setNewPlaylistName] = useState('')
|
||||||
|
const [newPlaylistUrl, setNewPlaylistUrl] = useState('')
|
||||||
const [showCreateForm, setShowCreateForm] = useState(false)
|
const [showCreateForm, setShowCreateForm] = useState(false)
|
||||||
const [playlistSongs, setPlaylistSongs] = useState<Song[]>([])
|
const [playlistSongs, setPlaylistSongs] = useState<Song[]>([])
|
||||||
|
const [isCreating, setIsCreating] = useState(false)
|
||||||
|
const [showSidebar, setShowSidebar] = useState(false)
|
||||||
const { showToast } = useToast()
|
const { showToast } = useToast()
|
||||||
const { playSong, isPlaylist, isShuffled, toggleShuffle } = usePlayer()
|
const { playSong, isPlaylist, isShuffled, toggleShuffle } = usePlayer()
|
||||||
|
|
||||||
@@ -63,25 +66,41 @@ export default function PlaylistsPage() {
|
|||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
if (!newPlaylistName.trim()) return
|
if (!newPlaylistName.trim()) return
|
||||||
|
|
||||||
|
setIsCreating(true)
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/playlists', {
|
const response = await fetch('/api/playlists', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
},
|
},
|
||||||
body: JSON.stringify({ name: newPlaylistName }),
|
body: JSON.stringify({
|
||||||
|
name: newPlaylistName,
|
||||||
|
youtubeUrl: newPlaylistUrl.trim() || undefined
|
||||||
|
}),
|
||||||
})
|
})
|
||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
const playlist = await response.json()
|
const result = await response.json()
|
||||||
setPlaylists((prev) => [{ ...playlist, playlistSongs: [] }, ...prev])
|
setPlaylists((prev) => [result.playlist, ...prev])
|
||||||
|
|
||||||
|
if (result.addedSongs > 0) {
|
||||||
|
showToast(`Playlist created with ${result.addedSongs} songs!`, 'success')
|
||||||
|
} else {
|
||||||
|
showToast('Playlist created', 'success')
|
||||||
|
}
|
||||||
|
|
||||||
setNewPlaylistName('')
|
setNewPlaylistName('')
|
||||||
|
setNewPlaylistUrl('')
|
||||||
setShowCreateForm(false)
|
setShowCreateForm(false)
|
||||||
showToast('Playlist created', 'success')
|
} else {
|
||||||
|
const error = await response.json()
|
||||||
|
showToast(error.error || 'Error creating playlist', 'error')
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error creating playlist:', error)
|
console.error('Error creating playlist:', error)
|
||||||
showToast('Error creating playlist', 'error')
|
showToast('Error creating playlist', 'error')
|
||||||
|
} finally {
|
||||||
|
setIsCreating(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -142,6 +161,41 @@ export default function PlaylistsPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleMoveToPlaylist = async (songId: string, fromPlaylistId: string, toPlaylistId: string) => {
|
||||||
|
try {
|
||||||
|
// Add song to the target playlist
|
||||||
|
const addResponse = await fetch(`/api/playlists/${toPlaylistId}/songs`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ songId }),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!addResponse.ok) {
|
||||||
|
const data = await addResponse.json()
|
||||||
|
showToast(data.error || 'Error moving song to playlist', 'error')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove song from current playlist
|
||||||
|
const removeResponse = await fetch(`/api/playlists/${fromPlaylistId}/songs/${songId}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
})
|
||||||
|
|
||||||
|
if (removeResponse.ok) {
|
||||||
|
// Update the playlists state
|
||||||
|
await fetchPlaylists()
|
||||||
|
showToast('Song moved to playlist', 'success')
|
||||||
|
} else {
|
||||||
|
showToast('Song added to new playlist but failed to remove from current', 'error')
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error moving song between playlists:', error)
|
||||||
|
showToast('Error moving song between playlists', 'error')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const handlePlaySong = (song: Song) => {
|
const handlePlaySong = (song: Song) => {
|
||||||
const index = playlistSongs.findIndex((s) => s.id === song.id)
|
const index = playlistSongs.findIndex((s) => s.id === song.id)
|
||||||
playSong(song, index, playlistSongs, true)
|
playSong(song, index, playlistSongs, true)
|
||||||
@@ -162,9 +216,23 @@ export default function PlaylistsPage() {
|
|||||||
<div className="flex flex-col h-screen overflow-hidden">
|
<div className="flex flex-col h-screen overflow-hidden">
|
||||||
<Navbar />
|
<Navbar />
|
||||||
|
|
||||||
<div className="flex-1 flex flex-col lg:flex-row overflow-hidden">
|
{/* Mobile header with menu button */}
|
||||||
|
<div className="lg:hidden flex items-center justify-between p-4 border-b border-gray-800 z-50 relative">
|
||||||
|
<h1 className="text-xl font-bold text-white">Playlists</h1>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowSidebar(!showSidebar)}
|
||||||
|
className="p-2 cursor-pointer text-gray-400 hover:text-white transition-colors"
|
||||||
|
title={showSidebar ? "Close playlists" : "Show playlists"}
|
||||||
|
>
|
||||||
|
{showSidebar ? <X size={24} /> : <Menu size={24} />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 flex flex-col lg:flex-row overflow-hidden relative">
|
||||||
{/* Playlists sidebar */}
|
{/* 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={`${
|
||||||
|
showSidebar ? 'block' : 'hidden'
|
||||||
|
} lg:block absolute lg:relative inset-0 lg:inset-auto z-40 lg:z-auto w-full lg:w-80 bg-gray-900 lg:bg-transparent border-b lg:border-b-0 lg:border-r border-gray-800 overflow-y-auto p-3 sm:p-6`}>
|
||||||
<div className="mb-4">
|
<div className="mb-4">
|
||||||
{!showCreateForm ? (
|
{!showCreateForm ? (
|
||||||
<button
|
<button
|
||||||
@@ -184,18 +252,27 @@ export default function PlaylistsPage() {
|
|||||||
className="w-full px-4 py-2 bg-gray-800 text-white rounded-lg focus:outline-none focus:ring-2 focus:ring-green-500"
|
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
|
autoFocus
|
||||||
/>
|
/>
|
||||||
|
<input
|
||||||
|
type="url"
|
||||||
|
value={newPlaylistUrl}
|
||||||
|
onChange={(e) => setNewPlaylistUrl(e.target.value)}
|
||||||
|
placeholder="YouTube playlist URL (optional)..."
|
||||||
|
className="w-full px-4 py-2 bg-gray-800 text-white rounded-lg focus:outline-none focus:ring-2 focus:ring-green-500"
|
||||||
|
/>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
className="flex-1 cursor-pointer px-4 py-2 bg-green-500 text-white rounded-lg hover:bg-green-600 transition-colors"
|
disabled={isCreating}
|
||||||
|
className="flex-1 cursor-pointer px-4 py-2 bg-green-500 text-white rounded-lg hover:bg-green-600 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
>
|
>
|
||||||
Create
|
{isCreating ? 'Creating...' : 'Create'}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setShowCreateForm(false)
|
setShowCreateForm(false)
|
||||||
setNewPlaylistName('')
|
setNewPlaylistName('')
|
||||||
|
setNewPlaylistUrl('')
|
||||||
}}
|
}}
|
||||||
className="flex-1 cursor-pointer px-4 py-2 bg-gray-700 text-white rounded-lg hover:bg-gray-600 transition-colors"
|
className="flex-1 cursor-pointer px-4 py-2 bg-gray-700 text-white rounded-lg hover:bg-gray-600 transition-colors"
|
||||||
>
|
>
|
||||||
@@ -222,7 +299,10 @@ export default function PlaylistsPage() {
|
|||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
onClick={() => setSelectedPlaylist(playlist)}
|
onClick={() => {
|
||||||
|
setSelectedPlaylist(playlist)
|
||||||
|
setShowSidebar(false) // Close sidebar on mobile when selecting playlist
|
||||||
|
}}
|
||||||
className="flex-1 min-w-0 cursor-pointer"
|
className="flex-1 min-w-0 cursor-pointer"
|
||||||
>
|
>
|
||||||
<h3 className="text-white font-semibold truncate">
|
<h3 className="text-white font-semibold truncate">
|
||||||
@@ -239,6 +319,7 @@ export default function PlaylistsPage() {
|
|||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
handlePlayPlaylist(playlist)
|
handlePlayPlaylist(playlist)
|
||||||
|
setShowSidebar(false) // Close sidebar on mobile when playing playlist
|
||||||
}}
|
}}
|
||||||
className="p-2 cursor-pointer bg-green-500 rounded-full hover:bg-green-600 transition-colors opacity-100"
|
className="p-2 cursor-pointer bg-green-500 rounded-full hover:bg-green-600 transition-colors opacity-100"
|
||||||
title="Play playlist"
|
title="Play playlist"
|
||||||
@@ -263,6 +344,14 @@ export default function PlaylistsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Mobile overlay */}
|
||||||
|
{showSidebar && (
|
||||||
|
<div
|
||||||
|
className="lg:hidden fixed inset-0 bg-black bg-opacity-50 z-30"
|
||||||
|
onClick={() => setShowSidebar(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Playlist content */}
|
{/* Playlist content */}
|
||||||
<main className="flex-1 overflow-y-auto pb-36 sm:pb-32 p-3 sm:p-6">
|
<main className="flex-1 overflow-y-auto pb-36 sm:pb-32 p-3 sm:p-6">
|
||||||
{!selectedPlaylist ? (
|
{!selectedPlaylist ? (
|
||||||
@@ -301,6 +390,9 @@ export default function PlaylistsPage() {
|
|||||||
song={ps.song}
|
song={ps.song}
|
||||||
onPlay={handlePlaySong}
|
onPlay={handlePlaySong}
|
||||||
onDelete={handleRemoveSongFromPlaylist}
|
onDelete={handleRemoveSongFromPlaylist}
|
||||||
|
playlists={playlists}
|
||||||
|
currentPlaylistId={selectedPlaylist.id}
|
||||||
|
onMoveToPlaylist={handleMoveToPlaylist}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -180,7 +180,7 @@ export default function PlayerBar({
|
|||||||
|
|
||||||
if (!currentSong) {
|
if (!currentSong) {
|
||||||
return (
|
return (
|
||||||
<div className="fixed bottom-0 left-0 right-0 bg-gray-900 border-t border-gray-800 p-4">
|
<div className="fixed bottom-0 left-0 right-0 bg-gray-900 border-t border-gray-800 p-4 z-50">
|
||||||
<div className="flex items-center justify-center text-gray-500">
|
<div className="flex items-center justify-center text-gray-500">
|
||||||
Select a song to start playing
|
Select a song to start playing
|
||||||
</div>
|
</div>
|
||||||
@@ -191,8 +191,8 @@ export default function PlayerBar({
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<audio ref={audioRef} />
|
<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="fixed bottom-0 left-0 right-0 bg-gray-900 border-t border-gray-800 p-2 sm:p-4 z-50">
|
||||||
<div className="flex flex-col gap-2 sm:gap-3">
|
<div className="flex flex-col gap-2 sm:gap-3 z-50">
|
||||||
{/* Song Info - Always on top in mobile */}
|
{/* Song Info - Always on top in mobile */}
|
||||||
<div className="flex items-center gap-3 w-full">
|
<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">
|
<div className="relative w-12 h-12 sm:w-14 sm:h-14 rounded-md overflow-hidden flex-shrink-0">
|
||||||
|
|||||||
+106
-43
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import Image from 'next/image'
|
import Image from 'next/image'
|
||||||
import { Play, Trash2, ListPlus } from 'lucide-react'
|
import { Play, Trash2, ListPlus, ArrowRight, CheckSquare } from 'lucide-react'
|
||||||
|
|
||||||
interface Song {
|
interface Song {
|
||||||
id: string
|
id: string
|
||||||
@@ -11,6 +11,7 @@ interface Song {
|
|||||||
channelName: string
|
channelName: string
|
||||||
thumbnail: string
|
thumbnail: string
|
||||||
duration: number
|
duration: number
|
||||||
|
playlists?: Playlist[]
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Playlist {
|
interface Playlist {
|
||||||
@@ -24,6 +25,11 @@ interface SongCardProps {
|
|||||||
onDelete: (songId: string) => void
|
onDelete: (songId: string) => void
|
||||||
playlists?: Playlist[]
|
playlists?: Playlist[]
|
||||||
onAddToPlaylist?: (songId: string, playlistId: string) => void
|
onAddToPlaylist?: (songId: string, playlistId: string) => void
|
||||||
|
currentPlaylistId?: string
|
||||||
|
onMoveToPlaylist?: (songId: string, fromPlaylistId: string, toPlaylistId: string) => void
|
||||||
|
isSelectionMode?: boolean
|
||||||
|
isSelected?: boolean
|
||||||
|
onToggleSelection?: (songId: string) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatDuration(seconds: number): string {
|
function formatDuration(seconds: number): string {
|
||||||
@@ -32,24 +38,24 @@ function formatDuration(seconds: number): string {
|
|||||||
return `${mins}:${secs.toString().padStart(2, '0')}`
|
return `${mins}:${secs.toString().padStart(2, '0')}`
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function SongCard({ song, onPlay, onDelete, playlists, onAddToPlaylist }: SongCardProps) {
|
export default function SongCard({
|
||||||
|
song,
|
||||||
|
onPlay,
|
||||||
|
onDelete,
|
||||||
|
playlists,
|
||||||
|
onAddToPlaylist,
|
||||||
|
currentPlaylistId,
|
||||||
|
onMoveToPlaylist,
|
||||||
|
isSelectionMode = false,
|
||||||
|
isSelected = false,
|
||||||
|
onToggleSelection
|
||||||
|
}: SongCardProps) {
|
||||||
const [showPlaylistMenu, setShowPlaylistMenu] = useState(false)
|
const [showPlaylistMenu, setShowPlaylistMenu] = useState(false)
|
||||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
|
const [showMoveMenu, setShowMoveMenu] = useState(false)
|
||||||
|
|
||||||
const handleDelete = (e: React.MouseEvent) => {
|
const handleDelete = (e: React.MouseEvent) => {
|
||||||
e.stopPropagation()
|
|
||||||
setShowDeleteConfirm(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
const confirmDelete = (e: React.MouseEvent) => {
|
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
onDelete(song.id)
|
onDelete(song.id)
|
||||||
setShowDeleteConfirm(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
const cancelDelete = (e: React.MouseEvent) => {
|
|
||||||
e.stopPropagation()
|
|
||||||
setShowDeleteConfirm(false)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleAddToPlaylist = (e: React.MouseEvent, playlistId: string) => {
|
const handleAddToPlaylist = (e: React.MouseEvent, playlistId: string) => {
|
||||||
@@ -60,45 +66,53 @@ export default function SongCard({ song, onPlay, onDelete, playlists, onAddToPla
|
|||||||
setShowPlaylistMenu(false)
|
setShowPlaylistMenu(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleMoveToPlaylist = (e: React.MouseEvent, toPlaylistId: string) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
if (onMoveToPlaylist && currentPlaylistId) {
|
||||||
|
onMoveToPlaylist(song.id, currentPlaylistId, toPlaylistId)
|
||||||
|
}
|
||||||
|
setShowMoveMenu(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggleMoveMenu = (e: React.MouseEvent) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
setShowMoveMenu(!showMoveMenu)
|
||||||
|
}
|
||||||
|
|
||||||
const togglePlaylistMenu = (e: React.MouseEvent) => {
|
const togglePlaylistMenu = (e: React.MouseEvent) => {
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
setShowPlaylistMenu(!showPlaylistMenu)
|
setShowPlaylistMenu(!showPlaylistMenu)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-gray-800 rounded-lg p-4 hover:bg-gray-700 transition-colors cursor-pointer group relative">
|
<div className={`bg-gray-800 rounded-lg p-4 transition-colors group relative ${
|
||||||
{/* Delete confirmation modal */}
|
isSelectionMode
|
||||||
{showDeleteConfirm && (
|
? 'cursor-default hover:bg-gray-750'
|
||||||
<div
|
: 'cursor-pointer hover:bg-gray-700'
|
||||||
className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50"
|
} ${isSelected ? 'ring-2 ring-blue-500 bg-gray-750' : ''}`}
|
||||||
onClick={cancelDelete}
|
onClick={isSelectionMode ? () => onToggleSelection?.(song.id) : () => onPlay(song)}>
|
||||||
>
|
|
||||||
<div
|
{/* Selection checkbox */}
|
||||||
className="bg-gray-900 rounded-lg p-6 max-w-sm w-full mx-4 border border-gray-700"
|
{isSelectionMode && (
|
||||||
onClick={(e) => e.stopPropagation()}
|
<div className="absolute top-2 left-2 z-20">
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
onToggleSelection?.(song.id)
|
||||||
|
}}
|
||||||
|
className={`w-6 h-6 rounded border-2 flex items-center justify-center transition-colors ${
|
||||||
|
isSelected
|
||||||
|
? 'bg-blue-500 border-blue-500 text-white'
|
||||||
|
: 'border-gray-400 hover:border-gray-300'
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
<h3 className="text-white text-lg font-semibold mb-2">Delete song?</h3>
|
{isSelected && <CheckSquare size={14} fill="currentColor" />}
|
||||||
<p className="text-gray-400 mb-6">Are you sure you want to delete "{song.title}"?</p>
|
</button>
|
||||||
<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>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="absolute top-2 right-2 z-10 flex gap-2">
|
<div className="absolute top-2 right-2 z-10 flex gap-2">
|
||||||
{playlists && playlists.length > 0 && onAddToPlaylist && (
|
{!isSelectionMode && playlists && playlists.length > 0 && onAddToPlaylist && (
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<button
|
<button
|
||||||
onClick={togglePlaylistMenu}
|
onClick={togglePlaylistMenu}
|
||||||
@@ -124,6 +138,35 @@ export default function SongCard({ song, onPlay, onDelete, playlists, onAddToPla
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{!isSelectionMode && currentPlaylistId && playlists && playlists.length > 1 && onMoveToPlaylist && (
|
||||||
|
<div className="relative">
|
||||||
|
<button
|
||||||
|
onClick={toggleMoveMenu}
|
||||||
|
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="Move to another playlist"
|
||||||
|
>
|
||||||
|
<ArrowRight size={16} className="text-white" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{showMoveMenu && (
|
||||||
|
<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
|
||||||
|
.filter(playlist => playlist.id !== currentPlaylistId)
|
||||||
|
.map((playlist) => (
|
||||||
|
<button
|
||||||
|
key={playlist.id}
|
||||||
|
onClick={(e) => handleMoveToPlaylist(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>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!isSelectionMode && (
|
||||||
<button
|
<button
|
||||||
onClick={handleDelete}
|
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"
|
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"
|
||||||
@@ -131,6 +174,7 @@ export default function SongCard({ song, onPlay, onDelete, playlists, onAddToPla
|
|||||||
>
|
>
|
||||||
<Trash2 size={16} className="text-white" />
|
<Trash2 size={16} className="text-white" />
|
||||||
</button>
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="relative mb-4 w-full aspect-video rounded-md overflow-hidden bg-gray-900">
|
<div className="relative mb-4 w-full aspect-video rounded-md overflow-hidden bg-gray-900">
|
||||||
@@ -155,6 +199,25 @@ export default function SongCard({ song, onPlay, onDelete, playlists, onAddToPla
|
|||||||
{song.title}
|
{song.title}
|
||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
|
{song.playlists && song.playlists.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-1 mb-2">
|
||||||
|
{song.playlists.slice(0, 2).map((playlist) => (
|
||||||
|
<span
|
||||||
|
key={playlist.id}
|
||||||
|
className="inline-block bg-green-600 text-white text-xs px-2 py-0.5 rounded-full truncate max-w-20"
|
||||||
|
title={playlist.name}
|
||||||
|
>
|
||||||
|
{playlist.name}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
{song.playlists.length > 2 && (
|
||||||
|
<span className="inline-block bg-gray-600 text-white text-xs px-2 py-0.5 rounded-full">
|
||||||
|
+{song.playlists.length - 2}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="flex justify-between items-center">
|
<div className="flex justify-between items-center">
|
||||||
<p className="text-gray-400 text-sm truncate flex-1" title={song.channelName}>
|
<p className="text-gray-400 text-sm truncate flex-1" title={song.channelName}>
|
||||||
{song.channelName}
|
{song.channelName}
|
||||||
|
|||||||
+1099
File diff suppressed because it is too large.
Load diff
@@ -146,3 +146,60 @@ export async function updateYtDlp(): Promise<string> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getYoutubePlaylistMetadata(url: string): Promise<Array<{
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
channel: string
|
||||||
|
thumbnail: string
|
||||||
|
duration: number
|
||||||
|
}>> {
|
||||||
|
try {
|
||||||
|
console.log('[getYoutubePlaylistMetadata] Fetching playlist metadata for URL:', url)
|
||||||
|
const stdout = await executeYtDlp([
|
||||||
|
'--dump-json',
|
||||||
|
'--no-warnings',
|
||||||
|
'--flat-playlist', // Extract playlist info
|
||||||
|
url
|
||||||
|
], 120000) // 2 minute timeout for playlists
|
||||||
|
|
||||||
|
// Split by newlines and parse each JSON object
|
||||||
|
const lines = stdout.trim().split('\n')
|
||||||
|
const videos = lines.map(line => JSON.parse(line)).filter((data: any) => data && data.id)
|
||||||
|
|
||||||
|
console.log('[getYoutubePlaylistMetadata] Found', videos.length, 'videos in playlist')
|
||||||
|
|
||||||
|
return videos.map((data: any) => {
|
||||||
|
console.log('[getYoutubePlaylistMetadata] Processing video:', data.title)
|
||||||
|
|
||||||
|
// Get best thumbnail URL
|
||||||
|
let thumbnail = ''
|
||||||
|
if (data.thumbnails && Array.isArray(data.thumbnails) && data.thumbnails.length > 0) {
|
||||||
|
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`
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: data.id,
|
||||||
|
title: data.title,
|
||||||
|
channel: data.channel || data.uploader || 'Unknown',
|
||||||
|
thumbnail,
|
||||||
|
duration: data.duration || 0,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[getYoutubePlaylistMetadata] Error:', error)
|
||||||
|
throw new Error(`Failed to get playlist metadata: ${error}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Generated
-105
@@ -8,7 +8,6 @@
|
|||||||
"name": "bitify",
|
"name": "bitify",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@prisma/client": "^5.22.0",
|
|
||||||
"lucide-react": "^0.454.0",
|
"lucide-react": "^0.454.0",
|
||||||
"next": "^15.0.3",
|
"next": "^15.0.3",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
@@ -20,7 +19,6 @@
|
|||||||
"@types/react": "^18.3.12",
|
"@types/react": "^18.3.12",
|
||||||
"@types/react-dom": "^18.3.1",
|
"@types/react-dom": "^18.3.1",
|
||||||
"postcss": "^8.4.49",
|
"postcss": "^8.4.49",
|
||||||
"prisma": "^5.22.0",
|
|
||||||
"tailwindcss": "^4.0.0",
|
"tailwindcss": "^4.0.0",
|
||||||
"typescript": "^5.6.3"
|
"typescript": "^5.6.3"
|
||||||
}
|
}
|
||||||
@@ -698,74 +696,6 @@
|
|||||||
"node": ">= 10"
|
"node": ">= 10"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@prisma/client": {
|
|
||||||
"version": "5.22.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@prisma/client/-/client-5.22.0.tgz",
|
|
||||||
"integrity": "sha512-M0SVXfyHnQREBKxCgyo7sffrKttwE6R8PMq330MIUF0pTwjUhLbW84pFDlf06B27XyCR++VtjugEnIHdr07SVA==",
|
|
||||||
"hasInstallScript": true,
|
|
||||||
"license": "Apache-2.0",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=16.13"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"prisma": "*"
|
|
||||||
},
|
|
||||||
"peerDependenciesMeta": {
|
|
||||||
"prisma": {
|
|
||||||
"optional": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@prisma/debug": {
|
|
||||||
"version": "5.22.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-5.22.0.tgz",
|
|
||||||
"integrity": "sha512-AUt44v3YJeggO2ZU5BkXI7M4hu9BF2zzH2iF2V5pyXT/lRTyWiElZ7It+bRH1EshoMRxHgpYg4VB6rCM+mG5jQ==",
|
|
||||||
"devOptional": true,
|
|
||||||
"license": "Apache-2.0"
|
|
||||||
},
|
|
||||||
"node_modules/@prisma/engines": {
|
|
||||||
"version": "5.22.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-5.22.0.tgz",
|
|
||||||
"integrity": "sha512-UNjfslWhAt06kVL3CjkuYpHAWSO6L4kDCVPegV6itt7nD1kSJavd3vhgAEhjglLJJKEdJ7oIqDJ+yHk6qO8gPA==",
|
|
||||||
"devOptional": true,
|
|
||||||
"hasInstallScript": true,
|
|
||||||
"license": "Apache-2.0",
|
|
||||||
"dependencies": {
|
|
||||||
"@prisma/debug": "5.22.0",
|
|
||||||
"@prisma/engines-version": "5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2",
|
|
||||||
"@prisma/fetch-engine": "5.22.0",
|
|
||||||
"@prisma/get-platform": "5.22.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@prisma/engines-version": {
|
|
||||||
"version": "5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2",
|
|
||||||
"resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2.tgz",
|
|
||||||
"integrity": "sha512-2PTmxFR2yHW/eB3uqWtcgRcgAbG1rwG9ZriSvQw+nnb7c4uCr3RAcGMb6/zfE88SKlC1Nj2ziUvc96Z379mHgQ==",
|
|
||||||
"devOptional": true,
|
|
||||||
"license": "Apache-2.0"
|
|
||||||
},
|
|
||||||
"node_modules/@prisma/fetch-engine": {
|
|
||||||
"version": "5.22.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-5.22.0.tgz",
|
|
||||||
"integrity": "sha512-bkrD/Mc2fSvkQBV5EpoFcZ87AvOgDxbG99488a5cexp5Ccny+UM6MAe/UFkUC0wLYD9+9befNOqGiIJhhq+HbA==",
|
|
||||||
"devOptional": true,
|
|
||||||
"license": "Apache-2.0",
|
|
||||||
"dependencies": {
|
|
||||||
"@prisma/debug": "5.22.0",
|
|
||||||
"@prisma/engines-version": "5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2",
|
|
||||||
"@prisma/get-platform": "5.22.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@prisma/get-platform": {
|
|
||||||
"version": "5.22.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-5.22.0.tgz",
|
|
||||||
"integrity": "sha512-pHhpQdr1UPFpt+zFfnPazhulaZYCUqeIcPpJViYoq9R+D/yw4fjE+CtnsnKzPYm0ddUbeXUzjGVGIRVgPDCk4Q==",
|
|
||||||
"devOptional": true,
|
|
||||||
"license": "Apache-2.0",
|
|
||||||
"dependencies": {
|
|
||||||
"@prisma/debug": "5.22.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@swc/helpers": {
|
"node_modules/@swc/helpers": {
|
||||||
"version": "0.5.15",
|
"version": "0.5.15",
|
||||||
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz",
|
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz",
|
||||||
@@ -1141,21 +1071,6 @@
|
|||||||
"node": ">=10.13.0"
|
"node": ">=10.13.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/fsevents": {
|
|
||||||
"version": "2.3.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
|
||||||
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
|
|
||||||
"dev": true,
|
|
||||||
"hasInstallScript": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"darwin"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/graceful-fs": {
|
"node_modules/graceful-fs": {
|
||||||
"version": "4.2.11",
|
"version": "4.2.11",
|
||||||
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
|
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
|
||||||
@@ -1604,26 +1519,6 @@
|
|||||||
"node": "^10 || ^12 || >=14"
|
"node": "^10 || ^12 || >=14"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/prisma": {
|
|
||||||
"version": "5.22.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/prisma/-/prisma-5.22.0.tgz",
|
|
||||||
"integrity": "sha512-vtpjW3XuYCSnMsNVBjLMNkTj6OZbudcPPTPYHqX0CJfpcdWciI1dM8uHETwmDxxiqEwCIE6WvXucWUetJgfu/A==",
|
|
||||||
"devOptional": true,
|
|
||||||
"hasInstallScript": true,
|
|
||||||
"license": "Apache-2.0",
|
|
||||||
"dependencies": {
|
|
||||||
"@prisma/engines": "5.22.0"
|
|
||||||
},
|
|
||||||
"bin": {
|
|
||||||
"prisma": "build/index.js"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=16.13"
|
|
||||||
},
|
|
||||||
"optionalDependencies": {
|
|
||||||
"fsevents": "2.3.3"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/react": {
|
"node_modules/react": {
|
||||||
"version": "18.3.1",
|
"version": "18.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
|
||||||
|
|||||||
Reference in new issue
Block a user