From 135fbf23ca26ed17b271397cebcbe1d66deeb429 Mon Sep 17 00:00:00 2001 From: olcxja Date: Sun, 23 Aug 2026 15:01:04 +0200 Subject: [PATCH] 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}`))); } }