diff --git a/resources/js/meet-room.js b/resources/js/meet-room.js index 9b4eb85..595482c 100644 --- a/resources/js/meet-room.js +++ b/resources/js/meet-room.js @@ -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,15 +729,121 @@ 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); + } }, participantInitials(name) { @@ -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(); } },