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: #135 Co-authored-by: Matt Lorentz <mplorentz@noreply.coracle.social> Co-committed-by: Matt Lorentz <mplorentz@noreply.coracle.social>
245 lines
8.9 KiB
Svelte
245 lines
8.9 KiB
Svelte
<script lang="ts">
|
|
import {readable} from "svelte/store"
|
|
import {fade, fly} from "svelte/transition"
|
|
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 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 {Capacitor} from "@capacitor/core"
|
|
import Icon from "@lib/components/Icon.svelte"
|
|
import Button from "@lib/components/Button.svelte"
|
|
import VoiceCallAudioSettingsDialog from "@app/components/VoiceCallAudioSettingsDialog.svelte"
|
|
import VoiceRoomJoinDialog from "@app/components/VoiceRoomJoinDialog.svelte"
|
|
import {
|
|
decodeRelay,
|
|
deriveRoom,
|
|
displayRoom,
|
|
getRoomType,
|
|
RoomType,
|
|
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 {
|
|
VideoCallLayout,
|
|
isDesktopLayout,
|
|
toggleCamera,
|
|
toggleScreenShare,
|
|
videoCallLayout,
|
|
} from "@app/call/video"
|
|
import {
|
|
VoiceState,
|
|
currentVoiceSession,
|
|
currentVoiceRoom,
|
|
voiceState,
|
|
isLocalSpeaking,
|
|
} from "@app/call/stores"
|
|
import {cancelJoinVoiceRoom, leaveVoiceRoom, toggleMute} from "@app/call/voice"
|
|
|
|
const {relay, h} = $derived($page.params)
|
|
const url = $derived(relay ? decodeRelay(relay) : undefined)
|
|
const displayedRoomStore = $derived(
|
|
url && h && typeof h === "string" ? deriveRoom(url, h) : readable(undefined),
|
|
)
|
|
const routeDisplayedRoom = $derived($displayedRoomStore)
|
|
|
|
const isViewingCurrentVoiceRoom = $derived(
|
|
$currentVoiceRoom !== undefined &&
|
|
url !== undefined &&
|
|
typeof h === "string" &&
|
|
$currentVoiceRoom.url === url &&
|
|
$currentVoiceRoom.h === h,
|
|
)
|
|
|
|
const targetRoom = $derived.by((): Room | undefined => {
|
|
if ($voiceState === VoiceState.Joining || $voiceState === VoiceState.Connected) {
|
|
return $currentVoiceRoom
|
|
}
|
|
if ($voiceState === VoiceState.Disconnected) {
|
|
if (routeDisplayedRoom) {
|
|
if (getRoomType(routeDisplayedRoom) === RoomType.Voice) {
|
|
return routeDisplayedRoom
|
|
}
|
|
return undefined
|
|
}
|
|
return $currentVoiceRoom
|
|
}
|
|
return $currentVoiceRoom
|
|
})
|
|
|
|
const roomName = $derived(targetRoom ? displayRoom(targetRoom.url, targetRoom.h) : "")
|
|
const spaceName = $derived(targetRoom ? displayRelayUrl(targetRoom.url) : "")
|
|
|
|
const openJoinDialog = async () => {
|
|
if (!targetRoom) return
|
|
await goto(makeRoomPath(targetRoom.url, targetRoom.h))
|
|
pushModal(VoiceRoomJoinDialog, {url: targetRoom.url, h: targetRoom.h})
|
|
}
|
|
|
|
const goToRoom = () => {
|
|
if (!targetRoom) return
|
|
const path = makeRoomPath(targetRoom.url, targetRoom.h)
|
|
if ($page.url.pathname !== path) {
|
|
void goto(path)
|
|
}
|
|
}
|
|
|
|
const openCallSettings = () => {
|
|
pushModal(VoiceCallAudioSettingsDialog)
|
|
}
|
|
|
|
const showChatButton = $derived($voiceState === VoiceState.Connected && isViewingCurrentVoiceRoom)
|
|
|
|
const isChatPanelActive = $derived(
|
|
showChatButton &&
|
|
(isDesktopLayout.current
|
|
? $videoCallLayout === VideoCallLayout.Split
|
|
: $videoCallLayout === VideoCallLayout.Chat),
|
|
)
|
|
|
|
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,
|
|
)
|
|
}
|
|
}
|
|
|
|
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>
|
|
|
|
{#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 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 showChatButton}
|
|
<Button
|
|
data-tip="Toggle Chat"
|
|
class={cx(
|
|
mediaToggleClass,
|
|
"relative shrink-0 overflow-visible",
|
|
isChatPanelActive && "text-primary",
|
|
)}
|
|
onclick={onChatToggle}>
|
|
<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}
|
|
</div>
|
|
<div class="flex flex-wrap items-center gap-2">
|
|
{#if $voiceState === VoiceState.Joining}
|
|
<span class="loading loading-spinner loading-sm"></span>
|
|
<Button
|
|
data-tip="Cancel"
|
|
class="center tooltip tooltip-top btn btn-sm btn-square btn-ghost"
|
|
onclick={cancelJoinVoiceRoom}>
|
|
<Icon icon={CloseCircle} size={4} />
|
|
</Button>
|
|
{:else if $voiceState === VoiceState.Connected && $currentVoiceSession}
|
|
<Button
|
|
data-tip={$currentVoiceSession.muted ? "Unmute" : "Mute"}
|
|
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}>
|
|
<span class="relative inline-flex items-center justify-center overflow-visible">
|
|
<Icon icon={Microphone} size={4} />
|
|
{#if $currentVoiceSession.muted}
|
|
<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}
|
|
</span>
|
|
</Button>
|
|
<Button
|
|
data-tip={$currentVoiceSession.cameraOn ? "Turn off camera" : "Turn on camera"}
|
|
class={cx(mediaToggleClass, $currentVoiceSession.cameraOn && "text-primary")}
|
|
onclick={toggleCamera}>
|
|
<Icon icon={$currentVoiceSession.cameraOn ? VideocameraRecord : Videocamera} size={4} />
|
|
</Button>
|
|
{#if !Capacitor.isNativePlatform()}
|
|
<Button
|
|
data-tip={$currentVoiceSession.screenShareOn ? "Stop sharing" : "Share screen"}
|
|
class={cx(mediaToggleClass, $currentVoiceSession.screenShareOn && "text-primary")}
|
|
onclick={toggleScreenShare}>
|
|
<Icon icon={Monitor} size={4} />
|
|
</Button>
|
|
{/if}
|
|
<Button
|
|
data-tip="Call settings"
|
|
class="center tooltip tooltip-top btn btn-sm btn-square btn-ghost"
|
|
onclick={openCallSettings}>
|
|
<Icon icon={Settings} size={4} />
|
|
</Button>
|
|
<Button
|
|
data-tip="Leave room"
|
|
class="center tooltip tooltip-top btn btn-sm btn-square btn-error"
|
|
onclick={leaveVoiceRoom}>
|
|
<Icon icon={PhoneRounded} size={4} />
|
|
</Button>
|
|
{:else}
|
|
<Button
|
|
data-tip="Join Voice"
|
|
class="center tooltip tooltip-top btn btn-sm btn-square btn-success"
|
|
onclick={openJoinDialog}>
|
|
<Icon icon={PhoneCallingRounded} size={4} />
|
|
</Button>
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
{/if}
|