forked from coracle/flotilla
f4ebc4e99e
#135 This PR adds basic video functionality to our voice rooms. Again I followed the Discord UX for inspiration, so all video calls start as voice-only calls that gracefully upgrade (and downgrade) when someone turns on a video or starts screen sharing. When a video feed is detected the Room page will change to display a grid of feeds. The grid logic is very basic, that's definitely an area to improve in the future. You can open the chat part of the room with a new button on the VoiceWidget - on the desktop layout this creates a split view with video on the left and chat on the right, but on mobile it switches to chat fullscreen. I also added a little pin icon you can use to focus on a single video feed (useful for screen sharing). There is a lot of tailwind I don't understand here, but it seems to work well enough. I moved voice.ts into a new `call` folder and moved some of its stores into `call/stores.ts` which allowed me to keep most of the video logic in `call/video.ts`. It's not a perfect encapsulation as voice.ts does subscribe to some of the hooks for the livekit calls and passes some of the signals onto `video.ts`. This could probably be broken up better but for this PR I'd rather not focus on making it perfect if that's ok. Partly for the sake of time but also because I envision another PR that renames/reorganizes things and I think a larger UX evaluation is necessary and should include real user feedback. I'm not confident tha""t the Voice Room concept as a whole will stick going forward. Maybe all rooms in a livekit enabled server should be able to host a call (like a slack huddle), maybe users want to be able to schedule calls as events, or even have them start with an ad-hoc set of participants completely outside of a NIP-29 group, etc. Co-authored-by: mplorentz <mplorentz@noreply.gitea.coracle.social> Reviewed-on: coracle/flotilla#135 Co-authored-by: Matt Lorentz <mplorentz@noreply.coracle.social> Co-committed-by: Matt Lorentz <mplorentz@noreply.coracle.social>
279 lines
9.1 KiB
Svelte
279 lines
9.1 KiB
Svelte
<script lang="ts">
|
|
import cx from "classnames"
|
|
import {Track} from "livekit-client"
|
|
import {displayProfileByPubkey, loadProfile} from "@welshman/app"
|
|
import Pin from "@assets/icons/pin.svg?dataurl"
|
|
import Button from "@lib/components/Button.svelte"
|
|
import Icon from "@lib/components/Icon.svelte"
|
|
import ProfileCircle from "@app/components/ProfileCircle.svelte"
|
|
import VideoCallTile from "@app/components/VideoCallTile.svelte"
|
|
import VoiceWidget from "@app/components/VoiceWidget.svelte"
|
|
import {get} from "svelte/store"
|
|
import {
|
|
VideoCallLayout,
|
|
isDesktopLayout,
|
|
toggleVideoPrimaryTile,
|
|
videoCallLayout,
|
|
videoCallViewportSync,
|
|
ViewportSize,
|
|
videoPrimaryTileKey,
|
|
} from "@app/call/video"
|
|
import {currentVoiceSession, currentVoiceRoom, pubkeyFromLiveKitIdentity} from "@app/call/stores"
|
|
|
|
type Props = {
|
|
layout: VideoCallLayout
|
|
mobile?: boolean
|
|
url: string
|
|
h: string
|
|
class?: string
|
|
}
|
|
|
|
type VideoTileData = {
|
|
identity: string
|
|
isLocal: boolean
|
|
trackSid: string
|
|
track: Track | undefined
|
|
source: Track.Source.Camera | Track.Source.ScreenShare
|
|
}
|
|
|
|
type TileLayout = "spotlight" | "default" | "strip"
|
|
|
|
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(
|
|
$currentVoiceRoom?.url === url && $currentVoiceRoom?.h === h,
|
|
)
|
|
|
|
const showVideoContent = $derived(
|
|
isViewingCurrentCallRoom &&
|
|
(mobile
|
|
? layout === VideoCallLayout.Video
|
|
: layout === VideoCallLayout.Split || layout === VideoCallLayout.Video),
|
|
)
|
|
|
|
const videoTiles = $derived.by(() => {
|
|
const session = $currentVoiceSession
|
|
if (!session || $currentVoiceRoom?.url !== url || $currentVoiceRoom?.h !== h) {
|
|
return []
|
|
}
|
|
|
|
const room = session.room
|
|
const videoTiles: VideoTileData[] = []
|
|
const user = room.localParticipant
|
|
|
|
if (session.cameraOn) {
|
|
const localPub = user.getTrackPublication(Track.Source.Camera)
|
|
videoTiles.push({
|
|
identity: user.identity,
|
|
isLocal: true,
|
|
trackSid: localPub?.trackSid ?? "local-camera",
|
|
track: localPub?.track,
|
|
source: Track.Source.Camera,
|
|
})
|
|
}
|
|
|
|
if (session.screenShareOn) {
|
|
const localPub = user.getTrackPublication(Track.Source.ScreenShare)
|
|
videoTiles.push({
|
|
identity: user.identity,
|
|
isLocal: true,
|
|
trackSid: localPub?.trackSid ?? "local-screen",
|
|
track: localPub?.track,
|
|
source: Track.Source.ScreenShare,
|
|
})
|
|
}
|
|
|
|
for (const rp of room.remoteParticipants.values()) {
|
|
const camPub = rp.getTrackPublication(Track.Source.Camera)
|
|
if (camPub?.isSubscribed && camPub.track) {
|
|
videoTiles.push({
|
|
identity: rp.identity,
|
|
isLocal: false,
|
|
trackSid: camPub.trackSid,
|
|
track: camPub.track,
|
|
source: Track.Source.Camera,
|
|
})
|
|
}
|
|
const screenPub = rp.getTrackPublication(Track.Source.ScreenShare)
|
|
if (screenPub?.isSubscribed && screenPub.track) {
|
|
videoTiles.push({
|
|
identity: rp.identity,
|
|
isLocal: false,
|
|
trackSid: screenPub.trackSid,
|
|
track: screenPub.track,
|
|
source: Track.Source.ScreenShare,
|
|
})
|
|
}
|
|
}
|
|
|
|
return videoTiles
|
|
})
|
|
|
|
/** Identity + source only — LiveKit can change trackSid after publish, which broke spotlight + stale-key effect. */
|
|
const tileKey = (t: VideoTileData) => `${t.identity}\x1f${t.source}`
|
|
|
|
const primaryTile = $derived.by(() => {
|
|
const k = $videoPrimaryTileKey
|
|
if (k === undefined) return undefined
|
|
return videoTiles.find(t => tileKey(t) === k)
|
|
})
|
|
|
|
const secondaryTiles = $derived.by(() => {
|
|
const p = primaryTile
|
|
if (p === undefined) return videoTiles
|
|
const pk = tileKey(p)
|
|
return videoTiles.filter(t => tileKey(t) !== pk)
|
|
})
|
|
|
|
const useSpotlightLayout = $derived(primaryTile !== undefined)
|
|
const useMultiGrid = $derived(!useSpotlightLayout && videoTiles.length > 2)
|
|
|
|
$effect(() => {
|
|
const k = $videoPrimaryTileKey
|
|
if (k === undefined) return
|
|
if (!videoTiles.some(t => tileKey(t) === k)) {
|
|
videoPrimaryTileKey.set(undefined)
|
|
}
|
|
})
|
|
|
|
$effect(() => {
|
|
for (const t of videoTiles) {
|
|
const pk = pubkeyFromLiveKitIdentity(t.identity)
|
|
if (pk) loadProfile(pk)
|
|
}
|
|
})
|
|
|
|
const labelFor = (identity: string, source: VideoTileData["source"]) => {
|
|
const pk = pubkeyFromLiveKitIdentity(identity)
|
|
const name = pk ? displayProfileByPubkey(pk) : "Unknown"
|
|
return source === Track.Source.ScreenShare ? `${name} · screen` : name
|
|
}
|
|
|
|
const showTileGrid = $derived(videoTiles.length > 0)
|
|
|
|
const spotlightHandlerFor = (key: string) => () => {
|
|
toggleVideoPrimaryTile(key)
|
|
}
|
|
|
|
const panelChrome = $derived(
|
|
cx(
|
|
mobile &&
|
|
"flex min-h-0 w-full flex-1 flex-col gap-2 overflow-y-auto overflow-x-hidden bg-base-200 px-2 pt-4 md:hidden pb-[calc(3.5rem+var(--saib))]",
|
|
!mobile &&
|
|
"flex min-h-0 w-full min-w-0 flex-1 flex-col gap-2 overflow-hidden bg-base-200 px-2 pb-2 pt-4",
|
|
className,
|
|
),
|
|
)
|
|
</script>
|
|
|
|
{#snippet videoTile(tile: VideoTileData, layout: TileLayout)}
|
|
<div
|
|
class={cx(
|
|
"relative isolate overflow-hidden rounded-box shadow-sm",
|
|
layout === "spotlight" && "min-h-0 flex-1",
|
|
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",
|
|
)}>
|
|
{#if tile.track}
|
|
<VideoCallTile
|
|
track={tile.track}
|
|
muted={tile.isLocal}
|
|
fit={tile.source === Track.Source.ScreenShare ? "contain" : "cover"}
|
|
class="pointer-events-none 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="pointer-events-none 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.source)}{tile.isLocal ? " (you)" : ""}
|
|
</span>
|
|
{#if videoTiles.length > 1}
|
|
{@const pinned = $videoPrimaryTileKey === tileKey(tile)}
|
|
<Button
|
|
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>
|
|
{/if}
|
|
</div>
|
|
{/snippet}
|
|
|
|
{#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 videoTiles 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 videoTiles 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 showVideoContent}
|
|
<div class={panelChrome}>
|
|
{#if 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 pb-2">
|
|
<VoiceWidget />
|
|
</div>
|
|
</div>
|
|
{:else}
|
|
{@render videoPanelBody()}
|
|
{/if}
|
|
</div>
|
|
{/if}
|