diff --git a/public/audios/clapping.wav b/public/audios/clapping.wav new file mode 100644 index 0000000..8bb60bc Binary files /dev/null and b/public/audios/clapping.wav differ diff --git a/resources/css/app.css b/resources/css/app.css index 674431b..f31beb0 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -136,11 +136,16 @@ html.meet-room-page { border-top: 0; } -.meet-room-page .meet-stage--panel #meet-stage-spotlight, -.meet-room-page .meet-stage--panel #meet-audience-strip { +.meet-room-page .meet-stage--panel #meet-stage-spotlight { display: none; } +.meet-room-page .meet-stage--panel .meet-stage-panel-grid { + flex: 1 1 0; + min-height: 0; + height: auto; +} + .meet-room-page .meet-stage-panel-grid { display: grid; height: 100%; diff --git a/resources/js/meet-room.js b/resources/js/meet-room.js index d252ac8..7dee5cd 100644 --- a/resources/js/meet-room.js +++ b/resources/js/meet-room.js @@ -19,6 +19,15 @@ function meetRoom() { let initialized = false; let recordingMediaRecorder = null; let recordingChunks = []; + let seenReactionKeys = new Set(); + let applauseClapTimes = []; + let applauseAudio = null; + let lastApplauseSoundAt = 0; + + const APPLAUSE_WINDOW_MS = 4000; + const APPLAUSE_MIN_CLAPPERS = 2; + const APPLAUSE_SOUND_COOLDOWN_MS = 3000; + const APPLAUSE_EMOJIS = new Set(['👍', '👏', '🙌']); return { isMuted: true, @@ -77,6 +86,7 @@ function meetRoom() { activeSpeakerIds: [], roomHost: null, floatingReactions: [], + reactionsInitialized: false, pollTimer: null, config: {}, @@ -120,6 +130,8 @@ function meetRoom() { this.passcode = el.dataset.passcode || ''; this.roomTitle = el.dataset.roomTitle || ''; + this.initApplauseAudio(el.dataset.applauseAudio || ''); + this.isHost = el.dataset.isHost === '1'; this.canPublish = el.dataset.canPublish !== '0'; this.isWebinar = el.dataset.isWebinar === '1'; @@ -686,16 +698,40 @@ function meetRoom() { return this.inCallParticipants() .filter((person) => person.uuid !== spotlightUuid) - .sort((a, b) => { - const aSpeaker = this.isSpeakerPerson(a); - const bSpeaker = this.isSpeakerPerson(b); + .sort((a, b) => this.compareStripParticipants(a, b)); + }, - if (aSpeaker !== bSpeaker) { - return aSpeaker ? -1 : 1; + panelStripParticipants() { + const onPanelRefs = new Set(this.getActivePanelSpeakerRefs()); + + return this.inCallParticipants() + .filter((person) => { + if (!this.isSpeakerPerson(person)) { + return true; } - return a.display_name.localeCompare(b.display_name); - }); + return !person.user_ref || !onPanelRefs.has(person.user_ref); + }) + .sort((a, b) => this.compareStripParticipants(a, b)); + }, + + compareStripParticipants(a, b) { + const aSpeaker = this.isSpeakerPerson(a); + const bSpeaker = this.isSpeakerPerson(b); + + if (aSpeaker !== bSpeaker) { + return aSpeaker ? -1 : 1; + } + + return a.display_name.localeCompare(b.display_name); + }, + + stripParticipants() { + if (this.stageLayout === 'panel') { + return this.panelStripParticipants(); + } + + return this.plenaryStripParticipants(); }, createStripInitials(person) { @@ -841,11 +877,11 @@ function meetRoom() { strip.replaceChildren(); - if (!this.usesStageLayout || this.stageLayout !== 'plenary') { + if (!this.usesStageLayout) { return; } - const audience = this.plenaryStripParticipants(); + const audience = this.stripParticipants(); if (audience.length === 0) { return; } @@ -1644,7 +1680,7 @@ function meetRoom() { }, async toggleMute() { - if (!room) return; + if (!room || !this.canPublish) return; await this.ensureAudioPlayback(); const enabled = room.localParticipant.isMicrophoneEnabled; @@ -1671,7 +1707,7 @@ function meetRoom() { }, async toggleVideo() { - if (!room || this.audioOnly) return; + if (!room || this.audioOnly || !this.canPublish) return; await this.ensureAudioPlayback(); const enabled = room.localParticipant.isCameraEnabled; await room.localParticipant.setCameraEnabled(!enabled); @@ -1690,7 +1726,7 @@ function meetRoom() { }, async toggleScreenShare() { - if (!room || this.audioOnly) return; + if (!room || this.audioOnly || !this.canPublish) return; this.isScreenSharing = !this.isScreenSharing; await room.localParticipant.setScreenShareEnabled(this.isScreenSharing); if (!this.isScreenSharing) { @@ -1844,6 +1880,10 @@ function meetRoom() { body: JSON.stringify({ emoji }), }); this.showFloatingReaction(kind); + + if (kind === 'thumbs') { + this.registerApplauseClap(this.displayName); + } }, async poll() { @@ -1884,6 +1924,7 @@ function meetRoom() { if (data.broadcasts?.length) { this.breakoutBroadcast = data.broadcasts[0].body; } + this.processReactionsFromPoll(data.reactions || []); } catch (e) { // ignore poll errors } @@ -1935,7 +1976,7 @@ function meetRoom() { return 'Multiple speakers shown together in a split-screen grid.'; } - return 'One main speaker on stage. Others appear in the strip below.'; + return ''; }, activePanelName() { @@ -2045,6 +2086,106 @@ function meetRoom() { }, 2000); }, + initApplauseAudio(url) { + if (!url) { + return; + } + + applauseAudio = new Audio(url); + applauseAudio.preload = 'auto'; + }, + + isApplauseEmoji(emoji) { + return APPLAUSE_EMOJIS.has(emoji); + }, + + registerApplauseClap(senderName) { + if (!senderName) { + return; + } + + applauseClapTimes.push({ at: Date.now(), sender: senderName }); + this.maybePlayApplauseSound(); + }, + + maybePlayApplauseSound() { + if (!applauseAudio?.src) { + return; + } + + const now = Date.now(); + applauseClapTimes = applauseClapTimes.filter((entry) => now - entry.at <= APPLAUSE_WINDOW_MS); + + const clappers = new Set(applauseClapTimes.map((entry) => entry.sender)); + if (clappers.size < APPLAUSE_MIN_CLAPPERS) { + return; + } + + if (now - lastApplauseSoundAt < APPLAUSE_SOUND_COOLDOWN_MS) { + return; + } + + const volume = 0.35 + Math.min(clappers.size - APPLAUSE_MIN_CLAPPERS + 1, 4) * 0.12; + this.playApplauseSound(volume); + }, + + playApplauseSound(volume = 0.7) { + if (!applauseAudio?.src) { + return; + } + + try { + applauseAudio.volume = Math.min(1, Math.max(0.2, volume)); + applauseAudio.currentTime = 0; + applauseAudio.play().catch(() => {}); + lastApplauseSoundAt = Date.now(); + } catch { + // Browser blocked playback until user gesture. + } + }, + + processReactionsFromPoll(reactions) { + if (!Array.isArray(reactions)) { + return; + } + + if (!this.reactionsInitialized) { + reactions.forEach((reaction) => { + seenReactionKeys.add(`${reaction.created_at}|${reaction.sender_name}|${reaction.emoji}`); + }); + this.reactionsInitialized = true; + + return; + } + + reactions.forEach((reaction) => { + const key = `${reaction.created_at}|${reaction.sender_name}|${reaction.emoji}`; + if (seenReactionKeys.has(key)) { + return; + } + + seenReactionKeys.add(key); + + if (this.isApplauseEmoji(reaction.emoji)) { + if (reaction.sender_name !== this.displayName) { + this.showFloatingReaction('thumbs'); + } + + this.registerApplauseClap(reaction.sender_name); + + return; + } + + if (reaction.emoji === '❤️' && reaction.sender_name !== this.displayName) { + this.showFloatingReaction('heart'); + } + }); + + if (seenReactionKeys.size > 200) { + seenReactionKeys = new Set([...seenReactionKeys].slice(-100)); + } + }, + headers() { return { 'Content-Type': 'application/json', diff --git a/resources/views/meet/room/show.blade.php b/resources/views/meet/room/show.blade.php index faf79f7..d885fa9 100644 --- a/resources/views/meet/room/show.blade.php +++ b/resources/views/meet/room/show.blade.php @@ -81,6 +81,8 @@ data-join-url="{{ $room->joinUrl() }}" data-room-title="{{ $room->title }}" data-passcode="{{ $room->passcode ?? '' }}" + @php $applauseAudioVer = @filemtime(public_path('audios/clapping.wav')) ?: null; @endphp + data-applause-audio="{{ $applauseAudioVer ? asset('audios/clapping.wav') . '?v=' . $applauseAudioVer : '' }}" data-csrf="{{ csrf_token() }}" data-session-uuid="{{ $session->uuid }}" data-initial-participants='@json($joinedParticipants)' @@ -175,7 +177,7 @@
- @@ -266,6 +268,7 @@