mirror of
https://github.com/linsa-io/linsa.git
synced 2026-01-12 12:20:23 +01:00
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:
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"last_commit": {
|
"last_commit": {
|
||||||
"timestamp": "2025-12-21T23:20:32.869030+00:00",
|
"timestamp": "2025-12-22T02:15:31.164777+00:00",
|
||||||
"session_id": "019b4336-8118-7961-95cb-f63fd5f8c638",
|
"session_id": "019b43d6-7d65-7750-9f2a-d85aa2642074",
|
||||||
"last_entry_timestamp": "2025-12-21T23:20:28.851Z"
|
"last_entry_timestamp": "2025-12-22T02:15:26.603Z"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
150
packages/web/src/components/WebRTCPlayer.tsx
Normal file
150
packages/web/src/components/WebRTCPlayer.tsx
Normal 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
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -249,6 +249,7 @@ export const streams = pgTable("streams", {
|
|||||||
stream_key: text("stream_key").notNull().unique(), // secret key for streaming
|
stream_key: text("stream_key").notNull().unique(), // secret key for streaming
|
||||||
// Stream endpoints (set by Linux server)
|
// Stream endpoints (set by Linux server)
|
||||||
hls_url: text("hls_url"), // HLS playback URL
|
hls_url: text("hls_url"), // HLS playback URL
|
||||||
|
webrtc_url: text("webrtc_url"), // WebRTC playback URL
|
||||||
thumbnail_url: text("thumbnail_url"),
|
thumbnail_url: text("thumbnail_url"),
|
||||||
started_at: timestamp("started_at", { withTimezone: true }),
|
started_at: timestamp("started_at", { withTimezone: true }),
|
||||||
ended_at: timestamp("ended_at", { withTimezone: true }),
|
ended_at: timestamp("ended_at", { withTimezone: true }),
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ export type StreamPageData = {
|
|||||||
is_live: boolean
|
is_live: boolean
|
||||||
viewer_count: number
|
viewer_count: number
|
||||||
hls_url: string | null
|
hls_url: string | null
|
||||||
|
webrtc_url: string | null
|
||||||
playback: StreamPlayback | null
|
playback: StreamPlayback | null
|
||||||
thumbnail_url: string | null
|
thumbnail_url: string | null
|
||||||
started_at: string | null
|
started_at: string | null
|
||||||
|
|||||||
@@ -5,19 +5,26 @@ export type CloudflareStreamRef = {
|
|||||||
|
|
||||||
export type StreamPlayback =
|
export type StreamPlayback =
|
||||||
| { type: "cloudflare"; uid: string; customerCode?: string }
|
| { type: "cloudflare"; uid: string; customerCode?: string }
|
||||||
|
| { type: "webrtc"; url: string }
|
||||||
| { type: "hls"; url: string }
|
| { type: "hls"; url: string }
|
||||||
|
|
||||||
type PlaybackInput = {
|
type PlaybackInput = {
|
||||||
hlsUrl?: string | null
|
hlsUrl?: string | null
|
||||||
|
webrtcUrl?: string | null
|
||||||
cloudflareUid?: string | null
|
cloudflareUid?: string | null
|
||||||
cloudflareCustomerCode?: string | null
|
cloudflareCustomerCode?: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export function resolveStreamPlayback({
|
export function resolveStreamPlayback({
|
||||||
hlsUrl,
|
hlsUrl,
|
||||||
|
webrtcUrl,
|
||||||
cloudflareUid,
|
cloudflareUid,
|
||||||
cloudflareCustomerCode,
|
cloudflareCustomerCode,
|
||||||
}: PlaybackInput): StreamPlayback | null {
|
}: PlaybackInput): StreamPlayback | null {
|
||||||
|
if (webrtcUrl) {
|
||||||
|
return { type: "webrtc", url: webrtcUrl }
|
||||||
|
}
|
||||||
|
|
||||||
if (cloudflareUid) {
|
if (cloudflareUid) {
|
||||||
return {
|
return {
|
||||||
type: "cloudflare",
|
type: "cloudflare",
|
||||||
|
|||||||
@@ -50,7 +50,10 @@ const getProfile = async ({ request }: { request: Request }) => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const playback = stream
|
const playback = stream
|
||||||
? resolveStreamPlayback({ hlsUrl: stream.hls_url })
|
? resolveStreamPlayback({
|
||||||
|
hlsUrl: stream.hls_url,
|
||||||
|
webrtcUrl: stream.webrtc_url,
|
||||||
|
})
|
||||||
: null
|
: null
|
||||||
|
|
||||||
return new Response(
|
return new Response(
|
||||||
@@ -66,6 +69,7 @@ const getProfile = async ({ request }: { request: Request }) => {
|
|||||||
title: stream.title,
|
title: stream.title,
|
||||||
is_live: stream.is_live,
|
is_live: stream.is_live,
|
||||||
hls_url: stream.hls_url,
|
hls_url: stream.hls_url,
|
||||||
|
webrtc_url: stream.webrtc_url,
|
||||||
playback,
|
playback,
|
||||||
stream_key: stream.stream_key,
|
stream_key: stream.stream_key,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 }), {
|
return new Response(JSON.stringify({ ...stream, playback }), {
|
||||||
status: 200,
|
status: 200,
|
||||||
@@ -72,10 +75,11 @@ const updateStream = async ({ request }: { request: Request }) => {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const body = await request.json()
|
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
|
title?: string
|
||||||
description?: string
|
description?: string
|
||||||
hls_url?: string
|
hls_url?: string
|
||||||
|
webrtc_url?: string
|
||||||
is_live?: boolean
|
is_live?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -96,6 +100,7 @@ const updateStream = async ({ request }: { request: Request }) => {
|
|||||||
if (title !== undefined) updates.title = title
|
if (title !== undefined) updates.title = title
|
||||||
if (description !== undefined) updates.description = description
|
if (description !== undefined) updates.description = description
|
||||||
if (hls_url !== undefined) updates.hls_url = hls_url
|
if (hls_url !== undefined) updates.hls_url = hls_url
|
||||||
|
if (webrtc_url !== undefined) updates.webrtc_url = webrtc_url
|
||||||
if (is_live !== undefined) {
|
if (is_live !== undefined) {
|
||||||
updates.is_live = is_live
|
updates.is_live = is_live
|
||||||
if (is_live && !stream.started_at) {
|
if (is_live && !stream.started_at) {
|
||||||
|
|||||||
@@ -60,7 +60,10 @@ const serve = async ({
|
|||||||
})
|
})
|
||||||
|
|
||||||
const playback = stream
|
const playback = stream
|
||||||
? resolveStreamPlayback({ hlsUrl: stream.hls_url })
|
? resolveStreamPlayback({
|
||||||
|
hlsUrl: stream.hls_url,
|
||||||
|
webrtcUrl: stream.webrtc_url,
|
||||||
|
})
|
||||||
: null
|
: null
|
||||||
|
|
||||||
const data = {
|
const data = {
|
||||||
@@ -78,6 +81,7 @@ const serve = async ({
|
|||||||
is_live: stream.is_live,
|
is_live: stream.is_live,
|
||||||
viewer_count: stream.viewer_count,
|
viewer_count: stream.viewer_count,
|
||||||
hls_url: stream.hls_url,
|
hls_url: stream.hls_url,
|
||||||
|
webrtc_url: stream.webrtc_url,
|
||||||
playback,
|
playback,
|
||||||
thumbnail_url: stream.thumbnail_url,
|
thumbnail_url: stream.thumbnail_url,
|
||||||
started_at: stream.started_at?.toISOString() ?? null,
|
started_at: stream.started_at?.toISOString() ?? null,
|
||||||
|
|||||||
Reference in New Issue
Block a user