UI update

This commit is contained in:
2025-10-31 22:58:26 +11:00
parent 9996c731fe
commit 325be8487e
9 changed files with 175 additions and 72 deletions
+5 -7
View File
@@ -165,10 +165,10 @@ while True:
) )
job = response.json() job = response.json()
print(f"Status: {job['status']}") print(f"Status: {job['status']}")
if job['status'] in ['completed', 'failed']: if job['status'] in ['completed', 'failed']:
break break
time.sleep(5) time.sleep(5)
``` ```
@@ -207,16 +207,14 @@ async function getJobStatus(jobId) {
// Usage // Usage
(async () => { (async () => {
const job = await createJob('Bohemian Rhapsody - Queen'); const job = await createJob('Bohemian Rhapsody - Queen');
console.log('Job created:', job.id);
while (true) { while (true) {
const status = await getJobStatus(job.id); const status = await getJobStatus(job.id);
console.log('Status:', status.status);
if (['completed', 'failed'].includes(status.status)) { if (['completed', 'failed'].includes(status.status)) {
break; break;
} }
await new Promise(resolve => setTimeout(resolve, 5000)); await new Promise(resolve => setTimeout(resolve, 5000));
} }
})(); })();
+42
View File
@@ -102,6 +102,48 @@ async def get_music(music_id: int, db: AsyncSession = Depends(get_db)):
return music return music
@router.get("/{music_id}/lyrics")
async def get_music_lyrics(music_id: int, db: AsyncSession = Depends(get_db)):
"""Get lyrics for a music track"""
import httpx
result = await db.execute(select(Music).where(Music.id == music_id))
music = result.scalar_one_or_none()
if not music:
raise HTTPException(status_code=404, detail="Music not found")
# Try to fetch from lrclib.net
try:
async with httpx.AsyncClient() as client:
params = {
"track_name": music.title,
"artist_name": music.artist or "",
"album_name": music.album or "",
"duration": int(music.duration) if music.duration else 0
}
response = await client.get("https://lrclib.net/api/get", params=params, timeout=10.0)
if response.status_code == 200:
data = response.json()
lyrics = data.get("plainLyrics") or data.get("syncedLyrics", "")
# Save lyrics to database
if lyrics:
music.lyrics = lyrics
await db.commit()
return {"lyrics": lyrics, "synced": bool(data.get("syncedLyrics"))}
except Exception as e:
logger.error(f"Error fetching lyrics: {e}")
# Return stored lyrics if available
if music.lyrics:
return {"lyrics": music.lyrics, "synced": False}
return {"lyrics": "", "synced": False}
@router.get("/{music_id}/info", response_model=MusicDetailInfo) @router.get("/{music_id}/info", response_model=MusicDetailInfo)
async def get_music_detail_info(music_id: int, db: AsyncSession = Depends(get_db)): async def get_music_detail_info(music_id: int, db: AsyncSession = Depends(get_db)):
"""Get detailed information about a music file""" """Get detailed information about a music file"""
+1
View File
@@ -22,6 +22,7 @@ dependencies = [
"aiosqlite>=0.20.0", "aiosqlite>=0.20.0",
"alembic>=1.17.1", "alembic>=1.17.1",
"apscheduler>=3.10.4", "apscheduler>=3.10.4",
"httpx>=0.28.1",
] ]
[tool.uv] [tool.uv]
+39
View File
@@ -143,6 +143,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/3a/2a/7cc015f5b9f5db42b7d48157e23356022889fc354a2813c15934b7cb5c0e/attrs-25.4.0-py3-none-any.whl", hash = "sha256:adcf7e2a1fb3b36ac48d97835bb6d8ade15b8dcce26aba8bf1d14847b57a3373", size = 67615, upload-time = "2025-10-06T13:54:43.17Z" }, { url = "https://files.pythonhosted.org/packages/3a/2a/7cc015f5b9f5db42b7d48157e23356022889fc354a2813c15934b7cb5c0e/attrs-25.4.0-py3-none-any.whl", hash = "sha256:adcf7e2a1fb3b36ac48d97835bb6d8ade15b8dcce26aba8bf1d14847b57a3373", size = 67615, upload-time = "2025-10-06T13:54:43.17Z" },
] ]
[[package]]
name = "certifi"
version = "2025.10.5"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/4c/5b/b6ce21586237c77ce67d01dc5507039d444b630dd76611bbca2d8e5dcd91/certifi-2025.10.5.tar.gz", hash = "sha256:47c09d31ccf2acf0be3f701ea53595ee7e0b8fa08801c6624be771df09ae7b43", size = 164519, upload-time = "2025-10-05T04:12:15.808Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e4/37/af0d2ef3967ac0d6113837b44a4f0bfe1328c2b9763bd5b1744520e5cfed/certifi-2025.10.5-py3-none-any.whl", hash = "sha256:0f212c2744a9bb6de0c56639a6f68afe01ecd92d91f14ae897c4fe7bbeeef0de", size = 163286, upload-time = "2025-10-05T04:12:14.03Z" },
]
[[package]] [[package]]
name = "cffi" name = "cffi"
version = "2.0.0" version = "2.0.0"
@@ -374,6 +383,19 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" },
] ]
[[package]]
name = "httpcore"
version = "1.0.9"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "certifi" },
{ name = "h11" },
]
sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" },
]
[[package]] [[package]]
name = "httptools" name = "httptools"
version = "0.7.1" version = "0.7.1"
@@ -396,6 +418,21 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/53/cf/878f3b91e4e6e011eff6d1fa9ca39f7eb17d19c9d7971b04873734112f30/httptools-0.7.1-cp314-cp314-win_amd64.whl", hash = "sha256:cfabda2a5bb85aa2a904ce06d974a3f30fb36cc63d7feaddec05d2050acede96", size = 88205, upload-time = "2025-10-10T03:55:00.389Z" }, { url = "https://files.pythonhosted.org/packages/53/cf/878f3b91e4e6e011eff6d1fa9ca39f7eb17d19c9d7971b04873734112f30/httptools-0.7.1-cp314-cp314-win_amd64.whl", hash = "sha256:cfabda2a5bb85aa2a904ce06d974a3f30fb36cc63d7feaddec05d2050acede96", size = 88205, upload-time = "2025-10-10T03:55:00.389Z" },
] ]
[[package]]
name = "httpx"
version = "0.28.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
{ name = "certifi" },
{ name = "httpcore" },
{ name = "idna" },
]
sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" },
]
[[package]] [[package]]
name = "idna" name = "idna"
version = "3.11" version = "3.11"
@@ -1136,6 +1173,7 @@ dependencies = [
{ name = "alembic" }, { name = "alembic" },
{ name = "apscheduler" }, { name = "apscheduler" },
{ name = "fastapi" }, { name = "fastapi" },
{ name = "httpx" },
{ name = "mutagen" }, { name = "mutagen" },
{ name = "pillow" }, { name = "pillow" },
{ name = "pydantic" }, { name = "pydantic" },
@@ -1155,6 +1193,7 @@ requires-dist = [
{ name = "alembic", specifier = ">=1.17.1" }, { name = "alembic", specifier = ">=1.17.1" },
{ name = "apscheduler", specifier = ">=3.10.4" }, { name = "apscheduler", specifier = ">=3.10.4" },
{ name = "fastapi", specifier = ">=0.115.0" }, { name = "fastapi", specifier = ">=0.115.0" },
{ name = "httpx", specifier = ">=0.28.1" },
{ name = "mutagen", specifier = ">=1.47.0" }, { name = "mutagen", specifier = ">=1.47.0" },
{ name = "pillow", specifier = ">=10.4.0" }, { name = "pillow", specifier = ">=10.4.0" },
{ name = "pydantic", specifier = ">=2.9.2" }, { name = "pydantic", specifier = ">=2.9.2" },
-16
View File
@@ -30,19 +30,6 @@ function App() {
const audioRef = useRef<HTMLAudioElement>(null) const audioRef = useRef<HTMLAudioElement>(null)
const location = useLocation() const location = useLocation()
// Load theme from localStorage on mount - NO LONGER NEEDED, handled in initial state
// useEffect(() => {
// const savedTheme = localStorage.getItem('theme') as 'light' | 'dark' | null
// console.log('Loading theme from localStorage:', savedTheme)
// 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 // Load play mode from localStorage
useEffect(() => { useEffect(() => {
const savedMode = localStorage.getItem('playMode') as 'loop' | 'shuffle' | 'repeat-one' | null const savedMode = localStorage.getItem('playMode') as 'loop' | 'shuffle' | 'repeat-one' | null
@@ -53,14 +40,12 @@ function App() {
// Apply theme to document // Apply theme to document
useEffect(() => { useEffect(() => {
console.log('Applying theme:', theme)
if (theme === 'dark') { if (theme === 'dark') {
document.documentElement.classList.add('dark') document.documentElement.classList.add('dark')
} else { } else {
document.documentElement.classList.remove('dark') document.documentElement.classList.remove('dark')
} }
localStorage.setItem('theme', theme) localStorage.setItem('theme', theme)
console.log('Theme saved to localStorage:', theme)
}, [theme]) }, [theme])
// Save play mode to localStorage // Save play mode to localStorage
@@ -69,7 +54,6 @@ function App() {
}, [playMode]) }, [playMode])
const handleThemeChange = (isDark: boolean) => { const handleThemeChange = (isDark: boolean) => {
console.log('handleThemeChange called with:', isDark)
setTheme(isDark ? 'dark' : 'light') setTheme(isDark ? 'dark' : 'light')
} }
+1
View File
@@ -17,6 +17,7 @@ export const musicApi = {
search: (query: string) => api.get('/music/search', { params: { q: query } }), search: (query: string) => api.get('/music/search', { params: { q: query } }),
getById: (id: number) => api.get(`/music/${id}`), getById: (id: number) => api.get(`/music/${id}`),
getDetailInfo: (id: number) => api.get(`/music/${id}/info`), getDetailInfo: (id: number) => api.get(`/music/${id}/info`),
getLyrics: (id: number) => api.get(`/music/${id}/lyrics`),
update: (id: number, data: any) => api.put(`/music/${id}`, data), update: (id: number, data: any) => api.put(`/music/${id}`, data),
delete: (id: number) => api.delete(`/music/${id}`), delete: (id: number) => api.delete(`/music/${id}`),
upload: (file: File) => { upload: (file: File) => {
@@ -1,5 +1,6 @@
import { Music } from '@/types' import { Music } from '@/types'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Slider } from '@/components/ui/slider'
import { X, Play, Pause, SkipBack, SkipForward, Heart, ListPlus, Share2, MessageSquareText } from 'lucide-react' import { X, Play, Pause, SkipBack, SkipForward, Heart, ListPlus, Share2, MessageSquareText } from 'lucide-react'
import { formatDuration } from '@/lib/utils' import { formatDuration } from '@/lib/utils'
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'
@@ -21,6 +22,7 @@ interface FullScreenPlayerProps {
onNavigateToArtist: () => void onNavigateToArtist: () => void
showLyrics?: boolean showLyrics?: boolean
onToggleLyrics?: () => void onToggleLyrics?: () => void
onSeek?: (value: number[]) => void
} }
export default function FullScreenPlayer({ export default function FullScreenPlayer({
@@ -39,31 +41,20 @@ export default function FullScreenPlayer({
onNavigateToArtist, onNavigateToArtist,
showLyrics = false, showLyrics = false,
onToggleLyrics, onToggleLyrics,
onSeek,
}: FullScreenPlayerProps) { }: FullScreenPlayerProps) {
// Fetch lyrics when enabled // Fetch lyrics when enabled
const { data: lyrics, isLoading: lyricsLoading } = useQuery({ const { data: lyrics, isLoading: lyricsLoading } = useQuery({
queryKey: ['lyrics', currentMusic.id], queryKey: ['lyrics', currentMusic.id],
queryFn: async () => { queryFn: async () => {
console.log('Fetching lyrics for:', currentMusic.title)
const response = await musicApi.getLyrics(currentMusic.id) const response = await musicApi.getLyrics(currentMusic.id)
console.log('Lyrics received:', response.data)
return response.data.lyrics return response.data.lyrics
}, },
enabled: showLyrics && !!currentMusic.id, enabled: showLyrics && !!currentMusic.id,
}) })
console.log('FullScreenPlayer - showLyrics:', showLyrics, 'lyrics:', lyrics)
return ( return (
<div className="fixed inset-0 z-50 bg-background/95 backdrop-blur-sm flex flex-col"> <div className="fixed inset-0 z-50 bg-background/95 backdrop-blur-sm flex flex-col">
{/* Header */}
<div className="flex items-center justify-between p-4 border-b">
<h2 className="text-lg font-semibold">Now Playing</h2>
<Button variant="ghost" size="icon" onClick={onClose}>
<X className="h-5 w-5" />
</Button>
</div>
{/* Main Content */} {/* Main Content */}
<div className="flex-1 flex flex-col items-center justify-center p-8 overflow-y-auto"> <div className="flex-1 flex flex-col items-center justify-center p-8 overflow-y-auto">
{/* Large Album Art */} {/* Large Album Art */}
@@ -102,45 +93,33 @@ export default function FullScreenPlayer({
</div> </div>
{/* Lyrics Display */} {/* Lyrics Display */}
<div className="w-full max-w-2xl mb-8">
<div className="text-sm text-center mb-2">
showLyrics: {String(showLyrics)} |
loading: {String(lyricsLoading)} |
lyrics: {lyrics ? `${lyrics.substring(0, 50)}...` : 'null'}
</div>
</div>
{showLyrics && ( {showLyrics && (
<div className="w-full max-w-2xl mb-8 relative overflow-hidden rounded-lg bg-gradient-to-b from-secondary/30 to-secondary/10 backdrop-blur-sm border-2 border-primary"> <div className="w-full max-w-2xl mb-4 md:mb-8 relative overflow-hidden rounded-lg bg-gradient-to-b from-secondary/30 to-secondary/10 backdrop-blur-sm border border-primary/20">
<div className="text-xs text-foreground p-2 bg-background/50">
DEBUG: showLyrics={String(showLyrics)}, loading={String(lyricsLoading)},
lyrics={lyrics ? `YES (${lyrics.length} chars)` : 'NO'},
type={typeof lyrics}
</div>
{lyricsLoading ? ( {lyricsLoading ? (
<p className="text-center text-foreground py-12 text-xl">Loading lyrics...</p> <p className="text-center text-foreground py-8 md:py-12 text-lg md:text-xl">Loading lyrics...</p>
) : lyrics && lyrics.length > 0 ? ( ) : lyrics && lyrics.length > 0 ? (
<div className="relative h-96 overflow-hidden bg-secondary/20 rounded-lg p-4"> <div className="relative h-64 md:h-96 overflow-hidden p-2 md:p-4">
<div className="h-full overflow-y-auto scrollbar-hide"> <div
<div className="lyrics-scroll-content"
className="lyrics-scroll-content" style={{
style={{ animationDuration: `${Math.max(lyrics.split('\n').length * 1.5, 30)}s`,
animationDuration: `${Math.max(lyrics.split('\n').length * 2, 40)}s` animationPlayState: isPlaying ? 'running' : 'paused'
}} }}
> >
<div className="whitespace-pre-wrap text-center text-lg leading-relaxed font-normal text-foreground min-h-full flex items-center justify-center"> <div className="whitespace-pre-wrap text-center text-base md:text-xl leading-relaxed font-normal text-foreground py-4 md:py-8 px-2">
{lyrics} {lyrics}
</div> </div>
{/* Duplicate for seamless loop */}
<div className="whitespace-pre-wrap text-center text-base md:text-xl leading-relaxed font-normal text-foreground py-4 md:py-8 px-2">
{lyrics}
</div> </div>
</div> </div>
{/* Gradient overlays for fade effect */} {/* Gradient overlays for fade effect */}
<div className="absolute top-0 left-0 right-0 h-16 bg-gradient-to-b from-background to-transparent pointer-events-none" /> <div className="absolute top-0 left-0 right-0 h-16 md:h-20 bg-gradient-to-b from-background to-transparent pointer-events-none" />
<div className="absolute bottom-0 left-0 right-0 h-16 bg-gradient-to-t from-background to-transparent pointer-events-none" /> <div className="absolute bottom-0 left-0 right-0 h-16 md:h-20 bg-gradient-to-t from-background to-transparent pointer-events-none" />
</div> </div>
) : ( ) : (
<p className="text-center text-foreground py-12 text-xl"> <p className="text-center text-foreground py-8 md:py-12 text-lg md:text-xl">No lyrics available</p>
{lyrics === '' ? 'No lyrics found for this song' : 'No lyrics available'}
</p>
)} )}
</div> </div>
)} )}
@@ -151,12 +130,17 @@ export default function FullScreenPlayer({
<span>{formatDuration(currentTime)}</span> <span>{formatDuration(currentTime)}</span>
<span>{formatDuration(duration)}</span> <span>{formatDuration(duration)}</span>
</div> </div>
<div className="h-1 bg-secondary rounded-full overflow-hidden"> <Slider
<div value={[currentTime]}
className="h-full bg-primary transition-all" max={duration || 100}
style={{ width: `${duration > 0 ? (currentTime / duration) * 100 : 0}%` }} step={0.1}
/> onValueChange={(value) => {
</div> if (onSeek) {
onSeek(value)
}
}}
className="w-full"
/>
</div> </div>
{/* Controls */} {/* Controls */}
@@ -230,6 +214,14 @@ export default function FullScreenPlayer({
> >
<Share2 className="h-6 w-6" /> <Share2 className="h-6 w-6" />
</Button> </Button>
<Button
variant="ghost"
size="icon"
onClick={onClose}
>
<X className="h-6 w-6" />
</Button>
</div> </div>
)} )}
</div> </div>
@@ -449,6 +449,7 @@ export default function Player({
}} }}
showLyrics={showLyrics} showLyrics={showLyrics}
onToggleLyrics={() => setShowLyrics(!showLyrics)} onToggleLyrics={() => setShowLyrics(!showLyrics)}
onSeek={handleSeek}
/> />
)} )}
</> </>
+45
View File
@@ -83,3 +83,48 @@
} }
} }
} }
@keyframes scroll {
0% {
transform: translateY(0);
}
100% {
transform: translateY(-50%);
}
}
.scrollbar-hide {
-ms-overflow-style: none;
scrollbar-width: none;
}
.scrollbar-hide::-webkit-scrollbar {
display: none;
}
.lyrics-scroll {
animation: lyricsScroll linear infinite;
will-change: transform;
}
@keyframes lyricsScroll {
0% {
transform: translateY(100%);
}
100% {
transform: translateY(-100%);
}
}
.lyrics-scroll-content {
animation: lyricsAutoScroll linear infinite;
}
@keyframes lyricsAutoScroll {
from {
transform: translateY(0);
}
to {
transform: translateY(-50%);
}
}