Compare commits

..

7 Commits

Author SHA1 Message Date
mplorentz ea4e1cde31 Show unread indicator on chat icon in VoiceWidget 2026-04-03 10:51:28 -04:00
mplorentz 4f2e494959 Allow clicking voice widget to go back to call 2026-04-03 10:41:05 -04:00
mplorentz fef449be85 rework video + text chat display controls 2026-04-03 10:36:14 -04:00
mplorentz 945e853e3b Style pin icon more better 2026-04-03 10:01:13 -04:00
mplorentz bad96500d5 Style voice widget icons to be less red 2026-04-03 09:58:00 -04:00
mplorentz 148286dc04 Add video settings to VoiceCallAudioSettingsDialog 2026-04-03 09:23:53 -04:00
mplorentz 3decff3cfc Fix merge artifacts 2026-04-03 09:19:32 -04:00
7 changed files with 282 additions and 160 deletions
+1
View File
@@ -50,6 +50,7 @@
--saib: var(--safe-area-inset-bottom, env(safe-area-inset-bottom));
--sail: var(--safe-area-inset-left, env(safe-area-inset-left));
--sair: var(--safe-area-inset-right, env(safe-area-inset-right));
--video-call-panel-bg: #181e24;
}
[data-theme] {
+61 -51
View File
@@ -7,10 +7,10 @@
import Icon from "@lib/components/Icon.svelte"
import ProfileCircle from "@app/components/ProfileCircle.svelte"
import VideoCallVideo from "@app/components/VideoCallVideo.svelte"
import VoiceWidget from "@app/components/VoiceWidget.svelte"
import {
currentVoiceSession,
currentVoiceRoom,
videoCallContentActive,
videoCallLayoutRevision,
videoPrimaryTileKey,
toggleVideoPrimaryTile,
@@ -41,13 +41,7 @@
const roomMatches = $derived($currentVoiceRoom?.url === url && $currentVoiceRoom?.h === h)
const allowEmptyPanel = $derived(variant === "desktop-split" || variant === "desktop-full")
const showPanel = $derived(
visible &&
roomMatches &&
(variant === "mobile" ? $videoCallContentActive : $videoCallContentActive || allowEmptyPanel),
)
const showPanel = $derived(visible && roomMatches)
const tiles = $derived.by(() => {
// eslint-disable-next-line @typescript-eslint/no-unused-expressions -- re-run when remote video subscribes
@@ -158,11 +152,11 @@
const panelChrome = $derived(
cx(
variant === "mobile" &&
"cb ct cw z-compose bg-base-300/95 fixed inset-x-0 flex min-h-0 flex-col gap-2 overflow-hidden p-2 md:hidden",
"cb top-[calc(var(--sait)+6rem)] cw z-compose bg-[var(--video-call-panel-bg)] fixed inset-x-0 flex min-h-0 flex-col gap-2 overflow-y-auto overflow-x-hidden px-2 pb-2 pt-1 md:hidden",
variant === "desktop-split" &&
"cb ct cw-split-video z-compose bg-base-300/95 fixed hidden min-h-0 flex-col gap-2 overflow-hidden p-2 md:flex",
"cb ct cw-split-video z-compose bg-[var(--video-call-panel-bg)] fixed hidden min-h-0 flex-col gap-2 overflow-hidden p-2 md:flex",
variant === "desktop-full" &&
"cb ct cw z-compose bg-base-300/95 fixed hidden min-h-0 flex-col gap-2 overflow-hidden p-2 md:flex",
"cb ct cw z-compose bg-[var(--video-call-panel-bg)] fixed hidden min-h-0 flex-col gap-2 overflow-hidden p-2 md:flex",
className,
),
)
@@ -176,8 +170,6 @@
layout === "default" && "aspect-video w-full min-h-0",
layout === "strip" && "aspect-video w-44 shrink-0",
tile.source === Track.Source.ScreenShare ? "bg-black" : "bg-base-100",
$videoPrimaryTileKey === tileKey(tile) &&
"ring-2 ring-primary ring-offset-2 ring-offset-base-300",
)}>
{#if tile.attachable}
<VideoCallVideo
@@ -195,9 +187,14 @@
{labelFor(tile.identity, tile.source)}{tile.isLocal ? " (you)" : ""}
</span>
{#if tiles.length > 1}
{@const pinned = $videoPrimaryTileKey === tileKey(tile)}
<Button
data-tip={$videoPrimaryTileKey === tileKey(tile) ? "Exit spotlight" : "Spotlight"}
class="absolute right-1 top-1 z-20 btn btn-xs btn-circle btn-ghost bg-base-100/70"
data-tip={pinned ? "Exit spotlight" : "Spotlight"}
aria-pressed={pinned}
class={cx(
"absolute right-1 top-1 z-20 btn btn-xs btn-square btn-ghost",
pinned ? "btn-active bg-primary/25 text-primary" : "bg-base-100/70",
)}
onclick={spotlightHandlerFor(tileKey(tile))}>
<Icon icon={Pin} size={3} />
</Button>
@@ -205,43 +202,56 @@
</div>
{/snippet}
{#if showPanel && (showTileGrid || allowEmptyPanel)}
<div class={panelChrome}>
{#if showTileGrid}
{#if useSpotlightLayout && primaryTile}
<div class="flex min-h-0 flex-1 flex-col gap-2 overflow-hidden">
{@render videoTile(primaryTile, "spotlight")}
{#if secondaryTiles.length > 0}
<div
class="flex max-h-40 shrink-0 flex-row gap-2 overflow-x-auto overflow-y-hidden py-0.5">
{#each secondaryTiles as tile (tileKey(tile))}
{@render videoTile(tile, "strip")}
{/each}
</div>
{/if}
</div>
{:else if useMultiGrid}
<div
class="grid min-h-0 flex-1 grid-cols-1 content-start gap-2 overflow-y-auto sm:grid-cols-2">
{#each tiles as tile (tileKey(tile))}
{@render videoTile(tile, "default")}
{/each}
</div>
{:else}
<div class="flex min-h-0 flex-1 flex-col gap-2 overflow-y-auto">
{#each tiles as tile (tileKey(tile))}
{@render videoTile(tile, "default")}
{/each}
</div>
{/if}
{:else}
<div
class="flex min-h-[12rem] flex-1 flex-col items-center justify-center gap-2 rounded-box bg-base-100/50 p-4 text-center text-sm opacity-80">
<p>No camera or screen share yet.</p>
<p class="text-xs">
Use the camera or screen share control in the voice widget to share video.
</p>
{#snippet videoPanelBody()}
{#if showTileGrid}
{#if useSpotlightLayout && primaryTile}
<div class="flex min-h-0 flex-1 flex-col gap-2 overflow-hidden">
{@render videoTile(primaryTile, "spotlight")}
{#if secondaryTiles.length > 0}
<div
class="flex max-h-40 shrink-0 flex-row gap-2 overflow-x-auto overflow-y-hidden py-0.5">
{#each secondaryTiles as tile (tileKey(tile))}
{@render videoTile(tile, "strip")}
{/each}
</div>
{/if}
</div>
{:else if useMultiGrid}
<div
class="grid min-h-0 flex-1 grid-cols-1 content-start gap-2 overflow-y-auto sm:grid-cols-2">
{#each tiles as tile (tileKey(tile))}
{@render videoTile(tile, "default")}
{/each}
</div>
{:else}
<div class="flex min-h-0 flex-1 flex-col gap-2 overflow-y-auto">
{#each tiles as tile (tileKey(tile))}
{@render videoTile(tile, "default")}
{/each}
</div>
{/if}
{:else}
<div
class="flex min-h-[12rem] flex-1 flex-col items-center justify-center gap-2 rounded-box bg-base-200/50 p-4 text-center text-sm opacity-80">
<p>No camera or screen share yet.</p>
<p class="text-xs">Use the camera or screen share control to share video.</p>
</div>
{/if}
{/snippet}
{#if showPanel}
<div class={panelChrome}>
{#if variant === "mobile"}
<div class="flex min-h-0 flex-1 flex-col gap-2">
<div class="min-h-0 flex-1 overflow-hidden">
{@render videoPanelBody()}
</div>
<div class="shrink-0">
<VoiceWidget />
</div>
</div>
{:else}
{@render videoPanelBody()}
{/if}
</div>
{/if}
@@ -5,10 +5,7 @@
import ModalBody from "@lib/components/ModalBody.svelte"
import ModalFooter from "@lib/components/ModalFooter.svelte"
import ModalHeader from "@lib/components/ModalHeader.svelte"
<<<<<<< HEAD
import ModalSubtitle from "@lib/components/ModalSubtitle.svelte"
=======
>>>>>>> 462086da (Add settings button to configure audio devices in call)
import ModalTitle from "@lib/components/ModalTitle.svelte"
import {
currentVoiceSession,
@@ -29,8 +26,10 @@
let audioInputs = $state<MediaDeviceInfo[]>([])
let audioOutputs = $state<MediaDeviceInfo[]>([])
let videoInputs = $state<MediaDeviceInfo[]>([])
let selectedInput = $state("")
let selectedOutput = $state("")
let selectedVideo = $state("")
const loadDevices = async () => {
if (!navigator.mediaDevices?.enumerateDevices) return
@@ -38,16 +37,25 @@
const devices = await navigator.mediaDevices.enumerateDevices()
audioInputs = devices.filter(d => d.kind === "audioinput")
audioOutputs = devices.filter(d => d.kind === "audiooutput")
videoInputs = devices.filter(d => d.kind === "videoinput")
} catch {
audioInputs = []
audioOutputs = []
videoInputs = []
}
}
$effect(() => {
loadDevices()
navigator.mediaDevices?.addEventListener?.("devicechange", loadDevices)
return () => navigator.mediaDevices?.removeEventListener?.("devicechange", loadDevices)
void loadDevices()
const md = navigator.mediaDevices
if (!md?.addEventListener) return
const onDeviceChange = () => {
void loadDevices()
}
md.addEventListener("devicechange", onDeviceChange)
return () => {
md.removeEventListener("devicechange", onDeviceChange)
}
})
$effect(() => {
@@ -58,6 +66,7 @@
}
selectedInput = selectValueForActiveDevice(session, DeviceKind.AudioInput)
selectedOutput = selectValueForActiveDevice(session, DeviceKind.AudioOutput)
selectedVideo = selectValueForActiveDevice(session, DeviceKind.VideoInput)
})
const onInputChange = () => {
@@ -68,6 +77,10 @@
void switchVoiceActiveDevice(DeviceKind.AudioOutput, selectedOutput)
}
const onVideoChange = () => {
void switchVoiceActiveDevice(DeviceKind.VideoInput, selectedVideo)
}
const onDone = () => {
popModal()
}
@@ -79,8 +92,8 @@
<Modal>
<ModalBody>
<ModalHeader>
<ModalTitle>Audio settings</ModalTitle>
<ModalSubtitle>Choose microphone and speaker for this call.</ModalSubtitle>
<ModalTitle>Call settings</ModalTitle>
<ModalSubtitle>Microphone, speaker, and camera for this call.</ModalSubtitle>
</ModalHeader>
<div class="flex flex-col gap-4 pt-2">
<FieldInline>
@@ -123,6 +136,25 @@
{/snippet}
</FieldInline>
{/if}
<FieldInline>
{#snippet label()}
<p>Camera</p>
{/snippet}
{#snippet input()}
<select
class="select select-bordered w-full"
bind:value={selectedVideo}
onchange={onVideoChange}
aria-label="Camera">
<option value="">Default camera</option>
{#each videoInputs as d (d.deviceId)}
<option value={d.deviceId}>
{d.label || `Camera ${d.deviceId.slice(0, 8)}`}
</option>
{/each}
</select>
{/snippet}
</FieldInline>
</div>
</ModalBody>
<ModalFooter>
+127 -31
View File
@@ -1,16 +1,18 @@
<script lang="ts">
import {readable} from "svelte/store"
import {fly} from "svelte/transition"
import {fade, fly} from "svelte/transition"
import {browser} from "$app/environment"
import {goto} from "$app/navigation"
import {page} from "$app/stores"
import cx from "classnames"
import {displayRelayUrl} from "@welshman/util"
import Microphone from "@assets/icons/microphone.svg?dataurl"
import MicrophoneOff from "@assets/icons/microphone-off.svg?dataurl"
import Videocamera from "@assets/icons/videocamera.svg?dataurl"
import VideocameraRecord from "@assets/icons/videocamera-record.svg?dataurl"
import Monitor from "@assets/icons/monitor.svg?dataurl"
import PhoneRounded from "@assets/icons/phone-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"
@@ -26,12 +28,16 @@
type Room,
} from "@app/core/state"
import {pushModal} from "@app/util/modal"
import {notifications} from "@app/util/notifications"
import {makeRoomPath} from "@app/util/routes"
import {
VoiceState,
currentVoiceSession,
currentVoiceRoom,
voiceState,
voiceMobileRoomPanel,
voiceDesktopRoomPanel,
isLocalSpeaking,
leaveVoiceRoom,
toggleMute,
toggleCamera,
@@ -71,29 +77,121 @@
pushModal(VoiceRoomJoinDialog, {url: targetRoom.url, h: targetRoom.h})
}
const openAudioSettings = () => {
const goToRoom = () => {
if (!targetRoom) return
const path = makeRoomPath(targetRoom.url, targetRoom.h)
if ($page.url.pathname !== path) {
void goto(path)
}
}
const openCallSettings = () => {
pushModal(VoiceCallAudioSettingsDialog)
}
let isMd = $state(
typeof window !== "undefined" && window.matchMedia("(min-width: 768px)").matches,
)
$effect(() => {
if (!browser) return
const mq = window.matchMedia("(min-width: 768px)")
const sync = () => {
isMd = mq.matches
}
sync()
mq.addEventListener("change", sync)
return () => mq.removeEventListener("change", sync)
})
const showVoiceLayoutToggle = $derived(
$voiceState === VoiceState.Connected &&
targetRoom !== undefined &&
getRoomType(targetRoom) === RoomType.Voice &&
typeof h === "string" &&
relay !== undefined &&
decodeRelay(relay) === targetRoom.url &&
h === targetRoom.h,
)
const layoutToggleActive = $derived(
showVoiceLayoutToggle &&
((!isMd && $voiceMobileRoomPanel === "chat") || (isMd && $voiceDesktopRoomPanel === "split")),
)
const onLayoutToggle = () => {
if (!showVoiceLayoutToggle) return
if (isMd) {
voiceDesktopRoomPanel.update(p => (p === "split" ? "chat" : "split"))
} else {
voiceMobileRoomPanel.update(p => (p === "chat" ? "video" : "chat"))
}
}
const chatUnread = $derived(
targetRoom !== undefined && $notifications.has(makeRoomPath(targetRoom.url, targetRoom.h)),
)
const mediaToggleClass = "center tooltip tooltip-top btn btn-sm btn-square btn-ghost"
</script>
{#snippet mutedSlash(show: boolean)}
{#if show}
<span
class="pointer-events-none absolute inset-0 flex items-center justify-center overflow-visible"
aria-hidden="true">
<span class="h-[1.3px] w-[150%] max-w-none shrink-0 -rotate-45 rounded-full bg-current"
></span>
</span>
{/if}
{/snippet}
{#if targetRoom}
<div
in:fly={{y: 60, duration: 350}}
out:fly={{y: 60, duration: 250}}
class="flex flex-col gap-2 rounded-box bg-base-100 p-3">
<div class="flex flex-col gap-0.5">
{#if $voiceState === VoiceState.Joining}
<span class="text-sm font-semibold text-warning">Joining...</span>
{:else if $voiceState === VoiceState.Connected}
<span class="text-sm font-semibold text-success">Voice Connected</span>
{:else}
<span class="text-sm font-semibold text-neutral-content">Disconnected</span>
<div class="flex items-start justify-between gap-2">
<button
type="button"
class="min-w-0 flex-1 rounded-lg px-1 py-0.5 text-left outline-none hover:bg-base-200/60 focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2 focus-visible:ring-offset-base-100"
onclick={goToRoom}
aria-label="Open room {roomName}">
<div class="flex flex-col gap-0.5">
{#if $voiceState === VoiceState.Joining}
<span class="text-sm font-semibold text-warning">Joining...</span>
{:else if $voiceState === VoiceState.Connected}
<span class="text-sm font-semibold text-success">Voice Connected</span>
{:else}
<span class="text-sm font-semibold text-neutral-content">Disconnected</span>
{/if}
<span class="ellipsize text-xs opacity-70">
{roomName} / {spaceName}
</span>
</div>
</button>
{#if showVoiceLayoutToggle}
<Button
data-tip="Toggle Chat"
class={cx(
mediaToggleClass,
"relative shrink-0 overflow-visible",
layoutToggleActive && "text-primary",
)}
onclick={onLayoutToggle}>
<span class="relative inline-flex">
<Icon icon={ChatRound} size={4} />
{#if chatUnread}
<span
transition:fade={{duration: 150}}
class="absolute -right-0.5 -top-0.5 h-2 w-2 rounded-full bg-primary ring-2 ring-base-100"
aria-hidden="true"></span>
{/if}
</span>
</Button>
{/if}
<span class="ellipsize text-xs opacity-70">
{roomName} / {spaceName}
</span>
</div>
<div class="flex items-center gap-1">
<div class="flex flex-wrap items-center gap-2">
{#if $voiceState === VoiceState.Joining}
<span class="loading loading-spinner loading-sm"></span>
<Button
@@ -105,37 +203,35 @@
{:else if $voiceState === VoiceState.Connected && $currentVoiceSession}
<Button
data-tip={$currentVoiceSession.muted ? "Unmute" : "Mute"}
class="center tooltip tooltip-top btn btn-sm btn-square {$currentVoiceSession.muted
? 'btn-error'
: 'btn-ghost'}"
class={cx(
mediaToggleClass,
"overflow-visible",
!$currentVoiceSession.muted && $isLocalSpeaking && "text-primary",
$currentVoiceSession.muted &&
"text-error ring-1 ring-error/50 ring-offset-0 ring-offset-base-100",
)}
onclick={toggleMute}>
<Icon icon={$currentVoiceSession.muted ? MicrophoneOff : Microphone} size={4} />
<span class="relative inline-flex items-center justify-center overflow-visible">
<Icon icon={Microphone} size={4} />
{@render mutedSlash($currentVoiceSession.muted)}
</span>
</Button>
<Button
data-tip={$currentVoiceSession.cameraOn ? "Turn off camera" : "Turn on camera"}
class="center tooltip tooltip-top btn btn-sm btn-square {$currentVoiceSession.cameraOn
? 'btn-ghost'
: 'btn-error'}"
class={cx(mediaToggleClass, $currentVoiceSession.cameraOn && "text-primary")}
onclick={toggleCamera}>
<Icon icon={$currentVoiceSession.cameraOn ? VideocameraRecord : Videocamera} size={4} />
</Button>
<Button>
data-tip="Audio settings" class="center tooltip tooltip-top btn btn-sm btn-square
btn-ghost" onclick={openAudioSettings}>
<Icon icon={Settings} size={4} />
</Button>
<Button
data-tip={$currentVoiceSession.screenShareOn ? "Stop sharing" : "Share screen"}
class="center tooltip tooltip-top btn btn-sm btn-square {$currentVoiceSession.screenShareOn
? 'btn-ghost'
: 'btn-error'}"
class={cx(mediaToggleClass, $currentVoiceSession.screenShareOn && "text-primary")}
onclick={toggleScreenShare}>
<Icon icon={Monitor} size={4} />
</Button>
<Button
data-tip="Audio settings"
data-tip="Call settings"
class="center tooltip tooltip-top btn btn-sm btn-square btn-ghost"
onclick={openAudioSettings}>
onclick={openCallSettings}>
<Icon icon={Settings} size={4} />
</Button>
<Button
+27 -20
View File
@@ -54,6 +54,7 @@ const LIVEKIT_DEFAULT_DEVICE_ID = "default"
export enum DeviceKind {
AudioInput = "audioinput",
AudioOutput = "audiooutput",
VideoInput = "videoinput",
}
export const switchVoiceActiveDevice = async (
@@ -74,6 +75,9 @@ export const switchVoiceActiveDevice = async (
case DeviceKind.AudioOutput:
label = "speaker"
break
case DeviceKind.VideoInput:
label = "camera"
break
}
pushToast({theme: "error", message: `Error changing ${label}`})
}
@@ -83,6 +87,17 @@ export const voiceState = writable<VoiceState>(VoiceState.Disconnected)
export const currentVoiceRoom = writable<Room | undefined>(undefined)
/** Mobile room UI: full-screen chat vs video (see VoiceWidget layout toggle). */
export const voiceMobileRoomPanel = writable<"chat" | "video">("chat")
/** Desktop room UI: messages only, video only, or split (see VoiceWidget layout toggle). */
export const voiceDesktopRoomPanel = writable<"chat" | "video" | "split">("split")
const resetVoiceRoomPanels = () => {
voiceMobileRoomPanel.set("chat")
voiceDesktopRoomPanel.set("chat")
}
export const participantPubkeyMap = writable<Map<string, Pubkey>>(new Map())
/** Bumps when remote video is subscribed/unsubscribed so layout/video UI can react. */
@@ -131,6 +146,16 @@ export const isParticipantSpeaking = derived(
$participants.some(sp => participantKey(sp) === participantKey(p)),
)
/** True when the local user is in LiveKits active-speakers list (currently talking). */
export const isLocalSpeaking = derived(
[currentVoiceSession, speakingParticipants],
([$session, $speaking]) => {
if (!$session?.room) return false
const local = participantFromLiveKitIdentity($session.room.localParticipant.identity)
return $speaking.some(sp => participantKey(sp) === participantKey(local))
},
)
const fetchLivekitToken = async (
url: string,
groupId: string,
@@ -215,6 +240,7 @@ const onRoomDisconnected = (reason?: DisconnectReason) => {
videoCallLayoutRevision.set(0)
videoPrimaryTileKey.set(undefined)
currentVoiceSession.set(undefined)
resetVoiceRoomPanels()
if (reason !== undefined && reason !== DisconnectReason.CLIENT_INITIATED) {
voiceState.set(VoiceState.Disconnected)
const message =
@@ -382,6 +408,7 @@ export const leaveVoiceRoom = async () => {
videoCallLayoutRevision.set(0)
videoPrimaryTileKey.set(undefined)
currentVoiceSession.set(undefined)
resetVoiceRoomPanels()
session.room.disconnect()
speakingParticipants.set([])
participantPubkeyMap.set(new Map())
@@ -415,26 +442,6 @@ export const toggleMute = async () => {
const VISUAL_SOURCES = [Track.Source.Camera, Track.Source.ScreenShare] as const
const roomHasSubscribedRemoteVisual = (room: LiveKitRoom): boolean => {
for (const p of room.remoteParticipants.values()) {
for (const source of VISUAL_SOURCES) {
const pub = p.getTrackPublication(source)
if (pub?.isSubscribed && pub.track) return true
}
}
return false
}
/** True when local camera/screen share is on or any subscribed remote camera/screen track. */
export const videoCallContentActive = derived(
[currentVoiceSession, voiceState, videoCallLayoutRevision],
([$session, $state, _rev]) => {
if ($state !== VoiceState.Connected || !$session) return false
if ($session.cameraOn || $session.screenShareOn) return true
return roomHasSubscribedRemoteVisual($session.room)
},
)
const countLiveVisualFeeds = (session: VoiceSession): number => {
const room = session.room
let n = 0
+1
View File
@@ -14,6 +14,7 @@
style?: string
disabled?: boolean
"data-tip"?: string
"aria-pressed"?: boolean
} = $props()
const className = $derived(`text-left ${restProps.class}`)
+25 -50
View File
@@ -52,7 +52,14 @@
} from "@app/core/state"
import VoiceWidget from "@app/components/VoiceWidget.svelte"
import VideoCallContent from "@app/components/VideoCallContent.svelte"
import {VoiceState, currentVoiceRoom, videoTileCount, voiceState} from "@app/voice"
import {
VoiceState,
currentVoiceRoom,
videoTileCount,
voiceMobileRoomPanel,
voiceDesktopRoomPanel,
voiceState,
} from "@app/voice"
import {makeFeed} from "@app/core/requests"
import {popKey} from "@lib/implicit"
import {checked} from "@app/util/notifications"
@@ -73,27 +80,24 @@
$currentVoiceRoom?.h === h,
)
let mobileRoomPanel = $state<"chat" | "video">("chat")
let voiceDesktopPanel = $state<"chat" | "video" | "split">("split")
const showMobileVideoPanel = $derived(
isVoiceRoom && $voiceState === VoiceState.Connected && mobileRoomPanel === "video",
isVoiceRoom && $voiceState === VoiceState.Connected && $voiceMobileRoomPanel === "video",
)
const pageContentFrame = $derived<"default" | "split-right">(
voiceConnectedHere && voiceDesktopPanel === "split" ? "split-right" : "default",
voiceConnectedHere && $voiceDesktopRoomPanel === "split" ? "split-right" : "default",
)
const pageContentHiddenDesktopVideoOnly = $derived(
voiceConnectedHere && voiceDesktopPanel === "video",
voiceConnectedHere && $voiceDesktopRoomPanel === "video",
)
let prevVideoTileCount = $state(0)
$effect(() => {
if ($voiceState !== VoiceState.Connected) {
mobileRoomPanel = "chat"
voiceDesktopPanel = "chat"
voiceMobileRoomPanel.set("chat")
voiceDesktopRoomPanel.set("chat")
prevVideoTileCount = 0
return
}
@@ -107,8 +111,8 @@
}
if (prevVideoTileCount === 0 && n >= 1) {
voiceDesktopPanel = "video"
mobileRoomPanel = "video"
voiceDesktopRoomPanel.set("video")
voiceMobileRoomPanel.set("video")
}
prevVideoTileCount = n
})
@@ -418,40 +422,6 @@
<RoomName {url} {h} />
{/snippet}
{#snippet action()}
{#if voiceConnectedHere}
<div class="flex gap-1 md:hidden">
<Button
class={cx("btn btn-sm", mobileRoomPanel === "chat" && "btn-primary")}
onclick={() => (mobileRoomPanel = "chat")}>
Chat
</Button>
<Button
class={cx("btn btn-sm", mobileRoomPanel === "video" && "btn-primary")}
onclick={() => (mobileRoomPanel = "video")}>
Video
</Button>
</div>
<div class="hidden flex-wrap gap-1 md:flex">
<Button
data-tip="Messages only"
class={cx("btn btn-sm", voiceDesktopPanel === "chat" && "btn-primary")}
onclick={() => (voiceDesktopPanel = "chat")}>
Chat
</Button>
<Button
data-tip="Video only"
class={cx("btn btn-sm", voiceDesktopPanel === "video" && "btn-primary")}
onclick={() => (voiceDesktopPanel = "video")}>
Video
</Button>
<Button
data-tip="Video and chat side by side"
class={cx("btn btn-sm", voiceDesktopPanel === "split" && "btn-primary")}
onclick={() => (voiceDesktopPanel = "split")}>
Video + Chat
</Button>
</div>
{/if}
<SpaceSearch {url} {h} />
<Button class="btn btn-neutral btn-sm btn-square" onclick={showRoomDetail}>
<Icon size={4} icon={InfoCircle} />
@@ -539,18 +509,22 @@
</PageContent>
{#if voiceConnectedHere}
<VideoCallContent variant="desktop-split" {url} {h} visible={voiceDesktopPanel === "split"} />
<VideoCallContent variant="desktop-full" {url} {h} visible={voiceDesktopPanel === "video"} />
<VideoCallContent
variant="desktop-split"
{url}
{h}
visible={$voiceDesktopRoomPanel === "split"} />
<VideoCallContent variant="desktop-full" {url} {h} visible={$voiceDesktopRoomPanel === "video"} />
{/if}
{#if isVoiceRoom && $voiceState === VoiceState.Connected}
<VideoCallContent variant="mobile" {url} {h} visible={mobileRoomPanel === "video"} />
<VideoCallContent variant="mobile" {url} {h} visible={$voiceMobileRoomPanel === "video"} />
{/if}
<div
class={cx(
"chat__compose-zone flex flex-col gap-1 bg-base-200 md:flex-row md:gap-0",
voiceConnectedHere && voiceDesktopPanel === "split" && "cw-split-chat",
voiceConnectedHere && $voiceDesktopRoomPanel === "split" && "cw-split-chat",
pageContentHiddenDesktopVideoOnly && "md:hidden",
showMobileVideoPanel && "max-md:hidden",
)}
@@ -602,7 +576,8 @@
{/if}
</div>
{#if isVoiceRoom || $voiceState === VoiceState.Joining || $voiceState === VoiceState.Connected}
<div class="hide-on-keyboard flex-shrink-0 p-2 md:hidden">
<div
class={cx("hide-on-keyboard flex-shrink-0 p-2 md:hidden", showMobileVideoPanel && "hidden")}>
<VoiceWidget />
</div>
{/if}