mirror of
https://github.com/linsa-io/linsa.git
synced 2026-01-12 12:20:23 +01:00
Add real-time viewer count component and integrate it into stream page; update presence tracking logic with Jazz.
This commit is contained in:
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user