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
15
Dockerfile
15
Dockerfile
|
|
@ -21,13 +21,16 @@ WORKDIR /app
|
|||
ENV NODE_ENV=production
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
|
||||
RUN groupadd --system --gid 1001 nodejs
|
||||
RUN useradd --system --uid 1001 nextjs
|
||||
RUN groupadd --system --gid 999 nodejs
|
||||
RUN useradd --system --uid 999 nextjs
|
||||
|
||||
COPY --from=builder /app/public ./build/public
|
||||
COPY --from=builder /app/public ./public
|
||||
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./build/
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./build/.next/static
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/server.ts ./
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/lib ./lib
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/node_modules ./node_modules
|
||||
|
||||
USER nextjs
|
||||
|
||||
|
|
@ -36,4 +39,4 @@ EXPOSE 3000
|
|||
ENV PORT=3000
|
||||
|
||||
ENV HOSTNAME="0.0.0.0"
|
||||
CMD ["node", "build/server.js"]
|
||||
CMD ["bun", "run", "server.ts"]
|
||||
|
|
@ -17,9 +17,10 @@ Just create a `.env` file with the below variables, run `docker compose -d --bui
|
|||
|
||||
## Environment Variables
|
||||
|
||||
| Variable | Description |
|
||||
|----------------------|-------------------------------------------------------------------------------------|
|
||||
| `LISTENBRAINZ_TOKEN` | Get this from your ListenBrainz [user settings](https://listenbrainz.org/settings/) |
|
||||
| Variable | Required? | Description |
|
||||
|----------------------|-----------|-------------------------------------------------------------------------------------|
|
||||
| `LISTENBRAINZ_TOKEN` | No | Get this from your ListenBrainz [user settings](https://listenbrainz.org/settings/) |
|
||||
| `LASTFM_API_KEY` | Yes | Get this from your Last.fm [API account](https://www.last.fm/api/account/create) |
|
||||
|
||||
## MusicBrainz
|
||||
|
||||
|
|
|
|||
|
|
@ -1,13 +1,16 @@
|
|||
import React from 'react'
|
||||
import { Metadata } from 'next'
|
||||
import Head from 'next/head'
|
||||
import { Metadata, Viewport } from 'next'
|
||||
import './globals.css'
|
||||
import { GeistSans } from 'geist/font/sans'
|
||||
import AnimatedTitle from '../components/AnimatedTitle'
|
||||
import I18nProvider from '../components/I18nProvider'
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'aidxn.cc',
|
||||
description: "The Internet home of Aidan. Come on in!",
|
||||
authors: [{ name: 'aidxn.cc' }],
|
||||
robots: 'index, follow',
|
||||
metadataBase: new URL('https://aidxn.cc'),
|
||||
openGraph: {
|
||||
type: "website",
|
||||
url: "https://aidxn.cc",
|
||||
|
|
@ -22,6 +25,32 @@ export const metadata: Metadata = {
|
|||
},
|
||||
],
|
||||
},
|
||||
icons: {
|
||||
icon: [
|
||||
{ url: '/favicon-16x16.png', sizes: '16x16', type: 'image/png' },
|
||||
{ url: '/favicon-32x32.png', sizes: '32x32', type: 'image/png' },
|
||||
{ url: '/favicon-96x96.png', sizes: '96x96', type: 'image/png' },
|
||||
{ url: '/android-icon-192x192.png', sizes: '192x192', type: 'image/png' },
|
||||
],
|
||||
apple: [
|
||||
{ url: '/apple-icon-57x57.png', sizes: '57x57' },
|
||||
{ url: '/apple-icon-60x60.png', sizes: '60x60' },
|
||||
{ url: '/apple-icon-72x72.png', sizes: '72x72' },
|
||||
{ url: '/apple-icon-76x76.png', sizes: '76x76' },
|
||||
{ url: '/apple-icon-114x114.png', sizes: '114x114' },
|
||||
{ url: '/apple-icon-120x120.png', sizes: '120x120' },
|
||||
{ url: '/apple-icon-144x144.png', sizes: '144x144' },
|
||||
{ url: '/apple-icon-152x152.png', sizes: '152x152' },
|
||||
{ url: '/apple-icon-180x180.png', sizes: '180x180' },
|
||||
],
|
||||
},
|
||||
manifest: '/manifest.json',
|
||||
}
|
||||
|
||||
export const viewport: Viewport = {
|
||||
themeColor: '#111827',
|
||||
width: 'device-width',
|
||||
initialScale: 1,
|
||||
}
|
||||
|
||||
export default function RootLayout({
|
||||
|
|
@ -31,30 +60,6 @@ export default function RootLayout({
|
|||
}) {
|
||||
return (
|
||||
<html lang="en" className="dark">
|
||||
<Head>
|
||||
<link rel="apple-touch-icon" sizes="57x57" href="/apple-icon-57x57.png" />
|
||||
<link rel="apple-touch-icon" sizes="60x60" href="/apple-icon-60x60.png" />
|
||||
<link rel="apple-touch-icon" sizes="72x72" href="/apple-icon-72x72.png" />
|
||||
<link rel="apple-touch-icon" sizes="76x76" href="/apple-icon-76x76.png" />
|
||||
<link rel="apple-touch-icon" sizes="114x114" href="/apple-icon-114x114.png" />
|
||||
<link rel="apple-touch-icon" sizes="120x120" href="/apple-icon-120x120.png" />
|
||||
<link rel="apple-touch-icon" sizes="144x144" href="/apple-icon-144x144.png" />
|
||||
<link rel="apple-touch-icon" sizes="152x152" href="/apple-icon-152x152.png" />
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/apple-icon-180x180.png" />
|
||||
<link rel="icon" type="image/png" sizes="192x192" href="/android-icon-192x192.png" />
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />
|
||||
<link rel="icon" type="image/png" sizes="96x96" href="/favicon-96x96.png" />
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png" />
|
||||
<link rel="manifest" href="/manifest.json" />
|
||||
<meta name="msapplication-TileColor" content="#ffffff" />
|
||||
<meta name="msapplication-TileImage" content="/ms-icon-144x144.png" />
|
||||
<meta name="theme-color" content="#ffffff" />
|
||||
<meta charSet="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="robots" content="index, follow" />
|
||||
<meta name="language" content="English" />
|
||||
<meta name="author" content="aidxn.cc" />
|
||||
</Head>
|
||||
<body className={`${GeistSans.className} bg-gray-900 text-gray-100`}>
|
||||
<AnimatedTitle />
|
||||
<I18nProvider>
|
||||
|
|
@ -63,5 +68,4 @@ export default function RootLayout({
|
|||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -4,6 +4,7 @@ import Header from '@/components/Header'
|
|||
import Footer from '@/components/Footer'
|
||||
import Button from '@/components/objects/Button'
|
||||
import LastPlayed from '@/components/widgets/NowPlaying'
|
||||
import LiveIndicator from '@/components/widgets/LiveIndicator'
|
||||
|
||||
import Image from 'next/image'
|
||||
|
||||
|
|
@ -59,12 +60,7 @@ export default function Home() {
|
|||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 p-4">
|
||||
<div className="relative border-2 border-gray-700 rounded-lg hover:border-gray-600 transition-colors duration-300 p-4">
|
||||
<div className="absolute top-2 right-2">
|
||||
<div className="flex items-center gap-1 bg-black bg-opacity-50 rounded-full px-2 py-1">
|
||||
<div className="w-1 h-1 bg-red-400 rounded-full animate-pulse"></div>
|
||||
<div className="text-white text-xs">
|
||||
LIVE
|
||||
</div>
|
||||
</div>
|
||||
<LiveIndicator />
|
||||
</div>
|
||||
<div className="flex justify-center items-center h-full">
|
||||
<LastPlayed />
|
||||
|
|
|
|||
36
components/widgets/LiveIndicator.tsx
Normal file
36
components/widgets/LiveIndicator.tsx
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import { connectSocket } from "@/lib/socket"
|
||||
|
||||
const LiveIndicator = () => {
|
||||
const [connected, setConnected] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const socket = connectSocket()
|
||||
|
||||
socket.on('connect', () => {
|
||||
setConnected(true)
|
||||
})
|
||||
|
||||
socket.on('disconnect', () => {
|
||||
setConnected(false)
|
||||
})
|
||||
|
||||
return () => {
|
||||
socket.off('connect')
|
||||
socket.off('disconnect')
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1 bg-black bg-opacity-50 rounded-full px-2 py-1">
|
||||
<div className={`w-1 h-1 rounded-full ${connected ? "bg-red-400 animate-pulse" : "bg-gray-400"}`}></div>
|
||||
<div className="text-white text-xs">
|
||||
{connected ? "LIVE" : "Connecting..."}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default LiveIndicator
|
||||
|
|
@ -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
|
||||
263
lib/now-playing-server.ts
Normal file
263
lib/now-playing-server.ts
Normal file
|
|
@ -0,0 +1,263 @@
|
|||
import { Server as SocketServer } from 'socket.io'
|
||||
|
||||
interface TrackMetadata {
|
||||
track_name: string
|
||||
artist_name: string
|
||||
release_name?: string
|
||||
mbid?: string
|
||||
additional_info?: {
|
||||
recording_mbid?: string
|
||||
artist_mbids?: string[]
|
||||
release_mbid?: string
|
||||
}
|
||||
}
|
||||
|
||||
interface LastFmImage {
|
||||
size: string
|
||||
'#text': string
|
||||
}
|
||||
|
||||
interface LastFmAlbum {
|
||||
image?: LastFmImage[]
|
||||
}
|
||||
|
||||
interface LastFmTrack {
|
||||
album?: LastFmAlbum
|
||||
}
|
||||
|
||||
interface LastFmResponse {
|
||||
album?: LastFmAlbum
|
||||
track?: LastFmTrack
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
export class NowPlayingService {
|
||||
private readonly io: SocketServer
|
||||
private readonly lastFmApiKey: string | undefined
|
||||
|
||||
constructor(io: SocketServer) {
|
||||
this.io = io
|
||||
this.lastFmApiKey = process.env.LASTFM_API_KEY
|
||||
}
|
||||
|
||||
async fetchNowPlaying(socketId: string) {
|
||||
const emit = (data: Partial<NowPlayingData>) => {
|
||||
this.io.to(socketId).emit('nowPlaying', data)
|
||||
}
|
||||
|
||||
try {
|
||||
emit({ status: 'loading', message: 'Fetching from ListenBrainz...' })
|
||||
|
||||
const listenBrainzResponse = await fetch(
|
||||
'https://api.listenbrainz.org/1/user/p0ntus/playing-now',
|
||||
{
|
||||
headers: process.env.LISTENBRAINZ_TOKEN
|
||||
? { Authorization: `Token ${process.env.LISTENBRAINZ_TOKEN}` }
|
||||
: {}
|
||||
}
|
||||
)
|
||||
|
||||
if (!listenBrainzResponse.ok) {
|
||||
emit({
|
||||
status: 'error',
|
||||
message: `ListenBrainz error: ${listenBrainzResponse.status}`
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const listenBrainzData = await listenBrainzResponse.json()
|
||||
|
||||
if (listenBrainzData.payload.count === 0) {
|
||||
emit({
|
||||
status: 'complete',
|
||||
message: 'No track currently playing'
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const trackMetadata: TrackMetadata = listenBrainzData.payload.listens[0].track_metadata
|
||||
|
||||
emit({
|
||||
status: 'partial',
|
||||
track_name: trackMetadata.track_name,
|
||||
artist_name: trackMetadata.artist_name,
|
||||
release_name: trackMetadata.release_name,
|
||||
mbid: trackMetadata.additional_info?.release_mbid,
|
||||
message: 'Fetching additional info...'
|
||||
})
|
||||
|
||||
// Try to get data from Last.fm if MBID
|
||||
let lastFmData: LastFmResponse | null = null
|
||||
let lastFmCoverArt: string | null = null
|
||||
|
||||
if (this.lastFmApiKey) {
|
||||
emit({ status: 'partial', message: 'Fetching from Last.fm...' })
|
||||
|
||||
const lastFmQueries = []
|
||||
|
||||
// Try with MBID if available
|
||||
if (trackMetadata.additional_info?.recording_mbid) {
|
||||
lastFmQueries.push(this.fetchLastFmByMbid(trackMetadata.additional_info.recording_mbid))
|
||||
}
|
||||
|
||||
// Also try with track and artist name
|
||||
lastFmQueries.push(this.fetchLastFmByTrack(
|
||||
trackMetadata.artist_name,
|
||||
trackMetadata.track_name
|
||||
))
|
||||
|
||||
const lastFmResults = await Promise.allSettled(lastFmQueries)
|
||||
|
||||
for (const result of lastFmResults) {
|
||||
if (result.status === 'fulfilled' && result.value) {
|
||||
lastFmData = result.value
|
||||
// Extract cover
|
||||
if (lastFmData.album?.image) {
|
||||
const images = lastFmData.album.image
|
||||
const largeImage = images.find((img: LastFmImage) => img.size === 'extralarge') ||
|
||||
images.find((img: LastFmImage) => img.size === 'large') ||
|
||||
images[images.length - 1]
|
||||
if (largeImage && largeImage['#text'] && largeImage['#text'].trim() !== '') {
|
||||
lastFmCoverArt = largeImage['#text']
|
||||
}
|
||||
} else if (lastFmData.track?.album?.image) {
|
||||
const images = lastFmData.track.album.image
|
||||
const largeImage = images.find((img: LastFmImage) => img.size === 'extralarge') ||
|
||||
images.find((img: LastFmImage) => img.size === 'large') ||
|
||||
images[images.length - 1]
|
||||
if (largeImage && largeImage['#text'] && largeImage['#text'].trim() !== '') {
|
||||
lastFmCoverArt = largeImage['#text']
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Get album art
|
||||
let finalCoverArt = lastFmCoverArt
|
||||
|
||||
if (!finalCoverArt) {
|
||||
if (trackMetadata.additional_info?.release_mbid) {
|
||||
emit({ status: 'partial', message: 'Fetching from Cover Art Archive...' })
|
||||
|
||||
try {
|
||||
const coverArtResponse = await fetch(
|
||||
`https://coverartarchive.org/release/${trackMetadata.additional_info.release_mbid}/front`
|
||||
)
|
||||
|
||||
if (coverArtResponse.ok) {
|
||||
finalCoverArt = coverArtResponse.url
|
||||
}
|
||||
} catch (error) {
|
||||
console.log('[!] Cover Art Archive direct fetch failed:', error)
|
||||
}
|
||||
}
|
||||
|
||||
if (!finalCoverArt && trackMetadata.release_name && trackMetadata.artist_name) {
|
||||
emit({ status: 'partial', message: 'Searching MusicBrainz for album art...' })
|
||||
|
||||
try {
|
||||
const mbSearchResponse = await fetch(
|
||||
`https://musicbrainz.org/ws/2/release/?query=artist:${encodeURIComponent(
|
||||
trackMetadata.artist_name
|
||||
)}%20AND%20release:${encodeURIComponent(trackMetadata.release_name)}&fmt=json&limit=1`
|
||||
)
|
||||
|
||||
if (mbSearchResponse.ok) {
|
||||
const mbData = await mbSearchResponse.json()
|
||||
|
||||
if (mbData.releases && mbData.releases.length > 0) {
|
||||
const releaseMbid = mbData.releases[0].id
|
||||
|
||||
try {
|
||||
const coverArtResponse = await fetch(
|
||||
`https://coverartarchive.org/release/${releaseMbid}/front`
|
||||
)
|
||||
|
||||
if (coverArtResponse.ok) {
|
||||
finalCoverArt = coverArtResponse.url
|
||||
}
|
||||
} catch (error) {
|
||||
console.log('[!] Cover Art Archive fallback fetch failed:', error)
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.log('[!] MusicBrainz search failed:', error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
emit({
|
||||
status: 'complete',
|
||||
track_name: trackMetadata.track_name,
|
||||
artist_name: trackMetadata.artist_name,
|
||||
release_name: trackMetadata.release_name,
|
||||
mbid: trackMetadata.additional_info?.release_mbid || trackMetadata.mbid,
|
||||
coverArt: finalCoverArt || null,
|
||||
lastFmData: lastFmData || undefined,
|
||||
message: 'Complete'
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('[!] Error in fetchNowPlaying:', error)
|
||||
emit({
|
||||
status: 'error',
|
||||
message: error instanceof Error ? error.message : 'Unknown error occurred'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
private async fetchLastFmByMbid(mbid: string): Promise<LastFmResponse | null> {
|
||||
if (!this.lastFmApiKey) return null
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
`https://ws.audioscrobbler.com/2.0/?method=track.getInfoByMbid&mbid=${mbid}&api_key=${this.lastFmApiKey}&format=json`
|
||||
)
|
||||
|
||||
if (response.ok) {
|
||||
return await response.json() as LastFmResponse
|
||||
}
|
||||
} catch (error) {
|
||||
console.log('[!] Last.fm MBID fetch failed:', error)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
private async fetchLastFmByTrack(artist: string, track: string): Promise<LastFmResponse | null> {
|
||||
if (!this.lastFmApiKey) return null
|
||||
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
method: 'track.getInfo',
|
||||
api_key: this.lastFmApiKey,
|
||||
artist: artist,
|
||||
track: track,
|
||||
format: 'json',
|
||||
autocorrect: '1'
|
||||
})
|
||||
|
||||
const response = await fetch(`https://ws.audioscrobbler.com/2.0/?${params}`)
|
||||
|
||||
if (response.ok) {
|
||||
return await response.json() as LastFmResponse
|
||||
}
|
||||
} catch (error) {
|
||||
console.log('[!] Last.fm track fetch failed:', error)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
}
|
||||
28
lib/socket.ts
Normal file
28
lib/socket.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
"use client"
|
||||
|
||||
import { io, Socket } from "socket.io-client"
|
||||
|
||||
let socket: Socket | null = null
|
||||
|
||||
export const getSocket = (): Socket => {
|
||||
if (!socket) {
|
||||
socket = io(undefined, {
|
||||
autoConnect: false,
|
||||
})
|
||||
}
|
||||
return socket
|
||||
}
|
||||
|
||||
export const connectSocket = (): Socket => {
|
||||
const s = getSocket()
|
||||
if (!s.connected) {
|
||||
s.connect()
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
export const disconnectSocket = (): void => {
|
||||
if (socket?.connected) {
|
||||
socket.disconnect()
|
||||
}
|
||||
}
|
||||
|
|
@ -16,12 +16,6 @@ const nextConfig: NextConfig = {
|
|||
port: '',
|
||||
pathname: '/**',
|
||||
},
|
||||
{
|
||||
protocol: 'https',
|
||||
hostname: '*.archive.org',
|
||||
port: '',
|
||||
pathname: '/**',
|
||||
},
|
||||
],
|
||||
dangerouslyAllowSVG: true,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -3,9 +3,9 @@
|
|||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev --turbopack",
|
||||
"dev": "bun run server.ts",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"start": "NODE_ENV=production bun run server.ts",
|
||||
"lint": "next lint"
|
||||
},
|
||||
"dependencies": {
|
||||
|
|
@ -22,6 +22,8 @@
|
|||
"react-i18next": "^15.7.3",
|
||||
"react-icons": "^5.5.0",
|
||||
"recharts": "^3.1.2",
|
||||
"socket.io": "^4.8.1",
|
||||
"socket.io-client": "^4.8.1",
|
||||
"tailwind-merge": "^3.3.1",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"tw-animate-css": "^1.3.8"
|
||||
|
|
@ -29,13 +31,14 @@
|
|||
"devDependencies": {
|
||||
"@eslint/eslintrc": "^3.3.1",
|
||||
"@tailwindcss/postcss": "^4.1.13",
|
||||
"@types/node": "^20.19.13",
|
||||
"@types/node": "^24.3.1",
|
||||
"@types/react": "^19.1.12",
|
||||
"@types/react-dom": "^19.1.9",
|
||||
"eslint": "^9.35.0",
|
||||
"eslint-config-next": "15.1.3",
|
||||
"postcss": "^8.5.6",
|
||||
"tailwindcss": "^4.1.13",
|
||||
"tsx": "^4.20.5",
|
||||
"typescript": "^5.9.2"
|
||||
},
|
||||
"trustedDependencies": [
|
||||
|
|
|
|||
45
server.ts
Normal file
45
server.ts
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
import { createServer } from "node:http";
|
||||
import next from "next";
|
||||
import { Server } from "socket.io";
|
||||
import { NowPlayingService } from "./lib/now-playing-server";
|
||||
|
||||
const dev = process.env.NODE_ENV !== "production";
|
||||
const hostname = "localhost";
|
||||
const port = parseInt(process.env.PORT || "3000", 10);
|
||||
|
||||
const app = next({ dev, hostname, port });
|
||||
const handler = app.getRequestHandler();
|
||||
|
||||
app.prepare().then(() => {
|
||||
const httpServer = createServer(handler);
|
||||
const io = new Server(httpServer);
|
||||
|
||||
const nowPlayingService = new NowPlayingService(io);
|
||||
|
||||
io.on("connection", (socket) => {
|
||||
console.log("[WS] Client connected:", socket.id);
|
||||
|
||||
socket.on("requestNowPlaying", async () => {
|
||||
await nowPlayingService.fetchNowPlaying(socket.id);
|
||||
});
|
||||
|
||||
socket.on("startAutoRefresh", () => {
|
||||
const intervalId = setInterval(async () => {
|
||||
await nowPlayingService.fetchNowPlaying(socket.id);
|
||||
}, 30000);
|
||||
|
||||
socket.on("disconnect", () => {
|
||||
clearInterval(intervalId);
|
||||
console.log("[WS] Client disconnected:", socket.id);
|
||||
});
|
||||
});
|
||||
|
||||
socket.on("disconnect", () => {
|
||||
console.log("[WS] Client disconnected:", socket.id);
|
||||
});
|
||||
});
|
||||
|
||||
httpServer.listen(port, () => {
|
||||
console.log(`✓ Ready on http://${hostname}:${port}`);
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue