From 4a6b510a5e370505669ff2b7f13b19f3b10e94ca Mon Sep 17 00:00:00 2001 From: Nikita Date: Tue, 23 Dec 2025 13:54:34 -0800 Subject: [PATCH] Update production setup docs with new Spotify secret; enhance video components for fullscreen handling; add Spotify now-playing API module. --- docs/production-setup.md | 2 + .../src/components/CloudflareStreamPlayer.tsx | 72 ++++++-- packages/web/src/components/VideoPlayer.tsx | 51 ++++- packages/web/src/lib/spotify/now-playing.ts | 30 +++ packages/web/src/routes/$username.tsx | 174 ++++++++++++++++-- .../web/src/routes/api/spotify.now-playing.ts | 125 +++++++++++++ 6 files changed, 418 insertions(+), 36 deletions(-) create mode 100644 packages/web/src/lib/spotify/now-playing.ts create mode 100644 packages/web/src/routes/api/spotify.now-playing.ts diff --git a/docs/production-setup.md b/docs/production-setup.md index ce090ab2..c70308a4 100644 --- a/docs/production-setup.md +++ b/docs/production-setup.md @@ -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_SECRET # only if Electric Cloud auth is on 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: ```bash @@ -79,6 +80,7 @@ f deploy | `APP_BASE_URL` | Yes | Production origin for cookies/CORS (e.g., https://app.example.com) | | `OPENROUTER_API_KEY` | No | Enables real AI responses | | `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 - Auth: `APP_BASE_URL` must match your deployed origin; rotate `BETTER_AUTH_SECRET` only when you intend to invalidate sessions. diff --git a/packages/web/src/components/CloudflareStreamPlayer.tsx b/packages/web/src/components/CloudflareStreamPlayer.tsx index 6f7405f5..058e9971 100644 --- a/packages/web/src/components/CloudflareStreamPlayer.tsx +++ b/packages/web/src/components/CloudflareStreamPlayer.tsx @@ -1,3 +1,4 @@ +import { useEffect, useRef } from "react" import { Stream } from "@cloudflare/stream-react" type CloudflareStreamPlayerProps = { @@ -15,23 +16,68 @@ export function CloudflareStreamPlayer({ muted = false, onReady, }: CloudflareStreamPlayerProps) { + const containerRef = useRef(null) + const handleReady = () => { 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 ( - +
+ +
) } diff --git a/packages/web/src/components/VideoPlayer.tsx b/packages/web/src/components/VideoPlayer.tsx index 92062ad7..ca80270c 100644 --- a/packages/web/src/components/VideoPlayer.tsx +++ b/packages/web/src/components/VideoPlayer.tsx @@ -12,6 +12,7 @@ export function VideoPlayer({ autoPlay = true, muted = false, }: VideoPlayerProps) { + const containerRef = useRef(null) const videoRef = useRef(null) const hlsRef = useRef(null) const [isPlaying, setIsPlaying] = useState(autoPlay) @@ -150,7 +151,8 @@ export function VideoPlayer({ const handleFullscreen = async () => { const video = videoRef.current - if (!video) return + const container = containerRef.current + if (!video || !container) return const doc = document as Document & { webkitFullscreenElement?: Element | null @@ -162,6 +164,9 @@ export function VideoPlayer({ webkitRequestFullscreen?: () => Promise | void webkitDisplayingFullscreen?: boolean } + const containerEl = container as HTMLElement & { + webkitRequestFullscreen?: () => Promise | void + } const isDocFullscreen = !!doc.fullscreenElement || !!doc.webkitFullscreenElement const isVideoFullscreen = !!videoEl.webkitDisplayingFullscreen @@ -184,15 +189,40 @@ export function VideoPlayer({ return } - if (video.requestFullscreen) { - await video.requestFullscreen() - setIsFullscreen(true) - } else if (videoEl.webkitRequestFullscreen) { - await videoEl.webkitRequestFullscreen() - setIsFullscreen(true) - } else if (videoEl.webkitEnterFullscreen) { - videoEl.webkitEnterFullscreen() - setIsFullscreen(true) + 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) { + await video.requestFullscreen() + setIsFullscreen(true) + } else if (videoEl.webkitRequestFullscreen) { + await videoEl.webkitRequestFullscreen() + setIsFullscreen(true) + } else if (videoEl.webkitEnterFullscreen) { + videoEl.webkitEnterFullscreen() + setIsFullscreen(true) + } + } catch { + // Ignore fullscreen errors to avoid breaking playback. } } @@ -216,6 +246,7 @@ export function VideoPlayer({ return (
isPlaying && setShowControls(false)} diff --git a/packages/web/src/lib/spotify/now-playing.ts b/packages/web/src/lib/spotify/now-playing.ts new file mode 100644 index 00000000..99fcc2c1 --- /dev/null +++ b/packages/web/src/lib/spotify/now-playing.ts @@ -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 { + 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() +} diff --git a/packages/web/src/routes/$username.tsx b/packages/web/src/routes/$username.tsx index 30ca8791..52cfef7c 100644 --- a/packages/web/src/routes/$username.tsx +++ b/packages/web/src/routes/$username.tsx @@ -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(null) const [streamReady, setStreamReady] = useState(false) const [webRtcFailed, setWebRtcFailed] = useState(false) + const [nowPlaying, setNowPlaying] = useState( + 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 (
@@ -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 ( @@ -252,19 +309,110 @@ function StreamPage() {
) : (
- + {shouldFetchSpotify ? ( +
+
+ + Offline +
+

+ Not live right now +

+ +
+

+ {nowPlaying?.isPlaying ? "Currently playing" : "Spotify"} +

+ + {nowPlayingLoading ? ( +

Checking Spotify...

+ ) : nowPlaying?.isPlaying && nowPlayingTrack ? ( +
+
+ {nowPlayingTrack.imageUrl ? ( + Spotify cover art + ) : ( +
+ )} +
+

+ {nowPlayingTrack.title} +

+ {nowPlayingArtists ? ( +

+ {nowPlayingArtists} +

+ ) : null} + {nowPlayingTrack.album ? ( +

+ {nowPlayingTrack.album} +

+ ) : null} +
+
+ + {nowPlayingEmbedUrl ? ( +