mirror of
https://github.com/wahyd4/you-music.git
synced 2026-08-08 20:59:47 +10:00
Update style
This commit is contained in:
+178
-1
@@ -1,15 +1,25 @@
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, func
|
||||
from typing import List
|
||||
from typing import List, Optional
|
||||
import aiohttp
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from app.db.session import get_db
|
||||
from app.models.models import Music
|
||||
from app.schemas.schemas import Music as MusicSchema
|
||||
from app.core.config import settings
|
||||
from pydantic import BaseModel
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
# Cache directory for artist info
|
||||
CACHE_DIR = os.path.join(settings.BASE_DIR, "data", "cache", "artists")
|
||||
CACHE_DURATION_DAYS = 30 # Cache for 30 days
|
||||
|
||||
|
||||
class Artist(BaseModel):
|
||||
name: str
|
||||
@@ -19,6 +29,128 @@ class Artist(BaseModel):
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class ArtistInfo(BaseModel):
|
||||
name: str
|
||||
image: Optional[str] = None
|
||||
bio: Optional[str] = None
|
||||
listeners: Optional[int] = None
|
||||
playcount: Optional[int] = None
|
||||
|
||||
|
||||
def get_cache_path(artist_name: str) -> str:
|
||||
"""Get cache file path for an artist"""
|
||||
os.makedirs(CACHE_DIR, exist_ok=True)
|
||||
# Use a safe filename
|
||||
safe_name = "".join(c if c.isalnum() or c in (' ', '-', '_') else '_' for c in artist_name)
|
||||
return os.path.join(CACHE_DIR, f"{safe_name}.json")
|
||||
|
||||
|
||||
def get_cached_artist_info(artist_name: str) -> Optional[ArtistInfo]:
|
||||
"""Get cached artist info if it exists and is not expired"""
|
||||
cache_path = get_cache_path(artist_name)
|
||||
|
||||
if not os.path.exists(cache_path):
|
||||
return None
|
||||
|
||||
try:
|
||||
# Check if cache is expired
|
||||
file_time = datetime.fromtimestamp(os.path.getmtime(cache_path))
|
||||
if datetime.now() - file_time > timedelta(days=CACHE_DURATION_DAYS):
|
||||
# Cache expired, remove it
|
||||
os.remove(cache_path)
|
||||
return None
|
||||
|
||||
# Read and return cached data
|
||||
with open(cache_path, 'r', encoding='utf-8') as f:
|
||||
data = json.load(f)
|
||||
return ArtistInfo(**data)
|
||||
except Exception as e:
|
||||
print(f"Error reading cache: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def save_artist_info_to_cache(artist_name: str, info: ArtistInfo):
|
||||
"""Save artist info to cache"""
|
||||
cache_path = get_cache_path(artist_name)
|
||||
|
||||
try:
|
||||
with open(cache_path, 'w', encoding='utf-8') as f:
|
||||
json.dump(info.model_dump(), f, ensure_ascii=False, indent=2)
|
||||
except Exception as e:
|
||||
print(f"Error writing cache: {e}")
|
||||
|
||||
|
||||
async def get_artist_info_from_apis(artist_name: str) -> Optional[ArtistInfo]:
|
||||
"""Fetch artist info from Deezer API (primary) and MusicBrainz (fallback)"""
|
||||
try:
|
||||
# Try Deezer API first (no key needed, has good artist images)
|
||||
deezer_url = "https://api.deezer.com/search/artist"
|
||||
params = {"q": artist_name, "limit": 1}
|
||||
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.get(deezer_url, params=params) as response:
|
||||
if response.status == 200:
|
||||
data = await response.json()
|
||||
|
||||
if data.get("data") and len(data["data"]) > 0:
|
||||
artist = data["data"][0]
|
||||
|
||||
# Deezer provides different image sizes
|
||||
image_url = artist.get("picture_xl") or artist.get("picture_big") or artist.get("picture_medium")
|
||||
|
||||
return ArtistInfo(
|
||||
name=artist.get("name", artist_name),
|
||||
image=image_url,
|
||||
bio=None,
|
||||
listeners=artist.get("nb_fan"), # Number of fans
|
||||
playcount=None
|
||||
)
|
||||
|
||||
# Fallback to MusicBrainz if Deezer fails
|
||||
search_url = "https://musicbrainz.org/ws/2/artist/"
|
||||
params = {
|
||||
"query": artist_name,
|
||||
"fmt": "json",
|
||||
"limit": 1
|
||||
}
|
||||
|
||||
async with session.get(search_url, params=params) as response:
|
||||
if response.status != 200:
|
||||
return None
|
||||
|
||||
data = await response.json()
|
||||
|
||||
if not data.get("artists"):
|
||||
return None
|
||||
|
||||
artist = data["artists"][0]
|
||||
artist_real_name = artist.get("name", artist_name)
|
||||
|
||||
# Get disambiguation and type as "bio"
|
||||
bio_parts = []
|
||||
if artist.get("type"):
|
||||
bio_parts.append(f"Type: {artist['type']}")
|
||||
if artist.get("disambiguation"):
|
||||
bio_parts.append(artist["disambiguation"])
|
||||
if artist.get("country"):
|
||||
bio_parts.append(f"Country: {artist['country']}")
|
||||
|
||||
bio = " • ".join(bio_parts) if bio_parts else None
|
||||
|
||||
return ArtistInfo(
|
||||
name=artist_real_name,
|
||||
image=None, # MusicBrainz doesn't have images
|
||||
bio=bio,
|
||||
listeners=None,
|
||||
playcount=None
|
||||
)
|
||||
|
||||
return None
|
||||
except Exception as e:
|
||||
print(f"Error fetching artist data: {e}")
|
||||
return None
|
||||
|
||||
|
||||
@router.get("/", response_model=List[Artist])
|
||||
async def get_artists(db: AsyncSession = Depends(get_db)):
|
||||
"""Get all artists with song counts"""
|
||||
@@ -41,6 +173,51 @@ async def get_artists(db: AsyncSession = Depends(get_db)):
|
||||
return artists
|
||||
|
||||
|
||||
@router.get("/{artist_name}/info", response_model=ArtistInfo)
|
||||
async def get_artist_info(artist_name: str):
|
||||
"""Get artist information from Deezer and MusicBrainz (with 30-day cache)"""
|
||||
# Try to get from cache first
|
||||
cached_info = get_cached_artist_info(artist_name)
|
||||
if cached_info:
|
||||
return cached_info
|
||||
|
||||
# Fetch from APIs if not cached
|
||||
info = await get_artist_info_from_apis(artist_name)
|
||||
if not info:
|
||||
# Return minimal info if APIs fail
|
||||
info = ArtistInfo(name=artist_name)
|
||||
|
||||
# Save to cache
|
||||
save_artist_info_to_cache(artist_name, info)
|
||||
|
||||
return info
|
||||
|
||||
|
||||
@router.delete("/{artist_name}/cache")
|
||||
async def clear_artist_cache(artist_name: str):
|
||||
"""Clear cached artist info (forces refresh on next request)"""
|
||||
cache_path = get_cache_path(artist_name)
|
||||
|
||||
if os.path.exists(cache_path):
|
||||
os.remove(cache_path)
|
||||
return {"message": f"Cache cleared for {artist_name}"}
|
||||
|
||||
return {"message": "No cache found"}
|
||||
|
||||
|
||||
@router.delete("/cache/all")
|
||||
async def clear_all_artist_cache():
|
||||
"""Clear all cached artist info"""
|
||||
if os.path.exists(CACHE_DIR):
|
||||
count = 0
|
||||
for file in Path(CACHE_DIR).glob("*.json"):
|
||||
file.unlink()
|
||||
count += 1
|
||||
return {"message": f"Cleared {count} cached artists"}
|
||||
|
||||
return {"message": "No cache found"}
|
||||
|
||||
|
||||
@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"""
|
||||
|
||||
+3
-1
@@ -3,7 +3,9 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||
<meta name="description" content="YouMusic - Modern Web Music Player" />
|
||||
<title>YouMusic - Your Music Player</title>
|
||||
</head>
|
||||
|
||||
+85
-7
@@ -5,6 +5,7 @@ import Player from './components/player/Player'
|
||||
import MusicLibrary from './components/MusicLibrary'
|
||||
import SearchPage from './components/search/SearchPage'
|
||||
import PlaylistsPage from './components/playlist/PlaylistsPage'
|
||||
import PlaylistDetailPage from './components/playlist/PlaylistDetailPage'
|
||||
import DownloadCenter from './components/download/DownloadCenter'
|
||||
import ArtistsPage from './components/artist/ArtistsPage'
|
||||
import ArtistDetailPage from './components/artist/ArtistDetailPage'
|
||||
@@ -15,9 +16,58 @@ function App() {
|
||||
const [currentMusic, setCurrentMusic] = useState<Music | null>(null)
|
||||
const [isPlaying, setIsPlaying] = useState(false)
|
||||
const [playlist, setPlaylist] = useState<Music[]>([])
|
||||
const [theme, setTheme] = useState<'light' | 'dark'>('dark')
|
||||
const [playMode, setPlayMode] = useState<'loop' | 'shuffle' | 'repeat-one'>('loop')
|
||||
const audioRef = useRef<HTMLAudioElement>(null)
|
||||
const location = useLocation()
|
||||
|
||||
// Load theme from localStorage on mount
|
||||
useEffect(() => {
|
||||
const savedTheme = localStorage.getItem('theme') as 'light' | 'dark' | null
|
||||
if (savedTheme) {
|
||||
setTheme(savedTheme)
|
||||
} else {
|
||||
// Default to system preference
|
||||
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||
setTheme(prefersDark ? 'dark' : 'light')
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Load play mode from localStorage
|
||||
useEffect(() => {
|
||||
const savedMode = localStorage.getItem('playMode') as 'loop' | 'shuffle' | 'repeat-one' | null
|
||||
if (savedMode) {
|
||||
setPlayMode(savedMode)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Apply theme to document
|
||||
useEffect(() => {
|
||||
if (theme === 'dark') {
|
||||
document.documentElement.classList.add('dark')
|
||||
} else {
|
||||
document.documentElement.classList.remove('dark')
|
||||
}
|
||||
localStorage.setItem('theme', theme)
|
||||
}, [theme])
|
||||
|
||||
// Save play mode to localStorage
|
||||
useEffect(() => {
|
||||
localStorage.setItem('playMode', playMode)
|
||||
}, [playMode])
|
||||
|
||||
const toggleTheme = () => {
|
||||
setTheme(prev => prev === 'dark' ? 'light' : 'dark')
|
||||
}
|
||||
|
||||
const togglePlayMode = () => {
|
||||
setPlayMode(prev => {
|
||||
if (prev === 'loop') return 'shuffle'
|
||||
if (prev === 'shuffle') return 'repeat-one'
|
||||
return 'loop'
|
||||
})
|
||||
}
|
||||
|
||||
const playMusic = (music: Music, musicList?: Music[]) => {
|
||||
setCurrentMusic(music)
|
||||
if (musicList) {
|
||||
@@ -38,11 +88,36 @@ function App() {
|
||||
}
|
||||
|
||||
const playNext = () => {
|
||||
if (currentMusic && playlist.length > 0) {
|
||||
const currentIndex = playlist.findIndex(m => m.id === currentMusic.id)
|
||||
if (currentIndex >= 0 && currentIndex < playlist.length - 1) {
|
||||
playMusic(playlist[currentIndex + 1], playlist)
|
||||
if (!currentMusic || playlist.length === 0) return
|
||||
|
||||
const currentIndex = playlist.findIndex(m => m.id === currentMusic.id)
|
||||
|
||||
if (playMode === 'repeat-one') {
|
||||
// Repeat current song
|
||||
if (audioRef.current) {
|
||||
audioRef.current.currentTime = 0
|
||||
audioRef.current.play()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (playMode === 'shuffle') {
|
||||
// Pick a random song (not the current one)
|
||||
const availableIndexes = playlist
|
||||
.map((_, idx) => idx)
|
||||
.filter(idx => idx !== currentIndex)
|
||||
|
||||
if (availableIndexes.length > 0) {
|
||||
const randomIndex = availableIndexes[Math.floor(Math.random() * availableIndexes.length)]
|
||||
playMusic(playlist[randomIndex], playlist)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Loop mode (default)
|
||||
if (currentIndex >= 0) {
|
||||
const nextIndex = currentIndex < playlist.length - 1 ? currentIndex + 1 : 0
|
||||
playMusic(playlist[nextIndex], playlist)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -77,16 +152,17 @@ function App() {
|
||||
}, [location])
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-screen bg-background">
|
||||
<Navigation />
|
||||
<div className="flex flex-col h-screen overflow-hidden bg-background">
|
||||
<Navigation onToggleTheme={toggleTheme} theme={theme} />
|
||||
|
||||
<main className="flex-1 overflow-y-auto pb-24 md:pb-28">
|
||||
<main className="flex-1 overflow-y-auto overflow-x-hidden pb-24 md:pb-28">
|
||||
<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="/playlists/:playlistId" element={<PlaylistDetailPage onPlayMusic={playMusic} />} />
|
||||
<Route path="/downloads" element={<DownloadCenter onPlayMusic={playMusic} />} />
|
||||
</Routes>
|
||||
</main>
|
||||
@@ -98,6 +174,8 @@ function App() {
|
||||
onNext={playNext}
|
||||
onPrevious={playPrevious}
|
||||
audioRef={audioRef}
|
||||
playMode={playMode}
|
||||
onTogglePlayMode={togglePlayMode}
|
||||
/>
|
||||
|
||||
<audio ref={audioRef} onEnded={playNext} />
|
||||
|
||||
@@ -66,4 +66,5 @@ export const searchApi = {
|
||||
export const artistApi = {
|
||||
getAll: () => api.get('/artists/'),
|
||||
getArtistSongs: (artistName: string) => api.get(`/artists/${encodeURIComponent(artistName)}`),
|
||||
getArtistInfo: (artistName: string) => api.get(`/artists/${encodeURIComponent(artistName)}/info`),
|
||||
}
|
||||
|
||||
@@ -35,7 +35,7 @@ export default function MusicLibrary({ onPlayMusic }: MusicLibraryProps) {
|
||||
>
|
||||
{music.thumbnail ? (
|
||||
<img
|
||||
src={`/music/${music.thumbnail}`}
|
||||
src={music.thumbnail.startsWith('http') ? music.thumbnail : `/music/${music.thumbnail}`}
|
||||
alt={music.title}
|
||||
className="w-12 h-12 rounded object-cover flex-shrink-0"
|
||||
/>
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
import { Link, useLocation } from 'react-router-dom'
|
||||
import { Home, Search, ListMusic, Download, Music2 } from 'lucide-react'
|
||||
import { Home, Search, ListMusic, Download, Music2, Sun, Moon } from 'lucide-react'
|
||||
import { Button } from './ui/button'
|
||||
|
||||
export default function Navigation() {
|
||||
interface NavigationProps {
|
||||
onToggleTheme: () => void
|
||||
theme: 'light' | 'dark'
|
||||
}
|
||||
|
||||
export default function Navigation({ onToggleTheme, theme }: NavigationProps) {
|
||||
const location = useLocation()
|
||||
|
||||
const navItems = [
|
||||
@@ -13,17 +19,17 @@ export default function Navigation() {
|
||||
]
|
||||
|
||||
return (
|
||||
<nav className="bg-card border-b border-border">
|
||||
<nav className="bg-card border-b border-border flex-shrink-0">
|
||||
<div className="max-w-screen-xl mx-auto px-4">
|
||||
<div className="flex items-center justify-between h-16">
|
||||
<h1 className="text-xl font-bold">YouMusic</h1>
|
||||
|
||||
<div className="flex gap-1">
|
||||
<div className="flex items-center gap-1">
|
||||
{navItems.map(({ path, label, icon: Icon }) => (
|
||||
<Link
|
||||
key={path}
|
||||
to={path}
|
||||
className={`flex items-center gap-2 px-4 py-2 rounded-md transition-colors ${
|
||||
className={`flex items-center gap-2 px-3 md:px-4 py-2 rounded-md transition-colors ${
|
||||
location.pathname === path
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'hover:bg-accent'
|
||||
@@ -33,6 +39,21 @@ export default function Navigation() {
|
||||
<span className="hidden md:inline">{label}</span>
|
||||
</Link>
|
||||
))}
|
||||
|
||||
{/* Theme Toggle */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={onToggleTheme}
|
||||
className="ml-2"
|
||||
title={theme === 'dark' ? 'Switch to light mode' : 'Switch to dark mode'}
|
||||
>
|
||||
{theme === 'dark' ? (
|
||||
<Sun className="h-5 w-5" />
|
||||
) : (
|
||||
<Moon className="h-5 w-5" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { artistApi } from '@/api/client'
|
||||
import { Music } from '@/types'
|
||||
import { Music, ArtistInfo } from '@/types'
|
||||
import { useParams, useNavigate } from 'react-router-dom'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { ArrowLeft, Play, Loader2 } from 'lucide-react'
|
||||
@@ -15,6 +15,15 @@ export default function ArtistDetailPage({ onPlayMusic }: ArtistDetailProps) {
|
||||
const navigate = useNavigate()
|
||||
const decodedArtistName = decodeURIComponent(artistName || '')
|
||||
|
||||
const { data: artistInfo } = useQuery({
|
||||
queryKey: ['artist-info', decodedArtistName],
|
||||
queryFn: async () => {
|
||||
const response = await artistApi.getArtistInfo(decodedArtistName)
|
||||
return response.data as ArtistInfo
|
||||
},
|
||||
enabled: !!decodedArtistName,
|
||||
})
|
||||
|
||||
const { data: songs, isLoading } = useQuery({
|
||||
queryKey: ['artist-songs', decodedArtistName],
|
||||
queryFn: async () => {
|
||||
@@ -32,95 +41,127 @@ export default function ArtistDetailPage({ onPlayMusic }: ArtistDetailProps) {
|
||||
|
||||
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 className="min-h-screen flex justify-center items-center">
|
||||
<Loader2 className="h-8 w-8 animate-spin" />
|
||||
</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 className="min-h-screen">
|
||||
{/* Hero Section with Background */}
|
||||
<div className="relative h-80 md:h-96">
|
||||
{/* Background Image with Gradient Overlay */}
|
||||
{artistInfo?.image ? (
|
||||
<>
|
||||
<div
|
||||
className="absolute inset-0 bg-cover bg-center"
|
||||
style={{ backgroundImage: `url(${artistInfo.image})` }}
|
||||
/>
|
||||
<div className="absolute inset-0 bg-gradient-to-b from-black/60 via-black/70 to-background" />
|
||||
</>
|
||||
) : (
|
||||
<div className="absolute inset-0 bg-gradient-to-b from-black via-black/90 to-background" />
|
||||
)}
|
||||
|
||||
{/* Content */}
|
||||
<div className="relative max-w-screen-xl mx-auto px-4 h-full flex flex-col justify-end pb-8">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => navigate('/artists')}
|
||||
className="absolute top-4 left-4 text-white hover:bg-white/20"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4 mr-2" />
|
||||
Back
|
||||
</Button>
|
||||
|
||||
<div className="flex items-end gap-6">
|
||||
{artistInfo?.image && (
|
||||
<div className="hidden md:block">
|
||||
<img
|
||||
src={artistInfo.image}
|
||||
alt={decodedArtistName}
|
||||
className="w-48 h-48 rounded-lg shadow-2xl object-cover"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex-1 text-white">
|
||||
<p className="text-sm font-medium mb-2 opacity-90">Artist</p>
|
||||
<h1 className="text-4xl md:text-6xl font-bold mb-4">{decodedArtistName}</h1>
|
||||
<div className="flex items-center gap-4 text-sm opacity-90">
|
||||
<span>{songs?.length || 0} {songs?.length === 1 ? 'song' : 'songs'}</span>
|
||||
{artistInfo?.listeners && artistInfo.listeners > 0 && (
|
||||
<span>• {artistInfo.listeners.toLocaleString()} fans</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{songs && songs.length > 0 && (
|
||||
<Button onClick={handlePlayAll} size="lg" className="mb-4">
|
||||
<Play className="h-5 w-5 mr-2" />
|
||||
Play
|
||||
</Button>
|
||||
)}
|
||||
</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)
|
||||
}}
|
||||
{/* Song List */}
|
||||
<div className="max-w-screen-xl mx-auto px-4 py-8">
|
||||
{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)}
|
||||
>
|
||||
<Play className="h-4 w-4" />
|
||||
</Button>
|
||||
<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>
|
||||
{song.thumbnail ? (
|
||||
<img
|
||||
src={song.thumbnail.startsWith('http') ? song.thumbnail : `/music/${song.thumbnail}`}
|
||||
alt={song.title}
|
||||
className="w-12 h-12 object-cover rounded"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-12 h-12 rounded bg-secondary" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{formatDuration(song.duration || 0)}
|
||||
<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>
|
||||
) : (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-muted-foreground">No songs found for this artist</p>
|
||||
</div>
|
||||
)}
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-muted-foreground">No songs found for this artist</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ 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, Heart, ListPlus } from 'lucide-react'
|
||||
import { Play, Pause, SkipBack, SkipForward, Volume2, VolumeX, Heart, ListPlus, Repeat, Repeat1, Shuffle } from 'lucide-react'
|
||||
import { formatDuration } from '@/lib/utils'
|
||||
import { playlistApi } from '@/api/client'
|
||||
import { toast } from 'sonner'
|
||||
@@ -17,6 +17,8 @@ interface PlayerProps {
|
||||
onNext: () => void
|
||||
onPrevious: () => void
|
||||
audioRef: React.RefObject<HTMLAudioElement>
|
||||
playMode: 'loop' | 'shuffle' | 'repeat-one'
|
||||
onTogglePlayMode: () => void
|
||||
}
|
||||
|
||||
export default function Player({
|
||||
@@ -26,6 +28,8 @@ export default function Player({
|
||||
onNext,
|
||||
onPrevious,
|
||||
audioRef,
|
||||
playMode,
|
||||
onTogglePlayMode,
|
||||
}: PlayerProps) {
|
||||
const [currentTime, setCurrentTime] = useState(0)
|
||||
const [duration, setDuration] = useState(0)
|
||||
@@ -158,7 +162,7 @@ export default function Player({
|
||||
<div className="flex items-center gap-3 flex-1 min-w-0">
|
||||
{currentMusic.thumbnail && (
|
||||
<img
|
||||
src={`/music/${currentMusic.thumbnail}`}
|
||||
src={currentMusic.thumbnail.startsWith('http') ? currentMusic.thumbnail : `/music/${currentMusic.thumbnail}`}
|
||||
alt={currentMusic.title}
|
||||
className="w-14 h-14 rounded object-cover"
|
||||
/>
|
||||
@@ -234,6 +238,25 @@ export default function Player({
|
||||
>
|
||||
<SkipForward className="h-5 w-5" />
|
||||
</Button>
|
||||
|
||||
{/* Play Mode Toggle */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={onTogglePlayMode}
|
||||
className="hidden md:flex"
|
||||
title={
|
||||
playMode === 'loop'
|
||||
? 'Loop playlist'
|
||||
: playMode === 'shuffle'
|
||||
? 'Shuffle'
|
||||
: 'Repeat one'
|
||||
}
|
||||
>
|
||||
{playMode === 'loop' && <Repeat className="h-5 w-5" />}
|
||||
{playMode === 'shuffle' && <Shuffle className="h-5 w-5" />}
|
||||
{playMode === 'repeat-one' && <Repeat1 className="h-5 w-5" />}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Volume control - desktop only */}
|
||||
|
||||
@@ -0,0 +1,219 @@
|
||||
import { useState } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { playlistApi } from '@/api/client'
|
||||
import { Music } from '@/types'
|
||||
import { useParams, useNavigate } from 'react-router-dom'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { ArrowLeft, Play, Loader2, ArrowUpDown } from 'lucide-react'
|
||||
import { formatDuration } from '@/lib/utils'
|
||||
|
||||
interface PlaylistDetailPageProps {
|
||||
onPlayMusic: (music: Music, musicList?: Music[]) => void
|
||||
}
|
||||
|
||||
type SortField = 'title' | 'artist' | 'album' | 'duration' | 'created_at'
|
||||
type SortDirection = 'asc' | 'desc'
|
||||
|
||||
export default function PlaylistDetailPage({ onPlayMusic }: PlaylistDetailPageProps) {
|
||||
const { playlistId } = useParams<{ playlistId: string }>()
|
||||
const navigate = useNavigate()
|
||||
const [sortField, setSortField] = useState<SortField>('created_at')
|
||||
const [sortDirection, setSortDirection] = useState<SortDirection>('desc')
|
||||
|
||||
const { data: playlist, isLoading } = useQuery({
|
||||
queryKey: ['playlist', playlistId],
|
||||
queryFn: async () => {
|
||||
const response = await playlistApi.getById(Number(playlistId))
|
||||
return response.data
|
||||
},
|
||||
enabled: !!playlistId,
|
||||
})
|
||||
|
||||
const handleSort = (field: SortField) => {
|
||||
if (sortField === field) {
|
||||
setSortDirection(sortDirection === 'asc' ? 'desc' : 'asc')
|
||||
} else {
|
||||
setSortField(field)
|
||||
setSortDirection('asc')
|
||||
}
|
||||
}
|
||||
|
||||
const sortedSongs = playlist?.music_items ? [...playlist.music_items].sort((a, b) => {
|
||||
let aVal: any = a[sortField]
|
||||
let bVal: any = b[sortField]
|
||||
|
||||
// Handle null/undefined values
|
||||
if (aVal === null || aVal === undefined) aVal = ''
|
||||
if (bVal === null || bVal === undefined) bVal = ''
|
||||
|
||||
// String comparison
|
||||
if (typeof aVal === 'string' && typeof bVal === 'string') {
|
||||
aVal = aVal.toLowerCase()
|
||||
bVal = bVal.toLowerCase()
|
||||
}
|
||||
|
||||
if (sortDirection === 'asc') {
|
||||
return aVal > bVal ? 1 : aVal < bVal ? -1 : 0
|
||||
} else {
|
||||
return aVal < bVal ? 1 : aVal > bVal ? -1 : 0
|
||||
}
|
||||
}) : []
|
||||
|
||||
const handlePlayAll = () => {
|
||||
if (sortedSongs.length > 0) {
|
||||
onPlayMusic(sortedSongs[0], sortedSongs)
|
||||
}
|
||||
}
|
||||
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
if (!playlist) {
|
||||
return (
|
||||
<div className="max-w-screen-xl mx-auto p-4">
|
||||
<p className="text-muted-foreground">Playlist not found</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const SortButton = ({ field, label }: { field: SortField; label: string }) => (
|
||||
<button
|
||||
onClick={() => handleSort(field)}
|
||||
className={`flex items-center gap-1 px-3 py-1.5 rounded-md text-sm transition-colors ${
|
||||
sortField === field
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'hover:bg-accent'
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
{sortField === field && (
|
||||
<ArrowUpDown className={`h-3 w-3 ${sortDirection === 'desc' ? 'rotate-180' : ''}`} />
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="max-w-screen-xl mx-auto p-4">
|
||||
<div className="mb-6">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => navigate('/playlists')}
|
||||
className="mb-4"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4 mr-2" />
|
||||
Back to Playlists
|
||||
</Button>
|
||||
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold">{playlist.name}</h1>
|
||||
{playlist.description && (
|
||||
<p className="text-muted-foreground mt-1">{playlist.description}</p>
|
||||
)}
|
||||
<p className="text-sm text-muted-foreground mt-2">
|
||||
{sortedSongs.length} {sortedSongs.length === 1 ? 'song' : 'songs'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{sortedSongs.length > 0 && (
|
||||
<Button onClick={handlePlayAll} size="lg">
|
||||
<Play className="h-5 w-5 mr-2" />
|
||||
Play All
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Sort controls */}
|
||||
<div className="flex items-center gap-2 flex-wrap mb-4">
|
||||
<span className="text-sm text-muted-foreground mr-2">Sort by:</span>
|
||||
<SortButton field="title" label="Title" />
|
||||
<SortButton field="artist" label="Artist" />
|
||||
<SortButton field="album" label="Album" />
|
||||
<SortButton field="duration" label="Duration" />
|
||||
<SortButton field="created_at" label="Date Added" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{sortedSongs.length > 0 ? (
|
||||
<div className="space-y-1">
|
||||
{sortedSongs.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, sortedSongs)}
|
||||
>
|
||||
<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, sortedSongs)
|
||||
}}
|
||||
>
|
||||
<Play className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
{song.thumbnail ? (
|
||||
<img
|
||||
src={song.thumbnail.startsWith('http') ? song.thumbnail : `/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 grid grid-cols-3 gap-4">
|
||||
<div className="min-w-0">
|
||||
<h3 className="font-medium truncate">{song.title}</h3>
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
{song.artist && song.artist !== 'Unknown' ? (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
navigate(`/artists/${encodeURIComponent(song.artist!)}`)
|
||||
}}
|
||||
className="text-sm text-muted-foreground truncate hover:underline"
|
||||
>
|
||||
{song.artist}
|
||||
</button>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground truncate">
|
||||
{song.artist || 'Unknown'}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm text-muted-foreground truncate">
|
||||
{song.album || '—'}
|
||||
</p>
|
||||
</div>
|
||||
</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 in this playlist</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,14 +1,16 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { playlistApi } from '@/api/client'
|
||||
import { Music } from '@/types'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Play } from 'lucide-react'
|
||||
import { Play, ChevronRight } from 'lucide-react'
|
||||
|
||||
interface PlaylistsPageProps {
|
||||
onPlayMusic: (music: Music, playlist: Music[]) => void
|
||||
}
|
||||
|
||||
export default function PlaylistsPage({ onPlayMusic }: PlaylistsPageProps) {
|
||||
const navigate = useNavigate()
|
||||
const { data: playlists = [], isLoading } = useQuery({
|
||||
queryKey: ['playlists'],
|
||||
queryFn: async () => {
|
||||
@@ -29,18 +31,34 @@ export default function PlaylistsPage({ onPlayMusic }: PlaylistsPageProps) {
|
||||
{playlists.map((playlist: any) => (
|
||||
<div
|
||||
key={playlist.id}
|
||||
className="p-4 rounded-lg border border-border hover:bg-accent transition-colors"
|
||||
onClick={() => navigate(`/playlists/${playlist.id}`)}
|
||||
className="p-4 rounded-lg border border-border hover:bg-accent transition-colors cursor-pointer group"
|
||||
>
|
||||
<h3 className="font-semibold mb-2">{playlist.name}</h3>
|
||||
<p className="text-sm text-muted-foreground mb-3">
|
||||
{playlist.music_items?.length || 0} songs
|
||||
</p>
|
||||
<div className="flex items-start justify-between mb-2">
|
||||
<div className="flex-1">
|
||||
<h3 className="font-semibold mb-1">{playlist.name}</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{playlist.music_items?.length || 0} songs
|
||||
</p>
|
||||
</div>
|
||||
<ChevronRight className="h-5 w-5 text-muted-foreground group-hover:translate-x-1 transition-transform" />
|
||||
</div>
|
||||
|
||||
{playlist.description && (
|
||||
<p className="text-sm text-muted-foreground mb-3 line-clamp-2">
|
||||
{playlist.description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{playlist.music_items?.length > 0 && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => onPlayMusic(playlist.music_items[0], playlist.music_items)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onPlayMusic(playlist.music_items[0], playlist.music_items)
|
||||
}}
|
||||
className="mt-2"
|
||||
>
|
||||
<Play className="h-4 w-4 mr-2" />
|
||||
Play
|
||||
|
||||
@@ -53,7 +53,33 @@
|
||||
* {
|
||||
@apply border-border;
|
||||
}
|
||||
|
||||
html,
|
||||
body,
|
||||
#root {
|
||||
@apply h-full w-full overflow-hidden;
|
||||
}
|
||||
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
/* Prevent horizontal scrolling on mobile */
|
||||
overflow-x: hidden;
|
||||
/* Prevent bounce scrolling on iOS */
|
||||
overscroll-behavior-y: none;
|
||||
/* Better font rendering */
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
/* Prevent horizontal overflow on all elements */
|
||||
* {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
/* Fix for mobile viewport height */
|
||||
@supports (-webkit-touch-callout: none) {
|
||||
body {
|
||||
min-height: -webkit-fill-available;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,3 +65,11 @@ export interface Artist {
|
||||
name: string
|
||||
song_count: number
|
||||
}
|
||||
|
||||
export interface ArtistInfo {
|
||||
name: string
|
||||
image?: string
|
||||
bio?: string
|
||||
listeners?: number
|
||||
playcount?: number
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user