Add WebRTC playback support: implement WebRTCPlayer component, update schema, database, playback types, and API endpoints to handle webrtc_url field and streaming logic

This commit is contained in:
Nikita
2025-12-21 18:34:06 -08:00
parent b9927d9807
commit 01102c6817
8 changed files with 179 additions and 7 deletions

View File

@@ -1,7 +1,7 @@
{
"last_commit": {
"timestamp": "2025-12-21T23:20:32.869030+00:00",
"session_id": "019b4336-8118-7961-95cb-f63fd5f8c638",
"last_entry_timestamp": "2025-12-21T23:20:28.851Z"
"timestamp": "2025-12-22T02:15:31.164777+00:00",
"session_id": "019b43d6-7d65-7750-9f2a-d85aa2642074",
"last_entry_timestamp": "2025-12-22T02:15:26.603Z"
}
}

View File

@@ -0,0 +1,150 @@
import { useEffect, useRef, useState } from "react"
type WebRTCPlayerProps = {
src: string
autoPlay?: boolean
muted?: boolean
onReady?: () => void
}
export function WebRTCPlayer({
src,
autoPlay = true,
muted = false,
onReady,
}: WebRTCPlayerProps) {
const videoRef = useRef<HTMLVideoElement>(null)
const [error, setError] = useState<string | null>(null)
const readyRef = useRef(false)
useEffect(() => {
const video = videoRef.current
if (!video || !src) return
let pc: RTCPeerConnection | null = new RTCPeerConnection()
const abortController = new AbortController()
readyRef.current = false
const markReady = () => {
if (readyRef.current) return
readyRef.current = true
onReady?.()
}
const cleanup = () => {
abortController.abort()
if (pc) {
pc.ontrack = null
pc.onconnectionstatechange = null
pc.oniceconnectionstatechange = null
pc.close()
}
pc = null
if (video.srcObject) {
const tracks = (video.srcObject as MediaStream).getTracks()
tracks.forEach((track) => track.stop())
video.srcObject = null
}
}
const waitForIceGathering = () =>
new Promise<void>((resolve) => {
if (!pc || pc.iceGatheringState === "complete") {
resolve()
return
}
const onStateChange = () => {
if (!pc) return
if (pc.iceGatheringState === "complete") {
pc.removeEventListener("icegatheringstatechange", onStateChange)
resolve()
}
}
pc.addEventListener("icegatheringstatechange", onStateChange)
})
const start = async () => {
try {
if (!pc) return
setError(null)
pc.addTransceiver("video", { direction: "recvonly" })
pc.addTransceiver("audio", { direction: "recvonly" })
pc.ontrack = (event) => {
const [stream] = event.streams
if (stream && video.srcObject !== stream) {
video.srcObject = stream
video.muted = muted
if (autoPlay) {
video.play().catch(() => {})
}
markReady()
}
}
pc.onconnectionstatechange = () => {
if (pc?.connectionState === "connected") {
markReady()
}
}
const offer = await pc.createOffer()
await pc.setLocalDescription(offer)
await waitForIceGathering()
const localSdp = pc.localDescription?.sdp
if (!localSdp) {
throw new Error("Missing local SDP")
}
const response = await fetch(src, {
method: "POST",
headers: {
"content-type": "application/sdp",
},
body: localSdp,
signal: abortController.signal,
})
if (!response.ok) {
throw new Error(`WebRTC request failed (${response.status})`)
}
const answerSdp = await response.text()
if (!answerSdp) {
throw new Error("Empty WebRTC answer")
}
await pc.setRemoteDescription({ type: "answer", sdp: answerSdp })
} catch (err) {
if (!abortController.signal.aborted) {
const message = err instanceof Error ? err.message : "WebRTC failed"
setError(message)
}
}
}
start()
return cleanup
}, [autoPlay, muted, onReady, src])
if (error) {
return (
<div className="flex h-full w-full items-center justify-center bg-black text-neutral-400">
<p>{error}</p>
</div>
)
}
return (
<video
ref={videoRef}
className="h-full w-full object-contain"
autoPlay={autoPlay}
muted={muted}
playsInline
/>
)
}

View File

@@ -249,6 +249,7 @@ export const streams = pgTable("streams", {
stream_key: text("stream_key").notNull().unique(), // secret key for streaming
// Stream endpoints (set by Linux server)
hls_url: text("hls_url"), // HLS playback URL
webrtc_url: text("webrtc_url"), // WebRTC playback URL
thumbnail_url: text("thumbnail_url"),
started_at: timestamp("started_at", { withTimezone: true }),
ended_at: timestamp("ended_at", { withTimezone: true }),

View File

@@ -14,6 +14,7 @@ export type StreamPageData = {
is_live: boolean
viewer_count: number
hls_url: string | null
webrtc_url: string | null
playback: StreamPlayback | null
thumbnail_url: string | null
started_at: string | null

View File

@@ -5,19 +5,26 @@ export type CloudflareStreamRef = {
export type StreamPlayback =
| { type: "cloudflare"; uid: string; customerCode?: string }
| { type: "webrtc"; url: string }
| { type: "hls"; url: string }
type PlaybackInput = {
hlsUrl?: string | null
webrtcUrl?: string | null
cloudflareUid?: string | null
cloudflareCustomerCode?: string | null
}
export function resolveStreamPlayback({
hlsUrl,
webrtcUrl,
cloudflareUid,
cloudflareCustomerCode,
}: PlaybackInput): StreamPlayback | null {
if (webrtcUrl) {
return { type: "webrtc", url: webrtcUrl }
}
if (cloudflareUid) {
return {
type: "cloudflare",

View File

@@ -50,7 +50,10 @@ const getProfile = async ({ request }: { request: Request }) => {
})
const playback = stream
? resolveStreamPlayback({ hlsUrl: stream.hls_url })
? resolveStreamPlayback({
hlsUrl: stream.hls_url,
webrtcUrl: stream.webrtc_url,
})
: null
return new Response(
@@ -66,6 +69,7 @@ const getProfile = async ({ request }: { request: Request }) => {
title: stream.title,
is_live: stream.is_live,
hls_url: stream.hls_url,
webrtc_url: stream.webrtc_url,
playback,
stream_key: stream.stream_key,
}

View File

@@ -43,7 +43,10 @@ const getStream = async ({ request }: { request: Request }) => {
})
}
const playback = resolveStreamPlayback({ hlsUrl: stream.hls_url })
const playback = resolveStreamPlayback({
hlsUrl: stream.hls_url,
webrtcUrl: stream.webrtc_url,
})
return new Response(JSON.stringify({ ...stream, playback }), {
status: 200,
@@ -72,10 +75,11 @@ const updateStream = async ({ request }: { request: Request }) => {
try {
const body = await request.json()
const { title, description, hls_url, is_live } = body as {
const { title, description, hls_url, webrtc_url, is_live } = body as {
title?: string
description?: string
hls_url?: string
webrtc_url?: string
is_live?: boolean
}
@@ -96,6 +100,7 @@ const updateStream = async ({ request }: { request: Request }) => {
if (title !== undefined) updates.title = title
if (description !== undefined) updates.description = description
if (hls_url !== undefined) updates.hls_url = hls_url
if (webrtc_url !== undefined) updates.webrtc_url = webrtc_url
if (is_live !== undefined) {
updates.is_live = is_live
if (is_live && !stream.started_at) {

View File

@@ -60,7 +60,10 @@ const serve = async ({
})
const playback = stream
? resolveStreamPlayback({ hlsUrl: stream.hls_url })
? resolveStreamPlayback({
hlsUrl: stream.hls_url,
webrtcUrl: stream.webrtc_url,
})
: null
const data = {
@@ -78,6 +81,7 @@ const serve = async ({
is_live: stream.is_live,
viewer_count: stream.viewer_count,
hls_url: stream.hls_url,
webrtc_url: stream.webrtc_url,
playback,
thumbnail_url: stream.thumbnail_url,
started_at: stream.started_at?.toISOString() ?? null,