使用 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-03 23:46
最后编辑:Jeebiz  更新时间:2025-05-04 00:55