diff --git a/config/index.ts b/config/index.ts
index cb3f09b..693d9e5 100644
--- a/config/index.ts
+++ b/config/index.ts
@@ -10,7 +10,7 @@ const environment: Environment = {
const srsUrl = process.env.SRS_URL;
function verifyRequiredVariables(): void {
- const requiredVariables = ["HOST", "PORT", "SRS_URL"];
+ const requiredVariables = ["HOST", "PORT", "SRS_URL", "REDIS_URL"];
let hasError = false;
diff --git a/public/chat.html b/public/chat.html
new file mode 100644
index 0000000..6792ed9
--- /dev/null
+++ b/public/chat.html
@@ -0,0 +1,79 @@
+
+
+
+
+
+
+ Chat
+
+
+
+
+
+
+
+
+
+
+
diff --git a/public/css/chat.css b/public/css/chat.css
new file mode 100644
index 0000000..9625b54
--- /dev/null
+++ b/public/css/chat.css
@@ -0,0 +1,453 @@
+* {
+ box-sizing: border-box;
+}
+
+body {
+ margin: 0;
+ padding: 0;
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
+ background: var(--bg-primary);
+ color: var(--text-primary);
+ height: 100vh;
+ overflow: hidden;
+}
+
+.chat-widget {
+ height: 100vh;
+ display: flex;
+ flex-direction: column;
+ background: var(--bg-card);
+ border: 1px solid var(--border-primary);
+}
+
+.chat-header {
+ padding: 0.5rem;
+ border-bottom: 1px solid var(--border-primary);
+ background: var(--bg-secondary);
+ flex-shrink: 0;
+}
+
+.username-section {
+ display: flex;
+ gap: 0.5rem;
+ align-items: center;
+}
+
+.username-section .username-input {
+ flex: 1;
+ padding: 0.375rem;
+ font-size: 0.75rem;
+ border: 1px solid var(--border-primary);
+ border-radius: 0.25rem;
+ background: var(--bg-input);
+ color: var(--text-primary);
+}
+
+.username-section .username-input:focus {
+ outline: none;
+ border-color: var(--accent-blue);
+}
+
+.username-section .username-input::placeholder {
+ color: var(--text-secondary);
+}
+
+.chat-messages {
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ overflow: hidden;
+ min-height: 0;
+}
+
+.loading-state,
+.error-state,
+.empty-state {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ padding: 1rem;
+ flex: 1;
+}
+
+.loading-state p,
+.error-state p,
+.empty-state p {
+ margin: 0.25rem 0 0 0;
+ font-size: 0.75rem;
+ color: var(--text-secondary);
+}
+
+.error-state p {
+ color: var(--accent-red);
+}
+
+.retry-btn {
+ background: var(--accent-blue);
+ color: white;
+ border: none;
+ padding: 0.375rem 0.75rem;
+ border-radius: 0.25rem;
+ font-size: 0.75rem;
+ cursor: pointer;
+ margin-top: 0.375rem;
+}
+
+.retry-btn:hover {
+ background: #2563eb;
+}
+
+.messages-container {
+ flex: 1;
+ overflow-y: auto;
+ padding: 0.5rem;
+ display: flex;
+ flex-direction: column;
+ gap: 0.375rem;
+}
+
+.message {
+ padding: 0.375rem 0.5rem;
+ background: var(--bg-secondary);
+ border: 1px solid var(--border-primary);
+ border-radius: 0.375rem;
+ font-size: 0.75rem;
+ animation: messageSlideIn 0.2s ease-out;
+}
+
+.message.system-message {
+ background: var(--bg-primary);
+ opacity: 0.7;
+ text-align: center;
+ font-style: italic;
+ padding: 0.25rem 0.5rem;
+}
+
+.message-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin-bottom: 0.125rem;
+}
+
+.message-username {
+ font-weight: 600;
+ color: var(--accent-blue);
+ font-size: 0.6875rem;
+}
+
+.message-time {
+ font-size: 0.625rem;
+ color: var(--text-muted);
+}
+
+.message-content {
+ color: var(--text-primary);
+ word-wrap: break-word;
+ line-height: 1.2;
+ font-size: 0.6875rem;
+}
+
+.chat-input-form {
+ padding: 0.5rem;
+ border-top: 1px solid var(--border-primary);
+ background: var(--bg-secondary);
+ flex-shrink: 0;
+}
+
+.message-row {
+ display: flex;
+ gap: 0.375rem;
+ align-items: center;
+}
+
+.message-input {
+ flex: 1;
+ padding: 0.375rem;
+ font-size: 0.75rem;
+ border: 1px solid var(--border-primary);
+ border-radius: 0.25rem;
+ background: var(--bg-input);
+ color: var(--text-primary);
+}
+
+.message-input:focus {
+ outline: none;
+ border-color: var(--accent-blue);
+}
+
+.message-input::placeholder {
+ color: var(--text-secondary);
+}
+
+.send-btn {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 32px;
+ height: 32px;
+ background: var(--accent-blue);
+ border: none;
+ border-radius: 0.25rem;
+ color: white;
+ cursor: pointer;
+ flex-shrink: 0;
+}
+
+.send-btn:hover:not(:disabled) {
+ background: #2563eb;
+}
+
+.send-btn:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+}
+
+.send-btn svg {
+ width: 14px;
+ height: 14px;
+}
+
+@keyframes messageSlideIn {
+ from {
+ opacity: 0;
+ transform: translateY(3px);
+ }
+ to {
+ opacity: 1;
+ transform: translateY(0);
+ }
+}
+
+.messages-container::-webkit-scrollbar {
+ width: 4px;
+}
+
+.messages-container::-webkit-scrollbar-track {
+ background: var(--bg-primary);
+}
+
+.messages-container::-webkit-scrollbar-thumb {
+ background: var(--border-secondary);
+ border-radius: 2px;
+}
+
+.messages-container::-webkit-scrollbar-thumb:hover {
+ background: var(--border-accent);
+}
+
+.settings-container {
+ position: relative;
+ display: inline-block;
+}
+
+.settings-btn {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 32px;
+ height: 32px;
+ background: none;
+ border: none;
+ color: var(--text-secondary);
+ cursor: pointer;
+ border-radius: 0.25rem;
+ transition: all 0.2s ease;
+ flex-shrink: 0;
+}
+
+.settings-btn:hover {
+ color: var(--text-primary);
+ background: var(--bg-primary);
+}
+
+.settings-btn svg {
+ width: 14px;
+ height: 14px;
+}
+
+.settings-dropdown {
+ position: absolute;
+ top: 100%;
+ right: 0;
+ background: var(--bg-card);
+ border: 1px solid var(--border-primary);
+ border-radius: 0.375rem;
+ padding: 0.5rem;
+ min-width: 160px;
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
+ z-index: 1000;
+ display: none;
+ margin-top: 0.375rem;
+}
+
+.settings-dropdown h4 {
+ margin: 0 0 0.5rem 0;
+ color: var(--text-primary);
+ font-size: 0.75rem;
+ font-weight: 600;
+}
+
+.setting-item {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin-bottom: 0.375rem;
+ padding: 0.125rem 0;
+}
+
+.setting-item:last-child {
+ margin-bottom: 0;
+}
+
+.setting-label {
+ color: var(--text-secondary);
+ font-size: 0.6875rem;
+}
+
+.setting-toggle {
+ position: relative;
+ width: 32px;
+ height: 16px;
+}
+
+.setting-toggle input {
+ opacity: 0;
+ width: 0;
+ height: 0;
+}
+
+.toggle-slider {
+ position: absolute;
+ cursor: pointer;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ background-color: var(--border-secondary);
+ transition: 0.3s;
+ border-radius: 16px;
+}
+
+.toggle-slider:before {
+ position: absolute;
+ content: "";
+ height: 12px;
+ width: 12px;
+ left: 2px;
+ bottom: 2px;
+ background-color: var(--text-primary);
+ transition: 0.3s;
+ border-radius: 50%;
+}
+
+input:checked + .toggle-slider {
+ background-color: var(--accent-blue);
+}
+
+input:checked + .toggle-slider:before {
+ transform: translateX(16px);
+}
+
+.message-image-container {
+ margin: 0.25rem 0;
+}
+
+.message-image {
+ max-width: 200px !important;
+ max-height: 120px !important;
+ border-radius: 4px !important;
+ margin: 0.25rem 0 !important;
+ display: block !important;
+}
+
+@media (max-width: 400px) {
+ .chat-header {
+ padding: 0.375rem;
+ }
+
+ .username-section {
+ gap: 0.375rem;
+ }
+
+ .username-section .username-input {
+ font-size: 0.6875rem;
+ padding: 0.25rem;
+ }
+
+ .settings-btn {
+ width: 28px;
+ height: 28px;
+ }
+
+ .settings-btn svg {
+ width: 12px;
+ height: 12px;
+ }
+
+ .message-input {
+ font-size: 0.6875rem;
+ padding: 0.25rem;
+ }
+
+ .send-btn {
+ width: 28px;
+ height: 28px;
+ }
+
+ .send-btn svg {
+ width: 12px;
+ height: 12px;
+ }
+
+ .message {
+ padding: 0.25rem 0.375rem;
+ }
+
+ .message-content,
+ .message-username {
+ font-size: 0.625rem;
+ }
+
+ .message-time {
+ font-size: 0.5625rem;
+ }
+
+ .messages-container {
+ padding: 0.375rem;
+ }
+
+ .chat-input-form {
+ padding: 0.375rem;
+ }
+
+ .message-row {
+ gap: 0.25rem;
+ }
+}
+
+@media (max-width: 280px) {
+ .username-section .username-input {
+ min-width: 0;
+ font-size: 0.625rem;
+ }
+
+ .settings-btn,
+ .send-btn {
+ width: 24px;
+ height: 24px;
+ min-width: 24px;
+ }
+
+ .settings-btn svg,
+ .send-btn svg {
+ width: 10px;
+ height: 10px;
+ }
+
+ .message-input {
+ font-size: 0.625rem;
+ min-width: 0;
+ }
+}
diff --git a/public/css/moveableChat.css b/public/css/moveableChat.css
new file mode 100644
index 0000000..49569f9
--- /dev/null
+++ b/public/css/moveableChat.css
@@ -0,0 +1,210 @@
+.moveable-chat-frame {
+ position: fixed;
+ top: 20%;
+ right: 20px;
+ width: 350px;
+ height: 500px;
+ background: var(--bg-card);
+ border-radius: 12px 12px 0 0;
+ box-shadow: var(--shadow-xl);
+ backdrop-filter: blur(20px);
+ z-index: 1000;
+ display: flex;
+ flex-direction: column;
+ overflow: hidden;
+ min-width: 280px;
+ min-height: 200px;
+ max-width: 90vw;
+ max-height: 90vh;
+ transition: all 0.3s ease;
+}
+
+.moveable-chat-frame.minimized {
+ height: 44px;
+ resize: none;
+}
+
+.moveable-chat-frame.minimized .chat-frame-content {
+ display: none;
+}
+
+.moveable-chat-frame.minimized .resize-handle {
+ display: none;
+}
+
+.chat-frame-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding: 12px 16px;
+ background: var(--bg-secondary);
+ border: 1px solid var(--border-primary);
+ border-bottom: none;
+ cursor: move;
+ user-select: none;
+ border-radius: 12px 12px 0 0;
+ flex-shrink: 0;
+}
+
+.chat-frame-title {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ color: var(--text-primary);
+ font-weight: 600;
+ font-size: 14px;
+}
+
+.chat-frame-title svg {
+ color: var(--accent-blue);
+}
+
+.chat-frame-controls {
+ display: flex;
+ gap: 4px;
+}
+
+.chat-control-btn {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 24px;
+ height: 24px;
+ background: none;
+ border: none;
+ border-radius: 4px;
+ color: var(--text-secondary);
+ cursor: pointer;
+ transition: all 0.2s ease;
+}
+
+.chat-control-btn:hover {
+ background: var(--bg-primary);
+ color: var(--text-primary);
+}
+
+.chat-frame-content {
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ overflow: hidden;
+ border: 1px solid var(--border-primary);
+ border-top: none;
+ border-radius: 0;
+}
+
+.chat-frame-iframe {
+ width: 100%;
+ height: 100%;
+ border: none;
+ background: var(--bg-card);
+}
+
+.resize-handle {
+ position: absolute;
+ background: transparent;
+ z-index: 10;
+}
+
+.resize-handle-n,
+.resize-handle-s {
+ left: 0;
+ right: 0;
+ height: 4px;
+ cursor: ns-resize;
+}
+
+.resize-handle-n {
+ top: 0;
+}
+.resize-handle-s {
+ bottom: 0;
+}
+
+.resize-handle-e,
+.resize-handle-w {
+ top: 0;
+ bottom: 0;
+ width: 4px;
+ cursor: ew-resize;
+}
+
+.resize-handle-e {
+ right: 0;
+}
+.resize-handle-w {
+ left: 0;
+}
+
+.resize-handle-ne,
+.resize-handle-nw,
+.resize-handle-se,
+.resize-handle-sw {
+ width: 8px;
+ height: 8px;
+}
+
+.resize-handle-ne {
+ top: 0;
+ right: 0;
+ cursor: ne-resize;
+}
+
+.resize-handle-nw {
+ top: 0;
+ left: 0;
+ cursor: nw-resize;
+}
+
+.resize-handle-se {
+ bottom: 0;
+ right: 0;
+ cursor: se-resize;
+}
+
+.resize-handle-sw {
+ bottom: 0;
+ left: 0;
+ cursor: sw-resize;
+}
+
+.chat-open #toggleChatBtn {
+ background: var(--accent-blue);
+ color: white;
+}
+
+@media (max-width: 768px) {
+ .moveable-chat-frame {
+ width: calc(100vw - 20px);
+ height: 60vh;
+ right: 10px;
+ left: 10px;
+ top: auto;
+ bottom: 10px;
+ border-radius: 12px 12px 0 0;
+ }
+
+ .moveable-chat-frame.minimized {
+ height: 44px;
+ bottom: 10px;
+ }
+
+ .resize-handle {
+ display: none;
+ }
+}
+
+.chat-frame-loading {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ flex: 1;
+ color: var(--text-secondary);
+ font-size: 14px;
+}
+
+.chat-frame-loading .spinner {
+ width: 20px;
+ height: 20px;
+ margin-right: 8px;
+}
diff --git a/public/css/style.css b/public/css/style.css
index 993233a..9c60f3a 100644
--- a/public/css/style.css
+++ b/public/css/style.css
@@ -22,12 +22,14 @@
--accent-red: #f85149;
--shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.3);
- --shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.4), 0 2px 4px -1px rgba(0, 0, 0, 0.3);
- --shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.4), 0 4px 6px -2px rgba(0, 0, 0, 0.3);
- --shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.4), 0 10px 10px -5px rgba(0, 0, 0, 0.3);
+ --shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.4), 0 2px 4px -1px
+ rgba(0, 0, 0, 0.3);
+ --shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.4), 0 4px 6px -2px
+ rgba(0, 0, 0, 0.3);
+ --shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.4), 0 10px 10px -5px
+ rgba(0, 0, 0, 0.3);
}
-
* {
box-sizing: border-box;
margin: 0;
diff --git a/public/js/chat.js b/public/js/chat.js
new file mode 100644
index 0000000..0b28101
--- /dev/null
+++ b/public/js/chat.js
@@ -0,0 +1,480 @@
+class ChatManager {
+ constructor() {
+ this.chatId = this.getChatId();
+ this.messages = [];
+ this.websocket = null;
+ this.isDestroyed = false;
+ this.isConnected = false;
+ this.reconnectAttempts = 0;
+ this.maxReconnectAttempts = 5;
+ this.hasJoinedChat = false;
+ this.hasLoadedHistory = false;
+
+ this.settings = {
+ showImages: localStorage.getItem("chat-show-images") !== "false",
+ use24hTime: localStorage.getItem("chat-24h-time") !== "false",
+ };
+
+ this.initializeElements();
+ this.bindEvents();
+ this.loadStoredUsername();
+ this.connectWebSocket();
+ }
+
+ getChatId() {
+ const metaTag = document.querySelector('meta[name="chat-id"]');
+ if (!metaTag) {
+ throw new Error("Chat ID not found in meta tags");
+ }
+ return metaTag.content;
+ }
+
+ initializeElements() {
+ this.elements = {
+ loadingState: document.getElementById("loadingState"),
+ errorState: document.getElementById("errorState"),
+ errorMessage: document.getElementById("errorMessage"),
+ retryBtn: document.getElementById("retryBtn"),
+ messagesContainer: document.getElementById("messagesContainer"),
+ emptyState: document.getElementById("emptyState"),
+ chatForm: document.getElementById("chatForm"),
+ usernameInput: document.getElementById("usernameInput"),
+ messageInput: document.getElementById("messageInput"),
+ sendBtn: document.getElementById("sendBtn"),
+ settingsBtn: document.getElementById("settingsBtn"),
+ settingsDropdown: document.getElementById("settingsDropdown"),
+ imageToggle: document.getElementById("imageToggle"),
+ timeToggle: document.getElementById("timeToggle"),
+ };
+
+ this.elements.imageToggle.checked = this.settings.showImages;
+ this.elements.timeToggle.checked = this.settings.use24hTime;
+ }
+
+ bindEvents() {
+ this.elements.chatForm.addEventListener("submit", (e) => {
+ e.preventDefault();
+ this.sendMessage();
+ });
+
+ this.elements.retryBtn.addEventListener("click", () => {
+ this.connectWebSocket();
+ });
+
+ this.elements.usernameInput.addEventListener("input", () => {
+ const username = this.elements.usernameInput.value.trim();
+ if (username) {
+ localStorage.setItem("chat-username", username);
+ }
+ this.validateForm();
+ });
+
+ this.elements.messageInput.addEventListener("input", () => {
+ this.validateForm();
+ });
+
+ this.elements.settingsBtn.addEventListener("click", () => {
+ this.toggleSettingsDropdown();
+ });
+
+ this.elements.imageToggle.addEventListener("change", () => {
+ this.settings.showImages = this.elements.imageToggle.checked;
+ localStorage.setItem("chat-show-images", this.settings.showImages);
+ this.renderMessages();
+ });
+
+ this.elements.timeToggle.addEventListener("change", () => {
+ this.settings.use24hTime = this.elements.timeToggle.checked;
+ localStorage.setItem("chat-24h-time", this.settings.use24hTime);
+ this.renderMessages();
+ });
+
+ document.addEventListener("click", (e) => {
+ if (!e.target.closest(".settings-container")) {
+ this.elements.settingsDropdown.style.display = "none";
+ }
+ });
+
+ document.addEventListener("visibilitychange", () => {
+ if (!document.hidden && !this.isConnected) {
+ this.connectWebSocket();
+ }
+ });
+
+ window.addEventListener("beforeunload", () => {
+ this.cleanup();
+ });
+
+ this.elements.messageInput.addEventListener("keydown", (e) => {
+ if (e.key === "Enter") {
+ e.preventDefault();
+ this.sendMessage();
+ }
+ });
+ }
+
+ loadStoredUsername() {
+ const storedUsername = localStorage.getItem("chat-username");
+ if (storedUsername) {
+ this.elements.usernameInput.value = storedUsername;
+ }
+ this.validateForm();
+ }
+
+ validateForm() {
+ const username = this.elements.usernameInput.value.trim();
+ const message = this.elements.messageInput.value.trim();
+ const isValid = username.length > 0 && message.length > 0;
+
+ this.elements.sendBtn.disabled = !isValid;
+ }
+
+ connectWebSocket() {
+ if (this.websocket) {
+ this.websocket.close();
+ }
+
+ this.showLoading();
+
+ const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
+ const wsUrl = `${protocol}//${window.location.host}`;
+
+ try {
+ this.websocket = new WebSocket(wsUrl);
+
+ this.websocket.onopen = () => {
+ console.log("WebSocket connected");
+ this.isConnected = true;
+ this.reconnectAttempts = 0;
+ this.hasLoadedHistory = false;
+
+ this.loadChatHistory();
+ };
+
+ this.websocket.onmessage = (event) => {
+ try {
+ const message = JSON.parse(event.data);
+ this.handleWebSocketMessage(message);
+ } catch (error) {
+ console.error("Error parsing WebSocket message:", error);
+ }
+ };
+
+ this.websocket.onerror = (error) => {
+ console.error("WebSocket error:", error);
+ this.isConnected = false;
+ };
+
+ this.websocket.onclose = (event) => {
+ console.log("WebSocket closed:", event.code, event.reason);
+ this.isConnected = false;
+
+ if (
+ !this.isDestroyed &&
+ this.reconnectAttempts < this.maxReconnectAttempts
+ ) {
+ this.reconnectAttempts++;
+ console.log(
+ `Attempting to reconnect... (${this.reconnectAttempts}/${this.maxReconnectAttempts})`,
+ );
+ setTimeout(
+ () => {
+ if (!this.isDestroyed) {
+ this.connectWebSocket();
+ }
+ },
+ Math.min(1000 * this.reconnectAttempts, 5000),
+ );
+ } else if (!this.isDestroyed) {
+ this.showError("Connection lost. Please refresh the page.");
+ }
+ };
+ } catch (error) {
+ console.error("Failed to create WebSocket:", error);
+ this.showError("Failed to connect to chat server");
+ }
+ }
+
+ loadChatHistory() {
+ if (
+ this.websocket &&
+ this.websocket.readyState === WebSocket.OPEN &&
+ !this.hasLoadedHistory
+ ) {
+ this.websocket.send(
+ JSON.stringify({
+ type: "load_history",
+ streamId: this.chatId,
+ }),
+ );
+ this.hasLoadedHistory = true;
+ }
+ }
+
+ handleWebSocketMessage(message) {
+ switch (message.type) {
+ case "connected":
+ console.log("Connected to chat server");
+ this.loadChatHistory();
+ break;
+
+ case "chat_history":
+ this.messages = message.data.messages || [];
+ this.renderMessages();
+ this.showMessages();
+ this.scrollToBottom();
+ break;
+
+ case "chat_message":
+ this.messages.push(message.data);
+ this.renderMessages();
+ this.scrollToBottom();
+ break;
+
+ case "user_joined":
+ this.showSystemMessage(`${message.data.username} joined`);
+ break;
+
+ case "user_left":
+ this.showSystemMessage(`${message.data.username} left`);
+ break;
+
+ case "error":
+ console.error("WebSocket error:", message.error);
+ this.showError(message.error);
+ break;
+
+ default:
+ console.log("Unknown message type:", message.type);
+ }
+ }
+
+ joinChat(username) {
+ if (this.websocket && this.websocket.readyState === WebSocket.OPEN) {
+ this.websocket.send(
+ JSON.stringify({
+ type: "join_chat",
+ streamId: this.chatId,
+ username: username,
+ }),
+ );
+ }
+ }
+
+ sendMessage() {
+ const username = this.elements.usernameInput.value.trim();
+ const message = this.elements.messageInput.value.trim();
+
+ if (!username || !message) {
+ return;
+ }
+
+ if (!this.websocket || this.websocket.readyState !== WebSocket.OPEN) {
+ this.showError("Not connected to chat server");
+ return;
+ }
+
+ if (!this.hasJoinedChat) {
+ this.joinChat(username);
+ this.hasJoinedChat = true;
+ }
+
+ this.elements.sendBtn.disabled = true;
+ this.elements.messageInput.disabled = true;
+
+ try {
+ this.websocket.send(
+ JSON.stringify({
+ type: "send_message",
+ message: message,
+ }),
+ );
+
+ this.elements.messageInput.value = "";
+
+ localStorage.setItem("chat-username", username);
+ } catch (error) {
+ console.error("Failed to send message:", error);
+ } finally {
+ this.elements.messageInput.disabled = false;
+ this.validateForm();
+ this.elements.messageInput.focus();
+ }
+ }
+
+ toggleSettingsDropdown() {
+ const dropdown = this.elements.settingsDropdown;
+ dropdown.style.display =
+ dropdown.style.display === "block" ? "none" : "block";
+ }
+
+ processMessageContent(text) {
+ if (!this.settings.showImages) {
+ return this.escapeHtml(text);
+ }
+
+ const imageUrlRegex =
+ /https:\/\/[^\s]+\.(?:jpg|jpeg|png|gif|webp|svg)(?:\?[^\s]*)?/gi;
+
+ const parts = text.split(imageUrlRegex);
+ const imageUrls = text.match(imageUrlRegex) || [];
+
+ let processedContent = "";
+
+ for (let i = 0; i < parts.length; i++) {
+ if (parts[i]) {
+ processedContent += this.escapeHtml(parts[i]);
+ }
+
+ if (imageUrls[i]) {
+ processedContent += this.createImageElement(imageUrls[i]);
+ }
+ }
+
+ return processedContent;
+ }
+
+ createImageElement(url) {
+ const imageId = `img_${Math.random().toString(36).substr(2, 9)}`;
+ return `
+
+
})
+
+ `;
+ }
+
+ renderMessages() {
+ this.elements.messagesContainer.innerHTML = "";
+
+ if (this.messages.length === 0) {
+ this.showEmpty();
+ return;
+ }
+
+ for (const message of this.messages) {
+ const messageElement = this.createMessageElement(message);
+ this.elements.messagesContainer.appendChild(messageElement);
+ }
+ }
+
+ createMessageElement(message) {
+ const messageDiv = document.createElement("div");
+ messageDiv.className = "message";
+
+ const time = new Date(message.timestamp).toLocaleTimeString([], {
+ hour: "2-digit",
+ minute: "2-digit",
+ hour12: !this.settings.use24hTime,
+ });
+
+ const processedContent = this.processMessageContent(message.message);
+
+ messageDiv.innerHTML = `
+
+ ${processedContent}
+ `;
+
+ setTimeout(() => {
+ const images = messageDiv.querySelectorAll(".message-image");
+ for (const img of images) {
+ img.addEventListener("error", () => {
+ img.style.display = "none";
+ const errorDiv = document.createElement("div");
+ errorDiv.style.cssText =
+ "padding: 8px; color: #ff6b6b; font-style: italic; background: rgba(255,107,107,0.1); border-radius: 4px; margin: 8px 0;";
+ errorDiv.textContent = "Failed to load image";
+ img.parentNode.appendChild(errorDiv);
+ });
+ }
+ }, 0);
+
+ return messageDiv;
+ }
+
+ showSystemMessage(text) {
+ const messageDiv = document.createElement("div");
+ messageDiv.className = "message system-message";
+
+ messageDiv.innerHTML = `
+
+ ${this.escapeHtml(text)}
+
+ `;
+
+ this.elements.messagesContainer.appendChild(messageDiv);
+ this.scrollToBottom();
+ }
+
+ scrollToBottom() {
+ setTimeout(() => {
+ const container = this.elements.messagesContainer;
+ container.scrollTop = container.scrollHeight;
+ }, 50);
+ }
+
+ cleanup() {
+ this.isDestroyed = true;
+ if (this.websocket) {
+ if (this.websocket.readyState === WebSocket.OPEN) {
+ this.websocket.send(
+ JSON.stringify({
+ type: "leave_chat",
+ }),
+ );
+ }
+ this.websocket.close();
+ this.websocket = null;
+ }
+ }
+
+ escapeHtml(text) {
+ const div = document.createElement("div");
+ div.textContent = text;
+ return div.innerHTML;
+ }
+
+ showLoading() {
+ this.elements.loadingState.style.display = "flex";
+ this.elements.errorState.style.display = "none";
+ this.elements.messagesContainer.style.display = "none";
+ this.elements.emptyState.style.display = "none";
+ }
+
+ showError(message) {
+ this.elements.loadingState.style.display = "none";
+ this.elements.errorState.style.display = "flex";
+ this.elements.messagesContainer.style.display = "none";
+ this.elements.emptyState.style.display = "none";
+ this.elements.errorMessage.textContent = message;
+ }
+
+ showMessages() {
+ this.elements.loadingState.style.display = "none";
+ this.elements.errorState.style.display = "none";
+ this.elements.messagesContainer.style.display = "flex";
+ this.elements.emptyState.style.display = "none";
+ }
+
+ showEmpty() {
+ this.elements.loadingState.style.display = "none";
+ this.elements.errorState.style.display = "none";
+ this.elements.messagesContainer.style.display = "none";
+ this.elements.emptyState.style.display = "flex";
+ }
+}
+
+try {
+ new ChatManager();
+} catch (error) {
+ console.error("Failed to initialize ChatManager:", error);
+}
diff --git a/public/js/moveableChat.js b/public/js/moveableChat.js
new file mode 100644
index 0000000..f548083
--- /dev/null
+++ b/public/js/moveableChat.js
@@ -0,0 +1,511 @@
+class MoveableChatFrame {
+ constructor() {
+ try {
+ this.initializeElements();
+ if (!this.frame) {
+ console.warn(
+ "MoveableChatFrame: Required elements not found, skipping initialization",
+ );
+ return;
+ }
+
+ this.isDragging = false;
+ this.isResizing = false;
+ this.isMinimized = false;
+ this.dragOffset = { x: 0, y: 0 };
+ this.resizeHandle = null;
+ this.chatLoaded = false;
+
+ this.initializeEventListeners();
+ this.loadSavedState();
+ } catch (error) {
+ console.error("MoveableChatFrame initialization failed:", error);
+ }
+ }
+
+ initializeElements() {
+ this.frame = document.getElementById("moveableChatFrame");
+ this.header = document.getElementById("chatFrameHeader");
+ this.toggleBtn = document.getElementById("toggleChatBtn");
+ this.minimizeBtn = document.getElementById("minimizeChatBtn");
+ this.closeBtn = document.getElementById("closeChatBtn");
+
+ if (!this.frame || !this.header || !this.toggleBtn) {
+ console.warn("MoveableChatFrame: Missing required DOM elements");
+ return false;
+ }
+ return true;
+ }
+
+ initializeEventListeners() {
+ if (
+ !this.toggleBtn ||
+ !this.minimizeBtn ||
+ !this.closeBtn ||
+ !this.header
+ ) {
+ console.warn("MoveableChatFrame: Cannot bind events - missing elements");
+ return;
+ }
+
+ try {
+ this.toggleBtn.addEventListener("click", (e) => {
+ e.preventDefault();
+ e.stopPropagation();
+ this.toggleChat();
+ });
+
+ this.minimizeBtn.addEventListener("click", (e) => {
+ e.preventDefault();
+ e.stopPropagation();
+ this.toggleMinimize();
+ });
+
+ this.closeBtn.addEventListener("click", (e) => {
+ e.preventDefault();
+ e.stopPropagation();
+ this.closeChat();
+ });
+
+ this.header.addEventListener("mousedown", (e) => {
+ if (e.target.closest(".chat-control-btn")) return;
+ e.preventDefault();
+ this.startDragging(e);
+ });
+
+ const resizeHandles = this.frame.querySelectorAll(".resize-handle");
+ for (const handle of resizeHandles) {
+ handle.addEventListener("mousedown", (e) => {
+ e.preventDefault();
+ this.startResizing(e, handle);
+ });
+ }
+
+ document.addEventListener(
+ "mousemove",
+ (e) => {
+ if (this.isDragging) {
+ this.drag(e);
+ } else if (this.isResizing) {
+ this.resize(e);
+ }
+ },
+ { passive: true },
+ );
+
+ document.addEventListener(
+ "mouseup",
+ () => {
+ this.stopDragging();
+ this.stopResizing();
+ },
+ { passive: true },
+ );
+
+ this.header.addEventListener(
+ "touchstart",
+ (e) => {
+ if (e.target.closest(".chat-control-btn")) return;
+ e.preventDefault();
+ this.startDragging(e.touches[0]);
+ },
+ { passive: false },
+ );
+
+ document.addEventListener(
+ "touchmove",
+ (e) => {
+ if (this.isDragging) {
+ e.preventDefault();
+ this.drag(e.touches[0]);
+ }
+ },
+ { passive: false },
+ );
+
+ document.addEventListener(
+ "touchend",
+ () => {
+ this.stopDragging();
+ },
+ { passive: true },
+ );
+
+ window.addEventListener(
+ "resize",
+ () => {
+ this.constrainToViewport();
+ },
+ { passive: true },
+ );
+ } catch (error) {
+ console.error("Error binding MoveableChatFrame events:", error);
+ }
+ }
+
+ toggleChat() {
+ if (!this.frame) return;
+
+ if (this.frame.style.display === "none") {
+ this.openChat();
+ } else {
+ this.closeChat();
+ }
+ }
+
+ openChat() {
+ if (!this.frame) return;
+
+ this.frame.style.display = "flex";
+ document.body.classList.add("chat-open");
+
+ if (!this.chatLoaded) {
+ this.loadChatContent();
+ }
+
+ this.saveState();
+ }
+
+ loadChatContent() {
+ const streamId = document.querySelector('meta[name="stream-id"]')?.content;
+ if (!streamId) {
+ this.showChatError("Stream ID not found");
+ return;
+ }
+
+ console.log("Loading chat for stream ID:", streamId);
+ this.showLoading();
+
+ const iframe = document.createElement("iframe");
+ iframe.className = "chat-frame-iframe";
+ iframe.src = `/chat/${encodeURIComponent(streamId)}`;
+ iframe.style.cssText = `
+ width: 100%;
+ height: 100%;
+ border: none;
+ border-radius: 0 0 12px 12px;
+ background: var(--bg-card);
+ `;
+
+ iframe.onload = () => {
+ this.chatLoaded = true;
+ this.hideLoading();
+ console.log("Chat iframe loaded successfully");
+ };
+
+ iframe.onerror = () => {
+ console.error("Failed to load chat iframe");
+ this.showChatError("Failed to load chat");
+ };
+
+ const loadTimeout = setTimeout(() => {
+ if (!this.chatLoaded) {
+ console.warn("Chat loading timeout");
+ this.showChatError("Chat loading timeout");
+ }
+ }, 10000);
+
+ iframe.addEventListener("load", () => {
+ clearTimeout(loadTimeout);
+ });
+
+ const content = this.frame.querySelector(".chat-frame-content");
+ if (content) {
+ content.innerHTML = "";
+ content.appendChild(iframe);
+ }
+ }
+
+ closeChat() {
+ if (!this.frame) return;
+
+ this.frame.style.display = "none";
+ document.body.classList.remove("chat-open");
+
+ const content = this.frame.querySelector(".chat-frame-content");
+ if (content) {
+ content.innerHTML = "";
+ }
+
+ this.chatLoaded = false;
+ this.saveState();
+ }
+
+ toggleMinimize() {
+ if (!this.frame || !this.minimizeBtn) return;
+
+ this.isMinimized = !this.isMinimized;
+ this.frame.classList.toggle("minimized", this.isMinimized);
+
+ const icon = this.minimizeBtn.querySelector("svg path");
+ if (icon) {
+ if (this.isMinimized) {
+ icon.setAttribute("d", "M18 15l-6-6-6 6");
+ this.minimizeBtn.title = "Maximize";
+ } else {
+ icon.setAttribute("d", "M6 9l6 6 6-6");
+ this.minimizeBtn.title = "Minimize";
+ }
+ }
+
+ this.saveState();
+ }
+
+ showLoading() {
+ if (!this.frame) return;
+
+ const loadingDiv = document.createElement("div");
+ loadingDiv.className = "chat-frame-loading";
+ loadingDiv.innerHTML = `
+
+ Loading chat...
+ `;
+
+ const content = this.frame.querySelector(".chat-frame-content");
+ if (content) {
+ content.innerHTML = "";
+ content.appendChild(loadingDiv);
+ }
+ }
+
+ hideLoading() {}
+
+ showChatError(message) {
+ if (!this.frame) return;
+
+ const errorDiv = document.createElement("div");
+ errorDiv.className = "chat-frame-error";
+ errorDiv.innerHTML = `
+
+
+ ${message}
+
+
+ `;
+
+ const content = this.frame.querySelector(".chat-frame-content");
+ if (content) {
+ content.innerHTML = "";
+ content.appendChild(errorDiv);
+ }
+ }
+
+ startDragging(event) {
+ if (!this.frame) return;
+
+ this.isDragging = true;
+ const rect = this.frame.getBoundingClientRect();
+ this.dragOffset.x = event.clientX - rect.left;
+ this.dragOffset.y = event.clientY - rect.top;
+ this.frame.style.transition = "none";
+ document.body.style.userSelect = "none";
+ }
+
+ drag(event) {
+ if (!this.isDragging || !this.frame) return;
+
+ const x = event.clientX - this.dragOffset.x;
+ const y = event.clientY - this.dragOffset.y;
+
+ const maxX = window.innerWidth - this.frame.offsetWidth;
+ const maxY = window.innerHeight - this.frame.offsetHeight;
+
+ const constrainedX = Math.max(0, Math.min(x, maxX));
+ const constrainedY = Math.max(0, Math.min(y, maxY));
+
+ this.frame.style.left = `${constrainedX}px`;
+ this.frame.style.top = `${constrainedY}px`;
+ this.frame.style.right = "auto";
+ this.frame.style.bottom = "auto";
+ }
+
+ stopDragging() {
+ if (this.isDragging && this.frame) {
+ this.isDragging = false;
+ this.frame.style.transition = "all 0.3s ease";
+ document.body.style.userSelect = "";
+ this.savePosition();
+ }
+ }
+
+ startResizing(event, handle) {
+ if (this.isMinimized || !this.frame) return;
+
+ this.isResizing = true;
+ this.resizeHandle = handle;
+ this.frame.style.transition = "none";
+ document.body.style.userSelect = "none";
+ event.preventDefault();
+ }
+
+ resize(event) {
+ if (!this.isResizing || !this.resizeHandle || !this.frame) return;
+
+ const rect = this.frame.getBoundingClientRect();
+ const handleClass = this.resizeHandle.className;
+
+ let newWidth = rect.width;
+ let newHeight = rect.height;
+ let newLeft = rect.left;
+ let newTop = rect.top;
+
+ if (handleClass.includes("resize-handle-e")) {
+ newWidth = event.clientX - rect.left;
+ }
+ if (handleClass.includes("resize-handle-w")) {
+ newWidth = rect.right - event.clientX;
+ newLeft = event.clientX;
+ }
+ if (handleClass.includes("resize-handle-s")) {
+ newHeight = event.clientY - rect.top;
+ }
+ if (handleClass.includes("resize-handle-n")) {
+ newHeight = rect.bottom - event.clientY;
+ newTop = event.clientY;
+ }
+
+ newWidth = Math.max(280, Math.min(newWidth, window.innerWidth));
+ newHeight = Math.max(200, Math.min(newHeight, window.innerHeight));
+
+ this.frame.style.width = `${newWidth}px`;
+ this.frame.style.height = `${newHeight}px`;
+ this.frame.style.left = `${newLeft}px`;
+ this.frame.style.top = `${newTop}px`;
+ this.frame.style.right = "auto";
+ this.frame.style.bottom = "auto";
+ }
+
+ stopResizing() {
+ if (this.isResizing && this.frame) {
+ this.isResizing = false;
+ this.resizeHandle = null;
+ this.frame.style.transition = "all 0.3s ease";
+ document.body.style.userSelect = "";
+ this.savePosition();
+ }
+ }
+
+ constrainToViewport() {
+ if (!this.frame || this.frame.style.display === "none") return;
+
+ const rect = this.frame.getBoundingClientRect();
+ const maxX = window.innerWidth - rect.width;
+ const maxY = window.innerHeight - rect.height;
+
+ const currentX = Number.parseInt(this.frame.style.left) || rect.left;
+ const currentY = Number.parseInt(this.frame.style.top) || rect.top;
+
+ const constrainedX = Math.max(0, Math.min(currentX, maxX));
+ const constrainedY = Math.max(0, Math.min(currentY, maxY));
+
+ this.frame.style.left = `${constrainedX}px`;
+ this.frame.style.top = `${constrainedY}px`;
+ this.frame.style.right = "auto";
+ this.frame.style.bottom = "auto";
+ }
+
+ savePosition() {
+ if (!this.frame) return;
+
+ try {
+ const rect = this.frame.getBoundingClientRect();
+ const position = {
+ left: rect.left,
+ top: rect.top,
+ width: rect.width,
+ height: rect.height,
+ };
+ localStorage.setItem("moveable-chat-position", JSON.stringify(position));
+ } catch (error) {
+ console.warn("Failed to save chat position:", error);
+ }
+ }
+
+ saveState() {
+ if (!this.frame) return;
+
+ try {
+ const state = {
+ visible: this.frame.style.display !== "none",
+ minimized: this.isMinimized,
+ };
+ localStorage.setItem("moveable-chat-state", JSON.stringify(state));
+ } catch (error) {
+ console.warn("Failed to save chat state:", error);
+ }
+ }
+
+ loadSavedState() {
+ if (!this.frame) return;
+
+ const savedPosition = localStorage.getItem("moveable-chat-position");
+ if (savedPosition) {
+ try {
+ const position = JSON.parse(savedPosition);
+ this.frame.style.left = `${position.left}px`;
+ this.frame.style.top = `${position.top}px`;
+ this.frame.style.width = `${position.width}px`;
+ this.frame.style.height = `${position.height}px`;
+ this.frame.style.right = "auto";
+ this.frame.style.bottom = "auto";
+ } catch (e) {
+ console.warn("Failed to load saved chat position:", e);
+ }
+ }
+
+ const savedState = localStorage.getItem("moveable-chat-state");
+ if (savedState) {
+ try {
+ const state = JSON.parse(savedState);
+ if (state.visible) {
+ this.openChat();
+ }
+ if (state.minimized) {
+ this.isMinimized = true;
+ this.toggleMinimize();
+ }
+ } catch (e) {
+ console.warn("Failed to load saved chat state:", e);
+ }
+ }
+
+ setTimeout(() => {
+ this.constrainToViewport();
+ }, 100);
+ }
+}
+
+function initializeMoveableChat() {
+ try {
+ const requiredElements = [
+ "moveableChatFrame",
+ "chatFrameHeader",
+ "toggleChatBtn",
+ ];
+
+ const missingElements = requiredElements.filter(
+ (id) => !document.getElementById(id),
+ );
+
+ if (missingElements.length > 0) {
+ console.warn(
+ "MoveableChatFrame: Missing required elements:",
+ missingElements,
+ );
+ return;
+ }
+
+ window.moveableChatFrame = new MoveableChatFrame();
+ } catch (error) {
+ console.error("Failed to initialize MoveableChatFrame:", error);
+ }
+}
+
+setTimeout(initializeMoveableChat, 100);
diff --git a/public/js/watch.js b/public/js/watch.js
index 9db6087..068e22c 100644
--- a/public/js/watch.js
+++ b/public/js/watch.js
@@ -49,12 +49,9 @@ class StreamWatcher {
bitrate: document.getElementById("bitrate"),
quality: document.getElementById("quality"),
streamId: document.getElementById("streamId"),
- streamApp: document.getElementById("streamApp"),
- streamDuration: document.getElementById("streamDuration"),
videoCodec: document.getElementById("videoCodec"),
audioCodec: document.getElementById("audioCodec"),
resolution: document.getElementById("resolution"),
- rtmpUrl: document.getElementById("rtmpUrl"),
hlsUrl: document.getElementById("hlsUrl"),
flvUrl: document.getElementById("flvUrl"),
webrtcUrl: document.getElementById("webrtcUrl"),
@@ -103,7 +100,6 @@ class StreamWatcher {
});
const urlElements = [
- this.elements.rtmpUrl,
this.elements.hlsUrl,
this.elements.flvUrl,
this.elements.webrtcUrl,
@@ -228,10 +224,6 @@ class StreamWatcher {
}
this.elements.streamId.textContent = stream.name || this.streamId;
- this.elements.streamApp.textContent = stream.app || "-";
- this.elements.streamDuration.textContent = isOnline
- ? this.formatDuration(stream.publish?.duration)
- : "-";
this.elements.videoCodec.textContent = stream.video
? `${stream.video.codec} ${stream.video.profile || ""}`.trim()
: "-";
@@ -247,7 +239,6 @@ class StreamWatcher {
const srsHost = new URL(this.srsUrl).host;
const streamPath = `${stream.app}/${stream.name}`;
- this.elements.rtmpUrl.textContent = `rtmp://${srsHost}/${streamPath}`;
this.elements.hlsUrl.textContent = `${this.srsUrl}/${streamPath}.m3u8`;
this.elements.flvUrl.textContent = `${this.srsUrl}/${streamPath}.flv`;
this.elements.webrtcUrl.textContent = `webrtc://${srsHost}/${streamPath}`;
diff --git a/public/watch.html b/public/watch.html
index e4e7276..29c4317 100644
--- a/public/watch.html
+++ b/public/watch.html
@@ -8,9 +8,11 @@
+
+
@@ -26,6 +28,15 @@
Back to Dashboard
Loading Stream...
+
@@ -78,14 +89,6 @@
Stream ID:
-
-
- Application:
- -
-
-
- Duration:
- -
-
Video Codec:
-
@@ -104,10 +107,6 @@
Stream URLs
-
HLS:
-
@@ -124,6 +123,43 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+