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,30 +12,14 @@
<select id="peers">
</select>
<input id="message-input" />
<input type="file" id="sendfile" disabled/>
<input id="message-input" disabled />
<input type="file" id="sendfile" disabled />
</div>
</div>
{% endblock %}
{% block pagescripts %}
<script>
(() => {
let message_list = document.getElementById("message-list")
let peers = document.getElementById("peers")
let inputbox = document.getElementById("message-input")
let sendfile = document.getElementById("sendfile")
let search = new URLSearchParams(location.search);
let sender = search.get("name") || "";
let room = search.get("room") || "public";
let peerMap = new Map();
let channelMap = new Map();
let ws;
let wsReconnectInterval = 5000;
(() => {
const MessageBootstrap = "Bootstrap";
const MessageDiscoverRequest = "DiscoverRequest";
const MessageDiscoverResponse = "DiscoverResponse";
@ -43,39 +27,443 @@
const MessageSessionAnswer = "SessionAnswer";
const MessageICECandidate = "IceCandidate";
let updateSendfile = () => {
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 = ""
function ChatRoom(room, name, ui, options) {
options = options || {};
let { iceServers } = options;
let peer = peerMap.get(peers.value)
if (!peer) {
this.room = room;
this.name = name;
this.iceServers = iceServers;
this.peers = new Map();
this.channels = new Map();
this.i_am_polite = new Map();
this.i_am_offering = new Map();
this.ws = null;
this.ui = ui;
this.event = new EventTarget();
}
let nativeEvent = window.Event;
function Event(type, data) {
let event = new nativeEvent(type);
if (data) {
for (let [k, v] of Object.entries(data)) {
event[k] = v
}
}
return event;
}
// ---------------- Chat Room Methods ------------
// ---------------- Utilities --------------------
ChatRoom.prototype.kickoff = function () {
this.event.dispatchEvent(new Event("kickoff"))
this.send_message({
message: {
type: MessageBootstrap,
},
})
}
ChatRoom.prototype.setup_peerconnection = function (sender) {
peer = this.peers.get(sender);
if (peer == null) {
peer = new RTCPeerConnection({
iceServers: [{ urls: this.iceServers }]
})
peer.addEventListener("iceconnectionstatechange", () => {
if (peer.iceConnectionState === "failed") {
peer.restartIce();
}
})
peer.addEventListener("negotiationneeded", async (ev) => {
this.on_negotiation_needed(sender)
})
peer.addEventListener("icecandidate", (ev) => {
if (!ev.candidate) {
return
}
display(`你给 ${peers.value} 传了一个文件: ${file.name}。传输中...`)
let reader = new FileReader()
let dc = peer.createDataChannel(`file:${file.name}`)
reader.addEventListener("load", (ev) => {
dc.send(reader.result)
dc.close()
let candidate = ev.candidate.toJSON()
this.on_local_ice_candidate(sender, candidate)
})
dc.addEventListener("open", () => {
reader.readAsArrayBuffer(file)
peer.addEventListener("datachannel", ({ channel }) => {
this.on_datachannel(sender, channel)
})
this.peers.set(sender, peer);
}
return {
peer,
}
}
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,
channel
}))
})
channel.addEventListener("message", (ev) => {
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
}))
}
// ----------------- Message from websocket --------------
ChatRoom.prototype.on_ws_message = function (wsMessage) {
this.event.dispatchEvent(new Event("recv_wsmessage", { wsMessage }))
console.trace("receiving message", wsMessage)
switch (wsMessage.message.type) {
case MessageBootstrap:
this.on_bootstrap(wsMessage);
break;
case MessageDiscoverRequest:
this.on_discover_request(wsMessage)
break
case MessageDiscoverResponse:
this.on_discover_response(wsMessage)
break;
case MessageSessionOffer:
this.on_session_message(wsMessage)
break
case MessageSessionAnswer:
this.on_session_message(wsMessage)
break
case MessageICECandidate:
this.on_remote_ice_candidate(wsMessage)
}
}
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") {
if (typeof child === "string") {
let inner = document.createElement("span")
inner.innerHTML = child
node.appendChild(inner)
@ -88,254 +476,66 @@
return node
}
const display = (...message) => {
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 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("服务器连上啦。你等等,给你起个名字...")
})
}
}
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}`)
channel.send(inputbox.value)
inputbox.value = ""
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)
})
const addPeer = (peerName) => {
display(`连接上了 ${peerName}`)
let newNode = document.createElement("option")
newNode.id = peerName
newNode.setAttribute("value", peerName)
newNode.innerHTML = peerName;
peers.appendChild(newNode)
updateSendfile()
}
const removePeer = (peerName) => {
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))
room.event.addEventListener("chat_message", ({ sender, data }) => {
ui.display(`${sender} -> 你: ${data}`)
})
ws.addEventListener("open", () => {
display("服务器连上啦。你等等,给你起个名字...")
ws.send(JSON.stringify({
message: {
type: MessageBootstrap,
},
room,
sender,
}))
})
}
let handle_ws_message = (wsMessage) => {
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) => {
console.log("signaling state changed: ", peer.signalingState)
})
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("icecandidate", (ev) => {
if (!ev.candidate) {
console.log("gather end")
return
}
let candidate = ev.candidate.toJSON()
ws.send(JSON.stringify({
message: {
type: MessageICECandidate,
candidate: JSON.stringify(candidate),
sender,
kind: 3,
},
room,
sender,
receiver: wsMessage.sender,
}))
})
peer.addEventListener("icegatheringstatechange", ev => {
console.log("gather", peer.iceGatheringState)
})
peer.addEventListener("datachannel", ({channel}) => {
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", () => {
room.event.addEventListener("file_received", ({ filename, buffer }) => {
var link = document.createElement('a');
link.href = window.URL.createObjectURL(new Blob(buffers));
link.href = window.URL.createObjectURL(buffer);
link.download = filename;
link.innerHTML = filename
display(
link.innerHTML = filename;
ui.display(
"文件:",
link,
)
})
}
})
peerMap.set(peerName, peer);
}
let peer = peerMap.get(peerName);
ui.event.addEventListener("send_message", ({ receiver, value }) => {
let channel = room.channels.get(receiver)
if (!channel) {
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({
message: {
type: resultMessageType,
sdp: JSON.stringify(resultSdp),
sender,
kind: 3,
},
room,
sender,
receiver: wsMessage.sender,
}))
}
let peer = peerMap.get(wsMessage.sender)
switch (wsMessage.message.type) {
case MessageBootstrap:
sender = wsMessage.sender;
ws.send(JSON.stringify({
message: {
type: MessageDiscoverRequest,
},
room,
sender: wsMessage.sender,
}))
display(`决定了,就叫 ${sender}. 我们等等小伙伴吧...`)
break;
case MessageDiscoverRequest:
display(`正在尝试连接 ${wsMessage.sender}`)
ws.send(JSON.stringify({
message: {
type: MessageDiscoverResponse,
},
room,
sender,
receiver: wsMessage.sender,
}))
break
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)
ui.display(`${receiver} 没准备好,这条消息发不出去: ${value}`)
return
}
peer.addIceCandidate(candidate)
}
}
ui.display(`你 -> ${receiver}: ${value}`)
channel.send(value)
})
ui.event.addEventListener("send_file", ({file,receiver}) => {
let peer = room.peers.get(receiver);
reconnect()
})()
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>
{% endblock %}