massive rewrite of frontend

This commit is contained in:
guochao 2024-06-04 11:29:30 +08:00
parent 683462cc10
commit 3acfe8a9ea
Signed by: guochao
GPG Key ID: 79F7306D2AA32FC3

View File

@ -12,330 +12,530 @@
<select id="peers"> <select id="peers">
</select> </select>
<input id="message-input" /> <input id="message-input" disabled />
<input type="file" id="sendfile" disabled/> <input type="file" id="sendfile" disabled />
</div> </div>
</div> </div>
{% endblock %} {% endblock %}
{% block pagescripts %} {% block pagescripts %}
<script> <script>
(() => { (() => {
let message_list = document.getElementById("message-list") const MessageBootstrap = "Bootstrap";
let peers = document.getElementById("peers") const MessageDiscoverRequest = "DiscoverRequest";
let inputbox = document.getElementById("message-input") const MessageDiscoverResponse = "DiscoverResponse";
let sendfile = document.getElementById("sendfile") const MessageSessionOffer = "SessionOffer";
const MessageSessionAnswer = "SessionAnswer";
const MessageICECandidate = "IceCandidate";
let search = new URLSearchParams(location.search); function ChatRoom(room, name, ui, options) {
let sender = search.get("name") || ""; options = options || {};
let { iceServers } = options;
let room = search.get("room") || "public"; this.room = room;
this.name = name;
this.iceServers = iceServers;
let peerMap = new Map(); this.peers = new Map();
let channelMap = new Map(); this.channels = new Map();
this.i_am_polite = new Map();
this.i_am_offering = new Map();
let ws; this.ws = null;
let wsReconnectInterval = 5000;
const MessageBootstrap = "Bootstrap"; this.ui = ui;
const MessageDiscoverRequest = "DiscoverRequest";
const MessageDiscoverResponse = "DiscoverResponse";
const MessageSessionOffer = "SessionOffer";
const MessageSessionAnswer = "SessionAnswer";
const MessageICECandidate = "IceCandidate";
let updateSendfile = () => { this.event = new EventTarget();
if (peers.value != "") {
sendfile.removeAttribute("disabled")
} else {
sendfile.setAttribute("disabled", "")
} }
}
peers.addEventListener("change", updateSendfile)
sendfile.addEventListener("change", async (ev) => {
let file = sendfile.files[0]
sendfile.value = ""
let peer = peerMap.get(peers.value) let nativeEvent = window.Event;
if (!peer) { function Event(type, data) {
return let event = new nativeEvent(type);
}
display(`你给 ${peers.value} 传了一个文件: ${file.name}。传输中...`)
let reader = new FileReader() if (data) {
let dc = peer.createDataChannel(`file:${file.name}`) for (let [k, v] of Object.entries(data)) {
reader.addEventListener("load", (ev) => { event[k] = v
dc.send(reader.result) }
dc.close()
})
dc.addEventListener("open", () => {
reader.readAsArrayBuffer(file)
})
})
const createNode = (tag, ...children) => {
let node = document.createElement(tag)
children.forEach(child => {
if(typeof child === "string") {
let inner = document.createElement("span")
inner.innerHTML = child
node.appendChild(inner)
} else if (child instanceof HTMLElement) {
node.appendChild(child)
} else {
console.log("child is not a node: ", child, typeof child)
} }
})
return node
}
const display = (...message) => { return event;
let scrollToBottom = Math.abs(message_list.scrollHeight - message_list.scrollTop - message_list.clientHeight) < 1;
message_list.appendChild(createNode("div",
`${new Date().toTimeString()}: `,
...message
))
if(scrollToBottom) {
message_list.scrollTo({
top: message_list.scrollHeight
})
}
}
inputbox.addEventListener("keyup", (e) => {
if (!(e.key === 'Enter' || e.keyCode === 13)) {
return
}
if (!inputbox.value || inputbox.value.trim().length == 0) {
return
}
let channel = channelMap.get(peers.value)
if (!channel) {
return
} }
display(`你 -> ${peers.value}: ${inputbox.value}`) // ---------------- Chat Room Methods ------------
channel.send(inputbox.value)
inputbox.value = ""
})
const addPeer = (peerName) => {
display(`连接上了 ${peerName}`)
let newNode = document.createElement("option") // ---------------- Utilities --------------------
newNode.id = peerName
newNode.setAttribute("value", peerName)
newNode.innerHTML = peerName;
peers.appendChild(newNode)
updateSendfile() ChatRoom.prototype.kickoff = function () {
} this.event.dispatchEvent(new Event("kickoff"))
const removePeer = (peerName) => { this.send_message({
display(`${peerName} 走了...`)
let el = document.getElementById(peerName);
if(el) el.remove()
updateSendfile()
}
let timeout;
let reconnect = () => {
if(ws && (ws.readyState == WebSocket.CONNECTING || ws.readyState == WebSocket.OPEN)) {
return;
}
let protocol = "ws://";
if (location.protocol === "https:") {
protocol = "wss://"
}
ws = new WebSocket(protocol+location.host+"/ws");
ws.addEventListener("error", reconnect)
ws.addEventListener("close", reconnect)
ws.addEventListener("message", ({data}) => {
handle_ws_message(JSON.parse(data))
})
ws.addEventListener("open", () => {
display("服务器连上啦。你等等,给你起个名字...")
ws.send(JSON.stringify({
message: { message: {
type: MessageBootstrap, type: MessageBootstrap,
}, },
room, })
sender, }
})) ChatRoom.prototype.setup_peerconnection = function (sender) {
}) peer = this.peers.get(sender);
} if (peer == null) {
peer = new RTCPeerConnection({
let handle_ws_message = (wsMessage) => { iceServers: [{ urls: this.iceServers }]
let recreateAndSetupPeer = async (peerName) => {
if (!peerMap.has(peerName)) {
let peer = new RTCPeerConnection({
iceServers: [{ urls: ["stun:nhz.jeffthecoder.xyz:3478", "stun:nhz.jeffthecoder.xyz:3479", "stun:nhz.jeffthecoder.xyz:13478"] }]
}) })
peer.addEventListener("signalingstatechange", (ev) => { peer.addEventListener("iceconnectionstatechange", () => {
console.log("signaling state changed: ", peer.signalingState) if (peer.iceConnectionState === "failed") {
}) peer.restartIce();
peer.addEventListener("connectionstatechange", (ev) => {
console.log("peer connection state changed: ", peer.connectionState)
switch (peer.connectionState) {
case "closed":
break;
case "disconnected":
case "failed":
peer.restartIce()
break
} }
}) })
peer.addEventListener("negotiationneeded", async (ev) => {
this.on_negotiation_needed(sender)
})
peer.addEventListener("icecandidate", (ev) => { peer.addEventListener("icecandidate", (ev) => {
if (!ev.candidate) { if (!ev.candidate) {
console.log("gather end")
return return
} }
let candidate = ev.candidate.toJSON() let candidate = ev.candidate.toJSON()
ws.send(JSON.stringify({ this.on_local_ice_candidate(sender, candidate)
message: {
type: MessageICECandidate,
candidate: JSON.stringify(candidate),
sender,
kind: 3,
},
room,
sender,
receiver: wsMessage.sender,
}))
}) })
peer.addEventListener("icegatheringstatechange", ev => { peer.addEventListener("datachannel", ({ channel }) => {
console.log("gather", peer.iceGatheringState) this.on_datachannel(sender, channel)
}) })
peer.addEventListener("datachannel", ({channel}) => { this.peers.set(sender, peer);
if(channel.label === "chat") {
channelMap.set(peerName, channel);
channel.addEventListener("open", (ev) => {
addPeer(peerName)
})
channel.addEventListener("message", (ev) => {
display(`${peerName} -> You: ${ev.data}`)
})
channel.addEventListener("close", () => {
removePeer(peerName)
})
} else if (channel.label.startsWith("file:")) {
let filename = channel.label.substr(5)
let buffers = [];
channel.addEventListener("open", ev => {
display(`${peerName} 给你发了一个文件: ${filename}。 接收中...`)
})
channel.addEventListener("message", ev => {
buffers.push(ev.data)
})
channel.addEventListener("close", () => {
var link = document.createElement('a');
link.href = window.URL.createObjectURL(new Blob(buffers));
link.download = filename;
link.innerHTML = filename
display(
"文件:",
link,
)
})
}
})
peerMap.set(peerName, peer);
}
let peer = peerMap.get(peerName);
let resultSdp = null, resultMessageType;
if(wsMessage.message.type === MessageDiscoverResponse) {
let channel = peer.createDataChannel("chat");
channel.addEventListener("open", (ev) => {
addPeer(peerName)
})
channel.addEventListener("message", (ev) => {
display(`${peerName} -> 你: ${ev.data}`)
})
channel.addEventListener("close", () => {
removePeer(peerName)
})
channelMap.set(peerName, channel);
resultSdp = await peer.createOffer();
resultMessageType = MessageSessionOffer;
peer.setLocalDescription(resultSdp)
console.log("set local offer")
} else {
peer.setRemoteDescription(JSON.parse(wsMessage.message.sdp))
console.log("set remote offer")
resultSdp = await peer.createAnswer();
resultMessageType = MessageSessionAnswer;
peer.setLocalDescription(resultSdp)
console.log("set local answer")
} }
ws.send(JSON.stringify({ return {
message: { peer,
type: resultMessageType, }
sdp: JSON.stringify(resultSdp), }
ChatRoom.prototype.setup_chat_datachannel = function (sender, channel) {
console.log("setup chat datachannel", { sender, channel })
channel.addEventListener("open", (ev) => {
this.event.dispatchEvent(new Event("chat-channel-open", {
sender, sender,
kind: 3, channel
}, }))
room, })
sender, channel.addEventListener("message", (ev) => {
receiver: wsMessage.sender, console.log(ev)
this.on_chat_message(sender, ev.data)
})
channel.addEventListener("close", () => {
this.event.dispatchEvent(new Event("chat-channel-close", {
sender,
channel
}))
})
}
ChatRoom.prototype.setup_file_datachannel = function (sender, channel) {
let filename = channel.label.substr(5)
let buffers = [];
channel.addEventListener("open", ev => {
this.event.dispatchEvent(new Event("file_receiving", {
filename,
}))
// this.display(`${sender} 给你发了一个文件: ${filename}。 接收中...`)
})
channel.addEventListener("message", ev => {
buffers.push(ev.data)
})
channel.addEventListener("close", () => {
this.event.dispatchEvent(new Event("file_received", {
buffer: new Blob(buffers),
filename
}))
})
}
ChatRoom.prototype.reconnect = function () {
this.event.dispatchEvent(new Event("ws_reconnect", { ws: this.ws }))
if (this.ws && (this.ws.readyState == WebSocket.CONNECTING || this.ws.readyState == WebSocket.OPEN)) {
return;
}
let protocol = "ws://";
if (location.protocol === "https:") {
protocol = "wss://"
}
let ws = new WebSocket(protocol + location.host + "/ws");
ws.addEventListener("error", (ev) => {
console.error("ws error", ev)
this.reconnect()
})
ws.addEventListener("close", () => {
console.warn("ws closed")
this.reconnect()
})
ws.addEventListener("message", ({ data }) => {
this.on_ws_message(JSON.parse(data))
})
ws.addEventListener("open", () => {
this.kickoff()
})
this.ws = ws;
}
ChatRoom.prototype.send_message = function (message) {
this.event.dispatchEvent(new Event("send_wsmessage", { message }))
console.trace("sending message", message)
this.ws.send(JSON.stringify({
room: this.room,
sender: this.name,
...message
})) }))
} }
let peer = peerMap.get(wsMessage.sender) // ----------------- Message from websocket --------------
switch (wsMessage.message.type) {
case MessageBootstrap:
sender = wsMessage.sender; ChatRoom.prototype.on_ws_message = function (wsMessage) {
ws.send(JSON.stringify({ this.event.dispatchEvent(new Event("recv_wsmessage", { wsMessage }))
message: { console.trace("receiving message", wsMessage)
type: MessageDiscoverRequest, switch (wsMessage.message.type) {
}, case MessageBootstrap:
room, this.on_bootstrap(wsMessage);
sender: wsMessage.sender, break;
})) case MessageDiscoverRequest:
display(`决定了,就叫 ${sender}. 我们等等小伙伴吧...`) this.on_discover_request(wsMessage)
break; break
case MessageDiscoverRequest: case MessageDiscoverResponse:
display(`正在尝试连接 ${wsMessage.sender}`) this.on_discover_response(wsMessage)
ws.send(JSON.stringify({ break;
message: { case MessageSessionOffer:
type: MessageDiscoverResponse, this.on_session_message(wsMessage)
}, break
room, case MessageSessionAnswer:
sender, this.on_session_message(wsMessage)
receiver: wsMessage.sender, break
})) case MessageICECandidate:
break this.on_remote_ice_candidate(wsMessage)
case MessageDiscoverResponse: }
recreateAndSetupPeer(wsMessage.sender)
break;
case MessageSessionOffer:
recreateAndSetupPeer(wsMessage.sender)
break
case MessageSessionAnswer:
console.log("set remote answer")
peer.setRemoteDescription(JSON.parse(wsMessage.message.sdp))
peer.restartIce()
break
case MessageICECandidate:
let candidate = JSON.parse(wsMessage.message.candidate);
if(!peer) {
console.warn("candidate dropped", candidate)
return
}
peer.addIceCandidate(candidate)
} }
}
reconnect() ChatRoom.prototype.on_bootstrap = function ({ sender, iceServers }) {
})() this.event.dispatchEvent(new Event("bootstrap", { sender }))
this.name = sender;
this.iceServers = iceServers || this.iceServers || ["stun:nhz.jeffthecoder.xyz:3478", "stun:nhz.jeffthecoder.xyz:3479", "stun:nhz.jeffthecoder.xyz:13478"];
this.send_message({
message: {
type: MessageDiscoverRequest,
},
})
}
ChatRoom.prototype.on_discover_request = function ({ sender }) {
this.event.dispatchEvent(new Event("discover_request", { sender }))
this.i_am_polite.set(sender, false)
this.setup_peerconnection(sender)
this.send_message({
message: {
type: MessageDiscoverResponse,
},
receiver: sender,
})
}
ChatRoom.prototype.on_discover_response = async function ({ sender }) {
this.i_am_polite.set(sender, true)
let { peer } = this.setup_peerconnection(sender);
let channel = peer.createDataChannel("chat");
this.setup_chat_datachannel(sender, channel)
this.channels.set(sender, channel)
}
ChatRoom.prototype.on_session_message = async function ({ sender, message }) {
let description = JSON.parse(message.sdp)
let polite = this.i_am_polite.get(sender);
let makingOffer = this.i_am_offering.get(sender) || false;
let peer = this.peers.get(sender)
console.log("on session message", { message, description, polite, makingOffer })
const offerCollision =
description.type === "offer" &&
(makingOffer || peer.signalingState !== "stable");
ignoreOffer = !polite && offerCollision;
if (ignoreOffer) {
return;
}
await peer.setRemoteDescription(description);
if (description.type === "offer") {
await peer.setLocalDescription();
this.event.dispatchEvent(new Event("offer", { sender, description }))
this.send_message({
message: {
type: MessageSessionAnswer,
sdp: JSON.stringify(peer.localDescription),
sender: this.name,
kind: 3,
},
receiver: sender,
})
}
}
ChatRoom.prototype.on_remote_ice_candidate = function ({ sender, message }) {
let candidate = JSON.parse(message.candidate);
this.event.dispatchEvent(new Event("candidate", { sender, candidate }))
let peer = this.peers.get(sender)
console.log("remote_candidate", sender, message)
peer.addIceCandidate(candidate)
}
// ----------------- webrtc state & events -----------------
ChatRoom.prototype.on_negotiation_needed = async function (sender) {
let polite = this.i_am_polite.get(sender);
console.log("negotiationneeded", { sender, polite })
let peer = this.peers.get(sender);
this.i_am_offering.set(sender, true)
try {
await peer.setLocalDescription();
this.send_message({
message: {
type: MessageSessionOffer,
sdp: JSON.stringify(peer.localDescription),
sender: this.name,
kind: 3,
},
receiver: sender,
})
} finally {
this.i_am_offering.delete(sender)
}
}
ChatRoom.prototype.on_local_ice_candidate = function (sender, candidate) {
this.event.dispatchEvent(new Event("candidate", { candidate }))
this.send_message({
message: {
type: MessageICECandidate,
candidate: JSON.stringify(candidate),
sender: this.name,
kind: 3,
},
receiver: sender,
})
}
ChatRoom.prototype.on_chat_message = function (sender, data) {
this.event.dispatchEvent(new Event("chat_message", { sender, data }))
}
ChatRoom.prototype.on_channel_open = function (sender, channel) {
this.event.dispatchEvent(new Event("channel_open", { data }))
}
ChatRoom.prototype.on_channel_close = function (sender, channel) {
this.event.dispatchEvent(new Event("channel_close", { data }))
}
ChatRoom.prototype.on_datachannel = function (sender, channel) {
console.trace("on datachannel", sender, channel)
if (channel.label === "chat") {
this.channels.set(sender, channel);
this.setup_chat_datachannel(sender, channel)
} else if (channel.label.startsWith("file:")) {
this.setup_file_datachannel(sender, channel)
}
}
// ----------------------------------------------------------
function ChatUI(messages, peerOptions, inputbox, sendfile) {
this.messages = messages;
this.peerOptions = peerOptions;
this.inputbox = inputbox;
this.sendfile = sendfile;
this.receiver = null;
this.event = new EventTarget();
}
ChatUI.prototype.setup = function () {
this.sendfile.addEventListener("change", e => this.on_sendfile_changed(e))
this.peerOptions.addEventListener("change", e => this.on_peer_list_changed(e))
this.inputbox.addEventListener("keyup", e => this.on_key_up(e))
}
ChatUI.prototype.on_peer_list_changed = function () {
this.receiver = this.peerOptions.value;
if (this.peerOptions.value != "") {
this.sendfile.removeAttribute("disabled")
this.inputbox.removeAttribute("disabled")
} else {
this.sendfile.setAttribute("disabled", "")
this.inputbox.setAttribute("disabled", "")
}
}
ChatUI.prototype.on_key_up = function (e) {
if (!(e.key === 'Enter' || e.keyCode === 13)) {
return
}
if (!this.inputbox.value || this.inputbox.value.trim().length == 0) {
return
}
let value = this.inputbox.value;
let receiver = this.receiver;
this.event.dispatchEvent(new Event("send_message", {
value,
receiver,
}))
this.inputbox.value = ""
}
ChatUI.prototype.on_sendfile_changed = function () {
let file = sendfile.files[0]
let receiver = this.receiver;
sendfile.value = ""
this.display(`你给 ${this.receiver} 传了一个文件: ${file.name}。传输中...`)
// let reader = new FileReader()
// let dc = peer.createDataChannel(`file:${file.name}`)
// reader.addEventListener("load", (ev) => {
// dc.send(reader.result)
// dc.close()
// })
// dc.addEventListener("open", () => {
// reader.readAsArrayBuffer(file)
// })
this.event.dispatchEvent(new Event("send_file", {
file,
receiver,
}))
}
ChatUI.prototype.add_peer = function (peerName) {
let newNode = document.createElement("option")
newNode.id = `peer-${peerName}`
newNode.setAttribute("value", peerName)
newNode.innerHTML = peerName;
this.peerOptions.appendChild(newNode)
this.on_peer_list_changed()
}
ChatUI.prototype.remove_peer = function (peerName) {
let el = document.getElementById(`peer-${peerName}`);
if (el) el.remove()
this.on_peer_list_changed()
}
ChatUI.prototype.display = function (...message) {
let message_list = this.messages;
let scrollToBottom = Math.abs(message_list.scrollHeight - message_list.scrollTop - message_list.clientHeight) < 1;
message_list.appendChild(createNode("div",
`${new Date().toTimeString()}: `,
...message
))
if (scrollToBottom) {
message_list.scrollTo({
top: message_list.scrollHeight
})
}
}
let message_list = document.getElementById("message-list")
let peers = document.getElementById("peers")
let inputbox = document.getElementById("message-input")
let sendfile = document.getElementById("sendfile")
const createNode = (tag, ...children) => {
let node = document.createElement(tag)
children.forEach(child => {
if (typeof child === "string") {
let inner = document.createElement("span")
inner.innerHTML = child
node.appendChild(inner)
} else if (child instanceof HTMLElement) {
node.appendChild(child)
} else {
console.log("child is not a node: ", child, typeof child)
}
})
return node
}
let search = new URLSearchParams(location.search);
let room = new ChatRoom(search.get("room") || "public", search.get("name") || "", undefined, {})
let ui = new ChatUI(message_list, peers, inputbox, sendfile);
ui.setup()
room.event.addEventListener("kickoff", () => {
ui.display("服务器连上啦。你等等,给你起个名字...")
})
room.event.addEventListener("chat-channel-open", ({ channel, sender }) => {
ui.display(`${sender} 连上了`)
ui.add_peer(sender)
})
room.event.addEventListener("chat-channel-closed", ({ channel, sender }) => {
ui.display(`${sender} 断开了`)
ui.remove_peer(sender)
})
room.event.addEventListener("chat_message", ({ sender, data }) => {
ui.display(`${sender} -> 你: ${data}`)
})
room.event.addEventListener("file_received", ({ filename, buffer }) => {
var link = document.createElement('a');
link.href = window.URL.createObjectURL(buffer);
link.download = filename;
link.innerHTML = filename;
ui.display(
"文件:",
link,
)
})
ui.event.addEventListener("send_message", ({ receiver, value }) => {
let channel = room.channels.get(receiver)
if (!channel) {
ui.display(`${receiver} 没准备好,这条消息发不出去: ${value}`)
return
}
ui.display(`你 -> ${receiver}: ${value}`)
channel.send(value)
})
ui.event.addEventListener("send_file", ({file,receiver}) => {
let peer = room.peers.get(receiver);
let reader = new FileReader()
let dc = peer.createDataChannel(`file:${file.name}`)
reader.addEventListener("load", (ev) => {
dc.send(reader.result)
dc.close()
})
dc.addEventListener("open", () => {
reader.readAsArrayBuffer(file)
})
})
let display = ui.display.bind(ui);
room.reconnect()
})()
</script> </script>
{% endblock %} {% endblock %}