wip: 0.2.2

1. rtc init
This commit is contained in:
loveuer 2025-05-16 16:14:40 +08:00
parent 3053394f03
commit 7c089bbe84
6 changed files with 154 additions and 78 deletions

View File

@ -1,7 +1,6 @@
import {CloudBackground} from "../component/fluid/cloud.tsx"; import {CloudBackground} from "../component/fluid/cloud.tsx";
import {useEffect} from "react"; import {useEffect} from "react";
import {createUseStyles} from "react-jss"; import {createUseStyles} from "react-jss";
import {useRTC} from "../store/rtc.ts";
import {Client, useRoom} from "../store/local.ts"; import {Client, useRoom} from "../store/local.ts";
const useClass = createUseStyles({ const useClass = createUseStyles({
@ -71,7 +70,6 @@ interface Bubble {
export const LocalSharing: React.FC = () => { export const LocalSharing: React.FC = () => {
const classes = useClass(); const classes = useClass();
const {register, enter, list, cleanup, client, clients} = useRoom(); const {register, enter, list, cleanup, client, clients} = useRoom();
const {connect, create} = useRTC();
// 生成随机颜色 // 生成随机颜色
const generateColor = () => { const generateColor = () => {
@ -147,20 +145,18 @@ export const LocalSharing: React.FC = () => {
useEffect(() => { useEffect(() => {
register().then(() => { register().then(() => {
enter().then(() => { setTimeout(() => {
list().then() enter().then(() => {
}) list().then()
})
}, 600)
}); });
connect().then(() => {
console.log("[D] rtc create!!!")
})
return () => cleanup(); return () => cleanup();
}, []); }, []);
// 气泡点击处理 // 气泡点击处理
const handleBubbleClick = async (id: string) => { const handleBubbleClick = async (id: string) => {
console.log('[D] click bubble!!!', id) console.log('[D] click bubble!!!', id)
await create()
}; };
return <div className={classes.container}> return <div className={classes.container}>

View File

@ -15,6 +15,10 @@ type RoomState = {
conn: WebSocket | null conn: WebSocket | null
client: Client | null client: Client | null
clients: Client[] clients: Client[]
pc: RTCPeerConnection | null
ch: RTCDataChannel | null
candidate: RTCIceCandidate | null
offer: RTCSessionDescription | null
retryCount: number retryCount: number
reconnectTimer: number | null reconnectTimer: number | null
} }
@ -23,6 +27,7 @@ type RoomActions = {
register: () => Promise<void> register: () => Promise<void>
enter: () => Promise<void> enter: () => Promise<void>
list: () => Promise<void> list: () => Promise<void>
send: (file: File) => Promise<void>
cleanup: () => void cleanup: () => void
} }
@ -32,6 +37,17 @@ interface Message {
body: any; body: any;
} }
function setupDataChannel(ch: RTCDataChannel) {
ch.onopen = () => console.log('通道已打开!');
ch.onmessage = (e) => handleFileChunk(e.data);
ch.onclose = () => console.log('通道关闭');
}
// 接收文件块
function handleFileChunk(chunk: any) {
console.log("[D] rtc file chunk =", chunk)
}
const MAX_RETRY_DELAY = 30000 // 最大重试间隔30秒 const MAX_RETRY_DELAY = 30000 // 最大重试间隔30秒
const NORMAL_CLOSE_CODE = 1000 // 正常关闭的状态码 const NORMAL_CLOSE_CODE = 1000 // 正常关闭的状态码
@ -39,14 +55,67 @@ export const useRoom = create<RoomState & RoomActions>()((set, get) => ({
conn: null, conn: null,
client: null, client: null,
clients: [], clients: [],
pc: null,
ch: null,
candidate: null,
offer: null,
retryCount: 0, retryCount: 0,
reconnectTimer: null, reconnectTimer: null,
register: async () => { register: async () => {
const api = `/api/ulocal/register` let candidate: RTCIceCandidate;
const res = await fetch(api, {method: 'POST'}) let offer: RTCSessionDescription | null;
const jes = await res.json() as Resp<Client> const rtc = new RTCPeerConnection({iceServers: [{urls: "stun:stun.qq.com:3478"}]})
return set(state => { // 处理接收方DataChannel
return {...state, client: jes.data} rtc.ondatachannel = (e) => {
setupDataChannel(e.channel);
};
const waitCandidate = new Promise<void>(resolve => {
rtc.onicecandidate = (e) => {
if (e.candidate) {
console.log('[D] candidate =', {candidate: e.candidate})
candidate = e.candidate
}
resolve();
}
})
// rtc.onicecandidate = (e) => {
// if (e.candidate) {
// console.log('[D] candidate =', {candidate: e.candidate})
// candidate = e.candidate
// }
// }
const waitOffer = new Promise<void>(resolve => {
rtc.onnegotiationneeded = async () => {
await rtc.setLocalDescription(await rtc.createOffer());
console.log("[D] offer =", {offer: rtc.localDescription})
offer = rtc.localDescription
resolve();
};
})
// rtc.onnegotiationneeded = async () => {
// await rtc.setLocalDescription(await rtc.createOffer());
// console.log("[D] offer =", {offer: rtc.localDescription})
// offer = rtc.localDescription
// };
const ch = rtc.createDataChannel("fileTransfer", {ordered: true})
setupDataChannel(ch)
Promise.all([waitCandidate, waitOffer]).then(() => {
const api = `/api/ulocal/register`
fetch(api, {
method: 'POST',
headers: {"Content-Type": "application/json"},
body: JSON.stringify({candidate: candidate, offer: offer})
}).then(res => {return res.json() as unknown as Resp<Client>}).then(jes => {
set({client: jes.data, candidate: candidate, offer: offer})
})
}) })
}, },
enter: async () => { enter: async () => {
@ -57,11 +126,11 @@ export const useRoom = create<RoomState & RoomActions>()((set, get) => ({
if (conn) conn.close() if (conn) conn.close()
const api = `${window.location.protocol === 'https' ? 'wss' : 'ws'}://${window.location.host}/api/ulocal/ws?id=${get().client?.id}` const api = `${window.location.protocol === 'https' ? 'wss' : 'ws'}://${window.location.host}/api/ulocal/ws?id=${get().client?.id}`
console.log('[D] websocket api =',api) console.log('[D] websocket api =', api)
const newConn = new WebSocket(api) const newConn = new WebSocket(api)
newConn.onopen = () => { newConn.onopen = () => {
set({conn: newConn, retryCount: 0}) // 重置重试计数器
} }
newConn.onerror = (error) => { newConn.onerror = (error) => {
@ -75,7 +144,7 @@ export const useRoom = create<RoomState & RoomActions>()((set, get) => ({
switch (msg.type) { switch (msg.type) {
case "enter": case "enter":
nc = msg.body as Client nc = msg.body as Client
if(nc.id && nc.name && nc.id !== get().client?.id) { if (nc.id && nc.name && nc.id !== get().client?.id) {
console.log('[D] enter new client =', nc) console.log('[D] enter new client =', nc)
set(state => { set(state => {
return {...state, clients: [...get().clients, nc]} return {...state, clients: [...get().clients, nc]}
@ -84,7 +153,7 @@ export const useRoom = create<RoomState & RoomActions>()((set, get) => ({
break break
case "leave": case "leave":
nc = msg.body as Client nc = msg.body as Client
if(nc.id) { if (nc.id) {
let idx = 0; let idx = 0;
let items = get().clients; let items = get().clients;
for (const item of items) { for (const item of items) {
@ -93,7 +162,7 @@ export const useRoom = create<RoomState & RoomActions>()((set, get) => ({
set(state => { set(state => {
return {...state, clients: items} return {...state, clients: items}
}) })
break; break;
} }
idx++; idx++;
} }
@ -127,9 +196,22 @@ export const useRoom = create<RoomState & RoomActions>()((set, get) => ({
const api = "/api/ulocal/clients?room=" const api = "/api/ulocal/clients?room="
const res = await fetch(api + get().client?.room) const res = await fetch(api + get().client?.room)
const jes = await res.json() as Resp<Client[]> const jes = await res.json() as Resp<Client[]>
set(state => { set({clients: jes.data})
return {...state, clients: jes.data} },
}) send: async (file: File) => {
const reader = new FileReader();
const channel = get().ch!;
reader.onload = (e) => {
const chunkSize = 16384; // 16KB每块
const buffer = e.target!.result! as ArrayBuffer;
let offset = 0;
while (offset < buffer.byteLength) {
const chunk = buffer.slice(offset, offset + chunkSize);
channel.send(chunk);
offset += chunkSize;
}
};
reader.readAsArrayBuffer(file);
}, },
cleanup: () => { cleanup: () => {
const {conn, reconnectTimer} = get() const {conn, reconnectTimer} = get()

View File

@ -1,50 +0,0 @@
import {create} from 'zustand'
type RTCState = {
conn: RTCPeerConnection | null
}
type RTCAction = {
connect: () => Promise<void>
create: () => Promise<void>
cleanup: () => void
}
export const useRTC = create<RTCState & RTCAction>()((set, get) => ({
conn: null,
connect: async () => {
const conn = new RTCPeerConnection()
const ch = conn.createDataChannel("fileTransfer", {ordered: true})
console.log('[D] channel =', ch)
ch.onopen = (event) => {
console.log('🚀🚀🚀 / rtc open event', event)
}
ch.onclose = (event) => {
}
ch.onerror = (event) => {
}
ch.onmessage = (event) => {
console.log('🚀🚀🚀 / rtc message event', event)
}
set((state) => {
return {...state, conn: conn}
})
},
create: async () => {
const conn = get().conn
if (conn) conn.onicecandidate = async (event) => {
console.log('[D] rtc local desc =', conn.localDescription)
const offer = await conn.createOffer()
await conn.setLocalDescription(offer)
}
},
cleanup: () => {
},
}))

View File

@ -39,6 +39,7 @@ const (
) )
type roomClient struct { type roomClient struct {
sync.Mutex
controller *roomController controller *roomController
conn *websocket.Conn conn *websocket.Conn
ClientType RoomClientType `json:"client_type"` ClientType RoomClientType `json:"client_type"`
@ -48,6 +49,8 @@ type roomClient struct {
Name string `json:"name"` Name string `json:"name"`
Id string `json:"id"` Id string `json:"id"`
RegisterAt time.Time `json:"register_at"` RegisterAt time.Time `json:"register_at"`
Offer any `json:"offer"`
Candidate any `json:"candidate"`
msgChan chan any msgChan chan any
} }
@ -90,10 +93,26 @@ func (rc *roomClient) start(ctx context.Context) {
rc.controller.Unregister(rc) rc.controller.Unregister(rc)
return return
case websocket.TextMessage: case websocket.TextMessage:
log.Info("RoomClient: received text message, IP = %s, Id = %s, Name = %s, text = %s", rc.IP, rc.Id, rc.Name, string(bs)) log.Debug("RoomClient: received text message, IP = %s, Id = %s, Name = %s, text = %s", rc.IP, rc.Id, rc.Name, string(bs))
case websocket.BinaryMessage: case websocket.BinaryMessage:
log.Debug("RoomClient: received bytes message, IP = %s, Id = %s, Name = %s, text = %s", rc.IP, rc.Id, rc.Name, string(bs))
// todo // todo
log.Info("RoomClient: received bytes message, IP = %s, Id = %s, Name = %s, text = %s", rc.IP, rc.Id, rc.Name, string(bs)) //msg := new(model.Message)
//if err = json.Unmarshal(bs, msg); err != nil {
// log.Error("RoomClient: unmarshal message failed, id = %s, name = %s, err = %s", rc.Id, rc.Name, err.Error())
// continue
//}
//
//switch msg.Type {
//case model.WSMessageTypeOffer:
// rc.Lock()
// rc.Offer = msg.Body
// rc.Unlock()
//case model.WSMessageTypeCandidate:
// rc.Lock()
// rc.Candidate = msg.Body
// rc.Unlock()
//}
} }
} }
}() }()
@ -134,7 +153,7 @@ func (rc *roomController) Start(ctx context.Context) {
}() }()
} }
func (rc *roomController) Register(ip, userAgent string) *roomClient { func (rc *roomController) Register(ip, userAgent string, candidate, offer any) *roomClient {
nrc := &roomClient{ nrc := &roomClient{
controller: rc, controller: rc,
ClientType: ClientTypeDesktop, ClientType: ClientTypeDesktop,
@ -144,6 +163,8 @@ func (rc *roomController) Register(ip, userAgent string) *roomClient {
Name: tool.RandomName(), Name: tool.RandomName(),
msgChan: make(chan any, 1), msgChan: make(chan any, 1),
RegisterAt: time.Now(), RegisterAt: time.Now(),
Candidate: candidate,
Offer: offer,
} }
ua := useragent.Parse(userAgent) ua := useragent.Parse(userAgent)

View File

@ -11,10 +11,23 @@ import (
func LocalRegister() nf.HandlerFunc { func LocalRegister() nf.HandlerFunc {
return func(c *nf.Ctx) error { return func(c *nf.Ctx) error {
ip := c.IP(true) type Req struct {
ua := c.Get("User-Agent") Candidate any `json:"candidate"`
Offer any `json:"offer"`
}
client := controller.RoomController.Register(ip, ua) var (
err error
req = new(Req)
ip = c.IP(true)
ua = c.Get("User-Agent")
)
if err = c.BodyParser(req); err != nil {
return c.Status(http.StatusBadRequest).JSON(map[string]interface{}{"msg": err.Error()})
}
client := controller.RoomController.Register(ip, ua, req.Candidate, req.Offer)
return resp.Resp200(c, client) return resp.Resp200(c, client)
} }

14
internal/model/ws.go Normal file
View File

@ -0,0 +1,14 @@
package model
type WSMessageType string
const (
WSMessageTypeOffer WSMessageType = "offer"
WSMessageTypeCandidate WSMessageType = "candidate"
)
type Message struct {
Type WSMessageType `json:"type"`
Time int64 `json:"time"`
Body any `json:"body"`
}