Update production setup docs with new Spotify secret; enhance video components for fullscreen handling; add Spotify now-playing API module.

This commit is contained in:
Nikita
2025-12-23 13:54:34 -08:00
parent 244aa9324a
commit 4a6b510a5e
6 changed files with 418 additions and 36 deletions

View File

@@ -7,6 +7,10 @@ import { WebRTCPlayer } from "@/components/WebRTCPlayer"
import { resolveStreamPlayback } from "@/lib/stream/playback"
import { JazzProvider } from "@/lib/jazz/provider"
import { ViewerCount } from "@/components/ViewerCount"
import {
getSpotifyNowPlaying,
type SpotifyNowPlayingResponse,
} from "@/lib/spotify/now-playing"
export const Route = createFileRoute("/$username")({
ssr: false,
@@ -46,6 +50,11 @@ function StreamPage() {
const [error, setError] = useState<string | null>(null)
const [streamReady, setStreamReady] = useState(false)
const [webRtcFailed, setWebRtcFailed] = useState(false)
const [nowPlaying, setNowPlaying] = useState<SpotifyNowPlayingResponse | null>(
null,
)
const [nowPlayingLoading, setNowPlayingLoading] = useState(false)
const [nowPlayingError, setNowPlayingError] = useState(false)
useEffect(() => {
let isActive = true
@@ -151,6 +160,47 @@ function StreamPage() {
activePlayback?.type === "hls" ? activePlayback.url : null,
])
const shouldFetchSpotify = username === "nikiv" && !stream?.is_live
useEffect(() => {
if (!shouldFetchSpotify) {
setNowPlaying(null)
setNowPlayingLoading(false)
setNowPlayingError(false)
return
}
let isActive = true
const fetchNowPlaying = async (showLoading: boolean) => {
if (showLoading) {
setNowPlayingLoading(true)
}
try {
const response = await getSpotifyNowPlaying()
if (!isActive) return
setNowPlaying(response)
setNowPlayingError(false)
} catch (err) {
if (!isActive) return
console.error("Failed to load Spotify now playing", err)
setNowPlayingError(true)
} finally {
if (isActive && showLoading) {
setNowPlayingLoading(false)
}
}
}
fetchNowPlaying(true)
const interval = setInterval(() => fetchNowPlaying(false), 30000)
return () => {
isActive = false
clearInterval(interval)
}
}, [shouldFetchSpotify])
if (loading) {
return (
<div className="flex min-h-screen items-center justify-center bg-black text-white">
@@ -187,6 +237,13 @@ function StreamPage() {
activePlayback?.type === "cloudflare" ||
activePlayback?.type === "webrtc" ||
(activePlayback?.type === "hls" && streamReady)
const nowPlayingTrack = nowPlaying?.track ?? null
const nowPlayingArtists = nowPlayingTrack?.artists.length
? nowPlayingTrack.artists.join(", ")
: null
const nowPlayingEmbedUrl = nowPlayingTrack?.id
? `https://open.spotify.com/embed/${nowPlayingTrack.type}/${nowPlayingTrack.id}?utm_source=linsa&theme=0`
: null
return (
<JazzProvider>
@@ -252,19 +309,110 @@ function StreamPage() {
</div>
) : (
<div className="flex h-full w-full items-center justify-center text-white">
<div className="text-center">
<p className="text-2xl font-medium text-neutral-400 mb-6">
stream soon
</p>
<a
href={username === "nikiv" ? "https://nikiv.dev" : "#"}
target="_blank"
rel="noopener noreferrer"
className="text-4xl font-medium text-white hover:text-neutral-300 transition-colors"
>
{username === "nikiv" ? "nikiv.dev" : `@${username}`}
</a>
</div>
{shouldFetchSpotify ? (
<div className="mx-auto flex w-full max-w-2xl flex-col items-center px-6 text-center">
<div className="flex items-center gap-2 text-xs uppercase tracking-[0.35em] text-neutral-400">
<span className="h-2 w-2 rounded-full bg-neutral-500" />
Offline
</div>
<p className="mt-6 text-3xl font-semibold">
Not live right now
</p>
<div className="mt-8 w-full rounded-2xl border border-white/10 bg-neutral-900/60 p-6 shadow-[0_0_40px_rgba(0,0,0,0.45)]">
<p className="text-xs uppercase tracking-[0.3em] text-neutral-400">
{nowPlaying?.isPlaying ? "Currently playing" : "Spotify"}
</p>
{nowPlayingLoading ? (
<p className="mt-4 text-neutral-400">Checking Spotify...</p>
) : nowPlaying?.isPlaying && nowPlayingTrack ? (
<div className="mt-4 flex flex-col gap-4">
<div className="flex flex-col items-center gap-4 sm:flex-row sm:items-center sm:text-left">
{nowPlayingTrack.imageUrl ? (
<img
src={nowPlayingTrack.imageUrl}
alt="Spotify cover art"
className="h-20 w-20 rounded-lg object-cover"
/>
) : (
<div className="h-20 w-20 rounded-lg bg-neutral-800" />
)}
<div>
<p className="text-2xl font-semibold">
{nowPlayingTrack.title}
</p>
{nowPlayingArtists ? (
<p className="text-neutral-400">
{nowPlayingArtists}
</p>
) : null}
{nowPlayingTrack.album ? (
<p className="text-sm text-neutral-500">
{nowPlayingTrack.album}
</p>
) : null}
</div>
</div>
{nowPlayingEmbedUrl ? (
<iframe
src={nowPlayingEmbedUrl}
title="Spotify player"
width="100%"
height="152"
allow="autoplay; clipboard-write; encrypted-media; fullscreen; picture-in-picture"
loading="lazy"
className="rounded-xl border border-white/10"
/>
) : null}
{nowPlayingTrack.url ? (
<a
href={nowPlayingTrack.url}
target="_blank"
rel="noopener noreferrer"
className="text-sm uppercase tracking-[0.2em] text-neutral-300 hover:text-white transition-colors"
>
Open in Spotify
</a>
) : null}
</div>
) : nowPlayingError ? (
<p className="mt-4 text-neutral-400">
Spotify status unavailable right now.
</p>
) : (
<p className="mt-4 text-neutral-400">
Not playing anything right now.
</p>
)}
</div>
<a
href="https://nikiv.dev"
target="_blank"
rel="noopener noreferrer"
className="mt-8 text-3xl font-medium text-white hover:text-neutral-300 transition-colors"
>
nikiv.dev
</a>
</div>
) : (
<div className="text-center">
<p className="text-2xl font-medium text-neutral-400 mb-6">
stream soon
</p>
<a
href={username === "nikiv" ? "https://nikiv.dev" : "#"}
target="_blank"
rel="noopener noreferrer"
className="text-4xl font-medium text-white hover:text-neutral-300 transition-colors"
>
{username === "nikiv" ? "nikiv.dev" : `@${username}`}
</a>
</div>
)}
</div>
)}
</div>

View File

@@ -0,0 +1,125 @@
import { createFileRoute } from "@tanstack/react-router"
type SpotifyTrack = {
id: string | null
title: string
artists: string[]
album: string | null
imageUrl: string | null
url: string | null
type: "track"
}
type SpotifyNowPlayingResponse = {
isPlaying: boolean
track: SpotifyTrack | null
}
const JAZZ_READ_KEY = "nikiv-spotify@garden.co"
const resolveSpotifyStateId = (): string | undefined => {
let stateId: string | undefined
try {
const { getServerContext } = require("@tanstack/react-start/server") as {
getServerContext: () => { cloudflare?: { env?: Record<string, string> } } | null
}
const ctx = getServerContext()
if (ctx?.cloudflare?.env) {
stateId = ctx.cloudflare.env.JAZZ_SPOTIFY_STATE_ID
}
} catch {
// not in Cloudflare context
}
return stateId ?? process.env.JAZZ_SPOTIFY_STATE_ID
}
const parseTrackIdFromUrl = (url: string | null | undefined) => {
if (!url) return null
try {
const parsed = new URL(url)
const parts = parsed.pathname.split("/").filter(Boolean)
const id = parts[1]
if (!id) return null
return id
} catch {
return null
}
}
const buildTrackPayload = (song: string, url: string | null): SpotifyTrack => {
const id = parseTrackIdFromUrl(url)
const splitIndex = song.indexOf(" - ")
const artists =
splitIndex > 0 ? [song.slice(0, splitIndex).trim()] : []
const title =
splitIndex > 0 ? song.slice(splitIndex + 3).trim() : song.trim()
return {
id,
title: title || song,
artists,
album: null,
imageUrl: null,
url,
type: "track",
}
}
const handler = async () => {
const stateId = resolveSpotifyStateId()
if (!stateId) {
return new Response(JSON.stringify({ error: "Spotify not configured" }), {
status: 503,
headers: { "content-type": "application/json" },
})
}
try {
const response = await fetch(
`https://cloud.jazz.tools/api/value/${stateId}?key=${JAZZ_READ_KEY}`,
{ cache: "no-store" },
)
if (!response.ok) {
return new Response(JSON.stringify({ error: "Spotify request failed" }), {
status: 502,
headers: { "content-type": "application/json" },
})
}
const data = await response.json()
const song = typeof data?.song === "string" ? data.song : null
const url = typeof data?.url === "string" ? data.url : null
const track = song ? buildTrackPayload(song, url) : null
const payload: SpotifyNowPlayingResponse = {
isPlaying: Boolean(song && url),
track,
}
return new Response(JSON.stringify(payload), {
status: 200,
headers: {
"content-type": "application/json",
"cache-control": "no-store",
},
})
} catch (error) {
console.error("Spotify now playing error:", error)
return new Response(JSON.stringify({ error: "Spotify request failed" }), {
status: 502,
headers: { "content-type": "application/json" },
})
}
}
export const Route = createFileRoute("/api/spotify/now-playing")({
server: {
handlers: {
GET: handler,
},
},
})