forked from coracle/flotilla
Video in calls (#135)
#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>
This commit is contained in:
@@ -44,4 +44,7 @@
|
|||||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||||
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||||
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
|
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
|
||||||
|
<uses-permission android:name="android.permission.CAMERA" />
|
||||||
|
|
||||||
|
<uses-feature android:name="android.hardware.camera.any" android:required="false" />
|
||||||
</manifest>
|
</manifest>
|
||||||
|
|||||||
@@ -24,8 +24,10 @@
|
|||||||
<false/>
|
<false/>
|
||||||
<key>LSRequiresIPhoneOS</key>
|
<key>LSRequiresIPhoneOS</key>
|
||||||
<true/>
|
<true/>
|
||||||
|
<key>NSCameraUsageDescription</key>
|
||||||
|
<string>Flotilla uses the camera when you enable it in a voice room.</string>
|
||||||
<key>NSMicrophoneUsageDescription</key>
|
<key>NSMicrophoneUsageDescription</key>
|
||||||
<string>Flotilla uses the microphone for voice chat in rooms.</string>
|
<string>Flotilla uses the microphone when you enable it in a voice room.</string>
|
||||||
<key>UIBackgroundModes</key>
|
<key>UIBackgroundModes</key>
|
||||||
<array>
|
<array>
|
||||||
<string>remote-notification</string>
|
<string>remote-notification</string>
|
||||||
|
|||||||
+11
-1
@@ -22,6 +22,16 @@
|
|||||||
@apply pl-sai pr-sai;
|
@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));
|
||||||
|
}
|
||||||
|
|
||||||
@utility py-sai {
|
@utility py-sai {
|
||||||
@apply pt-sai pb-sai;
|
@apply pt-sai pb-sai;
|
||||||
}
|
}
|
||||||
@@ -415,7 +425,7 @@ body.keyboard-open .hide-on-keyboard {
|
|||||||
/* chat view */
|
/* chat view */
|
||||||
|
|
||||||
.chat__compose {
|
.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 {
|
.chat__compose .chat__compose-inner {
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import {Room as LiveKitRoom} from "livekit-client"
|
||||||
|
import {derived, writable} from "svelte/store"
|
||||||
|
import {type Room} from "@app/core/state"
|
||||||
|
|
||||||
|
export type VoiceSession = {
|
||||||
|
url: string
|
||||||
|
h: string
|
||||||
|
room: LiveKitRoom
|
||||||
|
muted: boolean
|
||||||
|
cameraOn: boolean
|
||||||
|
screenShareOn: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Pubkey = string
|
||||||
|
|
||||||
|
export type VoiceParticipant = {pubkey?: Pubkey; identity: string}
|
||||||
|
|
||||||
|
export enum VoiceState {
|
||||||
|
Joining = "joining",
|
||||||
|
Connected = "connected",
|
||||||
|
Disconnected = "disconnected",
|
||||||
|
}
|
||||||
|
|
||||||
|
export const currentVoiceSession = writable<VoiceSession | undefined>(undefined)
|
||||||
|
|
||||||
|
export const voiceState = writable<VoiceState>(VoiceState.Disconnected)
|
||||||
|
|
||||||
|
export const currentVoiceRoom = writable<Room | undefined>(undefined)
|
||||||
|
|
||||||
|
export const participantPubkeyMap = writable<Map<string, Pubkey>>(new Map())
|
||||||
|
|
||||||
|
export const pubkeyFromLiveKitIdentity = (identity: string): string | undefined =>
|
||||||
|
/^[a-f0-9]{64}$/.test(identity.slice(0, 64)) ? identity.slice(0, 64) : undefined
|
||||||
|
|
||||||
|
export const participantFromLiveKitIdentity = (identity: string): VoiceParticipant => {
|
||||||
|
const pk = pubkeyFromLiveKitIdentity(identity)
|
||||||
|
return pk ? {pubkey: pk, identity} : {identity}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const participantKey = (p: VoiceParticipant) => p.pubkey ?? p.identity
|
||||||
|
|
||||||
|
export const speakingParticipants = writable<VoiceParticipant[]>([])
|
||||||
|
|
||||||
|
export const isParticipantSpeaking = derived(
|
||||||
|
speakingParticipants,
|
||||||
|
$participants => (p: VoiceParticipant) =>
|
||||||
|
$participants.some(sp => participantKey(sp) === participantKey(p)),
|
||||||
|
)
|
||||||
|
|
||||||
|
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))
|
||||||
|
},
|
||||||
|
)
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
import {Track} from "livekit-client"
|
||||||
|
import {MediaQuery} from "svelte/reactivity"
|
||||||
|
import {derived, get, writable} from "svelte/store"
|
||||||
|
import {currentVoiceSession, VoiceState, type VoiceSession, voiceState} from "@app/call/stores"
|
||||||
|
import {pushToast} from "@app/util/toast"
|
||||||
|
|
||||||
|
export enum VideoCallLayout {
|
||||||
|
Chat = "chat",
|
||||||
|
Video = "video",
|
||||||
|
Split = "split",
|
||||||
|
}
|
||||||
|
|
||||||
|
export const isDesktopLayout = new MediaQuery("min-width: 768px", false)
|
||||||
|
|
||||||
|
export enum ViewportSize {
|
||||||
|
Desktop = "desktop",
|
||||||
|
Mobile = "mobile",
|
||||||
|
}
|
||||||
|
|
||||||
|
export const videoCallViewportSync = {
|
||||||
|
previousLayout: undefined as ViewportSize | undefined,
|
||||||
|
}
|
||||||
|
|
||||||
|
export const videoCallLayout = writable<VideoCallLayout>(VideoCallLayout.Split)
|
||||||
|
|
||||||
|
export const resetVideoCallLayout = () => {
|
||||||
|
videoCallViewportSync.previousLayout = undefined
|
||||||
|
videoCallLayout.set(VideoCallLayout.Chat)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const videoPrimaryTileKey = writable<string | undefined>(undefined)
|
||||||
|
|
||||||
|
export const toggleVideoPrimaryTile = (key: string) => {
|
||||||
|
videoPrimaryTileKey.update(k => (k === key ? undefined : key))
|
||||||
|
}
|
||||||
|
|
||||||
|
const VISUAL_SOURCES = [Track.Source.Camera, Track.Source.ScreenShare] as const
|
||||||
|
|
||||||
|
const countLiveVisualFeeds = (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
|
||||||
|
}
|
||||||
|
if (session.screenShareOn) {
|
||||||
|
const pub = lp.getTrackPublication(Track.Source.ScreenShare)
|
||||||
|
if (pub?.track) n += 1
|
||||||
|
}
|
||||||
|
for (const rp of room.remoteParticipants.values()) {
|
||||||
|
for (const source of VISUAL_SOURCES) {
|
||||||
|
const pub = rp.getTrackPublication(source)
|
||||||
|
if (pub?.isSubscribed && pub.track) n += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
export const triggerVideoFeedCount = () => {
|
||||||
|
currentVoiceSession.update(s => (s ? {...s} : s))
|
||||||
|
}
|
||||||
|
|
||||||
|
export const videoTileCount = derived([currentVoiceSession, voiceState], ([$session, $state]) => {
|
||||||
|
if ($state !== VoiceState.Connected || !$session) return 0
|
||||||
|
return countLiveVisualFeeds($session)
|
||||||
|
})
|
||||||
|
|
||||||
|
export const toggleCamera = async () => {
|
||||||
|
const session = get(currentVoiceSession)
|
||||||
|
if (!session) return
|
||||||
|
|
||||||
|
const cameraOn = !session.cameraOn
|
||||||
|
try {
|
||||||
|
await session.room.localParticipant.setCameraEnabled(cameraOn)
|
||||||
|
currentVoiceSession.set({...session, cameraOn})
|
||||||
|
} catch {
|
||||||
|
pushToast({
|
||||||
|
theme: "error",
|
||||||
|
message: cameraOn ? "Could not access camera" : "Could not turn off camera",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const toggleScreenShare = async () => {
|
||||||
|
const session = get(currentVoiceSession)
|
||||||
|
if (!session) return
|
||||||
|
|
||||||
|
const screenShareOn = !session.screenShareOn
|
||||||
|
try {
|
||||||
|
await session.room.localParticipant.setScreenShareEnabled(screenShareOn)
|
||||||
|
currentVoiceSession.set({...session, screenShareOn})
|
||||||
|
} catch {
|
||||||
|
pushToast({
|
||||||
|
theme: "error",
|
||||||
|
message: screenShareOn ? "Could not start screen sharing" : "Could not stop screen sharing",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,21 +4,35 @@
|
|||||||
*/
|
*/
|
||||||
import {
|
import {
|
||||||
DisconnectReason,
|
DisconnectReason,
|
||||||
|
LocalParticipant,
|
||||||
|
LocalTrackPublication,
|
||||||
Room as LiveKitRoom,
|
Room as LiveKitRoom,
|
||||||
RoomEvent,
|
RoomEvent,
|
||||||
Track,
|
Track,
|
||||||
supportsAudioOutputSelection,
|
supportsAudioOutputSelection,
|
||||||
type AudioCaptureOptions,
|
type AudioCaptureOptions,
|
||||||
type LocalParticipant,
|
|
||||||
} from "livekit-client"
|
} from "livekit-client"
|
||||||
import {derived, get, writable} from "svelte/store"
|
import {derived, get} from "svelte/store"
|
||||||
import {map, removeUndefined, uniqBy} from "@welshman/lib"
|
import {map, removeUndefined, uniqBy} from "@welshman/lib"
|
||||||
import type {TrustedEvent} from "@welshman/util"
|
import type {TrustedEvent} from "@welshman/util"
|
||||||
import {makeHttpAuth, makeHttpAuthHeader, getTags} from "@welshman/util"
|
import {makeHttpAuth, makeHttpAuthHeader, getTags} from "@welshman/util"
|
||||||
import {signer} from "@welshman/app"
|
import {signer} from "@welshman/app"
|
||||||
import {getLivekitEndpoint} from "$lib/livekit"
|
import {getLivekitEndpoint} from "$lib/livekit"
|
||||||
import {AbortError, whenAborted, whenTimeout} from "$lib/util"
|
import {AbortError, whenAborted, whenTimeout} from "$lib/util"
|
||||||
import {deriveLatestEventForUrl, deriveRoom, makeRoomId, type Room} from "@app/core/state"
|
import {
|
||||||
|
currentVoiceRoom,
|
||||||
|
currentVoiceSession,
|
||||||
|
participantFromLiveKitIdentity,
|
||||||
|
participantKey,
|
||||||
|
participantPubkeyMap,
|
||||||
|
pubkeyFromLiveKitIdentity,
|
||||||
|
speakingParticipants,
|
||||||
|
VoiceState,
|
||||||
|
type VoiceParticipant,
|
||||||
|
voiceState,
|
||||||
|
} from "@app/call/stores"
|
||||||
|
import {resetVideoCallLayout, triggerVideoFeedCount, videoPrimaryTileKey} from "@app/call/video"
|
||||||
|
import {deriveLatestEventForUrl, deriveRoom, makeRoomId} from "@app/core/state"
|
||||||
import {pushToast} from "@app/util/toast"
|
import {pushToast} from "@app/util/toast"
|
||||||
|
|
||||||
export const LIVEKIT_PARTICIPANTS = 39004
|
export const LIVEKIT_PARTICIPANTS = 39004
|
||||||
@@ -27,30 +41,12 @@ export {checkRelayHasLivekit} from "$lib/livekit"
|
|||||||
|
|
||||||
export {supportsAudioOutputSelection}
|
export {supportsAudioOutputSelection}
|
||||||
|
|
||||||
export type VoiceSession = {
|
|
||||||
url: string
|
|
||||||
h: string
|
|
||||||
room: LiveKitRoom
|
|
||||||
muted: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
export type Pubkey = string
|
|
||||||
|
|
||||||
export type VoiceParticipant = {pubkey?: Pubkey; identity: string}
|
|
||||||
|
|
||||||
export enum VoiceState {
|
|
||||||
Joining = "joining",
|
|
||||||
Connected = "connected",
|
|
||||||
Disconnected = "disconnected",
|
|
||||||
}
|
|
||||||
|
|
||||||
export const currentVoiceSession = writable<VoiceSession | undefined>(undefined)
|
|
||||||
|
|
||||||
const LIVEKIT_DEFAULT_DEVICE_ID = "default"
|
const LIVEKIT_DEFAULT_DEVICE_ID = "default"
|
||||||
|
|
||||||
export enum DeviceKind {
|
export enum DeviceKind {
|
||||||
AudioInput = "audioinput",
|
AudioInput = "audioinput",
|
||||||
AudioOutput = "audiooutput",
|
AudioOutput = "audiooutput",
|
||||||
|
VideoInput = "videoinput",
|
||||||
}
|
}
|
||||||
|
|
||||||
export const switchVoiceActiveDevice = async (
|
export const switchVoiceActiveDevice = async (
|
||||||
@@ -71,17 +67,14 @@ export const switchVoiceActiveDevice = async (
|
|||||||
case DeviceKind.AudioOutput:
|
case DeviceKind.AudioOutput:
|
||||||
label = "speaker"
|
label = "speaker"
|
||||||
break
|
break
|
||||||
|
case DeviceKind.VideoInput:
|
||||||
|
label = "camera"
|
||||||
|
break
|
||||||
}
|
}
|
||||||
pushToast({theme: "error", message: `Error changing ${label}`})
|
pushToast({theme: "error", message: `Error changing ${label}`})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const voiceState = writable<VoiceState>(VoiceState.Disconnected)
|
|
||||||
|
|
||||||
export const currentVoiceRoom = writable<Room | undefined>(undefined)
|
|
||||||
|
|
||||||
export const participantPubkeyMap = writable<Map<string, Pubkey>>(new Map())
|
|
||||||
|
|
||||||
const addParticipant = (identity: string) => {
|
const addParticipant = (identity: string) => {
|
||||||
participantPubkeyMap.update(m => {
|
participantPubkeyMap.update(m => {
|
||||||
const next = new Map(m)
|
const next = new Map(m)
|
||||||
@@ -98,24 +91,6 @@ const deleteParticipant = (identity: string) => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export const pubkeyFromLiveKitIdentity = (identity: string): string | undefined =>
|
|
||||||
/^[a-f0-9]{64}$/.test(identity.slice(0, 64)) ? identity.slice(0, 64) : undefined
|
|
||||||
|
|
||||||
export const participantFromLiveKitIdentity = (identity: string): VoiceParticipant => {
|
|
||||||
const pk = pubkeyFromLiveKitIdentity(identity)
|
|
||||||
return pk ? {pubkey: pk, identity} : {identity}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const participantKey = (p: VoiceParticipant) => p.pubkey ?? p.identity
|
|
||||||
|
|
||||||
export const speakingParticipants = writable<VoiceParticipant[]>([])
|
|
||||||
|
|
||||||
export const isParticipantSpeaking = derived(
|
|
||||||
speakingParticipants,
|
|
||||||
$participants => (p: VoiceParticipant) =>
|
|
||||||
$participants.some(sp => participantKey(sp) === participantKey(p)),
|
|
||||||
)
|
|
||||||
|
|
||||||
const fetchLivekitToken = async (
|
const fetchLivekitToken = async (
|
||||||
url: string,
|
url: string,
|
||||||
groupId: string,
|
groupId: string,
|
||||||
@@ -197,7 +172,9 @@ const setUpMicrophone = async (
|
|||||||
}
|
}
|
||||||
|
|
||||||
const onRoomDisconnected = (reason?: DisconnectReason) => {
|
const onRoomDisconnected = (reason?: DisconnectReason) => {
|
||||||
|
videoPrimaryTileKey.set(undefined)
|
||||||
currentVoiceSession.set(undefined)
|
currentVoiceSession.set(undefined)
|
||||||
|
resetVideoCallLayout()
|
||||||
if (reason !== undefined && reason !== DisconnectReason.CLIENT_INITIATED) {
|
if (reason !== undefined && reason !== DisconnectReason.CLIENT_INITIATED) {
|
||||||
voiceState.set(VoiceState.Disconnected)
|
voiceState.set(VoiceState.Disconnected)
|
||||||
const message =
|
const message =
|
||||||
@@ -216,11 +193,16 @@ const onTrackSubscribed = (track: Track) => {
|
|||||||
element.style.display = "none"
|
element.style.display = "none"
|
||||||
document.body.appendChild(element)
|
document.body.appendChild(element)
|
||||||
element.play().catch(() => {})
|
element.play().catch(() => {})
|
||||||
|
} else if (track.kind === Track.Kind.Video) {
|
||||||
|
triggerVideoFeedCount()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const onTrackUnsubscribed = (track: Track) => {
|
const onTrackUnsubscribed = (track: Track) => {
|
||||||
track.detach().forEach(el => el.remove())
|
track.detach().forEach(el => el.remove())
|
||||||
|
if (track.kind === Track.Kind.Video) {
|
||||||
|
triggerVideoFeedCount()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const onActiveSpeakersChanged = (participants: {identity: string}[]) => {
|
const onActiveSpeakersChanged = (participants: {identity: string}[]) => {
|
||||||
@@ -241,6 +223,17 @@ const onParticipantDisconnected = (participant: {identity: string}) => {
|
|||||||
deleteParticipant(participant.identity)
|
deleteParticipant(participant.identity)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const onLocalTrackUnpublished = (
|
||||||
|
publication: LocalTrackPublication,
|
||||||
|
participant: LocalParticipant,
|
||||||
|
) => {
|
||||||
|
if (publication.source !== Track.Source.ScreenShare) return
|
||||||
|
const session = get(currentVoiceSession)
|
||||||
|
if (!session || participant.identity !== session.room.localParticipant.identity) return
|
||||||
|
if (!session.screenShareOn) return
|
||||||
|
currentVoiceSession.set({...session, screenShareOn: false})
|
||||||
|
}
|
||||||
|
|
||||||
let joinAbortController: AbortController | undefined
|
let joinAbortController: AbortController | undefined
|
||||||
|
|
||||||
export const cancelJoinVoiceRoom = () => {
|
export const cancelJoinVoiceRoom = () => {
|
||||||
@@ -278,6 +271,7 @@ export const joinVoiceRoom = async (
|
|||||||
liveKitRoom.on(RoomEvent.ParticipantDisconnected, onParticipantDisconnected)
|
liveKitRoom.on(RoomEvent.ParticipantDisconnected, onParticipantDisconnected)
|
||||||
liveKitRoom.on(RoomEvent.TrackSubscribed, onTrackSubscribed)
|
liveKitRoom.on(RoomEvent.TrackSubscribed, onTrackSubscribed)
|
||||||
liveKitRoom.on(RoomEvent.TrackUnsubscribed, onTrackUnsubscribed)
|
liveKitRoom.on(RoomEvent.TrackUnsubscribed, onTrackUnsubscribed)
|
||||||
|
liveKitRoom.on(RoomEvent.LocalTrackUnpublished, onLocalTrackUnpublished)
|
||||||
liveKitRoom.on(RoomEvent.ActiveSpeakersChanged, onActiveSpeakersChanged)
|
liveKitRoom.on(RoomEvent.ActiveSpeakersChanged, onActiveSpeakersChanged)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -301,7 +295,14 @@ export const joinVoiceRoom = async (
|
|||||||
|
|
||||||
const muted = await setUpMicrophone(startMuted, preferredMicId, liveKitRoom.localParticipant)
|
const muted = await setUpMicrophone(startMuted, preferredMicId, liveKitRoom.localParticipant)
|
||||||
|
|
||||||
currentVoiceSession.set({url, h, room: liveKitRoom, muted})
|
currentVoiceSession.set({
|
||||||
|
url,
|
||||||
|
h,
|
||||||
|
room: liveKitRoom,
|
||||||
|
muted,
|
||||||
|
cameraOn: false,
|
||||||
|
screenShareOn: false,
|
||||||
|
})
|
||||||
voiceState.set(VoiceState.Connected)
|
voiceState.set(VoiceState.Connected)
|
||||||
playJoinSound()
|
playJoinSound()
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -320,8 +321,26 @@ export const leaveVoiceRoom = async () => {
|
|||||||
const audio = new Audio("/leave-voice-room.mp3")
|
const audio = new Audio("/leave-voice-room.mp3")
|
||||||
audio.play().catch(() => {})
|
audio.play().catch(() => {})
|
||||||
|
|
||||||
|
if (session.cameraOn) {
|
||||||
|
try {
|
||||||
|
await session.room.localParticipant.setCameraEnabled(false)
|
||||||
|
} catch {
|
||||||
|
pushToast({theme: "error", message: "Error turning off camera."})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (session.screenShareOn) {
|
||||||
|
try {
|
||||||
|
await session.room.localParticipant.setScreenShareEnabled(false)
|
||||||
|
} catch {
|
||||||
|
pushToast({theme: "error", message: "Error turning off screen sharing."})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
voiceState.set(VoiceState.Disconnected)
|
voiceState.set(VoiceState.Disconnected)
|
||||||
|
videoPrimaryTileKey.set(undefined)
|
||||||
currentVoiceSession.set(undefined)
|
currentVoiceSession.set(undefined)
|
||||||
|
resetVideoCallLayout()
|
||||||
session.room.disconnect()
|
session.room.disconnect()
|
||||||
speakingParticipants.set([])
|
speakingParticipants.set([])
|
||||||
participantPubkeyMap.set(new Map())
|
participantPubkeyMap.set(new Map())
|
||||||
@@ -0,0 +1,278 @@
|
|||||||
|
<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}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type {Track} from "livekit-client"
|
||||||
|
import cx from "classnames"
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
track: Track
|
||||||
|
muted?: boolean
|
||||||
|
fit?: "cover" | "contain"
|
||||||
|
class?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const {track, muted = true, fit = "cover", class: className = ""}: Props = $props()
|
||||||
|
|
||||||
|
let videoElement = $state<HTMLVideoElement | undefined>()
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
const element = videoElement
|
||||||
|
const activeTrack = track
|
||||||
|
if (!element) return
|
||||||
|
activeTrack.attach(element)
|
||||||
|
return () => {
|
||||||
|
activeTrack.detach(element)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<video
|
||||||
|
bind:this={videoElement}
|
||||||
|
class={cx("h-full w-full", fit === "contain" ? "object-contain" : "object-cover", className)}
|
||||||
|
playsinline
|
||||||
|
{muted}></video>
|
||||||
@@ -7,13 +7,8 @@
|
|||||||
import ModalHeader from "@lib/components/ModalHeader.svelte"
|
import ModalHeader from "@lib/components/ModalHeader.svelte"
|
||||||
import ModalSubtitle from "@lib/components/ModalSubtitle.svelte"
|
import ModalSubtitle from "@lib/components/ModalSubtitle.svelte"
|
||||||
import ModalTitle from "@lib/components/ModalTitle.svelte"
|
import ModalTitle from "@lib/components/ModalTitle.svelte"
|
||||||
import {
|
import {currentVoiceSession, type VoiceSession} from "@app/call/stores"
|
||||||
currentVoiceSession,
|
import {DeviceKind, supportsAudioOutputSelection, switchVoiceActiveDevice} from "@app/call/voice"
|
||||||
DeviceKind,
|
|
||||||
supportsAudioOutputSelection,
|
|
||||||
switchVoiceActiveDevice,
|
|
||||||
type VoiceSession,
|
|
||||||
} from "@app/voice"
|
|
||||||
import {popModal} from "@app/util/modal"
|
import {popModal} from "@app/util/modal"
|
||||||
|
|
||||||
const selectValueForActiveDevice = (session: VoiceSession, kind: DeviceKind): string => {
|
const selectValueForActiveDevice = (session: VoiceSession, kind: DeviceKind): string => {
|
||||||
@@ -26,8 +21,10 @@
|
|||||||
|
|
||||||
let audioInputs = $state<MediaDeviceInfo[]>([])
|
let audioInputs = $state<MediaDeviceInfo[]>([])
|
||||||
let audioOutputs = $state<MediaDeviceInfo[]>([])
|
let audioOutputs = $state<MediaDeviceInfo[]>([])
|
||||||
|
let videoInputs = $state<MediaDeviceInfo[]>([])
|
||||||
let selectedInput = $state("")
|
let selectedInput = $state("")
|
||||||
let selectedOutput = $state("")
|
let selectedOutput = $state("")
|
||||||
|
let selectedVideo = $state("")
|
||||||
|
|
||||||
const loadDevices = async () => {
|
const loadDevices = async () => {
|
||||||
if (!navigator.mediaDevices?.enumerateDevices) return
|
if (!navigator.mediaDevices?.enumerateDevices) return
|
||||||
@@ -35,9 +32,11 @@
|
|||||||
const devices = await navigator.mediaDevices.enumerateDevices()
|
const devices = await navigator.mediaDevices.enumerateDevices()
|
||||||
audioInputs = devices.filter(d => d.kind === "audioinput")
|
audioInputs = devices.filter(d => d.kind === "audioinput")
|
||||||
audioOutputs = devices.filter(d => d.kind === "audiooutput")
|
audioOutputs = devices.filter(d => d.kind === "audiooutput")
|
||||||
|
videoInputs = devices.filter(d => d.kind === "videoinput")
|
||||||
} catch {
|
} catch {
|
||||||
audioInputs = []
|
audioInputs = []
|
||||||
audioOutputs = []
|
audioOutputs = []
|
||||||
|
videoInputs = []
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -55,6 +54,7 @@
|
|||||||
}
|
}
|
||||||
selectedInput = selectValueForActiveDevice(session, DeviceKind.AudioInput)
|
selectedInput = selectValueForActiveDevice(session, DeviceKind.AudioInput)
|
||||||
selectedOutput = selectValueForActiveDevice(session, DeviceKind.AudioOutput)
|
selectedOutput = selectValueForActiveDevice(session, DeviceKind.AudioOutput)
|
||||||
|
selectedVideo = selectValueForActiveDevice(session, DeviceKind.VideoInput)
|
||||||
})
|
})
|
||||||
|
|
||||||
const onInputChange = () => {
|
const onInputChange = () => {
|
||||||
@@ -65,6 +65,10 @@
|
|||||||
void switchVoiceActiveDevice(DeviceKind.AudioOutput, selectedOutput)
|
void switchVoiceActiveDevice(DeviceKind.AudioOutput, selectedOutput)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const onVideoChange = () => {
|
||||||
|
void switchVoiceActiveDevice(DeviceKind.VideoInput, selectedVideo)
|
||||||
|
}
|
||||||
|
|
||||||
const onDone = () => {
|
const onDone = () => {
|
||||||
popModal()
|
popModal()
|
||||||
}
|
}
|
||||||
@@ -76,8 +80,8 @@
|
|||||||
<Modal>
|
<Modal>
|
||||||
<ModalBody>
|
<ModalBody>
|
||||||
<ModalHeader>
|
<ModalHeader>
|
||||||
<ModalTitle>Audio settings</ModalTitle>
|
<ModalTitle>Call settings</ModalTitle>
|
||||||
<ModalSubtitle>Choose microphone and speaker for this call.</ModalSubtitle>
|
<ModalSubtitle>Microphone, speaker, and camera for this call.</ModalSubtitle>
|
||||||
</ModalHeader>
|
</ModalHeader>
|
||||||
<div class="flex flex-col gap-4 pt-2">
|
<div class="flex flex-col gap-4 pt-2">
|
||||||
<FieldInline>
|
<FieldInline>
|
||||||
@@ -120,6 +124,25 @@
|
|||||||
{/snippet}
|
{/snippet}
|
||||||
</FieldInline>
|
</FieldInline>
|
||||||
{/if}
|
{/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>
|
</div>
|
||||||
</ModalBody>
|
</ModalBody>
|
||||||
<ModalFooter>
|
<ModalFooter>
|
||||||
|
|||||||
@@ -12,14 +12,13 @@
|
|||||||
import {makeRoomId} from "@app/core/state"
|
import {makeRoomId} from "@app/core/state"
|
||||||
import {
|
import {
|
||||||
VoiceState,
|
VoiceState,
|
||||||
deriveVoiceParticipants,
|
|
||||||
cancelJoinVoiceRoom,
|
|
||||||
currentVoiceRoom,
|
currentVoiceRoom,
|
||||||
voiceState,
|
|
||||||
isParticipantSpeaking,
|
isParticipantSpeaking,
|
||||||
participantKey,
|
participantKey,
|
||||||
|
voiceState,
|
||||||
type VoiceParticipant,
|
type VoiceParticipant,
|
||||||
} from "@app/voice"
|
} from "@app/call/stores"
|
||||||
|
import {cancelJoinVoiceRoom, deriveVoiceParticipants} from "@app/call/voice"
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
url: string
|
url: string
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
import ModalTitle from "@lib/components/ModalTitle.svelte"
|
import ModalTitle from "@lib/components/ModalTitle.svelte"
|
||||||
import {AbortError, TimeoutError} from "$lib/util"
|
import {AbortError, TimeoutError} from "$lib/util"
|
||||||
import {displayRoom} from "@app/core/state"
|
import {displayRoom} from "@app/core/state"
|
||||||
import {joinVoiceRoom} from "@app/voice"
|
import {joinVoiceRoom} from "@app/call/voice"
|
||||||
import {popModal} from "@app/util/modal"
|
import {popModal} from "@app/util/modal"
|
||||||
import {pushToast} from "@app/util/toast"
|
import {pushToast} from "@app/util/toast"
|
||||||
|
|
||||||
|
|||||||
@@ -1,15 +1,20 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import {readable} from "svelte/store"
|
import {readable} from "svelte/store"
|
||||||
import {fly} from "svelte/transition"
|
import {fade, fly} from "svelte/transition"
|
||||||
import {goto} from "$app/navigation"
|
import {goto} from "$app/navigation"
|
||||||
import {page} from "$app/stores"
|
import {page} from "$app/stores"
|
||||||
|
import cx from "classnames"
|
||||||
import {displayRelayUrl} from "@welshman/util"
|
import {displayRelayUrl} from "@welshman/util"
|
||||||
import Microphone from "@assets/icons/microphone.svg?dataurl"
|
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 PhoneRounded from "@assets/icons/phone-rounded.svg?dataurl"
|
||||||
import PhoneCallingRounded from "@assets/icons/phone-calling-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 CloseCircle from "@assets/icons/close-circle.svg?dataurl"
|
||||||
import Settings from "@assets/icons/settings.svg?dataurl"
|
import Settings from "@assets/icons/settings.svg?dataurl"
|
||||||
|
import {Capacitor} from "@capacitor/core"
|
||||||
import Icon from "@lib/components/Icon.svelte"
|
import Icon from "@lib/components/Icon.svelte"
|
||||||
import Button from "@lib/components/Button.svelte"
|
import Button from "@lib/components/Button.svelte"
|
||||||
import VoiceCallAudioSettingsDialog from "@app/components/VoiceCallAudioSettingsDialog.svelte"
|
import VoiceCallAudioSettingsDialog from "@app/components/VoiceCallAudioSettingsDialog.svelte"
|
||||||
@@ -23,16 +28,23 @@
|
|||||||
type Room,
|
type Room,
|
||||||
} from "@app/core/state"
|
} from "@app/core/state"
|
||||||
import {pushModal} from "@app/util/modal"
|
import {pushModal} from "@app/util/modal"
|
||||||
|
import {notifications} from "@app/util/notifications"
|
||||||
import {makeRoomPath} from "@app/util/routes"
|
import {makeRoomPath} from "@app/util/routes"
|
||||||
|
import {
|
||||||
|
VideoCallLayout,
|
||||||
|
isDesktopLayout,
|
||||||
|
toggleCamera,
|
||||||
|
toggleScreenShare,
|
||||||
|
videoCallLayout,
|
||||||
|
} from "@app/call/video"
|
||||||
import {
|
import {
|
||||||
VoiceState,
|
VoiceState,
|
||||||
currentVoiceSession,
|
currentVoiceSession,
|
||||||
currentVoiceRoom,
|
currentVoiceRoom,
|
||||||
voiceState,
|
voiceState,
|
||||||
leaveVoiceRoom,
|
isLocalSpeaking,
|
||||||
toggleMute,
|
} from "@app/call/stores"
|
||||||
cancelJoinVoiceRoom,
|
import {cancelJoinVoiceRoom, leaveVoiceRoom, toggleMute} from "@app/call/voice"
|
||||||
} from "@app/voice"
|
|
||||||
|
|
||||||
const {relay, h} = $derived($page.params)
|
const {relay, h} = $derived($page.params)
|
||||||
const url = $derived(relay ? decodeRelay(relay) : undefined)
|
const url = $derived(relay ? decodeRelay(relay) : undefined)
|
||||||
@@ -41,6 +53,14 @@
|
|||||||
)
|
)
|
||||||
const routeDisplayedRoom = $derived($displayedRoomStore)
|
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 => {
|
const targetRoom = $derived.by((): Room | undefined => {
|
||||||
if ($voiceState === VoiceState.Joining || $voiceState === VoiceState.Connected) {
|
if ($voiceState === VoiceState.Joining || $voiceState === VoiceState.Connected) {
|
||||||
return $currentVoiceRoom
|
return $currentVoiceRoom
|
||||||
@@ -66,9 +86,45 @@
|
|||||||
pushModal(VoiceRoomJoinDialog, {url: targetRoom.url, h: targetRoom.h})
|
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)
|
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>
|
</script>
|
||||||
|
|
||||||
{#if targetRoom}
|
{#if targetRoom}
|
||||||
@@ -76,6 +132,12 @@
|
|||||||
in:fly={{y: 60, duration: 350}}
|
in:fly={{y: 60, duration: 350}}
|
||||||
out:fly={{y: 60, duration: 250}}
|
out:fly={{y: 60, duration: 250}}
|
||||||
class="flex flex-col gap-2 rounded-box bg-base-100 p-3">
|
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">
|
<div class="flex flex-col gap-0.5">
|
||||||
{#if $voiceState === VoiceState.Joining}
|
{#if $voiceState === VoiceState.Joining}
|
||||||
<span class="text-sm font-semibold text-warning">Joining...</span>
|
<span class="text-sm font-semibold text-warning">Joining...</span>
|
||||||
@@ -88,7 +150,29 @@
|
|||||||
{roomName} / {spaceName}
|
{roomName} / {spaceName}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-1">
|
</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}
|
{#if $voiceState === VoiceState.Joining}
|
||||||
<span class="loading loading-spinner loading-sm"></span>
|
<span class="loading loading-spinner loading-sm"></span>
|
||||||
<Button
|
<Button
|
||||||
@@ -100,16 +184,45 @@
|
|||||||
{:else if $voiceState === VoiceState.Connected && $currentVoiceSession}
|
{:else if $voiceState === VoiceState.Connected && $currentVoiceSession}
|
||||||
<Button
|
<Button
|
||||||
data-tip={$currentVoiceSession.muted ? "Unmute" : "Mute"}
|
data-tip={$currentVoiceSession.muted ? "Unmute" : "Mute"}
|
||||||
class="center tooltip tooltip-top btn btn-sm btn-square {$currentVoiceSession.muted
|
class={cx(
|
||||||
? 'btn-error'
|
mediaToggleClass,
|
||||||
: 'btn-ghost'}"
|
"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}>
|
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} />
|
||||||
|
{#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>
|
||||||
<Button
|
<Button
|
||||||
data-tip="Audio settings"
|
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"
|
class="center tooltip tooltip-top btn btn-sm btn-square btn-ghost"
|
||||||
onclick={openAudioSettings}>
|
onclick={openCallSettings}>
|
||||||
<Icon icon={Settings} size={4} />
|
<Icon icon={Settings} size={4} />
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ import {
|
|||||||
loadFeedsForPubkey,
|
loadFeedsForPubkey,
|
||||||
} from "@app/core/state"
|
} from "@app/core/state"
|
||||||
import {hasBlossomSupport} from "@app/core/commands"
|
import {hasBlossomSupport} from "@app/core/commands"
|
||||||
import {LIVEKIT_PARTICIPANTS} from "@app/voice"
|
import {LIVEKIT_PARTICIPANTS} from "@app/call/voice"
|
||||||
|
|
||||||
// Utils
|
// Utils
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
style?: string
|
style?: string
|
||||||
disabled?: boolean
|
disabled?: boolean
|
||||||
"data-tip"?: string
|
"data-tip"?: string
|
||||||
|
"aria-pressed"?: boolean
|
||||||
} = $props()
|
} = $props()
|
||||||
|
|
||||||
const className = $derived(`text-left ${restProps.class}`)
|
const className = $derived(`text-left ${restProps.class}`)
|
||||||
|
|||||||
@@ -10,7 +10,12 @@
|
|||||||
|
|
||||||
let {children, element = $bindable(), ...props}: Props = $props()
|
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>
|
</script>
|
||||||
|
|
||||||
<div {...props} bind:this={element} data-component="PageContent" class={className}>
|
<div {...props} bind:this={element} data-component="PageContent" class={className}>
|
||||||
|
|||||||
@@ -1,7 +1,14 @@
|
|||||||
<script>
|
<script lang="ts">
|
||||||
|
import type {Snippet} from "svelte"
|
||||||
import {page} from "$app/stores"
|
import {page} from "$app/stores"
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
children?: Snippet
|
||||||
|
}
|
||||||
|
|
||||||
|
const {children}: Props = $props()
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#key $page.url.searchParams.get("at")}
|
{#key $page.url.searchParams.get("at")}
|
||||||
<slot />
|
{@render children?.()}
|
||||||
{/key}
|
{/key}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
import ClockCircle from "@assets/icons/clock-circle.svg?dataurl"
|
import ClockCircle from "@assets/icons/clock-circle.svg?dataurl"
|
||||||
import InfoCircle from "@assets/icons/info-circle.svg?dataurl"
|
import InfoCircle from "@assets/icons/info-circle.svg?dataurl"
|
||||||
import Login2 from "@assets/icons/login-3.svg?dataurl"
|
import Login2 from "@assets/icons/login-3.svg?dataurl"
|
||||||
|
import cx from "classnames"
|
||||||
import {slide, fade, fly} from "@lib/transition"
|
import {slide, fade, fly} from "@lib/transition"
|
||||||
import Button from "@lib/components/Button.svelte"
|
import Button from "@lib/components/Button.svelte"
|
||||||
import Divider from "@lib/components/Divider.svelte"
|
import Divider from "@lib/components/Divider.svelte"
|
||||||
@@ -43,7 +44,9 @@
|
|||||||
userSettingsValues,
|
userSettingsValues,
|
||||||
} from "@app/core/state"
|
} from "@app/core/state"
|
||||||
import VoiceWidget from "@app/components/VoiceWidget.svelte"
|
import VoiceWidget from "@app/components/VoiceWidget.svelte"
|
||||||
import {VoiceState, voiceState} from "@app/voice"
|
import VideoCallContent from "@app/components/VideoCallContent.svelte"
|
||||||
|
import {VoiceState, currentVoiceRoom, voiceState} from "@app/call/stores"
|
||||||
|
import {VideoCallLayout, videoCallLayout, videoTileCount} from "@app/call/video"
|
||||||
import {makeFeed} from "@app/core/requests"
|
import {makeFeed} from "@app/core/requests"
|
||||||
import {popKey} from "@lib/implicit"
|
import {popKey} from "@lib/implicit"
|
||||||
import {checked} from "@app/util/notifications"
|
import {checked} from "@app/util/notifications"
|
||||||
@@ -56,6 +59,49 @@
|
|||||||
const url = decodeRelay(relay)
|
const url = decodeRelay(relay)
|
||||||
const room = deriveRoom(url, h)
|
const room = deriveRoom(url, h)
|
||||||
const isVoiceRoom = $derived(getRoomType($room) === RoomType.Voice)
|
const isVoiceRoom = $derived(getRoomType($room) === RoomType.Voice)
|
||||||
|
|
||||||
|
const voiceConnectedHere = $derived(
|
||||||
|
isVoiceRoom &&
|
||||||
|
$voiceState === VoiceState.Connected &&
|
||||||
|
$currentVoiceRoom?.url === url &&
|
||||||
|
$currentVoiceRoom?.h === h,
|
||||||
|
)
|
||||||
|
|
||||||
|
const showMobileVideoPanel = $derived(
|
||||||
|
isVoiceRoom &&
|
||||||
|
$voiceState === VoiceState.Connected &&
|
||||||
|
$videoCallLayout === VideoCallLayout.Video,
|
||||||
|
)
|
||||||
|
|
||||||
|
const pageContentHiddenDesktopVideoOnly = $derived(
|
||||||
|
voiceConnectedHere && $videoCallLayout === VideoCallLayout.Video,
|
||||||
|
)
|
||||||
|
|
||||||
|
let prevVideoTileCount = $state(0)
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if ($voiceState !== VoiceState.Connected) {
|
||||||
|
videoCallLayout.set(VideoCallLayout.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) {
|
||||||
|
videoCallLayout.set(VideoCallLayout.Video)
|
||||||
|
}
|
||||||
|
if (prevVideoTileCount >= 1 && n === 0 && $videoCallLayout === VideoCallLayout.Split) {
|
||||||
|
videoCallLayout.set(VideoCallLayout.Chat)
|
||||||
|
}
|
||||||
|
prevVideoTileCount = n
|
||||||
|
})
|
||||||
const shouldProtect = canEnforceNip70(url)
|
const shouldProtect = canEnforceNip70(url)
|
||||||
const membershipStatus = deriveUserRoomMembershipStatus(url, h)
|
const membershipStatus = deriveUserRoomMembershipStatus(url, h)
|
||||||
const at = $derived(parseInt($page.url.searchParams.get("at")!))
|
const at = $derived(parseInt($page.url.searchParams.get("at")!))
|
||||||
@@ -364,7 +410,37 @@
|
|||||||
{/snippet}
|
{/snippet}
|
||||||
</SpaceBar>
|
</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 && $videoCallLayout === VideoCallLayout.Split && "md:flex-row",
|
||||||
|
)}>
|
||||||
|
{#if voiceConnectedHere}
|
||||||
|
<VideoCallContent
|
||||||
|
layout={$videoCallLayout}
|
||||||
|
{url}
|
||||||
|
{h}
|
||||||
|
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 && $videoCallLayout === VideoCallLayout.Video && "md:hidden",
|
||||||
|
)}>
|
||||||
|
{#if isVoiceRoom && $voiceState === VoiceState.Connected}
|
||||||
|
<VideoCallContent layout={$videoCallLayout} mobile {url} {h} 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}
|
{#if $room.isPrivate && $membershipStatus !== MembershipStatus.Granted}
|
||||||
<div class="py-20">
|
<div class="py-20">
|
||||||
<div class="card2 col-8 m-auto max-w-md items-center text-center">
|
<div class="card2 col-8 m-auto max-w-md items-center text-center">
|
||||||
@@ -398,9 +474,9 @@
|
|||||||
{id}
|
{id}
|
||||||
class="flex items-center py-2 text-xs transition-colors"
|
class="flex items-center py-2 text-xs transition-colors"
|
||||||
class:opacity-0={showFixedNewMessages}>
|
class:opacity-0={showFixedNewMessages}>
|
||||||
<div class="h-px grow bg-primary"></div>
|
<div class="h-px flex-grow bg-primary"></div>
|
||||||
<p class="rounded-full bg-primary px-2 py-1 text-primary-content">New Messages</p>
|
<p class="rounded-full bg-primary px-2 py-1 text-primary-content">New Messages</p>
|
||||||
<div class="h-px grow bg-primary"></div>
|
<div class="h-px flex-grow bg-primary"></div>
|
||||||
</div>
|
</div>
|
||||||
{:else if type === "date"}
|
{:else if type === "date"}
|
||||||
<Divider>{value}</Divider>
|
<Divider>{value}</Divider>
|
||||||
@@ -433,7 +509,12 @@
|
|||||||
<div class="h-screen"></div>
|
<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={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">
|
<div class="chat__compose-inner min-w-0 flex-1">
|
||||||
{#if $room.isPrivate && $membershipStatus !== MembershipStatus.Granted}
|
{#if $room.isPrivate && $membershipStatus !== MembershipStatus.Granted}
|
||||||
<!-- pass -->
|
<!-- pass -->
|
||||||
@@ -481,11 +562,17 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{#if isVoiceRoom || $voiceState === VoiceState.Joining || $voiceState === VoiceState.Connected}
|
{#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 />
|
<VoiceWidget />
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{#if showScrollButton}
|
{#if showScrollButton}
|
||||||
<div in:fade class="chat__scroll-down">
|
<div in:fade class="chat__scroll-down">
|
||||||
|
|||||||
Reference in New Issue
Block a user