Add basic screen sharing

This commit is contained in:
mplorentz
2026-03-26 10:59:50 -04:00
parent edceb92acc
commit eb8117cc95
4 changed files with 136 additions and 27 deletions
+72 -13
View File
@@ -2,7 +2,14 @@
* Voice rooms via LiveKit. Note: Voice does not work on localhost in Firefox
* (ICE candidate gathering fails). Use Chrome or test from deployed HTTPS.
*/
import {DisconnectReason, Room, RoomEvent, Track} from "livekit-client"
import {
DisconnectReason,
LocalParticipant,
LocalTrackPublication,
Room,
RoomEvent,
Track,
} from "livekit-client"
import {derived, get, writable} from "svelte/store"
import {map, removeUndefined, uniqBy} from "@welshman/lib"
import type {TrustedEvent} from "@welshman/util"
@@ -23,6 +30,7 @@ export type VoiceSession = {
room: Room
muted: boolean
cameraOn: boolean
screenShareOn: boolean
}
export type Pubkey = string
@@ -189,6 +197,18 @@ const onParticipantDisconnected = (participant: {identity: string}) => {
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})
bumpVideoCallLayoutRevision()
}
let joinAbortController: AbortController | undefined
export const cancelJoinVoiceRoom = () => {
@@ -221,6 +241,7 @@ export const joinVoiceRoom = async (url: string, h: string): Promise<void> => {
room.on(RoomEvent.ParticipantDisconnected, onParticipantDisconnected)
room.on(RoomEvent.TrackSubscribed, onTrackSubscribed)
room.on(RoomEvent.TrackUnsubscribed, onTrackUnsubscribed)
room.on(RoomEvent.LocalTrackUnpublished, onLocalTrackUnpublished)
room.on(RoomEvent.ActiveSpeakersChanged, onActiveSpeakersChanged)
try {
@@ -250,7 +271,7 @@ export const joinVoiceRoom = async (url: string, h: string): Promise<void> => {
pushToast({theme: "error", message: "Could not access microphone"})
}
currentVoiceSession.set({url, h, room, muted, cameraOn: false})
currentVoiceSession.set({url, h, room, muted, cameraOn: false, screenShareOn: false})
voiceState.set("connected")
playJoinSound()
} catch (e) {
@@ -277,6 +298,14 @@ export const leaveVoiceRoom = async () => {
}
}
if (session.screenShareOn) {
try {
await session.room.localParticipant.setScreenShareEnabled(false)
} catch {
/* pass */
}
}
voiceState.set("disconnected")
videoCallLayoutRevision.set(0)
currentVoiceSession.set(undefined)
@@ -310,26 +339,29 @@ export const toggleMute = async () => {
}
}
const roomHasSubscribedRemoteCamera = (room: Room): boolean => {
const VISUAL_SOURCES = [Track.Source.Camera, Track.Source.ScreenShare] as const
const roomHasSubscribedRemoteVisual = (room: Room): boolean => {
for (const p of room.remoteParticipants.values()) {
const pub = p.getTrackPublication(Track.Source.Camera)
if (pub?.isSubscribed && pub.track) return true
for (const source of VISUAL_SOURCES) {
const pub = p.getTrackPublication(source)
if (pub?.isSubscribed && pub.track) return true
}
}
return false
}
/** True when the connected session has local camera on or any subscribed remote camera track. */
/** 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 !== "connected" || !$session) return false
if ($session.cameraOn) return true
return roomHasSubscribedRemoteCamera($session.room)
if ($session.cameraOn || $session.screenShareOn) return true
return roomHasSubscribedRemoteVisual($session.room)
},
)
/** Live camera tracks (local + remote) for layout automation. */
const countLiveCameraFeeds = (session: VoiceSession): number => {
const countLiveVisualFeeds = (session: VoiceSession): number => {
const room = session.room
let n = 0
const lp = room.localParticipant
@@ -337,9 +369,15 @@ const countLiveCameraFeeds = (session: VoiceSession): number => {
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()) {
const pub = rp.getTrackPublication(Track.Source.Camera)
if (pub?.isSubscribed && pub.track) n += 1
for (const source of VISUAL_SOURCES) {
const pub = rp.getTrackPublication(source)
if (pub?.isSubscribed && pub.track) n += 1
}
}
return n
}
@@ -348,7 +386,7 @@ export const videoTileCount = derived(
[currentVoiceSession, voiceState, videoCallLayoutRevision],
([$session, $state, _rev]) => {
if ($state !== "connected" || !$session) return 0
return countLiveCameraFeeds($session)
return countLiveVisualFeeds($session)
},
)
@@ -372,3 +410,24 @@ export const toggleCamera = async () => {
pushToast({theme: "error", message: "Could not access camera"})
}
}
export const toggleScreenShare = async () => {
const session = get(currentVoiceSession)
if (!session) return
const screenShareOn = !session.screenShareOn
if (!screenShareOn) {
session.room.localParticipant.setScreenShareEnabled(false)
currentVoiceSession.set({...session, screenShareOn})
bumpVideoCallLayoutRevision()
return
}
try {
await session.room.localParticipant.setScreenShareEnabled(true)
currentVoiceSession.set({...session, screenShareOn})
bumpVideoCallLayoutRevision()
} catch (e) {
pushToast({theme: "error", message: "Could not start screen sharing"})
}
}