Refine calls: accessible controls, mic level check before joining (#331)

Co-authored-by: Aditya Chaudhary <30+useradityaa@noreply.coracle.social>
This commit is contained in:
Aditya Chaudhary 2026-08-10 18:14:46 +00:00 committed by hodlbod
parent 65c9eac5e5
commit 7dcc713ec2
8 changed files with 335 additions and 181 deletions

View file

@ -2,7 +2,6 @@
* Voice rooms via LiveKit. Note: Voice does not work on localhost in Firefox
* (ICE candidate gathering fails). Use Chrome or test from deployed HTTPS.
*/
import {MediaQuery} from "svelte/reactivity"
import {
DisconnectReason,
LocalParticipant,
@ -32,7 +31,6 @@ export const LIVEKIT_PARTICIPANTS = 39004
export {supportsAudioOutputSelection}
const LIVEKIT_DEFAULT_DEVICE_ID = "default"
const VISUAL_SOURCES = [Track.Source.Camera, Track.Source.ScreenShare] as const
const RECONNECT_DELAYS = [1000, 2000, 4000, 8000, 16000]
export type CallSession = {
@ -65,11 +63,6 @@ export enum VideoCallLayout {
Split = "split",
}
export enum ViewportSize {
Desktop = "desktop",
Mobile = "mobile",
}
export enum DeviceKind {
AudioInput = "audioinput",
AudioOutput = "audiooutput",
@ -109,24 +102,10 @@ export const triggerVideoTrackRevision = () => {
videoTrackRevision.update(n => n + 1)
}
export const isDesktopLayout = new MediaQuery("min-width: 768px", false)
export const videoCallViewportSync = {
previousLayout: undefined as ViewportSize | undefined,
}
export const videoCallLayout = writable<VideoCallLayout>(VideoCallLayout.Split)
export const videoPrimaryTileKey = writable<string | undefined>(undefined)
export const videoTileCount = derived(
[currentCallSession, callState, videoTrackRevision, participantMediaState],
([$session, $state]) => {
if ($state !== CallState.Connected || !$session) return 0
return countLiveVisualFeeds($session)
},
)
export const joinVoiceRoom = async (
url: string,
h: string,
@ -375,7 +354,6 @@ export const switchCallActiveDevice = async (
}
export const resetVideoCallLayout = () => {
videoCallViewportSync.previousLayout = undefined
videoCallLayout.set(VideoCallLayout.Chat)
}
@ -458,27 +436,6 @@ const teardownRoom = (livekit: LiveKitRoom) => {
livekit.disconnect()
}
const countLiveVisualFeeds = (session: CallSession): number => {
const livekit = session.livekit
let n = 0
const lp = livekit.localParticipant
if (session.cameraOn) {
const pub = lp.getTrackPublication(Track.Source.Camera)
if (pub?.track) n += 1
}
if (session.screenShareOn) {
const pub = lp.getTrackPublication(Track.Source.ScreenShare)
if (pub?.track) n += 1
}
for (const rp of livekit.remoteParticipants.values()) {
for (const source of VISUAL_SOURCES) {
const pub = rp.getTrackPublication(source)
if (pub?.isSubscribed && pub.track && !pub.isMuted) n += 1
}
}
return n
}
const participantMediaFrom = (participant: Participant): ParticipantMediaState => ({
muted: !participant.isMicrophoneEnabled,
cameraOn: participant.isCameraEnabled,

View file

@ -0,0 +1,91 @@
<script lang="ts">
import cx from "classnames"
import Microphone from "@assets/icons/microphone.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
type Props = {
track: MediaStreamTrack | undefined
error?: string
offMessage?: string
}
const {track, error, offMessage = "Microphone is off"}: Props = $props()
let level = $state(0)
$effect(() => {
const t = track
level = 0
if (!t || error) return
let frame: number | undefined
const context = new AudioContext()
const analyser = context.createAnalyser()
analyser.fftSize = 512
context.createMediaStreamSource(new MediaStream([t])).connect(analyser)
const data = new Uint8Array(analyser.frequencyBinCount)
// Smooth into a plain local, not into `level` itself: reading the `$state`
// we also write would make this effect depend on its own output, so every
// frame would invalidate and re-run it, tearing down the AudioContext
// before it ever produced a reading.
let smoothed = 0
const tick = () => {
if (!t.enabled || t.readyState !== "live") {
smoothed = 0
level = 0
frame = requestAnimationFrame(tick)
return
}
analyser.getByteTimeDomainData(data)
let sumSquares = 0
for (const v of data) {
const normalized = (v - 128) / 128
sumSquares += normalized * normalized
}
const rms = Math.sqrt(sumSquares / data.length)
const NOISE_FLOOR = 0.03
const gated = rms < NOISE_FLOOR ? 0 : (rms - NOISE_FLOOR) / (1 - NOISE_FLOOR)
smoothed += (Math.min(1, gated * 4) - smoothed) * 0.5
if (smoothed < 0.005) smoothed = 0
level = smoothed
frame = requestAnimationFrame(tick)
}
tick()
return () => {
if (frame !== undefined) cancelAnimationFrame(frame)
context.close().catch(() => {})
}
})
</script>
<div class="flex items-center gap-2">
<Icon icon={Microphone} size={4} class="shrink-0 opacity-70" />
{#if error}
<p class="text-xs text-error" role="alert">{error}</p>
{:else if !track}
<p class="text-xs opacity-60">{offMessage}</p>
{:else}
<div
class="flex flex-1 items-center gap-1"
role="meter"
aria-label="Microphone input level"
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={Math.round(level * 100)}>
{#each Array(5) as _, i (i)}
<div
class={cx(
"h-2 flex-1 rounded-full transition-colors",
level * 5 > i ? "bg-success" : "bg-surface-more",
)}>
</div>
{/each}
</div>
<span class="shrink-0 text-xs opacity-60">Speak to test</span>
{/if}
</div>

View file

@ -13,6 +13,8 @@
import AltArrowDown from "@assets/icons/alt-arrow-down.svg?dataurl"
import ClockCircle from "@assets/icons/clock-circle.svg?dataurl"
import Login2 from "@assets/icons/login-3.svg?dataurl"
import ChatRound from "@assets/icons/chat-round.svg?dataurl"
import Close from "@assets/icons/close.svg?dataurl"
import {fade, fly} from "@lib/transition"
import {popKey} from "@lib/implicit"
import {documentActive} from "@lib/html"
@ -31,14 +33,7 @@
import VoiceWidget from "@app/components/VoiceWidget.svelte"
import {deletes, relays, rooms, thunks, user} from "@app/core"
import {publishRoomJoinRequest} from "@app/access"
import {
CallState,
callTargetRoom,
callState,
VideoCallLayout,
videoCallLayout,
videoTileCount,
} from "@app/call"
import {CallState, callTargetRoom, callState, VideoCallLayout, videoCallLayout} from "@app/call"
import {
PROTECTED,
RoomType,
@ -49,7 +44,7 @@
} from "@app/rooms"
import {userSettingsValues} from "@app/settings"
import {makeFeed} from "@app/feeds"
import {checked, deferredRoomPath, setChecked} from "@app/notifications"
import {checked, deferredRoomPath, notifications, setChecked} from "@app/notifications"
import {makeRoomPath} from "@app/routes"
import {pushToast} from "@app/toast"
@ -75,45 +70,53 @@
isVoiceRoom && $callState === CallState.Connected && $videoCallLayout === VideoCallLayout.Video,
)
const roomPath = h ? makeRoomPath(url, h) : undefined
// Basic pass at issue #121's "chat overlaid on video" idea: float the chat panel over
// the full-bleed video instead of splitting the screen into fixed columns, so the call
// stays the visual focus. The panel opens from — and collapses back to — a chat FAB.
const isOverlayChat = $derived(voiceConnectedHere && $videoCallLayout === VideoCallLayout.Split)
const videoCallChatHidden = $derived(
voiceConnectedHere && $videoCallLayout === VideoCallLayout.Video,
)
const roomPath = h ? makeRoomPath(url, h) : undefined
const chatUnread = $derived(roomPath !== undefined && $notifications.has(roomPath))
const openChat = () => videoCallLayout.set(VideoCallLayout.Split)
const closeChat = () => videoCallLayout.set(VideoCallLayout.Video)
$effect(() => {
deferredRoomPath.set(videoCallChatHidden ? roomPath : undefined)
if (roomPath && voiceConnectedHere && !videoCallChatHidden) {
setChecked(roomPath)
}
// The messages pane (and its scroll position) is offscreen while the call view is
// showing — don't leave its floating "scroll down"/"new messages" chips stranded.
if (videoCallChatHidden) {
showScrollButton = false
showFixedNewMessages = false
}
})
onDestroy(() => deferredRoomPath.set(undefined))
let prevVideoTileCount = $state(0)
let wasConnectedHere = $state(false)
$effect(() => {
if ($callState !== CallState.Connected) {
videoCallLayout.set(VideoCallLayout.Chat)
prevVideoTileCount = 0
if (!voiceConnectedHere) {
if ($callState !== CallState.Connected) {
videoCallLayout.set(VideoCallLayout.Chat)
}
wasConnectedHere = false
return
}
const here = isVoiceRoom && $callTargetRoom?.url === url && $callTargetRoom?.h === h
const n = $videoTileCount
if (!here) {
prevVideoTileCount = 0
return
}
if (prevVideoTileCount === 0 && n >= 1) {
// Land on the call view (chat as a FAB) as soon as the call starts here, whether
// or not anyone's camera is on — chat is one tap away via the FAB.
if (!wasConnectedHere) {
videoCallLayout.set(VideoCallLayout.Video)
wasConnectedHere = true
}
if (prevVideoTileCount >= 1 && n === 0 && $videoCallLayout === VideoCallLayout.Split) {
videoCallLayout.set(VideoCallLayout.Chat)
}
prevVideoTileCount = n
})
const shouldProtect = $relays.hasNip(url, 70)
@ -463,30 +466,45 @@
})
</script>
<div
class={cx(
"flex min-h-0 flex-1 flex-col",
voiceConnectedHere && $videoCallLayout === VideoCallLayout.Split && "md:flex-row",
)}>
<div class={cx("flex min-h-0 flex-1 flex-col", isOverlayChat && "relative")}>
{#if h && voiceConnectedHere}
<VideoCallContent
layout={$videoCallLayout}
{url}
{h}
class="hidden min-h-0 w-full min-w-0 flex-1 flex-col md:flex" />
class={cx(
"hidden min-h-0 w-full min-w-0 flex-1 flex-col md:flex",
isOverlayChat && "md:absolute md:inset-0",
)} />
{/if}
{#if h && isVoiceRoom && $callState === CallState.Connected}
<VideoCallContent
layout={$videoCallLayout}
mobile
{url}
{h}
class={cx("md:hidden", isOverlayChat && "absolute inset-0")} />
{/if}
<div
class={cx(
"room flex min-h-0 min-w-0 flex-1 flex-col",
voiceConnectedHere && $videoCallLayout === VideoCallLayout.Video && "md:hidden",
videoCallChatHidden && "hidden",
isOverlayChat &&
"absolute inset-x-2 top-4 bottom-[calc(3.5rem+var(--saib))] z-popover overflow-hidden rounded-2xl border border-line bg-surface/95 shadow-lg md:left-auto md:right-4 md:bottom-4 md:w-96 md:max-w-[26rem] md:bg-surface/90",
)}>
{#if h}
<RoomPinnedMessages {url} {h} />
{#if isOverlayChat}
<Button
aria-label="Close room chat"
class="button button-neutral button-xs button-circle absolute right-2 top-2 z-popover"
onclick={closeChat}>
<Icon icon={Close} size={4} />
</Button>
{/if}
{#if h && isVoiceRoom && $callState === CallState.Connected}
<VideoCallContent layout={$videoCallLayout} mobile {url} {h} class="md:hidden" />
{#if h}
<RoomPinnedMessages {url} {h} />
{/if}
<div
@ -621,13 +639,27 @@
<div
class={cx(
"hide-on-keyboard flex-shrink-0 p-2 md:hidden",
showMobileVideoPanel && "hidden",
videoCallChatHidden && "hidden",
)}>
<VoiceWidget />
</div>
{/if}
</div>
</div>
{#if videoCallChatHidden}
<Button
aria-label="Open room chat"
class="button button-primary button-circle fixed z-popover bottom-[calc(4.5rem+var(--saib))] right-4 shadow-lg md:bottom-4"
onclick={openChat}>
<Icon icon={ChatRound} size={5} />
{#if chatUnread}
<span
class="absolute -right-0.5 -top-0.5 h-2.5 w-2.5 rounded-full bg-primary ring-2 ring-surface"
aria-hidden="true"></span>
{/if}
</Button>
{/if}
</div>
{#if showScrollButton}

View file

@ -2,7 +2,6 @@
import {spec} from "@welshman/lib"
import cx from "classnames"
import {Track} from "livekit-client"
import {get} from "svelte/store"
import Pin from "@assets/icons/pin.svg?dataurl"
import Button from "@lib/components/Button.svelte"
import Icon from "@lib/components/Icon.svelte"
@ -12,11 +11,7 @@
import VoiceParticipantMediaBadges from "@app/components/VoiceParticipantMediaBadges.svelte"
import {
VideoCallLayout,
isDesktopLayout,
toggleVideoPrimaryTile,
videoCallLayout,
videoCallViewportSync,
ViewportSize,
videoPrimaryTileKey,
currentCallSession,
callTargetRoom,
@ -47,32 +42,13 @@
const {layout, mobile = false, url, h, class: className = ""}: Props = $props()
$effect(() => {
const currentLayout = isDesktopLayout.current ? ViewportSize.Desktop : ViewportSize.Mobile
const {previousLayout} = videoCallViewportSync
if (previousLayout === undefined) {
videoCallViewportSync.previousLayout = currentLayout
return
}
if (previousLayout === currentLayout) return
const p = get(videoCallLayout)
if (previousLayout === ViewportSize.Desktop && currentLayout === ViewportSize.Mobile) {
if (p === VideoCallLayout.Split) videoCallLayout.set(VideoCallLayout.Video)
} else if (previousLayout === ViewportSize.Mobile && currentLayout === ViewportSize.Desktop) {
if (p === VideoCallLayout.Chat) videoCallLayout.set(VideoCallLayout.Split)
}
videoCallViewportSync.previousLayout = currentLayout
})
const isViewingCurrentCallRoom = $derived(
$callTargetRoom?.url === url && $callTargetRoom?.h === h,
)
const showVideoContent = $derived(
isViewingCurrentCallRoom &&
(mobile
? layout === VideoCallLayout.Video
: layout === VideoCallLayout.Split || layout === VideoCallLayout.Video),
(layout === VideoCallLayout.Split || layout === VideoCallLayout.Video),
)
const videoTiles = $derived.by(() => {
@ -219,6 +195,7 @@
{#snippet videoTile(tile: VideoTileData, layout: TileLayout)}
{@const media = $mediaStateByIdentity(tile.liveKitIdentity)}
{@const label = labelFor(tile.liveKitIdentity, tile.source)}
<div
class={cx(
"relative isolate overflow-hidden rounded-2xl shadow-sm",
@ -249,12 +226,13 @@
{/if}
<span
class="pointer-events-none absolute bottom-1 left-1 max-w-[calc(100%-0.5rem)] truncate rounded bg-surface/80 px-1.5 py-0.5 text-xs">
{labelFor(tile.liveKitIdentity, tile.source)}{tile.isLocal ? " (you)" : ""}
{label}{tile.isLocal ? " (you)" : ""}
</span>
{#if videoTiles.length > 1}
{@const pinned = $videoPrimaryTileKey === tileKey(tile)}
<Button
data-tip={pinned ? "Exit spotlight" : "Spotlight"}
aria-label={pinned ? `Exit spotlight for ${label}` : `Spotlight ${label}`}
aria-pressed={pinned}
class={cx(
`button button-${pinned ? "primary" : "ghost"} button-xs button-square`,

View file

@ -1,5 +1,6 @@
<script lang="ts">
import {spec} from "@welshman/lib"
import {RoomEvent, Track} from "livekit-client"
import Button from "@lib/components/Button.svelte"
import FieldInline from "@lib/components/FieldInline.svelte"
import Modal from "@lib/components/Modal.svelte"
@ -8,6 +9,7 @@
import ModalHeader from "@lib/components/ModalHeader.svelte"
import ModalSubtitle from "@lib/components/ModalSubtitle.svelte"
import ModalTitle from "@lib/components/ModalTitle.svelte"
import MicLevelMeter from "@app/components/MicLevelMeter.svelte"
import {
currentCallSession,
DeviceKind,
@ -31,6 +33,11 @@
let selectedInput = $state("")
let selectedOutput = $state("")
let selectedVideo = $state("")
let micTrack = $state<MediaStreamTrack | undefined>(undefined)
const readMicTrack = (session: CallSession): MediaStreamTrack | undefined =>
session.livekit.localParticipant.getTrackPublication(Track.Source.Microphone)?.track
?.mediaStreamTrack
const loadDevices = async () => {
if (!navigator.mediaDevices?.enumerateDevices) return
@ -61,10 +68,29 @@
selectedInput = selectValueForActiveDevice(session, DeviceKind.AudioInput)
selectedOutput = selectValueForActiveDevice(session, DeviceKind.AudioOutput)
selectedVideo = selectValueForActiveDevice(session, DeviceKind.VideoInput)
// Mic mute/unmute publishes or unpublishes the microphone track; keep the
// level meter in sync with whichever track is actually live right now.
const {livekit} = session
const refreshMicTrack = () => {
micTrack = readMicTrack(session)
}
refreshMicTrack()
livekit.on(RoomEvent.LocalTrackPublished, refreshMicTrack)
livekit.on(RoomEvent.LocalTrackUnpublished, refreshMicTrack)
return () => {
livekit.off(RoomEvent.LocalTrackPublished, refreshMicTrack)
livekit.off(RoomEvent.LocalTrackUnpublished, refreshMicTrack)
}
})
const onInputChange = () => {
void switchCallActiveDevice(DeviceKind.AudioInput, selectedInput)
const onInputChange = async () => {
await switchCallActiveDevice(DeviceKind.AudioInput, selectedInput)
// Switching the active device restarts the existing LocalAudioTrack in
// place with a new MediaStreamTrack rather than emitting a publish event,
// so the meter needs an explicit re-read here.
const session = $currentCallSession
if (session) micTrack = readMicTrack(session)
}
const onOutputChange = () => {
@ -109,6 +135,7 @@
</select>
{/snippet}
</FieldInline>
<MicLevelMeter track={micTrack} offMessage="Microphone is off — unmute to test it" />
{#if canPickOutput}
<FieldInline>
{#snippet label()}

View file

@ -35,6 +35,10 @@
const {url, h, replaceState = false, notification = false}: Props = $props()
// Beyond this many participants, a full name+avatar row per person makes the
// sidebar item too tall — fall back to the compact ProfileCircles cluster instead.
const ACTIVE_LIST_MAX = 5
const participants = deriveCallParticipants(url, h)
const participantPubkeys = $derived($participants.flatMap(p => (p.pubkey ? [p.pubkey] : [])))
const isActive = $derived(
@ -85,7 +89,7 @@
<RoomName {url} {h} />
</div>
{#if participantPubkeys.length > 0}
{#if isActive}
{#if isActive && $participants.length <= ACTIVE_LIST_MAX}
{#each $participants as p (participantKey(p as CallParticipant))}
{@const media = $mediaStateByIdentity(p.liveKitIdentity)}
<div class="flex items-center gap-2 ml-6">

View file

@ -13,6 +13,7 @@
import ModalSubtitle from "@lib/components/ModalSubtitle.svelte"
import ModalTitle from "@lib/components/ModalTitle.svelte"
import {AbortError, TimeoutError} from "$lib/util"
import MicLevelMeter from "@app/components/MicLevelMeter.svelte"
import ProfileCircles from "@app/components/ProfileCircles.svelte"
import {displayRoom} from "@app/rooms"
import {deriveCallParticipants, joinVoiceRoom, loadCallParticipants} from "@app/call"
@ -33,6 +34,7 @@
let audioInputs = $state<MediaDeviceInfo[]>([])
let selectedDeviceId = $state("")
let startWithoutMic = $state(false)
let micError = $state(false)
const loadDevices = async () => {
if (!navigator.mediaDevices?.enumerateDevices) return
@ -49,7 +51,60 @@
void loadDevices()
})
const goBack = () => history.back()
// Live mic level meter so the user can confirm their selected microphone is
// actually picking up sound before joining, rather than finding out mid-call.
// The stream handle stays a plain local while only the track is reactive: the
// effect below both reads and writes it, and reading reactive state an effect
// also writes makes that effect depend on its own output, so it would re-run
// forever and cancel the in-flight getUserMedia every time.
let previewStream: MediaStream | undefined
let previewTrack = $state<MediaStreamTrack | undefined>(undefined)
const stopMicPreview = () => {
previewStream?.getTracks().forEach(t => t.stop())
previewStream = undefined
previewTrack = undefined
}
$effect(() => {
// Re-runs when selectedDeviceId or startWithoutMic changes.
void selectedDeviceId
const withoutMic = startWithoutMic
stopMicPreview()
micError = false
if (withoutMic || !navigator.mediaDevices?.getUserMedia) return
let cancelled = false
const deviceId = selectedDeviceId
void (async () => {
try {
const stream = await navigator.mediaDevices.getUserMedia({
audio: deviceId ? {deviceId: {exact: deviceId}} : true,
})
if (cancelled) {
stream.getTracks().forEach(t => t.stop())
return
}
previewStream = stream
previewTrack = stream.getAudioTracks()[0]
} catch {
if (!cancelled) micError = true
}
})()
return () => {
cancelled = true
stopMicPreview()
}
})
const goBack = () => {
stopMicPreview()
history.back()
}
const handleJoinError = (e: unknown) => {
if (e instanceof AbortError) return
@ -62,6 +117,7 @@
}
const joinVoice = async () => {
stopMicPreview()
popModal()
await joinVoiceRoom(
url,
@ -121,6 +177,13 @@
</select>
{/snippet}
</FieldInline>
{#if !startWithoutMic}
<MicLevelMeter
track={previewTrack}
error={micError
? "Could not access this microphone. Check your browser permissions, or join without one."
: undefined} />
{/if}
</div>
</ModalBody>
<ModalFooter>

View file

@ -19,7 +19,6 @@
import Settings from "@assets/icons/settings.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte"
import Spinner from "@lib/components/Spinner.svelte"
import VoiceCallAudioSettingsDialog from "@app/components/VoiceCallAudioSettingsDialog.svelte"
import VoiceRoomJoinDialog from "@app/components/VoiceRoomJoinDialog.svelte"
import {rooms} from "@app/core"
@ -30,7 +29,6 @@
import {makeRoomPath} from "@app/routes"
import {
VideoCallLayout,
isDesktopLayout,
toggleCamera,
toggleScreenShare,
videoCallLayout,
@ -98,24 +96,13 @@
const showChatButton = $derived($callState === CallState.Connected && isViewingCurrentVoiceRoom)
const isChatPanelActive = $derived(
showChatButton &&
(isDesktopLayout.current
? $videoCallLayout === VideoCallLayout.Split
: $videoCallLayout === VideoCallLayout.Chat),
)
const isChatPanelActive = $derived(showChatButton && $videoCallLayout === VideoCallLayout.Split)
const onChatToggle = () => {
if (!showChatButton) return
if (isDesktopLayout.current) {
videoCallLayout.update(p =>
p === VideoCallLayout.Split ? VideoCallLayout.Video : VideoCallLayout.Split,
)
} else {
videoCallLayout.update(p =>
p === VideoCallLayout.Video ? VideoCallLayout.Chat : VideoCallLayout.Video,
)
}
videoCallLayout.update(p =>
p === VideoCallLayout.Split ? VideoCallLayout.Video : VideoCallLayout.Split,
)
}
const chatUnread = $derived(
@ -128,99 +115,114 @@
in:fly={{y: 60, duration: 350}}
out:fly={{y: 60, duration: 250}}
class="card card-sm flex flex-col gap-2">
<div class="flex items-start justify-between gap-2">
<Button
class="min-w-0 flex-1 rounded-xl px-2 py-1 text-left hover:bg-surface"
onclick={goToRoom}
aria-label="Open room {roomName}">
<div class="flex flex-col gap-0.5">
{#if $callState === CallState.Joining}
<span class="text-sm font-semibold text-warning">Joining...</span>
{:else if $callState === CallState.Connected}
<span class="text-sm font-semibold text-success">Voice Connected</span>
{:else}
<span class="text-sm font-semibold text-content-muted">Disconnected</span>
{/if}
<span class="truncate min-w-0 text-xs text-content-muted">
{roomName} / {spaceName}
</span>
</div>
</Button>
{#if showChatButton}
<Button
data-tip="Toggle Chat"
class={cx(
"button button-sm button-square tip tip-top relative",
isChatPanelActive && "button-primary",
)}
onclick={onChatToggle}>
<Icon icon={ChatRound} size={4} />
{#if chatUnread}
<span
transition:fade={{duration: 150}}
class="absolute right-1 top-1 h-2 w-2 rounded-full bg-primary"
aria-hidden="true"></span>
{/if}
</Button>
{/if}
</div>
<div class="flex items-center gap-1.5">
<Button
class="w-full min-w-0 rounded-xl px-2 py-1 text-left hover:bg-surface"
onclick={goToRoom}
aria-label="Open room {roomName}">
<div class="flex flex-col gap-0.5">
{#if $callState === CallState.Joining}
<span class="text-sm font-semibold text-warning">Joining...</span>
{:else if $callState === CallState.Connected}
<span class="text-sm font-semibold text-success">Voice Connected</span>
{:else}
<span class="text-sm font-semibold text-content-muted">Disconnected</span>
{/if}
<span class="truncate min-w-0 text-xs text-content-muted">
{roomName} / {spaceName}
</span>
</div>
</Button>
<div class="grid grid-cols-3 gap-1.5">
{#if $callState === CallState.Joining}
<Spinner size="sm" />
<Button
data-tip="Cancel"
class="button button-sm button-square tip tip-top"
aria-label="Cancel joining voice room"
class="button button-neutral col-span-3 flex-col !h-auto gap-0.5 rounded-xl px-2 py-1.5"
onclick={cancelJoinVoiceRoom}>
<Icon icon={CloseCircle} size={4} />
<span class="text-[10px] leading-none font-semibold">Cancel</span>
</Button>
{:else if $callState === CallState.Connected && $currentCallSession}
<Button
data-tip={$callMicMuted ? "Unmute" : "Mute"}
aria-label={$callMicMuted ? "Unmute microphone" : "Mute microphone"}
aria-pressed={!$callMicMuted}
class={cx(
"button button-sm button-square tip tip-top",
"button w-full flex-col !h-auto gap-0.5 rounded-xl px-2 py-1.5",
$callMicMuted ? "button-neutral" : "button-primary",
)}
onclick={toggleMute}>
<Icon icon={$callMicMuted ? MicrophoneOff : Microphone} size={$callMicMuted ? 4 : 4.5} />
<span class="text-[10px] leading-none font-semibold">
{$callMicMuted ? "Unmute" : "Mute"}
</span>
</Button>
<Button
data-tip={$currentCallSession.cameraOn ? "Turn off camera" : "Turn on camera"}
aria-label={$currentCallSession.cameraOn ? "Turn off camera" : "Turn on camera"}
aria-pressed={$currentCallSession.cameraOn}
class={cx(
"button button-sm button-square tip tip-top",
"button w-full flex-col !h-auto gap-0.5 rounded-xl px-2 py-1.5",
$currentCallSession.cameraOn ? "button-primary" : "button-neutral",
)}
onclick={toggleCamera}>
<Icon icon={$currentCallSession.cameraOn ? VideocameraRecord : VideocameraOff} size={4} />
<span class="text-[10px] leading-none font-semibold">
{$currentCallSession.cameraOn ? "Stop" : "Start"}
</span>
</Button>
{#if !Capacitor.isNativePlatform()}
<Button
data-tip={$currentCallSession.screenShareOn ? "Stop sharing" : "Share screen"}
aria-label={$currentCallSession.screenShareOn ? "Stop sharing screen" : "Share screen"}
aria-pressed={$currentCallSession.screenShareOn}
class={cx(
"button button-sm button-square tip tip-top",
"button w-full flex-col !h-auto gap-0.5 rounded-xl px-2 py-1.5",
$currentCallSession.screenShareOn ? "button-primary" : "button-neutral",
)}
onclick={toggleScreenShare}>
<Icon icon={Monitor} size={3.75} />
<span class="text-[10px] leading-none font-semibold">
{$currentCallSession.screenShareOn ? "Stop share" : "Share"}
</span>
</Button>
{/if}
<Button
data-tip="Call settings"
class="button button-neutral button-sm button-square tip tip-top"
aria-label="Call settings"
class="button button-neutral w-full flex-col !h-auto gap-0.5 rounded-xl px-2 py-1.5"
onclick={openCallSettings}>
<Icon icon={Settings} size={4} />
<span class="text-[10px] leading-none font-semibold">Settings</span>
</Button>
<Button
data-tip="Leave room"
class="button button-error button-sm button-square tip tip-top"
aria-label="Leave voice room"
class="button button-error w-full flex-col !h-auto gap-0.5 rounded-xl px-2 py-1.5"
onclick={leaveVoiceRoom}>
<Icon icon={EndCall} size={4.5} />
<span class="text-[10px] leading-none font-semibold">Leave</span>
</Button>
{#if showChatButton}
<Button
aria-label="Toggle chat panel"
aria-pressed={isChatPanelActive}
class={cx(
"button w-full flex-col !h-auto gap-0.5 rounded-xl px-2 py-1.5 relative",
isChatPanelActive ? "button-primary" : "button-neutral",
)}
onclick={onChatToggle}>
<Icon icon={ChatRound} size={4} />
<span class="text-[10px] leading-none font-semibold">Chat</span>
{#if chatUnread}
<span
transition:fade={{duration: 150}}
class="absolute right-2 top-1.5 h-2 w-2 rounded-full bg-primary"
aria-hidden="true"></span>
{/if}
</Button>
{/if}
{:else}
<Button
data-tip="Join Voice"
class="button button-primary button-sm button-square tip tip-top"
aria-label="Join voice room"
class="button button-primary col-span-3 flex-col !h-auto gap-0.5 rounded-xl px-2 py-1.5"
onclick={openJoinDialog}>
<Icon icon={PhoneCallingRounded} size={4.5} />
<span class="text-[10px] leading-none font-semibold">Join</span>
</Button>
{/if}
</div>