rework video + text chat display controls
This commit is contained in:
+11
@@ -22,6 +22,17 @@
|
||||
@apply pl-sai pr-sai;
|
||||
}
|
||||
|
||||
/* root */
|
||||
|
||||
:root {
|
||||
font-family: Lato;
|
||||
--sait: var(--safe-area-inset-top, env(safe-area-inset-top));
|
||||
--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;
|
||||
}
|
||||
|
||||
@utility py-sai {
|
||||
@apply pt-sai pb-sai;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
)
|
||||
@@ -208,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}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
import {readable} from "svelte/store"
|
||||
import {fly} from "svelte/transition"
|
||||
import {browser} from "$app/environment"
|
||||
import {goto} from "$app/navigation"
|
||||
import {page} from "$app/stores"
|
||||
import cx from "classnames"
|
||||
@@ -11,6 +12,7 @@
|
||||
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"
|
||||
@@ -32,6 +34,8 @@
|
||||
currentVoiceSession,
|
||||
currentVoiceRoom,
|
||||
voiceState,
|
||||
voiceMobileRoomPanel,
|
||||
voiceDesktopRoomPanel,
|
||||
isLocalSpeaking,
|
||||
leaveVoiceRoom,
|
||||
toggleMute,
|
||||
@@ -76,6 +80,45 @@
|
||||
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 mediaToggleClass = "center tooltip tooltip-top btn btn-sm btn-square btn-ghost"
|
||||
</script>
|
||||
|
||||
@@ -96,18 +139,28 @@
|
||||
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>
|
||||
{/if}
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
{#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}
|
||||
{#if showVoiceLayoutToggle}
|
||||
<Button
|
||||
data-tip="Toggle full-screen chat ↔ video (mobile) or split view (desktop)"
|
||||
class={cx(mediaToggleClass, "shrink-0", layoutToggleActive && "text-primary")}
|
||||
onclick={onLayoutToggle}>
|
||||
<Icon icon={ChatRound} size={4} />
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
<span class="ellipsize text-xs opacity-70">
|
||||
{roomName} / {spaceName}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
{#if $voiceState === VoiceState.Joining}
|
||||
<span class="loading loading-spinner loading-sm"></span>
|
||||
<Button
|
||||
|
||||
+13
-20
@@ -87,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. */
|
||||
@@ -229,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 =
|
||||
@@ -396,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())
|
||||
@@ -429,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
|
||||
|
||||
@@ -45,7 +45,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"
|
||||
@@ -66,23 +73,20 @@
|
||||
$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 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
|
||||
}
|
||||
@@ -96,8 +100,8 @@
|
||||
}
|
||||
|
||||
if (prevVideoTileCount === 0 && n >= 1) {
|
||||
voiceDesktopPanel = "video"
|
||||
mobileRoomPanel = "video"
|
||||
voiceDesktopRoomPanel.set("video")
|
||||
voiceMobileRoomPanel.set("video")
|
||||
}
|
||||
prevVideoTileCount = n
|
||||
})
|
||||
@@ -402,40 +406,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} />
|
||||
@@ -446,34 +416,34 @@
|
||||
<div
|
||||
class={cx(
|
||||
"flex min-h-0 flex-1 flex-col",
|
||||
voiceConnectedHere && voiceDesktopPanel === "split" && "md:flex-row",
|
||||
voiceConnectedHere && $voiceDesktopRoomPanel === "split" && "md:flex-row",
|
||||
)}>
|
||||
{#if voiceConnectedHere}
|
||||
<VideoCallContent
|
||||
variant="desktop-split"
|
||||
{url}
|
||||
{h}
|
||||
visible={voiceDesktopPanel === "split"}
|
||||
visible={$voiceDesktopRoomPanel === "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"}
|
||||
visible={$voiceDesktopRoomPanel === "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",
|
||||
voiceConnectedHere && $voiceDesktopRoomPanel === "video" && "md:hidden",
|
||||
)}>
|
||||
{#if isVoiceRoom && $voiceState === VoiceState.Connected}
|
||||
<VideoCallContent
|
||||
variant="mobile"
|
||||
{url}
|
||||
{h}
|
||||
visible={mobileRoomPanel === "video"}
|
||||
visible={$voiceMobileRoomPanel === "video"}
|
||||
class="md:hidden" />
|
||||
{/if}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user