wip: 0.2.2
1. rtc init
This commit is contained in:
parent
3053394f03
commit
7c089bbe84
@ -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}>
|
||||||
|
@ -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()
|
||||||
|
@ -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: () => {
|
|
||||||
},
|
|
||||||
}))
|
|
@ -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)
|
||||||
|
@ -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
14
internal/model/ws.go
Normal 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"`
|
||||||
|
}
|
Loading…
x
Reference in New Issue
Block a user