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:
parent
65c9eac5e5
commit
7dcc713ec2
8 changed files with 335 additions and 181 deletions
|
|
@ -2,7 +2,6 @@
|
||||||
* Voice rooms via LiveKit. Note: Voice does not work on localhost in Firefox
|
* Voice rooms via LiveKit. Note: Voice does not work on localhost in Firefox
|
||||||
* (ICE candidate gathering fails). Use Chrome or test from deployed HTTPS.
|
* (ICE candidate gathering fails). Use Chrome or test from deployed HTTPS.
|
||||||
*/
|
*/
|
||||||
import {MediaQuery} from "svelte/reactivity"
|
|
||||||
import {
|
import {
|
||||||
DisconnectReason,
|
DisconnectReason,
|
||||||
LocalParticipant,
|
LocalParticipant,
|
||||||
|
|
@ -32,7 +31,6 @@ export const LIVEKIT_PARTICIPANTS = 39004
|
||||||
export {supportsAudioOutputSelection}
|
export {supportsAudioOutputSelection}
|
||||||
|
|
||||||
const LIVEKIT_DEFAULT_DEVICE_ID = "default"
|
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]
|
const RECONNECT_DELAYS = [1000, 2000, 4000, 8000, 16000]
|
||||||
|
|
||||||
export type CallSession = {
|
export type CallSession = {
|
||||||
|
|
@ -65,11 +63,6 @@ export enum VideoCallLayout {
|
||||||
Split = "split",
|
Split = "split",
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum ViewportSize {
|
|
||||||
Desktop = "desktop",
|
|
||||||
Mobile = "mobile",
|
|
||||||
}
|
|
||||||
|
|
||||||
export enum DeviceKind {
|
export enum DeviceKind {
|
||||||
AudioInput = "audioinput",
|
AudioInput = "audioinput",
|
||||||
AudioOutput = "audiooutput",
|
AudioOutput = "audiooutput",
|
||||||
|
|
@ -109,24 +102,10 @@ export const triggerVideoTrackRevision = () => {
|
||||||
videoTrackRevision.update(n => n + 1)
|
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 videoCallLayout = writable<VideoCallLayout>(VideoCallLayout.Split)
|
||||||
|
|
||||||
export const videoPrimaryTileKey = writable<string | undefined>(undefined)
|
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 (
|
export const joinVoiceRoom = async (
|
||||||
url: string,
|
url: string,
|
||||||
h: string,
|
h: string,
|
||||||
|
|
@ -375,7 +354,6 @@ export const switchCallActiveDevice = async (
|
||||||
}
|
}
|
||||||
|
|
||||||
export const resetVideoCallLayout = () => {
|
export const resetVideoCallLayout = () => {
|
||||||
videoCallViewportSync.previousLayout = undefined
|
|
||||||
videoCallLayout.set(VideoCallLayout.Chat)
|
videoCallLayout.set(VideoCallLayout.Chat)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -458,27 +436,6 @@ const teardownRoom = (livekit: LiveKitRoom) => {
|
||||||
livekit.disconnect()
|
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 => ({
|
const participantMediaFrom = (participant: Participant): ParticipantMediaState => ({
|
||||||
muted: !participant.isMicrophoneEnabled,
|
muted: !participant.isMicrophoneEnabled,
|
||||||
cameraOn: participant.isCameraEnabled,
|
cameraOn: participant.isCameraEnabled,
|
||||||
|
|
|
||||||
91
src/app/components/MicLevelMeter.svelte
Normal file
91
src/app/components/MicLevelMeter.svelte
Normal 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>
|
||||||
|
|
@ -13,6 +13,8 @@
|
||||||
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 {fade, fly} from "@lib/transition"
|
import {fade, fly} from "@lib/transition"
|
||||||
import {popKey} from "@lib/implicit"
|
import {popKey} from "@lib/implicit"
|
||||||
import {documentActive} from "@lib/html"
|
import {documentActive} from "@lib/html"
|
||||||
|
|
@ -31,14 +33,7 @@
|
||||||
import VoiceWidget from "@app/components/VoiceWidget.svelte"
|
import VoiceWidget from "@app/components/VoiceWidget.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 {
|
import {CallState, callTargetRoom, callState, VideoCallLayout, videoCallLayout} from "@app/call"
|
||||||
CallState,
|
|
||||||
callTargetRoom,
|
|
||||||
callState,
|
|
||||||
VideoCallLayout,
|
|
||||||
videoCallLayout,
|
|
||||||
videoTileCount,
|
|
||||||
} from "@app/call"
|
|
||||||
import {
|
import {
|
||||||
PROTECTED,
|
PROTECTED,
|
||||||
RoomType,
|
RoomType,
|
||||||
|
|
@ -49,7 +44,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, setChecked} from "@app/notifications"
|
import {checked, deferredRoomPath, notifications, setChecked} from "@app/notifications"
|
||||||
import {makeRoomPath} from "@app/routes"
|
import {makeRoomPath} from "@app/routes"
|
||||||
import {pushToast} from "@app/toast"
|
import {pushToast} from "@app/toast"
|
||||||
|
|
||||||
|
|
@ -75,45 +70,53 @@
|
||||||
isVoiceRoom && $callState === CallState.Connected && $videoCallLayout === VideoCallLayout.Video,
|
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(
|
const videoCallChatHidden = $derived(
|
||||||
voiceConnectedHere && $videoCallLayout === VideoCallLayout.Video,
|
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(() => {
|
$effect(() => {
|
||||||
deferredRoomPath.set(videoCallChatHidden ? roomPath : undefined)
|
deferredRoomPath.set(videoCallChatHidden ? roomPath : undefined)
|
||||||
if (roomPath && voiceConnectedHere && !videoCallChatHidden) {
|
if (roomPath && voiceConnectedHere && !videoCallChatHidden) {
|
||||||
setChecked(roomPath)
|
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))
|
onDestroy(() => deferredRoomPath.set(undefined))
|
||||||
|
|
||||||
let prevVideoTileCount = $state(0)
|
let wasConnectedHere = $state(false)
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if ($callState !== CallState.Connected) {
|
if (!voiceConnectedHere) {
|
||||||
videoCallLayout.set(VideoCallLayout.Chat)
|
if ($callState !== CallState.Connected) {
|
||||||
prevVideoTileCount = 0
|
videoCallLayout.set(VideoCallLayout.Chat)
|
||||||
|
}
|
||||||
|
wasConnectedHere = false
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const here = isVoiceRoom && $callTargetRoom?.url === url && $callTargetRoom?.h === h
|
// Land on the call view (chat as a FAB) as soon as the call starts here, whether
|
||||||
const n = $videoTileCount
|
// or not anyone's camera is on — chat is one tap away via the FAB.
|
||||||
|
if (!wasConnectedHere) {
|
||||||
if (!here) {
|
|
||||||
prevVideoTileCount = 0
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (prevVideoTileCount === 0 && n >= 1) {
|
|
||||||
videoCallLayout.set(VideoCallLayout.Video)
|
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)
|
const shouldProtect = $relays.hasNip(url, 70)
|
||||||
|
|
@ -463,30 +466,45 @@
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div
|
<div class={cx("flex min-h-0 flex-1 flex-col", isOverlayChat && "relative")}>
|
||||||
class={cx(
|
|
||||||
"flex min-h-0 flex-1 flex-col",
|
|
||||||
voiceConnectedHere && $videoCallLayout === VideoCallLayout.Split && "md:flex-row",
|
|
||||||
)}>
|
|
||||||
{#if h && voiceConnectedHere}
|
{#if h && voiceConnectedHere}
|
||||||
<VideoCallContent
|
<VideoCallContent
|
||||||
layout={$videoCallLayout}
|
layout={$videoCallLayout}
|
||||||
{url}
|
{url}
|
||||||
{h}
|
{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}
|
{/if}
|
||||||
|
|
||||||
<div
|
<div
|
||||||
class={cx(
|
class={cx(
|
||||||
"room flex min-h-0 min-w-0 flex-1 flex-col",
|
"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}
|
{#if isOverlayChat}
|
||||||
<RoomPinnedMessages {url} {h} />
|
<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}
|
||||||
|
|
||||||
{#if h && isVoiceRoom && $callState === CallState.Connected}
|
{#if h}
|
||||||
<VideoCallContent layout={$videoCallLayout} mobile {url} {h} class="md:hidden" />
|
<RoomPinnedMessages {url} {h} />
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<div
|
<div
|
||||||
|
|
@ -621,13 +639,27 @@
|
||||||
<div
|
<div
|
||||||
class={cx(
|
class={cx(
|
||||||
"hide-on-keyboard flex-shrink-0 p-2 md:hidden",
|
"hide-on-keyboard flex-shrink-0 p-2 md:hidden",
|
||||||
showMobileVideoPanel && "hidden",
|
videoCallChatHidden && "hidden",
|
||||||
)}>
|
)}>
|
||||||
<VoiceWidget />
|
<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}
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,6 @@
|
||||||
import {spec} from "@welshman/lib"
|
import {spec} from "@welshman/lib"
|
||||||
import cx from "classnames"
|
import cx from "classnames"
|
||||||
import {Track} from "livekit-client"
|
import {Track} from "livekit-client"
|
||||||
import {get} from "svelte/store"
|
|
||||||
import Pin from "@assets/icons/pin.svg?dataurl"
|
import Pin from "@assets/icons/pin.svg?dataurl"
|
||||||
import Button from "@lib/components/Button.svelte"
|
import Button from "@lib/components/Button.svelte"
|
||||||
import Icon from "@lib/components/Icon.svelte"
|
import Icon from "@lib/components/Icon.svelte"
|
||||||
|
|
@ -12,11 +11,7 @@
|
||||||
import VoiceParticipantMediaBadges from "@app/components/VoiceParticipantMediaBadges.svelte"
|
import VoiceParticipantMediaBadges from "@app/components/VoiceParticipantMediaBadges.svelte"
|
||||||
import {
|
import {
|
||||||
VideoCallLayout,
|
VideoCallLayout,
|
||||||
isDesktopLayout,
|
|
||||||
toggleVideoPrimaryTile,
|
toggleVideoPrimaryTile,
|
||||||
videoCallLayout,
|
|
||||||
videoCallViewportSync,
|
|
||||||
ViewportSize,
|
|
||||||
videoPrimaryTileKey,
|
videoPrimaryTileKey,
|
||||||
currentCallSession,
|
currentCallSession,
|
||||||
callTargetRoom,
|
callTargetRoom,
|
||||||
|
|
@ -47,32 +42,13 @@
|
||||||
|
|
||||||
const {layout, mobile = false, url, h, class: className = ""}: Props = $props()
|
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(
|
const isViewingCurrentCallRoom = $derived(
|
||||||
$callTargetRoom?.url === url && $callTargetRoom?.h === h,
|
$callTargetRoom?.url === url && $callTargetRoom?.h === h,
|
||||||
)
|
)
|
||||||
|
|
||||||
const showVideoContent = $derived(
|
const showVideoContent = $derived(
|
||||||
isViewingCurrentCallRoom &&
|
isViewingCurrentCallRoom &&
|
||||||
(mobile
|
(layout === VideoCallLayout.Split || layout === VideoCallLayout.Video),
|
||||||
? layout === VideoCallLayout.Video
|
|
||||||
: layout === VideoCallLayout.Split || layout === VideoCallLayout.Video),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const videoTiles = $derived.by(() => {
|
const videoTiles = $derived.by(() => {
|
||||||
|
|
@ -219,6 +195,7 @@
|
||||||
|
|
||||||
{#snippet videoTile(tile: VideoTileData, layout: TileLayout)}
|
{#snippet videoTile(tile: VideoTileData, layout: TileLayout)}
|
||||||
{@const media = $mediaStateByIdentity(tile.liveKitIdentity)}
|
{@const media = $mediaStateByIdentity(tile.liveKitIdentity)}
|
||||||
|
{@const label = labelFor(tile.liveKitIdentity, tile.source)}
|
||||||
<div
|
<div
|
||||||
class={cx(
|
class={cx(
|
||||||
"relative isolate overflow-hidden rounded-2xl shadow-sm",
|
"relative isolate overflow-hidden rounded-2xl shadow-sm",
|
||||||
|
|
@ -249,12 +226,13 @@
|
||||||
{/if}
|
{/if}
|
||||||
<span
|
<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">
|
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>
|
</span>
|
||||||
{#if videoTiles.length > 1}
|
{#if videoTiles.length > 1}
|
||||||
{@const pinned = $videoPrimaryTileKey === tileKey(tile)}
|
{@const pinned = $videoPrimaryTileKey === tileKey(tile)}
|
||||||
<Button
|
<Button
|
||||||
data-tip={pinned ? "Exit spotlight" : "Spotlight"}
|
data-tip={pinned ? "Exit spotlight" : "Spotlight"}
|
||||||
|
aria-label={pinned ? `Exit spotlight for ${label}` : `Spotlight ${label}`}
|
||||||
aria-pressed={pinned}
|
aria-pressed={pinned}
|
||||||
class={cx(
|
class={cx(
|
||||||
`button button-${pinned ? "primary" : "ghost"} button-xs button-square`,
|
`button button-${pinned ? "primary" : "ghost"} button-xs button-square`,
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import {spec} from "@welshman/lib"
|
import {spec} from "@welshman/lib"
|
||||||
|
import {RoomEvent, Track} from "livekit-client"
|
||||||
import Button from "@lib/components/Button.svelte"
|
import Button from "@lib/components/Button.svelte"
|
||||||
import FieldInline from "@lib/components/FieldInline.svelte"
|
import FieldInline from "@lib/components/FieldInline.svelte"
|
||||||
import Modal from "@lib/components/Modal.svelte"
|
import Modal from "@lib/components/Modal.svelte"
|
||||||
|
|
@ -8,6 +9,7 @@
|
||||||
import ModalHeader from "@lib/components/ModalHeader.svelte"
|
import ModalHeader from "@lib/components/ModalHeader.svelte"
|
||||||
import ModalSubtitle from "@lib/components/ModalSubtitle.svelte"
|
import ModalSubtitle from "@lib/components/ModalSubtitle.svelte"
|
||||||
import ModalTitle from "@lib/components/ModalTitle.svelte"
|
import ModalTitle from "@lib/components/ModalTitle.svelte"
|
||||||
|
import MicLevelMeter from "@app/components/MicLevelMeter.svelte"
|
||||||
import {
|
import {
|
||||||
currentCallSession,
|
currentCallSession,
|
||||||
DeviceKind,
|
DeviceKind,
|
||||||
|
|
@ -31,6 +33,11 @@
|
||||||
let selectedInput = $state("")
|
let selectedInput = $state("")
|
||||||
let selectedOutput = $state("")
|
let selectedOutput = $state("")
|
||||||
let selectedVideo = $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 () => {
|
const loadDevices = async () => {
|
||||||
if (!navigator.mediaDevices?.enumerateDevices) return
|
if (!navigator.mediaDevices?.enumerateDevices) return
|
||||||
|
|
@ -61,10 +68,29 @@
|
||||||
selectedInput = selectValueForActiveDevice(session, DeviceKind.AudioInput)
|
selectedInput = selectValueForActiveDevice(session, DeviceKind.AudioInput)
|
||||||
selectedOutput = selectValueForActiveDevice(session, DeviceKind.AudioOutput)
|
selectedOutput = selectValueForActiveDevice(session, DeviceKind.AudioOutput)
|
||||||
selectedVideo = selectValueForActiveDevice(session, DeviceKind.VideoInput)
|
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 = () => {
|
const onInputChange = async () => {
|
||||||
void switchCallActiveDevice(DeviceKind.AudioInput, selectedInput)
|
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 = () => {
|
const onOutputChange = () => {
|
||||||
|
|
@ -109,6 +135,7 @@
|
||||||
</select>
|
</select>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
</FieldInline>
|
</FieldInline>
|
||||||
|
<MicLevelMeter track={micTrack} offMessage="Microphone is off — unmute to test it" />
|
||||||
{#if canPickOutput}
|
{#if canPickOutput}
|
||||||
<FieldInline>
|
<FieldInline>
|
||||||
{#snippet label()}
|
{#snippet label()}
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,10 @@
|
||||||
|
|
||||||
const {url, h, replaceState = false, notification = false}: Props = $props()
|
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 participants = deriveCallParticipants(url, h)
|
||||||
const participantPubkeys = $derived($participants.flatMap(p => (p.pubkey ? [p.pubkey] : [])))
|
const participantPubkeys = $derived($participants.flatMap(p => (p.pubkey ? [p.pubkey] : [])))
|
||||||
const isActive = $derived(
|
const isActive = $derived(
|
||||||
|
|
@ -85,7 +89,7 @@
|
||||||
<RoomName {url} {h} />
|
<RoomName {url} {h} />
|
||||||
</div>
|
</div>
|
||||||
{#if participantPubkeys.length > 0}
|
{#if participantPubkeys.length > 0}
|
||||||
{#if isActive}
|
{#if isActive && $participants.length <= ACTIVE_LIST_MAX}
|
||||||
{#each $participants as p (participantKey(p as CallParticipant))}
|
{#each $participants as p (participantKey(p as CallParticipant))}
|
||||||
{@const media = $mediaStateByIdentity(p.liveKitIdentity)}
|
{@const media = $mediaStateByIdentity(p.liveKitIdentity)}
|
||||||
<div class="flex items-center gap-2 ml-6">
|
<div class="flex items-center gap-2 ml-6">
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@
|
||||||
import ModalSubtitle from "@lib/components/ModalSubtitle.svelte"
|
import ModalSubtitle from "@lib/components/ModalSubtitle.svelte"
|
||||||
import ModalTitle from "@lib/components/ModalTitle.svelte"
|
import ModalTitle from "@lib/components/ModalTitle.svelte"
|
||||||
import {AbortError, TimeoutError} from "$lib/util"
|
import {AbortError, TimeoutError} from "$lib/util"
|
||||||
|
import MicLevelMeter from "@app/components/MicLevelMeter.svelte"
|
||||||
import ProfileCircles from "@app/components/ProfileCircles.svelte"
|
import ProfileCircles from "@app/components/ProfileCircles.svelte"
|
||||||
import {displayRoom} from "@app/rooms"
|
import {displayRoom} from "@app/rooms"
|
||||||
import {deriveCallParticipants, joinVoiceRoom, loadCallParticipants} from "@app/call"
|
import {deriveCallParticipants, joinVoiceRoom, loadCallParticipants} from "@app/call"
|
||||||
|
|
@ -33,6 +34,7 @@
|
||||||
let audioInputs = $state<MediaDeviceInfo[]>([])
|
let audioInputs = $state<MediaDeviceInfo[]>([])
|
||||||
let selectedDeviceId = $state("")
|
let selectedDeviceId = $state("")
|
||||||
let startWithoutMic = $state(false)
|
let startWithoutMic = $state(false)
|
||||||
|
let micError = $state(false)
|
||||||
|
|
||||||
const loadDevices = async () => {
|
const loadDevices = async () => {
|
||||||
if (!navigator.mediaDevices?.enumerateDevices) return
|
if (!navigator.mediaDevices?.enumerateDevices) return
|
||||||
|
|
@ -49,7 +51,60 @@
|
||||||
void loadDevices()
|
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) => {
|
const handleJoinError = (e: unknown) => {
|
||||||
if (e instanceof AbortError) return
|
if (e instanceof AbortError) return
|
||||||
|
|
@ -62,6 +117,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
const joinVoice = async () => {
|
const joinVoice = async () => {
|
||||||
|
stopMicPreview()
|
||||||
popModal()
|
popModal()
|
||||||
await joinVoiceRoom(
|
await joinVoiceRoom(
|
||||||
url,
|
url,
|
||||||
|
|
@ -121,6 +177,13 @@
|
||||||
</select>
|
</select>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
</FieldInline>
|
</FieldInline>
|
||||||
|
{#if !startWithoutMic}
|
||||||
|
<MicLevelMeter
|
||||||
|
track={previewTrack}
|
||||||
|
error={micError
|
||||||
|
? "Could not access this microphone. Check your browser permissions, or join without one."
|
||||||
|
: undefined} />
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</ModalBody>
|
</ModalBody>
|
||||||
<ModalFooter>
|
<ModalFooter>
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,6 @@
|
||||||
import Settings from "@assets/icons/settings.svg?dataurl"
|
import Settings from "@assets/icons/settings.svg?dataurl"
|
||||||
import Icon from "@lib/components/Icon.svelte"
|
import Icon from "@lib/components/Icon.svelte"
|
||||||
import Button from "@lib/components/Button.svelte"
|
import Button from "@lib/components/Button.svelte"
|
||||||
import Spinner from "@lib/components/Spinner.svelte"
|
|
||||||
import VoiceCallAudioSettingsDialog from "@app/components/VoiceCallAudioSettingsDialog.svelte"
|
import VoiceCallAudioSettingsDialog from "@app/components/VoiceCallAudioSettingsDialog.svelte"
|
||||||
import VoiceRoomJoinDialog from "@app/components/VoiceRoomJoinDialog.svelte"
|
import VoiceRoomJoinDialog from "@app/components/VoiceRoomJoinDialog.svelte"
|
||||||
import {rooms} from "@app/core"
|
import {rooms} from "@app/core"
|
||||||
|
|
@ -30,7 +29,6 @@
|
||||||
import {makeRoomPath} from "@app/routes"
|
import {makeRoomPath} from "@app/routes"
|
||||||
import {
|
import {
|
||||||
VideoCallLayout,
|
VideoCallLayout,
|
||||||
isDesktopLayout,
|
|
||||||
toggleCamera,
|
toggleCamera,
|
||||||
toggleScreenShare,
|
toggleScreenShare,
|
||||||
videoCallLayout,
|
videoCallLayout,
|
||||||
|
|
@ -98,24 +96,13 @@
|
||||||
|
|
||||||
const showChatButton = $derived($callState === CallState.Connected && isViewingCurrentVoiceRoom)
|
const showChatButton = $derived($callState === CallState.Connected && isViewingCurrentVoiceRoom)
|
||||||
|
|
||||||
const isChatPanelActive = $derived(
|
const isChatPanelActive = $derived(showChatButton && $videoCallLayout === VideoCallLayout.Split)
|
||||||
showChatButton &&
|
|
||||||
(isDesktopLayout.current
|
|
||||||
? $videoCallLayout === VideoCallLayout.Split
|
|
||||||
: $videoCallLayout === VideoCallLayout.Chat),
|
|
||||||
)
|
|
||||||
|
|
||||||
const onChatToggle = () => {
|
const onChatToggle = () => {
|
||||||
if (!showChatButton) return
|
if (!showChatButton) return
|
||||||
if (isDesktopLayout.current) {
|
videoCallLayout.update(p =>
|
||||||
videoCallLayout.update(p =>
|
p === VideoCallLayout.Split ? VideoCallLayout.Video : VideoCallLayout.Split,
|
||||||
p === VideoCallLayout.Split ? VideoCallLayout.Video : VideoCallLayout.Split,
|
)
|
||||||
)
|
|
||||||
} else {
|
|
||||||
videoCallLayout.update(p =>
|
|
||||||
p === VideoCallLayout.Video ? VideoCallLayout.Chat : VideoCallLayout.Video,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const chatUnread = $derived(
|
const chatUnread = $derived(
|
||||||
|
|
@ -128,99 +115,114 @@
|
||||||
in:fly={{y: 60, duration: 350}}
|
in:fly={{y: 60, duration: 350}}
|
||||||
out:fly={{y: 60, duration: 250}}
|
out:fly={{y: 60, duration: 250}}
|
||||||
class="card card-sm flex flex-col gap-2">
|
class="card card-sm flex flex-col gap-2">
|
||||||
<div class="flex items-start justify-between gap-2">
|
<Button
|
||||||
<Button
|
class="w-full min-w-0 rounded-xl px-2 py-1 text-left hover:bg-surface"
|
||||||
class="min-w-0 flex-1 rounded-xl px-2 py-1 text-left hover:bg-surface"
|
onclick={goToRoom}
|
||||||
onclick={goToRoom}
|
aria-label="Open room {roomName}">
|
||||||
aria-label="Open room {roomName}">
|
<div class="flex flex-col gap-0.5">
|
||||||
<div class="flex flex-col gap-0.5">
|
{#if $callState === CallState.Joining}
|
||||||
{#if $callState === CallState.Joining}
|
<span class="text-sm font-semibold text-warning">Joining...</span>
|
||||||
<span class="text-sm font-semibold text-warning">Joining...</span>
|
{:else if $callState === CallState.Connected}
|
||||||
{:else if $callState === CallState.Connected}
|
<span class="text-sm font-semibold text-success">Voice Connected</span>
|
||||||
<span class="text-sm font-semibold text-success">Voice Connected</span>
|
{:else}
|
||||||
{:else}
|
<span class="text-sm font-semibold text-content-muted">Disconnected</span>
|
||||||
<span class="text-sm font-semibold text-content-muted">Disconnected</span>
|
{/if}
|
||||||
{/if}
|
<span class="truncate min-w-0 text-xs text-content-muted">
|
||||||
<span class="truncate min-w-0 text-xs text-content-muted">
|
{roomName} / {spaceName}
|
||||||
{roomName} / {spaceName}
|
</span>
|
||||||
</span>
|
</div>
|
||||||
</div>
|
</Button>
|
||||||
</Button>
|
<div class="grid grid-cols-3 gap-1.5">
|
||||||
{#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">
|
|
||||||
{#if $callState === CallState.Joining}
|
{#if $callState === CallState.Joining}
|
||||||
<Spinner size="sm" />
|
|
||||||
<Button
|
<Button
|
||||||
data-tip="Cancel"
|
aria-label="Cancel joining voice room"
|
||||||
class="button button-sm button-square tip tip-top"
|
class="button button-neutral col-span-3 flex-col !h-auto gap-0.5 rounded-xl px-2 py-1.5"
|
||||||
onclick={cancelJoinVoiceRoom}>
|
onclick={cancelJoinVoiceRoom}>
|
||||||
<Icon icon={CloseCircle} size={4} />
|
<Icon icon={CloseCircle} size={4} />
|
||||||
|
<span class="text-[10px] leading-none font-semibold">Cancel</span>
|
||||||
</Button>
|
</Button>
|
||||||
{:else if $callState === CallState.Connected && $currentCallSession}
|
{:else if $callState === CallState.Connected && $currentCallSession}
|
||||||
<Button
|
<Button
|
||||||
data-tip={$callMicMuted ? "Unmute" : "Mute"}
|
aria-label={$callMicMuted ? "Unmute microphone" : "Mute microphone"}
|
||||||
|
aria-pressed={!$callMicMuted}
|
||||||
class={cx(
|
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",
|
$callMicMuted ? "button-neutral" : "button-primary",
|
||||||
)}
|
)}
|
||||||
onclick={toggleMute}>
|
onclick={toggleMute}>
|
||||||
<Icon icon={$callMicMuted ? MicrophoneOff : Microphone} size={$callMicMuted ? 4 : 4.5} />
|
<Icon icon={$callMicMuted ? MicrophoneOff : Microphone} size={$callMicMuted ? 4 : 4.5} />
|
||||||
|
<span class="text-[10px] leading-none font-semibold">
|
||||||
|
{$callMicMuted ? "Unmute" : "Mute"}
|
||||||
|
</span>
|
||||||
</Button>
|
</Button>
|
||||||
<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(
|
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",
|
$currentCallSession.cameraOn ? "button-primary" : "button-neutral",
|
||||||
)}
|
)}
|
||||||
onclick={toggleCamera}>
|
onclick={toggleCamera}>
|
||||||
<Icon icon={$currentCallSession.cameraOn ? VideocameraRecord : VideocameraOff} size={4} />
|
<Icon icon={$currentCallSession.cameraOn ? VideocameraRecord : VideocameraOff} size={4} />
|
||||||
|
<span class="text-[10px] leading-none font-semibold">
|
||||||
|
{$currentCallSession.cameraOn ? "Stop" : "Start"}
|
||||||
|
</span>
|
||||||
</Button>
|
</Button>
|
||||||
{#if !Capacitor.isNativePlatform()}
|
{#if !Capacitor.isNativePlatform()}
|
||||||
<Button
|
<Button
|
||||||
data-tip={$currentCallSession.screenShareOn ? "Stop sharing" : "Share screen"}
|
aria-label={$currentCallSession.screenShareOn ? "Stop sharing screen" : "Share screen"}
|
||||||
|
aria-pressed={$currentCallSession.screenShareOn}
|
||||||
class={cx(
|
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",
|
$currentCallSession.screenShareOn ? "button-primary" : "button-neutral",
|
||||||
)}
|
)}
|
||||||
onclick={toggleScreenShare}>
|
onclick={toggleScreenShare}>
|
||||||
<Icon icon={Monitor} size={3.75} />
|
<Icon icon={Monitor} size={3.75} />
|
||||||
|
<span class="text-[10px] leading-none font-semibold">
|
||||||
|
{$currentCallSession.screenShareOn ? "Stop share" : "Share"}
|
||||||
|
</span>
|
||||||
</Button>
|
</Button>
|
||||||
{/if}
|
{/if}
|
||||||
<Button
|
<Button
|
||||||
data-tip="Call settings"
|
aria-label="Call settings"
|
||||||
class="button button-neutral button-sm button-square tip tip-top"
|
class="button button-neutral w-full flex-col !h-auto gap-0.5 rounded-xl px-2 py-1.5"
|
||||||
onclick={openCallSettings}>
|
onclick={openCallSettings}>
|
||||||
<Icon icon={Settings} size={4} />
|
<Icon icon={Settings} size={4} />
|
||||||
|
<span class="text-[10px] leading-none font-semibold">Settings</span>
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
data-tip="Leave room"
|
aria-label="Leave voice room"
|
||||||
class="button button-error button-sm button-square tip tip-top"
|
class="button button-error w-full flex-col !h-auto gap-0.5 rounded-xl px-2 py-1.5"
|
||||||
onclick={leaveVoiceRoom}>
|
onclick={leaveVoiceRoom}>
|
||||||
<Icon icon={EndCall} size={4.5} />
|
<Icon icon={EndCall} size={4.5} />
|
||||||
|
<span class="text-[10px] leading-none font-semibold">Leave</span>
|
||||||
</Button>
|
</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}
|
{:else}
|
||||||
<Button
|
<Button
|
||||||
data-tip="Join Voice"
|
aria-label="Join voice room"
|
||||||
class="button button-primary button-sm button-square tip tip-top"
|
class="button button-primary col-span-3 flex-col !h-auto gap-0.5 rounded-xl px-2 py-1.5"
|
||||||
onclick={openJoinDialog}>
|
onclick={openJoinDialog}>
|
||||||
<Icon icon={PhoneCallingRounded} size={4.5} />
|
<Icon icon={PhoneCallingRounded} size={4.5} />
|
||||||
|
<span class="text-[10px] leading-none font-semibold">Join</span>
|
||||||
</Button>
|
</Button>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue