Keep screen share visible when the sharer switches apps.
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:
isaacclad
2026-07-16 00:11:36 +00:00
parent 86fa37b9fd
commit 3f0c94cae7
+219 -24
View File
@@ -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();
}
},