Update style

This commit is contained in:
2025-10-30 16:19:01 +11:00
parent b3bb5668b7
commit 58ef625bd8
12 changed files with 714 additions and 100 deletions
+178 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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} />
+1
View File
@@ -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`),
}
+1 -1
View File
@@ -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"
/>
+26 -5
View File
@@ -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>
)
}
+25 -2
View File
@@ -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
+26
View File
@@ -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;
}
}
}
+8
View File
@@ -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
}