Improve VideoPlayer to notify when ready and update StreamPage to handle player readiness and connection state

This commit is contained in:
Nikita
2025-12-24 19:06:58 -08:00
parent 0aa68c9ae8
commit 3141241626
3 changed files with 213 additions and 192 deletions

View File

@@ -5,12 +5,14 @@ interface VideoPlayerProps {
src: string src: string
autoPlay?: boolean autoPlay?: boolean
muted?: boolean muted?: boolean
onReady?: () => void
} }
export function VideoPlayer({ export function VideoPlayer({
src, src,
autoPlay = true, autoPlay = true,
muted = false, muted = false,
onReady,
}: VideoPlayerProps) { }: VideoPlayerProps) {
const containerRef = useRef<HTMLDivElement>(null) const containerRef = useRef<HTMLDivElement>(null)
const videoRef = useRef<HTMLVideoElement>(null) const videoRef = useRef<HTMLVideoElement>(null)
@@ -39,7 +41,17 @@ export function VideoPlayer({
// Check if native HLS is supported (Safari) // Check if native HLS is supported (Safari)
if (video.canPlayType("application/vnd.apple.mpegurl")) { if (video.canPlayType("application/vnd.apple.mpegurl")) {
video.src = src video.src = src
if (autoPlay) video.play().catch(() => setIsPlaying(false)) if (autoPlay) {
video.play()
.then(() => {
setIsPlaying(true)
onReady?.()
})
.catch(() => setIsPlaying(false))
} else {
// Even if not autoplay, notify ready when we can play
video.addEventListener("canplay", () => onReady?.(), { once: true })
}
return return
} }
@@ -56,7 +68,16 @@ export function VideoPlayer({
hls.attachMedia(video) hls.attachMedia(video)
hls.on(Hls.Events.MANIFEST_PARSED, () => { hls.on(Hls.Events.MANIFEST_PARSED, () => {
if (autoPlay) video.play().catch(() => setIsPlaying(false)) if (autoPlay) {
video.play()
.then(() => {
setIsPlaying(true)
onReady?.()
})
.catch(() => setIsPlaying(false))
} else {
onReady?.()
}
}) })
hls.on(Hls.Events.ERROR, (_, data) => { hls.on(Hls.Events.ERROR, (_, data) => {
@@ -87,7 +108,7 @@ export function VideoPlayer({
} else { } else {
setError("HLS playback not supported in this browser") setError("HLS playback not supported in this browser")
} }
}, [src, autoPlay]) }, [src, autoPlay, onReady])
useEffect(() => { useEffect(() => {
return () => { return () => {

View File

@@ -14,6 +14,7 @@ import {
} from "@/lib/spotify/now-playing" } from "@/lib/spotify/now-playing"
import { getStreamStatus } from "@/lib/stream/status" import { getStreamStatus } from "@/lib/stream/status"
import { authClient } from "@/lib/auth-client" import { authClient } from "@/lib/auth-client"
import { MessageCircle, LogIn, X } from "lucide-react"
export const Route = createFileRoute("/$username")({ export const Route = createFileRoute("/$username")({
ssr: false, ssr: false,
@@ -21,7 +22,7 @@ export const Route = createFileRoute("/$username")({
}) })
// Cloudflare Stream HLS URL // Cloudflare Stream HLS URL
const HLS_URL = "https://customer-xctsztqzu046isdc.cloudflarestream.com/bb7858eafc85de6c92963f3817477b5d/manifest/video.m3u8" const HLS_URL = "https://customer-xctsztqzu046isdc.cloudflarestream.com/1b0363e3f8d54ddc639dc85737f8c28a/manifest/video.m3u8"
const NIKIV_PLAYBACK = resolveStreamPlayback({ hlsUrl: HLS_URL, webrtcUrl: null }) const NIKIV_PLAYBACK = resolveStreamPlayback({ hlsUrl: HLS_URL, webrtcUrl: null })
const READY_PULSE_MS = 1200 const READY_PULSE_MS = 1200
@@ -79,9 +80,9 @@ function StreamPage() {
const [data, setData] = useState<StreamPageData | null>(null) const [data, setData] = useState<StreamPageData | null>(null)
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [streamReady, setStreamReady] = useState(false) const [playerReady, setPlayerReady] = useState(false)
const [webRtcFailed, setWebRtcFailed] = useState(false)
const [hlsLive, setHlsLive] = useState<boolean | null>(null) const [hlsLive, setHlsLive] = useState<boolean | null>(null)
const [isConnecting, setIsConnecting] = useState(false)
const [nowPlaying, setNowPlaying] = useState<SpotifyNowPlayingResponse | null>( const [nowPlaying, setNowPlaying] = useState<SpotifyNowPlayingResponse | null>(
null, null,
) )
@@ -90,12 +91,16 @@ function StreamPage() {
const [streamLive, setStreamLive] = useState(false) const [streamLive, setStreamLive] = useState(false)
const [showReadyPulse, setShowReadyPulse] = useState(false) const [showReadyPulse, setShowReadyPulse] = useState(false)
const readyPulseTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null) const readyPulseTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const hasConnectedOnce = useRef(false)
// Free preview tracking // Free preview tracking
const [watchTime, setWatchTime] = useState(0) const [watchTime, setWatchTime] = useState(0)
const [previewExpired, setPreviewExpired] = useState(false) const [previewExpired, setPreviewExpired] = useState(false)
const watchStartRef = useRef<number | null>(null) const watchStartRef = useRef<number | null>(null)
// Mobile chat overlay
const [showMobileChat, setShowMobileChat] = useState(false)
const isAuthenticated = !sessionLoading && !!session?.user const isAuthenticated = !sessionLoading && !!session?.user
// Track watch time for unauthenticated users // Track watch time for unauthenticated users
@@ -228,7 +233,7 @@ function StreamPage() {
readyPulseTimeoutRef.current = null readyPulseTimeoutRef.current = null
} }
if (!streamReady) { if (!playerReady) {
setShowReadyPulse(false) setShowReadyPulse(false)
return return
} }
@@ -245,17 +250,13 @@ function StreamPage() {
readyPulseTimeoutRef.current = null readyPulseTimeoutRef.current = null
} }
} }
}, [streamReady]) }, [playerReady])
const stream = data?.stream ?? null const stream = data?.stream ?? null
const playback = stream?.playback ?? null // For nikiv, always use HLS directly (no WebRTC)
const fallbackPlayback = stream?.hls_url const activePlayback = username === "nikiv"
? { type: "hls", url: stream.hls_url } ? { type: "hls" as const, url: HLS_URL }
: null : stream?.playback ?? null
const activePlayback =
playback?.type === "webrtc" && webRtcFailed
? fallbackPlayback ?? playback
: playback
const isHlsPlaylistLive = (manifest: string) => { const isHlsPlaylistLive = (manifest: string) => {
const upper = manifest.toUpperCase() const upper = manifest.toUpperCase()
@@ -275,32 +276,31 @@ function StreamPage() {
let isActive = true let isActive = true
const checkHlsViaApi = async () => { const checkHlsViaApi = async () => {
console.log("[HLS Check] Calling /api/check-hls...")
try { try {
const res = await fetch("/api/check-hls", { cache: "no-store" }) const res = await fetch("/api/check-hls", { cache: "no-store" })
if (!isActive) return if (!isActive) return
const data = await res.json() const data = await res.json()
console.log("[HLS Check] API response:", data)
if (data.isLive) { if (data.isLive) {
setStreamReady(true) // Stream is live - set connecting state if first time
if (!hasConnectedOnce.current) {
setIsConnecting(true)
}
setHlsLive(true) setHlsLive(true)
} else { } else {
setStreamReady(false) // Only set offline if we haven't connected yet
setHlsLive(false) // This prevents flickering when HLS check temporarily fails
} if (!hasConnectedOnce.current) {
} catch (err) { setHlsLive(false)
console.error("[HLS Check] API error:", err) }
if (isActive) {
setStreamReady(false)
setHlsLive(false)
} }
} catch {
// Silently ignore errors - don't change state on network issues
} }
} }
// Initial check // Initial check
setStreamReady(false)
setHlsLive(null) setHlsLive(null)
checkHlsViaApi() checkHlsViaApi()
@@ -315,15 +315,13 @@ function StreamPage() {
// For non-nikiv users, use direct HLS check // For non-nikiv users, use direct HLS check
useEffect(() => { useEffect(() => {
let isActive = true
if (username === "nikiv" || !activePlayback || activePlayback.type !== "hls") { if (username === "nikiv" || !activePlayback || activePlayback.type !== "hls") {
return () => { return
isActive = false
}
} }
let isActive = true
const checkHlsLive = async () => { const checkHlsLive = async () => {
console.log("[HLS Check] Fetching manifest:", activePlayback.url)
try { try {
const res = await fetch(activePlayback.url, { const res = await fetch(activePlayback.url, {
cache: "no-store", cache: "no-store",
@@ -332,11 +330,10 @@ function StreamPage() {
if (!isActive) return if (!isActive) return
console.log("[HLS Check] Response status:", res.status, res.ok)
if (!res.ok) { if (!res.ok) {
setStreamReady(false) if (!hasConnectedOnce.current) {
setHlsLive(false) setHlsLive(false)
}
return return
} }
@@ -344,28 +341,22 @@ function StreamPage() {
if (!isActive) return if (!isActive) return
const live = isHlsPlaylistLive(manifest) const live = isHlsPlaylistLive(manifest)
console.log("[HLS Check] Manifest live check:", { if (live) {
live, if (!hasConnectedOnce.current) {
manifestLength: manifest.length, setIsConnecting(true)
first200: manifest.slice(0, 200) }
}) setHlsLive(true)
setStreamReady(live) } else if (!hasConnectedOnce.current) {
setHlsLive(live)
} catch (err) {
console.error("[HLS Check] Fetch error:", err)
if (isActive) {
setStreamReady(false)
setHlsLive(false) setHlsLive(false)
} }
} catch {
// Silently ignore fetch errors
} }
} }
// Initial check
setStreamReady(false)
setHlsLive(null) setHlsLive(null)
checkHlsLive() checkHlsLive()
// Poll every 5 seconds to detect when stream goes live
const interval = setInterval(checkHlsLive, 5000) const interval = setInterval(checkHlsLive, 5000)
return () => { return () => {
@@ -409,30 +400,14 @@ function StreamPage() {
} }
}, [activePlayback?.type, stream?.hls_url]) }, [activePlayback?.type, stream?.hls_url])
// For nikiv, primarily use HLS live check from Cloudflare // For nikiv, use HLS live check from our API
// Fall back to streamLive status if HLS check hasn't completed
// For other users, use stream?.is_live from the database // For other users, use stream?.is_live from the database
const isLiveStatus = username === "nikiv" const isActuallyLive = username === "nikiv"
? (hlsLive === true || (hlsLive === null && streamLive)) ? hlsLive === true
: Boolean(stream?.is_live) : Boolean(stream?.is_live)
const isActuallyLive = isLiveStatus
const shouldFetchSpotify = username === "nikiv" && !isActuallyLive
// Debug logging for stream status // Only show Spotify when we know stream is offline (not during initial check)
useEffect(() => { const shouldFetchSpotify = username === "nikiv" && !isActuallyLive && hlsLive === false
if (username === "nikiv") {
console.log("[Stream Debug]", {
streamLive,
hlsLive,
isLiveStatus,
isActuallyLive,
streamReady,
activePlaybackType: activePlayback?.type,
webRtcFailed,
hlsUrl: stream?.hls_url,
})
}
}, [username, streamLive, hlsLive, isLiveStatus, isActuallyLive, streamReady, activePlayback?.type, webRtcFailed, stream?.hls_url])
useEffect(() => { useEffect(() => {
if (!shouldFetchSpotify) { if (!shouldFetchSpotify) {
@@ -453,9 +428,9 @@ function StreamPage() {
if (!isActive) return if (!isActive) return
setNowPlaying(response) setNowPlaying(response)
setNowPlayingError(false) setNowPlayingError(false)
} catch (err) { } catch {
if (!isActive) return if (!isActive) return
console.error("Failed to load Spotify now playing", err) // Silently handle Spotify errors - it's not critical
setNowPlayingError(true) setNowPlayingError(true)
} finally { } finally {
if (isActive && showLoading) { if (isActive && showLoading) {
@@ -540,10 +515,6 @@ function StreamPage() {
) )
} }
const showPlayer =
activePlayback?.type === "cloudflare" ||
activePlayback?.type === "webrtc" ||
(activePlayback?.type === "hls" && streamReady)
const nowPlayingTrack = nowPlaying?.track ?? null const nowPlayingTrack = nowPlaying?.track ?? null
const nowPlayingArtists = nowPlayingTrack?.artists.length const nowPlayingArtists = nowPlayingTrack?.artists.length
? nowPlayingTrack.artists.join(", ") ? nowPlayingTrack.artists.join(", ")
@@ -554,19 +525,29 @@ function StreamPage() {
: nowPlayingTrack.title : nowPlayingTrack.title
: null : null
// Callback when player is ready
const handlePlayerReady = () => {
hasConnectedOnce.current = true
setIsConnecting(false)
setPlayerReady(true)
}
// Show loading state during initial check
const isChecking = hlsLive === null
return ( return (
<JazzProvider> <JazzProvider>
<div className="h-screen w-screen bg-black flex"> <div className="h-screen w-screen bg-black flex flex-col md:flex-row">
{/* Main content area */} {/* Main content area */}
<div className="flex-1 relative"> <div className="flex-1 relative min-h-0">
{/* Free preview countdown banner for unauthenticated users */} {/* Free preview countdown banner - hidden on mobile */}
{!isAuthenticated && !previewExpired && ( {!isAuthenticated && !previewExpired && isActuallyLive && (
<div className="absolute top-0 left-0 right-0 z-20 bg-gradient-to-r from-purple-600/90 to-pink-600/90 backdrop-blur-sm px-4 py-2 flex items-center justify-center gap-4"> <div className="hidden md:flex absolute top-0 left-0 right-0 z-20 bg-gradient-to-r from-purple-600/90 to-pink-600/90 backdrop-blur-sm px-4 py-2 items-center justify-center gap-4">
<span className="text-white text-sm"> <span className="text-white text-sm">
Free preview: <span className="font-mono font-bold">{remainingFormatted}</span> remaining Free preview: <span className="font-mono font-bold">{remainingFormatted}</span> remaining
</span> </span>
<Link <Link
to="/login" to="/auth"
className="text-xs font-medium bg-white/20 hover:bg-white/30 text-white px-3 py-1 rounded-full transition-colors" className="text-xs font-medium bg-white/20 hover:bg-white/30 text-white px-3 py-1 rounded-full transition-colors"
> >
Sign in for unlimited access Sign in for unlimited access
@@ -574,47 +555,39 @@ function StreamPage() {
</div> </div>
)} )}
{/* Viewer count overlay */} {/* Viewer count overlay - hidden on mobile */}
<div className="absolute top-4 right-4 z-10 rounded-lg bg-black/50 px-3 py-2 backdrop-blur-sm" style={{ top: !isAuthenticated && !previewExpired ? '3rem' : '1rem' }}> {isActuallyLive && (
<ViewerCount username={username} /> <div className="hidden md:block absolute top-4 right-4 z-10 rounded-lg bg-black/50 px-3 py-2 backdrop-blur-sm" style={{ top: !isAuthenticated && !previewExpired ? '3rem' : '1rem' }}>
</div> <ViewerCount username={username} />
</div>
)}
{isActuallyLive && activePlayback && showPlayer ? ( {/* Loading state - checking if stream is live */}
activePlayback.type === "webrtc" ? ( {isChecking ? (
<div className="flex h-full w-full flex-col items-center justify-center text-white">
<div className="relative">
<div className="w-16 h-16 border-4 border-white/20 border-t-white rounded-full animate-spin" />
</div>
<p className="mt-6 text-lg text-neutral-400">Checking stream status...</p>
</div>
) : isActuallyLive && activePlayback ? (
/* Stream is live - show the player */
<div className="relative h-full w-full"> <div className="relative h-full w-full">
<WebRTCPlayer <VideoPlayer
src={activePlayback.url} src={activePlayback.url}
muted={false} muted={false}
onReady={() => setStreamReady(true)} onReady={handlePlayerReady}
onError={() => {
setWebRtcFailed(true)
setStreamReady(!fallbackPlayback)
}}
/> />
{!streamReady && ( {/* Loading overlay while connecting */}
<div className="pointer-events-none absolute inset-0 z-20 flex items-center justify-center bg-black/70"> {(isConnecting || !playerReady) && (
<div className="animate-pulse text-4xl">🟡</div> <div className="pointer-events-none absolute inset-0 z-20 flex flex-col items-center justify-center bg-black/80">
</div> <div className="relative">
)} <div className="w-16 h-16 border-4 border-white/20 border-t-red-500 rounded-full animate-spin" />
{showReadyPulse && ( </div>
<div className="pointer-events-none absolute inset-0 z-20 flex items-center justify-center"> <p className="mt-6 text-lg text-white">Connecting to stream...</p>
<div className="animate-pulse text-4xl">🔴</div>
</div>
)}
</div>
) : activePlayback.type === "cloudflare" ? (
<div className="relative h-full w-full">
<CloudflareStreamPlayer
uid={activePlayback.uid}
customerCode={activePlayback.customerCode}
muted={false}
onReady={() => setStreamReady(true)}
/>
{!streamReady && (
<div className="pointer-events-none absolute inset-0 z-20 flex items-center justify-center bg-black/70">
<div className="animate-pulse text-4xl">🟡</div>
</div> </div>
)} )}
{/* Ready pulse */}
{showReadyPulse && ( {showReadyPulse && (
<div className="pointer-events-none absolute inset-0 z-20 flex items-center justify-center"> <div className="pointer-events-none absolute inset-0 z-20 flex items-center justify-center">
<div className="animate-pulse text-4xl">🔴</div> <div className="animate-pulse text-4xl">🔴</div>
@@ -622,86 +595,113 @@ function StreamPage() {
)} )}
</div> </div>
) : ( ) : (
<div className="relative h-full w-full"> /* Stream is offline */
<VideoPlayer src={activePlayback.url} muted={false} /> <div className="flex h-full w-full items-center justify-center text-white pb-16 md:pb-0">
{showReadyPulse && ( {shouldFetchSpotify ? (
<div className="pointer-events-none absolute inset-0 z-20 flex items-center justify-center"> <div className="mx-auto flex w-full max-w-2xl flex-col items-center px-6 text-center">
<div className="animate-pulse text-4xl">🔴</div> <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-2xl md:text-3xl font-semibold">
Not live right now
</p>
<div className="mt-6 text-base md:text-lg text-neutral-300">
{nowPlayingLoading ? (
<span>Checking Spotify...</span>
) : nowPlaying?.isPlaying && nowPlayingTrack ? (
<span>
Currently playing{" "}
{nowPlayingTrack.url ? (
<a
href={nowPlayingTrack.url}
target="_blank"
rel="noopener noreferrer"
className="text-white hover:text-neutral-300 transition-colors"
>
{nowPlayingText ?? "Spotify"}
</a>
) : (
<span className="text-white">
{nowPlayingText ?? "Spotify"}
</span>
)}
</span>
) : null}
</div>
<a
href="https://nikiv.dev"
target="_blank"
rel="noopener noreferrer"
className="mt-8 text-2xl md:text-3xl font-medium text-white hover:text-neutral-300 transition-colors"
>
nikiv.dev
</a>
</div>
) : (
<div className="text-center">
<p className="text-xl md: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-2xl md:text-4xl font-medium text-white hover:text-neutral-300 transition-colors"
>
{username === "nikiv" ? "nikiv.dev" : `@${username}`}
</a>
</div> </div>
)} )}
</div> </div>
) )}
) : isActuallyLive && activePlayback ? (
<div className="flex h-full w-full items-center justify-center text-white">
<div className="animate-pulse text-4xl">🟡</div>
</div>
) : (
<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-6 text-lg text-neutral-300">
{nowPlayingLoading ? (
<span>Checking Spotify...</span>
) : nowPlaying?.isPlaying && nowPlayingTrack ? (
<span>
Currently playing{" "}
{nowPlayingTrack.url ? (
<a
href={nowPlayingTrack.url}
target="_blank"
rel="noopener noreferrer"
className="text-white hover:text-neutral-300 transition-colors"
>
{nowPlayingText ?? "Spotify"}
</a>
) : (
<span className="text-white">
{nowPlayingText ?? "Spotify"}
</span>
)}
</span>
) : null}
</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> </div>
{/* Chat sidebar */} {/* Desktop Chat sidebar */}
<div className="w-80 h-full border-l border-white/10 flex-shrink-0"> <div className="hidden md:block w-80 h-full border-l border-white/10 flex-shrink-0">
<CommentBox username={username} /> <CommentBox username={username} />
</div> </div>
{/* Mobile bottom bar */}
<div className="md:hidden fixed bottom-0 left-0 right-0 z-30 bg-black/90 backdrop-blur-sm border-t border-white/10 px-4 py-3 flex items-center justify-center gap-6">
{!isAuthenticated && (
<Link
to="/auth"
className="flex items-center gap-2 px-4 py-2 rounded-full bg-white/10 text-white text-sm font-medium"
>
<LogIn className="w-4 h-4" />
Sign In
</Link>
)}
<button
type="button"
onClick={() => setShowMobileChat(true)}
className="flex items-center gap-2 px-4 py-2 rounded-full bg-white/10 text-white text-sm font-medium"
>
<MessageCircle className="w-4 h-4" />
Chat
</button>
</div>
{/* Mobile chat overlay */}
{showMobileChat && (
<div className="md:hidden fixed inset-0 z-40 bg-black flex flex-col">
<div className="flex items-center justify-between px-4 py-3 border-b border-white/10">
<span className="text-white font-medium">Chat</span>
<button
type="button"
onClick={() => setShowMobileChat(false)}
className="p-2 text-white/70 hover:text-white"
>
<X className="w-5 h-5" />
</button>
</div>
<div className="flex-1 min-h-0">
<CommentBox username={username} />
</div>
</div>
)}
</div> </div>
</JazzProvider> </JazzProvider>
) )

View File

@@ -7,7 +7,7 @@ const json = (data: unknown, status = 200) =>
}) })
// Cloudflare Stream HLS URL // Cloudflare Stream HLS URL
const HLS_URL = "https://customer-xctsztqzu046isdc.cloudflarestream.com/bb7858eafc85de6c92963f3817477b5d/manifest/video.m3u8" const HLS_URL = "https://customer-xctsztqzu046isdc.cloudflarestream.com/1b0363e3f8d54ddc639dc85737f8c28a/manifest/video.m3u8"
function isHlsPlaylistLive(manifest: string): boolean { function isHlsPlaylistLive(manifest: string): boolean {
const upper = manifest.toUpperCase() const upper = manifest.toUpperCase()