Keep screen share visible when the sharer switches apps.
Deploy Ladill Meet / deploy (push) Successful in 44s
Deploy Ladill Meet / deploy (push) Successful in 44s
Stop clearing the overlay on transient mute/unsubscribe, re-attach existing screen tracks for all viewers, and resume LiveKit upstream if the browser briefly mutes the capture while the presenter focuses another window.
This commit is contained in:
+219
-24
@@ -1,5 +1,5 @@
|
||||
import Alpine from 'alpinejs';
|
||||
import { ConnectionState, Room, RoomEvent, Track } from 'livekit-client';
|
||||
import { ConnectionState, Room, RoomEvent, Track, TrackEvent } from 'livekit-client';
|
||||
|
||||
function readMeetRoomBootState() {
|
||||
const el = document.getElementById('meet-config');
|
||||
@@ -393,7 +393,11 @@ function meetRoom() {
|
||||
async startLiveKitSession() {
|
||||
try {
|
||||
room = new Room({
|
||||
adaptiveStream: true,
|
||||
// Keep remote video (including screen share) subscribed when a viewer
|
||||
// backgrounds the Meet tab — otherwise shares "disappear" for some users.
|
||||
adaptiveStream: {
|
||||
pauseVideoInBackground: false,
|
||||
},
|
||||
dynacast: true,
|
||||
disconnectOnPageLeave: true,
|
||||
});
|
||||
@@ -404,11 +408,12 @@ function meetRoom() {
|
||||
room.on(RoomEvent.TrackUnsubscribed, (track, publication, participant) => {
|
||||
track.detach();
|
||||
|
||||
const isScreen = publication?.source === Track.Source.ScreenShare
|
||||
|| track.source === Track.Source.ScreenShare;
|
||||
const isScreen = this.isScreenShareSource(publication, track);
|
||||
|
||||
if (isScreen) {
|
||||
this.clearScreenShareOverlay();
|
||||
// Temporary unsubscribes must not permanently hide the share —
|
||||
// re-sync from any remaining screen-share publications.
|
||||
this.syncScreenShareOverlay();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -418,7 +423,15 @@ function meetRoom() {
|
||||
}
|
||||
});
|
||||
room.on(RoomEvent.TrackMuted, (publication, participant) => {
|
||||
if (publication.kind === Track.Kind.Video && publication.source !== Track.Source.ScreenShare) {
|
||||
// Browser/OS may briefly mute a capture when the sharer switches
|
||||
// apps. Keep the screen-share surface mounted so it does not vanish.
|
||||
if (publication.source === Track.Source.ScreenShare) {
|
||||
if (participant === room.localParticipant) {
|
||||
this.syncLocalMediaState();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (publication.kind === Track.Kind.Video) {
|
||||
publication.track?.detach();
|
||||
this.updateParticipantTile(participant);
|
||||
}
|
||||
@@ -446,19 +459,28 @@ function meetRoom() {
|
||||
if (publication.track?.kind === Track.Kind.Video) {
|
||||
this.attachTrack(publication.track, publication, participant);
|
||||
}
|
||||
if (publication.source === Track.Source.ScreenShare && publication.track) {
|
||||
this.guardLocalScreenShare(publication.track);
|
||||
}
|
||||
this.updateParticipantTile(participant);
|
||||
this.syncLocalMediaState();
|
||||
});
|
||||
room.on(RoomEvent.LocalTrackUnpublished, (publication, participant) => {
|
||||
if (publication.kind === Track.Kind.Video) {
|
||||
if (publication.source === Track.Source.ScreenShare) {
|
||||
this.clearScreenShareOverlay();
|
||||
this.syncScreenShareOverlay();
|
||||
} else {
|
||||
this.updateParticipantTile(participant);
|
||||
}
|
||||
}
|
||||
this.syncLocalMediaState();
|
||||
});
|
||||
room.on(RoomEvent.TrackPublished, (publication, participant) => {
|
||||
// Late joiners / reconnect: screen share is already published.
|
||||
if (publication.source === Track.Source.ScreenShare && publication.track) {
|
||||
this.attachTrack(publication.track, publication, participant);
|
||||
}
|
||||
});
|
||||
room.on(RoomEvent.MediaDevicesError, (error) => {
|
||||
console.error(error);
|
||||
this.needsMediaUnlock = true;
|
||||
@@ -707,14 +729,120 @@ function meetRoom() {
|
||||
const local = room.localParticipant;
|
||||
this.isMuted = !local.isMicrophoneEnabled;
|
||||
this.isVideoOff = !local.isCameraEnabled;
|
||||
// Publication present = still sharing, even if the browser briefly mutes
|
||||
// the capture track while the sharer focuses another window/app.
|
||||
this.isScreenSharing = Array.from(local.videoTrackPublications.values())
|
||||
.some((pub) => pub.source === Track.Source.ScreenShare && pub.track && !pub.isMuted);
|
||||
.some((pub) => pub.source === Track.Source.ScreenShare && pub.track);
|
||||
},
|
||||
|
||||
if (!this.isScreenSharing) {
|
||||
const screen = document.getElementById('screen-share');
|
||||
if (screen && !screen.classList.contains('hidden')) {
|
||||
this.clearScreenShareOverlay();
|
||||
isScreenShareSource(publication, track) {
|
||||
return publication?.source === Track.Source.ScreenShare
|
||||
|| track?.source === Track.Source.ScreenShare;
|
||||
},
|
||||
|
||||
/**
|
||||
* Find an active screen-share publication in the room.
|
||||
* Prefers unmuted tracks but keeps muted ones so the surface does not vanish.
|
||||
*/
|
||||
findActiveScreenShare() {
|
||||
if (!room) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const participants = [room.localParticipant, ...room.remoteParticipants.values()];
|
||||
let mutedFallback = null;
|
||||
|
||||
for (const participant of participants) {
|
||||
for (const publication of participant.videoTrackPublications.values()) {
|
||||
if (publication.source !== Track.Source.ScreenShare || !publication.track) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const candidate = { participant, publication, track: publication.track };
|
||||
if (!publication.isMuted && publication.track.mediaStreamTrack?.readyState !== 'ended') {
|
||||
return candidate;
|
||||
}
|
||||
|
||||
mutedFallback ??= candidate;
|
||||
}
|
||||
}
|
||||
|
||||
return mutedFallback;
|
||||
},
|
||||
|
||||
/**
|
||||
* Keep or restore the screen-share overlay from live room state.
|
||||
* Only hides when nobody is sharing anymore.
|
||||
*/
|
||||
syncScreenShareOverlay() {
|
||||
if (!room || this.audioOnly) {
|
||||
this.clearScreenShareOverlay({ force: true });
|
||||
return;
|
||||
}
|
||||
|
||||
const active = this.findActiveScreenShare();
|
||||
if (!active) {
|
||||
this.clearScreenShareOverlay({ force: true });
|
||||
return;
|
||||
}
|
||||
|
||||
const container = document.getElementById('screen-share');
|
||||
if (!container) {
|
||||
return;
|
||||
}
|
||||
|
||||
const existingVideo = container.querySelector('video');
|
||||
const activeMediaTrack = active.track.mediaStreamTrack;
|
||||
if (existingVideo?.srcObject instanceof MediaStream && activeMediaTrack) {
|
||||
const attached = existingVideo.srcObject.getVideoTracks().some((t) => t.id === activeMediaTrack.id);
|
||||
if (attached) {
|
||||
container.classList.remove('hidden');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
this.attachTrack(active.track, active.publication, active.participant);
|
||||
},
|
||||
|
||||
/**
|
||||
* LiveKit pauses upstream ~5s after a MediaStreamTrack "mute" event (common when
|
||||
* the sharer alt-tabs). Immediately resume so remotes keep receiving the share.
|
||||
*/
|
||||
guardLocalScreenShare(track) {
|
||||
if (!track || track.source !== Track.Source.ScreenShare) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (track._ladillScreenShareGuarded) {
|
||||
return;
|
||||
}
|
||||
track._ladillScreenShareGuarded = true;
|
||||
|
||||
const resume = () => {
|
||||
if (typeof track.resumeUpstream !== 'function') {
|
||||
return;
|
||||
}
|
||||
if (!track.isUpstreamPaused) {
|
||||
return;
|
||||
}
|
||||
track.resumeUpstream().catch((error) => {
|
||||
console.warn('Failed to resume screen-share upstream', error);
|
||||
});
|
||||
};
|
||||
|
||||
track.on(TrackEvent.UpstreamPaused, resume);
|
||||
|
||||
// Cancel LiveKit's debounced pause-on-mute when possible.
|
||||
const mediaTrack = track.mediaStreamTrack;
|
||||
if (mediaTrack) {
|
||||
mediaTrack.addEventListener('mute', () => {
|
||||
track.debouncedTrackMuteHandler?.cancel?.('ladill-keep-screen-share');
|
||||
// If pause already started, reverse it.
|
||||
setTimeout(resume, 0);
|
||||
setTimeout(resume, 600);
|
||||
setTimeout(resume, 5200);
|
||||
});
|
||||
mediaTrack.addEventListener('unmute', resume);
|
||||
}
|
||||
},
|
||||
|
||||
@@ -2403,8 +2531,19 @@ function meetRoom() {
|
||||
|
||||
participant.videoTrackPublications.forEach((pub) => {
|
||||
if (this.audioOnly) return;
|
||||
if (!pub.track) return;
|
||||
|
||||
if (pub.track && !pub.isMuted && pub.source !== Track.Source.ScreenShare) {
|
||||
// Include screen share — previously skipped, so some viewers never saw it
|
||||
// (or could not recover after a temporary mute/unsubscribe).
|
||||
if (pub.source === Track.Source.ScreenShare) {
|
||||
this.attachTrack(pub.track, pub, participant);
|
||||
if (isLocal) {
|
||||
this.guardLocalScreenShare(pub.track);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!pub.isMuted) {
|
||||
this.attachTrack(pub.track, pub, participant);
|
||||
}
|
||||
});
|
||||
@@ -2412,7 +2551,14 @@ function meetRoom() {
|
||||
this.updateParticipantTile(participant);
|
||||
},
|
||||
|
||||
clearScreenShareOverlay() {
|
||||
clearScreenShareOverlay({ force = false } = {}) {
|
||||
// Unless forced (nobody sharing), prefer re-syncing from room state so a
|
||||
// transient event cannot blank the share for remaining viewers.
|
||||
if (!force && this.findActiveScreenShare()) {
|
||||
this.syncScreenShareOverlay();
|
||||
return;
|
||||
}
|
||||
|
||||
const screen = document.getElementById('screen-share');
|
||||
if (!screen) {
|
||||
return;
|
||||
@@ -2426,7 +2572,7 @@ function meetRoom() {
|
||||
|
||||
if (room?.localParticipant) {
|
||||
this.isScreenSharing = Array.from(room.localParticipant.videoTrackPublications.values())
|
||||
.some((pub) => pub.source === Track.Source.ScreenShare && pub.track && !pub.isMuted);
|
||||
.some((pub) => pub.source === Track.Source.ScreenShare && pub.track);
|
||||
} else {
|
||||
this.isScreenSharing = false;
|
||||
}
|
||||
@@ -2466,24 +2612,52 @@ function meetRoom() {
|
||||
return;
|
||||
}
|
||||
|
||||
const isScreen = publication?.source === Track.Source.ScreenShare
|
||||
|| track.source === Track.Source.ScreenShare;
|
||||
const isScreen = this.isScreenShareSource(publication, track);
|
||||
|
||||
if (isScreen) {
|
||||
const container = document.getElementById('screen-share');
|
||||
if (!container) return;
|
||||
container.classList.remove('hidden');
|
||||
|
||||
// Avoid tearing down a healthy attachment of the same track.
|
||||
const existingVideo = container.querySelector('video');
|
||||
const mediaTrack = track.mediaStreamTrack;
|
||||
if (existingVideo?.srcObject instanceof MediaStream && mediaTrack) {
|
||||
const alreadyAttached = existingVideo.srcObject.getVideoTracks()
|
||||
.some((t) => t.id === mediaTrack.id);
|
||||
if (alreadyAttached) {
|
||||
existingVideo.play?.().catch(() => {});
|
||||
if (participant === room?.localParticipant) {
|
||||
this.isScreenSharing = true;
|
||||
this.guardLocalScreenShare(track);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
container.innerHTML = '';
|
||||
const el = track.attach();
|
||||
el.className = 'h-full w-full rounded-xl object-contain';
|
||||
el.playsInline = true;
|
||||
el.autoplay = true;
|
||||
container.appendChild(el);
|
||||
el.play?.().catch(() => {});
|
||||
|
||||
track.mediaStreamTrack?.addEventListener('ended', () => {
|
||||
this.clearScreenShareOverlay();
|
||||
}, { once: true });
|
||||
if (mediaTrack && !mediaTrack._ladillEndedBound) {
|
||||
mediaTrack._ladillEndedBound = true;
|
||||
mediaTrack.addEventListener('ended', () => {
|
||||
// User stopped sharing via the browser chrome, or capture truly ended.
|
||||
if (participant === room?.localParticipant) {
|
||||
this.isScreenSharing = false;
|
||||
room?.localParticipant?.setScreenShareEnabled(false).catch(() => {});
|
||||
}
|
||||
this.syncScreenShareOverlay();
|
||||
});
|
||||
}
|
||||
|
||||
if (participant === room?.localParticipant) {
|
||||
this.isScreenSharing = true;
|
||||
this.guardLocalScreenShare(track);
|
||||
}
|
||||
|
||||
return;
|
||||
@@ -2633,10 +2807,31 @@ function meetRoom() {
|
||||
|
||||
async toggleScreenShare() {
|
||||
if (!room || this.audioOnly || this.audioOnlyPublish || !this.canPublish) return;
|
||||
this.isScreenSharing = !this.isScreenSharing;
|
||||
await room.localParticipant.setScreenShareEnabled(this.isScreenSharing);
|
||||
if (!this.isScreenSharing) {
|
||||
this.clearScreenShareOverlay();
|
||||
|
||||
const enabling = !this.isScreenSharing;
|
||||
try {
|
||||
if (enabling) {
|
||||
await room.localParticipant.setScreenShareEnabled(true, {
|
||||
// Prefer detail over motion for slides/docs; contentHint helps some browsers
|
||||
// keep producing frames while the Meet tab is not focused.
|
||||
contentHint: 'detail',
|
||||
resolution: { width: 1920, height: 1080, frameRate: 15 },
|
||||
});
|
||||
const pub = room.localParticipant.getTrackPublication(Track.Source.ScreenShare);
|
||||
if (pub?.track) {
|
||||
this.guardLocalScreenShare(pub.track);
|
||||
this.attachTrack(pub.track, pub, room.localParticipant);
|
||||
}
|
||||
this.isScreenSharing = Boolean(pub?.track);
|
||||
} else {
|
||||
await room.localParticipant.setScreenShareEnabled(false);
|
||||
this.isScreenSharing = false;
|
||||
this.syncScreenShareOverlay();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
this.syncLocalMediaState();
|
||||
this.syncScreenShareOverlay();
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
Reference in New Issue
Block a user