From 7121ec926fa219250a17ef0dfee6201ae81d281e Mon Sep 17 00:00:00 2001 From: Aidan Date: Sun, 7 Sep 2025 00:09:06 -0400 Subject: [PATCH] feat/fix: implement WebSockets for NowPlaying, better data fetching with addl. Last.fm fetch, docker build fix --- Dockerfile | 15 +- README.md | 7 +- app/layout.tsx | 60 +++--- app/page.tsx | 8 +- components/widgets/LiveIndicator.tsx | 36 ++++ components/widgets/NowPlaying.tsx | 212 +++++++++------------ lib/now-playing-server.ts | 263 +++++++++++++++++++++++++++ lib/socket.ts | 28 +++ next.config.ts | 6 - package.json | 9 +- server.ts | 45 +++++ 11 files changed, 514 insertions(+), 175 deletions(-) create mode 100644 components/widgets/LiveIndicator.tsx create mode 100644 lib/now-playing-server.ts create mode 100644 lib/socket.ts create mode 100644 server.ts diff --git a/Dockerfile b/Dockerfile index 0e2d1be..922363e 100644 --- a/Dockerfile +++ b/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"] \ No newline at end of file +CMD ["bun", "run", "server.ts"] \ No newline at end of file diff --git a/README.md b/README.md index 2300315..29ae9d3 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/app/layout.tsx b/app/layout.tsx index 853653d..758a424 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -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 ( - - - - - - - - - - - - - - - - - - - - - - - - @@ -63,5 +68,4 @@ export default function RootLayout({ ); -} - +} \ No newline at end of file diff --git a/app/page.tsx b/app/page.tsx index c6f7e8f..65b0146 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -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() {
-
-
-
- LIVE -
-
+
diff --git a/components/widgets/LiveIndicator.tsx b/components/widgets/LiveIndicator.tsx new file mode 100644 index 0000000..d357719 --- /dev/null +++ b/components/widgets/LiveIndicator.tsx @@ -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 ( +
+
+
+ {connected ? "LIVE" : "Connecting..."} +
+
+ ) +} + +export default LiveIndicator \ No newline at end of file diff --git a/components/widgets/NowPlaying.tsx b/components/widgets/NowPlaying.tsx index 9ba1eb3..0f1b9b3 100644 --- a/components/widgets/NowPlaying.tsx +++ b/components/widgets/NowPlaying.tsx @@ -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(null) - const [coverArt, setCoverArt] = useState(null) - const [loading, setLoading] = useState(true) - const [albumArtLoading, setAlbumArtLoading] = useState(false) - const [loadingStatus, setLoadingStatus] = useState("Initializing") - const [error, setError] = useState(null) - const [progress, setProgress] = useState(0) - const [steps, setSteps] = useState(0) + const [nowPlaying, setNowPlaying] = useState({ 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 (
-
{loadingStatus}
- 0 ? (progress * 100) / steps : 0} className="h-1" /> +
{nowPlaying.message || 'Connecting...'}
+ 0 ? (progressSteps.current * 100) / progressSteps.total : 0} + className="h-1" + />
) } - if (error) { + if (nowPlaying.status === 'error') { return (
-
{error}
+
+ {nowPlaying.message || 'Error loading data'} +
) } - if (!track) { + if (!nowPlaying.track_name) { return (
@@ -175,34 +142,33 @@ const NowPlaying: React.FC = () => { } // normal state - const currentTrack = track!; return ( <>
- - - {currentTrack.release_name && } + + + {nowPlaying.release_name && }
{/* Album art */}
- {albumArtLoading ? ( + {nowPlaying.status === 'partial' && !nowPlaying.coverArt ? (
Fetching Album Art
- ) : coverArt ? ( - {currentTrack.track_name} ) : (
@@ -216,7 +182,7 @@ const NowPlaying: React.FC = () => { return (
-
+
{/* Volume buttons */}
setVolume(v => Math.min(100, v + 5))}>
{/* up */} @@ -245,8 +211,8 @@ const NowPlaying: React.FC = () => {
)} {/* Player controls and seekbar */} - {screenOn && track && ( -
+ {screenOn && nowPlaying.track_name && ( +