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 NODE_ENV=production
|
||||||
ENV NEXT_TELEMETRY_DISABLED=1
|
ENV NEXT_TELEMETRY_DISABLED=1
|
||||||
|
|
||||||
RUN groupadd --system --gid 1001 nodejs
|
RUN groupadd --system --gid 999 nodejs
|
||||||
RUN useradd --system --uid 1001 nextjs
|
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/standalone ./
|
||||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./build/.next/static
|
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
|
USER nextjs
|
||||||
|
|
||||||
|
|
@ -36,4 +39,4 @@ EXPOSE 3000
|
||||||
ENV PORT=3000
|
ENV PORT=3000
|
||||||
|
|
||||||
ENV HOSTNAME="0.0.0.0"
|
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
|
## Environment Variables
|
||||||
|
|
||||||
| Variable | Description |
|
| Variable | Required? | Description |
|
||||||
|----------------------|-------------------------------------------------------------------------------------|
|
|----------------------|-----------|-------------------------------------------------------------------------------------|
|
||||||
| `LISTENBRAINZ_TOKEN` | Get this from your ListenBrainz [user settings](https://listenbrainz.org/settings/) |
|
| `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
|
## MusicBrainz
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,16 @@
|
||||||
import React from 'react'
|
import React from 'react'
|
||||||
import { Metadata } from 'next'
|
import { Metadata, Viewport } from 'next'
|
||||||
import Head from 'next/head'
|
|
||||||
import './globals.css'
|
import './globals.css'
|
||||||
import { GeistSans } from 'geist/font/sans'
|
import { GeistSans } from 'geist/font/sans'
|
||||||
import AnimatedTitle from '../components/AnimatedTitle'
|
import AnimatedTitle from '../components/AnimatedTitle'
|
||||||
import I18nProvider from '../components/I18nProvider'
|
import I18nProvider from '../components/I18nProvider'
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
|
title: 'aidxn.cc',
|
||||||
description: "The Internet home of Aidan. Come on in!",
|
description: "The Internet home of Aidan. Come on in!",
|
||||||
|
authors: [{ name: 'aidxn.cc' }],
|
||||||
|
robots: 'index, follow',
|
||||||
|
metadataBase: new URL('https://aidxn.cc'),
|
||||||
openGraph: {
|
openGraph: {
|
||||||
type: "website",
|
type: "website",
|
||||||
url: "https://aidxn.cc",
|
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({
|
export default function RootLayout({
|
||||||
|
|
@ -31,30 +60,6 @@ export default function RootLayout({
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<html lang="en" className="dark">
|
<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`}>
|
<body className={`${GeistSans.className} bg-gray-900 text-gray-100`}>
|
||||||
<AnimatedTitle />
|
<AnimatedTitle />
|
||||||
<I18nProvider>
|
<I18nProvider>
|
||||||
|
|
@ -63,5 +68,4 @@ export default function RootLayout({
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -4,6 +4,7 @@ import Header from '@/components/Header'
|
||||||
import Footer from '@/components/Footer'
|
import Footer from '@/components/Footer'
|
||||||
import Button from '@/components/objects/Button'
|
import Button from '@/components/objects/Button'
|
||||||
import LastPlayed from '@/components/widgets/NowPlaying'
|
import LastPlayed from '@/components/widgets/NowPlaying'
|
||||||
|
import LiveIndicator from '@/components/widgets/LiveIndicator'
|
||||||
|
|
||||||
import Image from 'next/image'
|
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="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="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="absolute top-2 right-2">
|
||||||
<div className="flex items-center gap-1 bg-black bg-opacity-50 rounded-full px-2 py-1">
|
<LiveIndicator />
|
||||||
<div className="w-1 h-1 bg-red-400 rounded-full animate-pulse"></div>
|
|
||||||
<div className="text-white text-xs">
|
|
||||||
LIVE
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-center items-center h-full">
|
<div className="flex justify-center items-center h-full">
|
||||||
<LastPlayed />
|
<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"
|
"use client"
|
||||||
|
|
||||||
import type React from "react"
|
import type React from "react"
|
||||||
import { useEffect, useState, useCallback } from "react"
|
import { useEffect, useState } from "react"
|
||||||
import Image from "next/image"
|
|
||||||
import { Loader2, AlertCircle } from "lucide-react"
|
import { Loader2, AlertCircle } from "lucide-react"
|
||||||
import { PiMusicNotesFill } from "react-icons/pi";
|
import { PiMusicNotesFill } from "react-icons/pi";
|
||||||
import { FaBluetoothB } from "react-icons/fa6";
|
import { FaBluetoothB } from "react-icons/fa6";
|
||||||
|
|
@ -12,116 +11,79 @@ import { TbDiscOff } from "react-icons/tb"
|
||||||
import { Progress } from "@/components/ui/progress"
|
import { Progress } from "@/components/ui/progress"
|
||||||
import Link from "@/components/objects/Link"
|
import Link from "@/components/objects/Link"
|
||||||
import ScrollTxt from "@/components/objects/MusicText"
|
import ScrollTxt from "@/components/objects/MusicText"
|
||||||
|
import { connectSocket, disconnectSocket } from "@/lib/socket"
|
||||||
|
|
||||||
interface Track {
|
interface LastFmResponse {
|
||||||
track_name: string
|
album?: {
|
||||||
artist_name: string
|
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
|
release_name?: string
|
||||||
mbid?: string
|
mbid?: string
|
||||||
|
coverArt?: string | null
|
||||||
|
lastFmData?: LastFmResponse
|
||||||
|
status: 'loading' | 'partial' | 'complete' | 'error'
|
||||||
|
message?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const NowPlaying: React.FC = () => {
|
const NowPlaying: React.FC = () => {
|
||||||
const [track, setTrack] = useState<Track | null>(null)
|
const [nowPlaying, setNowPlaying] = useState<NowPlayingData>({ status: 'loading' })
|
||||||
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 [currentTime, setCurrentTime] = useState(new Date())
|
const [currentTime, setCurrentTime] = useState(new Date())
|
||||||
const [volume, setVolume] = useState(25)
|
const [volume, setVolume] = useState(25)
|
||||||
const [screenOn, setScreenOn] = useState(true)
|
const [screenOn, setScreenOn] = useState(true)
|
||||||
|
const [progressSteps, setProgressSteps] = useState({ current: 0, total: 3 })
|
||||||
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])
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchNowPlaying()
|
const socket = connectSocket()
|
||||||
}, [fetchNowPlaying])
|
|
||||||
|
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(() => {
|
useEffect(() => {
|
||||||
const timer = setInterval(() => {
|
const timer = setInterval(() => {
|
||||||
|
|
@ -139,28 +101,33 @@ const NowPlaying: React.FC = () => {
|
||||||
}
|
}
|
||||||
|
|
||||||
const renderScreenContent = () => {
|
const renderScreenContent = () => {
|
||||||
if (loading) {
|
if (nowPlaying.status === 'loading') {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center justify-center h-full">
|
<div className="flex flex-col items-center justify-center h-full">
|
||||||
<Loader2 className="animate-spin text-white mb-4" size={32} />
|
<Loader2 className="animate-spin text-white mb-4" size={32} />
|
||||||
<div className="text-white text-xs text-center px-4">
|
<div className="text-white text-xs text-center px-4">
|
||||||
<div className="mb-2">{loadingStatus}</div>
|
<div className="mb-2">{nowPlaying.message || 'Connecting...'}</div>
|
||||||
<Progress value={steps > 0 ? (progress * 100) / steps : 0} className="h-1" />
|
<Progress
|
||||||
|
value={progressSteps.total > 0 ? (progressSteps.current * 100) / progressSteps.total : 0}
|
||||||
|
className="h-1"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (error) {
|
if (nowPlaying.status === 'error') {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center justify-center h-full">
|
<div className="flex flex-col items-center justify-center h-full">
|
||||||
<AlertCircle className="text-red-500 mb-4" size={32} />
|
<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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!track) {
|
if (!nowPlaying.track_name) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center justify-center h-full">
|
<div className="flex flex-col items-center justify-center h-full">
|
||||||
<TbDiscOff className="text-gray-400 mb-4" size={32} />
|
<TbDiscOff className="text-gray-400 mb-4" size={32} />
|
||||||
|
|
@ -175,34 +142,33 @@ const NowPlaying: React.FC = () => {
|
||||||
}
|
}
|
||||||
|
|
||||||
// normal state
|
// normal state
|
||||||
const currentTrack = track!;
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<a
|
<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"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
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%)'}}
|
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">
|
<div className="text-center leading-none pb-1">
|
||||||
<ScrollTxt text={currentTrack.artist_name.toUpperCase()} type="artist" />
|
<ScrollTxt text={nowPlaying.artist_name?.toUpperCase() || ''} type="artist" />
|
||||||
<ScrollTxt text={currentTrack.track_name} type="track" className="-mt-0.5" />
|
<ScrollTxt text={nowPlaying.track_name || ''} type="track" className="-mt-0.5" />
|
||||||
{currentTrack.release_name && <ScrollTxt text={currentTrack.release_name} type="release" className="-mt-1.5" />}
|
{nowPlaying.release_name && <ScrollTxt text={nowPlaying.release_name} type="release" className="-mt-1.5" />}
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
{/* Album art */}
|
{/* Album art */}
|
||||||
<div className="relative w-full aspect-square">
|
<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">
|
<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} />
|
<Loader2 className="animate-spin text-gray-400 mb-2" size={32} />
|
||||||
<div className="text-gray-400 text-xs">Fetching Album Art</div>
|
<div className="text-gray-400 text-xs">Fetching Album Art</div>
|
||||||
</div>
|
</div>
|
||||||
) : coverArt ? (
|
) : nowPlaying.coverArt ? (
|
||||||
<Image
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
src={coverArt}
|
<img
|
||||||
alt={currentTrack.track_name}
|
src={nowPlaying.coverArt}
|
||||||
fill
|
alt={nowPlaying.track_name || 'Album cover'}
|
||||||
style={{ objectFit: "cover" }}
|
className="w-full h-full object-cover"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="w-full h-full bg-gray-700 flex items-center justify-center">
|
<div className="w-full h-full bg-gray-700 flex items-center justify-center">
|
||||||
|
|
@ -216,7 +182,7 @@ const NowPlaying: React.FC = () => {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex justify-center items-center">
|
<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 */}
|
{/* Volume buttons */}
|
||||||
<div className="absolute -left-[2.55px] top-8 rounded-l w-[1.75px] flex flex-col z-0">
|
<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 */}
|
<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>
|
<div className="w-full h-full bg-black"></div>
|
||||||
)}
|
)}
|
||||||
{/* Player controls and seekbar */}
|
{/* Player controls and seekbar */}
|
||||||
{screenOn && track && (
|
{screenOn && nowPlaying.track_name && (
|
||||||
<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%)'}}>
|
<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">
|
<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">
|
<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">
|
<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: '',
|
port: '',
|
||||||
pathname: '/**',
|
pathname: '/**',
|
||||||
},
|
},
|
||||||
{
|
|
||||||
protocol: 'https',
|
|
||||||
hostname: '*.archive.org',
|
|
||||||
port: '',
|
|
||||||
pathname: '/**',
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
dangerouslyAllowSVG: true,
|
dangerouslyAllowSVG: true,
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -3,9 +3,9 @@
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev --turbopack",
|
"dev": "bun run server.ts",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start",
|
"start": "NODE_ENV=production bun run server.ts",
|
||||||
"lint": "next lint"
|
"lint": "next lint"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|
@ -22,6 +22,8 @@
|
||||||
"react-i18next": "^15.7.3",
|
"react-i18next": "^15.7.3",
|
||||||
"react-icons": "^5.5.0",
|
"react-icons": "^5.5.0",
|
||||||
"recharts": "^3.1.2",
|
"recharts": "^3.1.2",
|
||||||
|
"socket.io": "^4.8.1",
|
||||||
|
"socket.io-client": "^4.8.1",
|
||||||
"tailwind-merge": "^3.3.1",
|
"tailwind-merge": "^3.3.1",
|
||||||
"tailwindcss-animate": "^1.0.7",
|
"tailwindcss-animate": "^1.0.7",
|
||||||
"tw-animate-css": "^1.3.8"
|
"tw-animate-css": "^1.3.8"
|
||||||
|
|
@ -29,13 +31,14 @@
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/eslintrc": "^3.3.1",
|
"@eslint/eslintrc": "^3.3.1",
|
||||||
"@tailwindcss/postcss": "^4.1.13",
|
"@tailwindcss/postcss": "^4.1.13",
|
||||||
"@types/node": "^20.19.13",
|
"@types/node": "^24.3.1",
|
||||||
"@types/react": "^19.1.12",
|
"@types/react": "^19.1.12",
|
||||||
"@types/react-dom": "^19.1.9",
|
"@types/react-dom": "^19.1.9",
|
||||||
"eslint": "^9.35.0",
|
"eslint": "^9.35.0",
|
||||||
"eslint-config-next": "15.1.3",
|
"eslint-config-next": "15.1.3",
|
||||||
"postcss": "^8.5.6",
|
"postcss": "^8.5.6",
|
||||||
"tailwindcss": "^4.1.13",
|
"tailwindcss": "^4.1.13",
|
||||||
|
"tsx": "^4.20.5",
|
||||||
"typescript": "^5.9.2"
|
"typescript": "^5.9.2"
|
||||||
},
|
},
|
||||||
"trustedDependencies": [
|
"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