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:
parent
d61c0d00fb
commit
fc768b6393
7 changed files with 265 additions and 290 deletions
|
|
@ -17,6 +17,7 @@
|
||||||
callState,
|
callState,
|
||||||
callTargetRoom,
|
callTargetRoom,
|
||||||
callMicMuted,
|
callMicMuted,
|
||||||
|
cancelJoinVoiceRoom,
|
||||||
leaveVoiceRoom,
|
leaveVoiceRoom,
|
||||||
toggleMute,
|
toggleMute,
|
||||||
} from "@app/call"
|
} from "@app/call"
|
||||||
|
|
@ -24,7 +25,7 @@
|
||||||
const {relay, h} = $derived($page.params)
|
const {relay, h} = $derived($page.params)
|
||||||
const routeUrl = $derived(relay ? decodeRelay(relay) : undefined)
|
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.
|
// the banner would just be redundant clutter there.
|
||||||
const isViewingCurrentVoiceRoom = $derived(
|
const isViewingCurrentVoiceRoom = $derived(
|
||||||
$callTargetRoom !== undefined &&
|
$callTargetRoom !== undefined &&
|
||||||
|
|
@ -49,6 +50,16 @@
|
||||||
if (!$callTargetRoom) return
|
if (!$callTargetRoom) return
|
||||||
void goto(makeRoomPath($callTargetRoom.url, $callTargetRoom.h))
|
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>
|
</script>
|
||||||
|
|
||||||
{#if visible}
|
{#if visible}
|
||||||
|
|
@ -87,9 +98,11 @@
|
||||||
</Button>
|
</Button>
|
||||||
{/if}
|
{/if}
|
||||||
<Button
|
<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"
|
class="button button-circle button-sm button-error"
|
||||||
onclick={leaveVoiceRoom}>
|
onclick={endCall}>
|
||||||
<Icon icon={EndCall} size={4} />
|
<Icon icon={EndCall} size={4} />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
199
src/app/components/CallControlBar.svelte
Normal file
199
src/app/components/CallControlBar.svelte
Normal 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}
|
||||||
|
|
@ -14,7 +14,6 @@
|
||||||
import AltArrowDown from "@assets/icons/alt-arrow-down.svg?dataurl"
|
import AltArrowDown from "@assets/icons/alt-arrow-down.svg?dataurl"
|
||||||
import ClockCircle from "@assets/icons/clock-circle.svg?dataurl"
|
import ClockCircle from "@assets/icons/clock-circle.svg?dataurl"
|
||||||
import Login2 from "@assets/icons/login-3.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 Close from "@assets/icons/close.svg?dataurl"
|
||||||
import {fade, fly} from "@lib/transition"
|
import {fade, fly} from "@lib/transition"
|
||||||
import {documentActive} from "@lib/html"
|
import {documentActive} from "@lib/html"
|
||||||
|
|
@ -30,7 +29,7 @@
|
||||||
import RoomPinnedMessages from "@app/components/RoomPinnedMessages.svelte"
|
import RoomPinnedMessages from "@app/components/RoomPinnedMessages.svelte"
|
||||||
import ThunkToast from "@app/components/ThunkToast.svelte"
|
import ThunkToast from "@app/components/ThunkToast.svelte"
|
||||||
import VideoCallContent from "@app/components/VideoCallContent.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 {deletes, relays, rooms, thunks, user} from "@app/core"
|
||||||
import {publishRoomJoinRequest} from "@app/access"
|
import {publishRoomJoinRequest} from "@app/access"
|
||||||
import {CallState, callTargetRoom, callState, VideoCallLayout, videoCallLayout} from "@app/call"
|
import {CallState, callTargetRoom, callState, VideoCallLayout, videoCallLayout} from "@app/call"
|
||||||
|
|
@ -44,7 +43,7 @@
|
||||||
} from "@app/rooms"
|
} from "@app/rooms"
|
||||||
import {userSettingsValues} from "@app/settings"
|
import {userSettingsValues} from "@app/settings"
|
||||||
import {makeFeed} from "@app/feeds"
|
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 {makeRoomPath} from "@app/routes"
|
||||||
import {pendingShare, type Share} from "@app/share"
|
import {pendingShare, type Share} from "@app/share"
|
||||||
import {pushToast} from "@app/toast"
|
import {pushToast} from "@app/toast"
|
||||||
|
|
@ -60,15 +59,17 @@
|
||||||
const addMemberKind = h ? ROOM_ADD_MEMBER : RELAY_ADD_MEMBER
|
const addMemberKind = h ? ROOM_ADD_MEMBER : RELAY_ADD_MEMBER
|
||||||
const isVoiceRoom = $derived($room && getRoomType($room) === RoomType.Voice)
|
const isVoiceRoom = $derived($room && getRoomType($room) === RoomType.Voice)
|
||||||
|
|
||||||
|
const isCallTargetingThisRoom = $derived($callTargetRoom?.url === url && $callTargetRoom?.h === h)
|
||||||
|
|
||||||
const voiceConnectedHere = $derived(
|
const voiceConnectedHere = $derived(
|
||||||
isVoiceRoom &&
|
isVoiceRoom && $callState === CallState.Connected && isCallTargetingThisRoom,
|
||||||
$callState === CallState.Connected &&
|
|
||||||
$callTargetRoom?.url === url &&
|
|
||||||
$callTargetRoom?.h === h,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// 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(
|
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
|
// 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 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)
|
const closeChat = () => videoCallLayout.set(VideoCallLayout.Video)
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
|
|
@ -503,7 +502,7 @@
|
||||||
)} />
|
)} />
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if h && isVoiceRoom && $callState === CallState.Connected}
|
{#if h && voiceConnectedHere}
|
||||||
<VideoCallContent
|
<VideoCallContent
|
||||||
layout={$videoCallLayout}
|
layout={$videoCallLayout}
|
||||||
mobile
|
mobile
|
||||||
|
|
@ -611,8 +610,12 @@
|
||||||
|
|
||||||
<div
|
<div
|
||||||
class={cx(
|
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",
|
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">
|
<div class="room__compose-inner min-w-0 flex-1">
|
||||||
{#if $room?.meta?.isPrivate() && $membershipStatus !== MembershipStatus.Granted}
|
{#if $room?.meta?.isPrivate() && $membershipStatus !== MembershipStatus.Granted}
|
||||||
|
|
@ -660,31 +663,13 @@
|
||||||
{/key}
|
{/key}
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{#if isVoiceRoom || $callState === CallState.Joining || $callState === CallState.Connected}
|
{#if h}
|
||||||
<div
|
<div class="hide-on-keyboard flex flex-shrink-0 items-center justify-center py-2">
|
||||||
class={cx(
|
<CallControlBar {url} {h} hideConnectedOnDesktop />
|
||||||
"hide-on-keyboard flex-shrink-0 p-2 md:hidden",
|
|
||||||
videoCallChatHidden && "hidden",
|
|
||||||
)}>
|
|
||||||
<VoiceWidget />
|
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
|
|
||||||
{#if showScrollButton}
|
{#if showScrollButton}
|
||||||
|
|
|
||||||
|
|
@ -107,7 +107,7 @@
|
||||||
})
|
})
|
||||||
</script>
|
</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">
|
<div class="join">
|
||||||
<Button
|
<Button
|
||||||
class="join-item h-10 w-10 min-w-10 button button-neutral"
|
class="join-item h-10 w-10 min-w-10 button button-neutral"
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,6 @@
|
||||||
import SpaceMenuNavItems from "@app/components/SpaceMenuNavItems.svelte"
|
import SpaceMenuNavItems from "@app/components/SpaceMenuNavItems.svelte"
|
||||||
import SpaceMenuRooms from "@app/components/SpaceMenuRooms.svelte"
|
import SpaceMenuRooms from "@app/components/SpaceMenuRooms.svelte"
|
||||||
import Link from "@lib/components/Link.svelte"
|
import Link from "@lib/components/Link.svelte"
|
||||||
import VoiceWidget from "@app/components/VoiceWidget.svelte"
|
|
||||||
import SocketStatusIndicator from "@app/components/SocketStatusIndicator.svelte"
|
import SocketStatusIndicator from "@app/components/SocketStatusIndicator.svelte"
|
||||||
import {makeSpacePath} from "@app/routes"
|
import {makeSpacePath} from "@app/routes"
|
||||||
|
|
||||||
|
|
@ -31,7 +30,6 @@
|
||||||
<SpaceMenuRooms {url} mobile />
|
<SpaceMenuRooms {url} mobile />
|
||||||
</div>
|
</div>
|
||||||
<div class="card space-menu__card space-menu__status shrink-0 z-nav-item">
|
<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">
|
<Link href={makeSpacePath(url, "about")} class="space-menu__status-link">
|
||||||
<SocketStatusIndicator {url} />
|
<SocketStatusIndicator {url} />
|
||||||
</Link>
|
</Link>
|
||||||
|
|
@ -50,7 +48,6 @@
|
||||||
</div>
|
</div>
|
||||||
</SecondaryNavSection>
|
</SecondaryNavSection>
|
||||||
<div class="flex shrink-0 flex-col gap-2 p-2 pt-0 -mt-4 pb-1 md:pb-2 z-nav">
|
<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">
|
<Link href={makeSpacePath(url, "about")} class="button button-neutral button-sm h-10">
|
||||||
<SocketStatusIndicator {url} />
|
<SocketStatusIndicator {url} />
|
||||||
</Link>
|
</Link>
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@
|
||||||
import Icon from "@lib/components/Icon.svelte"
|
import Icon from "@lib/components/Icon.svelte"
|
||||||
import ProfileCircle from "@app/components/ProfileCircle.svelte"
|
import ProfileCircle from "@app/components/ProfileCircle.svelte"
|
||||||
import VideoCallTile from "@app/components/VideoCallTile.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 VoiceParticipantMediaBadges from "@app/components/VoiceParticipantMediaBadges.svelte"
|
||||||
import {
|
import {
|
||||||
VideoCallLayout,
|
VideoCallLayout,
|
||||||
|
|
@ -150,10 +150,12 @@
|
||||||
})
|
})
|
||||||
|
|
||||||
const useSpotlightLayout = $derived(primaryTile !== undefined)
|
const useSpotlightLayout = $derived(primaryTile !== undefined)
|
||||||
const useMultiGrid = $derived(!useSpotlightLayout && videoTiles.length > 2)
|
// 2 tiles stacked full-width felt like they were "overlapping" each other rather
|
||||||
const multiGridClass = $derived(
|
// than tiling — put 2+ side by side in a grid, same as everyone else's call UI.
|
||||||
layout === VideoCallLayout.Split ? "grid-cols-1" : "grid-cols-1 sm:grid-cols-2",
|
// 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(() => {
|
$effect(() => {
|
||||||
const k = $videoPrimaryTileKey
|
const k = $videoPrimaryTileKey
|
||||||
|
|
@ -198,11 +200,13 @@
|
||||||
{@const label = labelFor(tile.liveKitIdentity, tile.source)}
|
{@const label = labelFor(tile.liveKitIdentity, tile.source)}
|
||||||
<div
|
<div
|
||||||
class={cx(
|
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 === "spotlight" && "min-h-0 flex-1",
|
||||||
layout === "default" && "aspect-video w-full min-h-0",
|
layout === "default" && "aspect-video w-full min-h-0",
|
||||||
layout === "strip" && "aspect-video w-44 shrink-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}
|
{#if tile.track}
|
||||||
<VideoCallTile
|
<VideoCallTile
|
||||||
|
|
@ -275,7 +279,7 @@
|
||||||
{/if}
|
{/if}
|
||||||
{:else}
|
{:else}
|
||||||
<div
|
<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>
|
<p>Waiting for participants…</p>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
@ -283,17 +287,24 @@
|
||||||
|
|
||||||
{#if showVideoContent}
|
{#if showVideoContent}
|
||||||
<div class={panelChrome}>
|
<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()}
|
{@render videoPanelBody()}
|
||||||
</div>
|
</div>
|
||||||
<div class="shrink-0 pb-2">
|
<!-- fixed (viewport-relative), not absolute within panelChrome — panelChrome's
|
||||||
<VoiceWidget />
|
flex-computed height isn't guaranteed to reach the real viewport bottom, so
|
||||||
</div>
|
an absolute child positioned against its box can end up overlapping the
|
||||||
</div>
|
fixed bottom nav bar. Matches the same bottom-nav-clearing offset the old
|
||||||
{:else}
|
chat FAB used.
|
||||||
{@render videoPanelBody()}
|
|
||||||
{/if}
|
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>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
|
||||||
|
|
@ -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}
|
|
||||||
Loading…
Reference in a new issue