From b6b99d26f49f4df1182c587adde13db2cb496c86 Mon Sep 17 00:00:00 2001 From: Aidan Date: Mon, 17 Feb 2025 18:02:30 -0500 Subject: [PATCH] bump, improve/update now playing component to use listenbrainz, update deploy link, update env variables in readme --- README.md | 21 +-- components/objects/ScrollTxt.tsx | 47 ++++++ components/widgets/LastPlayed.tsx | 251 ++++++++++++++++++++++++------ next.config.ts | 15 +- package.json | 8 +- tailwind.config.ts | 65 ++++++++ 6 files changed, 340 insertions(+), 67 deletions(-) create mode 100644 components/objects/ScrollTxt.tsx create mode 100644 tailwind.config.ts diff --git a/README.md b/README.md index 3b9e16d..8440a03 100644 --- a/README.md +++ b/README.md @@ -8,15 +8,9 @@ It's built with Next.js and Tailwind CSS. aidxnCC will always be a work in progr ## Deploy with Vercel -[![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2Fihatenodejs%2FaidxnCC&env=LASTFM_API_URL&envDescription=LastFM%20public%20API%20link%20for%20your%20username%20(https%3A%2F%2Flastfm-last-played.biancarosa.com.br%2FUSERNAME%2Flatest-song)&envLink=https%3A%2F%2Fgit.pontusmail.org%2Faidan%2FaidxnCC%2Fsrc%2Fbranch%2Fmain%23deploy-with-vercel) +[![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2Fihatenodejs%2FaidxnCC&env=BRAINZ_USER_AGENT,LISTENBRAINZ_TOKEN&envDescription=You%20will%20need%20both%20a%20custom%20user%20agent%20(for%20identifying%20yourself%20to%20MusicBrainz)%2C%20and%20a%20ListenBrainz%20User%20Token.%20See%20the%20README%20for%20more%20information.&envLink=https%3A%2F%2Fgit.pontusmail.org%2Faidan%2FaidxnCC&project-name=aidxn-cc&repository-name=aidxnCC) -To deploy with Vercel, simply click the button above. When prompted for `LASTFM_API_URL`, simply input: - -```plaintext -https://lastfm-last-played.biancarosa.com.br/USERNAME/latest-song -``` - -where `USERNAME` is your LastFM username. +To deploy with Vercel, simply click the button above. When prompted for environment variables, see the section below. ## Contributing @@ -24,7 +18,16 @@ Any and all contributions are welcome! Simply create a pull request and I should Please use common sense when contributing :) +## Environment Variables + +You will need some environment variables set to properly self-host aidxnCC. They are listed below. + +| Environment Variable | Description | Example | +|----------------------|-------------|---------| +| `BRAINZ_USER_AGENT` | User agent used to make requests to MusicBrainz (should include your contact info) | `aidxnCC/1.0 ( aidan@p0ntus.com )` | +| `LISTENBRAINZ_TOKEN` | Your ListenBrainz user token (get this in [settings](https://listenbrainz.org/settings/)) | `0e0x0a0m-0p0l-0e0t-0o0k-0e0n00000000` | + ## To-Do - [ ] Dockerize for easier deployment - +- [ ] Improve speed of fetching now playing diff --git a/components/objects/ScrollTxt.tsx b/components/objects/ScrollTxt.tsx new file mode 100644 index 0000000..ea5b898 --- /dev/null +++ b/components/objects/ScrollTxt.tsx @@ -0,0 +1,47 @@ +"use client" + +import type React from "react" +import { useEffect, useRef, useState } from "react" + +interface ScrollTxtProps { + text: string + className?: string +} + +const ScrollTxt: React.FC = ({ text, className = "" }) => { + const containerRef = useRef(null) + const textRef = useRef(null) + const [shouldScroll, setShouldScroll] = useState(false) + + useEffect(() => { + if (containerRef.current && textRef.current) { + const containerWidth = containerRef.current.offsetWidth + const textWidth = textRef.current.offsetWidth + setShouldScroll(textWidth > containerWidth) + } + }, []) // Updated dependency array + + return ( +
+
+ {shouldScroll ? ( + <> + {text} + + {text} + + {text} + + ) : ( + text + )} +
+
+ ) +} + +export default ScrollTxt + diff --git a/components/widgets/LastPlayed.tsx b/components/widgets/LastPlayed.tsx index e92bb45..44fa1a8 100644 --- a/components/widgets/LastPlayed.tsx +++ b/components/widgets/LastPlayed.tsx @@ -1,64 +1,221 @@ -"use client"; +"use client" -import React, { useEffect, useState } from 'react'; -import Image from 'next/image'; -import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' -import { faLastfm } from '@fortawesome/free-brands-svg-icons' -import { faCompactDisc, faUser } from '@fortawesome/free-solid-svg-icons' +import type React from "react" +import { useEffect, useState, useCallback, useRef } from "react" +import Image from "next/image" +import { Music, ExternalLink, Disc, User, Loader2 } from "lucide-react" +import Marquee from "react-fast-marquee" interface Track { - name: string; - artist: { '#text': string }; - album: { '#text': string }; - image: { '#text': string; size: string }[]; - url: string; - '@attr'?: { nowplaying: string }; + track_name: string + artist_name: string + release_name?: string + mbid?: string } -const LastPlayed: React.FC = () => { - const [track, setTrack] = useState(null); - const apiUrl = process.env.LASTFM_API_URL || 'https://lastfm-last-played.biancarosa.com.br/aidxn_/latest-song'; +const ScrollableText: React.FC<{ text: string; className?: string }> = ({ text, className = "" }) => { + const containerRef = useRef(null) + const [shouldScroll, setShouldScroll] = useState(false) useEffect(() => { - fetch(apiUrl) - .then(response => response.json()) - .then(data => setTrack(data.track)) - .catch(error => console.error('Error fetching now playing:', error)); - }, [apiUrl]); + if (containerRef.current) { + setShouldScroll(containerRef.current.scrollWidth > containerRef.current.clientWidth) + console.log("[i] text width checked: ", containerRef.current.scrollWidth, containerRef.current.clientWidth) + } + }, [containerRef]) - if (!track) { + if (shouldScroll) { + console.log("✅ scrolling is active") return ( -
-

Last Played Song

-
- -
-
- ); + +
{text}
+ +
+ ) } return ( -
-

Last Played Song

-
- img.size === 'large')?.['#text'] || '/placeholder.png'} - alt={track.name} - width={96} - height={96} - className="rounded-lg mr-4" - /> -
-

{track.name}

-

{track.album['#text']}

- {track.artist['#text']} - - View on Last.fm +
+
{text}
+
+ ) +} + +const NowPlaying: React.FC = () => { + const [track, setTrack] = useState(null) + const [coverArt, setCoverArt] = useState(null) + const [loading, setLoading] = useState(true) + const [loadingStatus, setLoadingStatus] = useState("Initializing") + const [error, setError] = useState(null) + + const fetchAlbumArt = useCallback(async (artist: string, album?: string) => { + if (!album) { + console.log("[i] no album found") + setCoverArt(null) + setLoading(false) + return + } + try { + console.log("[i] fetching album art: ", 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) { + console.log("[!] album art fetch error:", response.status) + throw new Error(`HTTP error! status: ${response.status}`) + } + const data = await response.json() + if (data.releases && data.releases.length > 0) { + const mbid = data.releases[0].id + console.log("✅ mbid found:", mbid) + 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-250`) + if (coverArtResponse.ok) { + console.log("✅ cover art found") + setCoverArt(coverArtResponse.url) + } else { + console.log("[i] cover art not found!") + setCoverArt(null) + } + } else { + console.log("[i] no releases in data!") + setCoverArt(null) + } + } catch (error) { + console.log("[!] album art error", error) + setCoverArt(null) + } finally { + setLoading(false) + console.log("[i] album art done") + } + }, []) + + const fetchNowPlaying = useCallback(async () => { + setLoadingStatus("Fetching now playing") + console.log("[i] fetching now playing...") + try { + const response = await fetch("https://api.listenbrainz.org/1/user/p0ntus/playing-now", { + headers: { + Authorization: `Token ${process.env.NEXT_PUBLIC_LISTENBRAINZ_TOKEN}`, + }, + }) + if (!response.ok) { + console.log("[!] now playing error:", response.status) + throw new Error(`HTTP error! Status: ${response.status}`) + } + 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 found: ", trackMetadata.track_name) + console.log("[i] song details: ", trackMetadata.track_name, "-", trackMetadata.artist_name, "/", trackMetadata.release_name) + setTrack({ + track_name: trackMetadata.track_name, + artist_name: trackMetadata.artist_name, + release_name: trackMetadata.release_name, + mbid: trackMetadata.mbid, + }) + setLoadingStatus("Fetching album art") + await fetchAlbumArt(trackMetadata.artist_name, trackMetadata.release_name) + } else { + console.log("[i] no track playing") + setLoadingStatus("No track playing") + setLoading(false) + } + } catch (error) { + console.log("[!] now playing error:", error) + setError("Error fetching now playing data") + setLoading(false) + } + }, [fetchAlbumArt]) + + useEffect(() => { + fetchNowPlaying() + }, [fetchNowPlaying]) + + if (loading) { + return ( +
+

Now Playing

+
+ +

{loadingStatus}

+
+
+ ) + } + + if (error) { + return ( +
+

Now Playing

+
+

{error}

+
+
+ ) + } + + if (!track) { + return ( +
+

Now Playing

+
+

No track playing

+
+
+ ) + } + + return ( +
- ); -}; + ) +} -export default LastPlayed; \ No newline at end of file +export default NowPlaying \ No newline at end of file diff --git a/next.config.ts b/next.config.ts index fe5c876..13b5dee 100644 --- a/next.config.ts +++ b/next.config.ts @@ -3,12 +3,6 @@ import type { NextConfig } from "next"; const nextConfig: NextConfig = { images: { remotePatterns: [ - { - protocol: 'https', - hostname: 'lastfm.freetls.fastly.net', - port: '', - pathname: '/**', - }, { protocol: 'https', hostname: 'p0ntus.com', @@ -21,10 +15,15 @@ const nextConfig: NextConfig = { port: '', pathname: '/**', }, + { + protocol: 'https', + hostname: '*.archive.org', + port: '', + pathname: '/**', + }, ], dangerouslyAllowSVG: true, }, }; -export default nextConfig; - +export default nextConfig; \ No newline at end of file diff --git a/package.json b/package.json index bc37682..83eb3e3 100644 --- a/package.json +++ b/package.json @@ -17,18 +17,20 @@ "lucide-react": "^0.469.0", "next": "^15.2.0-canary.63", "react": "^19.0.0", - "react-dom": "^19.0.0" + "react-dom": "^19.0.0", + "react-fast-marquee": "^1.6.5", + "tailwindcss-animate": "^1.0.7" }, "devDependencies": { "@eslint/eslintrc": "^3.2.0", - "@tailwindcss/postcss": "^4.0.0", + "@tailwindcss/postcss": "^4.0.6", "@types/node": "^20.17.19", "@types/react": "^19.0.10", "@types/react-dom": "^19.0.4", "eslint": "^9.20.1", "eslint-config-next": "15.1.3", "postcss": "^8.5.2", - "tailwindcss": "^4.0.0", + "tailwindcss": "^4.0.6", "typescript": "^5.7.3" } } diff --git a/tailwind.config.ts b/tailwind.config.ts new file mode 100644 index 0000000..667e618 --- /dev/null +++ b/tailwind.config.ts @@ -0,0 +1,65 @@ +import type { Config } from "tailwindcss" +import tailwindAnimate from "tailwindcss-animate" + +const config: Config = { + darkMode: "class", + content: [ + "app/**/*.{ts,tsx}", + "components/**/*.{ts,tsx}", + "./pages/**/*.{js,ts,jsx,tsx,mdx}", + "./components/**/*.{js,ts,jsx,tsx,mdx}", + "./app/**/*.{js,ts,jsx,tsx,mdx}", + "*.{js,ts,jsx,tsx,mdx}", + ], + theme: { + extend: { + colors: { + border: "hsl(var(--border))", + input: "hsl(var(--input))", + ring: "hsl(var(--ring))", + background: "hsl(var(--background))", + foreground: "hsl(var(--foreground))", + primary: { + DEFAULT: "hsl(var(--primary))", + foreground: "hsl(var(--primary-foreground))", + }, + secondary: { + DEFAULT: "hsl(var(--secondary))", + foreground: "hsl(var(--secondary-foreground))", + }, + destructive: { + DEFAULT: "hsl(var(--destructive))", + foreground: "hsl(var(--destructive-foreground))", + }, + muted: { + DEFAULT: "hsl(var(--muted))", + foreground: "hsl(var(--muted-foreground))", + }, + accent: { + DEFAULT: "hsl(var(--accent))", + foreground: "hsl(var(--accent-foreground))", + }, + popover: { + DEFAULT: "hsl(var(--popover))", + foreground: "hsl(var(--popover-foreground))", + }, + card: { + DEFAULT: "hsl(var(--card))", + foreground: "hsl(var(--card-foreground))", + }, + }, + borderRadius: { + lg: "var(--radius)", + md: "calc(var(--radius) - 2px)", + sm: "calc(var(--radius) - 4px)", + }, + boxShadow: { + neon: '0 0 5px theme("colors.blue.400"), 0 0 20px theme("colors.blue.700")', + }, + }, + }, + plugins: [tailwindAnimate], +} + +export default config +