Video in calls #135

Merged
hodlbod merged 20 commits from feature/134-video-in-calls into dev 2026-04-08 17:10:21 +00:00
8 changed files with 411 additions and 15 deletions
Showing only changes of commit 9f6b16089b - Show all commits
+2 -2
View File
@@ -400,7 +400,7 @@ progress[value]::-webkit-progress-value {
transition: width 0.5s;
}
/* content width for fixed elements */
/* Anchors for fixed overlays (compose, search, reply) — main scroll lives in Page / PageContent flow */
.left-content {
@apply md:left-[calc(18.5rem+var(--sail))];
@@ -415,7 +415,7 @@ body.keyboard-open .hide-on-keyboard {
/* chat view */
.chat__compose {
@apply z-compose relative mb-14 grow md:mb-0;
@apply relative z-compose mb-14 shrink-0 md:mb-0;
}
.chat__compose .chat__compose-inner {
+131
View File
@@ -0,0 +1,131 @@
<script lang="ts">
import cx from "classnames"
import {Track} from "livekit-client"
import {displayProfileByPubkey, loadProfile} from "@welshman/app"
import ProfileCircle from "@app/components/ProfileCircle.svelte"
import VideoCallVideo from "@app/components/VideoCallVideo.svelte"
import {
currentVoiceSession,
currentVoiceRoom,
videoCallContentActive,
videoCallLayoutRevision,
pubkeyFromLiveKitIdentity,
} from "@app/voice"
type Variant = "mobile" | "desktop-split" | "desktop-full"
type Props = {
variant: Variant
url: string
h: string
visible?: boolean
class?: string
}
type Tile = {
identity: string
isLocal: boolean
trackSid: string
attachable: Track | undefined
}
const {variant, url, h, visible = true, class: className = ""}: Props = $props()
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 tiles = $derived.by(() => {
// eslint-disable-next-line @typescript-eslint/no-unused-expressions -- re-run when remote video subscribes
$videoCallLayoutRevision
const session = $currentVoiceSession
if (!session || $currentVoiceRoom?.url !== url || $currentVoiceRoom?.h !== h) {
return []
}
const room = session.room
const out: Tile[] = []
const lp = room.localParticipant
if (session.cameraOn) {
const localPub = lp.getTrackPublication(Track.Source.Camera)
out.push({
identity: lp.identity,
isLocal: true,
trackSid: localPub?.trackSid ?? "local-camera",
attachable: localPub?.track,
})
}
for (const rp of room.remoteParticipants.values()) {
const pub = rp.getTrackPublication(Track.Source.Camera)
if (pub?.isSubscribed && pub.track) {
out.push({
identity: rp.identity,
isLocal: false,
trackSid: pub.trackSid,
attachable: pub.track,
})
}
}
return out
})
$effect(() => {
for (const t of tiles) {
const pk = pubkeyFromLiveKitIdentity(t.identity)
if (pk) loadProfile(pk)
}
})
const labelFor = (identity: string) => {
const pk = pubkeyFromLiveKitIdentity(identity)
return pk ? displayProfileByPubkey(pk) : "Unknown"
}
const showTileGrid = $derived(tiles.length > 0)
</script>
{#if showPanel && (showTileGrid || allowEmptyPanel)}
<div
class={cx(
variant === "mobile" &&
"cb ct cw z-compose bg-base-300/95 fixed inset-x-0 flex flex-col gap-2 overflow-y-auto p-2 md:hidden",
variant === "desktop-split" &&
"cb ct cw-split-video z-compose bg-base-300/95 fixed hidden flex-col gap-2 overflow-y-auto p-2 md:flex",
variant === "desktop-full" &&
"cb ct cw z-compose bg-base-300/95 fixed hidden flex-col gap-2 overflow-y-auto p-2 md:flex",
className,
)}>
{#if showTileGrid}
{#each tiles as tile (tile.trackSid + tile.identity)}
<div class="relative aspect-video overflow-hidden rounded-box bg-base-100 shadow-sm">
{#if tile.attachable}
<VideoCallVideo track={tile.attachable} muted={tile.isLocal} class="absolute inset-0" />
{:else}
<div class="absolute inset-0 flex items-center justify-center">
<ProfileCircle pubkey={pubkeyFromLiveKitIdentity(tile.identity)} {url} size={14} />
</div>
{/if}
<span
class="absolute bottom-1 left-1 max-w-[calc(100%-0.5rem)] truncate rounded bg-base-100/80 px-1.5 py-0.5 text-xs">
{labelFor(tile.identity)}{tile.isLocal ? " (you)" : ""}
</span>
</div>
{/each}
{: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 video yet.</p>
<p class="text-xs">Use the camera control in the voice widget to share video.</p>
</div>
{/if}
</div>
{/if}
+27
View File
@@ -0,0 +1,27 @@
<script lang="ts">
import type {Track} from "livekit-client"
import cx from "classnames"
type Props = {
track: Track
muted?: boolean
class?: string
}
const {track, muted = true, class: className = ""}: Props = $props()
let el = $state<HTMLVideoElement | undefined>()
$effect(() => {
const v = el
const t = track
if (!v) return
t.attach(v)
return () => {
t.detach(v)
}
})
</script>
<video bind:this={el} class={cx("h-full w-full object-cover", className)} playsinline {muted}
></video>
+13 -3
View File
@@ -6,6 +6,8 @@
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 PhoneRounded from "@assets/icons/phone-rounded.svg?dataurl"
import PhoneCallingRounded from "@assets/icons/phone-calling-rounded.svg?dataurl"
import CloseCircle from "@assets/icons/close-circle.svg?dataurl"
@@ -31,6 +33,7 @@
voiceState,
leaveVoiceRoom,
toggleMute,
toggleCamera,
cancelJoinVoiceRoom,
} from "@app/voice"
@@ -107,9 +110,16 @@
<Icon icon={$currentVoiceSession.muted ? MicrophoneOff : Microphone} size={4} />
</Button>
<Button
data-tip="Audio settings"
class="center tooltip tooltip-top btn btn-sm btn-square btn-ghost"
onclick={openAudioSettings}>
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'}"
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
+85 -1
View File
@@ -32,6 +32,7 @@ export type VoiceSession = {
h: string
room: LiveKitRoom
muted: boolean
cameraOn: boolean
}
export type Pubkey = string
@@ -82,6 +83,11 @@ export const currentVoiceRoom = writable<Room | undefined>(undefined)
export const participantPubkeyMap = writable<Map<string, Pubkey>>(new Map())
/** Bumps when remote video is subscribed/unsubscribed so layout/video UI can react. */
export const videoCallLayoutRevision = writable(0)
const bumpVideoCallLayoutRevision = () => videoCallLayoutRevision.update(n => n + 1)
const addParticipant = (identity: string) => {
participantPubkeyMap.update(m => {
const next = new Map(m)
@@ -197,6 +203,7 @@ const setUpMicrophone = async (
}
const onRoomDisconnected = (reason?: DisconnectReason) => {
videoCallLayoutRevision.set(0)
currentVoiceSession.set(undefined)
if (reason !== undefined && reason !== DisconnectReason.CLIENT_INITIATED) {
voiceState.set(VoiceState.Disconnected)
@@ -216,11 +223,16 @@ const onTrackSubscribed = (track: Track) => {
element.style.display = "none"
document.body.appendChild(element)
element.play().catch(() => {})
} else if (track.kind === Track.Kind.Video) {
bumpVideoCallLayoutRevision()
}
}
const onTrackUnsubscribed = (track: Track) => {
track.detach().forEach(el => el.remove())
if (track.kind === Track.Kind.Video) {
bumpVideoCallLayoutRevision()
}
}
const onActiveSpeakersChanged = (participants: {identity: string}[]) => {
@@ -301,7 +313,7 @@ export const joinVoiceRoom = async (
const muted = await setUpMicrophone(startMuted, preferredMicId, liveKitRoom.localParticipant)
currentVoiceSession.set({url, h, room: liveKitRoom, muted})
currentVoiceSession.set({url, h, room: liveKitRoom, muted, cameraOn: false})
voiceState.set(VoiceState.Connected)
playJoinSound()
} catch (e) {
@@ -320,7 +332,16 @@ export const leaveVoiceRoom = async () => {
const audio = new Audio("/leave-voice-room.mp3")
audio.play().catch(() => {})
if (session.cameraOn) {
try {
await session.room.localParticipant.setCameraEnabled(false)
} catch {
/* pass */
}
}
voiceState.set(VoiceState.Disconnected)
videoCallLayoutRevision.set(0)
currentVoiceSession.set(undefined)
session.room.disconnect()
speakingParticipants.set([])
@@ -352,3 +373,66 @@ export const toggleMute = async () => {
pushToast({theme: "error", message: "Could not access microphone"})
}
}
const roomHasSubscribedRemoteCamera = (room: LiveKitRoom): boolean => {
for (const p of room.remoteParticipants.values()) {
const pub = p.getTrackPublication(Track.Source.Camera)
if (pub?.isSubscribed && pub.track) return true
}
return false
}
/** True when the connected session has local camera on or any subscribed remote camera track. */
export const videoCallContentActive = derived(
[currentVoiceSession, voiceState, videoCallLayoutRevision],
([$session, $state, _rev]) => {
if ($state !== VoiceState.Connected || !$session) return false
if ($session.cameraOn) return true
return roomHasSubscribedRemoteCamera($session.room)
},
)
/** Live camera tracks (local + remote) for layout automation. */
const countLiveCameraFeeds = (session: VoiceSession): number => {
const room = session.room
let n = 0
const lp = room.localParticipant
if (session.cameraOn) {
const pub = lp.getTrackPublication(Track.Source.Camera)
if (pub?.track) n += 1
}
for (const rp of room.remoteParticipants.values()) {
const pub = rp.getTrackPublication(Track.Source.Camera)
if (pub?.isSubscribed && pub.track) n += 1
}
return n
}
export const videoTileCount = derived(
[currentVoiceSession, voiceState, videoCallLayoutRevision],
([$session, $state, _rev]) => {
if ($state !== VoiceState.Connected || !$session) return 0
return countLiveCameraFeeds($session)
},
)
export const toggleCamera = async () => {
const session = get(currentVoiceSession)
if (!session) return
const cameraOn = !session.cameraOn
if (!cameraOn) {
session.room.localParticipant.setCameraEnabled(false)
currentVoiceSession.set({...session, cameraOn})
bumpVideoCallLayoutRevision()
return
}
try {
await session.room.localParticipant.setCameraEnabled(true)
currentVoiceSession.set({...session, cameraOn})
bumpVideoCallLayoutRevision()
} catch (e) {
pushToast({theme: "error", message: "Could not access camera"})
}
}
+6 -1
View File
@@ -10,7 +10,12 @@
let {children, element = $bindable(), ...props}: Props = $props()
const className = cx(props.class, "scroll-container z-feature overflow-y-auto overflow-x-hidden")
const className = $derived(
cx(
props.class,
"scroll-container z-feature flex min-h-0 w-full min-w-0 flex-1 flex-col overflow-y-auto overflow-x-hidden",
),
)
</script>
<div {...props} bind:this={element} data-component="PageContent" class={className}>
+9 -2
View File
@@ -1,7 +1,14 @@
<script>
<script lang="ts">
import type {Snippet} from "svelte"
import {page} from "$app/stores"
type Props = {
children?: Snippet
}
const {children}: Props = $props()
</script>
{#key $page.url.searchParams.get("at")}
<slot />
{@render children?.()}
Outdated
Review

The AI said this is the right way to do things now in Svelte 5, and the AI never lies.

The AI said this is the right way to do things now in Svelte 5, and the AI never lies.
{/key}
+138 -6
View File
@@ -13,6 +13,7 @@
import ClockCircle from "@assets/icons/clock-circle.svg?dataurl"
import InfoCircle from "@assets/icons/info-circle.svg?dataurl"
import Login2 from "@assets/icons/login-3.svg?dataurl"
import cx from "classnames"
import {slide, fade, fly} from "@lib/transition"
import Button from "@lib/components/Button.svelte"
import Divider from "@lib/components/Divider.svelte"
@@ -43,7 +44,8 @@
userSettingsValues,
} from "@app/core/state"
import VoiceWidget from "@app/components/VoiceWidget.svelte"
import {VoiceState, voiceState} from "@app/voice"
import VideoCallContent from "@app/components/VideoCallContent.svelte"
import {VoiceState, currentVoiceRoom, videoTileCount, voiceState} from "@app/voice"
import {makeFeed} from "@app/core/requests"
import {popKey} from "@lib/implicit"
import {checked} from "@app/util/notifications"
@@ -56,6 +58,49 @@
const url = decodeRelay(relay)
const room = deriveRoom(url, h)
const isVoiceRoom = $derived(getRoomType($room) === RoomType.Voice)
const voiceConnectedHere = $derived(
isVoiceRoom &&
$voiceState === VoiceState.Connected &&
$currentVoiceRoom?.url === url &&
$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",
)
const pageContentHiddenDesktopVideoOnly = $derived(
voiceConnectedHere && voiceDesktopPanel === "video",
)
let prevVideoTileCount = $state(0)
$effect(() => {
if ($voiceState !== VoiceState.Connected) {
mobileRoomPanel = "chat"
voiceDesktopPanel = "chat"
prevVideoTileCount = 0
return
}
const here = isVoiceRoom && $currentVoiceRoom?.url === url && $currentVoiceRoom?.h === h
const n = $videoTileCount
if (!here) {
prevVideoTileCount = 0
return
}
if (prevVideoTileCount === 0 && n >= 1) {
voiceDesktopPanel = "video"
mobileRoomPanel = "video"
}
prevVideoTileCount = n
})
const shouldProtect = canEnforceNip70(url)
const membershipStatus = deriveUserRoomMembershipStatus(url, h)
const at = $derived(parseInt($page.url.searchParams.get("at")!))
@@ -357,6 +402,40 @@
<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} />
@@ -364,7 +443,49 @@
{/snippet}
</SpaceBar>
<PageContent bind:element onscroll={onScroll} class="flex flex-col-reverse pt-4">
<div
class={cx(
"flex min-h-0 flex-1 flex-col",
voiceConnectedHere && voiceDesktopPanel === "split" && "md:flex-row",
)}>
{#if voiceConnectedHere}
<VideoCallContent
variant="desktop-split"
{url}
{h}
visible={voiceDesktopPanel === "split"}
class="hidden min-h-0 w-full min-w-0 flex-1 flex-col md:flex" />
<VideoCallContent
variant="desktop-full"
{url}
{h}
visible={voiceDesktopPanel === "video"}
class="hidden min-h-0 w-full min-w-0 flex-1 flex-col md:flex" />
{/if}
<div
class={cx(
"flex min-h-0 min-w-0 flex-1 flex-col",
voiceConnectedHere && voiceDesktopPanel === "video" && "md:hidden",
)}>
{#if isVoiceRoom && $voiceState === VoiceState.Connected}
<VideoCallContent
variant="mobile"
{url}
{h}
visible={mobileRoomPanel === "video"}
class="md:hidden" />
{/if}
<PageContent
bind:element
onscroll={onScroll}
class={cx(
showMobileVideoPanel
? "hidden flex-col-reverse pt-4 md:flex md:flex-col-reverse"
: "flex flex-col-reverse pt-4",
pageContentHiddenDesktopVideoOnly && "md:hidden",
)}>
{#if $room.isPrivate && $membershipStatus !== MembershipStatus.Granted}
<div class="py-20">
<div class="card2 col-8 m-auto max-w-md items-center text-center">
@@ -431,10 +552,15 @@
</p>
{/if}
<div class="h-screen"></div>
</PageContent>
</PageContent>
<div class="chat__compose flex flex-col gap-1 bg-base-200 md:flex-row md:gap-0">
<div class="chat__compose-inner min-w-0 flex-1">
<div
class={cx(
"chat__compose-zone chat__compose flex flex-col gap-1 bg-base-200 md:flex-row md:gap-0",
pageContentHiddenDesktopVideoOnly && "md:hidden",
showMobileVideoPanel && "max-md:hidden",
)}>
<div class="chat__compose-inner min-w-0 flex-1">
{#if $room.isPrivate && $membershipStatus !== MembershipStatus.Granted}
<!-- pass -->
{:else if $room.isRestricted && $membershipStatus !== MembershipStatus.Granted}
@@ -481,10 +607,16 @@
{/if}
</div>
{#if isVoiceRoom || $voiceState === VoiceState.Joining || $voiceState === VoiceState.Connected}
<div class="hide-on-keyboard shrink-0 p-2 md:hidden">
<div
class={cx(
"hide-on-keyboard flex-shrink-0 p-2 md:hidden",
showMobileVideoPanel && "hidden",
)}>
<VoiceWidget />
</div>
{/if}
</div>
</div>
</div>
{#if showScrollButton}