diff --git a/src/app/call.ts b/src/app/call.ts index e81c442e..1a2d6ad8 100644 --- a/src/app/call.ts +++ b/src/app/call.ts @@ -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.Split) export const videoPrimaryTileKey = writable(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, diff --git a/src/app/components/MicLevelMeter.svelte b/src/app/components/MicLevelMeter.svelte new file mode 100644 index 00000000..5f1e77dc --- /dev/null +++ b/src/app/components/MicLevelMeter.svelte @@ -0,0 +1,91 @@ + + +
+ + {#if error} + + {:else if !track} +

{offMessage}

+ {:else} +
+ {#each Array(5) as _, i (i)} +
i ? "bg-success" : "bg-surface-more", + )}> +
+ {/each} +
+ Speak to test + {/if} +
diff --git a/src/app/components/RoomChat.svelte b/src/app/components/RoomChat.svelte index c0608f12..5aaabfcf 100644 --- a/src/app/components/RoomChat.svelte +++ b/src/app/components/RoomChat.svelte @@ -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 @@ }) -
+
{#if h && voiceConnectedHere}
+ + {#if videoCallChatHidden} + + {/if}
{#if showScrollButton} diff --git a/src/app/components/VideoCallContent.svelte b/src/app/components/VideoCallContent.svelte index c5db244b..310d2d0b 100644 --- a/src/app/components/VideoCallContent.svelte +++ b/src/app/components/VideoCallContent.svelte @@ -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)}
- {labelFor(tile.liveKitIdentity, tile.source)}{tile.isLocal ? " (you)" : ""} + {label}{tile.isLocal ? " (you)" : ""} {#if videoTiles.length > 1} {@const pinned = $videoPrimaryTileKey === tileKey(tile)}
{#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)}
diff --git a/src/app/components/VoiceRoomJoinDialog.svelte b/src/app/components/VoiceRoomJoinDialog.svelte index 8b0cafe4..fe78d20b 100644 --- a/src/app/components/VoiceRoomJoinDialog.svelte +++ b/src/app/components/VoiceRoomJoinDialog.svelte @@ -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([]) 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(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 @@ {/snippet} + {#if !startWithoutMic} + + {/if}
diff --git a/src/app/components/VoiceWidget.svelte b/src/app/components/VoiceWidget.svelte index 88072da8..dfc81dfb 100644 --- a/src/app/components/VoiceWidget.svelte +++ b/src/app/components/VoiceWidget.svelte @@ -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"> -
- - {#if showChatButton} - - {/if} -
-
+ +
{#if $callState === CallState.Joining} - {:else if $callState === CallState.Connected && $currentCallSession} {#if !Capacitor.isNativePlatform()} {/if} + {#if showChatButton} + + {/if} {:else} {/if}