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 Alpine from 'alpinejs';
|
||||||
import { ConnectionState, Room, RoomEvent, Track } from 'livekit-client';
|
import { ConnectionState, Room, RoomEvent, Track, TrackEvent } from 'livekit-client';
|
||||||
|
|
||||||
function readMeetRoomBootState() {
|
function readMeetRoomBootState() {
|
||||||
const el = document.getElementById('meet-config');
|
const el = document.getElementById('meet-config');
|
||||||
@@ -393,7 +393,11 @@ function meetRoom() {
|
|||||||
async startLiveKitSession() {
|
async startLiveKitSession() {
|
||||||
try {
|
try {
|
||||||
room = new Room({
|
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,
|
dynacast: true,
|
||||||
disconnectOnPageLeave: true,
|
disconnectOnPageLeave: true,
|
||||||
});
|
});
|
||||||
@@ -404,11 +408,12 @@ function meetRoom() {
|
|||||||
room.on(RoomEvent.TrackUnsubscribed, (track, publication, participant) => {
|
room.on(RoomEvent.TrackUnsubscribed, (track, publication, participant) => {
|
||||||
track.detach();
|
track.detach();
|
||||||
|
|
||||||
const isScreen = publication?.source === Track.Source.ScreenShare
|
const isScreen = this.isScreenShareSource(publication, track);
|
||||||
|| track.source === Track.Source.ScreenShare;
|
|
||||||
|
|
||||||
if (isScreen) {
|
if (isScreen) {
|
||||||
this.clearScreenShareOverlay();
|
// Temporary unsubscribes must not permanently hide the share —
|
||||||
|
// re-sync from any remaining screen-share publications.
|
||||||
|
this.syncScreenShareOverlay();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -418,7 +423,15 @@ function meetRoom() {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
room.on(RoomEvent.TrackMuted, (publication, participant) => {
|
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();
|
publication.track?.detach();
|
||||||
this.updateParticipantTile(participant);
|
this.updateParticipantTile(participant);
|
||||||
}
|
}
|
||||||
@@ -446,19 +459,28 @@ function meetRoom() {
|
|||||||
if (publication.track?.kind === Track.Kind.Video) {
|
if (publication.track?.kind === Track.Kind.Video) {
|
||||||
this.attachTrack(publication.track, publication, participant);
|
this.attachTrack(publication.track, publication, participant);
|
||||||
}
|
}
|
||||||
|
if (publication.source === Track.Source.ScreenShare && publication.track) {
|
||||||
|
this.guardLocalScreenShare(publication.track);
|
||||||
|
}
|
||||||
this.updateParticipantTile(participant);
|
this.updateParticipantTile(participant);
|
||||||
this.syncLocalMediaState();
|
this.syncLocalMediaState();
|
||||||
});
|
});
|
||||||
room.on(RoomEvent.LocalTrackUnpublished, (publication, participant) => {
|
room.on(RoomEvent.LocalTrackUnpublished, (publication, participant) => {
|
||||||
if (publication.kind === Track.Kind.Video) {
|
if (publication.kind === Track.Kind.Video) {
|
||||||
if (publication.source === Track.Source.ScreenShare) {
|
if (publication.source === Track.Source.ScreenShare) {
|
||||||
this.clearScreenShareOverlay();
|
this.syncScreenShareOverlay();
|
||||||
} else {
|
} else {
|
||||||
this.updateParticipantTile(participant);
|
this.updateParticipantTile(participant);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
this.syncLocalMediaState();
|
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) => {
|
room.on(RoomEvent.MediaDevicesError, (error) => {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
this.needsMediaUnlock = true;
|
this.needsMediaUnlock = true;
|
||||||
@@ -707,15 +729,121 @@ function meetRoom() {
|
|||||||
const local = room.localParticipant;
|
const local = room.localParticipant;
|
||||||
this.isMuted = !local.isMicrophoneEnabled;
|
this.isMuted = !local.isMicrophoneEnabled;
|
||||||
this.isVideoOff = !local.isCameraEnabled;
|
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())
|
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) {
|
isScreenShareSource(publication, track) {
|
||||||
const screen = document.getElementById('screen-share');
|
return publication?.source === Track.Source.ScreenShare
|
||||||
if (screen && !screen.classList.contains('hidden')) {
|
|| track?.source === Track.Source.ScreenShare;
|
||||||
this.clearScreenShareOverlay();
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
participantInitials(name) {
|
participantInitials(name) {
|
||||||
@@ -2403,8 +2531,19 @@ function meetRoom() {
|
|||||||
|
|
||||||
participant.videoTrackPublications.forEach((pub) => {
|
participant.videoTrackPublications.forEach((pub) => {
|
||||||
if (this.audioOnly) return;
|
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);
|
this.attachTrack(pub.track, pub, participant);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -2412,7 +2551,14 @@ function meetRoom() {
|
|||||||
this.updateParticipantTile(participant);
|
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');
|
const screen = document.getElementById('screen-share');
|
||||||
if (!screen) {
|
if (!screen) {
|
||||||
return;
|
return;
|
||||||
@@ -2426,7 +2572,7 @@ function meetRoom() {
|
|||||||
|
|
||||||
if (room?.localParticipant) {
|
if (room?.localParticipant) {
|
||||||
this.isScreenSharing = Array.from(room.localParticipant.videoTrackPublications.values())
|
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 {
|
} else {
|
||||||
this.isScreenSharing = false;
|
this.isScreenSharing = false;
|
||||||
}
|
}
|
||||||
@@ -2466,24 +2612,52 @@ function meetRoom() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const isScreen = publication?.source === Track.Source.ScreenShare
|
const isScreen = this.isScreenShareSource(publication, track);
|
||||||
|| track.source === Track.Source.ScreenShare;
|
|
||||||
|
|
||||||
if (isScreen) {
|
if (isScreen) {
|
||||||
const container = document.getElementById('screen-share');
|
const container = document.getElementById('screen-share');
|
||||||
if (!container) return;
|
if (!container) return;
|
||||||
container.classList.remove('hidden');
|
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 = '';
|
container.innerHTML = '';
|
||||||
const el = track.attach();
|
const el = track.attach();
|
||||||
el.className = 'h-full w-full rounded-xl object-contain';
|
el.className = 'h-full w-full rounded-xl object-contain';
|
||||||
|
el.playsInline = true;
|
||||||
|
el.autoplay = true;
|
||||||
container.appendChild(el);
|
container.appendChild(el);
|
||||||
|
el.play?.().catch(() => {});
|
||||||
|
|
||||||
track.mediaStreamTrack?.addEventListener('ended', () => {
|
if (mediaTrack && !mediaTrack._ladillEndedBound) {
|
||||||
this.clearScreenShareOverlay();
|
mediaTrack._ladillEndedBound = true;
|
||||||
}, { once: 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) {
|
if (participant === room?.localParticipant) {
|
||||||
this.isScreenSharing = true;
|
this.isScreenSharing = true;
|
||||||
|
this.guardLocalScreenShare(track);
|
||||||
}
|
}
|
||||||
|
|
||||||
return;
|
return;
|
||||||
@@ -2633,10 +2807,31 @@ function meetRoom() {
|
|||||||
|
|
||||||
async toggleScreenShare() {
|
async toggleScreenShare() {
|
||||||
if (!room || this.audioOnly || this.audioOnlyPublish || !this.canPublish) return;
|
if (!room || this.audioOnly || this.audioOnlyPublish || !this.canPublish) return;
|
||||||
this.isScreenSharing = !this.isScreenSharing;
|
|
||||||
await room.localParticipant.setScreenShareEnabled(this.isScreenSharing);
|
const enabling = !this.isScreenSharing;
|
||||||
if (!this.isScreenSharing) {
|
try {
|
||||||
this.clearScreenShareOverlay();
|
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