wip: 0.2.7

实现了 rtc 握手和打开数据通道
This commit is contained in:
loveuer 2025-05-26 17:40:06 +08:00
parent 5bc695bde3
commit 21287e0874
7 changed files with 194 additions and 97 deletions

View File

@ -15,6 +15,7 @@ server {
location /ushare { location /ushare {
proxy_pass http://localhost:9119; proxy_pass http://localhost:9119;
const rtc = new RTCPeerConnection({iceServers: [{urls: "stun:stun.qq.com:3478"}]})
proxy_set_header Host $host; proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;

View File

@ -1,46 +0,0 @@
import {useState, useCallback,} from 'react';
export const useRegister = () => {
const [id, setId] = useState('');
const [name, setName] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState<Error | null>(null);
// 封装为可手动触发的异步函数
const register = useCallback(async () => {
setLoading(true);
setError(null);
try {
const response = await fetch('/api/ulocal/register', {
method: 'POST'
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data = (await response.json()).data;
setId(data.id);
setName(data.name);
return {id: data.id, name: data.name};
} catch (err) {
setError(err instanceof Error ? err : new Error('Request failed'));
} finally {
setLoading(false);
}
}, []);
// 如果需要自动执行(组件挂载时自动注册)
// useEffect(() => { register() }, [register]);
return {
id,
name,
register, // 手动触发函数
loading,
error
};
};

View File

@ -1,9 +1,10 @@
import {CloudBackground} from "../../component/fluid/cloud.tsx"; import {CloudBackground} from "../../component/fluid/cloud.tsx";
import {useEffect, useState} from "react"; import {useEffect, useRef, useState} from "react";
import {createUseStyles} from "react-jss"; import {createUseStyles} from "react-jss";
import {useWebsocket} from "../../hook/websocket/u-ws.tsx"; import {useWebsocket} from "../../hook/websocket/u-ws.tsx";
import {Resp} from "../../interface/response.ts"; import {Resp} from "../../interface/response.ts";
import {useRegister} from "./hook/register.tsx"; import {useRegister} from "./hook/register.tsx";
import {useLocalStore} from "../../store/local.ts";
const useClass = createUseStyles({ const useClass = createUseStyles({
'@global': { '@global': {
@ -70,9 +71,9 @@ interface Bubble {
} }
interface WSMessage { interface WSMessage {
body: Client; data: any;
time: number; time: number;
type: "register" | "enter" | "leave" | "offer" | "answer" type: "register" | "enter" | "leave" | "offer" | "answer" | "candidate"
} }
interface Client { interface Client {
@ -100,9 +101,9 @@ function handleFileChunk(chunk: any) {
export const LocalSharing: React.FC = () => { export const LocalSharing: React.FC = () => {
const classes = useClass(); const classes = useClass();
const {id, name, register } = useRegister() const {id, name, set} = useLocalStore()
const [rtc, setRTC] = useState<RTCPeerConnection| null>(null); const [rtc, setRTC] = useState<RTCPeerConnection>();
const [client, setClient] = useState<Client| null>(null); const rtcRef = useRef<RTCPeerConnection>();
const [clients, setClients] = useState<Client[]>([]); const [clients, setClients] = useState<Client[]>([]);
const {connect, close} = useWebsocket({}) const {connect, close} = useWebsocket({})
@ -175,7 +176,6 @@ export const LocalSharing: React.FC = () => {
} }
} }
console.log('[D] generated bubbles =', bubbles);
return bubbles; return bubbles;
}; };
@ -183,38 +183,96 @@ export const LocalSharing: React.FC = () => {
setTimeout(async () => { setTimeout(async () => {
const res = await fetch(`/api/ulocal/clients`) const res = await fetch(`/api/ulocal/clients`)
const jes = await res.json() as Resp<Client[]> const jes = await res.json() as Resp<Client[]>
console.log('[D] update clients =', jes) // console.log('[D] update clients =', jes)
setClients(jes.data) setClients(jes.data)
}, 500) }, 500)
} }
const handleWSEvent = async (e: MessageEvent) => { const handleWSEvent = async (e: MessageEvent) => {
let current_id: string;
let current_rtc: RTCPeerConnection
const msg = JSON.parse(e.data) as WSMessage const msg = JSON.parse(e.data) as WSMessage
console.log('[D] ws event msg =', msg) console.log('[D] ws event msg =', msg)
switch (msg.type) { switch (msg.type) {
case "register":
const reg_resp = JSON.parse(e.data) as { body: Client }
setClient(reg_resp.body)
break
case "enter": case "enter":
await updateClients() await updateClients()
break return
case "leave": case "leave":
await updateClients() await updateClients()
break return
case "offer": case "offer":
break const offer_data = msg.data as { id: string; from: string; offer: RTCSessionDescriptionInit }
current_id = useLocalStore.getState().id
if (offer_data.id !== current_id) {
console.warn(`[W] wrong offer id, want = ${current_id}, got = ${offer_data.id}, data =`, offer_data)
return
}
current_rtc = rtcRef.current
if(!current_rtc) {
console.warn('[W] rtc undefined')
return
}
await current_rtc.setRemoteDescription(offer_data.offer)
const answer = await current_rtc.createAnswer()
if(!answer) {
console.log('[W] answer undefined')
return
}
await current_rtc.setLocalDescription(answer)
await fetch('/api/ulocal/answer', {
method: 'POST',
headers: {"Content-Type": "application/json"},
body: JSON.stringify({id: offer_data.from, answer: answer})
})
return
case "answer": case "answer":
break const answer_data = msg.data as {answer: RTCSessionDescriptionInit; id: string}
current_id = useLocalStore.getState().id
if (answer_data.id !== current_id) {
console.warn(`[W] wrong answer id, want = ${current_id}, got = ${answer_data.id}, data =`, answer_data)
}
current_rtc = rtcRef.current
if(!current_rtc) {
console.warn('[W] rtc undefined')
return
}
await current_rtc.setRemoteDescription(answer_data.answer)
return
case "candidate":
const candidate_data = msg.data as { candidate: RTCIceCandidateInit }
current_rtc = rtcRef.current
if(!current_rtc) {
console.warn('[W] rtc undefined')
return
}
if(!candidate_data.candidate) {
console.log('[W] candidate data null')
return
}
await current_rtc.addIceCandidate(candidate_data.candidate)
return
} }
} }
// 气泡点击处理 // 气泡点击处理
const handleBubbleClick = async (bubble: Bubble) => { const handleBubbleClick = async (bubble: Bubble) => {
const offer = await rtc?.createOffer() console.log(`[D] click id = ${bubble.id}`)
await rtc?.setLocalDescription(offer) const current_rtc = rtcRef.current
current_rtc.onnegotiationneeded = async() => {
const offer = await current_rtc.createOffer()
console.log('[D] offer created', offer)
await current_rtc.setLocalDescription(offer)
const data = { const data = {
id: bubble.id, id: bubble.id,
from: useLocalStore.getState().id,
offer: offer, offer: offer,
} }
await fetch('/api/ulocal/offer', { await fetch('/api/ulocal/offer', {
@ -222,32 +280,49 @@ export const LocalSharing: React.FC = () => {
headers: {"Content-Type": "application/json"}, headers: {"Content-Type": "application/json"},
body: JSON.stringify(data) body: JSON.stringify(data)
}) })
}
current_rtc.onicecandidate = async (e) => {
console.log('[D] on candidate ',e )
await fetch('/api/ulocal/candidate', {
method: 'POST',
headers: {"Content-Type": "application/json"},
body: JSON.stringify({candidate: e.candidate, id: useLocalStore.getState().id})
})
}
const ch = current_rtc.createDataChannel('local', {ordered: true})
setupDataChannel(ch)
}; };
useEffect(() => { useEffect(() => {
const fn = async () => { const fn = async () => {
const reg_data = await register(); const response = await fetch('/api/ulocal/register', {method: 'POST'});
console.log(`[D] register id = ${reg_data?.id}`); const data = ((await response.json()) as Resp<{id: string; name: string}>).data;
connect(`/api/ulocal/ws?id=${reg_data?.id}`, {fn: handleWSEvent}) set(data.id, data.name)
console.log(`[D] register id = ${data.id}`);
connect(`/api/ulocal/ws?id=${data.id}`, {fn: handleWSEvent})
await updateClients() await updateClients()
const rtc = new RTCPeerConnection({iceServers: [{urls: "stun:stun.qq.com:3478"}]})
rtc.onicecandidate = async (e) => {
if(e.candidate) {
await fetch('/api/ulocal/candidate')
}
}
setRTC(rtc)
return () => {close();} const _rtc = new RTCPeerConnection();
rtcRef.current = _rtc; // 同步设置 ref
setRTC(_rtc); // 更新状态(如果需要触发渲染)
return () => {
close();
if (rtcRef) {
rtcRef.current.close()
}
}
}; };
fn() fn()
}, []) }, [])
return <div className={classes.container}> return <div className={classes.container}>
<CloudBackground/> <CloudBackground/>
<h1 className={classes.title}>{name}</h1> <h1 className={classes.title}>{name}
<span> - {id}</span>
</h1>
{clients && generateBubbles().map(bubble => { {clients && generateBubbles().map(bubble => {
return client ? ( return (
<div <div
key={bubble.id} key={bubble.id}
className={classes.bubble} className={classes.bubble}
@ -263,7 +338,7 @@ export const LocalSharing: React.FC = () => {
> >
{bubble.name} {bubble.name}
</div> </div>
) : null; );
})} })}
</div> </div>
} }

View File

@ -0,0 +1,17 @@
import {create} from 'zustand'
export interface LocalStore {
id: string;
name: string;
set: (id: string, name: string) => void;
}
export const useLocalStore = create<LocalStore>()((_set, get) => ({
id: '',
name: '',
set: (id: string, name: string) => {
_set(state => {
return {...state, id: id, name: name};
})
}
}))

View File

@ -28,6 +28,7 @@ func Start(ctx context.Context) <-chan struct{} {
api.Post("/register", handler.LocalRegister()) api.Post("/register", handler.LocalRegister())
api.Post("/offer", handler.LocalOffer()) api.Post("/offer", handler.LocalOffer())
api.Post("/answer", handler.LocalAnswer()) api.Post("/answer", handler.LocalAnswer())
api.Post("/candidate", handler.LocalCandidate())
api.Get("/clients", handler.LocalClients()) api.Get("/clients", handler.LocalClients())
api.Get("/ws", handler.LocalWS()) api.Get("/ws", handler.LocalWS())
} }

View File

@ -224,16 +224,19 @@ func (rc *roomController) Unregister(client *roomClient) {
rc.Broadcast(map[string]any{"type": RoomMessageTypeLeave, "time": time.Now().UnixMilli(), "body": client}) rc.Broadcast(map[string]any{"type": RoomMessageTypeLeave, "time": time.Now().UnixMilli(), "body": client})
} }
func (rc *roomController) Offer(id string, offer *RoomOffer, candidate *RoomCandidate) { func (rc *roomController) Offer(id, from string, offer *RoomOffer) {
if _, ok := rc.clients[id]; !ok { if _, ok := rc.clients[id]; !ok {
return return
} }
rc.clients[id].msgChan <- map[string]any{ rc.clients[id].msgChan <- map[string]any{
"type": "offer", "type": "offer",
"time": time.Now().UnixMilli(),
"data": map[string]any{
"id": id, "id": id,
"from": from,
"offer": offer, "offer": offer,
"candidate": candidate, },
} }
} }
@ -244,7 +247,31 @@ func (rc *roomController) Answer(id string, answer *RoomOffer) {
rc.clients[id].msgChan <- map[string]any{ rc.clients[id].msgChan <- map[string]any{
"type": "answer", "type": "answer",
"time": time.Now().UnixMilli(),
"data": map[string]any{
"id": id, "id": id,
"answer": answer, "answer": answer,
},
}
}
func (rc *roomController) Candidate(id string, candidate *RoomCandidate) {
if _, ok := rc.clients[id]; !ok {
return
}
for _, client := range rc.clients {
if client.Id == id {
continue
}
client.msgChan <- map[string]any{
"type": "candidate",
"time": time.Now().UnixMilli(),
"data": map[string]any{
"id": client.Id,
"candidate": candidate,
},
}
} }
} }

View File

@ -62,8 +62,8 @@ func LocalOffer() nf.HandlerFunc {
return func(c *nf.Ctx) error { return func(c *nf.Ctx) error {
type Req struct { type Req struct {
Id string `json:"id"` Id string `json:"id"`
From string `json:"from"`
Offer *controller.RoomOffer `json:"offer"` Offer *controller.RoomOffer `json:"offer"`
Candidate *controller.RoomCandidate `json:"candidate"`
} }
var ( var (
@ -75,7 +75,7 @@ func LocalOffer() nf.HandlerFunc {
return c.Status(http.StatusBadRequest).JSON(map[string]string{"err": err.Error()}) return c.Status(http.StatusBadRequest).JSON(map[string]string{"err": err.Error()})
} }
controller.RoomController.Offer(req.Id, req.Offer, req.Candidate) controller.RoomController.Offer(req.Id, req.From, req.Offer)
return resp.Resp200(c, req.Offer) return resp.Resp200(c, req.Offer)
} }
@ -102,3 +102,25 @@ func LocalAnswer() nf.HandlerFunc {
return resp.Resp200(c, req) return resp.Resp200(c, req)
} }
} }
func LocalCandidate() nf.HandlerFunc {
return func(c *nf.Ctx) error {
type Req struct {
Id string `json:"id"`
Candidate *controller.RoomCandidate `json:"candidate"`
}
var (
err error
req = new(Req)
)
if err = c.BodyParser(req); err != nil {
return c.Status(http.StatusBadRequest).JSON(map[string]string{"err": err.Error()})
}
controller.RoomController.Candidate(req.Id, req.Candidate)
return resp.Resp200(c, req)
}
}