Add real-time viewer count component and integrate it into stream page; update presence tracking logic with Jazz.

This commit is contained in:
Nikita
2025-12-21 15:12:32 -08:00
parent c16440c876
commit f188310411
7 changed files with 372 additions and 52 deletions
+28
View File
@@ -0,0 +1,28 @@
import { JazzReactProvider } from "jazz-tools/react"
import { ViewerAccount } from "./schema"
// Jazz Cloud API key - using public demo key for now
// TODO: Replace with linsa-specific key from https://jazz.tools
const JAZZ_API_KEY = "jazz_cloud_demo"
interface JazzProviderProps {
children: React.ReactNode
}
/**
* Jazz provider for stream viewer presence tracking
* Uses anonymous auth - viewers don't need to sign in
*/
export function JazzProvider({ children }: JazzProviderProps) {
return (
<JazzReactProvider
sync={{
peer: `wss://cloud.jazz.tools/?key=${JAZZ_API_KEY}`,
when: "always",
}}
AccountSchema={ViewerAccount}
>
{children}
</JazzReactProvider>
)
}
+63
View File
@@ -0,0 +1,63 @@
import { co, z } from "jazz-tools"
/**
* Presence entry - pushed to a feed when a viewer joins/updates
*/
export const Presence = z.object({
/** Last activity timestamp */
lastActive: z.number(),
})
export type Presence = z.infer<typeof Presence>
/**
* A feed of presence entries - each session pushes their own presence
* Jazz automatically tracks sessions, so we can count unique viewers
*/
export const PresenceFeed = co.feed(Presence)
/**
* Container for a stream's presence feed - enables upsertUnique
*/
export const StreamPresenceContainer = co.map({
presenceFeed: PresenceFeed,
})
/**
* Account profile - minimal, just for Jazz to work
*/
export const ViewerProfile = co
.profile({
name: z.string(),
})
.withPermissions({
onCreate: (newGroup) => newGroup.makePublic(),
})
/**
* Viewer account root - stores any viewer-specific data
*/
export const ViewerRoot = co.map({
/** Placeholder field */
version: z.number(),
})
/**
* Viewer account - anonymous viewers watching streams
*/
export const ViewerAccount = co
.account({
profile: ViewerProfile,
root: ViewerRoot,
})
.withMigration((account) => {
if (!account.$jazz.has("profile")) {
account.$jazz.set("profile", {
name: "Anonymous",
})
}
if (!account.$jazz.has("root")) {
account.$jazz.set("root", {
version: 1,
})
}
})
@@ -0,0 +1,147 @@
import { useEffect, useState } from "react"
import { useAccount, useCoState } from "jazz-tools/react"
import { Group } from "jazz-tools"
import {
PresenceFeed,
StreamPresenceContainer,
ViewerAccount,
} from "./schema"
/** How old a presence entry can be before considered stale (10 seconds) */
const PRESENCE_STALE_MS = 10_000
/** How often to update presence (5 seconds) */
const PRESENCE_UPDATE_MS = 5_000
interface UseStreamViewersResult {
/** Number of active viewers */
viewerCount: number
/** Whether Jazz is connected */
isConnected: boolean
/** Whether the room is loaded */
isLoading: boolean
}
/**
* Hook to track and count stream viewers using Jazz presence
*
* @param username - The streamer's username (used as room identifier)
* @returns Viewer count and connection status
*/
export function useStreamViewers(username: string): UseStreamViewersResult {
const [feedId, setFeedId] = useState<string | null>(null)
const [isLoading, setIsLoading] = useState(true)
const [viewerCount, setViewerCount] = useState(0)
// Get current account
const me = useAccount(ViewerAccount)
// Load the presence feed directly
const presenceFeed = useCoState(PresenceFeed, feedId ?? undefined, {
resolve: true,
})
// Create or load the stream presence container
useEffect(() => {
if (!me.$isLoaded) return
const loadPresenceFeed = async () => {
setIsLoading(true)
try {
// Create a unique identifier for this stream's presence
const containerUID = { stream: username, origin: "linsa.io" }
// Create a group for this container that anyone can write to
const group = Group.create({ owner: me })
group.addMember("everyone", "writer")
// Try to upsert the container (create if doesn't exist)
const container = await StreamPresenceContainer.upsertUnique({
value: {
presenceFeed: [],
},
resolve: { presenceFeed: true },
unique: containerUID,
owner: group,
})
if (container.$isLoaded && container.$jazz.refs.presenceFeed) {
setFeedId(container.$jazz.refs.presenceFeed.id)
}
} catch (err) {
console.error("Failed to load presence feed:", err)
} finally {
setIsLoading(false)
}
}
loadPresenceFeed()
}, [me.$isLoaded, me.$jazz?.id, username])
// Update our presence periodically
useEffect(() => {
if (!presenceFeed?.$isLoaded) return
const updatePresence = () => {
try {
presenceFeed.$jazz.push({
lastActive: Date.now(),
})
} catch (err) {
console.error("Failed to update presence:", err)
}
}
// Update immediately
updatePresence()
// Then update periodically
const interval = setInterval(updatePresence, PRESENCE_UPDATE_MS)
return () => clearInterval(interval)
}, [presenceFeed?.$isLoaded])
// Count active viewers
useEffect(() => {
if (!presenceFeed?.$isLoaded) {
setViewerCount(0)
return
}
const countViewers = () => {
const now = Date.now()
const activeViewers = new Set<string>()
// Get all sessions and count those with recent activity
const perSession = presenceFeed.perSession
if (perSession) {
for (const sessionId of Object.keys(perSession)) {
const entry = (perSession as Record<string, { value?: { lastActive: number } }>)[sessionId]
if (entry?.value) {
const age = now - entry.value.lastActive
if (age < PRESENCE_STALE_MS) {
activeViewers.add(sessionId)
}
}
}
}
setViewerCount(activeViewers.size)
}
// Count immediately
countViewers()
// Recount periodically
const interval = setInterval(countViewers, 2000)
return () => clearInterval(interval)
}, [presenceFeed?.$isLoaded, presenceFeed])
return {
viewerCount,
isConnected: me.$isLoaded,
isLoading,
}
}