mirror of
https://github.com/linsa-io/linsa.git
synced 2026-01-11 20:00:23 +01:00
Improve VideoPlayer to notify when ready and update StreamPage to handle player readiness and connection state
This commit is contained in:
@@ -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 () => {
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
Reference in New Issue
Block a user