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:
bitc0de committed 2025-12-25 13:15:34 +01:00
1 parent 84ae14bfb5
commit 706d808b83
10 files changed
+1598 -239

No files matched your search

+2
View File
@@ -2,6 +2,8 @@ name: Build and Push Docker Image
on:
push:
branches:
- main
tags:
- 'v*.*.*'
workflow_dispatch:
+37 -76
View File
@@ -14,26 +14,28 @@ Self-hosted YouTube music streaming application built with Next.js.
## Features
- 🎵 Stream music from YouTube without downloading files
- 🎨 Beautiful dark mode interface
- 📱 Fully responsive design (mobile & desktop)
- 🎮 Full playback controls (play, pause, next, previous, shuffle)
- 🔊 Volume control with visual feedback
- 📊 Progress bar with seeking and time display
- 🎼 Create and manage playlists
- 🌐 Global player - music continues playing across page navigation
- 🔔 Toast notifications for user feedback
- � Simple JSON file storage - no database setup required
- 🐳 Easy Docker deployment
- Stream music from YouTube without downloading files
- Beautiful dark mode interface
- Fully responsive design (mobile & desktop)
- Full playback controls (play, pause, next, previous, shuffle)
- Volume control with visual feedback
- Progress bar with seeking and time display
- Create and manage playlists
- Global player - music continues playing across page navigation
- Toast notifications for user feedback
- Simple JSON file storage - no database setup required
- 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
@@ -48,12 +50,27 @@ Self-hosted YouTube music streaming application built with Next.js.
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
pip install yt-dlp
```
3. **Run the development server:**
5. **Run the development server:**
```bash
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
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
@@ -207,6 +171,3 @@ The file is created automatically on first run. To backup your library, simply c
MIT
## Author
Built with ❤️ using Next.js and yt-dlp
+52 -2
View File
@@ -1,5 +1,6 @@
import { NextRequest, NextResponse } from 'next/server'
import { db } from '@/lib/db'
import { getYoutubePlaylistMetadata } from '@/lib/ytdlp'
export async function GET() {
try {
@@ -26,7 +27,7 @@ export async function GET() {
export async function POST(request: NextRequest) {
try {
const { name } = await request.json()
const { name, youtubeUrl } = await request.json()
if (!name) {
return NextResponse.json(
@@ -36,9 +37,58 @@ export async function POST(request: NextRequest) {
}
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)
return NextResponse.json(playlist)
return NextResponse.json({
playlist: {
...playlist,
playlistSongs: addedSongs > 0 ? db.getPlaylistSongs(playlist.id) : []
},
addedSongs
})
} catch (error) {
console.error('[API] Error creating playlist:', error)
return NextResponse.json(
+142 -2
View File
@@ -1,6 +1,7 @@
'use client'
import { useState, useEffect } from 'react'
import { Trash2, CheckSquare, Square, X } from 'lucide-react'
import Navbar from '@/components/Navbar'
import Header from '@/components/Header'
import SongCard from '@/components/SongCard'
@@ -14,6 +15,7 @@ interface Song {
channelName: string
thumbnail: string
duration: number
playlists?: Playlist[]
}
interface Playlist {
@@ -25,27 +27,52 @@ export default function Home() {
const [songs, setSongs] = useState<Song[]>([])
const [playlists, setPlaylists] = useState<Playlist[]>([])
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 { playSong } = usePlayer()
// Fetch songs and playlists on mount
useEffect(() => {
fetchSongs()
fetchPlaylists()
fetchData()
}, [])
const fetchData = async () => {
await Promise.all([fetchSongs(), fetchPlaylists()])
}
const fetchPlaylists = async () => {
try {
const response = await fetch('/api/playlists')
if (response.ok) {
const data = await response.json()
setPlaylists(data)
// Update songs with playlist information
updateSongsWithPlaylists(data)
}
} catch (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 () => {
try {
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) => {
try {
const response = await fetch(`/api/playlists/${playlistId}/songs`, {
@@ -120,6 +202,8 @@ export default function Home() {
if (response.ok) {
showToast('Song added to playlist', 'success')
// Refresh playlists to update song-playlist relationships
await fetchPlaylists()
} else {
const data = await response.json()
showToast(data.error || 'Error adding song to playlist', 'error')
@@ -135,6 +219,45 @@ export default function Home() {
<Navbar />
<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">
{songs.length === 0 ? (
<div className="flex items-center justify-center h-full">
@@ -148,6 +271,19 @@ export default function Home() {
</div>
</div>
) : (
<div>
{!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
@@ -157,9 +293,13 @@ export default function Home() {
onDelete={handleDeleteSong}
playlists={playlists}
onAddToPlaylist={handleAddToPlaylist}
isSelectionMode={isSelectionMode}
isSelected={selectedSongs.has(song.id)}
onToggleSelection={toggleSongSelection}
/>
))}
</div>
</div>
)}
</main>
</div>
+103 -11
View File
@@ -1,7 +1,7 @@
'use client'
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 SongCard from '@/components/SongCard'
import { useToast } from '@/components/Toast'
@@ -31,8 +31,11 @@ export default function PlaylistsPage() {
const [playlists, setPlaylists] = useState<Playlist[]>([])
const [selectedPlaylist, setSelectedPlaylist] = useState<Playlist | null>(null)
const [newPlaylistName, setNewPlaylistName] = useState('')
const [newPlaylistUrl, setNewPlaylistUrl] = useState('')
const [showCreateForm, setShowCreateForm] = useState(false)
const [playlistSongs, setPlaylistSongs] = useState<Song[]>([])
const [isCreating, setIsCreating] = useState(false)
const [showSidebar, setShowSidebar] = useState(false)
const { showToast } = useToast()
const { playSong, isPlaylist, isShuffled, toggleShuffle } = usePlayer()
@@ -63,25 +66,41 @@ export default function PlaylistsPage() {
e.preventDefault()
if (!newPlaylistName.trim()) return
setIsCreating(true)
try {
const response = await fetch('/api/playlists', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ name: newPlaylistName }),
body: JSON.stringify({
name: newPlaylistName,
youtubeUrl: newPlaylistUrl.trim() || undefined
}),
})
if (response.ok) {
const playlist = await response.json()
setPlaylists((prev) => [{ ...playlist, playlistSongs: [] }, ...prev])
setNewPlaylistName('')
setShowCreateForm(false)
const result = await response.json()
setPlaylists((prev) => [result.playlist, ...prev])
if (result.addedSongs > 0) {
showToast(`Playlist created with ${result.addedSongs} songs!`, 'success')
} else {
showToast('Playlist created', 'success')
}
setNewPlaylistName('')
setNewPlaylistUrl('')
setShowCreateForm(false)
} else {
const error = await response.json()
showToast(error.error || 'Error creating playlist', 'error')
}
} catch (error) {
console.error('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 index = playlistSongs.findIndex((s) => s.id === song.id)
playSong(song, index, playlistSongs, true)
@@ -162,9 +216,23 @@ export default function PlaylistsPage() {
<div className="flex flex-col h-screen overflow-hidden">
<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 */}
<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">
{!showCreateForm ? (
<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"
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">
<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"
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
type="button"
onClick={() => {
setShowCreateForm(false)
setNewPlaylistName('')
setNewPlaylistUrl('')
}}
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
onClick={() => setSelectedPlaylist(playlist)}
onClick={() => {
setSelectedPlaylist(playlist)
setShowSidebar(false) // Close sidebar on mobile when selecting playlist
}}
className="flex-1 min-w-0 cursor-pointer"
>
<h3 className="text-white font-semibold truncate">
@@ -239,6 +319,7 @@ export default function PlaylistsPage() {
onClick={(e) => {
e.stopPropagation()
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"
title="Play playlist"
@@ -263,6 +344,14 @@ export default function PlaylistsPage() {
</div>
</div>
{/* Mobile overlay */}
{showSidebar && (
<div
className="lg:hidden fixed inset-0 bg-black bg-opacity-50 z-30"
onClick={() => setShowSidebar(false)}
/>
)}
{/* Playlist content */}
<main className="flex-1 overflow-y-auto pb-36 sm:pb-32 p-3 sm:p-6">
{!selectedPlaylist ? (
@@ -301,6 +390,9 @@ export default function PlaylistsPage() {
song={ps.song}
onPlay={handlePlaySong}
onDelete={handleRemoveSongFromPlaylist}
playlists={playlists}
currentPlaylistId={selectedPlaylist.id}
onMoveToPlaylist={handleMoveToPlaylist}
/>
))}
</div>
+3 -3
View File
@@ -180,7 +180,7 @@ export default function PlayerBar({
if (!currentSong) {
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">
Select a song to start playing
</div>
@@ -191,8 +191,8 @@ export default function PlayerBar({
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">
<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 z-50">
{/* 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">
+103 -40
View File
@@ -2,7 +2,7 @@
import { useState } from 'react'
import Image from 'next/image'
import { Play, Trash2, ListPlus } from 'lucide-react'
import { Play, Trash2, ListPlus, ArrowRight, CheckSquare } from 'lucide-react'
interface Song {
id: string
@@ -11,6 +11,7 @@ interface Song {
channelName: string
thumbnail: string
duration: number
playlists?: Playlist[]
}
interface Playlist {
@@ -24,6 +25,11 @@ interface SongCardProps {
onDelete: (songId: string) => void
playlists?: Playlist[]
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 {
@@ -32,24 +38,24 @@ function formatDuration(seconds: number): string {
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 [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
const [showMoveMenu, setShowMoveMenu] = 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) => {
@@ -60,45 +66,53 @@ export default function SongCard({ song, onPlay, onDelete, playlists, onAddToPla
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) => {
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">
<div className={`bg-gray-800 rounded-lg p-4 transition-colors group relative ${
isSelectionMode
? 'cursor-default hover:bg-gray-750'
: 'cursor-pointer hover:bg-gray-700'
} ${isSelected ? 'ring-2 ring-blue-500 bg-gray-750' : ''}`}
onClick={isSelectionMode ? () => onToggleSelection?.(song.id) : () => onPlay(song)}>
{/* Selection checkbox */}
{isSelectionMode && (
<div className="absolute top-2 left-2 z-20">
<button
onClick={cancelDelete}
className="px-4 py-2 cursor-pointer bg-gray-700 text-white rounded-lg hover:bg-gray-600 transition-colors"
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'
}`}
>
Cancel
{isSelected && <CheckSquare size={14} fill="currentColor" />}
</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 && (
{!isSelectionMode && playlists && playlists.length > 0 && onAddToPlaylist && (
<div className="relative">
<button
onClick={togglePlaylistMenu}
@@ -124,6 +138,35 @@ export default function SongCard({ song, onPlay, onDelete, playlists, onAddToPla
</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
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"
@@ -131,6 +174,7 @@ export default function SongCard({ song, onPlay, onDelete, playlists, onAddToPla
>
<Trash2 size={16} className="text-white" />
</button>
)}
</div>
<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}
</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">
<p className="text-gray-400 text-sm truncate flex-1" title={song.channelName}>
{song.channelName}
+1099
View File
File diff suppressed because it is too large. Load diff
+57
View File
@@ -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}`)
}
}
-105
View File
@@ -8,7 +8,6 @@
"name": "bitify",
"version": "1.0.0",
"dependencies": {
"@prisma/client": "^5.22.0",
"lucide-react": "^0.454.0",
"next": "^15.0.3",
"react": "^18.3.1",
@@ -20,7 +19,6 @@
"@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"
}
@@ -698,74 +696,6 @@
"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": {
"version": "0.5.15",
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz",
@@ -1141,21 +1071,6 @@
"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": {
"version": "4.2.11",
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
@@ -1604,26 +1519,6 @@
"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": {
"version": "18.3.1",
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",