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
|
||||
* (ICE candidate gathering fails). Use Chrome or test from deployed HTTPS.
|
||||
*/
|
||||
import {MediaQuery} from "svelte/reactivity"
|
||||
import {
|
||||
DisconnectReason,
|
||||
LocalParticipant,
|
||||
|
|
@ -32,7 +31,6 @@ export const LIVEKIT_PARTICIPANTS = 39004
|
|||
export {supportsAudioOutputSelection}
|
||||
|
||||
const LIVEKIT_DEFAULT_DEVICE_ID = "default"
|
||||
const VISUAL_SOURCES = [Track.Source.Camera, Track.Source.ScreenShare] as const
|
||||
const RECONNECT_DELAYS = [1000, 2000, 4000, 8000, 16000]
|
||||
|
||||
export type CallSession = {
|
||||
|
|
@ -65,11 +63,6 @@ export enum VideoCallLayout {
|
|||
Split = "split",
|
||||
}
|
||||
|
||||
export enum ViewportSize {
|
||||
Desktop = "desktop",
|
||||
Mobile = "mobile",
|
||||
}
|
||||
|
||||
export enum DeviceKind {
|
||||
AudioInput = "audioinput",
|
||||
AudioOutput = "audiooutput",
|
||||
|
|
@ -109,24 +102,10 @@ export const triggerVideoTrackRevision = () => {
|
|||
videoTrackRevision.update(n => n + 1)
|
||||
}
|
||||
|
||||
export const isDesktopLayout = new MediaQuery("min-width: 768px", false)
|
||||
|
||||
export const videoCallViewportSync = {
|
||||
previousLayout: undefined as ViewportSize | undefined,
|
||||
}
|
||||
|
||||
export const videoCallLayout = writable<VideoCallLayout>(VideoCallLayout.Split)
|
||||
|
||||
export const videoPrimaryTileKey = writable<string | undefined>(undefined)
|
||||
|
||||
export const videoTileCount = derived(
|
||||
[currentCallSession, callState, videoTrackRevision, participantMediaState],
|
||||
([$session, $state]) => {
|
||||
if ($state !== CallState.Connected || !$session) return 0
|
||||
return countLiveVisualFeeds($session)
|
||||
},
|
||||
)
|
||||
|
||||
export const joinVoiceRoom = async (
|
||||
url: string,
|
||||
h: string,
|
||||
|
|
@ -375,7 +354,6 @@ export const switchCallActiveDevice = async (
|
|||
}
|
||||
|
||||
export const resetVideoCallLayout = () => {
|
||||
videoCallViewportSync.previousLayout = undefined
|
||||
videoCallLayout.set(VideoCallLayout.Chat)
|
||||
}
|
||||
|
||||
|
|
@ -458,27 +436,6 @@ const teardownRoom = (livekit: LiveKitRoom) => {
|
|||
livekit.disconnect()
|
||||
}
|
||||
|
||||
const countLiveVisualFeeds = (session: CallSession): number => {
|
||||
const livekit = session.livekit
|
||||
let n = 0
|
||||
const lp = livekit.localParticipant
|
||||
if (session.cameraOn) {
|
||||
const pub = lp.getTrackPublication(Track.Source.Camera)
|
||||
if (pub?.track) n += 1
|
||||
}
|
||||
if (session.screenShareOn) {
|
||||
const pub = lp.getTrackPublication(Track.Source.ScreenShare)
|
||||
if (pub?.track) n += 1
|
||||
}
|
||||
for (const rp of livekit.remoteParticipants.values()) {
|
||||
for (const source of VISUAL_SOURCES) {
|
||||
const pub = rp.getTrackPublication(source)
|
||||
if (pub?.isSubscribed && pub.track && !pub.isMuted) n += 1
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
const participantMediaFrom = (participant: Participant): ParticipantMediaState => ({
|
||||
muted: !participant.isMicrophoneEnabled,
|
||||
cameraOn: participant.isCameraEnabled,
|
||||
|
|
|
|||
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 ClockCircle from "@assets/icons/clock-circle.svg?dataurl"
|
||||
import Login2 from "@assets/icons/login-3.svg?dataurl"
|
||||
import ChatRound from "@assets/icons/chat-round.svg?dataurl"
|
||||
import Close from "@assets/icons/close.svg?dataurl"
|
||||
import {fade, fly} from "@lib/transition"
|
||||
import {popKey} from "@lib/implicit"
|
||||
import {documentActive} from "@lib/html"
|
||||
|
|
@ -31,14 +33,7 @@
|
|||
import VoiceWidget from "@app/components/VoiceWidget.svelte"
|
||||
import {deletes, relays, rooms, thunks, user} from "@app/core"
|
||||
import {publishRoomJoinRequest} from "@app/access"
|
||||
import {
|
||||
CallState,
|
||||
callTargetRoom,
|
||||
callState,
|
||||
VideoCallLayout,
|
||||
videoCallLayout,
|
||||
videoTileCount,
|
||||
} from "@app/call"
|
||||
import {CallState, callTargetRoom, callState, VideoCallLayout, videoCallLayout} from "@app/call"
|
||||
import {
|
||||
PROTECTED,
|
||||
RoomType,
|
||||
|
|
@ -49,7 +44,7 @@
|
|||
} from "@app/rooms"
|
||||
import {userSettingsValues} from "@app/settings"
|
||||
import {makeFeed} from "@app/feeds"
|
||||
import {checked, deferredRoomPath, setChecked} from "@app/notifications"
|
||||
import {checked, deferredRoomPath, notifications, setChecked} from "@app/notifications"
|
||||
import {makeRoomPath} from "@app/routes"
|
||||
import {pushToast} from "@app/toast"
|
||||
|
||||
|
|
@ -75,45 +70,53 @@
|
|||
isVoiceRoom && $callState === CallState.Connected && $videoCallLayout === VideoCallLayout.Video,
|
||||
)
|
||||
|
||||
const roomPath = h ? makeRoomPath(url, h) : undefined
|
||||
// Basic pass at issue #121's "chat overlaid on video" idea: float the chat panel over
|
||||
// the full-bleed video instead of splitting the screen into fixed columns, so the call
|
||||
// stays the visual focus. The panel opens from — and collapses back to — a chat FAB.
|
||||
const isOverlayChat = $derived(voiceConnectedHere && $videoCallLayout === VideoCallLayout.Split)
|
||||
|
||||
const videoCallChatHidden = $derived(
|
||||
voiceConnectedHere && $videoCallLayout === VideoCallLayout.Video,
|
||||
)
|
||||
|
||||
const roomPath = h ? makeRoomPath(url, h) : undefined
|
||||
const chatUnread = $derived(roomPath !== undefined && $notifications.has(roomPath))
|
||||
|
||||
const openChat = () => videoCallLayout.set(VideoCallLayout.Split)
|
||||
const closeChat = () => videoCallLayout.set(VideoCallLayout.Video)
|
||||
|
||||
$effect(() => {
|
||||
deferredRoomPath.set(videoCallChatHidden ? roomPath : undefined)
|
||||
if (roomPath && voiceConnectedHere && !videoCallChatHidden) {
|
||||
setChecked(roomPath)
|
||||
}
|
||||
// The messages pane (and its scroll position) is offscreen while the call view is
|
||||
// showing — don't leave its floating "scroll down"/"new messages" chips stranded.
|
||||
if (videoCallChatHidden) {
|
||||
showScrollButton = false
|
||||
showFixedNewMessages = false
|
||||
}
|
||||
})
|
||||
|
||||
onDestroy(() => deferredRoomPath.set(undefined))
|
||||
|
||||
let prevVideoTileCount = $state(0)
|
||||
let wasConnectedHere = $state(false)
|
||||
|
||||
$effect(() => {
|
||||
if ($callState !== CallState.Connected) {
|
||||
videoCallLayout.set(VideoCallLayout.Chat)
|
||||
prevVideoTileCount = 0
|
||||
if (!voiceConnectedHere) {
|
||||
if ($callState !== CallState.Connected) {
|
||||
videoCallLayout.set(VideoCallLayout.Chat)
|
||||
}
|
||||
wasConnectedHere = false
|
||||
return
|
||||
}
|
||||
|
||||
const here = isVoiceRoom && $callTargetRoom?.url === url && $callTargetRoom?.h === h
|
||||
const n = $videoTileCount
|
||||
|
||||
if (!here) {
|
||||
prevVideoTileCount = 0
|
||||
return
|
||||
}
|
||||
|
||||
if (prevVideoTileCount === 0 && n >= 1) {
|
||||
// Land on the call view (chat as a FAB) as soon as the call starts here, whether
|
||||
// or not anyone's camera is on — chat is one tap away via the FAB.
|
||||
if (!wasConnectedHere) {
|
||||
videoCallLayout.set(VideoCallLayout.Video)
|
||||
wasConnectedHere = true
|
||||
}
|
||||
if (prevVideoTileCount >= 1 && n === 0 && $videoCallLayout === VideoCallLayout.Split) {
|
||||
videoCallLayout.set(VideoCallLayout.Chat)
|
||||
}
|
||||
prevVideoTileCount = n
|
||||
})
|
||||
|
||||
const shouldProtect = $relays.hasNip(url, 70)
|
||||
|
|
@ -463,30 +466,45 @@
|
|||
})
|
||||
</script>
|
||||
|
||||
<div
|
||||
class={cx(
|
||||
"flex min-h-0 flex-1 flex-col",
|
||||
voiceConnectedHere && $videoCallLayout === VideoCallLayout.Split && "md:flex-row",
|
||||
)}>
|
||||
<div class={cx("flex min-h-0 flex-1 flex-col", isOverlayChat && "relative")}>
|
||||
{#if h && voiceConnectedHere}
|
||||
<VideoCallContent
|
||||
layout={$videoCallLayout}
|
||||
{url}
|
||||
{h}
|
||||
class="hidden min-h-0 w-full min-w-0 flex-1 flex-col md:flex" />
|
||||
class={cx(
|
||||
"hidden min-h-0 w-full min-w-0 flex-1 flex-col md:flex",
|
||||
isOverlayChat && "md:absolute md:inset-0",
|
||||
)} />
|
||||
{/if}
|
||||
|
||||
{#if h && isVoiceRoom && $callState === CallState.Connected}
|
||||
<VideoCallContent
|
||||
layout={$videoCallLayout}
|
||||
mobile
|
||||
{url}
|
||||
{h}
|
||||
class={cx("md:hidden", isOverlayChat && "absolute inset-0")} />
|
||||
{/if}
|
||||
|
||||
<div
|
||||
class={cx(
|
||||
"room flex min-h-0 min-w-0 flex-1 flex-col",
|
||||
voiceConnectedHere && $videoCallLayout === VideoCallLayout.Video && "md:hidden",
|
||||
videoCallChatHidden && "hidden",
|
||||
isOverlayChat &&
|
||||
"absolute inset-x-2 top-4 bottom-[calc(3.5rem+var(--saib))] z-popover overflow-hidden rounded-2xl border border-line bg-surface/95 shadow-lg md:left-auto md:right-4 md:bottom-4 md:w-96 md:max-w-[26rem] md:bg-surface/90",
|
||||
)}>
|
||||
{#if h}
|
||||
<RoomPinnedMessages {url} {h} />
|
||||
{#if isOverlayChat}
|
||||
<Button
|
||||
aria-label="Close room chat"
|
||||
class="button button-neutral button-xs button-circle absolute right-2 top-2 z-popover"
|
||||
onclick={closeChat}>
|
||||
<Icon icon={Close} size={4} />
|
||||
</Button>
|
||||
{/if}
|
||||
|
||||
{#if h && isVoiceRoom && $callState === CallState.Connected}
|
||||
<VideoCallContent layout={$videoCallLayout} mobile {url} {h} class="md:hidden" />
|
||||
{#if h}
|
||||
<RoomPinnedMessages {url} {h} />
|
||||
{/if}
|
||||
|
||||
<div
|
||||
|
|
@ -621,13 +639,27 @@
|
|||
<div
|
||||
class={cx(
|
||||
"hide-on-keyboard flex-shrink-0 p-2 md:hidden",
|
||||
showMobileVideoPanel && "hidden",
|
||||
videoCallChatHidden && "hidden",
|
||||
)}>
|
||||
<VoiceWidget />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if videoCallChatHidden}
|
||||
<Button
|
||||
aria-label="Open room chat"
|
||||
class="button button-primary button-circle fixed z-popover bottom-[calc(4.5rem+var(--saib))] right-4 shadow-lg md:bottom-4"
|
||||
onclick={openChat}>
|
||||
<Icon icon={ChatRound} size={5} />
|
||||
{#if chatUnread}
|
||||
<span
|
||||
class="absolute -right-0.5 -top-0.5 h-2.5 w-2.5 rounded-full bg-primary ring-2 ring-surface"
|
||||
aria-hidden="true"></span>
|
||||
{/if}
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if showScrollButton}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@
|
|||
import {spec} from "@welshman/lib"
|
||||
import cx from "classnames"
|
||||
import {Track} from "livekit-client"
|
||||
import {get} from "svelte/store"
|
||||
import Pin from "@assets/icons/pin.svg?dataurl"
|
||||
import Button from "@lib/components/Button.svelte"
|
||||
import Icon from "@lib/components/Icon.svelte"
|
||||
|
|
@ -12,11 +11,7 @@
|
|||
import VoiceParticipantMediaBadges from "@app/components/VoiceParticipantMediaBadges.svelte"
|
||||
import {
|
||||
VideoCallLayout,
|
||||
isDesktopLayout,
|
||||
toggleVideoPrimaryTile,
|
||||
videoCallLayout,
|
||||
videoCallViewportSync,
|
||||
ViewportSize,
|
||||
videoPrimaryTileKey,
|
||||
currentCallSession,
|
||||
callTargetRoom,
|
||||
|
|
@ -47,32 +42,13 @@
|
|||
|
||||
const {layout, mobile = false, url, h, class: className = ""}: Props = $props()
|
||||
|
||||
$effect(() => {
|
||||
const currentLayout = isDesktopLayout.current ? ViewportSize.Desktop : ViewportSize.Mobile
|
||||
const {previousLayout} = videoCallViewportSync
|
||||
if (previousLayout === undefined) {
|
||||
videoCallViewportSync.previousLayout = currentLayout
|
||||
return
|
||||
}
|
||||
if (previousLayout === currentLayout) return
|
||||
const p = get(videoCallLayout)
|
||||
if (previousLayout === ViewportSize.Desktop && currentLayout === ViewportSize.Mobile) {
|
||||
if (p === VideoCallLayout.Split) videoCallLayout.set(VideoCallLayout.Video)
|
||||
} else if (previousLayout === ViewportSize.Mobile && currentLayout === ViewportSize.Desktop) {
|
||||
if (p === VideoCallLayout.Chat) videoCallLayout.set(VideoCallLayout.Split)
|
||||
}
|
||||
videoCallViewportSync.previousLayout = currentLayout
|
||||
})
|
||||
|
||||
const isViewingCurrentCallRoom = $derived(
|
||||
$callTargetRoom?.url === url && $callTargetRoom?.h === h,
|
||||
)
|
||||
|
||||
const showVideoContent = $derived(
|
||||
isViewingCurrentCallRoom &&
|
||||
(mobile
|
||||
? layout === VideoCallLayout.Video
|
||||
: layout === VideoCallLayout.Split || layout === VideoCallLayout.Video),
|
||||
(layout === VideoCallLayout.Split || layout === VideoCallLayout.Video),
|
||||
)
|
||||
|
||||
const videoTiles = $derived.by(() => {
|
||||
|
|
@ -219,6 +195,7 @@
|
|||
|
||||
{#snippet videoTile(tile: VideoTileData, layout: TileLayout)}
|
||||
{@const media = $mediaStateByIdentity(tile.liveKitIdentity)}
|
||||
{@const label = labelFor(tile.liveKitIdentity, tile.source)}
|
||||
<div
|
||||
class={cx(
|
||||
"relative isolate overflow-hidden rounded-2xl shadow-sm",
|
||||
|
|
@ -249,12 +226,13 @@
|
|||
{/if}
|
||||
<span
|
||||
class="pointer-events-none absolute bottom-1 left-1 max-w-[calc(100%-0.5rem)] truncate rounded bg-surface/80 px-1.5 py-0.5 text-xs">
|
||||
{labelFor(tile.liveKitIdentity, tile.source)}{tile.isLocal ? " (you)" : ""}
|
||||
{label}{tile.isLocal ? " (you)" : ""}
|
||||
</span>
|
||||
{#if videoTiles.length > 1}
|
||||
{@const pinned = $videoPrimaryTileKey === tileKey(tile)}
|
||||
<Button
|
||||
data-tip={pinned ? "Exit spotlight" : "Spotlight"}
|
||||
aria-label={pinned ? `Exit spotlight for ${label}` : `Spotlight ${label}`}
|
||||
aria-pressed={pinned}
|
||||
class={cx(
|
||||
`button button-${pinned ? "primary" : "ghost"} button-xs button-square`,
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
<script lang="ts">
|
||||
import {spec} from "@welshman/lib"
|
||||
import {RoomEvent, Track} from "livekit-client"
|
||||
import Button from "@lib/components/Button.svelte"
|
||||
import FieldInline from "@lib/components/FieldInline.svelte"
|
||||
import Modal from "@lib/components/Modal.svelte"
|
||||
|
|
@ -8,6 +9,7 @@
|
|||
import ModalHeader from "@lib/components/ModalHeader.svelte"
|
||||
import ModalSubtitle from "@lib/components/ModalSubtitle.svelte"
|
||||
import ModalTitle from "@lib/components/ModalTitle.svelte"
|
||||
import MicLevelMeter from "@app/components/MicLevelMeter.svelte"
|
||||
import {
|
||||
currentCallSession,
|
||||
DeviceKind,
|
||||
|
|
@ -31,6 +33,11 @@
|
|||
let selectedInput = $state("")
|
||||
let selectedOutput = $state("")
|
||||
let selectedVideo = $state("")
|
||||
let micTrack = $state<MediaStreamTrack | undefined>(undefined)
|
||||
|
||||
const readMicTrack = (session: CallSession): MediaStreamTrack | undefined =>
|
||||
session.livekit.localParticipant.getTrackPublication(Track.Source.Microphone)?.track
|
||||
?.mediaStreamTrack
|
||||
|
||||
const loadDevices = async () => {
|
||||
if (!navigator.mediaDevices?.enumerateDevices) return
|
||||
|
|
@ -61,10 +68,29 @@
|
|||
selectedInput = selectValueForActiveDevice(session, DeviceKind.AudioInput)
|
||||
selectedOutput = selectValueForActiveDevice(session, DeviceKind.AudioOutput)
|
||||
selectedVideo = selectValueForActiveDevice(session, DeviceKind.VideoInput)
|
||||
|
||||
// Mic mute/unmute publishes or unpublishes the microphone track; keep the
|
||||
// level meter in sync with whichever track is actually live right now.
|
||||
const {livekit} = session
|
||||
const refreshMicTrack = () => {
|
||||
micTrack = readMicTrack(session)
|
||||
}
|
||||
refreshMicTrack()
|
||||
livekit.on(RoomEvent.LocalTrackPublished, refreshMicTrack)
|
||||
livekit.on(RoomEvent.LocalTrackUnpublished, refreshMicTrack)
|
||||
return () => {
|
||||
livekit.off(RoomEvent.LocalTrackPublished, refreshMicTrack)
|
||||
livekit.off(RoomEvent.LocalTrackUnpublished, refreshMicTrack)
|
||||
}
|
||||
})
|
||||
|
||||
const onInputChange = () => {
|
||||
void switchCallActiveDevice(DeviceKind.AudioInput, selectedInput)
|
||||
const onInputChange = async () => {
|
||||
await switchCallActiveDevice(DeviceKind.AudioInput, selectedInput)
|
||||
// Switching the active device restarts the existing LocalAudioTrack in
|
||||
// place with a new MediaStreamTrack rather than emitting a publish event,
|
||||
// so the meter needs an explicit re-read here.
|
||||
const session = $currentCallSession
|
||||
if (session) micTrack = readMicTrack(session)
|
||||
}
|
||||
|
||||
const onOutputChange = () => {
|
||||
|
|
@ -109,6 +135,7 @@
|
|||
</select>
|
||||
{/snippet}
|
||||
</FieldInline>
|
||||
<MicLevelMeter track={micTrack} offMessage="Microphone is off — unmute to test it" />
|
||||
{#if canPickOutput}
|
||||
<FieldInline>
|
||||
{#snippet label()}
|
||||
|
|
|
|||
|
|
@ -35,6 +35,10 @@
|
|||
|
||||
const {url, h, replaceState = false, notification = false}: Props = $props()
|
||||
|
||||
// Beyond this many participants, a full name+avatar row per person makes the
|
||||
// sidebar item too tall — fall back to the compact ProfileCircles cluster instead.
|
||||
const ACTIVE_LIST_MAX = 5
|
||||
|
||||
const participants = deriveCallParticipants(url, h)
|
||||
const participantPubkeys = $derived($participants.flatMap(p => (p.pubkey ? [p.pubkey] : [])))
|
||||
const isActive = $derived(
|
||||
|
|
@ -85,7 +89,7 @@
|
|||
<RoomName {url} {h} />
|
||||
</div>
|
||||
{#if participantPubkeys.length > 0}
|
||||
{#if isActive}
|
||||
{#if isActive && $participants.length <= ACTIVE_LIST_MAX}
|
||||
{#each $participants as p (participantKey(p as CallParticipant))}
|
||||
{@const media = $mediaStateByIdentity(p.liveKitIdentity)}
|
||||
<div class="flex items-center gap-2 ml-6">
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@
|
|||
import ModalSubtitle from "@lib/components/ModalSubtitle.svelte"
|
||||
import ModalTitle from "@lib/components/ModalTitle.svelte"
|
||||
import {AbortError, TimeoutError} from "$lib/util"
|
||||
import MicLevelMeter from "@app/components/MicLevelMeter.svelte"
|
||||
import ProfileCircles from "@app/components/ProfileCircles.svelte"
|
||||
import {displayRoom} from "@app/rooms"
|
||||
import {deriveCallParticipants, joinVoiceRoom, loadCallParticipants} from "@app/call"
|
||||
|
|
@ -33,6 +34,7 @@
|
|||
let audioInputs = $state<MediaDeviceInfo[]>([])
|
||||
let selectedDeviceId = $state("")
|
||||
let startWithoutMic = $state(false)
|
||||
let micError = $state(false)
|
||||
|
||||
const loadDevices = async () => {
|
||||
if (!navigator.mediaDevices?.enumerateDevices) return
|
||||
|
|
@ -49,7 +51,60 @@
|
|||
void loadDevices()
|
||||
})
|
||||
|
||||
const goBack = () => history.back()
|
||||
// Live mic level meter so the user can confirm their selected microphone is
|
||||
// actually picking up sound before joining, rather than finding out mid-call.
|
||||
// The stream handle stays a plain local while only the track is reactive: the
|
||||
// effect below both reads and writes it, and reading reactive state an effect
|
||||
// also writes makes that effect depend on its own output, so it would re-run
|
||||
// forever and cancel the in-flight getUserMedia every time.
|
||||
let previewStream: MediaStream | undefined
|
||||
let previewTrack = $state<MediaStreamTrack | undefined>(undefined)
|
||||
|
||||
const stopMicPreview = () => {
|
||||
previewStream?.getTracks().forEach(t => t.stop())
|
||||
previewStream = undefined
|
||||
previewTrack = undefined
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
// Re-runs when selectedDeviceId or startWithoutMic changes.
|
||||
void selectedDeviceId
|
||||
const withoutMic = startWithoutMic
|
||||
|
||||
stopMicPreview()
|
||||
micError = false
|
||||
|
||||
if (withoutMic || !navigator.mediaDevices?.getUserMedia) return
|
||||
|
||||
let cancelled = false
|
||||
const deviceId = selectedDeviceId
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
const stream = await navigator.mediaDevices.getUserMedia({
|
||||
audio: deviceId ? {deviceId: {exact: deviceId}} : true,
|
||||
})
|
||||
if (cancelled) {
|
||||
stream.getTracks().forEach(t => t.stop())
|
||||
return
|
||||
}
|
||||
previewStream = stream
|
||||
previewTrack = stream.getAudioTracks()[0]
|
||||
} catch {
|
||||
if (!cancelled) micError = true
|
||||
}
|
||||
})()
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
stopMicPreview()
|
||||
}
|
||||
})
|
||||
|
||||
const goBack = () => {
|
||||
stopMicPreview()
|
||||
history.back()
|
||||
}
|
||||
|
||||
const handleJoinError = (e: unknown) => {
|
||||
if (e instanceof AbortError) return
|
||||
|
|
@ -62,6 +117,7 @@
|
|||
}
|
||||
|
||||
const joinVoice = async () => {
|
||||
stopMicPreview()
|
||||
popModal()
|
||||
await joinVoiceRoom(
|
||||
url,
|
||||
|
|
@ -121,6 +177,13 @@
|
|||
</select>
|
||||
{/snippet}
|
||||
</FieldInline>
|
||||
{#if !startWithoutMic}
|
||||
<MicLevelMeter
|
||||
track={previewTrack}
|
||||
error={micError
|
||||
? "Could not access this microphone. Check your browser permissions, or join without one."
|
||||
: undefined} />
|
||||
{/if}
|
||||
</div>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
|
|
|
|||
|
|
@ -19,7 +19,6 @@
|
|||
import Settings from "@assets/icons/settings.svg?dataurl"
|
||||
import Icon from "@lib/components/Icon.svelte"
|
||||
import Button from "@lib/components/Button.svelte"
|
||||
import Spinner from "@lib/components/Spinner.svelte"
|
||||
import VoiceCallAudioSettingsDialog from "@app/components/VoiceCallAudioSettingsDialog.svelte"
|
||||
import VoiceRoomJoinDialog from "@app/components/VoiceRoomJoinDialog.svelte"
|
||||
import {rooms} from "@app/core"
|
||||
|
|
@ -30,7 +29,6 @@
|
|||
import {makeRoomPath} from "@app/routes"
|
||||
import {
|
||||
VideoCallLayout,
|
||||
isDesktopLayout,
|
||||
toggleCamera,
|
||||
toggleScreenShare,
|
||||
videoCallLayout,
|
||||
|
|
@ -98,24 +96,13 @@
|
|||
|
||||
const showChatButton = $derived($callState === CallState.Connected && isViewingCurrentVoiceRoom)
|
||||
|
||||
const isChatPanelActive = $derived(
|
||||
showChatButton &&
|
||||
(isDesktopLayout.current
|
||||
? $videoCallLayout === VideoCallLayout.Split
|
||||
: $videoCallLayout === VideoCallLayout.Chat),
|
||||
)
|
||||
const isChatPanelActive = $derived(showChatButton && $videoCallLayout === VideoCallLayout.Split)
|
||||
|
||||
const onChatToggle = () => {
|
||||
if (!showChatButton) return
|
||||
if (isDesktopLayout.current) {
|
||||
videoCallLayout.update(p =>
|
||||
p === VideoCallLayout.Split ? VideoCallLayout.Video : VideoCallLayout.Split,
|
||||
)
|
||||
} else {
|
||||
videoCallLayout.update(p =>
|
||||
p === VideoCallLayout.Video ? VideoCallLayout.Chat : VideoCallLayout.Video,
|
||||
)
|
||||
}
|
||||
videoCallLayout.update(p =>
|
||||
p === VideoCallLayout.Split ? VideoCallLayout.Video : VideoCallLayout.Split,
|
||||
)
|
||||
}
|
||||
|
||||
const chatUnread = $derived(
|
||||
|
|
@ -128,99 +115,114 @@
|
|||
in:fly={{y: 60, duration: 350}}
|
||||
out:fly={{y: 60, duration: 250}}
|
||||
class="card card-sm flex flex-col gap-2">
|
||||
<div class="flex items-start justify-between gap-2">
|
||||
<Button
|
||||
class="min-w-0 flex-1 rounded-xl px-2 py-1 text-left hover:bg-surface"
|
||||
onclick={goToRoom}
|
||||
aria-label="Open room {roomName}">
|
||||
<div class="flex flex-col gap-0.5">
|
||||
{#if $callState === CallState.Joining}
|
||||
<span class="text-sm font-semibold text-warning">Joining...</span>
|
||||
{:else if $callState === CallState.Connected}
|
||||
<span class="text-sm font-semibold text-success">Voice Connected</span>
|
||||
{:else}
|
||||
<span class="text-sm font-semibold text-content-muted">Disconnected</span>
|
||||
{/if}
|
||||
<span class="truncate min-w-0 text-xs text-content-muted">
|
||||
{roomName} / {spaceName}
|
||||
</span>
|
||||
</div>
|
||||
</Button>
|
||||
{#if showChatButton}
|
||||
<Button
|
||||
data-tip="Toggle Chat"
|
||||
class={cx(
|
||||
"button button-sm button-square tip tip-top relative",
|
||||
isChatPanelActive && "button-primary",
|
||||
)}
|
||||
onclick={onChatToggle}>
|
||||
<Icon icon={ChatRound} size={4} />
|
||||
{#if chatUnread}
|
||||
<span
|
||||
transition:fade={{duration: 150}}
|
||||
class="absolute right-1 top-1 h-2 w-2 rounded-full bg-primary"
|
||||
aria-hidden="true"></span>
|
||||
{/if}
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<Button
|
||||
class="w-full min-w-0 rounded-xl px-2 py-1 text-left hover:bg-surface"
|
||||
onclick={goToRoom}
|
||||
aria-label="Open room {roomName}">
|
||||
<div class="flex flex-col gap-0.5">
|
||||
{#if $callState === CallState.Joining}
|
||||
<span class="text-sm font-semibold text-warning">Joining...</span>
|
||||
{:else if $callState === CallState.Connected}
|
||||
<span class="text-sm font-semibold text-success">Voice Connected</span>
|
||||
{:else}
|
||||
<span class="text-sm font-semibold text-content-muted">Disconnected</span>
|
||||
{/if}
|
||||
<span class="truncate min-w-0 text-xs text-content-muted">
|
||||
{roomName} / {spaceName}
|
||||
</span>
|
||||
</div>
|
||||
</Button>
|
||||
<div class="grid grid-cols-3 gap-1.5">
|
||||
{#if $callState === CallState.Joining}
|
||||
<Spinner size="sm" />
|
||||
<Button
|
||||
data-tip="Cancel"
|
||||
class="button button-sm button-square tip tip-top"
|
||||
aria-label="Cancel joining voice room"
|
||||
class="button button-neutral col-span-3 flex-col !h-auto gap-0.5 rounded-xl px-2 py-1.5"
|
||||
onclick={cancelJoinVoiceRoom}>
|
||||
<Icon icon={CloseCircle} size={4} />
|
||||
<span class="text-[10px] leading-none font-semibold">Cancel</span>
|
||||
</Button>
|
||||
{:else if $callState === CallState.Connected && $currentCallSession}
|
||||
<Button
|
||||
data-tip={$callMicMuted ? "Unmute" : "Mute"}
|
||||
aria-label={$callMicMuted ? "Unmute microphone" : "Mute microphone"}
|
||||
aria-pressed={!$callMicMuted}
|
||||
class={cx(
|
||||
"button button-sm button-square tip tip-top",
|
||||
"button w-full flex-col !h-auto gap-0.5 rounded-xl px-2 py-1.5",
|
||||
$callMicMuted ? "button-neutral" : "button-primary",
|
||||
)}
|
||||
onclick={toggleMute}>
|
||||
<Icon icon={$callMicMuted ? MicrophoneOff : Microphone} size={$callMicMuted ? 4 : 4.5} />
|
||||
<span class="text-[10px] leading-none font-semibold">
|
||||
{$callMicMuted ? "Unmute" : "Mute"}
|
||||
</span>
|
||||
</Button>
|
||||
<Button
|
||||
data-tip={$currentCallSession.cameraOn ? "Turn off camera" : "Turn on camera"}
|
||||
aria-label={$currentCallSession.cameraOn ? "Turn off camera" : "Turn on camera"}
|
||||
aria-pressed={$currentCallSession.cameraOn}
|
||||
class={cx(
|
||||
"button button-sm button-square tip tip-top",
|
||||
"button w-full flex-col !h-auto gap-0.5 rounded-xl px-2 py-1.5",
|
||||
$currentCallSession.cameraOn ? "button-primary" : "button-neutral",
|
||||
)}
|
||||
onclick={toggleCamera}>
|
||||
<Icon icon={$currentCallSession.cameraOn ? VideocameraRecord : VideocameraOff} size={4} />
|
||||
<span class="text-[10px] leading-none font-semibold">
|
||||
{$currentCallSession.cameraOn ? "Stop" : "Start"}
|
||||
</span>
|
||||
</Button>
|
||||
{#if !Capacitor.isNativePlatform()}
|
||||
<Button
|
||||
data-tip={$currentCallSession.screenShareOn ? "Stop sharing" : "Share screen"}
|
||||
aria-label={$currentCallSession.screenShareOn ? "Stop sharing screen" : "Share screen"}
|
||||
aria-pressed={$currentCallSession.screenShareOn}
|
||||
class={cx(
|
||||
"button button-sm button-square tip tip-top",
|
||||
"button w-full flex-col !h-auto gap-0.5 rounded-xl px-2 py-1.5",
|
||||
$currentCallSession.screenShareOn ? "button-primary" : "button-neutral",
|
||||
)}
|
||||
onclick={toggleScreenShare}>
|
||||
<Icon icon={Monitor} size={3.75} />
|
||||
<span class="text-[10px] leading-none font-semibold">
|
||||
{$currentCallSession.screenShareOn ? "Stop share" : "Share"}
|
||||
</span>
|
||||
</Button>
|
||||
{/if}
|
||||
<Button
|
||||
data-tip="Call settings"
|
||||
class="button button-neutral button-sm button-square tip tip-top"
|
||||
aria-label="Call settings"
|
||||
class="button button-neutral w-full flex-col !h-auto gap-0.5 rounded-xl px-2 py-1.5"
|
||||
onclick={openCallSettings}>
|
||||
<Icon icon={Settings} size={4} />
|
||||
<span class="text-[10px] leading-none font-semibold">Settings</span>
|
||||
</Button>
|
||||
<Button
|
||||
data-tip="Leave room"
|
||||
class="button button-error button-sm button-square tip tip-top"
|
||||
aria-label="Leave voice room"
|
||||
class="button button-error w-full flex-col !h-auto gap-0.5 rounded-xl px-2 py-1.5"
|
||||
onclick={leaveVoiceRoom}>
|
||||
<Icon icon={EndCall} size={4.5} />
|
||||
<span class="text-[10px] leading-none font-semibold">Leave</span>
|
||||
</Button>
|
||||
{#if showChatButton}
|
||||
<Button
|
||||
aria-label="Toggle chat panel"
|
||||
aria-pressed={isChatPanelActive}
|
||||
class={cx(
|
||||
"button w-full flex-col !h-auto gap-0.5 rounded-xl px-2 py-1.5 relative",
|
||||
isChatPanelActive ? "button-primary" : "button-neutral",
|
||||
)}
|
||||
onclick={onChatToggle}>
|
||||
<Icon icon={ChatRound} size={4} />
|
||||
<span class="text-[10px] leading-none font-semibold">Chat</span>
|
||||
{#if chatUnread}
|
||||
<span
|
||||
transition:fade={{duration: 150}}
|
||||
class="absolute right-2 top-1.5 h-2 w-2 rounded-full bg-primary"
|
||||
aria-hidden="true"></span>
|
||||
{/if}
|
||||
</Button>
|
||||
{/if}
|
||||
{:else}
|
||||
<Button
|
||||
data-tip="Join Voice"
|
||||
class="button button-primary button-sm button-square tip tip-top"
|
||||
aria-label="Join voice room"
|
||||
class="button button-primary col-span-3 flex-col !h-auto gap-0.5 rounded-xl px-2 py-1.5"
|
||||
onclick={openJoinDialog}>
|
||||
<Icon icon={PhoneCallingRounded} size={4.5} />
|
||||
<span class="text-[10px] leading-none font-semibold">Join</span>
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
|
|
|
|||
Loading…
Reference in a new issue