feat/fix: implement WebSockets for NowPlaying, better data fetching with addl. Last.fm fetch, docker build fix
This commit is contained in:
parent
4cec7406c3
commit
7121ec926f
11 changed files with 514 additions and 175 deletions
|
|
@ -1,8 +1,7 @@
|
|||
"use client"
|
||||
|
||||
import type React from "react"
|
||||
import { useEffect, useState, useCallback } from "react"
|
||||
import Image from "next/image"
|
||||
import { useEffect, useState } from "react"
|
||||
import { Loader2, AlertCircle } from "lucide-react"
|
||||
import { PiMusicNotesFill } from "react-icons/pi";
|
||||
import { FaBluetoothB } from "react-icons/fa6";
|
||||
|
|
@ -12,116 +11,79 @@ import { TbDiscOff } from "react-icons/tb"
|
|||
import { Progress } from "@/components/ui/progress"
|
||||
import Link from "@/components/objects/Link"
|
||||
import ScrollTxt from "@/components/objects/MusicText"
|
||||
import { connectSocket, disconnectSocket } from "@/lib/socket"
|
||||
|
||||
interface Track {
|
||||
track_name: string
|
||||
artist_name: string
|
||||
interface LastFmResponse {
|
||||
album?: {
|
||||
image?: Array<{ size: string; '#text': string }>
|
||||
}
|
||||
track?: {
|
||||
album?: {
|
||||
image?: Array<{ size: string; '#text': string }>
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
interface NowPlayingData {
|
||||
track_name?: string
|
||||
artist_name?: string
|
||||
release_name?: string
|
||||
mbid?: string
|
||||
coverArt?: string | null
|
||||
lastFmData?: LastFmResponse
|
||||
status: 'loading' | 'partial' | 'complete' | 'error'
|
||||
message?: string
|
||||
}
|
||||
|
||||
const NowPlaying: React.FC = () => {
|
||||
const [track, setTrack] = useState<Track | null>(null)
|
||||
const [coverArt, setCoverArt] = useState<string | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [albumArtLoading, setAlbumArtLoading] = useState(false)
|
||||
const [loadingStatus, setLoadingStatus] = useState("Initializing")
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [progress, setProgress] = useState(0)
|
||||
const [steps, setSteps] = useState(0)
|
||||
const [nowPlaying, setNowPlaying] = useState<NowPlayingData>({ status: 'loading' })
|
||||
const [currentTime, setCurrentTime] = useState(new Date())
|
||||
const [volume, setVolume] = useState(25)
|
||||
const [screenOn, setScreenOn] = useState(true)
|
||||
|
||||
const updateProgress = useCallback((current: number, total: number, status: string) => {
|
||||
setProgress(current)
|
||||
setSteps(total)
|
||||
setLoadingStatus(status)
|
||||
console.log(`[${current}/${total}] ${status}`)
|
||||
}, [])
|
||||
|
||||
const fetchAlbumArt = useCallback(async (artist: string, album?: string) => {
|
||||
if (!album) {
|
||||
updateProgress(0, 0, "No album found")
|
||||
setCoverArt(null)
|
||||
setAlbumArtLoading(false)
|
||||
return
|
||||
}
|
||||
try {
|
||||
setAlbumArtLoading(true)
|
||||
updateProgress(2, 3, `Searching for album: ${artist} - ${album}`)
|
||||
const response = await fetch(
|
||||
`https://musicbrainz.org/ws/2/release/?query=artist:${encodeURIComponent(
|
||||
artist
|
||||
)}%20AND%20release:${encodeURIComponent(album)}&fmt=json`
|
||||
)
|
||||
if (!response.ok) {
|
||||
updateProgress(0, 0, `Album art fetch error: ${response.status}`)
|
||||
setError("Error fetching album art (see console for details)")
|
||||
setAlbumArtLoading(false)
|
||||
return
|
||||
}
|
||||
const data = await response.json()
|
||||
if (data.releases && data.releases.length > 0) {
|
||||
const mbid = data.releases[0].id
|
||||
updateProgress(3, 3, "Fetching cover art...")
|
||||
setTrack(prev => prev ? { ...prev, mbid: `${mbid || null}` } : { track_name: "", artist_name: "", release_name: undefined, mbid: `${mbid || null}` })
|
||||
const coverArtResponse = await fetch(`https://coverartarchive.org/release/${mbid}/front`)
|
||||
if (coverArtResponse.ok) {
|
||||
setCoverArt(coverArtResponse.url)
|
||||
setAlbumArtLoading(false)
|
||||
} else {
|
||||
updateProgress(0, 0, "Cover art not found")
|
||||
setCoverArt(null)
|
||||
setAlbumArtLoading(false)
|
||||
}
|
||||
} else {
|
||||
updateProgress(0, 0, "No releases found")
|
||||
setCoverArt(null)
|
||||
setAlbumArtLoading(false)
|
||||
}
|
||||
} catch (error) {
|
||||
updateProgress(0, 0, `Error: ${error}`)
|
||||
setCoverArt(null)
|
||||
setAlbumArtLoading(false)
|
||||
}
|
||||
}, [updateProgress])
|
||||
|
||||
const fetchNowPlaying = useCallback(async () => {
|
||||
updateProgress(1, 3, "Fetching current listen...")
|
||||
try {
|
||||
const response = await fetch("https://api.listenbrainz.org/1/user/p0ntus/playing-now")
|
||||
const data = await response.json()
|
||||
|
||||
if (data.payload.count > 0 && data.payload.listens[0].track_metadata) {
|
||||
const trackMetadata = data.payload.listens[0].track_metadata
|
||||
console.log("= TRACK METADATA =")
|
||||
if (trackMetadata.track_name) { console.log("🎵", trackMetadata.track_name) }
|
||||
if (trackMetadata.artist_name) { console.log("🎤", trackMetadata.artist_name) }
|
||||
if (trackMetadata.release_name) { console.log("💿", trackMetadata.release_name) }
|
||||
setTrack({
|
||||
track_name: trackMetadata.track_name,
|
||||
artist_name: trackMetadata.artist_name,
|
||||
release_name: trackMetadata.release_name,
|
||||
mbid: trackMetadata.mbid,
|
||||
})
|
||||
setLoading(false)
|
||||
updateProgress(2, 3, "Finding album art...")
|
||||
await fetchAlbumArt(trackMetadata.artist_name, trackMetadata.release_name)
|
||||
} else {
|
||||
updateProgress(0, 0, "No track playing")
|
||||
setLoading(false)
|
||||
}
|
||||
} catch (error) {
|
||||
updateProgress(0, 0, `Error: ${error}`)
|
||||
setError("Error fetching now playing data")
|
||||
setLoading(false)
|
||||
}
|
||||
}, [fetchAlbumArt, updateProgress])
|
||||
const [progressSteps, setProgressSteps] = useState({ current: 0, total: 3 })
|
||||
|
||||
useEffect(() => {
|
||||
fetchNowPlaying()
|
||||
}, [fetchNowPlaying])
|
||||
const socket = connectSocket()
|
||||
|
||||
socket.on('connect', () => {
|
||||
console.log('Connected to server')
|
||||
socket.emit('requestNowPlaying')
|
||||
socket.emit('startAutoRefresh')
|
||||
})
|
||||
|
||||
socket.on('disconnect', () => {
|
||||
console.log('[i] Disconnected from server')
|
||||
})
|
||||
|
||||
socket.on('nowPlaying', (data: NowPlayingData) => {
|
||||
console.log('Received now playing data:', data)
|
||||
setNowPlaying(prevState => ({
|
||||
...prevState,
|
||||
...data
|
||||
}))
|
||||
|
||||
if (data.status === 'loading') {
|
||||
setProgressSteps({ current: 1, total: 3 })
|
||||
} else if (data.status === 'partial') {
|
||||
setProgressSteps({ current: 2, total: 3 })
|
||||
} else if (data.status === 'complete') {
|
||||
setProgressSteps({ current: 3, total: 3 })
|
||||
}
|
||||
})
|
||||
|
||||
socket.on('connect_error', (error) => {
|
||||
console.error('[!] Connection error:', error)
|
||||
setNowPlaying({ status: 'error', message: 'Connection failed' })
|
||||
})
|
||||
|
||||
return () => {
|
||||
socket.off('connect')
|
||||
socket.off('disconnect')
|
||||
socket.off('nowPlaying')
|
||||
socket.off('connect_error')
|
||||
disconnectSocket()
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setInterval(() => {
|
||||
|
|
@ -139,28 +101,33 @@ const NowPlaying: React.FC = () => {
|
|||
}
|
||||
|
||||
const renderScreenContent = () => {
|
||||
if (loading) {
|
||||
if (nowPlaying.status === 'loading') {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-full">
|
||||
<Loader2 className="animate-spin text-white mb-4" size={32} />
|
||||
<div className="text-white text-xs text-center px-4">
|
||||
<div className="mb-2">{loadingStatus}</div>
|
||||
<Progress value={steps > 0 ? (progress * 100) / steps : 0} className="h-1" />
|
||||
<div className="mb-2">{nowPlaying.message || 'Connecting...'}</div>
|
||||
<Progress
|
||||
value={progressSteps.total > 0 ? (progressSteps.current * 100) / progressSteps.total : 0}
|
||||
className="h-1"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
if (nowPlaying.status === 'error') {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-full">
|
||||
<AlertCircle className="text-red-500 mb-4" size={32} />
|
||||
<div className="text-red-500 text-xs text-center px-4">{error}</div>
|
||||
<div className="text-red-500 text-xs text-center px-4">
|
||||
{nowPlaying.message || 'Error loading data'}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!track) {
|
||||
if (!nowPlaying.track_name) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-full">
|
||||
<TbDiscOff className="text-gray-400 mb-4" size={32} />
|
||||
|
|
@ -175,34 +142,33 @@ const NowPlaying: React.FC = () => {
|
|||
}
|
||||
|
||||
// normal state
|
||||
const currentTrack = track!;
|
||||
return (
|
||||
<>
|
||||
<a
|
||||
href={currentTrack.mbid ? `https://musicbrainz.org/release/${currentTrack.mbid}` : `https://listenbrainz.org/user/p0ntus`}
|
||||
href={nowPlaying.mbid ? `https://musicbrainz.org/release/${nowPlaying.mbid}` : `https://listenbrainz.org/user/p0ntus`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="bg-gradient-to-b from-gray-700 to-gray-900 border-b border-gray-700 px-2 py-0 block" style={{background: 'linear-gradient(to bottom, #4b5563 0%, #374151 30%, #1f2937 70%, #111827 100%)'}}
|
||||
>
|
||||
<div className="text-center leading-none pb-1">
|
||||
<ScrollTxt text={currentTrack.artist_name.toUpperCase()} type="artist" />
|
||||
<ScrollTxt text={currentTrack.track_name} type="track" className="-mt-0.5" />
|
||||
{currentTrack.release_name && <ScrollTxt text={currentTrack.release_name} type="release" className="-mt-1.5" />}
|
||||
<ScrollTxt text={nowPlaying.artist_name?.toUpperCase() || ''} type="artist" />
|
||||
<ScrollTxt text={nowPlaying.track_name || ''} type="track" className="-mt-0.5" />
|
||||
{nowPlaying.release_name && <ScrollTxt text={nowPlaying.release_name} type="release" className="-mt-1.5" />}
|
||||
</div>
|
||||
</a>
|
||||
{/* Album art */}
|
||||
<div className="relative w-full aspect-square">
|
||||
{albumArtLoading ? (
|
||||
{nowPlaying.status === 'partial' && !nowPlaying.coverArt ? (
|
||||
<div className="w-full h-full bg-gray-700 flex flex-col items-center justify-center">
|
||||
<Loader2 className="animate-spin text-gray-400 mb-2" size={32} />
|
||||
<div className="text-gray-400 text-xs">Fetching Album Art</div>
|
||||
</div>
|
||||
) : coverArt ? (
|
||||
<Image
|
||||
src={coverArt}
|
||||
alt={currentTrack.track_name}
|
||||
fill
|
||||
style={{ objectFit: "cover" }}
|
||||
) : nowPlaying.coverArt ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={nowPlaying.coverArt}
|
||||
alt={nowPlaying.track_name || 'Album cover'}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full bg-gray-700 flex items-center justify-center">
|
||||
|
|
@ -216,7 +182,7 @@ const NowPlaying: React.FC = () => {
|
|||
|
||||
return (
|
||||
<div className="flex justify-center items-center">
|
||||
<div className={`relative w-52 bg-[#D4C29A] rounded-xs border border-[#BFAF8A] z-10 ${track?.release_name ? "h-[24.25rem]" : "h-[23.6rem]"}`}>
|
||||
<div className={`relative w-52 bg-[#D4C29A] rounded-xs border border-[#BFAF8A] z-10 ${nowPlaying.release_name ? "h-[24.25rem]" : "h-[23.6rem]"}`}>
|
||||
{/* Volume buttons */}
|
||||
<div className="absolute -left-[2.55px] top-8 rounded-l w-[1.75px] flex flex-col z-0">
|
||||
<div className="h-8 bg-[#BFAF8A] border-b border-[#A09070] rounded-l cursor-pointer" onClick={() => setVolume(v => Math.min(100, v + 5))}></div> {/* up */}
|
||||
|
|
@ -245,8 +211,8 @@ const NowPlaying: React.FC = () => {
|
|||
<div className="w-full h-full bg-black"></div>
|
||||
)}
|
||||
{/* Player controls and seekbar */}
|
||||
{screenOn && track && (
|
||||
<div className={`bg-gradient-to-b from-gray-700 to-gray-900 ${track?.release_name ? "pb-3" : "pb-[12.5px]"} flex flex-col items-center`} style={{background: 'linear-gradient(to bottom, #4b5563 0%, #374151 30%, #1f2937 70%, #111827 100%)'}}>
|
||||
{screenOn && nowPlaying.track_name && (
|
||||
<div className={`bg-gradient-to-b from-gray-700 to-gray-900 ${nowPlaying.release_name ? "pb-3" : "pb-[12.5px]"} flex flex-col items-center`} style={{background: 'linear-gradient(to bottom, #4b5563 0%, #374151 30%, #1f2937 70%, #111827 100%)'}}>
|
||||
<div className="flex justify-center items-center gap-0 px-2">
|
||||
<button className="hover:drop-shadow-[0_0_8px_rgba(255,255,255,0.9)] hover:filter hover:brightness-110 transition-all duration-200 p-1 rounded-full overflow-hidden">
|
||||
<svg width="38" height="34" viewBox="0 0 24 20" className="drop-shadow-sm">
|
||||
|
|
@ -323,4 +289,4 @@ const NowPlaying: React.FC = () => {
|
|||
)
|
||||
}
|
||||
|
||||
export default NowPlaying
|
||||
export default NowPlaying
|
||||
Loading…
Add table
Add a link
Reference in a new issue