From 706d808b835035d5f6448291aa1cb53d4bfe5b7f Mon Sep 17 00:00:00 2001 From: bitc0de Date: Thu, 25 Dec 2025 13:15:34 +0100 Subject: [PATCH] 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 --- .github/workflows/docker-publish.yml | 2 + README.md | 113 +-- app/api/playlists/route.ts | 54 +- app/page.tsx | 152 +++- app/playlists/page.tsx | 112 ++- components/PlayerBar.tsx | 6 +- components/SongCard.tsx | 149 +++- data/db.json | 1099 ++++++++++++++++++++++++++ lib/ytdlp.ts | 57 ++ package-lock.json | 105 --- 10 files changed, 1604 insertions(+), 245 deletions(-) create mode 100644 data/db.json diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index 935a507..cffd0f7 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -2,6 +2,8 @@ name: Build and Push Docker Image on: push: + branches: + - main tags: - 'v*.*.*' workflow_dispatch: diff --git a/README.md b/README.md index e808531..7fa0451 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/app/api/playlists/route.ts b/app/api/playlists/route.ts index a7d6649..d4c45a2 100644 --- a/app/api/playlists/route.ts +++ b/app/api/playlists/route.ts @@ -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( diff --git a/app/page.tsx b/app/page.tsx index 6d63a7c..bd623c2 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -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([]) const [playlists, setPlaylists] = useState([]) const [isLoading, setIsLoading] = useState(false) + const [isSelectionMode, setIsSelectionMode] = useState(false) + const [selectedSongs, setSelectedSongs] = useState>(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') @@ -134,9 +218,48 @@ export default function Home() {
- + + {/* Selection mode bar */} + {isSelectionMode && ( +
+
+ + + {selectedSongs.size} of {songs.length} selected + +
+
+ + {selectedSongs.size > 0 && ( + + )} +
+
+ )} +
- {songs.length === 0 ? ( + {songs.length === 0 ? (

@@ -148,8 +271,21 @@ export default function Home() {

) : ( -
- {songs.map((song) => ( +
+ {!isSelectionMode && ( +
+ +
+ )} +
+ {songs.map((song) => ( ))} +
)}
diff --git a/app/playlists/page.tsx b/app/playlists/page.tsx index f83aa92..c037499 100644 --- a/app/playlists/page.tsx +++ b/app/playlists/page.tsx @@ -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([]) const [selectedPlaylist, setSelectedPlaylist] = useState(null) const [newPlaylistName, setNewPlaylistName] = useState('') + const [newPlaylistUrl, setNewPlaylistUrl] = useState('') const [showCreateForm, setShowCreateForm] = useState(false) const [playlistSongs, setPlaylistSongs] = useState([]) + 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]) + 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) - showToast('Playlist created', 'success') + } 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() {
-
+ {/* Mobile header with menu button */} +
+

Playlists

+ +
+ +
{/* Playlists sidebar */} -
+
{!showCreateForm ? (
+ {/* Mobile overlay */} + {showSidebar && ( +
setShowSidebar(false)} + /> + )} + {/* Playlist content */}
{!selectedPlaylist ? ( @@ -301,6 +390,9 @@ export default function PlaylistsPage() { song={ps.song} onPlay={handlePlaySong} onDelete={handleRemoveSongFromPlaylist} + playlists={playlists} + currentPlaylistId={selectedPlaylist.id} + onMoveToPlaylist={handleMoveToPlaylist} /> ))}
diff --git a/components/PlayerBar.tsx b/components/PlayerBar.tsx index 2c51097..e09cd95 100644 --- a/components/PlayerBar.tsx +++ b/components/PlayerBar.tsx @@ -180,7 +180,7 @@ export default function PlayerBar({ if (!currentSong) { return ( -
+
Select a song to start playing
@@ -191,8 +191,8 @@ export default function PlayerBar({ return ( <>