From f188310411b56bc65fd6358fe0ef40b0fc70c358 Mon Sep 17 00:00:00 2001 From: Nikita Date: Sun, 21 Dec 2025 15:12:32 -0800 Subject: [PATCH] Add real-time viewer count component and integrate it into stream page; update presence tracking logic with Jazz. --- .ai/commit-checkpoints.json | 6 +- packages/web/src/components/ViewerCount.tsx | 34 ++++ packages/web/src/lib/jazz/provider.tsx | 28 ++++ packages/web/src/lib/jazz/schema.ts | 63 ++++++++ packages/web/src/lib/jazz/useStreamViewers.ts | 147 ++++++++++++++++++ packages/web/src/routes/$username.tsx | 101 ++++++------ packages/web/src/routes/index.tsx | 45 +++++- 7 files changed, 372 insertions(+), 52 deletions(-) create mode 100644 packages/web/src/components/ViewerCount.tsx create mode 100644 packages/web/src/lib/jazz/provider.tsx create mode 100644 packages/web/src/lib/jazz/schema.ts create mode 100644 packages/web/src/lib/jazz/useStreamViewers.ts diff --git a/.ai/commit-checkpoints.json b/.ai/commit-checkpoints.json index dde312f3..bae9385e 100644 --- a/.ai/commit-checkpoints.json +++ b/.ai/commit-checkpoints.json @@ -1,7 +1,7 @@ { "last_commit": { - "timestamp": "2025-12-21T22:56:32.894190+00:00", - "session_id": "019b431c-d0bc-7511-a9ac-47633a3ec88c", - "last_entry_timestamp": "2025-12-21T22:56:29.923Z" + "timestamp": "2025-12-21T23:04:42.523116+00:00", + "session_id": "019b4325-bfcd-7a43-bed2-90e50260dd9f", + "last_entry_timestamp": "2025-12-21T23:04:39.775Z" } } \ No newline at end of file diff --git a/packages/web/src/components/ViewerCount.tsx b/packages/web/src/components/ViewerCount.tsx new file mode 100644 index 00000000..64ef3410 --- /dev/null +++ b/packages/web/src/components/ViewerCount.tsx @@ -0,0 +1,34 @@ +import { useStreamViewers } from "@/lib/jazz/useStreamViewers" + +interface ViewerCountProps { + username: string +} + +/** + * Displays the real-time viewer count for a stream + */ +export function ViewerCount({ username }: ViewerCountProps) { + const { viewerCount, isConnected, isLoading } = useStreamViewers(username) + + if (isLoading) { + return ( +
+
+ ... +
+ ) + } + + return ( +
+
+ + {viewerCount} {viewerCount === 1 ? "viewer" : "viewers"} + +
+ ) +} diff --git a/packages/web/src/lib/jazz/provider.tsx b/packages/web/src/lib/jazz/provider.tsx new file mode 100644 index 00000000..646afd35 --- /dev/null +++ b/packages/web/src/lib/jazz/provider.tsx @@ -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 ( + + {children} + + ) +} diff --git a/packages/web/src/lib/jazz/schema.ts b/packages/web/src/lib/jazz/schema.ts new file mode 100644 index 00000000..1bbc8d08 --- /dev/null +++ b/packages/web/src/lib/jazz/schema.ts @@ -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 + +/** + * 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, + }) + } + }) diff --git a/packages/web/src/lib/jazz/useStreamViewers.ts b/packages/web/src/lib/jazz/useStreamViewers.ts new file mode 100644 index 00000000..791e8907 --- /dev/null +++ b/packages/web/src/lib/jazz/useStreamViewers.ts @@ -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(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() + + // 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)[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, + } +} diff --git a/packages/web/src/routes/$username.tsx b/packages/web/src/routes/$username.tsx index 0d53a2ae..8ba25999 100644 --- a/packages/web/src/routes/$username.tsx +++ b/packages/web/src/routes/$username.tsx @@ -4,6 +4,8 @@ import { getStreamByUsername, type StreamPageData } from "@/lib/stream/db" import { VideoPlayer } from "@/components/VideoPlayer" import { CloudflareStreamPlayer } from "@/components/CloudflareStreamPlayer" import { resolveStreamPlayback } from "@/lib/stream/playback" +import { JazzProvider } from "@/lib/jazz/provider" +import { ViewerCount } from "@/components/ViewerCount" export const Route = createFileRoute("/$username")({ ssr: false, @@ -26,7 +28,7 @@ const NIKIV_DATA: StreamPageData = { id: "nikiv-stream", title: "Live Coding", description: "Building in public", - is_live: true, + is_live: false, // Set to true when actually streaming viewer_count: 0, hls_url: HLS_URL, playback: NIKIV_PLAYBACK, @@ -149,54 +151,61 @@ function StreamPage() { playback?.type === "cloudflare" || (playback?.type === "hls" && streamReady) return ( -
- {stream?.is_live && playback && showPlayer ? ( - playback.type === "cloudflare" ? ( -
- setStreamReady(true)} - /> - {!streamReady && ( -
-
-
🔴
-

- Connecting to stream... -

+ +
+ {/* Viewer count overlay */} +
+ +
+ + {stream?.is_live && playback && showPlayer ? ( + playback.type === "cloudflare" ? ( +
+ setStreamReady(true)} + /> + {!streamReady && ( +
+
+
🔴
+

+ Connecting to stream... +

+
-
- )} + )} +
+ ) : ( + + ) + ) : stream?.is_live && playback ? ( +
+
+
🔴
+

+ Connecting to stream... +

+
) : ( - - ) - ) : stream?.is_live && playback ? ( -
-
-
🔴
-

- Connecting to stream... -

+
+
+

Streaming soon

+ + nikiv.dev + +
-
- ) : ( -
-
-

Streaming soon

- - nikiv.dev - -
-
- )} -
+ )} +
+ ) } diff --git a/packages/web/src/routes/index.tsx b/packages/web/src/routes/index.tsx index da4dd828..08c878ec 100644 --- a/packages/web/src/routes/index.tsx +++ b/packages/web/src/routes/index.tsx @@ -1,18 +1,30 @@ import { createFileRoute } from "@tanstack/react-router" import { ShaderBackground } from "@/components/ShaderBackground" +const galleryItems = [ + { id: 1, image: "https://picsum.photos/seed/linsa1/400/600", title: "Nature" }, + { id: 2, image: "https://picsum.photos/seed/linsa2/400/600", title: "Urban" }, + { id: 3, image: "https://picsum.photos/seed/linsa3/400/600", title: "Abstract" }, + { id: 4, image: "https://picsum.photos/seed/linsa4/400/600", title: "Portrait" }, + { id: 5, image: "https://picsum.photos/seed/linsa5/400/600", title: "Landscape" }, + { id: 6, image: "https://picsum.photos/seed/linsa6/400/600", title: "Art" }, + { id: 7, image: "https://picsum.photos/seed/linsa7/400/600", title: "Design" }, + { id: 8, image: "https://picsum.photos/seed/linsa8/400/600", title: "Photo" }, +] + function LandingPage() { return ( -
+