From bc915332629a642acfc05d25d78e99de58f0e1b2 Mon Sep 17 00:00:00 2001 From: olcxja Date: Sun, 23 Aug 2026 11:39:24 +0200 Subject: [PATCH 1/3] better vc ui --- android/app/src/main/assets/public/main.js | 14 +- android/app/src/main/assets/public/style.css | 441 ++++++++++++++----- android/app/src/main/assets/public/voice.js | 137 ++++-- webroot/main.js | 14 +- webroot/style.css | 441 ++++++++++++++----- webroot/voice.js | 137 ++++-- 6 files changed, 870 insertions(+), 314 deletions(-) diff --git a/android/app/src/main/assets/public/main.js b/android/app/src/main/assets/public/main.js index ecd51413..f514589c 100644 --- a/android/app/src/main/assets/public/main.js +++ b/android/app/src/main/assets/public/main.js @@ -1461,8 +1461,8 @@ function showFixedContextMenu(rect, html) { fixedContextMenu.style.transition = 'none'; fixedContextMenu.style.opacity = '0'; - fixedContextMenu.style.left = '0px'; - fixedContextMenu.style.top = '0px'; + fixedContextMenu.style.left = '0rem'; + fixedContextMenu.style.top = '0rem'; fixedContextMenu.classList.add("show"); @@ -3139,7 +3139,7 @@ async function generateMessagesHtml(entries, isPinnedContext = false) { let safeType = encodeURIComponent(att.type).replace(/'/g, "%27"); let safeName = encodeURIComponent(att.name).replace(/'/g, "%27"); - attachmentsHtml += `
+ attachmentsHtml += `
${iconSvg}
${escapeHtml(displayName)}
@@ -3653,12 +3653,12 @@ async function downloadAndRenderAttachment(container, msgId, attId, type, name, container.dataset.url = blobUrl; if (type.startsWith("image/")) { - container.innerHTML = ``; + container.innerHTML = ``; } else if (type.startsWith("video/")) { - container.innerHTML = ``; + container.innerHTML = ``; } else { container.innerHTML = originalHtml; - container.style.border = "1px solid var(--accent-color)"; + container.style.border = "0.0625rem solid var(--accent-color)"; let a = document.createElement("a"); a.href = blobUrl; a.download = name; @@ -3716,7 +3716,7 @@ function renderAttachmentsPreview() { } html += ` -
+
${iconHtml}
${escapeHtml(file.name)}
-
`; document.body.appendChild(incomingUi); + requestAnimationFrame(() => { + requestAnimationFrame(() => incomingUi.classList.add("show")); + }); +} + +function dismissIncomingCall() { + let el = document.getElementById("incoming-call-popup"); + if (!el || el.classList.contains("hide")) return; + el.classList.remove("show"); + el.classList.add("hide"); + setTimeout(() => el.remove(), 400); +} + +function joinIncomingCall(roomId) { + dismissIncomingCall(); + initiateVoiceCall(roomId); } async function sendVoiceSignalToUser(targetUserId, roomId) { @@ -510,19 +542,20 @@ function showVoiceUI() { voiceUiContainer.id = "voice-call-popup"; voiceUiContainer.innerHTML = ` -
-
- Voice Chat +
+
+ ${ICONS.call({width: "1.1rem", height: "1.1rem"})} + Voice Chat
-
Connecting...
+
Connecting...
-
-
+
+
`; - + document.body.appendChild(voiceUiContainer); + requestAnimationFrame(() => { + requestAnimationFrame(() => voiceUiContainer.classList.add("show")); + }); let header = document.getElementById("voice-header"); let isDragging = false; @@ -639,10 +675,6 @@ function updateVoiceUIUsers() { let mediaEl = streamData.canvas || streamData.video; if (mediaEl) { - mediaEl.style.width = "100%"; - mediaEl.style.height = "100%"; - mediaEl.style.objectFit = "cover"; - mediaEl.style.background = "#000"; div.appendChild(mediaEl); } @@ -696,6 +728,9 @@ function showVoiceUserSettings(clientId, e) { if (top + rect.height > window.innerHeight) top = window.innerHeight - rect.height - 10; popup.style.left = left + "px"; popup.style.top = top + "px"; + requestAnimationFrame(() => { + requestAnimationFrame(() => popup.classList.add("open")); + }); const closePopup = (ev) => { if (!popup.contains(ev.target)) { @@ -718,6 +753,7 @@ window.updateVoiceVol = function(clientId, val, type) { function toggleVoiceMic() { isMicMuted = !isMicMuted; + if (isMicMuted) setTileSpeaking(String(window.id), false); let btn = document.getElementById("voice-btn-mic"); if (btn) { if (isMicMuted) { @@ -779,9 +815,14 @@ function endVoiceSession() { currentVoiceCallType = null; currentVoiceCallId = null; + clearAllSpeaking(); + if (voiceUiContainer) { - voiceUiContainer.remove(); + let popupEl = voiceUiContainer; voiceUiContainer = null; + popupEl.classList.remove("show"); + popupEl.classList.add("hide"); + setTimeout(() => popupEl.remove(), 350); } } diff --git a/webroot/main.js b/webroot/main.js index ecd51413..f514589c 100644 --- a/webroot/main.js +++ b/webroot/main.js @@ -1461,8 +1461,8 @@ function showFixedContextMenu(rect, html) { fixedContextMenu.style.transition = 'none'; fixedContextMenu.style.opacity = '0'; - fixedContextMenu.style.left = '0px'; - fixedContextMenu.style.top = '0px'; + fixedContextMenu.style.left = '0rem'; + fixedContextMenu.style.top = '0rem'; fixedContextMenu.classList.add("show"); @@ -3139,7 +3139,7 @@ async function generateMessagesHtml(entries, isPinnedContext = false) { let safeType = encodeURIComponent(att.type).replace(/'/g, "%27"); let safeName = encodeURIComponent(att.name).replace(/'/g, "%27"); - attachmentsHtml += `
+ attachmentsHtml += `
${iconSvg}
${escapeHtml(displayName)}
@@ -3653,12 +3653,12 @@ async function downloadAndRenderAttachment(container, msgId, attId, type, name, container.dataset.url = blobUrl; if (type.startsWith("image/")) { - container.innerHTML = ``; + container.innerHTML = ``; } else if (type.startsWith("video/")) { - container.innerHTML = ``; + container.innerHTML = ``; } else { container.innerHTML = originalHtml; - container.style.border = "1px solid var(--accent-color)"; + container.style.border = "0.0625rem solid var(--accent-color)"; let a = document.createElement("a"); a.href = blobUrl; a.download = name; @@ -3716,7 +3716,7 @@ function renderAttachmentsPreview() { } html += ` -
+
${iconHtml}
${escapeHtml(file.name)}
-
`; document.body.appendChild(incomingUi); + requestAnimationFrame(() => { + requestAnimationFrame(() => incomingUi.classList.add("show")); + }); +} + +function dismissIncomingCall() { + let el = document.getElementById("incoming-call-popup"); + if (!el || el.classList.contains("hide")) return; + el.classList.remove("show"); + el.classList.add("hide"); + setTimeout(() => el.remove(), 400); +} + +function joinIncomingCall(roomId) { + dismissIncomingCall(); + initiateVoiceCall(roomId); } async function sendVoiceSignalToUser(targetUserId, roomId) { @@ -510,19 +542,20 @@ function showVoiceUI() { voiceUiContainer.id = "voice-call-popup"; voiceUiContainer.innerHTML = ` -
-
- Voice Chat +
+
+ ${ICONS.call({width: "1.1rem", height: "1.1rem"})} + Voice Chat
-
Connecting...
+
Connecting...
-
-
+
+
`; - + document.body.appendChild(voiceUiContainer); + requestAnimationFrame(() => { + requestAnimationFrame(() => voiceUiContainer.classList.add("show")); + }); let header = document.getElementById("voice-header"); let isDragging = false; @@ -639,10 +675,6 @@ function updateVoiceUIUsers() { let mediaEl = streamData.canvas || streamData.video; if (mediaEl) { - mediaEl.style.width = "100%"; - mediaEl.style.height = "100%"; - mediaEl.style.objectFit = "cover"; - mediaEl.style.background = "#000"; div.appendChild(mediaEl); } @@ -696,6 +728,9 @@ function showVoiceUserSettings(clientId, e) { if (top + rect.height > window.innerHeight) top = window.innerHeight - rect.height - 10; popup.style.left = left + "px"; popup.style.top = top + "px"; + requestAnimationFrame(() => { + requestAnimationFrame(() => popup.classList.add("open")); + }); const closePopup = (ev) => { if (!popup.contains(ev.target)) { @@ -718,6 +753,7 @@ window.updateVoiceVol = function(clientId, val, type) { function toggleVoiceMic() { isMicMuted = !isMicMuted; + if (isMicMuted) setTileSpeaking(String(window.id), false); let btn = document.getElementById("voice-btn-mic"); if (btn) { if (isMicMuted) { @@ -779,9 +815,14 @@ function endVoiceSession() { currentVoiceCallType = null; currentVoiceCallId = null; + clearAllSpeaking(); + if (voiceUiContainer) { - voiceUiContainer.remove(); + let popupEl = voiceUiContainer; voiceUiContainer = null; + popupEl.classList.remove("show"); + popupEl.classList.add("hide"); + setTimeout(() => popupEl.remove(), 350); } } From 135fbf23ca26ed17b271397cebcbe1d66deeb429 Mon Sep 17 00:00:00 2001 From: olcxja Date: Sun, 23 Aug 2026 15:01:04 +0200 Subject: [PATCH 2/3] voice chat ui improvements - better resize - better grid styling --- android/app/build.gradle | 4 +- .../src/main/assets/public/blah/en-cat.json | 23 +- .../src/main/assets/public/blah/en-us.json | 23 +- .../src/main/assets/public/blah/pl-pl.json | 23 +- android/app/src/main/assets/public/style.css | 144 ++++--- android/app/src/main/assets/public/version.js | 2 +- android/app/src/main/assets/public/voice.js | 397 +++++++++++++++--- electron/assets/icon.png | Bin 14827 -> 14827 bytes electron/package.json | 2 +- icons/icon.png | Bin 14827 -> 14827 bytes package.json | 2 +- version.json | 6 +- webroot/blah/en-cat.json | 23 +- webroot/blah/en-us.json | 23 +- webroot/blah/pl-pl.json | 23 +- webroot/style.css | 144 ++++--- webroot/version.js | 2 +- webroot/voice.js | 397 +++++++++++++++--- 18 files changed, 1003 insertions(+), 235 deletions(-) diff --git a/android/app/build.gradle b/android/app/build.gradle index 18033c55..7c7a58a9 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -7,8 +7,8 @@ android { applicationId "olcxja.miarven" minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion - versionCode 1 - versionName "0.1.0" + versionCode 2 + versionName "0.1.1" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" aaptOptions { // Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps. diff --git a/android/app/src/main/assets/public/blah/en-cat.json b/android/app/src/main/assets/public/blah/en-cat.json index ae56e047..2834dbe0 100644 --- a/android/app/src/main/assets/public/blah/en-cat.json +++ b/android/app/src/main/assets/public/blah/en-cat.json @@ -290,5 +290,24 @@ "message.delete.failed": "Failed to delete meow", "message.react.failed": "Failed to react to meow", "message.send.failed": "Failed to send meow", - "owner.transferred": "Purrship transferred successfully" -} \ No newline at end of file + "owner.transferred": "Purrship transferred successfully", + "title.voice.chat": "voice chat :3", + "status.voice.connecting": "connecting... :3", + "status.voice.connected": "connected :3", + "action.call.mute": "mute microphone", + "action.call.camera": "camera", + "action.call.screen": "share screen", + "action.call.disconnect": "disconnect :3", + "action.call.join": "join :3", + "action.call.decline": "decline :c", + "title.incoming.call": "incoming voice chat :3", + "desc.call.in.chat": "{0} in chat :3", + "label.user.volume": "user volume", + "label.stream.volume": "screen volume", + "error.call.unsupported": "no permission or not supported: {0} :c", + "error.call.camera": "camera error: {0} :c", + "error.call.screen": "screen share error: {0} :c", + "error.call.screen.unsupported": "ur browser doesn't support screen sharing :c", + "label.stream.cam": "cam", + "label.stream.screen": "screen" +} diff --git a/android/app/src/main/assets/public/blah/en-us.json b/android/app/src/main/assets/public/blah/en-us.json index 42d98cb7..5486df54 100644 --- a/android/app/src/main/assets/public/blah/en-us.json +++ b/android/app/src/main/assets/public/blah/en-us.json @@ -299,5 +299,24 @@ "message.delete.failed": "Failed to delete message", "message.react.failed": "Failed to react to message", "message.send.failed": "Failed to send message", - "owner.transferred": "Ownership transferred successfully" -} \ No newline at end of file + "owner.transferred": "Ownership transferred successfully", + "title.voice.chat": "Voice Chat", + "status.voice.connecting": "Connecting...", + "status.voice.connected": "Connected", + "action.call.mute": "Mute Microphone", + "action.call.camera": "Camera", + "action.call.screen": "Share Screen", + "action.call.disconnect": "Disconnect", + "action.call.join": "Join", + "action.call.decline": "Decline", + "title.incoming.call": "Incoming Voice Chat", + "desc.call.in.chat": "{0} in chat", + "label.user.volume": "User Volume", + "label.stream.volume": "Screen Volume", + "error.call.unsupported": "No permission or feature not supported: {0}", + "error.call.camera": "Camera error: {0}", + "error.call.screen": "Screen share error: {0}", + "error.call.screen.unsupported": "Screen sharing is not supported on this browser.", + "label.stream.cam": "Cam", + "label.stream.screen": "Screen" +} diff --git a/android/app/src/main/assets/public/blah/pl-pl.json b/android/app/src/main/assets/public/blah/pl-pl.json index d1fea3af..aa9e1fb0 100644 --- a/android/app/src/main/assets/public/blah/pl-pl.json +++ b/android/app/src/main/assets/public/blah/pl-pl.json @@ -299,5 +299,24 @@ "message.delete.failed": "Nie udało się usunąć wiadomości", "message.react.failed": "Nie udało się dodać reakcji", "message.send.failed": "Nie udało się wysłać wiadomości", - "owner.transferred": "Własność przekazana pomyślnie" -} \ No newline at end of file + "owner.transferred": "Własność przekazana pomyślnie", + "title.voice.chat": "Czat głosowy", + "status.voice.connecting": "Łączenie...", + "status.voice.connected": "Połączono", + "action.call.mute": "Wycisz mikrofon", + "action.call.camera": "Kamera", + "action.call.screen": "Udostępnij ekran", + "action.call.disconnect": "Rozłącz", + "action.call.join": "Dołącz", + "action.call.decline": "Odrzuć", + "title.incoming.call": "Połączenie głosowe", + "desc.call.in.chat": "{0} w rozmowie", + "label.user.volume": "Głośność użytkownika", + "label.stream.volume": "Głośność ekranu", + "error.call.unsupported": "Brak uprawnień lub funkcja nieobsługiwana: {0}", + "error.call.camera": "Błąd kamery: {0}", + "error.call.screen": "Błąd udostępniania ekranu: {0}", + "error.call.screen.unsupported": "Udostępnianie ekranu nie jest obsługiwane w tej przeglądarce.", + "label.stream.cam": "Kamera", + "label.stream.screen": "Ekran" +} diff --git a/android/app/src/main/assets/public/style.css b/android/app/src/main/assets/public/style.css index 7c4985ce..b1f08f2d 100644 --- a/android/app/src/main/assets/public/style.css +++ b/android/app/src/main/assets/public/style.css @@ -1298,7 +1298,7 @@ input:checked + .toggle-slider:before { display: flex; flex-direction: column; overflow: hidden; - resize: both; + touch-action: none; opacity: 0; transform: translateY(1.5rem) scale(0.92); @@ -1329,6 +1329,7 @@ input:checked + .toggle-slider:before { font-weight: bold; user-select: none; -webkit-user-select: none; + touch-action: none; border-bottom: var(--border-width) solid var(--light-border-color); } #voice-header:active { @@ -1347,26 +1348,66 @@ input:checked + .toggle-slider:before { } #voice-status { - font-size: 0.75rem; + display: flex; + align-items: center; + gap: 0.4rem; + font-size: 0.8rem; font-weight: 600; color: var(--darker-text-color); - background: rgba(255, 255, 255, 0.05); - border: var(--border-width) solid var(--light-border-color); - padding: 0.15rem 0.6rem; - border-radius: 50rem; + opacity: 0.85; +} +#voice-status .status-dot { + width: 0.55rem; + height: 0.55rem; + flex-shrink: 0; + border-radius: 50%; + background: var(--darker-text-color); +} +#voice-status.connecting .status-dot { + animation: voice-dot-pulse 1.2s ease-in-out infinite; +} +#voice-status.connected { + opacity: 1; +} +#voice-status.connected .status-dot { + background: var(--big-green); + box-shadow: 0 0 0.35rem rgba(120, 200, 120, 0.45); +} +@keyframes voice-dot-pulse { + 50% { opacity: 0.25; } } #voice-users { padding: 0.9375rem; - display: grid; - grid-template-columns: repeat(auto-fit, minmax(6.25rem, 1fr)); + display: flex; + flex-wrap: wrap; + justify-content: center; + align-content: flex-start; gap: 0.75rem; overflow-y: auto; flex-grow: 1; + touch-action: pan-y; } +.voice-resize-handle { + position: absolute; + z-index: 6; + touch-action: none; +} +.voice-resize-n { top: 0; left: 0.6rem; right: 0.6rem; height: 0.45rem; cursor: ns-resize; } +.voice-resize-s { bottom: 0; left: 0.6rem; right: 0.6rem; height: 0.45rem; cursor: ns-resize; } +.voice-resize-e { right: 0; top: 0.6rem; bottom: 0.6rem; width: 0.45rem; cursor: ew-resize; } +.voice-resize-w { left: 0; top: 0.6rem; bottom: 0.6rem; width: 0.45rem; cursor: ew-resize; } +.voice-resize-ne, .voice-resize-nw, .voice-resize-se, .voice-resize-sw { width: 0.8rem; height: 0.8rem; } +.voice-resize-ne { top: 0; right: 0; cursor: nesw-resize; } +.voice-resize-nw { top: 0; left: 0; cursor: nwse-resize; } +.voice-resize-se { bottom: 0; right: 0; cursor: nwse-resize; } +.voice-resize-sw { bottom: 0; left: 0; cursor: nesw-resize; } + .voice-tile { position: relative; + width: var(--voice-tile-w, 9rem); + height: var(--voice-tile-h, 8rem); background: var(--light-border-color); background-size: cover; background-position: center; @@ -1375,18 +1416,17 @@ input:checked + .toggle-slider:before { cursor: pointer; border: var(--border-width) solid transparent; box-shadow: 0 0.25rem 0.625rem rgba(0, 0, 0, 0.3); - animation: voice-tile-in 0.25s cubic-bezier(0.175, 0.885, 0.32, 1.275); - transition: border-color 0.15s ease, box-shadow 0.2s ease, transform 0.2s ease; + transition: border-color 0.15s ease, box-shadow 0.2s ease; } -.voice-tile:hover { - transform: translateY(-0.125rem); +.voice-tile.tile-enter { + animation: voice-tile-in 0.25s cubic-bezier(0.175, 0.885, 0.32, 1.275); } .voice-tile video, .voice-tile canvas { display: block; width: 100%; height: 100%; - object-fit: cover; + object-fit: contain; background: #000; } @keyframes voice-tile-in { @@ -1502,9 +1542,12 @@ input:checked + .toggle-slider:before { /* Incoming Call Popup */ #incoming-call-popup { position: fixed; - top: 1.5rem; - left: 50%; - transform: translateX(-50%) translateY(calc(-100% - 3rem)); + top: calc(env(safe-area-inset-top, 0rem) + 4.5rem); + left: 0; + right: 0; + margin-inline: auto; + width: fit-content; + max-width: calc(100vw - 2rem); display: flex; align-items: center; gap: 1rem; @@ -1518,39 +1561,22 @@ input:checked + .toggle-slider:before { z-index: 9999; opacity: 0; transition: - opacity 0.25s ease, - transform 0.35s cubic-bezier(0.175, 0.885, 0.32, 1.275); + opacity 0.2s ease, + transform 0.25s cubic-bezier(0.175, 0.885, 0.32, 1.275); } #incoming-call-popup.show { opacity: 1; - transform: translateX(-50%) translateY(0); -} -#incoming-call-popup.hide { - opacity: 0; - transform: translateX(-50%) translateY(calc(-100% - 2rem)); + transform: translateY(0); } -.incoming-call-icon { +.incoming-call-avatar { width: 2.5rem; height: 2.5rem; flex-shrink: 0; - border-radius: 50%; - background: var(--big-green); - color: white !important; - display: flex; - align-items: center; - justify-content: center; - animation: voice-call-pulse 1.5s ease-out infinite; -} -.incoming-call-icon svg { - width: 1.25rem; - height: 1.25rem; - pointer-events: none !important; -} -@keyframes voice-call-pulse { - 0% { box-shadow: 0 0 0 0 hsla(120, 60%, 55%, 0.45); } - 70% { box-shadow: 0 0 0 0.8rem hsla(120, 60%, 55%, 0); } - 100% { box-shadow: 0 0 0 0 hsla(120, 60%, 55%, 0); } + border-radius: 0.65rem; + border: var(--border-width) solid rgba(255, 255, 255, 0.2); + object-fit: cover; + aspect-ratio: 1 / 1; } .incoming-call-info { @@ -1558,8 +1584,15 @@ input:checked + .toggle-slider:before { flex-direction: column; gap: 0.1rem; text-align: left; + min-width: 0; } -.incoming-call-title { +.incoming-call-name, +.incoming-call-subtitle { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.incoming-call-name { font-weight: bold; font-size: 1.05rem; color: var(--text-color); @@ -1603,15 +1636,13 @@ input:checked + .toggle-slider:before { color: white !important; } .voice-call-btn.decline { - background: rgba(255, 255, 255, 0.05) !important; - border: var(--border-width) solid var(--light-border-color) !important; - color: var(--text-color) !important; -} -.voice-call-btn.accept:hover { - filter: brightness(1.12); + background: var(--big-red) !important; + border: none !important; + color: white !important; } +.voice-call-btn.accept:hover, .voice-call-btn.decline:hover { - background: rgba(255, 255, 255, 0.1) !important; + filter: brightness(0.85); } .voice-call-btn:active, .voice-call-btn.is-pressed { transform: var(--press-scale); @@ -1672,13 +1703,16 @@ input:checked + .toggle-slider:before { @media (max-width: 55rem) { #voice-call-popup { - width: calc(100vw - 2rem); - height: 60vh; - left: 1rem; - right: unset; + width: 16rem; + height: 17rem; + min-width: 13rem; + min-height: 12rem; } #incoming-call-popup { - width: calc(100vw - 2rem); - box-sizing: border-box; + gap: 0.75rem; + padding: 0.6rem 0.6rem 0.6rem 0.8rem; + } + .voice-call-btn { + padding: 0 0.75rem; } } diff --git a/android/app/src/main/assets/public/version.js b/android/app/src/main/assets/public/version.js index a0465de1..62bf5330 100644 --- a/android/app/src/main/assets/public/version.js +++ b/android/app/src/main/assets/public/version.js @@ -1 +1 @@ -const CLIENT_VERSION = "0.1.0"; +const CLIENT_VERSION = "0.1.1"; diff --git a/android/app/src/main/assets/public/voice.js b/android/app/src/main/assets/public/voice.js index 7c3e5616..fb1a8f03 100644 --- a/android/app/src/main/assets/public/voice.js +++ b/android/app/src/main/assets/public/voice.js @@ -17,6 +17,42 @@ let camControl = null, screenControl = null; const SPEAKING_HANGOVER_MS = 400; +let voiceDragState = false; +let voiceResizeAbort = null; + +function clampVoiceWindowToViewport(popupEl) { + const r = popupEl.getBoundingClientRect(); + const W = window.innerWidth, H = window.innerHeight; + const w = Math.min(r.width, W); + const h = Math.min(r.height, H); + const l = Math.min(Math.max(r.left, 0), W - w); + const t = Math.min(Math.max(r.top, 0), H - h); + if (l !== r.left || t !== r.top || w !== r.width || h !== r.height) { + popupEl.style.right = "auto"; + popupEl.style.bottom = "auto"; + popupEl.style.width = w + "px"; + popupEl.style.height = h + "px"; + popupEl.style.left = l + "px"; + popupEl.style.top = t + "px"; + } +} + +let voiceVolumeState = {}; + +function getStoredVolume(type, clientId) { + let v = voiceVolumeState[`${type}:${clientId}`]; + return v === undefined ? 100 : v; +} + +function setVoiceStatus(state) { + let el = document.getElementById("voice-status"); + if (!el) return; + el.classList.toggle("connected", state === "connected"); + el.classList.toggle("connecting", state !== "connected"); + let textEl = el.querySelector(".voice-status-text"); + if (textEl) textEl.innerText = processBlah(state === "connected" ? "status.voice.connected" : "status.voice.connecting"); +} + function setTileSpeaking(clientId, speaking) { let tile = document.getElementById(`voice-tile-${clientId}`); if (!tile) return; @@ -171,7 +207,7 @@ async function initiateVoiceCall(roomId = null) { async function startVoiceSession(serverUrl) { showVoiceUI(); updateVoiceUIUsers(); - document.getElementById("voice-status").innerText = "Connecting..."; + setVoiceStatus("connecting"); try { const wsUrl = new URL(serverUrl); @@ -186,8 +222,7 @@ async function startVoiceSession(serverUrl) { currentVoiceWs.binaryType = 'arraybuffer'; currentVoiceWs.onopen = async () => { - document.getElementById("voice-status").innerText = "Connected"; - document.getElementById("voice-status").style.color = "var(--big-green)"; + setVoiceStatus("connected"); let btn = document.getElementById("voice-btn-mic"); if (btn) { if (isMicMuted) btn.classList.add("voice-btn-danger"); @@ -449,25 +484,47 @@ function handleCallUpdate(data) { } } +async function resolveCallerInfo(roomId) { + let name = window.dmUsernameCache ? window.dmUsernameCache[roomId] : null; + if (name && name.includes(':')) name = name.split(':')[0]; + if (!name && window.fetchAsync) { + try { + let fetched = await window.fetchAsync(`${window.url}/idtoname?id=${roomId}`); + if (fetched && !fetched.startsWith("error:")) { + name = fetched.includes(':') ? fetched.split(':')[0] : fetched; + if (window.dmUsernameCache) window.dmUsernameCache[roomId] = name; + } + } catch (e) {} + } + let pfpUrl = window.dmPfpCache ? window.dmPfpCache[roomId] : null; + if (!pfpUrl && window.getAvatarUrl) { + try { pfpUrl = await window.getAvatarUrl(roomId, name || "User"); } catch (e) {} + if (pfpUrl && window.dmPfpCache) window.dmPfpCache[roomId] = pfpUrl; + } + return { name: name || processBlah("title.unknown"), pfpUrl }; +} + function showIncomingCallUI(roomId) { if (document.getElementById("incoming-call-popup") || currentVoiceWs) return; + let cachedName = window.dmUsernameCache ? window.dmUsernameCache[roomId] : null; + if (cachedName && cachedName.includes(':')) cachedName = cachedName.split(':')[0]; + let cachedPfp = window.dmPfpCache ? window.dmPfpCache[roomId] : null; + let incomingUi = document.createElement("div"); incomingUi.id = "incoming-call-popup"; incomingUi.innerHTML = ` -
- ${ICONS.call({width: "1.25rem", height: "1.25rem"})} -
+
- ${processBlah("title.incoming.call") || "Incoming Voice Chat"} - ${voiceActiveUsers.length} in chat + ${cachedName || "..."} + ${processBlah("title.incoming.call")} · ${processBlah(`:desc.call.in.chat:${voiceActiveUsers.length}`)}
`; @@ -475,6 +532,14 @@ function showIncomingCallUI(roomId) { requestAnimationFrame(() => { requestAnimationFrame(() => incomingUi.classList.add("show")); }); + + resolveCallerInfo(roomId).then(({name, pfpUrl}) => { + if (!document.body.contains(incomingUi)) return; + let nameEl = incomingUi.querySelector(".incoming-call-name"); + let avatarEl = incomingUi.querySelector(".incoming-call-avatar"); + if (nameEl) nameEl.innerText = name; + if (avatarEl && pfpUrl) avatarEl.src = pfpUrl; + }); } function dismissIncomingCall() { @@ -482,7 +547,7 @@ function dismissIncomingCall() { if (!el || el.classList.contains("hide")) return; el.classList.remove("show"); el.classList.add("hide"); - setTimeout(() => el.remove(), 400); + setTimeout(() => el.remove(), 300); } function joinIncomingCall(roomId) { @@ -545,25 +610,28 @@ function showVoiceUI() {
${ICONS.call({width: "1.1rem", height: "1.1rem"})} - Voice Chat + ${processBlah("title.voice.chat")} +
+
+ + ${processBlah("status.voice.connecting")}
-
Connecting...
@@ -575,11 +643,12 @@ function showVoiceUI() { }); let header = document.getElementById("voice-header"); - let isDragging = false; let startX, startY, initialLeft, initialTop; - - header.addEventListener('mousedown', (e) => { - isDragging = true; + + header.addEventListener('pointerdown', (e) => { + if (e.button !== undefined && e.button !== 0) return; + voiceDragState = true; + try { header.setPointerCapture(e.pointerId); } catch (err) {} let rect = voiceUiContainer.getBoundingClientRect(); initialLeft = rect.left; initialTop = rect.top; @@ -589,17 +658,149 @@ function showVoiceUI() { voiceUiContainer.style.bottom = "auto"; voiceUiContainer.style.left = initialLeft + "px"; voiceUiContainer.style.top = initialTop + "px"; + clampVoiceWindowToViewport(voiceUiContainer); }); - - window.addEventListener('mousemove', (e) => { - if (!isDragging) return; + + header.addEventListener('pointermove', (e) => { + if (!voiceDragState) return; let dx = e.clientX - startX; let dy = e.clientY - startY; voiceUiContainer.style.left = (initialLeft + dx) + "px"; voiceUiContainer.style.top = (initialTop + dy) + "px"; + clampVoiceWindowToViewport(voiceUiContainer); }); - - window.addEventListener('mouseup', () => { isDragging = false; }); + + let stopDrag = () => { voiceDragState = false; }; + header.addEventListener('pointerup', stopDrag); + header.addEventListener('pointercancel', stopDrag); + + ensureVoiceGridObserver(); + + setupVoiceWindowResize(); +} + +function setupVoiceWindowResize() { + const popup = voiceUiContainer; + if (!popup || popup.dataset.resizeInit) return; + popup.dataset.resizeInit = "1"; + + const minW = () => parseFloat(getComputedStyle(popup).minWidth) || 200; + const minH = () => parseFloat(getComputedStyle(popup).minHeight) || 160; + + const applySize = (l, t, w, h) => { + const W = window.innerWidth, H = window.innerHeight; + w = Math.round(Math.min(Math.max(w, minW()), W)); + h = Math.round(Math.min(Math.max(h, minH()), H)); + l = Math.round(Math.min(Math.max(l, 0), W - w)); + t = Math.round(Math.min(Math.max(t, 0), H - h)); + popup.style.right = "auto"; + popup.style.bottom = "auto"; + popup.style.width = w + "px"; + popup.style.height = h + "px"; + popup.style.left = l + "px"; + popup.style.top = t + "px"; + }; + + let edgeResizing = false; + + ["n", "s", "e", "w", "ne", "nw", "se", "sw"].forEach((dir) => { + const handle = document.createElement("div"); + handle.className = `voice-resize-handle voice-resize-${dir}`; + popup.appendChild(handle); + + handle.addEventListener("pointerdown", (e) => { + if (e.button !== undefined && e.button !== 0) return; + e.stopPropagation(); + e.preventDefault(); + edgeResizing = true; + const sx = e.clientX, sy = e.clientY; + const rect = popup.getBoundingClientRect(); + const sw = rect.width, sh = rect.height, sl = rect.left, st = rect.top; + try { handle.setPointerCapture(e.pointerId); } catch (err) {} + + const onMove = (ev) => { + const dx = ev.clientX - sx, dy = ev.clientY - sy; + let w = sw, h = sh, l = sl, t = st; + if (dir.includes("e")) w = sw + dx; + if (dir.includes("s")) h = sh + dy; + if (dir.includes("w")) { + w = sw - dx; + l = sl + (sw - w); + } + if (dir.includes("n")) { + h = sh - dy; + t = st + (sh - h); + } + applySize(l, t, w, h); + }; + const onUp = () => { + edgeResizing = false; + handle.removeEventListener("pointermove", onMove); + handle.removeEventListener("pointerup", onUp); + handle.removeEventListener("pointercancel", onUp); + }; + handle.addEventListener("pointermove", onMove); + handle.addEventListener("pointerup", onUp); + handle.addEventListener("pointercancel", onUp); + }); + }); + + // pinch-to-resize (dwa palce jak zoom) + const pointers = new Map(); + let pinch = null; + let suppressClick = false; + + popup.addEventListener("pointerdown", (e) => { + pointers.set(e.pointerId, { x: e.clientX, y: e.clientY }); + if (pointers.size === 2 && !pinch && !voiceDragState && !edgeResizing) { + const [a, b] = [...pointers.values()]; + const rect = popup.getBoundingClientRect(); + pinch = { + dist: Math.max(20, Math.hypot(a.x - b.x, a.y - b.y)), + w: rect.width, + h: rect.height + }; + suppressClick = true; + } + if (pointers.size >= 2 || pinch) { + try { popup.setPointerCapture(e.pointerId); } catch (err) {} + } + }); + + popup.addEventListener("pointermove", (e) => { + if (!pointers.has(e.pointerId)) return; + pointers.set(e.pointerId, { x: e.clientX, y: e.clientY }); + if (pointers.size === 2 && pinch) { + const [a, b] = [...pointers.values()]; + const scale = Math.max(0.05, Math.hypot(a.x - b.x, a.y - b.y)) / pinch.dist; + const rect = popup.getBoundingClientRect(); + const cx = rect.left + rect.width / 2; + const cy = rect.top + rect.height / 2; + applySize(cx - pinch.w * scale / 2, cy - pinch.h * scale / 2, pinch.w * scale, pinch.h * scale); + } + }); + + const releasePointer = (e) => { + pointers.delete(e.pointerId); + if (pointers.size < 2) pinch = null; + if (pointers.size === 0) setTimeout(() => { suppressClick = false; }, 250); + }; + popup.addEventListener("pointerup", releasePointer); + popup.addEventListener("pointercancel", releasePointer); + + popup.addEventListener("click", (e) => { + if (suppressClick) { + e.stopPropagation(); + e.preventDefault(); + suppressClick = false; + } + }, true); + + if (voiceResizeAbort) voiceResizeAbort.abort(); + voiceResizeAbort = new AbortController(); + window.addEventListener("resize", () => { + clampVoiceWindowToViewport(popup); + }, { signal: voiceResizeAbort.signal }); } function updateVoiceUIUsers() { @@ -610,9 +811,10 @@ function updateVoiceUIUsers() { let div = document.getElementById(`voice-tile-${u}`); if (!div) { div = document.createElement("div"); - div.className = "voice-tile"; + div.className = "voice-tile tile-enter"; div.id = `voice-tile-${u}`; div.onclick = (e) => showVoiceUserSettings(u, e); + setTimeout(() => div.classList.remove("tile-enter"), 400); div.innerHTML = `
${u}
@@ -670,8 +872,10 @@ function updateVoiceUIUsers() { let div = document.getElementById(`voice-tile-stream-${decoderId}`); if (!div) { div = document.createElement("div"); - div.className = "voice-tile"; + div.className = "voice-tile tile-enter"; div.id = `voice-tile-stream-${decoderId}`; + div.onclick = (e) => showVoiceUserSettings(clientId, e, true); + setTimeout(() => div.classList.remove("tile-enter"), 400); let mediaEl = streamData.canvas || streamData.video; if (mediaEl) { @@ -689,7 +893,7 @@ function updateVoiceUIUsers() { if (authorName && authorName.includes(':')) authorName = authorName.split(':')[0]; let nameEl = document.getElementById(`voice-stream-name-${decoderId}`); - if (nameEl) nameEl.innerText = `${authorName || clientId} (${type == '1' ? 'Cam' : 'Screen'})`; + if (nameEl) nameEl.innerText = `${authorName || clientId} (${processBlah(type == '1' ? "label.stream.cam" : "label.stream.screen")})`; } Array.from(container.children).forEach(child => { @@ -700,26 +904,110 @@ function updateVoiceUIUsers() { } } }); + + let orderedTiles = []; + for (let u of voiceActiveUsers) { + let userTile = document.getElementById(`voice-tile-${u}`); + if (userTile) orderedTiles.push(userTile); + for (let decoderId in remoteVideos) { + if (decoderId.split('-')[0] === u) { + let streamTile = document.getElementById(`voice-tile-stream-${decoderId}`); + if (streamTile) orderedTiles.push(streamTile); + } + } + } + for (let tile of orderedTiles) { + container.appendChild(tile); + } + + relayoutVoiceTiles(); } -function showVoiceUserSettings(clientId, e) { +let voiceGridRO = null; + +function ensureVoiceGridObserver() { + let container = document.getElementById("voice-users"); + if (!container || !container.isConnected || container.dataset.roInit) return; + container.dataset.roInit = "1"; + if (!voiceGridRO) { + voiceGridRO = new ResizeObserver(() => relayoutVoiceTiles()); + } + voiceGridRO.observe(container); +} + +function relayoutVoiceTiles() { + let container = document.getElementById("voice-users"); + if (!container || !container.isConnected) return; + + const tiles = Array.from(container.children).filter(el => el.classList.contains("voice-tile")); + const n = tiles.length; + if (!n) return; + + const rootFs = parseFloat(getComputedStyle(document.documentElement).fontSize); + const cs = getComputedStyle(container); + const innerW = container.clientWidth - parseFloat(cs.paddingLeft) - parseFloat(cs.paddingRight); + const innerH = container.clientHeight - parseFloat(cs.paddingTop) - parseFloat(cs.paddingBottom); + const minCol = 6.25 * rootFs; + const minRow = 7 * rootFs; + const gap = 0.75 * rootFs; + + const maxCols = Math.max(1, Math.floor((innerW + gap) / (minCol + gap))); + const cols = Math.min(n, maxCols); + const rows = Math.ceil(n / cols); + const w = Math.floor((innerW - gap * (cols - 1)) / cols); + const h = Math.floor(Math.max(minRow, (innerH - gap * (rows - 1)) / rows)); + + const sig = `${n}:${cols}:${w}x${h}`; + if (container.dataset.layoutSig === sig) return; + container.dataset.layoutSig = sig; + + container.style.setProperty("--voice-tile-w", w + "px"); + container.style.setProperty("--voice-tile-h", h + "px"); +} + +let voiceSettingsState = null; + +function hideVoiceUserSettings() { + let popup = document.getElementById("voice-user-settings"); + if (popup) popup.remove(); + voiceSettingsState = null; + document.removeEventListener('click', closeVoiceUserSettingsOnOutsideClick); +} + +function closeVoiceUserSettingsOnOutsideClick(ev) { + let popup = document.getElementById("voice-user-settings"); + if (!popup) { + hideVoiceUserSettings(); + return; + } + if (!popup.contains(ev.target)) hideVoiceUserSettings(); +} + +function showVoiceUserSettings(clientId, e, isStream = false) { e.stopPropagation(); - let existing = document.getElementById("voice-user-settings"); - if (existing) existing.remove(); - + + if (voiceSettingsState && voiceSettingsState.clientId === clientId && voiceSettingsState.isStream === isStream) { + hideVoiceUserSettings(); + return; + } + hideVoiceUserSettings(); + + voiceSettingsState = { clientId, isStream }; + let popup = document.createElement("div"); popup.id = "voice-user-settings"; - popup.innerHTML = ` + popup.innerHTML = isStream ? `
- - + +
+ ` : `
- - + +
`; - + document.body.appendChild(popup); let rect = popup.getBoundingClientRect(); let left = e.clientX; @@ -731,17 +1019,12 @@ function showVoiceUserSettings(clientId, e) { requestAnimationFrame(() => { requestAnimationFrame(() => popup.classList.add("open")); }); - - const closePopup = (ev) => { - if (!popup.contains(ev.target)) { - popup.remove(); - document.removeEventListener('mousedown', closePopup); - } - }; - setTimeout(() => document.addEventListener('mousedown', closePopup), 10); + + document.addEventListener('click', closeVoiceUserSettingsOnOutsideClick); } window.updateVoiceVol = function(clientId, val, type) { + voiceVolumeState[`${type}:${clientId}`] = val; let el = document.getElementById(`val-${type}-${clientId}`); if (el) el.innerText = val + '%'; const audioId = type === 'screen' ? `${clientId}_screen` : clientId; @@ -816,7 +1099,17 @@ function endVoiceSession() { currentVoiceCallId = null; clearAllSpeaking(); - + + if (voiceResizeAbort) { + voiceResizeAbort.abort(); + voiceResizeAbort = null; + } + + if (voiceGridRO) { + voiceGridRO.disconnect(); + voiceGridRO = null; + } + if (voiceUiContainer) { let popupEl = voiceUiContainer; voiceUiContainer = null; @@ -936,7 +1229,7 @@ async function startHardwareVideo(btnEl, streamPromise, typeLabel, typeId) { } else if (typeId === 1) { camControl = null; } }; return { stop: stopFunction }; - } catch (e) { console.error(e); alert(`No permission or feature not supported: ${typeLabel}`); return null; } + } catch (e) { console.error(e); alert(processBlah(`:error.call.unsupported:${typeLabel}`)); return null; } } function toggleVoiceCam() { @@ -946,7 +1239,7 @@ function toggleVoiceCam() { } else { navigator.mediaDevices.getUserMedia({video: {width: {ideal: 1280}, height: {ideal: 720}}}).then(stream => { startHardwareVideo(btn, Promise.resolve(stream), "Camera", 1).then(ctrl => camControl = ctrl); - }).catch(err => alert("Camera error: " + err.message)); + }).catch(err => alert(processBlah(`:error.call.camera:${err.message}`))); } } @@ -955,11 +1248,11 @@ function toggleVoiceScreen() { if (screenControl) { screenControl.stop(); } else { - if (!navigator.mediaDevices.getDisplayMedia) { alert("Screen sharing is not supported on this browser."); return; } + if (!navigator.mediaDevices.getDisplayMedia) { alert(processBlah("error.call.screen.unsupported")); return; } navigator.mediaDevices.getDisplayMedia({ video: true, audio: { autoGainControl: false, echoCancellation: false, noiseSuppression: false }, systemAudio: "include", selfBrowserSurface: "exclude" }).then(stream => { startHardwareVideo(btn, Promise.resolve(stream), "Screen", 2).then(ctrl => screenControl = ctrl); - }).catch(err => alert("Screen error: " + err.message)); + }).catch(err => alert(processBlah(`:error.call.screen:${err.message}`))); } } diff --git a/electron/assets/icon.png b/electron/assets/icon.png index b50fbd138a234ceab2ee3813cace1e1a5de48f30..a63fdef212f831638a0e5812b778e521c5bb046c 100644 GIT binary patch delta 118 zcmaD|{JMBTJ%>0i1GnVcUia^hZq`L85md@8fpVc1A_w!%>fJy45}rr5hW>! tC8<`q`6-!cl?+A(MrOJO7P?008aCEiznR=-sUU(b@hn5ic=9ewLja;#BRBv6 delta 118 zcmaD|{JMBTJ%<>(EW;74)$tqKcUia^g&3Gy8ChBx7-$<90Li~m3?QId;u=wsl30>z rm7AZEnO4bQWME{bYha;kgs!27eaD{3ZI%im=n}VuW+qSGWoZZiXPF|j diff --git a/electron/package.json b/electron/package.json index ee95df90..d07682a5 100644 --- a/electron/package.json +++ b/electron/package.json @@ -1,6 +1,6 @@ { "name": "Miarven", - "version": "0.1.0", + "version": "0.1.1", "description": "An Amazing Capacitor App", "author": { "name": "", diff --git a/icons/icon.png b/icons/icon.png index b50fbd138a234ceab2ee3813cace1e1a5de48f30..a63fdef212f831638a0e5812b778e521c5bb046c 100644 GIT binary patch delta 118 zcmaD|{JMBTJ%>0i1GnVcUia^hZq`L85md@8fpVc1A_w!%>fJy45}rr5hW>! tC8<`q`6-!cl?+A(MrOJO7P?008aCEiznR=-sUU(b@hn5ic=9ewLja;#BRBv6 delta 118 zcmaD|{JMBTJ%<>(EW;74)$tqKcUia^g&3Gy8ChBx7-$<90Li~m3?QId;u=wsl30>z rm7AZEnO4bQWME{bYha;kgs!27eaD{3ZI%im=n}VuW+qSGWoZZiXPF|j diff --git a/package.json b/package.json index c8778cd2..02b0c6a4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "olcxja.miarven", - "version": "0.1.0", + "version": "0.1.1", "description": "Larpix client", "license": "AGPL-3.0", "author": "olcxja's softworks", diff --git a/version.json b/version.json index 931d63ab..863e6940 100644 --- a/version.json +++ b/version.json @@ -1,5 +1,5 @@ { - "versionCode": 1, - "versionName": "0.1.0", - "npmVersion": "0.1.0" + "versionCode": 2, + "versionName": "0.1.1", + "npmVersion": "0.1.1" } diff --git a/webroot/blah/en-cat.json b/webroot/blah/en-cat.json index ae56e047..2834dbe0 100644 --- a/webroot/blah/en-cat.json +++ b/webroot/blah/en-cat.json @@ -290,5 +290,24 @@ "message.delete.failed": "Failed to delete meow", "message.react.failed": "Failed to react to meow", "message.send.failed": "Failed to send meow", - "owner.transferred": "Purrship transferred successfully" -} \ No newline at end of file + "owner.transferred": "Purrship transferred successfully", + "title.voice.chat": "voice chat :3", + "status.voice.connecting": "connecting... :3", + "status.voice.connected": "connected :3", + "action.call.mute": "mute microphone", + "action.call.camera": "camera", + "action.call.screen": "share screen", + "action.call.disconnect": "disconnect :3", + "action.call.join": "join :3", + "action.call.decline": "decline :c", + "title.incoming.call": "incoming voice chat :3", + "desc.call.in.chat": "{0} in chat :3", + "label.user.volume": "user volume", + "label.stream.volume": "screen volume", + "error.call.unsupported": "no permission or not supported: {0} :c", + "error.call.camera": "camera error: {0} :c", + "error.call.screen": "screen share error: {0} :c", + "error.call.screen.unsupported": "ur browser doesn't support screen sharing :c", + "label.stream.cam": "cam", + "label.stream.screen": "screen" +} diff --git a/webroot/blah/en-us.json b/webroot/blah/en-us.json index 42d98cb7..5486df54 100644 --- a/webroot/blah/en-us.json +++ b/webroot/blah/en-us.json @@ -299,5 +299,24 @@ "message.delete.failed": "Failed to delete message", "message.react.failed": "Failed to react to message", "message.send.failed": "Failed to send message", - "owner.transferred": "Ownership transferred successfully" -} \ No newline at end of file + "owner.transferred": "Ownership transferred successfully", + "title.voice.chat": "Voice Chat", + "status.voice.connecting": "Connecting...", + "status.voice.connected": "Connected", + "action.call.mute": "Mute Microphone", + "action.call.camera": "Camera", + "action.call.screen": "Share Screen", + "action.call.disconnect": "Disconnect", + "action.call.join": "Join", + "action.call.decline": "Decline", + "title.incoming.call": "Incoming Voice Chat", + "desc.call.in.chat": "{0} in chat", + "label.user.volume": "User Volume", + "label.stream.volume": "Screen Volume", + "error.call.unsupported": "No permission or feature not supported: {0}", + "error.call.camera": "Camera error: {0}", + "error.call.screen": "Screen share error: {0}", + "error.call.screen.unsupported": "Screen sharing is not supported on this browser.", + "label.stream.cam": "Cam", + "label.stream.screen": "Screen" +} diff --git a/webroot/blah/pl-pl.json b/webroot/blah/pl-pl.json index d1fea3af..aa9e1fb0 100644 --- a/webroot/blah/pl-pl.json +++ b/webroot/blah/pl-pl.json @@ -299,5 +299,24 @@ "message.delete.failed": "Nie udało się usunąć wiadomości", "message.react.failed": "Nie udało się dodać reakcji", "message.send.failed": "Nie udało się wysłać wiadomości", - "owner.transferred": "Własność przekazana pomyślnie" -} \ No newline at end of file + "owner.transferred": "Własność przekazana pomyślnie", + "title.voice.chat": "Czat głosowy", + "status.voice.connecting": "Łączenie...", + "status.voice.connected": "Połączono", + "action.call.mute": "Wycisz mikrofon", + "action.call.camera": "Kamera", + "action.call.screen": "Udostępnij ekran", + "action.call.disconnect": "Rozłącz", + "action.call.join": "Dołącz", + "action.call.decline": "Odrzuć", + "title.incoming.call": "Połączenie głosowe", + "desc.call.in.chat": "{0} w rozmowie", + "label.user.volume": "Głośność użytkownika", + "label.stream.volume": "Głośność ekranu", + "error.call.unsupported": "Brak uprawnień lub funkcja nieobsługiwana: {0}", + "error.call.camera": "Błąd kamery: {0}", + "error.call.screen": "Błąd udostępniania ekranu: {0}", + "error.call.screen.unsupported": "Udostępnianie ekranu nie jest obsługiwane w tej przeglądarce.", + "label.stream.cam": "Kamera", + "label.stream.screen": "Ekran" +} diff --git a/webroot/style.css b/webroot/style.css index 7c4985ce..b1f08f2d 100644 --- a/webroot/style.css +++ b/webroot/style.css @@ -1298,7 +1298,7 @@ input:checked + .toggle-slider:before { display: flex; flex-direction: column; overflow: hidden; - resize: both; + touch-action: none; opacity: 0; transform: translateY(1.5rem) scale(0.92); @@ -1329,6 +1329,7 @@ input:checked + .toggle-slider:before { font-weight: bold; user-select: none; -webkit-user-select: none; + touch-action: none; border-bottom: var(--border-width) solid var(--light-border-color); } #voice-header:active { @@ -1347,26 +1348,66 @@ input:checked + .toggle-slider:before { } #voice-status { - font-size: 0.75rem; + display: flex; + align-items: center; + gap: 0.4rem; + font-size: 0.8rem; font-weight: 600; color: var(--darker-text-color); - background: rgba(255, 255, 255, 0.05); - border: var(--border-width) solid var(--light-border-color); - padding: 0.15rem 0.6rem; - border-radius: 50rem; + opacity: 0.85; +} +#voice-status .status-dot { + width: 0.55rem; + height: 0.55rem; + flex-shrink: 0; + border-radius: 50%; + background: var(--darker-text-color); +} +#voice-status.connecting .status-dot { + animation: voice-dot-pulse 1.2s ease-in-out infinite; +} +#voice-status.connected { + opacity: 1; +} +#voice-status.connected .status-dot { + background: var(--big-green); + box-shadow: 0 0 0.35rem rgba(120, 200, 120, 0.45); +} +@keyframes voice-dot-pulse { + 50% { opacity: 0.25; } } #voice-users { padding: 0.9375rem; - display: grid; - grid-template-columns: repeat(auto-fit, minmax(6.25rem, 1fr)); + display: flex; + flex-wrap: wrap; + justify-content: center; + align-content: flex-start; gap: 0.75rem; overflow-y: auto; flex-grow: 1; + touch-action: pan-y; } +.voice-resize-handle { + position: absolute; + z-index: 6; + touch-action: none; +} +.voice-resize-n { top: 0; left: 0.6rem; right: 0.6rem; height: 0.45rem; cursor: ns-resize; } +.voice-resize-s { bottom: 0; left: 0.6rem; right: 0.6rem; height: 0.45rem; cursor: ns-resize; } +.voice-resize-e { right: 0; top: 0.6rem; bottom: 0.6rem; width: 0.45rem; cursor: ew-resize; } +.voice-resize-w { left: 0; top: 0.6rem; bottom: 0.6rem; width: 0.45rem; cursor: ew-resize; } +.voice-resize-ne, .voice-resize-nw, .voice-resize-se, .voice-resize-sw { width: 0.8rem; height: 0.8rem; } +.voice-resize-ne { top: 0; right: 0; cursor: nesw-resize; } +.voice-resize-nw { top: 0; left: 0; cursor: nwse-resize; } +.voice-resize-se { bottom: 0; right: 0; cursor: nwse-resize; } +.voice-resize-sw { bottom: 0; left: 0; cursor: nesw-resize; } + .voice-tile { position: relative; + width: var(--voice-tile-w, 9rem); + height: var(--voice-tile-h, 8rem); background: var(--light-border-color); background-size: cover; background-position: center; @@ -1375,18 +1416,17 @@ input:checked + .toggle-slider:before { cursor: pointer; border: var(--border-width) solid transparent; box-shadow: 0 0.25rem 0.625rem rgba(0, 0, 0, 0.3); - animation: voice-tile-in 0.25s cubic-bezier(0.175, 0.885, 0.32, 1.275); - transition: border-color 0.15s ease, box-shadow 0.2s ease, transform 0.2s ease; + transition: border-color 0.15s ease, box-shadow 0.2s ease; } -.voice-tile:hover { - transform: translateY(-0.125rem); +.voice-tile.tile-enter { + animation: voice-tile-in 0.25s cubic-bezier(0.175, 0.885, 0.32, 1.275); } .voice-tile video, .voice-tile canvas { display: block; width: 100%; height: 100%; - object-fit: cover; + object-fit: contain; background: #000; } @keyframes voice-tile-in { @@ -1502,9 +1542,12 @@ input:checked + .toggle-slider:before { /* Incoming Call Popup */ #incoming-call-popup { position: fixed; - top: 1.5rem; - left: 50%; - transform: translateX(-50%) translateY(calc(-100% - 3rem)); + top: calc(env(safe-area-inset-top, 0rem) + 4.5rem); + left: 0; + right: 0; + margin-inline: auto; + width: fit-content; + max-width: calc(100vw - 2rem); display: flex; align-items: center; gap: 1rem; @@ -1518,39 +1561,22 @@ input:checked + .toggle-slider:before { z-index: 9999; opacity: 0; transition: - opacity 0.25s ease, - transform 0.35s cubic-bezier(0.175, 0.885, 0.32, 1.275); + opacity 0.2s ease, + transform 0.25s cubic-bezier(0.175, 0.885, 0.32, 1.275); } #incoming-call-popup.show { opacity: 1; - transform: translateX(-50%) translateY(0); -} -#incoming-call-popup.hide { - opacity: 0; - transform: translateX(-50%) translateY(calc(-100% - 2rem)); + transform: translateY(0); } -.incoming-call-icon { +.incoming-call-avatar { width: 2.5rem; height: 2.5rem; flex-shrink: 0; - border-radius: 50%; - background: var(--big-green); - color: white !important; - display: flex; - align-items: center; - justify-content: center; - animation: voice-call-pulse 1.5s ease-out infinite; -} -.incoming-call-icon svg { - width: 1.25rem; - height: 1.25rem; - pointer-events: none !important; -} -@keyframes voice-call-pulse { - 0% { box-shadow: 0 0 0 0 hsla(120, 60%, 55%, 0.45); } - 70% { box-shadow: 0 0 0 0.8rem hsla(120, 60%, 55%, 0); } - 100% { box-shadow: 0 0 0 0 hsla(120, 60%, 55%, 0); } + border-radius: 0.65rem; + border: var(--border-width) solid rgba(255, 255, 255, 0.2); + object-fit: cover; + aspect-ratio: 1 / 1; } .incoming-call-info { @@ -1558,8 +1584,15 @@ input:checked + .toggle-slider:before { flex-direction: column; gap: 0.1rem; text-align: left; + min-width: 0; } -.incoming-call-title { +.incoming-call-name, +.incoming-call-subtitle { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.incoming-call-name { font-weight: bold; font-size: 1.05rem; color: var(--text-color); @@ -1603,15 +1636,13 @@ input:checked + .toggle-slider:before { color: white !important; } .voice-call-btn.decline { - background: rgba(255, 255, 255, 0.05) !important; - border: var(--border-width) solid var(--light-border-color) !important; - color: var(--text-color) !important; -} -.voice-call-btn.accept:hover { - filter: brightness(1.12); + background: var(--big-red) !important; + border: none !important; + color: white !important; } +.voice-call-btn.accept:hover, .voice-call-btn.decline:hover { - background: rgba(255, 255, 255, 0.1) !important; + filter: brightness(0.85); } .voice-call-btn:active, .voice-call-btn.is-pressed { transform: var(--press-scale); @@ -1672,13 +1703,16 @@ input:checked + .toggle-slider:before { @media (max-width: 55rem) { #voice-call-popup { - width: calc(100vw - 2rem); - height: 60vh; - left: 1rem; - right: unset; + width: 16rem; + height: 17rem; + min-width: 13rem; + min-height: 12rem; } #incoming-call-popup { - width: calc(100vw - 2rem); - box-sizing: border-box; + gap: 0.75rem; + padding: 0.6rem 0.6rem 0.6rem 0.8rem; + } + .voice-call-btn { + padding: 0 0.75rem; } } diff --git a/webroot/version.js b/webroot/version.js index a0465de1..62bf5330 100644 --- a/webroot/version.js +++ b/webroot/version.js @@ -1 +1 @@ -const CLIENT_VERSION = "0.1.0"; +const CLIENT_VERSION = "0.1.1"; diff --git a/webroot/voice.js b/webroot/voice.js index 7c3e5616..fb1a8f03 100644 --- a/webroot/voice.js +++ b/webroot/voice.js @@ -17,6 +17,42 @@ let camControl = null, screenControl = null; const SPEAKING_HANGOVER_MS = 400; +let voiceDragState = false; +let voiceResizeAbort = null; + +function clampVoiceWindowToViewport(popupEl) { + const r = popupEl.getBoundingClientRect(); + const W = window.innerWidth, H = window.innerHeight; + const w = Math.min(r.width, W); + const h = Math.min(r.height, H); + const l = Math.min(Math.max(r.left, 0), W - w); + const t = Math.min(Math.max(r.top, 0), H - h); + if (l !== r.left || t !== r.top || w !== r.width || h !== r.height) { + popupEl.style.right = "auto"; + popupEl.style.bottom = "auto"; + popupEl.style.width = w + "px"; + popupEl.style.height = h + "px"; + popupEl.style.left = l + "px"; + popupEl.style.top = t + "px"; + } +} + +let voiceVolumeState = {}; + +function getStoredVolume(type, clientId) { + let v = voiceVolumeState[`${type}:${clientId}`]; + return v === undefined ? 100 : v; +} + +function setVoiceStatus(state) { + let el = document.getElementById("voice-status"); + if (!el) return; + el.classList.toggle("connected", state === "connected"); + el.classList.toggle("connecting", state !== "connected"); + let textEl = el.querySelector(".voice-status-text"); + if (textEl) textEl.innerText = processBlah(state === "connected" ? "status.voice.connected" : "status.voice.connecting"); +} + function setTileSpeaking(clientId, speaking) { let tile = document.getElementById(`voice-tile-${clientId}`); if (!tile) return; @@ -171,7 +207,7 @@ async function initiateVoiceCall(roomId = null) { async function startVoiceSession(serverUrl) { showVoiceUI(); updateVoiceUIUsers(); - document.getElementById("voice-status").innerText = "Connecting..."; + setVoiceStatus("connecting"); try { const wsUrl = new URL(serverUrl); @@ -186,8 +222,7 @@ async function startVoiceSession(serverUrl) { currentVoiceWs.binaryType = 'arraybuffer'; currentVoiceWs.onopen = async () => { - document.getElementById("voice-status").innerText = "Connected"; - document.getElementById("voice-status").style.color = "var(--big-green)"; + setVoiceStatus("connected"); let btn = document.getElementById("voice-btn-mic"); if (btn) { if (isMicMuted) btn.classList.add("voice-btn-danger"); @@ -449,25 +484,47 @@ function handleCallUpdate(data) { } } +async function resolveCallerInfo(roomId) { + let name = window.dmUsernameCache ? window.dmUsernameCache[roomId] : null; + if (name && name.includes(':')) name = name.split(':')[0]; + if (!name && window.fetchAsync) { + try { + let fetched = await window.fetchAsync(`${window.url}/idtoname?id=${roomId}`); + if (fetched && !fetched.startsWith("error:")) { + name = fetched.includes(':') ? fetched.split(':')[0] : fetched; + if (window.dmUsernameCache) window.dmUsernameCache[roomId] = name; + } + } catch (e) {} + } + let pfpUrl = window.dmPfpCache ? window.dmPfpCache[roomId] : null; + if (!pfpUrl && window.getAvatarUrl) { + try { pfpUrl = await window.getAvatarUrl(roomId, name || "User"); } catch (e) {} + if (pfpUrl && window.dmPfpCache) window.dmPfpCache[roomId] = pfpUrl; + } + return { name: name || processBlah("title.unknown"), pfpUrl }; +} + function showIncomingCallUI(roomId) { if (document.getElementById("incoming-call-popup") || currentVoiceWs) return; + let cachedName = window.dmUsernameCache ? window.dmUsernameCache[roomId] : null; + if (cachedName && cachedName.includes(':')) cachedName = cachedName.split(':')[0]; + let cachedPfp = window.dmPfpCache ? window.dmPfpCache[roomId] : null; + let incomingUi = document.createElement("div"); incomingUi.id = "incoming-call-popup"; incomingUi.innerHTML = ` -
- ${ICONS.call({width: "1.25rem", height: "1.25rem"})} -
+
- ${processBlah("title.incoming.call") || "Incoming Voice Chat"} - ${voiceActiveUsers.length} in chat + ${cachedName || "..."} + ${processBlah("title.incoming.call")} · ${processBlah(`:desc.call.in.chat:${voiceActiveUsers.length}`)}
`; @@ -475,6 +532,14 @@ function showIncomingCallUI(roomId) { requestAnimationFrame(() => { requestAnimationFrame(() => incomingUi.classList.add("show")); }); + + resolveCallerInfo(roomId).then(({name, pfpUrl}) => { + if (!document.body.contains(incomingUi)) return; + let nameEl = incomingUi.querySelector(".incoming-call-name"); + let avatarEl = incomingUi.querySelector(".incoming-call-avatar"); + if (nameEl) nameEl.innerText = name; + if (avatarEl && pfpUrl) avatarEl.src = pfpUrl; + }); } function dismissIncomingCall() { @@ -482,7 +547,7 @@ function dismissIncomingCall() { if (!el || el.classList.contains("hide")) return; el.classList.remove("show"); el.classList.add("hide"); - setTimeout(() => el.remove(), 400); + setTimeout(() => el.remove(), 300); } function joinIncomingCall(roomId) { @@ -545,25 +610,28 @@ function showVoiceUI() {
${ICONS.call({width: "1.1rem", height: "1.1rem"})} - Voice Chat + ${processBlah("title.voice.chat")} +
+
+ + ${processBlah("status.voice.connecting")}
-
Connecting...
@@ -575,11 +643,12 @@ function showVoiceUI() { }); let header = document.getElementById("voice-header"); - let isDragging = false; let startX, startY, initialLeft, initialTop; - - header.addEventListener('mousedown', (e) => { - isDragging = true; + + header.addEventListener('pointerdown', (e) => { + if (e.button !== undefined && e.button !== 0) return; + voiceDragState = true; + try { header.setPointerCapture(e.pointerId); } catch (err) {} let rect = voiceUiContainer.getBoundingClientRect(); initialLeft = rect.left; initialTop = rect.top; @@ -589,17 +658,149 @@ function showVoiceUI() { voiceUiContainer.style.bottom = "auto"; voiceUiContainer.style.left = initialLeft + "px"; voiceUiContainer.style.top = initialTop + "px"; + clampVoiceWindowToViewport(voiceUiContainer); }); - - window.addEventListener('mousemove', (e) => { - if (!isDragging) return; + + header.addEventListener('pointermove', (e) => { + if (!voiceDragState) return; let dx = e.clientX - startX; let dy = e.clientY - startY; voiceUiContainer.style.left = (initialLeft + dx) + "px"; voiceUiContainer.style.top = (initialTop + dy) + "px"; + clampVoiceWindowToViewport(voiceUiContainer); }); - - window.addEventListener('mouseup', () => { isDragging = false; }); + + let stopDrag = () => { voiceDragState = false; }; + header.addEventListener('pointerup', stopDrag); + header.addEventListener('pointercancel', stopDrag); + + ensureVoiceGridObserver(); + + setupVoiceWindowResize(); +} + +function setupVoiceWindowResize() { + const popup = voiceUiContainer; + if (!popup || popup.dataset.resizeInit) return; + popup.dataset.resizeInit = "1"; + + const minW = () => parseFloat(getComputedStyle(popup).minWidth) || 200; + const minH = () => parseFloat(getComputedStyle(popup).minHeight) || 160; + + const applySize = (l, t, w, h) => { + const W = window.innerWidth, H = window.innerHeight; + w = Math.round(Math.min(Math.max(w, minW()), W)); + h = Math.round(Math.min(Math.max(h, minH()), H)); + l = Math.round(Math.min(Math.max(l, 0), W - w)); + t = Math.round(Math.min(Math.max(t, 0), H - h)); + popup.style.right = "auto"; + popup.style.bottom = "auto"; + popup.style.width = w + "px"; + popup.style.height = h + "px"; + popup.style.left = l + "px"; + popup.style.top = t + "px"; + }; + + let edgeResizing = false; + + ["n", "s", "e", "w", "ne", "nw", "se", "sw"].forEach((dir) => { + const handle = document.createElement("div"); + handle.className = `voice-resize-handle voice-resize-${dir}`; + popup.appendChild(handle); + + handle.addEventListener("pointerdown", (e) => { + if (e.button !== undefined && e.button !== 0) return; + e.stopPropagation(); + e.preventDefault(); + edgeResizing = true; + const sx = e.clientX, sy = e.clientY; + const rect = popup.getBoundingClientRect(); + const sw = rect.width, sh = rect.height, sl = rect.left, st = rect.top; + try { handle.setPointerCapture(e.pointerId); } catch (err) {} + + const onMove = (ev) => { + const dx = ev.clientX - sx, dy = ev.clientY - sy; + let w = sw, h = sh, l = sl, t = st; + if (dir.includes("e")) w = sw + dx; + if (dir.includes("s")) h = sh + dy; + if (dir.includes("w")) { + w = sw - dx; + l = sl + (sw - w); + } + if (dir.includes("n")) { + h = sh - dy; + t = st + (sh - h); + } + applySize(l, t, w, h); + }; + const onUp = () => { + edgeResizing = false; + handle.removeEventListener("pointermove", onMove); + handle.removeEventListener("pointerup", onUp); + handle.removeEventListener("pointercancel", onUp); + }; + handle.addEventListener("pointermove", onMove); + handle.addEventListener("pointerup", onUp); + handle.addEventListener("pointercancel", onUp); + }); + }); + + // pinch-to-resize (dwa palce jak zoom) + const pointers = new Map(); + let pinch = null; + let suppressClick = false; + + popup.addEventListener("pointerdown", (e) => { + pointers.set(e.pointerId, { x: e.clientX, y: e.clientY }); + if (pointers.size === 2 && !pinch && !voiceDragState && !edgeResizing) { + const [a, b] = [...pointers.values()]; + const rect = popup.getBoundingClientRect(); + pinch = { + dist: Math.max(20, Math.hypot(a.x - b.x, a.y - b.y)), + w: rect.width, + h: rect.height + }; + suppressClick = true; + } + if (pointers.size >= 2 || pinch) { + try { popup.setPointerCapture(e.pointerId); } catch (err) {} + } + }); + + popup.addEventListener("pointermove", (e) => { + if (!pointers.has(e.pointerId)) return; + pointers.set(e.pointerId, { x: e.clientX, y: e.clientY }); + if (pointers.size === 2 && pinch) { + const [a, b] = [...pointers.values()]; + const scale = Math.max(0.05, Math.hypot(a.x - b.x, a.y - b.y)) / pinch.dist; + const rect = popup.getBoundingClientRect(); + const cx = rect.left + rect.width / 2; + const cy = rect.top + rect.height / 2; + applySize(cx - pinch.w * scale / 2, cy - pinch.h * scale / 2, pinch.w * scale, pinch.h * scale); + } + }); + + const releasePointer = (e) => { + pointers.delete(e.pointerId); + if (pointers.size < 2) pinch = null; + if (pointers.size === 0) setTimeout(() => { suppressClick = false; }, 250); + }; + popup.addEventListener("pointerup", releasePointer); + popup.addEventListener("pointercancel", releasePointer); + + popup.addEventListener("click", (e) => { + if (suppressClick) { + e.stopPropagation(); + e.preventDefault(); + suppressClick = false; + } + }, true); + + if (voiceResizeAbort) voiceResizeAbort.abort(); + voiceResizeAbort = new AbortController(); + window.addEventListener("resize", () => { + clampVoiceWindowToViewport(popup); + }, { signal: voiceResizeAbort.signal }); } function updateVoiceUIUsers() { @@ -610,9 +811,10 @@ function updateVoiceUIUsers() { let div = document.getElementById(`voice-tile-${u}`); if (!div) { div = document.createElement("div"); - div.className = "voice-tile"; + div.className = "voice-tile tile-enter"; div.id = `voice-tile-${u}`; div.onclick = (e) => showVoiceUserSettings(u, e); + setTimeout(() => div.classList.remove("tile-enter"), 400); div.innerHTML = `
${u}
@@ -670,8 +872,10 @@ function updateVoiceUIUsers() { let div = document.getElementById(`voice-tile-stream-${decoderId}`); if (!div) { div = document.createElement("div"); - div.className = "voice-tile"; + div.className = "voice-tile tile-enter"; div.id = `voice-tile-stream-${decoderId}`; + div.onclick = (e) => showVoiceUserSettings(clientId, e, true); + setTimeout(() => div.classList.remove("tile-enter"), 400); let mediaEl = streamData.canvas || streamData.video; if (mediaEl) { @@ -689,7 +893,7 @@ function updateVoiceUIUsers() { if (authorName && authorName.includes(':')) authorName = authorName.split(':')[0]; let nameEl = document.getElementById(`voice-stream-name-${decoderId}`); - if (nameEl) nameEl.innerText = `${authorName || clientId} (${type == '1' ? 'Cam' : 'Screen'})`; + if (nameEl) nameEl.innerText = `${authorName || clientId} (${processBlah(type == '1' ? "label.stream.cam" : "label.stream.screen")})`; } Array.from(container.children).forEach(child => { @@ -700,26 +904,110 @@ function updateVoiceUIUsers() { } } }); + + let orderedTiles = []; + for (let u of voiceActiveUsers) { + let userTile = document.getElementById(`voice-tile-${u}`); + if (userTile) orderedTiles.push(userTile); + for (let decoderId in remoteVideos) { + if (decoderId.split('-')[0] === u) { + let streamTile = document.getElementById(`voice-tile-stream-${decoderId}`); + if (streamTile) orderedTiles.push(streamTile); + } + } + } + for (let tile of orderedTiles) { + container.appendChild(tile); + } + + relayoutVoiceTiles(); } -function showVoiceUserSettings(clientId, e) { +let voiceGridRO = null; + +function ensureVoiceGridObserver() { + let container = document.getElementById("voice-users"); + if (!container || !container.isConnected || container.dataset.roInit) return; + container.dataset.roInit = "1"; + if (!voiceGridRO) { + voiceGridRO = new ResizeObserver(() => relayoutVoiceTiles()); + } + voiceGridRO.observe(container); +} + +function relayoutVoiceTiles() { + let container = document.getElementById("voice-users"); + if (!container || !container.isConnected) return; + + const tiles = Array.from(container.children).filter(el => el.classList.contains("voice-tile")); + const n = tiles.length; + if (!n) return; + + const rootFs = parseFloat(getComputedStyle(document.documentElement).fontSize); + const cs = getComputedStyle(container); + const innerW = container.clientWidth - parseFloat(cs.paddingLeft) - parseFloat(cs.paddingRight); + const innerH = container.clientHeight - parseFloat(cs.paddingTop) - parseFloat(cs.paddingBottom); + const minCol = 6.25 * rootFs; + const minRow = 7 * rootFs; + const gap = 0.75 * rootFs; + + const maxCols = Math.max(1, Math.floor((innerW + gap) / (minCol + gap))); + const cols = Math.min(n, maxCols); + const rows = Math.ceil(n / cols); + const w = Math.floor((innerW - gap * (cols - 1)) / cols); + const h = Math.floor(Math.max(minRow, (innerH - gap * (rows - 1)) / rows)); + + const sig = `${n}:${cols}:${w}x${h}`; + if (container.dataset.layoutSig === sig) return; + container.dataset.layoutSig = sig; + + container.style.setProperty("--voice-tile-w", w + "px"); + container.style.setProperty("--voice-tile-h", h + "px"); +} + +let voiceSettingsState = null; + +function hideVoiceUserSettings() { + let popup = document.getElementById("voice-user-settings"); + if (popup) popup.remove(); + voiceSettingsState = null; + document.removeEventListener('click', closeVoiceUserSettingsOnOutsideClick); +} + +function closeVoiceUserSettingsOnOutsideClick(ev) { + let popup = document.getElementById("voice-user-settings"); + if (!popup) { + hideVoiceUserSettings(); + return; + } + if (!popup.contains(ev.target)) hideVoiceUserSettings(); +} + +function showVoiceUserSettings(clientId, e, isStream = false) { e.stopPropagation(); - let existing = document.getElementById("voice-user-settings"); - if (existing) existing.remove(); - + + if (voiceSettingsState && voiceSettingsState.clientId === clientId && voiceSettingsState.isStream === isStream) { + hideVoiceUserSettings(); + return; + } + hideVoiceUserSettings(); + + voiceSettingsState = { clientId, isStream }; + let popup = document.createElement("div"); popup.id = "voice-user-settings"; - popup.innerHTML = ` + popup.innerHTML = isStream ? `
- - + +
+ ` : `
- - + +
`; - + document.body.appendChild(popup); let rect = popup.getBoundingClientRect(); let left = e.clientX; @@ -731,17 +1019,12 @@ function showVoiceUserSettings(clientId, e) { requestAnimationFrame(() => { requestAnimationFrame(() => popup.classList.add("open")); }); - - const closePopup = (ev) => { - if (!popup.contains(ev.target)) { - popup.remove(); - document.removeEventListener('mousedown', closePopup); - } - }; - setTimeout(() => document.addEventListener('mousedown', closePopup), 10); + + document.addEventListener('click', closeVoiceUserSettingsOnOutsideClick); } window.updateVoiceVol = function(clientId, val, type) { + voiceVolumeState[`${type}:${clientId}`] = val; let el = document.getElementById(`val-${type}-${clientId}`); if (el) el.innerText = val + '%'; const audioId = type === 'screen' ? `${clientId}_screen` : clientId; @@ -816,7 +1099,17 @@ function endVoiceSession() { currentVoiceCallId = null; clearAllSpeaking(); - + + if (voiceResizeAbort) { + voiceResizeAbort.abort(); + voiceResizeAbort = null; + } + + if (voiceGridRO) { + voiceGridRO.disconnect(); + voiceGridRO = null; + } + if (voiceUiContainer) { let popupEl = voiceUiContainer; voiceUiContainer = null; @@ -936,7 +1229,7 @@ async function startHardwareVideo(btnEl, streamPromise, typeLabel, typeId) { } else if (typeId === 1) { camControl = null; } }; return { stop: stopFunction }; - } catch (e) { console.error(e); alert(`No permission or feature not supported: ${typeLabel}`); return null; } + } catch (e) { console.error(e); alert(processBlah(`:error.call.unsupported:${typeLabel}`)); return null; } } function toggleVoiceCam() { @@ -946,7 +1239,7 @@ function toggleVoiceCam() { } else { navigator.mediaDevices.getUserMedia({video: {width: {ideal: 1280}, height: {ideal: 720}}}).then(stream => { startHardwareVideo(btn, Promise.resolve(stream), "Camera", 1).then(ctrl => camControl = ctrl); - }).catch(err => alert("Camera error: " + err.message)); + }).catch(err => alert(processBlah(`:error.call.camera:${err.message}`))); } } @@ -955,11 +1248,11 @@ function toggleVoiceScreen() { if (screenControl) { screenControl.stop(); } else { - if (!navigator.mediaDevices.getDisplayMedia) { alert("Screen sharing is not supported on this browser."); return; } + if (!navigator.mediaDevices.getDisplayMedia) { alert(processBlah("error.call.screen.unsupported")); return; } navigator.mediaDevices.getDisplayMedia({ video: true, audio: { autoGainControl: false, echoCancellation: false, noiseSuppression: false }, systemAudio: "include", selfBrowserSurface: "exclude" }).then(stream => { startHardwareVideo(btn, Promise.resolve(stream), "Screen", 2).then(ctrl => screenControl = ctrl); - }).catch(err => alert("Screen error: " + err.message)); + }).catch(err => alert(processBlah(`:error.call.screen:${err.message}`))); } } From 88dde78581c3215d5c081c3c859516ec525a1003 Mon Sep 17 00:00:00 2001 From: olcxja Date: Sun, 23 Aug 2026 15:36:30 +0200 Subject: [PATCH 3/3] fix group call popup & improve vc topbar design --- .../src/main/assets/public/blah/en-cat.json | 2 +- .../src/main/assets/public/blah/en-us.json | 2 +- .../src/main/assets/public/blah/pl-pl.json | 2 +- android/app/src/main/assets/public/style.css | 30 +++- android/app/src/main/assets/public/voice.js | 141 +++++++++++++++--- webroot/blah/en-cat.json | 2 +- webroot/blah/en-us.json | 2 +- webroot/blah/pl-pl.json | 2 +- webroot/style.css | 30 +++- webroot/voice.js | 141 +++++++++++++++--- 10 files changed, 292 insertions(+), 62 deletions(-) diff --git a/android/app/src/main/assets/public/blah/en-cat.json b/android/app/src/main/assets/public/blah/en-cat.json index 2834dbe0..db64989f 100644 --- a/android/app/src/main/assets/public/blah/en-cat.json +++ b/android/app/src/main/assets/public/blah/en-cat.json @@ -293,7 +293,7 @@ "owner.transferred": "Purrship transferred successfully", "title.voice.chat": "voice chat :3", "status.voice.connecting": "connecting... :3", - "status.voice.connected": "connected :3", + "status.voice.disconnected": "disconnected :3", "action.call.mute": "mute microphone", "action.call.camera": "camera", "action.call.screen": "share screen", diff --git a/android/app/src/main/assets/public/blah/en-us.json b/android/app/src/main/assets/public/blah/en-us.json index 5486df54..ee67f52d 100644 --- a/android/app/src/main/assets/public/blah/en-us.json +++ b/android/app/src/main/assets/public/blah/en-us.json @@ -302,7 +302,7 @@ "owner.transferred": "Ownership transferred successfully", "title.voice.chat": "Voice Chat", "status.voice.connecting": "Connecting...", - "status.voice.connected": "Connected", + "status.voice.disconnected": "Disconnected", "action.call.mute": "Mute Microphone", "action.call.camera": "Camera", "action.call.screen": "Share Screen", diff --git a/android/app/src/main/assets/public/blah/pl-pl.json b/android/app/src/main/assets/public/blah/pl-pl.json index aa9e1fb0..7d6f14ab 100644 --- a/android/app/src/main/assets/public/blah/pl-pl.json +++ b/android/app/src/main/assets/public/blah/pl-pl.json @@ -302,7 +302,7 @@ "owner.transferred": "Własność przekazana pomyślnie", "title.voice.chat": "Czat głosowy", "status.voice.connecting": "Łączenie...", - "status.voice.connected": "Połączono", + "status.voice.disconnected": "Rozłączono", "action.call.mute": "Wycisz mikrofon", "action.call.camera": "Kamera", "action.call.screen": "Udostępnij ekran", diff --git a/android/app/src/main/assets/public/style.css b/android/app/src/main/assets/public/style.css index b1f08f2d..5e495bcb 100644 --- a/android/app/src/main/assets/public/style.css +++ b/android/app/src/main/assets/public/style.css @@ -1340,6 +1340,15 @@ input:checked + .toggle-slider:before { align-items: center; gap: 0.5rem; color: var(--text-color); + min-width: 0; +} +.voice-title span { + font-size: 0.95rem; + font-weight: 700; + max-width: 16rem; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } .voice-title svg { width: 1.1rem; @@ -1351,10 +1360,12 @@ input:checked + .toggle-slider:before { display: flex; align-items: center; gap: 0.4rem; - font-size: 0.8rem; - font-weight: 600; + flex-shrink: 0; + font-size: 0.75rem; + font-weight: 500; color: var(--darker-text-color); - opacity: 0.85; + opacity: 0.75; + font-variant-numeric: tabular-nums; } #voice-status .status-dot { width: 0.55rem; @@ -1364,14 +1375,17 @@ input:checked + .toggle-slider:before { background: var(--darker-text-color); } #voice-status.connecting .status-dot { + background: hsl(35, 90%, 55%); animation: voice-dot-pulse 1.2s ease-in-out infinite; } -#voice-status.connected { - opacity: 1; -} #voice-status.connected .status-dot { background: var(--big-green); - box-shadow: 0 0 0.35rem rgba(120, 200, 120, 0.45); +} +#voice-status.disconnected .status-dot { + background: var(--big-red); +} +.status-ping:empty { + display: none; } @keyframes voice-dot-pulse { 50% { opacity: 0.25; } @@ -1414,7 +1428,7 @@ input:checked + .toggle-slider:before { border-radius: 0.65rem; overflow: hidden; cursor: pointer; - border: var(--border-width) solid transparent; + border: calc(var(--border-width) * 2) solid rgba(85, 85, 85); box-shadow: 0 0.25rem 0.625rem rgba(0, 0, 0, 0.3); transition: border-color 0.15s ease, box-shadow 0.2s ease; } diff --git a/android/app/src/main/assets/public/voice.js b/android/app/src/main/assets/public/voice.js index fb1a8f03..b3a68a16 100644 --- a/android/app/src/main/assets/public/voice.js +++ b/android/app/src/main/assets/public/voice.js @@ -44,13 +44,48 @@ function getStoredVolume(type, clientId) { return v === undefined ? 100 : v; } +let voiceConnectedAt = null; +let voiceTimerInterval = null; +let voicePingMs = null; + +function formatVoiceDuration(ms) { + let totalSec = Math.floor(ms / 1000); + let h = Math.floor(totalSec / 3600); + let m = Math.floor((totalSec % 3600) / 60); + let s = totalSec % 60; + let mm = String(m).padStart(2, "0"), ss = String(s).padStart(2, "0"); + return h > 0 ? `${h}:${mm}:${ss}` : `${m}:${ss}`; +} + +function updateVoicePing() { + let pingEl = document.getElementById("voice-ping"); + if (pingEl) pingEl.innerText = voicePingMs !== null ? `${voicePingMs} ms` : ""; +} + function setVoiceStatus(state) { let el = document.getElementById("voice-status"); if (!el) return; el.classList.toggle("connected", state === "connected"); - el.classList.toggle("connecting", state !== "connected"); + el.classList.toggle("connecting", state === "connecting"); + el.classList.toggle("disconnected", state === "disconnected"); + let textEl = el.querySelector(".voice-status-text"); - if (textEl) textEl.innerText = processBlah(state === "connected" ? "status.voice.connected" : "status.voice.connecting"); + if (state === "connected") { + if (voiceConnectedAt === null) voiceConnectedAt = Date.now(); + if (!voiceTimerInterval) { + voiceTimerInterval = setInterval(() => { + let tickEl = document.querySelector("#voice-status .voice-status-text"); + if (tickEl && voiceConnectedAt !== null) tickEl.innerText = formatVoiceDuration(Date.now() - voiceConnectedAt); + }, 1000); + } + if (textEl) textEl.innerText = formatVoiceDuration(Date.now() - voiceConnectedAt); + updateVoicePing(); + } else { + if (voiceTimerInterval) { clearInterval(voiceTimerInterval); voiceTimerInterval = null; } + voiceConnectedAt = null; + if (state === "disconnected") { voicePingMs = null; updateVoicePing(); } + if (textEl) textEl.innerText = processBlah(state === "disconnected" ? "status.voice.disconnected" : "status.voice.connecting"); + } } function setTileSpeaking(clientId, speaking) { @@ -208,6 +243,7 @@ async function startVoiceSession(serverUrl) { showVoiceUI(); updateVoiceUIUsers(); setVoiceStatus("connecting"); + updateVoiceChatTitle(); try { const wsUrl = new URL(serverUrl); @@ -234,8 +270,11 @@ async function startVoiceSession(serverUrl) { try { const baseUrl = currentVoiceWs.url.split('/ws')[0]; const httpUrl = baseUrl.replace('ws:', 'http:').replace('wss:', 'https:'); + const t0 = performance.now(); const res = await fetch(`${httpUrl}/room/users?room=${currentVoiceCallId}`); const usersRaw = await res.json(); + voicePingMs = Math.round(performance.now() - t0); + updateVoicePing(); const users = usersRaw.map(u => String(u)); let changed = (users.length !== voiceActiveUsers.length); for(let u of users) if(!voiceActiveUsers.includes(u)) changed = true; @@ -485,23 +524,46 @@ function handleCallUpdate(data) { } async function resolveCallerInfo(roomId) { - let name = window.dmUsernameCache ? window.dmUsernameCache[roomId] : null; - if (name && name.includes(':')) name = name.split(':')[0]; - if (!name && window.fetchAsync) { + if (window.fetchEncrypted) { try { - let fetched = await window.fetchAsync(`${window.url}/idtoname?id=${roomId}`); - if (fetched && !fetched.startsWith("error:")) { - name = fetched.includes(':') ? fetched.split(':')[0] : fetched; - if (window.dmUsernameCache) window.dmUsernameCache[roomId] = name; + let infoPayload = await fetchEncrypted("group/info", roomId); + if (infoPayload && infoPayload.startsWith("{")) { + let info = JSON.parse(infoPayload); + if (info.string2) { + let pfpUrl = null; + if (window.getGroupAvatarUrl) { + try { pfpUrl = await getGroupAvatarUrl(roomId, info.string2); } catch (e) {} + } + return { name: info.string2, pfpUrl }; + } } } catch (e) {} } - let pfpUrl = window.dmPfpCache ? window.dmPfpCache[roomId] : null; - if (!pfpUrl && window.getAvatarUrl) { - try { pfpUrl = await window.getAvatarUrl(roomId, name || "User"); } catch (e) {} - if (pfpUrl && window.dmPfpCache) window.dmPfpCache[roomId] = pfpUrl; + + let targetId = String(roomId); + let parts = targetId.split('_'); + let myId = `${window.id};${window.host}`; + if (parts.length === 2 && (parts[0] === myId || parts[1] === myId)) { + targetId = parts[0] === myId ? parts[1] : parts[0]; } - return { name: name || processBlah("title.unknown"), pfpUrl }; + + let name = window.dmUsernameCache ? window.dmUsernameCache[targetId] : null; + if (!name && window.fetchAsync) { + try { + let fetched = await window.fetchAsync(`${window.url}/idtoname?id=${targetId}`); + if (fetched && !fetched.startsWith("error:")) { + name = fetched; + if (window.dmUsernameCache) window.dmUsernameCache[targetId] = fetched; + } + } catch (e) {} + } + + let pfpUrl = window.dmPfpCache ? window.dmPfpCache[targetId] : null; + if (!pfpUrl && window.getAvatarUrl) { + try { pfpUrl = await window.getAvatarUrl(targetId, name || "User"); } catch (e) {} + if (pfpUrl && window.dmPfpCache) window.dmPfpCache[targetId] = pfpUrl; + } + return { name: (name ? name.split(':')[0] : null) || processBlah("title.unknown"), pfpUrl }; } function showIncomingCallUI(roomId) { @@ -600,6 +662,39 @@ async function handleRoomSignal(data) { } } +async function resolveVoiceChatTitle() { + try { + if (currentVoiceCallType === 'groups') { + if (!currentVoiceCallId || !window.fetchEncrypted) return null; + let infoPayload = await fetchEncrypted("group/info", currentVoiceCallId); + if (infoPayload && infoPayload.startsWith("{")) { + return JSON.parse(infoPayload).string2 || null; + } + return null; + } + if (!currentVoiceCallId) return null; + let parts = String(currentVoiceCallId).split('_'); + let myId = `${window.id};${window.host}`; + let targetId = parts[0] === myId ? parts[1] : parts[0]; + if (!targetId) return null; + let name = window.dmUsernameCache ? window.dmUsernameCache[targetId] : null; + if (!name && window.fetchAsync) { + name = await window.fetchAsync(`${window.url}/idtoname?id=${targetId}`); + if (name && !name.startsWith("error:") && window.dmUsernameCache) window.dmUsernameCache[targetId] = name; + } + if (!name || name.startsWith("error:")) return null; + return name.split(':')[0]; + } catch (e) { + return null; + } +} + +async function updateVoiceChatTitle() { + let name = await resolveVoiceChatTitle(); + let el = document.querySelector("#voice-header .voice-title span"); + if (el && name) el.innerText = name; +} + function showVoiceUI() { if (voiceUiContainer) return; @@ -614,6 +709,7 @@ function showVoiceUI() {
+ ${processBlah("status.voice.connecting")}
@@ -834,8 +930,8 @@ function updateVoiceUIUsers() { } }); } - if (authorName && authorName.includes(':')) authorName = authorName.split(':')[0]; - if (!authorName && u === String(window.id)) authorName = window.username; + let resolvedName = authorName && authorName !== u ? authorName : null; + if (!resolvedName && u === String(window.id) && window.username) resolvedName = window.host ? `${window.username}:${window.host}` : window.username; let avatarRes = window.dmPfpCache ? window.dmPfpCache[u] : null; let avatarUrl = avatarRes ? avatarRes : "assets/default_avatar.png"; @@ -852,7 +948,7 @@ function updateVoiceUIUsers() { div.style.backgroundImage = `url('${avatarUrl.replace(/'/g, "\\'")}')`; let nameEl = document.getElementById(`voice-name-${u}`); - if (nameEl) nameEl.innerText = authorName || u; + if (nameEl) nameEl.innerText = resolvedName ? `${resolvedName} (${u})` : String(u); } Array.from(container.children).forEach(child => { @@ -890,10 +986,13 @@ function updateVoiceUIUsers() { } let authorName = window.dmUsernameCache ? window.dmUsernameCache[clientId] : null; - if (authorName && authorName.includes(':')) authorName = authorName.split(':')[0]; - + if (!authorName && clientId === String(window.id) && window.username) authorName = window.host ? `${window.username}:${window.host}` : window.username; + + let streamLabel = processBlah(type == '1' ? "label.stream.cam" : "label.stream.screen"); + let baseName = authorName && authorName !== clientId ? `${authorName} (${clientId})` : clientId; + let nameEl = document.getElementById(`voice-stream-name-${decoderId}`); - if (nameEl) nameEl.innerText = `${authorName || clientId} (${processBlah(type == '1' ? "label.stream.cam" : "label.stream.screen")})`; + if (nameEl) nameEl.innerText = `${baseName} · ${streamLabel}`; } Array.from(container.children).forEach(child => { @@ -1048,6 +1147,8 @@ function toggleVoiceMic() { } function endVoiceSession() { + setVoiceStatus("disconnected"); + if (currentVoiceCallId) { let payload = JSON.stringify({ string1: currentVoiceCallType, string2: currentVoiceCallId }); if (window.fetchEncrypted) window.fetchEncrypted("room/voice/leave", payload).catch(()=>{}); diff --git a/webroot/blah/en-cat.json b/webroot/blah/en-cat.json index 2834dbe0..db64989f 100644 --- a/webroot/blah/en-cat.json +++ b/webroot/blah/en-cat.json @@ -293,7 +293,7 @@ "owner.transferred": "Purrship transferred successfully", "title.voice.chat": "voice chat :3", "status.voice.connecting": "connecting... :3", - "status.voice.connected": "connected :3", + "status.voice.disconnected": "disconnected :3", "action.call.mute": "mute microphone", "action.call.camera": "camera", "action.call.screen": "share screen", diff --git a/webroot/blah/en-us.json b/webroot/blah/en-us.json index 5486df54..ee67f52d 100644 --- a/webroot/blah/en-us.json +++ b/webroot/blah/en-us.json @@ -302,7 +302,7 @@ "owner.transferred": "Ownership transferred successfully", "title.voice.chat": "Voice Chat", "status.voice.connecting": "Connecting...", - "status.voice.connected": "Connected", + "status.voice.disconnected": "Disconnected", "action.call.mute": "Mute Microphone", "action.call.camera": "Camera", "action.call.screen": "Share Screen", diff --git a/webroot/blah/pl-pl.json b/webroot/blah/pl-pl.json index aa9e1fb0..7d6f14ab 100644 --- a/webroot/blah/pl-pl.json +++ b/webroot/blah/pl-pl.json @@ -302,7 +302,7 @@ "owner.transferred": "Własność przekazana pomyślnie", "title.voice.chat": "Czat głosowy", "status.voice.connecting": "Łączenie...", - "status.voice.connected": "Połączono", + "status.voice.disconnected": "Rozłączono", "action.call.mute": "Wycisz mikrofon", "action.call.camera": "Kamera", "action.call.screen": "Udostępnij ekran", diff --git a/webroot/style.css b/webroot/style.css index b1f08f2d..5e495bcb 100644 --- a/webroot/style.css +++ b/webroot/style.css @@ -1340,6 +1340,15 @@ input:checked + .toggle-slider:before { align-items: center; gap: 0.5rem; color: var(--text-color); + min-width: 0; +} +.voice-title span { + font-size: 0.95rem; + font-weight: 700; + max-width: 16rem; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } .voice-title svg { width: 1.1rem; @@ -1351,10 +1360,12 @@ input:checked + .toggle-slider:before { display: flex; align-items: center; gap: 0.4rem; - font-size: 0.8rem; - font-weight: 600; + flex-shrink: 0; + font-size: 0.75rem; + font-weight: 500; color: var(--darker-text-color); - opacity: 0.85; + opacity: 0.75; + font-variant-numeric: tabular-nums; } #voice-status .status-dot { width: 0.55rem; @@ -1364,14 +1375,17 @@ input:checked + .toggle-slider:before { background: var(--darker-text-color); } #voice-status.connecting .status-dot { + background: hsl(35, 90%, 55%); animation: voice-dot-pulse 1.2s ease-in-out infinite; } -#voice-status.connected { - opacity: 1; -} #voice-status.connected .status-dot { background: var(--big-green); - box-shadow: 0 0 0.35rem rgba(120, 200, 120, 0.45); +} +#voice-status.disconnected .status-dot { + background: var(--big-red); +} +.status-ping:empty { + display: none; } @keyframes voice-dot-pulse { 50% { opacity: 0.25; } @@ -1414,7 +1428,7 @@ input:checked + .toggle-slider:before { border-radius: 0.65rem; overflow: hidden; cursor: pointer; - border: var(--border-width) solid transparent; + border: calc(var(--border-width) * 2) solid rgba(85, 85, 85); box-shadow: 0 0.25rem 0.625rem rgba(0, 0, 0, 0.3); transition: border-color 0.15s ease, box-shadow 0.2s ease; } diff --git a/webroot/voice.js b/webroot/voice.js index fb1a8f03..b3a68a16 100644 --- a/webroot/voice.js +++ b/webroot/voice.js @@ -44,13 +44,48 @@ function getStoredVolume(type, clientId) { return v === undefined ? 100 : v; } +let voiceConnectedAt = null; +let voiceTimerInterval = null; +let voicePingMs = null; + +function formatVoiceDuration(ms) { + let totalSec = Math.floor(ms / 1000); + let h = Math.floor(totalSec / 3600); + let m = Math.floor((totalSec % 3600) / 60); + let s = totalSec % 60; + let mm = String(m).padStart(2, "0"), ss = String(s).padStart(2, "0"); + return h > 0 ? `${h}:${mm}:${ss}` : `${m}:${ss}`; +} + +function updateVoicePing() { + let pingEl = document.getElementById("voice-ping"); + if (pingEl) pingEl.innerText = voicePingMs !== null ? `${voicePingMs} ms` : ""; +} + function setVoiceStatus(state) { let el = document.getElementById("voice-status"); if (!el) return; el.classList.toggle("connected", state === "connected"); - el.classList.toggle("connecting", state !== "connected"); + el.classList.toggle("connecting", state === "connecting"); + el.classList.toggle("disconnected", state === "disconnected"); + let textEl = el.querySelector(".voice-status-text"); - if (textEl) textEl.innerText = processBlah(state === "connected" ? "status.voice.connected" : "status.voice.connecting"); + if (state === "connected") { + if (voiceConnectedAt === null) voiceConnectedAt = Date.now(); + if (!voiceTimerInterval) { + voiceTimerInterval = setInterval(() => { + let tickEl = document.querySelector("#voice-status .voice-status-text"); + if (tickEl && voiceConnectedAt !== null) tickEl.innerText = formatVoiceDuration(Date.now() - voiceConnectedAt); + }, 1000); + } + if (textEl) textEl.innerText = formatVoiceDuration(Date.now() - voiceConnectedAt); + updateVoicePing(); + } else { + if (voiceTimerInterval) { clearInterval(voiceTimerInterval); voiceTimerInterval = null; } + voiceConnectedAt = null; + if (state === "disconnected") { voicePingMs = null; updateVoicePing(); } + if (textEl) textEl.innerText = processBlah(state === "disconnected" ? "status.voice.disconnected" : "status.voice.connecting"); + } } function setTileSpeaking(clientId, speaking) { @@ -208,6 +243,7 @@ async function startVoiceSession(serverUrl) { showVoiceUI(); updateVoiceUIUsers(); setVoiceStatus("connecting"); + updateVoiceChatTitle(); try { const wsUrl = new URL(serverUrl); @@ -234,8 +270,11 @@ async function startVoiceSession(serverUrl) { try { const baseUrl = currentVoiceWs.url.split('/ws')[0]; const httpUrl = baseUrl.replace('ws:', 'http:').replace('wss:', 'https:'); + const t0 = performance.now(); const res = await fetch(`${httpUrl}/room/users?room=${currentVoiceCallId}`); const usersRaw = await res.json(); + voicePingMs = Math.round(performance.now() - t0); + updateVoicePing(); const users = usersRaw.map(u => String(u)); let changed = (users.length !== voiceActiveUsers.length); for(let u of users) if(!voiceActiveUsers.includes(u)) changed = true; @@ -485,23 +524,46 @@ function handleCallUpdate(data) { } async function resolveCallerInfo(roomId) { - let name = window.dmUsernameCache ? window.dmUsernameCache[roomId] : null; - if (name && name.includes(':')) name = name.split(':')[0]; - if (!name && window.fetchAsync) { + if (window.fetchEncrypted) { try { - let fetched = await window.fetchAsync(`${window.url}/idtoname?id=${roomId}`); - if (fetched && !fetched.startsWith("error:")) { - name = fetched.includes(':') ? fetched.split(':')[0] : fetched; - if (window.dmUsernameCache) window.dmUsernameCache[roomId] = name; + let infoPayload = await fetchEncrypted("group/info", roomId); + if (infoPayload && infoPayload.startsWith("{")) { + let info = JSON.parse(infoPayload); + if (info.string2) { + let pfpUrl = null; + if (window.getGroupAvatarUrl) { + try { pfpUrl = await getGroupAvatarUrl(roomId, info.string2); } catch (e) {} + } + return { name: info.string2, pfpUrl }; + } } } catch (e) {} } - let pfpUrl = window.dmPfpCache ? window.dmPfpCache[roomId] : null; - if (!pfpUrl && window.getAvatarUrl) { - try { pfpUrl = await window.getAvatarUrl(roomId, name || "User"); } catch (e) {} - if (pfpUrl && window.dmPfpCache) window.dmPfpCache[roomId] = pfpUrl; + + let targetId = String(roomId); + let parts = targetId.split('_'); + let myId = `${window.id};${window.host}`; + if (parts.length === 2 && (parts[0] === myId || parts[1] === myId)) { + targetId = parts[0] === myId ? parts[1] : parts[0]; } - return { name: name || processBlah("title.unknown"), pfpUrl }; + + let name = window.dmUsernameCache ? window.dmUsernameCache[targetId] : null; + if (!name && window.fetchAsync) { + try { + let fetched = await window.fetchAsync(`${window.url}/idtoname?id=${targetId}`); + if (fetched && !fetched.startsWith("error:")) { + name = fetched; + if (window.dmUsernameCache) window.dmUsernameCache[targetId] = fetched; + } + } catch (e) {} + } + + let pfpUrl = window.dmPfpCache ? window.dmPfpCache[targetId] : null; + if (!pfpUrl && window.getAvatarUrl) { + try { pfpUrl = await window.getAvatarUrl(targetId, name || "User"); } catch (e) {} + if (pfpUrl && window.dmPfpCache) window.dmPfpCache[targetId] = pfpUrl; + } + return { name: (name ? name.split(':')[0] : null) || processBlah("title.unknown"), pfpUrl }; } function showIncomingCallUI(roomId) { @@ -600,6 +662,39 @@ async function handleRoomSignal(data) { } } +async function resolveVoiceChatTitle() { + try { + if (currentVoiceCallType === 'groups') { + if (!currentVoiceCallId || !window.fetchEncrypted) return null; + let infoPayload = await fetchEncrypted("group/info", currentVoiceCallId); + if (infoPayload && infoPayload.startsWith("{")) { + return JSON.parse(infoPayload).string2 || null; + } + return null; + } + if (!currentVoiceCallId) return null; + let parts = String(currentVoiceCallId).split('_'); + let myId = `${window.id};${window.host}`; + let targetId = parts[0] === myId ? parts[1] : parts[0]; + if (!targetId) return null; + let name = window.dmUsernameCache ? window.dmUsernameCache[targetId] : null; + if (!name && window.fetchAsync) { + name = await window.fetchAsync(`${window.url}/idtoname?id=${targetId}`); + if (name && !name.startsWith("error:") && window.dmUsernameCache) window.dmUsernameCache[targetId] = name; + } + if (!name || name.startsWith("error:")) return null; + return name.split(':')[0]; + } catch (e) { + return null; + } +} + +async function updateVoiceChatTitle() { + let name = await resolveVoiceChatTitle(); + let el = document.querySelector("#voice-header .voice-title span"); + if (el && name) el.innerText = name; +} + function showVoiceUI() { if (voiceUiContainer) return; @@ -614,6 +709,7 @@ function showVoiceUI() {
+ ${processBlah("status.voice.connecting")}
@@ -834,8 +930,8 @@ function updateVoiceUIUsers() { } }); } - if (authorName && authorName.includes(':')) authorName = authorName.split(':')[0]; - if (!authorName && u === String(window.id)) authorName = window.username; + let resolvedName = authorName && authorName !== u ? authorName : null; + if (!resolvedName && u === String(window.id) && window.username) resolvedName = window.host ? `${window.username}:${window.host}` : window.username; let avatarRes = window.dmPfpCache ? window.dmPfpCache[u] : null; let avatarUrl = avatarRes ? avatarRes : "assets/default_avatar.png"; @@ -852,7 +948,7 @@ function updateVoiceUIUsers() { div.style.backgroundImage = `url('${avatarUrl.replace(/'/g, "\\'")}')`; let nameEl = document.getElementById(`voice-name-${u}`); - if (nameEl) nameEl.innerText = authorName || u; + if (nameEl) nameEl.innerText = resolvedName ? `${resolvedName} (${u})` : String(u); } Array.from(container.children).forEach(child => { @@ -890,10 +986,13 @@ function updateVoiceUIUsers() { } let authorName = window.dmUsernameCache ? window.dmUsernameCache[clientId] : null; - if (authorName && authorName.includes(':')) authorName = authorName.split(':')[0]; - + if (!authorName && clientId === String(window.id) && window.username) authorName = window.host ? `${window.username}:${window.host}` : window.username; + + let streamLabel = processBlah(type == '1' ? "label.stream.cam" : "label.stream.screen"); + let baseName = authorName && authorName !== clientId ? `${authorName} (${clientId})` : clientId; + let nameEl = document.getElementById(`voice-stream-name-${decoderId}`); - if (nameEl) nameEl.innerText = `${authorName || clientId} (${processBlah(type == '1' ? "label.stream.cam" : "label.stream.screen")})`; + if (nameEl) nameEl.innerText = `${baseName} · ${streamLabel}`; } Array.from(container.children).forEach(child => { @@ -1048,6 +1147,8 @@ function toggleVoiceMic() { } function endVoiceSession() { + setVoiceStatus("disconnected"); + if (currentVoiceCallId) { let payload = JSON.stringify({ string1: currentVoiceCallType, string2: currentVoiceCallId }); if (window.fetchEncrypted) window.fetchEncrypted("room/voice/leave", payload).catch(()=>{});