Replace sidebar voice widget with in-room floating call controls (#348)

Co-authored-by: Aditya Chaudhary <30+useradityaa@noreply.coracle.social>
This commit is contained in:
Aditya Chaudhary 2026-08-13 16:01:08 +00:00 committed by hodlbod
parent d61c0d00fb
commit fc768b6393
7 changed files with 265 additions and 290 deletions

View file

@ -17,6 +17,7 @@
callState,
callTargetRoom,
callMicMuted,
cancelJoinVoiceRoom,
leaveVoiceRoom,
toggleMute,
} from "@app/call"
@ -24,7 +25,7 @@
const {relay, h} = $derived($page.params)
const routeUrl = $derived(relay ? decodeRelay(relay) : undefined)
// The call's own room page already shows full controls (VoiceWidget), so
// The call's own room page already shows full controls (CallControlBar), so
// the banner would just be redundant clutter there.
const isViewingCurrentVoiceRoom = $derived(
$callTargetRoom !== undefined &&
@ -49,6 +50,16 @@
if (!$callTargetRoom) return
void goto(makeRoomPath($callTargetRoom.url, $callTargetRoom.h))
}
// leaveVoiceRoom no-ops during Joining (no session exists yet to leave) — cancel
// the in-flight join instead, otherwise this button silently does nothing.
const endCall = () => {
if ($callState === CallState.Joining) {
cancelJoinVoiceRoom()
} else {
leaveVoiceRoom()
}
}
</script>
{#if visible}
@ -87,9 +98,11 @@
</Button>
{/if}
<Button
aria-label="Leave voice room"
aria-label={$callState === CallState.Joining
? "Cancel joining voice room"
: "Leave voice room"}
class="button button-circle button-sm button-error"
onclick={leaveVoiceRoom}>
onclick={endCall}>
<Icon icon={EndCall} size={4} />
</Button>
</div>

View file

@ -0,0 +1,199 @@
<script lang="ts">
import {fade} from "svelte/transition"
import cx from "classnames"
import {Capacitor} from "@capacitor/core"
import Microphone from "@assets/icons/microphone.svg?dataurl"
import MicrophoneOff from "@assets/icons/microphone-off.svg?dataurl"
import VideocameraOff from "@assets/icons/videocamera-off.svg?dataurl"
import VideocameraRecord from "@assets/icons/videocamera-record.svg?dataurl"
import Monitor from "@assets/icons/monitor.svg?dataurl"
import EndCall from "@assets/icons/end-call-rounded.svg?dataurl"
import PhoneCallingRounded from "@assets/icons/phone-calling-rounded.svg?dataurl"
import ChatRound from "@assets/icons/chat-round.svg?dataurl"
import CloseCircle from "@assets/icons/close-circle.svg?dataurl"
import Settings from "@assets/icons/settings.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte"
import VoiceCallAudioSettingsDialog from "@app/components/VoiceCallAudioSettingsDialog.svelte"
import VoiceRoomJoinDialog from "@app/components/VoiceRoomJoinDialog.svelte"
import {rooms} from "@app/core"
import {RoomType, getRoomType} from "@app/rooms"
import {pushModal} from "@app/modal"
import {notifications} from "@app/notifications"
import {makeRoomPath} from "@app/routes"
import {
VideoCallLayout,
toggleCamera,
toggleScreenShare,
videoCallLayout,
CallState,
currentCallSession,
callTargetRoom,
callMicMuted,
callState,
cancelJoinVoiceRoom,
leaveVoiceRoom,
toggleMute,
} from "@app/call"
type Props = {
url: string
h: string
// suppress the full in-call control row on desktop — used where a wider,
// unobstructed copy of it already floats over the video panel
hideConnectedOnDesktop?: boolean
}
const {url, h, hideConnectedOnDesktop = false}: Props = $props()
const roomStore = $derived($rooms.forRoom(url, h))
const room = $derived($roomStore)
const isVoiceRoom = $derived(room !== undefined && getRoomType(room) === RoomType.Voice)
const isTargetingThisRoom = $derived(
$callTargetRoom !== undefined && $callTargetRoom.url === url && $callTargetRoom.h === h,
)
const joiningHere = $derived($callState === CallState.Joining && isTargetingThisRoom)
const connectedHere = $derived($callState === CallState.Connected && isTargetingThisRoom)
// callTargetRoom isn't cleared on cancel/leave, so it can still point at this room
// after the call has ended — check actual state, not just "is this the last target".
const showJoin = $derived(isVoiceRoom && !joiningHere && !connectedHere)
const openJoinDialog = () => pushModal(VoiceRoomJoinDialog, {url, h})
const openCallSettings = () => pushModal(VoiceCallAudioSettingsDialog)
const roomPath = $derived(makeRoomPath(url, h))
const chatUnread = $derived($notifications.has(roomPath))
const isChatPanelActive = $derived($videoCallLayout === VideoCallLayout.Split)
const onChatToggle = () => {
videoCallLayout.update(p =>
p === VideoCallLayout.Split ? VideoCallLayout.Video : VideoCallLayout.Split,
)
}
</script>
{#if joiningHere || connectedHere || showJoin}
<!-- every state shares this grid cell so switching between them cross-fades in
place instead of pushing/jumping past each other as flex siblings, and the
grid auto-sizes to whichever state is actually present (nothing reserves
layout space when e.g. the connected bar is display:none on desktop). -->
<div class="grid items-center justify-center">
{#if joiningHere}
<div
in:fade={{duration: 160}}
out:fade={{duration: 120}}
class="pointer-events-auto col-start-1 row-start-1 flex h-10 items-center gap-1.5 rounded-full border border-line bg-surface/95 px-1.5 shadow-xl backdrop-blur-md">
<span
data-tip="Joining call…"
role="status"
aria-label="Joining call"
class="spinner spinner-sm mx-1.5 shrink-0"></span>
<Button
data-tip="Cancel"
aria-label="Cancel joining voice room"
class="button button-circle button-xs button-neutral"
onclick={cancelJoinVoiceRoom}>
<Icon icon={CloseCircle} size={3} />
</Button>
</div>
{/if}
{#if connectedHere}
<div
in:fade={{duration: 160}}
out:fade={{duration: 120}}
class={cx(
"pointer-events-auto col-start-1 row-start-1 flex items-center gap-1.5 rounded-full border border-line bg-surface/95 p-1.5 shadow-xl backdrop-blur-md md:gap-2 md:p-2",
hideConnectedOnDesktop && "md:hidden",
)}>
<Button
data-tip={$callMicMuted ? "Unmute" : "Mute"}
aria-label={$callMicMuted ? "Unmute microphone" : "Mute microphone"}
aria-pressed={!$callMicMuted}
class={cx(
"button button-circle max-md:h-9 max-md:w-9",
$callMicMuted ? "button-neutral" : "button-primary",
)}
onclick={toggleMute}>
<Icon icon={$callMicMuted ? MicrophoneOff : Microphone} size={4.5} />
</Button>
{#if $currentCallSession}
<Button
data-tip={$currentCallSession.cameraOn ? "Stop camera" : "Start camera"}
aria-label={$currentCallSession.cameraOn ? "Turn off camera" : "Turn on camera"}
aria-pressed={$currentCallSession.cameraOn}
class={cx(
"button button-circle max-md:h-9 max-md:w-9",
$currentCallSession.cameraOn ? "button-primary" : "button-neutral",
)}
onclick={toggleCamera}>
<Icon
icon={$currentCallSession.cameraOn ? VideocameraRecord : VideocameraOff}
size={4.5} />
</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-circle max-md:h-9 max-md:w-9",
$currentCallSession.screenShareOn ? "button-primary" : "button-neutral",
)}
onclick={toggleScreenShare}>
<Icon icon={Monitor} size={4.5} />
</Button>
{/if}
{/if}
<Button
data-tip="Call settings"
aria-label="Call settings"
class="button button-circle button-neutral max-md:h-9 max-md:w-9"
onclick={openCallSettings}>
<Icon icon={Settings} size={4.5} />
</Button>
<Button
data-tip="Toggle chat"
aria-label="Toggle chat panel"
aria-pressed={isChatPanelActive}
class={cx(
"button button-circle relative max-md:h-9 max-md:w-9",
isChatPanelActive ? "button-primary" : "button-neutral",
)}
onclick={onChatToggle}>
<Icon icon={ChatRound} size={4.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>
<div class="mx-0.5 h-6 w-px shrink-0 bg-line" aria-hidden="true"></div>
<Button
data-tip="Leave call"
aria-label="Leave voice room"
class="button button-circle button-error max-md:h-9 max-md:w-9"
onclick={leaveVoiceRoom}>
<Icon icon={EndCall} size={5} />
</Button>
</div>
{/if}
{#if showJoin}
<div
in:fade={{duration: 160}}
out:fade={{duration: 120}}
class="pointer-events-auto col-start-1 row-start-1">
<Button
data-tip="Join call"
aria-label="Join voice room"
class="button button-primary button-circle shadow-xl"
onclick={openJoinDialog}>
<Icon icon={PhoneCallingRounded} size={4.5} />
</Button>
</div>
{/if}
</div>
{/if}

View file

@ -14,7 +14,6 @@
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 {documentActive} from "@lib/html"
@ -30,7 +29,7 @@
import RoomPinnedMessages from "@app/components/RoomPinnedMessages.svelte"
import ThunkToast from "@app/components/ThunkToast.svelte"
import VideoCallContent from "@app/components/VideoCallContent.svelte"
import VoiceWidget from "@app/components/VoiceWidget.svelte"
import CallControlBar from "@app/components/CallControlBar.svelte"
import {deletes, relays, rooms, thunks, user} from "@app/core"
import {publishRoomJoinRequest} from "@app/access"
import {CallState, callTargetRoom, callState, VideoCallLayout, videoCallLayout} from "@app/call"
@ -44,7 +43,7 @@
} from "@app/rooms"
import {userSettingsValues} from "@app/settings"
import {makeFeed} from "@app/feeds"
import {checked, deferredRoomPath, notifications, setChecked} from "@app/notifications"
import {checked, deferredRoomPath, setChecked} from "@app/notifications"
import {makeRoomPath} from "@app/routes"
import {pendingShare, type Share} from "@app/share"
import {pushToast} from "@app/toast"
@ -60,15 +59,17 @@
const addMemberKind = h ? ROOM_ADD_MEMBER : RELAY_ADD_MEMBER
const isVoiceRoom = $derived($room && getRoomType($room) === RoomType.Voice)
const isCallTargetingThisRoom = $derived($callTargetRoom?.url === url && $callTargetRoom?.h === h)
const voiceConnectedHere = $derived(
isVoiceRoom &&
$callState === CallState.Connected &&
$callTargetRoom?.url === url &&
$callTargetRoom?.h === h,
isVoiceRoom && $callState === CallState.Connected && isCallTargetingThisRoom,
)
// Reuses voiceConnectedHere (rather than re-deriving isVoiceRoom/callState) so it
// can't diverge and stay true for a different voice room the call isn't targeting
// — that previously hid this room's messages/compose row while connected elsewhere.
const showMobileVideoPanel = $derived(
isVoiceRoom && $callState === CallState.Connected && $videoCallLayout === VideoCallLayout.Video,
voiceConnectedHere && $videoCallLayout === VideoCallLayout.Video,
)
// Basic pass at issue #121's "chat overlaid on video" idea: float the chat panel over
@ -81,9 +82,7 @@
)
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(() => {
@ -503,7 +502,7 @@
)} />
{/if}
{#if h && isVoiceRoom && $callState === CallState.Connected}
{#if h && voiceConnectedHere}
<VideoCallContent
layout={$videoCallLayout}
mobile
@ -611,8 +610,12 @@
<div
class={cx(
"room__compose flex flex-col gap-1 md:flex-row md:gap-0",
"room__compose flex flex-row items-center gap-1 px-2",
showMobileVideoPanel && "max-md:hidden",
// the connected bar (mic/camera/screenshare/settings/chat/leave) is too wide
// to sit beside the compose input on any screen size — give it its own row.
// gap-2 only applies here (the inline row stays flush with no gap).
voiceConnectedHere && "flex-col items-stretch gap-2",
)}>
<div class="room__compose-inner min-w-0 flex-1">
{#if $room?.meta?.isPrivate() && $membershipStatus !== MembershipStatus.Granted}
@ -660,31 +663,13 @@
{/key}
{/if}
</div>
{#if isVoiceRoom || $callState === CallState.Joining || $callState === CallState.Connected}
<div
class={cx(
"hide-on-keyboard flex-shrink-0 p-2 md:hidden",
videoCallChatHidden && "hidden",
)}>
<VoiceWidget />
{#if h}
<div class="hide-on-keyboard flex flex-shrink-0 items-center justify-center py-2">
<CallControlBar {url} {h} hideConnectedOnDesktop />
</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

@ -107,7 +107,7 @@
})
</script>
<form class="relative flex gap-2 p-2" onsubmit={preventDefault(submit)}>
<form class="relative flex gap-2 py-2" onsubmit={preventDefault(submit)}>
<div class="join">
<Button
class="join-item h-10 w-10 min-w-10 button button-neutral"

View file

@ -5,7 +5,6 @@
import SpaceMenuNavItems from "@app/components/SpaceMenuNavItems.svelte"
import SpaceMenuRooms from "@app/components/SpaceMenuRooms.svelte"
import Link from "@lib/components/Link.svelte"
import VoiceWidget from "@app/components/VoiceWidget.svelte"
import SocketStatusIndicator from "@app/components/SocketStatusIndicator.svelte"
import {makeSpacePath} from "@app/routes"
@ -31,7 +30,6 @@
<SpaceMenuRooms {url} mobile />
</div>
<div class="card space-menu__card space-menu__status shrink-0 z-nav-item">
<VoiceWidget />
<Link href={makeSpacePath(url, "about")} class="space-menu__status-link">
<SocketStatusIndicator {url} />
</Link>
@ -50,7 +48,6 @@
</div>
</SecondaryNavSection>
<div class="flex shrink-0 flex-col gap-2 p-2 pt-0 -mt-4 pb-1 md:pb-2 z-nav">
<VoiceWidget />
<Link href={makeSpacePath(url, "about")} class="button button-neutral button-sm h-10">
<SocketStatusIndicator {url} />
</Link>

View file

@ -7,7 +7,7 @@
import Icon from "@lib/components/Icon.svelte"
import ProfileCircle from "@app/components/ProfileCircle.svelte"
import VideoCallTile from "@app/components/VideoCallTile.svelte"
import VoiceWidget from "@app/components/VoiceWidget.svelte"
import CallControlBar from "@app/components/CallControlBar.svelte"
import VoiceParticipantMediaBadges from "@app/components/VoiceParticipantMediaBadges.svelte"
import {
VideoCallLayout,
@ -150,10 +150,12 @@
})
const useSpotlightLayout = $derived(primaryTile !== undefined)
const useMultiGrid = $derived(!useSpotlightLayout && videoTiles.length > 2)
const multiGridClass = $derived(
layout === VideoCallLayout.Split ? "grid-cols-1" : "grid-cols-1 sm:grid-cols-2",
)
// 2 tiles stacked full-width felt like they were "overlapping" each other rather
// than tiling — put 2+ side by side in a grid, same as everyone else's call UI.
// Chat (Split) floats over the video as an overlay card rather than shrinking
// this panel's width, so the tile grid doesn't need to collapse for it either.
const useMultiGrid = $derived(!useSpotlightLayout && videoTiles.length > 1)
const multiGridClass = "grid-cols-1 sm:grid-cols-2"
$effect(() => {
const k = $videoPrimaryTileKey
@ -198,11 +200,13 @@
{@const label = labelFor(tile.liveKitIdentity, tile.source)}
<div
class={cx(
"relative isolate overflow-hidden rounded-2xl shadow-sm",
// bg-surface-more (rather than bg-surface, same as the panel behind it) so a
// camera-off tile reads as its own card instead of blending into the panel.
"relative isolate overflow-hidden rounded-2xl border border-line shadow-sm",
layout === "spotlight" && "min-h-0 flex-1",
layout === "default" && "aspect-video w-full min-h-0",
layout === "strip" && "aspect-video w-44 shrink-0",
tile.source === Track.Source.ScreenShare ? "bg-black" : "bg-surface",
tile.source === Track.Source.ScreenShare ? "bg-black" : "bg-surface-more",
)}>
{#if tile.track}
<VideoCallTile
@ -275,7 +279,7 @@
{/if}
{:else}
<div
class="flex min-h-[12rem] flex-1 flex-col items-center justify-center gap-2 rounded-2xl bg-surface p-4 text-center text-sm opacity-80">
class="flex min-h-[12rem] flex-1 flex-col items-center justify-center gap-2 rounded-2xl border border-line bg-surface-more p-4 text-center text-sm opacity-80">
<p>Waiting for participants…</p>
</div>
{/if}
@ -283,17 +287,24 @@
{#if showVideoContent}
<div class={panelChrome}>
{#if mobile}
<div class="flex min-h-0 flex-1 flex-col gap-2">
<div class="min-h-0 flex-1 overflow-hidden">
{@render videoPanelBody()}
</div>
<div class="shrink-0 pb-2">
<VoiceWidget />
</div>
</div>
{:else}
{@render videoPanelBody()}
{/if}
{@render videoPanelBody()}
</div>
<!-- fixed (viewport-relative), not absolute within panelChrome — panelChrome's
flex-computed height isn't guaranteed to reach the real viewport bottom, so
an absolute child positioned against its box can end up overlapping the
fixed bottom nav bar. Matches the same bottom-nav-clearing offset the old
chat FAB used.
RoomChat mounts both a desktop and a mobile VideoCallContent instance at
once while connected (each gated by its own `class` prop, e.g. "md:hidden"
vs "hidden ... md:flex"), so this fixed bar needs the same mobile/desktop
split — otherwise both instances render their own copy at the identical
screen position simultaneously. -->
<div
class={cx(
"left-content pointer-events-none fixed right-sai z-popover justify-center bottom-[calc(4.5rem+var(--saib))] md:bottom-4",
mobile ? "flex md:hidden" : "hidden md:flex",
)}>
<CallControlBar {url} {h} />
</div>
{/if}

View file

@ -1,230 +0,0 @@
<script lang="ts">
import {readable} from "svelte/store"
import {fade, fly} from "svelte/transition"
import {goto} from "$app/navigation"
import {page} from "$app/stores"
import cx from "classnames"
import {Capacitor} from "@capacitor/core"
import {displayRelayUrl} from "@welshman/util"
import type {Room} from "@welshman/app"
import Microphone from "@assets/icons/microphone.svg?dataurl"
import MicrophoneOff from "@assets/icons/microphone-off.svg?dataurl"
import VideocameraOff from "@assets/icons/videocamera-off.svg?dataurl"
import VideocameraRecord from "@assets/icons/videocamera-record.svg?dataurl"
import Monitor from "@assets/icons/monitor.svg?dataurl"
import EndCall from "@assets/icons/end-call-rounded.svg?dataurl"
import PhoneCallingRounded from "@assets/icons/phone-calling-rounded.svg?dataurl"
import ChatRound from "@assets/icons/chat-round.svg?dataurl"
import CloseCircle from "@assets/icons/close-circle.svg?dataurl"
import Settings from "@assets/icons/settings.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte"
import VoiceCallAudioSettingsDialog from "@app/components/VoiceCallAudioSettingsDialog.svelte"
import VoiceRoomJoinDialog from "@app/components/VoiceRoomJoinDialog.svelte"
import {rooms} from "@app/core"
import {decodeRelay} from "@app/relays"
import {RoomType, displayRoom, getRoomType} from "@app/rooms"
import {pushModal} from "@app/modal"
import {notifications} from "@app/notifications"
import {makeRoomPath} from "@app/routes"
import {
VideoCallLayout,
toggleCamera,
toggleScreenShare,
videoCallLayout,
CallState,
currentCallSession,
callTargetRoom,
callMicMuted,
callState,
cancelJoinVoiceRoom,
leaveVoiceRoom,
toggleMute,
} from "@app/call"
const {relay, h} = $derived($page.params)
const url = $derived(relay ? decodeRelay(relay) : undefined)
const displayedRoomStore = $derived(
url && h && typeof h === "string" ? $rooms.forRoom(url, h) : readable(undefined),
)
const routeDisplayedRoom = $derived($displayedRoomStore)
const isViewingCurrentVoiceRoom = $derived(
$callTargetRoom !== undefined &&
url !== undefined &&
typeof h === "string" &&
$callTargetRoom.url === url &&
$callTargetRoom.h === h,
)
const targetRoom = $derived.by((): Room | undefined => {
if ($callState === CallState.Joining || $callState === CallState.Connected) {
return $callTargetRoom
}
if ($callState === CallState.Disconnected) {
if (routeDisplayedRoom) {
if (getRoomType(routeDisplayedRoom) === RoomType.Voice) {
return routeDisplayedRoom
}
return undefined
}
return $callTargetRoom
}
return $callTargetRoom
})
const roomName = $derived(targetRoom ? displayRoom(targetRoom.url, targetRoom.h) : "")
const spaceName = $derived(targetRoom ? displayRelayUrl(targetRoom.url) : "")
const openJoinDialog = async () => {
if (!targetRoom) return
await goto(makeRoomPath(targetRoom.url, targetRoom.h))
pushModal(VoiceRoomJoinDialog, {url: targetRoom.url, h: targetRoom.h})
}
const goToRoom = () => {
if (!targetRoom) return
const path = makeRoomPath(targetRoom.url, targetRoom.h)
if ($page.url.pathname !== path) {
void goto(path)
}
}
const openCallSettings = () => {
pushModal(VoiceCallAudioSettingsDialog)
}
const showChatButton = $derived($callState === CallState.Connected && isViewingCurrentVoiceRoom)
const isChatPanelActive = $derived(showChatButton && $videoCallLayout === VideoCallLayout.Split)
const onChatToggle = () => {
if (!showChatButton) return
videoCallLayout.update(p =>
p === VideoCallLayout.Split ? VideoCallLayout.Video : VideoCallLayout.Split,
)
}
const chatUnread = $derived(
targetRoom !== undefined && $notifications.has(makeRoomPath(targetRoom.url, targetRoom.h)),
)
</script>
{#if targetRoom}
<div
in:fly={{y: 60, duration: 350}}
out:fly={{y: 60, duration: 250}}
class="card card-sm flex flex-col gap-2">
<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}
<Button
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
aria-label={$callMicMuted ? "Unmute microphone" : "Mute microphone"}
aria-pressed={!$callMicMuted}
class={cx(
"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
aria-label={$currentCallSession.cameraOn ? "Turn off camera" : "Turn on camera"}
aria-pressed={$currentCallSession.cameraOn}
class={cx(
"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
aria-label={$currentCallSession.screenShareOn ? "Stop sharing screen" : "Share screen"}
aria-pressed={$currentCallSession.screenShareOn}
class={cx(
"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
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
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
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>
</div>
{/if}