Now it can download , play add to playlist, artist list page

This commit is contained in:
2025-10-30 15:10:03 +11:00
parent 3d2e95e858
commit b3bb5668b7
14 changed files with 794 additions and 102 deletions
+53
View File
@@ -0,0 +1,53 @@
from fastapi import APIRouter, Depends
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func
from typing import List
from app.db.session import get_db
from app.models.models import Music
from app.schemas.schemas import Music as MusicSchema
from pydantic import BaseModel
router = APIRouter()
class Artist(BaseModel):
name: str
song_count: int
class Config:
from_attributes = True
@router.get("/", response_model=List[Artist])
async def get_artists(db: AsyncSession = Depends(get_db)):
"""Get all artists with song counts"""
result = await db.execute(
select(
Music.artist,
func.count(Music.id).label('song_count')
)
.where(Music.artist.isnot(None))
.where(Music.artist != "")
.where(Music.artist != "Unknown")
.group_by(Music.artist)
.order_by(func.count(Music.id).desc())
)
artists = []
for row in result:
artists.append(Artist(name=row[0], song_count=row[1]))
return artists
@router.get("/{artist_name}", response_model=List[MusicSchema])
async def get_artist_songs(artist_name: str, db: AsyncSession = Depends(get_db)):
"""Get all songs by a specific artist"""
result = await db.execute(
select(Music)
.where(Music.artist == artist_name)
.order_by(Music.created_at.desc())
)
songs = result.scalars().all()
return songs
+2 -1
View File
@@ -47,7 +47,8 @@ async def process_download(
file_path=relative_path,
file_size=os.path.getsize(file_path),
source_url=url,
source_type=source_type
source_type=source_type,
thumbnail=metadata.get("thumbnail") # Add thumbnail
)
db.add(db_music)
+13 -10
View File
@@ -1,6 +1,7 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from sqlalchemy.orm import selectinload
from typing import List
from app.db.session import get_db
@@ -13,7 +14,9 @@ router = APIRouter()
@router.get("/", response_model=List[PlaylistSchema])
async def get_playlists(db: AsyncSession = Depends(get_db)):
"""Get all playlists"""
result = await db.execute(select(Playlist))
result = await db.execute(
select(Playlist).options(selectinload(Playlist.music_items))
)
playlists = result.scalars().all()
return playlists
@@ -35,7 +38,7 @@ async def create_playlist(
db_playlist = Playlist(**playlist.model_dump())
db.add(db_playlist)
await db.commit()
await db.refresh(db_playlist)
await db.refresh(db_playlist, ['music_items'])
return db_playlist
@@ -43,7 +46,7 @@ async def create_playlist(
async def get_playlist(playlist_id: int, db: AsyncSession = Depends(get_db)):
"""Get a specific playlist with all its music"""
result = await db.execute(
select(Playlist).where(Playlist.id == playlist_id)
select(Playlist).options(selectinload(Playlist.music_items)).where(Playlist.id == playlist_id)
)
playlist = result.scalar_one_or_none()
if not playlist:
@@ -59,7 +62,7 @@ async def update_playlist(
):
"""Update playlist details"""
result = await db.execute(
select(Playlist).where(Playlist.id == playlist_id)
select(Playlist).options(selectinload(Playlist.music_items)).where(Playlist.id == playlist_id)
)
playlist = result.scalar_one_or_none()
if not playlist:
@@ -69,7 +72,7 @@ async def update_playlist(
setattr(playlist, field, value)
await db.commit()
await db.refresh(playlist)
await db.refresh(playlist, ['music_items'])
return playlist
@@ -96,9 +99,9 @@ async def add_music_to_playlist(
db: AsyncSession = Depends(get_db)
):
"""Add music to playlist"""
# Get playlist
# Get playlist with music_items loaded
result = await db.execute(
select(Playlist).where(Playlist.id == playlist_id)
select(Playlist).options(selectinload(Playlist.music_items)).where(Playlist.id == playlist_id)
)
playlist = result.scalar_one_or_none()
if not playlist:
@@ -130,9 +133,9 @@ async def remove_music_from_playlist(
db: AsyncSession = Depends(get_db)
):
"""Remove music from playlist"""
# Get playlist
# Get playlist with music_items loaded
result = await db.execute(
select(Playlist).where(Playlist.id == playlist_id)
select(Playlist).options(selectinload(Playlist.music_items)).where(Playlist.id == playlist_id)
)
playlist = result.scalar_one_or_none()
if not playlist:
@@ -162,7 +165,7 @@ async def get_playlist_by_name(
):
"""Get playlist by name"""
result = await db.execute(
select(Playlist).where(Playlist.name == playlist_name)
select(Playlist).options(selectinload(Playlist.music_items)).where(Playlist.name == playlist_name)
)
playlist = result.scalar_one_or_none()
if not playlist:
+168 -6
View File
@@ -33,6 +33,45 @@ class MusicDownloader:
Returns: (success, file_path, error_message)
"""
try:
# First, extract info to get metadata
info_cmd = [
"yt-dlp",
"--dump-json",
"--no-playlist",
url
]
if self.proxy:
info_cmd.extend(["--proxy", self.proxy])
info_process = await asyncio.create_subprocess_exec(
*info_cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE
)
info_stdout, _ = await info_process.communicate()
# Extract metadata from info
import json
video_info = {}
artist_name = None
thumbnail_url = None
if info_process.returncode == 0 and info_stdout:
try:
video_info = json.loads(info_stdout.decode())
# Try to extract artist from various fields
artist_name = (
video_info.get('artist') or
video_info.get('creator') or
video_info.get('uploader') or
video_info.get('channel')
)
# Get best thumbnail
thumbnail_url = video_info.get('thumbnail')
except:
pass
# Prepare output template
if output_name:
title = f"{output_name}.%(ext)s"
@@ -49,6 +88,9 @@ class MusicDownloader:
"--paths", self.download_path,
"-o", title,
"--ffmpeg-location", self.ffmpeg_location,
"--embed-metadata", # Embed metadata
"--parse-metadata", "%(artist)s:%(meta_artist)s", # Parse artist
"--parse-metadata", "%(uploader)s:%(meta_artist)s", # Fallback to uploader
]
if self.proxy:
@@ -71,6 +113,17 @@ class MusicDownloader:
# Find the downloaded file
output_file = await self._find_downloaded_file(output_name)
if output_file:
# Download and embed thumbnail
thumbnail_path = None
if thumbnail_url:
thumbnail_path = await self._download_thumbnail(thumbnail_url, output_file)
if thumbnail_path:
await self._embed_thumbnail(output_file, thumbnail_path)
# Embed artist info if we extracted it
if artist_name:
await self._embed_artist_metadata(output_file, artist_name, video_info)
logger.info(f"Download successful: {output_file}")
return True, output_file, None
else:
@@ -109,6 +162,89 @@ class MusicDownloader:
logger.error(f"Error finding downloaded file: {e}")
return None
async def _embed_artist_metadata(self, file_path: str, artist: str, video_info: dict = None):
"""Embed artist and other metadata into MP3 file"""
try:
audio = MP3(file_path, ID3=ID3)
# Add ID3 tag if it doesn't exist
try:
audio.add_tags()
except mutagen.id3.error:
pass
# Set artist
audio.tags.add(TPE1(encoding=3, text=artist))
# Set title if available
if video_info and video_info.get('title'):
audio.tags.add(TIT2(encoding=3, text=video_info['title']))
# Set album if available
if video_info and video_info.get('album'):
audio.tags.add(TALB(encoding=3, text=video_info['album']))
audio.save()
logger.info(f"Embedded artist metadata: {artist}")
except Exception as e:
logger.warning(f"Could not embed metadata: {e}")
async def _download_thumbnail(self, thumbnail_url: str, audio_file_path: str) -> Optional[str]:
"""Download thumbnail from URL"""
try:
# Create thumbnails directory
thumbnails_dir = os.path.join(settings.MUSIC_DIR, "thumbnails")
os.makedirs(thumbnails_dir, exist_ok=True)
# Generate thumbnail filename based on audio file
audio_basename = os.path.splitext(os.path.basename(audio_file_path))[0]
thumbnail_path = os.path.join(thumbnails_dir, f"{audio_basename}.jpg")
# Download thumbnail
async with aiohttp.ClientSession() as session:
async with session.get(thumbnail_url) as response:
if response.status == 200:
with open(thumbnail_path, 'wb') as f:
f.write(await response.read())
logger.info(f"Downloaded thumbnail: {thumbnail_path}")
return thumbnail_path
return None
except Exception as e:
logger.warning(f"Could not download thumbnail: {e}")
return None
async def _embed_thumbnail(self, audio_file_path: str, thumbnail_path: str):
"""Embed thumbnail into MP3 file"""
try:
audio = MP3(audio_file_path, ID3=ID3)
# Add ID3 tag if it doesn't exist
try:
audio.add_tags()
except mutagen.id3.error:
pass
# Read thumbnail data
with open(thumbnail_path, 'rb') as img_file:
img_data = img_file.read()
# Add cover art
audio.tags.add(
APIC(
encoding=3,
mime='image/jpeg',
type=3, # Cover (front)
desc='Cover',
data=img_data
)
)
audio.save()
logger.info(f"Embedded thumbnail into {audio_file_path}")
except Exception as e:
logger.warning(f"Could not embed thumbnail: {e}")
async def download_playlist(
self,
url: str,
@@ -162,17 +298,43 @@ class MusicDownloader:
async def get_music_metadata(self, file_path: str) -> dict:
"""Extract metadata from audio file using mutagen"""
try:
audio = mutagen.File(file_path, easy=True)
if audio is None:
# First get basic metadata with easy=True
audio_easy = mutagen.File(file_path, easy=True)
if audio_easy is None:
return {}
metadata = {
"title": audio.get("title", [os.path.basename(file_path)])[0] if audio.get("title") else os.path.basename(file_path),
"artist": audio.get("artist", ["Unknown"])[0] if audio.get("artist") else "Unknown",
"album": audio.get("album", [""])[0] if audio.get("album") else "",
"duration": audio.info.length if hasattr(audio, 'info') else 0,
"title": audio_easy.get("title", [os.path.basename(file_path)])[0] if audio_easy.get("title") else os.path.basename(file_path),
"artist": audio_easy.get("artist", ["Unknown"])[0] if audio_easy.get("artist") else "Unknown",
"album": audio_easy.get("album", [""])[0] if audio_easy.get("album") else "",
"duration": audio_easy.info.length if hasattr(audio_easy, 'info') else 0,
}
# Now extract thumbnail from ID3 tags
try:
audio = MP3(file_path, ID3=ID3)
if audio.tags:
# Look for APIC (attached picture) frames
for tag in audio.tags.values():
if isinstance(tag, APIC):
# Save thumbnail to thumbnails directory
thumbnails_dir = os.path.join(settings.MUSIC_DIR, "thumbnails")
os.makedirs(thumbnails_dir, exist_ok=True)
audio_basename = os.path.splitext(os.path.basename(file_path))[0]
thumbnail_filename = f"{audio_basename}.jpg"
thumbnail_path = os.path.join(thumbnails_dir, thumbnail_filename)
# Save thumbnail
with open(thumbnail_path, 'wb') as img_file:
img_file.write(tag.data)
# Store relative path for database
metadata["thumbnail"] = f"thumbnails/{thumbnail_filename}"
break
except Exception as e:
logger.warning(f"Could not extract thumbnail: {e}")
return metadata
except Exception as e:
logger.error(f"Error extracting metadata: {e}")
+2 -1
View File
@@ -3,7 +3,7 @@ from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from app.api import music, playlist, download, search, stream
from app.api import music, playlist, download, search, stream, artist
from app.core.config import settings
from app.db.session import init_db
@@ -43,6 +43,7 @@ app.include_router(playlist.router, prefix="/api/playlists", tags=["playlists"])
app.include_router(download.router, prefix="/api/download", tags=["download"])
app.include_router(search.router, prefix="/api/search", tags=["search"])
app.include_router(stream.router, prefix="/api", tags=["stream"])
app.include_router(artist.router, prefix="/api/artists", tags=["artists"])
@app.get("/")
+4
View File
@@ -6,6 +6,8 @@ import MusicLibrary from './components/MusicLibrary'
import SearchPage from './components/search/SearchPage'
import PlaylistsPage from './components/playlist/PlaylistsPage'
import DownloadCenter from './components/download/DownloadCenter'
import ArtistsPage from './components/artist/ArtistsPage'
import ArtistDetailPage from './components/artist/ArtistDetailPage'
import Navigation from './components/Navigation'
import { Toaster } from 'sonner'
@@ -82,6 +84,8 @@ function App() {
<Routes>
<Route path="/" element={<MusicLibrary onPlayMusic={playMusic} />} />
<Route path="/search" element={<SearchPage onPlayMusic={playMusic} />} />
<Route path="/artists" element={<ArtistsPage />} />
<Route path="/artists/:artistName" element={<ArtistDetailPage onPlayMusic={playMusic} />} />
<Route path="/playlists" element={<PlaylistsPage onPlayMusic={playMusic} />} />
<Route path="/downloads" element={<DownloadCenter onPlayMusic={playMusic} />} />
</Routes>
+6
View File
@@ -61,3 +61,9 @@ export const searchApi = {
searchBilibili: (query: string, limit: number = 10) =>
api.get('/search/bilibili', { params: { q: query, limit } }),
}
// Artist API
export const artistApi = {
getAll: () => api.get('/artists/'),
getArtistSongs: (artistName: string) => api.get(`/artists/${encodeURIComponent(artistName)}`),
}
+26 -3
View File
@@ -1,4 +1,5 @@
import { useQuery } from '@tanstack/react-query'
import { useNavigate } from 'react-router-dom'
import { musicApi } from '@/api/client'
import { Music } from '@/types'
import { Button } from '@/components/ui/button'
@@ -9,6 +10,7 @@ interface MusicLibraryProps {
}
export default function MusicLibrary({ onPlayMusic }: MusicLibraryProps) {
const navigate = useNavigate()
const { data: musicList = [], isLoading } = useQuery({
queryKey: ['music'],
queryFn: async () => {
@@ -31,6 +33,18 @@ export default function MusicLibrary({ onPlayMusic }: MusicLibraryProps) {
key={music.id}
className="flex items-center gap-4 p-3 rounded-lg hover:bg-accent transition-colors"
>
{music.thumbnail ? (
<img
src={`/music/${music.thumbnail}`}
alt={music.title}
className="w-12 h-12 rounded object-cover flex-shrink-0"
/>
) : (
<div className="w-12 h-12 rounded bg-secondary flex items-center justify-center flex-shrink-0">
<Play className="h-6 w-6 text-muted-foreground" />
</div>
)}
<div className="flex-shrink-0">
<Button
size="icon"
@@ -43,9 +57,18 @@ export default function MusicLibrary({ onPlayMusic }: MusicLibraryProps) {
<div className="flex-1 min-w-0">
<h3 className="font-medium truncate">{music.title}</h3>
<p className="text-sm text-muted-foreground truncate">
{music.artist || 'Unknown Artist'}
</p>
{music.artist && music.artist !== 'Unknown' ? (
<button
onClick={() => navigate(`/artists/${encodeURIComponent(music.artist!)}`)}
className="text-sm text-muted-foreground truncate hover:underline text-left"
>
{music.artist}
</button>
) : (
<p className="text-sm text-muted-foreground truncate">
{music.artist || 'Unknown Artist'}
</p>
)}
</div>
<div className="text-sm text-muted-foreground">
+2 -1
View File
@@ -1,5 +1,5 @@
import { Link, useLocation } from 'react-router-dom'
import { Home, Search, ListMusic, Download } from 'lucide-react'
import { Home, Search, ListMusic, Download, Music2 } from 'lucide-react'
export default function Navigation() {
const location = useLocation()
@@ -7,6 +7,7 @@ export default function Navigation() {
const navItems = [
{ path: '/', label: 'Library', icon: Home },
{ path: '/search', label: 'Search', icon: Search },
{ path: '/artists', label: 'Artists', icon: Music2 },
{ path: '/playlists', label: 'Playlists', icon: ListMusic },
{ path: '/downloads', label: 'Downloads', icon: Download },
]
@@ -0,0 +1,126 @@
import { useQuery } from '@tanstack/react-query'
import { artistApi } from '@/api/client'
import { Music } from '@/types'
import { useParams, useNavigate } from 'react-router-dom'
import { Button } from '@/components/ui/button'
import { ArrowLeft, Play, Loader2 } from 'lucide-react'
import { formatDuration } from '@/lib/utils'
interface ArtistDetailProps {
onPlayMusic: (music: Music, musicList?: Music[]) => void
}
export default function ArtistDetailPage({ onPlayMusic }: ArtistDetailProps) {
const { artistName } = useParams<{ artistName: string }>()
const navigate = useNavigate()
const decodedArtistName = decodeURIComponent(artistName || '')
const { data: songs, isLoading } = useQuery({
queryKey: ['artist-songs', decodedArtistName],
queryFn: async () => {
const response = await artistApi.getArtistSongs(decodedArtistName)
return response.data as Music[]
},
enabled: !!decodedArtistName,
})
const handlePlayAll = () => {
if (songs && songs.length > 0) {
onPlayMusic(songs[0], songs)
}
}
if (isLoading) {
return (
<div className="max-w-screen-xl mx-auto p-4">
<div className="flex justify-center items-center h-64">
<Loader2 className="h-8 w-8 animate-spin" />
</div>
</div>
)
}
return (
<div className="max-w-screen-xl mx-auto p-4">
<div className="mb-6">
<Button
variant="ghost"
size="sm"
onClick={() => navigate('/artists')}
className="mb-4"
>
<ArrowLeft className="h-4 w-4 mr-2" />
Back to Artists
</Button>
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold">{decodedArtistName}</h1>
<p className="text-muted-foreground mt-1">
{songs?.length || 0} {songs?.length === 1 ? 'song' : 'songs'}
</p>
</div>
{songs && songs.length > 0 && (
<Button onClick={handlePlayAll} size="lg">
<Play className="h-5 w-5 mr-2" />
Play All
</Button>
)}
</div>
</div>
{songs && songs.length > 0 ? (
<div className="space-y-1">
{songs.map((song, index) => (
<div
key={song.id}
className="flex items-center gap-4 p-3 rounded-lg hover:bg-accent cursor-pointer group"
onClick={() => onPlayMusic(song, songs)}
>
<div className="w-8 text-center text-muted-foreground group-hover:hidden">
{index + 1}
</div>
<Button
variant="ghost"
size="icon"
className="hidden group-hover:flex w-8"
onClick={(e) => {
e.stopPropagation()
onPlayMusic(song, songs)
}}
>
<Play className="h-4 w-4" />
</Button>
{song.thumbnail ? (
<img
src={`/music/${song.thumbnail}`}
alt={song.title}
className="w-12 h-12 object-cover rounded"
/>
) : (
<div className="w-12 h-12 rounded bg-secondary" />
)}
<div className="flex-1 min-w-0">
<h3 className="font-medium truncate">{song.title}</h3>
{song.album && (
<p className="text-sm text-muted-foreground truncate">{song.album}</p>
)}
</div>
<div className="text-sm text-muted-foreground">
{formatDuration(song.duration || 0)}
</div>
</div>
))}
</div>
) : (
<div className="text-center py-12">
<p className="text-muted-foreground">No songs found for this artist</p>
</div>
)}
</div>
)
}
@@ -0,0 +1,65 @@
import { useQuery } from '@tanstack/react-query'
import { artistApi } from '@/api/client'
import { Artist } from '@/types'
import { useNavigate } from 'react-router-dom'
import { Music2, Loader2 } from 'lucide-react'
export default function ArtistsPage() {
const navigate = useNavigate()
const { data: artists, isLoading } = useQuery({
queryKey: ['artists'],
queryFn: async () => {
const response = await artistApi.getAll()
return response.data as Artist[]
},
})
if (isLoading) {
return (
<div className="max-w-screen-xl mx-auto p-4">
<div className="flex justify-center items-center h-64">
<Loader2 className="h-8 w-8 animate-spin" />
</div>
</div>
)
}
return (
<div className="max-w-screen-xl mx-auto p-4">
<h2 className="text-2xl font-bold mb-6">Artists</h2>
{artists && artists.length > 0 ? (
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
{artists.map((artist) => (
<button
key={artist.name}
onClick={() => navigate(`/artists/${encodeURIComponent(artist.name)}`)}
className="p-4 rounded-lg bg-card hover:bg-accent transition-colors text-left"
>
<div className="flex items-center gap-3 mb-2">
<div className="w-12 h-12 rounded-full bg-primary/10 flex items-center justify-center">
<Music2 className="h-6 w-6 text-primary" />
</div>
<div className="flex-1 min-w-0">
<h3 className="font-semibold truncate">{artist.name}</h3>
<p className="text-sm text-muted-foreground">
{artist.song_count} {artist.song_count === 1 ? 'song' : 'songs'}
</p>
</div>
</div>
</button>
))}
</div>
) : (
<div className="text-center py-12">
<Music2 className="h-12 w-12 mx-auto text-muted-foreground mb-4" />
<p className="text-muted-foreground">No artists yet</p>
<p className="text-sm text-muted-foreground mt-2">
Download some music to see artists here!
</p>
</div>
)}
</div>
)
}
+190 -80
View File
@@ -1,9 +1,14 @@
import { useState, useEffect, useRef } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { useNavigate } from 'react-router-dom'
import { Music } from '@/types'
import { Slider } from '@/components/ui/slider'
import { Button } from '@/components/ui/button'
import { Play, Pause, SkipBack, SkipForward, Volume2, VolumeX } from 'lucide-react'
import { Play, Pause, SkipBack, SkipForward, Volume2, VolumeX, Heart, ListPlus } from 'lucide-react'
import { formatDuration } from '@/lib/utils'
import { playlistApi } from '@/api/client'
import { toast } from 'sonner'
import PlaylistSelector from './PlaylistSelector'
interface PlayerProps {
currentMusic: Music | null
@@ -26,6 +31,61 @@ export default function Player({
const [duration, setDuration] = useState(0)
const [volume, setVolume] = useState(1)
const [isMuted, setIsMuted] = useState(false)
const [showPlaylistSelector, setShowPlaylistSelector] = useState(false)
const [isLiked, setIsLiked] = useState(false)
const queryClient = useQueryClient()
const navigate = useNavigate()
// Check if current music is in Liked playlist
useQuery({
queryKey: ['liked-playlist', currentMusic?.id],
queryFn: async () => {
if (!currentMusic) return null
try {
const response = await playlistApi.getByName('Liked')
const likedPlaylist = response.data
const inLiked = likedPlaylist.music_items?.some((m: Music) => m.id === currentMusic.id)
setIsLiked(inLiked)
return likedPlaylist
} catch {
setIsLiked(false)
return null
}
},
enabled: !!currentMusic && currentMusic.id !== 0,
})
const toggleLikeMutation = useMutation({
mutationFn: async () => {
if (!currentMusic) return
// Ensure Liked playlist exists
let likedPlaylist
try {
const response = await playlistApi.getByName('Liked')
likedPlaylist = response.data
} catch {
// Create Liked playlist if it doesn't exist
const createResponse = await playlistApi.create({ name: 'Liked', description: 'Your favorite songs' })
likedPlaylist = createResponse.data
}
if (isLiked) {
await playlistApi.removeMusic(likedPlaylist.id, currentMusic.id)
} else {
await playlistApi.addMusic(likedPlaylist.id, currentMusic.id)
}
},
onSuccess: () => {
setIsLiked(!isLiked)
queryClient.invalidateQueries({ queryKey: ['liked-playlist'] })
queryClient.invalidateQueries({ queryKey: ['playlists'] })
toast.success(isLiked ? 'Removed from Liked' : 'Added to Liked')
},
onError: () => {
toast.error('Failed to update Liked')
},
})
useEffect(() => {
const audio = audioRef.current
@@ -75,89 +135,139 @@ export default function Player({
}
return (
<div className="fixed bottom-0 left-0 right-0 bg-card border-t border-border p-4 md:p-6">
<div className="max-w-screen-xl mx-auto">
{/* Progress bar */}
<div className="mb-3">
<Slider
value={[currentTime]}
max={duration || 100}
step={0.1}
onValueChange={handleSeek}
className="w-full"
/>
<div className="flex justify-between text-xs text-muted-foreground mt-1">
<span>{formatDuration(currentTime)}</span>
<span>{formatDuration(duration)}</span>
</div>
</div>
<div className="flex items-center justify-between gap-4">
{/* Music info */}
<div className="flex-1 min-w-0">
<h3 className="font-semibold truncate">{currentMusic.title}</h3>
<p className="text-sm text-muted-foreground truncate">
{currentMusic.artist || 'Unknown Artist'}
</p>
</div>
{/* Controls */}
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="icon"
onClick={onPrevious}
className="hidden md:flex"
>
<SkipBack className="h-5 w-5" />
</Button>
<Button
variant="default"
size="icon"
onClick={onTogglePlay}
className="h-10 w-10"
>
{isPlaying ? (
<Pause className="h-5 w-5" />
) : (
<Play className="h-5 w-5" />
)}
</Button>
<Button
variant="ghost"
size="icon"
onClick={onNext}
className="hidden md:flex"
>
<SkipForward className="h-5 w-5" />
</Button>
</div>
{/* Volume control - desktop only */}
<div className="hidden md:flex items-center gap-2 flex-1 justify-end">
<Button
variant="ghost"
size="icon"
onClick={toggleMute}
>
{isMuted || volume === 0 ? (
<VolumeX className="h-5 w-5" />
) : (
<Volume2 className="h-5 w-5" />
)}
</Button>
<>
<div className="fixed bottom-0 left-0 right-0 bg-card border-t border-border p-4 md:p-6">
<div className="max-w-screen-xl mx-auto">
{/* Progress bar */}
<div className="mb-3">
<Slider
value={[isMuted ? 0 : volume]}
max={1}
step={0.01}
onValueChange={handleVolumeChange}
className="w-24"
value={[currentTime]}
max={duration || 100}
step={0.1}
onValueChange={handleSeek}
className="w-full"
/>
<div className="flex justify-between text-xs text-muted-foreground mt-1">
<span>{formatDuration(currentTime)}</span>
<span>{formatDuration(duration)}</span>
</div>
</div>
<div className="flex items-center justify-between gap-4">
{/* Album art and music info */}
<div className="flex items-center gap-3 flex-1 min-w-0">
{currentMusic.thumbnail && (
<img
src={`/music/${currentMusic.thumbnail}`}
alt={currentMusic.title}
className="w-14 h-14 rounded object-cover"
/>
)}
<div className="flex-1 min-w-0">
<h3 className="font-semibold truncate">{currentMusic.title}</h3>
{currentMusic.artist && currentMusic.artist !== 'Unknown' ? (
<button
onClick={() => navigate(`/artists/${encodeURIComponent(currentMusic.artist!)}`)}
className="text-sm text-muted-foreground truncate hover:underline text-left"
>
{currentMusic.artist}
</button>
) : (
<p className="text-sm text-muted-foreground truncate">
{currentMusic.artist || 'Unknown Artist'}
</p>
)}
</div>
</div>
{/* Controls */}
<div className="flex items-center gap-2">
{/* Like and Playlist buttons */}
{currentMusic.id !== 0 && (
<>
<Button
variant="ghost"
size="icon"
onClick={() => toggleLikeMutation.mutate()}
disabled={toggleLikeMutation.isPending}
>
<Heart className={`h-5 w-5 ${isLiked ? 'fill-red-500 text-red-500' : ''}`} />
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => setShowPlaylistSelector(true)}
>
<ListPlus className="h-5 w-5" />
</Button>
</>
)}
<Button
variant="ghost"
size="icon"
onClick={onPrevious}
className="hidden md:flex"
>
<SkipBack className="h-5 w-5" />
</Button>
<Button
variant="default"
size="icon"
onClick={onTogglePlay}
className="h-10 w-10"
>
{isPlaying ? (
<Pause className="h-5 w-5" />
) : (
<Play className="h-5 w-5" />
)}
</Button>
<Button
variant="ghost"
size="icon"
onClick={onNext}
className="hidden md:flex"
>
<SkipForward className="h-5 w-5" />
</Button>
</div>
{/* Volume control - desktop only */}
<div className="hidden md:flex items-center gap-2 flex-1 justify-end">
<Button
variant="ghost"
size="icon"
onClick={toggleMute}
>
{isMuted || volume === 0 ? (
<VolumeX className="h-5 w-5" />
) : (
<Volume2 className="h-5 w-5" />
)}
</Button>
<Slider
value={[isMuted ? 0 : volume]}
max={1}
step={0.01}
onValueChange={handleVolumeChange}
className="w-24"
/>
</div>
</div>
</div>
</div>
</div>
{/* Playlist Selector Modal */}
{showPlaylistSelector && currentMusic && (
<PlaylistSelector
music={currentMusic}
onClose={() => setShowPlaylistSelector(false)}
/>
)}
</>
)
}
@@ -0,0 +1,132 @@
import { useState } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { playlistApi } from '@/api/client'
import { Music, Playlist } from '@/types'
import { Button } from '@/components/ui/button'
import { X, Plus, Check } from 'lucide-react'
import { toast } from 'sonner'
interface PlaylistSelectorProps {
music: Music
onClose: () => void
}
export default function PlaylistSelector({ music, onClose }: PlaylistSelectorProps) {
const queryClient = useQueryClient()
const [newPlaylistName, setNewPlaylistName] = useState('')
const { data: playlists, isLoading } = useQuery({
queryKey: ['playlists'],
queryFn: async () => {
const response = await playlistApi.getAll()
return response.data as Playlist[]
},
})
const createPlaylistMutation = useMutation({
mutationFn: (name: string) => playlistApi.create({ name }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['playlists'] })
setNewPlaylistName('')
toast.success('Playlist created')
},
})
const addToPlaylistMutation = useMutation({
mutationFn: ({ playlistId }: { playlistId: number }) =>
playlistApi.addMusic(playlistId, music.id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['playlists'] })
toast.success('Added to playlist')
},
onError: () => {
toast.error('Failed to add to playlist')
},
})
const removeFromPlaylistMutation = useMutation({
mutationFn: ({ playlistId }: { playlistId: number }) =>
playlistApi.removeMusic(playlistId, music.id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['playlists'] })
toast.success('Removed from playlist')
},
})
const isInPlaylist = (playlist: Playlist) => {
return playlist.music_items?.some(m => m.id === music.id)
}
const handleTogglePlaylist = (playlist: Playlist) => {
if (isInPlaylist(playlist)) {
removeFromPlaylistMutation.mutate({ playlistId: playlist.id })
} else {
addToPlaylistMutation.mutate({ playlistId: playlist.id })
}
}
const handleCreatePlaylist = (e: React.FormEvent) => {
e.preventDefault()
if (newPlaylistName.trim()) {
createPlaylistMutation.mutate(newPlaylistName.trim())
}
}
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50" onClick={onClose}>
<div className="bg-card rounded-lg p-6 max-w-md w-full mx-4 max-h-[80vh] overflow-y-auto" onClick={(e) => e.stopPropagation()}>
<div className="flex items-center justify-between mb-4">
<h2 className="text-xl font-bold">Add to Playlist</h2>
<Button variant="ghost" size="icon" onClick={onClose}>
<X className="h-5 w-5" />
</Button>
</div>
<div className="mb-4 p-3 bg-accent rounded-lg">
<p className="font-medium truncate">{music.title}</p>
<p className="text-sm text-muted-foreground truncate">{music.artist || 'Unknown'}</p>
</div>
{/* Create new playlist */}
<form onSubmit={handleCreatePlaylist} className="mb-4">
<div className="flex gap-2">
<input
type="text"
placeholder="New playlist name..."
value={newPlaylistName}
onChange={(e) => setNewPlaylistName(e.target.value)}
className="flex-1 px-3 py-2 bg-background border border-input rounded-md text-sm"
/>
<Button type="submit" size="sm" disabled={!newPlaylistName.trim() || createPlaylistMutation.isPending}>
<Plus className="h-4 w-4" />
</Button>
</div>
</form>
{/* Playlist list */}
<div className="space-y-2">
{isLoading ? (
<p className="text-center text-muted-foreground py-4">Loading playlists...</p>
) : playlists && playlists.length > 0 ? (
playlists.map((playlist) => {
const inPlaylist = isInPlaylist(playlist)
return (
<button
key={playlist.id}
onClick={() => handleTogglePlaylist(playlist)}
className="w-full flex items-center justify-between p-3 rounded-lg hover:bg-accent transition-colors"
disabled={addToPlaylistMutation.isPending || removeFromPlaylistMutation.isPending}
>
<span className="font-medium">{playlist.name}</span>
{inPlaylist && <Check className="h-5 w-5 text-primary" />}
</button>
)
})
) : (
<p className="text-center text-muted-foreground py-4">No playlists yet</p>
)}
</div>
</div>
</div>
)
}
+5
View File
@@ -60,3 +60,8 @@ export interface DownloadStatus {
completed: number
failed: number
}
export interface Artist {
name: string
song_count: number
}