使用 Javalin 构建 Omegle 克隆版
使用 Javalin 构建 Omegle 克隆版
2023年12月30日 • 作者:David Åse阅读时间:25-50分钟
本教程的源代码可以在 GitHub上找到。请 fork/clone 并在阅读时查看。
介绍
在本教程中,我们将使用 Javalin 构建一个简单且功能齐全的 Omegle 克隆版。我们的应用程序将支持随机配对用户进行视频聊天,并且外观与真正的 Omegle 平台非常相似(安息吧)。对于前端,我们将使用纯 JavaScript 和 WebRTC,不使用任何框架或库。尽管如此,完整的 JavaScript 前端代码也只有两百多行。后端代码将在 80-120 行之间,具体取决于您使用的是 Kotlin 还是 Java 版本的教程。
我们的应用程序将如下所示:
设置项目
您可以使用Maven或 Gradle (甚至Bazel)来设置您的项目。请遵循您选择的构建工具的相应指南。至于依赖项,我们将仅使用 Javalin 软件包,其中包含 Javalin、Jetty、Jackson 和 Logback。
请将以下依赖项添加到您的构建文件中:
<dependency>
<groupId>io.javalin</groupId>
<artifactId>javalin-bundle</artifactId>
<version>6.6.0</version>
</dependency>
项目结构
我们将使用以下项目结构:
src
├── main
│ ├── java/kotlin
│ │ └── io
│ │ └── javalin
│ │ └── omeglin
│ │ ├── OmeglinMain.kt/java // main class
│ │ └── Matchmaker.kt/java // matchmaking logic
└── resources
└── public
├── index.html // html for the frontend
├── js
│ ├── app.js // main class
│ ├── peer-connection.js // webrtc logic
│ └── chat.js // chat logic
└── style.css // styling
实现后端
后端非常简单。我们需要一个用于前端的静态文件处理程序,以及一个用于 WebRTC 信令的 WebSocket 处理程序。我们来看看主类:
package io.javalin.omeglin;
import io.javalin.Javalin;
import io.javalin.http.staticfiles.Location;
public class OmeglinMain {
public static void main(String[] args) {
Javalin.create(config -> {
config.staticFiles.add("src/main/resources/public", Location.EXTERNAL);
config.router.mount(router -> {
router.ws("/api/matchmaking", Matchmaking::websocket);
});
}).start(7070);
}
}
我们使用 添加静态文件Loation.EXTERNAL,这样我们就可以更改前端而无需重启服务器。我们还添加了一个 websocket 处理程序,用于 WebRTC 信令。最后,我们在端口 7070 上启动服务器。所有后端逻辑都包含在匹配类中。在展示完整代码之前,让我们先讨论一下构成匹配逻辑的各个类和方法。
媒人
此类负责处理应用程序的 WebSocket 连接。它维护一个Exchange对象队列,每个对象Exchange 代表一对想要进行 SDP(会话描述协议)交换的用户。
交换完成后,该Exchange对象将从队列中移除,所有后续消息将直接在用户之间发送(点对点)。
视频和音频流直接在用户之间发送,无需经过服务器。
该websocket方法设置所有 WebSocket 事件处理程序:
- onConnect:当用户连接时,将启用自动 ping 以保持连接处于活动状态。
- onClose:当用户断开连接时,pairingAbort调用该函数将用户从配对队列中移除(如果用户在队列中)。
- onMessage:收到消息后,系统会根据其类型进行处理。可以接收多种类型的消息,例如“PAIRING_START”、“PAIRING_ABORT”、“PAIRING_DONE”以及与建立 WebRTC 连接相关的各种 SDP 消息。这些 SDP 消息只会发送给配对中的另一个用户,而不会由服务器处理。
交换
此类包含WsContext执行 SDP 交换以进行配对所需的两个用户( ),以及一个doneCount用于跟踪两个用户是否已完成配对(用于清理目的)的 。它有一个otherUser方法,可以根据一个用户返回配对中的另一个用户,这对于在用户之间传递消息非常有用。
信息
此类表示可通过 WebSocket 连接发送的消息。它包含一个name表示消息类型的参数,以及可选的data附加信息。
完整的匹配代码
package io.javalin.omeglin;
import io.javalin.websocket.WsContext;
import io.javalin.websocket.WsConfig;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.util.concurrent.ConcurrentLinkedQueue;
public class Matchmaking {
private static final Logger logger = LoggerFactory.getLogger(Matchmaking.class);
private static final ConcurrentLinkedQueue<Exchange> queue = new ConcurrentLinkedQueue<>();
public static void websocket(WsConfig ws) {
ws.onConnect(user -> user.enableAutomaticPings());
ws.onClose(user -> pairingAbort(user));
ws.onMessage(user -> {
logger.info("Received message: " + user.message());
var message = user.messageAsClass(Message.class);
switch (message.name()) {
case "PAIRING_START" -> pairingStart(user);
case "PAIRING_ABORT" -> pairingAbort(user);
case "PAIRING_DONE" -> pairingDone(user);
case "SDP_OFFER", "SDP_ANSWER", "SDP_ICE_CANDIDATE" -> {
var exchange = findExchange(user);
if (exchange != null && exchange.a != null && exchange.b != null) {
send(exchange.otherUser(user), message); // forward message to other user
} else {
logger.warn("Received SDP message from unpaired user");
}
}
}
});
}
private static void pairingStart(WsContext user) {
queue.removeIf(ex -> ex.a == user || ex.b == user); // prevent double queueing
var exchange = queue.stream()
.filter(ex -> ex.b == null)
.findFirst()
.orElse(null);
if (exchange != null) {
exchange.b = user;
send(exchange.a, new Message("PARTNER_FOUND", "GO_FIRST"));
send(exchange.b, new Message("PARTNER_FOUND"));
} else {
queue.add(new Exchange(user));
}
}
private static void pairingAbort(WsContext user) {
var exchange = findExchange(user);
if (exchange != null) {
send(exchange.otherUser(user), new Message("PARTNER_LEFT"));
queue.remove(exchange);
}
}
private static void pairingDone(WsContext user) {
var exchange = findExchange(user);
if (exchange != null) {
exchange.doneCount++;
}
queue.removeIf(ex -> ex.doneCount == 2);
}
private static Exchange findExchange(WsContext user) {
return queue.stream()
.filter(ex -> user.equals(ex.a) || user.equals(ex.b))
.findFirst()
.orElse(null);
}
private static void send(WsContext user, Message message) { // null safe send method
if (user != null) {
user.send(message);
}
}
record Message(String name, String data) {
public Message(String name) {
this(name, null);
}
}
static class Exchange {
public WsContext a;
public WsContext b;
public int doneCount = 0;
public Exchange(WsContext a) {
this.a = a;
}
public WsContext otherUser(WsContext user) {
return user.equals(a) ? b : a;
}
}
}
现在我们已经有了后端逻辑,让我们继续讨论前端。
实现前端
我们将尽量简化前端。我们将使用纯 JavaScript 和 WebRTC(以及一些 CSS),不使用任何框架或库。前端将分为五个文件:
- index.html:页面的 HTML(定义 UI 元素:视频、按钮、聊天记录等)
- style.css:页面的 CSS(这定义了所有内容的外观)。
- app.js:主 JavaScript 文件(类似于后端中的主类)
- peer-connection.js:WebRTC 逻辑(建立对等连接并处理 SDP 交换)
- chat.js:聊天逻辑(处理聊天用户输入和与聊天相关的 UI 更新)
索引.html
该项目的 HTML 没有什么特别之处,需要注意的主要元素是:
- video本地和远程视频流的元素。
- button寻找新伙伴、中止搜索和结束通话的元素。
- 聊天消息的输入/输出。
元素上的类用于样式(在styles.css中定义),ID用于附加事件监听器(在javascript文件中定义)。
该app.js
文件作为模块包含在内,以便我们可以使用 JavaScript import语法来导入其他 JavaScript 文件(本机 javascript 模块)。
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Omeglin - Talk to strangers</title>
<link href="/favicon.ico" type="image/svg+xml" rel="icon">
<link rel="stylesheet" href="/styles.css">
</head>
<body>
<header>
<img src="/omeglin.svg" alt="Omeglin logo" class="logo">
</header>
<main>
<div class="video-panel">
<section class="remote">
<img src="/tail-spin.svg" class="spinner" alt="">
<video id="remoteVideo" autoplay playsinline></video>
</section>
<section class="local">
<video id="localVideo" autoplay playsinline muted></video>
</section>
</div>
<div class="chat-panel">
<div class="chat-log" id="chatLog"></div>
<div class="chat-controls">
<div class="pairing">
<button id="startPairing">Find stranger</button>
<button id="abortPairing">Cancel</button>
<button id="leavePairing">Disconnect</button>
</div>
<div class="messaging">
<input type="text" placeholder="Type a message..." id="chatInput">
<button id="chatSend">Send</button>
</div>
</div>
</div>
</main>
<script type="module" src="/js/app.js"></script>
</body>
</html>
App.js
该app.js文件是主要的 JavaScript 文件,类似于后端的主类。它负责初始化所有内容并设置 的事件监听器peer-connection.js,以及 UI 元素的事件监听器(聊天功能除外,聊天功能由 处理chat.js)。
import {Chat} from './chat.js';
import {PeerConnection} from "./peer-connection.js";
const peerConnection = new PeerConnection({
onLocalMedia: stream => document.getElementById("localVideo").srcObject = stream,
onRemoteMedia: stream => document.getElementById("remoteVideo").srcObject = stream,
onChatMessage: message => chat.addRemoteMessage(message),
onStateChange: state => {
document.body.dataset.state = state;
chat.updateUi(state);
}
});
let chat = new Chat(peerConnection);
document.getElementById("startPairing").addEventListener("click", async () => {
peerConnection.setState("CONNECTING");
peerConnection.sdpExchange.send(JSON.stringify({name: "PAIRING_START"}))
});
document.getElementById("abortPairing").addEventListener("click", () => {
peerConnection.sdpExchange.send(JSON.stringify({name: "PAIRING_ABORT"}))
peerConnection.disconnect("LOCAL");
})
document.getElementById("leavePairing").addEventListener("click", () => {
peerConnection.sendBye();
});
window.addEventListener("beforeunload", () => {
if (peerConnection.state === "CONNECTED") {
peerConnection.sendBye();
}
});
得益于变量提升,我们可以在定义变量之前定义对等连接事件监听器chat,即使我们chat在事件监听器中使用该变量。这并非一个非常简洁的解决方案,但在本教程中有效。如果您想将此应用程序构建为一个真正的项目,则可能需要重构它以允许app.js并chat.js 附加事件监听器到对等连接。
Chat.js
该chat.js文件负责处理聊天消息的 UI 和逻辑。只需几行代码:
export class Chat {
#input = document.getElementById("chatInput");
#sendBtn = document.getElementById("chatSend");
#log = document.getElementById("chatLog");
#peerConnection;
constructor(peerConnection) {
this.#peerConnection = peerConnection;
this.updateUi("NOT_CONNECTED");
this.#sendBtn.addEventListener("click", () => {
if (this.#peerConnection.dataChannel === null) return console.log("No data channel");
if (this.#input.value.trim() === "") return this.#input.value = "";
this.#addToLog("local", this.#input.value);
this.#peerConnection.dataChannel.send(JSON.stringify({chat: this.#input.value}));
this.#input.value = "";
});
this.#input.addEventListener("keyup", event => {
if (event.key !== "Enter") return;
this.#sendBtn.click(); // reuse the click handler
});
}
updateUi(state) {
if (["NOT_CONNECTED", "CONNECTING", "CONNECTED"].includes(state)) {
this.#log.innerHTML = "";
}
if (state === "NOT_CONNECTED") this.#addToLog("server", "Click 'Find Stranger' to connect with a random person!");
if (state === "CONNECTING") this.#addToLog("server", "Finding a stranger for you to chat with...");
if (state === "CONNECTED") this.#addToLog("server", "You're talking to a random person. Say hi!");
if (state === "DISCONNECTED_LOCAL") this.#addToLog("server", "You disconnected");
if (state === "DISCONNECTED_REMOTE") this.#addToLog("server", "Stranger disconnected");
}
addRemoteMessage = (message) => this.#addToLog("remote", message)
#addToLog(owner, message) {
this.#log.insertAdjacentHTML("beforeend", `<div class="message ${owner}">${message}</div>`);
this.#log.scrollTop = this.#log.scrollHeight;
}
}
大多数代码仅用于更新 UI,主要是因为对等连接可以处于不同的连接状态。#在 JavaScript 类中使用访问修饰符,类似于private在 Java/Kotlin 中。
该类公开了两个方法,updateUi和addRemoteMessage,它们用于中定义的对等连接的回调app.js。同样,这不是一个超级干净的解决方案,因此如果您想将此应用程序构建为一个真正的项目,应该将它们附加到对等连接上,而不是公开。
Peer-connection.js
这是目前为止项目中最复杂的文件,所以我们先来简单介绍一下WebRTC 和 SDP。
WebRTC 连接建立的方式是通过在两个用户(或“对等方”)之间交换 SDP 消息。这些 SDP 消息通常通过 WebSocket 连接进行交换(就像我们的应用一样),但您也可以使用 REST 或其他任何协议。这些消息的交换方式不属于 WebRTC 规范。SDP 交换完成后,媒体流将直接在对等方之间发送(点对点)。
要建立连接,一方必须发送“offer” SDP 消息,另一方必须发送“answer” SDP 消息。在我们的应用中,由“GO_FIRST”指令决定谁发送 offer,谁发送 answer,该指令由后端在双方配对成功后发送。决定谁先连接并非 WebRTC 规范的一部分,双方用户只需以某种方式达成一致即可。
对于“提议者”(先出场的用户)和“应答者”(后出场的用户),SDP 交换的逻辑是不同的。让我们分别来了解一下这两种情况。
“要约人”的流程如下:
- 与后端建立 WebSocket 连接。
- 使用“GO_FIRST”指令接收“PARTNER_FOUND”。
- 创建对等连接、数据通道和报价。
- 设置对等连接的本地描述。
- 将报价 SDP 消息发送到后端。
- 等待后端的应答SDP消息,并将其设置为远程描述。
- 将 ICE 候选生成后发送到后端。
- 从后端接收对等ICE 候选并将其添加到连接中。
- 连接将根据 ICE 候选集建立,之后即可发送和接收视频和音频流。ondatachannel由于数据通道已在步骤 3 中创建,因此不会触发任何事件。
“应答者”的流程如下:
- 与后端建立 WebSocket 连接。
- 接收“PARTNER_FOUND”(不带“GO_FIRST”指令)。
- 等待后端提供的SDP消息。
- 创建对等连接并将远程描述设置为提供 SDP 消息。
- 创建一个答案 并将本地描述设置为该答案。
- 将答案SDP消息发送到后端。
- 将 ICE 候选生成后发送到后端。
- 从后端接收对等ICE 候选并将其添加到连接中。
- 连接将根据 ICE 候选对象建立,之后即可发送和接收视频和音频流。ondatachannel连接建立时还会触发一个事件,该事件与步骤 3 中为“提供者”创建的数据通道相同。
流程类似,但“提议者”必须创建数据通道并发送“提议”,而“应答者”必须等待“提议”并发送“应答”。“提议者”和“应答者”都会交换 ICE 候选。“提议者”和“应答者”获取数据通道的时间不同,但数据通道是同一个。
好了,现在终于可以看看代码了:
export class PeerConnection {
sdpExchange; // WebSocket with listeners for exchanging SDP offers and answers
peerConnection; // RTCPeerConnection for exchanging media (with listeners for media and ICE)
dataChannel; // RTCDataChannel for exchanging signaling and chat messages (with listeners)
state; // NOT_CONNECTED, CONNECTING, CONNECTED, DISCONNECTED_SELF, DISCONNECTED_REMOTE
options; // constructor args {onStateChange, onLocalMedia, onRemoteMedia, onChatMessage}
localStream; // MediaStream from local webcam and microphone
constructor(options) {
this.options = options;
this.init();
}
async init() { // needs to be separate from constructor because of async
try {
this.localStream = await navigator.mediaDevices.getUserMedia({video: true, audio: true});
this.options.onLocalMedia(this.localStream);
} catch (error) {
alert("Failed to enable webcam and/or microphone, please reload the page and try again");
}
this.setState("NOT_CONNECTED");
this.peerConnection = this.createPeerConnection();
this.sdpExchange = this.createSdpExchange();
}
createSdpExchange() { // WebSocket with listeners for exchanging SDP offers and answers
let ws = new WebSocket(`ws://${window.location.host}/api/matchmaking`);
ws.addEventListener("message", (event) => {
const message = JSON.parse(event.data);
console.log("Received WebSocket message", message.name)
if (message.name === "PARTNER_FOUND") this.handlePartnerFound(message.data);
if (message.name === "SDP_OFFER") this.handleSdpOffer(JSON.parse(message.data));
if (message.name === "SDP_ANSWER") this.handleSdpAnswer(JSON.parse(message.data));
if (message.name === "SDP_ICE_CANDIDATE") this.handleIceCandidate(JSON.parse(message.data));
});
ws.addEventListener("close", async () => {
while (this.sdpExchange.readyState === WebSocket.CLOSED) {
console.log("WebSocket closed, reconnecting in 1 second");
await new Promise(resolve => setTimeout(resolve, 1000));
this.sdpExchange = this.createSdpExchange();
}
});
return ws;
}
createPeerConnection() { // RTCPeerConnection for exchanging media (with listeners for media and ICE)
let conn = new RTCPeerConnection();
conn.ontrack = event => {
console.log(`Received ${event.track.kind} track`);
this.options.onRemoteMedia(event.streams[0])
};
conn.onicecandidate = event => {
if (event.candidate === null) { // candidate gathering complete
console.log("ICE candidate gathering complete");
return this.sdpExchange.send(JSON.stringify({name: "PAIRING_DONE"}));
}
console.log("ICE candidate created, sending to partner");
let candidate = JSON.stringify(event.candidate);
this.sdpExchange.send(JSON.stringify({name: "SDP_ICE_CANDIDATE", data: candidate}))
};
conn.oniceconnectionstatechange = () => {
if (conn.iceConnectionState === "connected") {
this.setState("CONNECTED");
// ice candidates can still be added after "connected" state, so we need to log this with a delay
setTimeout(() => console.log("WebRTC connection established"), 500);
}
};
conn.ondatachannel = event => { // only for the "answerer" (the one who receives the SDP offer)
console.log("Received data channel from offerer");
this.dataChannel = this.setupDataChannel(event.channel)
};
return conn;
}
setupDataChannel(channel) { // RTCDataChannel for exchanging signaling and chat messages
channel.onmessage = event => {
console.log("Received data channel message", event.data);
if (event.data === "BYE") {
this.disconnect("REMOTE");
return console.log("Received BYE message, closing connection");
}
this.options.onChatMessage(JSON.parse(event.data).chat);
}
return channel;
}
sendBye() {
if (this.dataChannel === null) return console.log("No data channel");
this.dataChannel.send("BYE");
this.disconnect("LOCAL");
}
disconnect(orignator) {
this.dataChannel = null;
this.peerConnection.close();
this.peerConnection = this.createPeerConnection();
this.setState(`DISCONNECTED_${orignator}`);
}
setState(state) {
this.state = state;
this.options.onStateChange(state);
}
handlePartnerFound(instructions) {
if (instructions !== "GO_FIRST") {
return console.log("Partner found, waiting for SDP offer ..."); // only for the "answerer" (the one who receives the SDP offer)
}
console.log("Partner found, creating SDP offer and data channel");
this.tryHandle("PARTNER_FOUND", async () => { // only for the "offerer" (the one who sends the SDP offer)
this.dataChannel = this.setupDataChannel(this.peerConnection.createDataChannel("data-channel"));
this.localStream.getTracks().forEach(track => this.peerConnection.addTrack(track, this.localStream));
const offer = await this.peerConnection.createOffer();
await this.peerConnection.setLocalDescription(offer);
let offerJson = JSON.stringify(this.peerConnection.localDescription);
this.sdpExchange.send(JSON.stringify({name: "SDP_OFFER", data: offerJson}))
});
}
handleSdpOffer(offer) { // only for the "answerer" (the one who receives the SDP offer)
this.tryHandle("SDP_OFFER", async () => {
console.log("Received SDP offer, creating SDP answer")
await this.peerConnection.setRemoteDescription(new RTCSessionDescription(offer));
this.localStream.getTracks().forEach(track => this.peerConnection.addTrack(track, this.localStream));
const answer = await this.peerConnection.createAnswer();
await this.peerConnection.setLocalDescription(answer);
let answerJson = JSON.stringify(this.peerConnection.localDescription);
this.sdpExchange.send(JSON.stringify({name: "SDP_ANSWER", data: answerJson}))
});
}
handleSdpAnswer(answer) { // only for the "offerer" (the one who sends the SDP offer)
this.tryHandle("SDP_ANSWER", async () => {
await this.peerConnection.setRemoteDescription(new RTCSessionDescription(answer));
});
}
handleIceCandidate(iceCandidate) {
this.tryHandle("ICE_CANDIDATE", async () => {
await this.peerConnection.addIceCandidate(new RTCIceCandidate(iceCandidate));
});
}
tryHandle(command, callback) {
try {
callback()
} catch (error) {
console.error(`Failed to handle ${command}`, error);
}
}
}
这个类封装了所有 WebRTC 逻辑,并通过其构造函数公开回调。希望这能让一切变得简单(或者至少更容易?)。最困难的部分可能是跟踪“提供者”和“应答者”的不同代码路径。为了帮助解决这个问题,代码中包含了注释,指出哪些部分由“提供者”或“应答者”专门调用。
造型
CSS 非常简单。它使用 CSS 网格来定位和调整元素大小,并使用一些 CSS 变量来设置间距和边框半径。它还使用 body 上设置的状态来有条件地显示和隐藏元素。例如,当前 3 个按钮中只有 1 个按钮显示:
[data-state=NOT_CONNECTED] button#startPairing, /* start button */
[data-state=DISCONNECTED_LOCAL] button#startPairing, /* start button */
[data-state=DISCONNECTED_REMOTE] button#startPairing, /* start button */
[data-state=CONNECTING] button#abortPairing, /* abort button */
[data-state=CONNECTED] button#leavePairing { /* leave button */
display: block;
}
完整的 CSS 如下所示:
:root {
--spacing: 12px;
--border-radius-large: 8px;
--border-radius-small: 4px;
}
* {
box-sizing: border-box;
}
body { /* wraps header and main */
display: grid;
grid-template-rows: auto 1fr;
grid-row-gap: var(--spacing);
height: 100vh;
padding: var(--spacing);
margin: 0;
font-family: 'Roboto', sans-serif;
}
header img {
display: block;
margin: 4px auto;
max-height: 40px;
}
main { /* wraps video-panel and chat-panel */
display: grid;
grid-template-columns: 4fr 7fr; /* video panel is 4/11 of the width, chat panel is 7/11 */
grid-column-gap: var(--spacing);
overflow: auto;
}
.video-panel {
display: grid;
grid-template-rows: 1fr 1fr;
grid-row-gap: var(--spacing);
overflow: auto;
& section {
overflow: auto;
display: flex;
justify-content: center;
align-items: center;
background: #f2f2f2;
border-radius: var(--border-radius-large);
& video {
width: 100%;
height: 100%;
object-fit: cover;
}
&.local video {
transform: scaleX(-1);
}
}
}
.chat-panel {
display: grid;
grid-template-rows: 1fr auto;
grid-row-gap: var(--spacing);
padding: var(--spacing);
background: #f2f2f2;
border-radius: var(--border-radius-large);
.chat-log {
padding: var(--spacing);
background: #fff;
overflow-y: scroll;
border-radius: var(--border-radius-small);
line-height: 1.4;
.message.local::before {
font-weight: bold;
color: blue;
content: "You: ";
}
.message.remote::before {
font-weight: bold;
color: red;
content: "Stranger: ";
}
.message.server {
color: #999;
font-style: italic;
}
}
.chat-controls {
display: flex;
.messaging {
display: flex;
flex-grow: 1;
& input {
margin: 0 16px;
width: 100%;
height: 40px;
padding: 16px;
border: 0;
border-radius: var(--border-radius-small);
font-size: 16px;
&:focus {
outline: none;
}
}
}
.pairing button {
width: 120px;
}
}
}
button {
background: #1e88e5;
color: #fff;
border: 0;
border-radius: var(--border-radius-small);
height: 40px;
line-height: 1;
padding: 0 16px;
cursor: pointer;
&:hover {
background: #1976d2;
}
}
/* Conditional styles for buttons */
.chat-controls .pairing button {
display: none;
}
[data-state=NOT_CONNECTED] button#startPairing,
[data-state=DISCONNECTED_LOCAL] button#startPairing,
[data-state=DISCONNECTED_REMOTE] button#startPairing,
[data-state=CONNECTING] button#abortPairing,
[data-state=CONNECTED] button#leavePairing {
display: block;
}
/* Conditional styles for spinner */
.spinner {
display: none;
}
[data-state=CONNECTING] .spinner {
display: block;
}
/* Conditional styles for remote video */
body:not([data-state=CONNECTED]) .remote video {
display: none;
}
/* Conditional styles for chat */
body:not([data-state=CONNECTED]) .chat-controls .messaging {
filter: grayscale(1);
opacity: 0.6;
pointer-events: none;
}
/* Mobile layout */
@media (max-width: 768px) {
main { /* put video panel on top of chat panel */
grid-template-columns: none;
grid-template-rows: 1fr 2fr;
grid-row-gap: var(--spacing);
}
.video-panel { /* put video side by side */
grid-template-rows: none;
grid-template-columns: 1fr 1fr;
grid-column-gap: var(--spacing);
}
}
结论
就这样!我们用 Javalin、WebRTC 和纯 JavaScript 构建了一个简单的 Omegle 克隆版本。完整代码可在 GitHub 上找到(链接如下),使用 Maven 进行配置。如果您一直在学习教程并进行复制粘贴,那么您缺少一个网站图标和一个 SVG 旋转按钮,这两个功能您也可以在 GitHub 上找到。
最后编辑:Jeebiz 更新时间:2025-05-04 00:55