mirror of
https://github.com/linsa-io/linsa.git
synced 2026-04-25 09:48:44 +02:00
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:
@@ -38,6 +38,7 @@ wrangler secret put ELECTRIC_URL # e.g., https://your-electric-host/v1/sh
|
|||||||
wrangler secret put ELECTRIC_SOURCE_ID # only if Electric Cloud auth is on
|
wrangler secret put ELECTRIC_SOURCE_ID # only if Electric Cloud auth is on
|
||||||
wrangler secret put ELECTRIC_SOURCE_SECRET # only if Electric Cloud auth is on
|
wrangler secret put ELECTRIC_SOURCE_SECRET # only if Electric Cloud auth is on
|
||||||
wrangler secret put OPENROUTER_API_KEY # optional, for real AI replies
|
wrangler secret put OPENROUTER_API_KEY # optional, for real AI replies
|
||||||
|
wrangler secret put JAZZ_SPOTIFY_STATE_ID # optional, for Spotify now playing
|
||||||
```
|
```
|
||||||
- Set non-secret vars:
|
- Set non-secret vars:
|
||||||
```bash
|
```bash
|
||||||
@@ -79,6 +80,7 @@ f deploy
|
|||||||
| `APP_BASE_URL` | Yes | Production origin for cookies/CORS (e.g., https://app.example.com) |
|
| `APP_BASE_URL` | Yes | Production origin for cookies/CORS (e.g., https://app.example.com) |
|
||||||
| `OPENROUTER_API_KEY` | No | Enables real AI responses |
|
| `OPENROUTER_API_KEY` | No | Enables real AI responses |
|
||||||
| `OPENROUTER_MODEL` | No | AI model id (default: `anthropic/claude-sonnet-4`) |
|
| `OPENROUTER_MODEL` | No | AI model id (default: `anthropic/claude-sonnet-4`) |
|
||||||
|
| `JAZZ_SPOTIFY_STATE_ID` | No | Jazz Spotify state id (from `x/server`) for now playing proxy |
|
||||||
|
|
||||||
## Troubleshooting
|
## Troubleshooting
|
||||||
- Auth: `APP_BASE_URL` must match your deployed origin; rotate `BETTER_AUTH_SECRET` only when you intend to invalidate sessions.
|
- Auth: `APP_BASE_URL` must match your deployed origin; rotate `BETTER_AUTH_SECRET` only when you intend to invalidate sessions.
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { useEffect, useRef } from "react"
|
||||||
import { Stream } from "@cloudflare/stream-react"
|
import { Stream } from "@cloudflare/stream-react"
|
||||||
|
|
||||||
type CloudflareStreamPlayerProps = {
|
type CloudflareStreamPlayerProps = {
|
||||||
@@ -15,11 +16,55 @@ export function CloudflareStreamPlayer({
|
|||||||
muted = false,
|
muted = false,
|
||||||
onReady,
|
onReady,
|
||||||
}: CloudflareStreamPlayerProps) {
|
}: CloudflareStreamPlayerProps) {
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
const handleReady = () => {
|
const handleReady = () => {
|
||||||
onReady?.()
|
onReady?.()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const container = containerRef.current
|
||||||
|
if (!container) return
|
||||||
|
|
||||||
|
const ensureFullscreenAllow = () => {
|
||||||
|
const iframe = container.querySelector("iframe")
|
||||||
|
if (!iframe) return false
|
||||||
|
|
||||||
|
const allow = iframe.getAttribute("allow") ?? ""
|
||||||
|
const parts = allow
|
||||||
|
.split(";")
|
||||||
|
.map((part) => part.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
if (!parts.includes("fullscreen")) {
|
||||||
|
parts.push("fullscreen")
|
||||||
|
iframe.setAttribute("allow", parts.join("; "))
|
||||||
|
}
|
||||||
|
if (!iframe.hasAttribute("allowfullscreen")) {
|
||||||
|
iframe.setAttribute("allowfullscreen", "")
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ensureFullscreenAllow()) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof MutationObserver === "undefined") {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const observer = new MutationObserver(() => {
|
||||||
|
if (ensureFullscreenAllow()) {
|
||||||
|
observer.disconnect()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
observer.observe(container, { childList: true, subtree: true })
|
||||||
|
|
||||||
|
return () => observer.disconnect()
|
||||||
|
}, [uid, customerCode])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<div ref={containerRef} className="h-full w-full">
|
||||||
<Stream
|
<Stream
|
||||||
className="h-full w-full"
|
className="h-full w-full"
|
||||||
src={uid}
|
src={uid}
|
||||||
@@ -33,5 +78,6 @@ export function CloudflareStreamPlayer({
|
|||||||
onCanPlay={handleReady}
|
onCanPlay={handleReady}
|
||||||
onPlaying={handleReady}
|
onPlaying={handleReady}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ export function VideoPlayer({
|
|||||||
autoPlay = true,
|
autoPlay = true,
|
||||||
muted = false,
|
muted = false,
|
||||||
}: VideoPlayerProps) {
|
}: VideoPlayerProps) {
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null)
|
||||||
const videoRef = useRef<HTMLVideoElement>(null)
|
const videoRef = useRef<HTMLVideoElement>(null)
|
||||||
const hlsRef = useRef<Hls | null>(null)
|
const hlsRef = useRef<Hls | null>(null)
|
||||||
const [isPlaying, setIsPlaying] = useState(autoPlay)
|
const [isPlaying, setIsPlaying] = useState(autoPlay)
|
||||||
@@ -150,7 +151,8 @@ export function VideoPlayer({
|
|||||||
|
|
||||||
const handleFullscreen = async () => {
|
const handleFullscreen = async () => {
|
||||||
const video = videoRef.current
|
const video = videoRef.current
|
||||||
if (!video) return
|
const container = containerRef.current
|
||||||
|
if (!video || !container) return
|
||||||
|
|
||||||
const doc = document as Document & {
|
const doc = document as Document & {
|
||||||
webkitFullscreenElement?: Element | null
|
webkitFullscreenElement?: Element | null
|
||||||
@@ -162,6 +164,9 @@ export function VideoPlayer({
|
|||||||
webkitRequestFullscreen?: () => Promise<void> | void
|
webkitRequestFullscreen?: () => Promise<void> | void
|
||||||
webkitDisplayingFullscreen?: boolean
|
webkitDisplayingFullscreen?: boolean
|
||||||
}
|
}
|
||||||
|
const containerEl = container as HTMLElement & {
|
||||||
|
webkitRequestFullscreen?: () => Promise<void> | void
|
||||||
|
}
|
||||||
|
|
||||||
const isDocFullscreen = !!doc.fullscreenElement || !!doc.webkitFullscreenElement
|
const isDocFullscreen = !!doc.fullscreenElement || !!doc.webkitFullscreenElement
|
||||||
const isVideoFullscreen = !!videoEl.webkitDisplayingFullscreen
|
const isVideoFullscreen = !!videoEl.webkitDisplayingFullscreen
|
||||||
@@ -184,6 +189,28 @@ export function VideoPlayer({
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const requestContainerFullscreen = async () => {
|
||||||
|
if (containerEl.requestFullscreen) {
|
||||||
|
await containerEl.requestFullscreen()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if (containerEl.webkitRequestFullscreen) {
|
||||||
|
await containerEl.webkitRequestFullscreen()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (await requestContainerFullscreen()) {
|
||||||
|
setIsFullscreen(true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Fall through to video fullscreen methods.
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
if (video.requestFullscreen) {
|
if (video.requestFullscreen) {
|
||||||
await video.requestFullscreen()
|
await video.requestFullscreen()
|
||||||
setIsFullscreen(true)
|
setIsFullscreen(true)
|
||||||
@@ -194,6 +221,9 @@ export function VideoPlayer({
|
|||||||
videoEl.webkitEnterFullscreen()
|
videoEl.webkitEnterFullscreen()
|
||||||
setIsFullscreen(true)
|
setIsFullscreen(true)
|
||||||
}
|
}
|
||||||
|
} catch {
|
||||||
|
// Ignore fullscreen errors to avoid breaking playback.
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleMouseMove = () => {
|
const handleMouseMove = () => {
|
||||||
@@ -216,6 +246,7 @@ export function VideoPlayer({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
ref={containerRef}
|
||||||
className="group relative h-full w-full bg-black"
|
className="group relative h-full w-full bg-black"
|
||||||
onMouseMove={handleMouseMove}
|
onMouseMove={handleMouseMove}
|
||||||
onMouseLeave={() => isPlaying && setShowControls(false)}
|
onMouseLeave={() => isPlaying && setShowControls(false)}
|
||||||
|
|||||||
30
packages/web/src/lib/spotify/now-playing.ts
Normal file
30
packages/web/src/lib/spotify/now-playing.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
export type SpotifyNowPlayingTrack = {
|
||||||
|
id: string | null
|
||||||
|
title: string
|
||||||
|
artists: string[]
|
||||||
|
album: string | null
|
||||||
|
imageUrl: string | null
|
||||||
|
url: string | null
|
||||||
|
type: "track" | "episode"
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SpotifyNowPlayingResponse = {
|
||||||
|
isPlaying: boolean
|
||||||
|
track: SpotifyNowPlayingTrack | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getSpotifyNowPlaying(): Promise<SpotifyNowPlayingResponse> {
|
||||||
|
const response = await fetch("/api/spotify/now-playing", {
|
||||||
|
credentials: "include",
|
||||||
|
})
|
||||||
|
|
||||||
|
if (response.status === 204) {
|
||||||
|
return { isPlaying: false, track: null }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error("Failed to load Spotify now playing")
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json()
|
||||||
|
}
|
||||||
@@ -7,6 +7,10 @@ import { WebRTCPlayer } from "@/components/WebRTCPlayer"
|
|||||||
import { resolveStreamPlayback } from "@/lib/stream/playback"
|
import { resolveStreamPlayback } from "@/lib/stream/playback"
|
||||||
import { JazzProvider } from "@/lib/jazz/provider"
|
import { JazzProvider } from "@/lib/jazz/provider"
|
||||||
import { ViewerCount } from "@/components/ViewerCount"
|
import { ViewerCount } from "@/components/ViewerCount"
|
||||||
|
import {
|
||||||
|
getSpotifyNowPlaying,
|
||||||
|
type SpotifyNowPlayingResponse,
|
||||||
|
} from "@/lib/spotify/now-playing"
|
||||||
|
|
||||||
export const Route = createFileRoute("/$username")({
|
export const Route = createFileRoute("/$username")({
|
||||||
ssr: false,
|
ssr: false,
|
||||||
@@ -46,6 +50,11 @@ function StreamPage() {
|
|||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
const [streamReady, setStreamReady] = useState(false)
|
const [streamReady, setStreamReady] = useState(false)
|
||||||
const [webRtcFailed, setWebRtcFailed] = 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(() => {
|
useEffect(() => {
|
||||||
let isActive = true
|
let isActive = true
|
||||||
@@ -151,6 +160,47 @@ function StreamPage() {
|
|||||||
activePlayback?.type === "hls" ? activePlayback.url : null,
|
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) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-screen items-center justify-center bg-black text-white">
|
<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 === "cloudflare" ||
|
||||||
activePlayback?.type === "webrtc" ||
|
activePlayback?.type === "webrtc" ||
|
||||||
(activePlayback?.type === "hls" && streamReady)
|
(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 (
|
return (
|
||||||
<JazzProvider>
|
<JazzProvider>
|
||||||
@@ -252,6 +309,96 @@ function StreamPage() {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex h-full w-full items-center justify-center text-white">
|
<div className="flex h-full w-full items-center justify-center text-white">
|
||||||
|
{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">
|
<div className="text-center">
|
||||||
<p className="text-2xl font-medium text-neutral-400 mb-6">
|
<p className="text-2xl font-medium text-neutral-400 mb-6">
|
||||||
stream soon
|
stream soon
|
||||||
@@ -265,6 +412,7 @@ function StreamPage() {
|
|||||||
{username === "nikiv" ? "nikiv.dev" : `@${username}`}
|
{username === "nikiv" ? "nikiv.dev" : `@${username}`}
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
125
packages/web/src/routes/api/spotify.now-playing.ts
Normal file
125
packages/web/src/routes/api/spotify.now-playing.ts
Normal 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,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user