NOTICE: By continued use of this site you understand and agree to the binding Terms of Service and Privacy Policy.
// ==UserScript== // @name Google Meet Grid View & Attendance // @namespace https://openuserjs.org/users/Al_Caughey // @version 0.1.2 // @description Builds upon Chris Gamble's Grid View script to register whether or not invitees actually joined the Meet // @author Al Caughey // @include https://meet.google.com/* // @grant none // @license MIT // @run-at document-idle // ==/UserScript== // see https://greasyfork.org/en/scripts/397862-google-meet-grid-view for Chris' original Grid View script // My enhancements are prefaced by a comment with my name // currrently using Google Meet Grid View v1.14 ;(function() { // Translations const translations = { ca: { showOnlyVideo: 'Mostra només els participants amb video', highlightSpeaker: 'Ressalta el que parla', includeOwnVideo: 'Inclou el meu video a la graella', }, de: { showOnlyVideo: 'Nur Teilnehmer mit Video anzeigen', highlightSpeaker: 'Sprecher hervorheben', includeOwnVideo: 'Mich im Raster anzeigen', }, en: { showOnlyVideo: 'Only show participants with video', highlightSpeaker: 'Highlight speakers', includeOwnVideo: 'Include yourself in the grid', }, es: { showOnlyVideo: 'Unicamente mostrar participantes con video', highlightSpeaker: 'Resaltar participantes', includeOwnVideo: 'Incluir mi video en el grid', }, fr: { showOnlyVideo: 'Ne montrer que les participants avec caméra', highlightSpeaker: 'Surligner ceux qui parlent', includeOwnVideo: 'Vous inclure dans la grille', }, hr: { showOnlyVideo: 'Prikaži samo sudionike sa kamerom', highlightSpeaker: 'Naglasi govornike', includeOwnVideo: 'Uključi sebe u mrežnom prikazu', }, it: { showOnlyVideo: 'Mostra solo i partecipanti con la fotocamera attiva', highlightSpeaker: 'Illumina chi sta parlando', includeOwnVideo: 'Includi te stesso nella griglia', }, nl: { showOnlyVideo: 'Toon alleen deelnemers met video', highlightSpeaker: 'Highlight sprekers', includeOwnVideo: 'Toon jezelf in het raster', }, pl: { showOnlyVideo: 'Pokaż tylko uczestników z wideo', highlightSpeaker: 'Wyróżnij osobę prezentującą', includeOwnVideo: 'Uwzględnij siebie', }, pt: { showOnlyVideo: 'Mostrar somente participantes com vídeo', highlightSpeaker: 'Destacar quem está falando', includeOwnVideo: 'Incluir meu vídeo no grid', }, sv: { showOnlyVideo: 'Visa endast deltagare med video', highlightSpeaker: 'Markera/följ talare', includeOwnVideo: 'Inkludera mig i rutnätet', }, zh: { showOnlyVideo: '仅显示有视讯的与会者', highlightSpeaker: '强调发言者', includeOwnVideo: '将自己的视讯显示于网格中', }, 'zh-TW': { showOnlyVideo: '僅顯示有視訊的與會者', highlightSpeaker: '強調發言者', includeOwnVideo: '將自己的視訊顯示於網格中', }, } const T = key => navigator.languages .concat(['en']) .map(l => (translations[l] && translations[l][key]) || (translations[l.split('-')[0]] && translations[l.split('-')[0]][key])) .find(t => t) // SVGs const gridOff = '<path fill="currentColor" d="M0,2.77L1.28,1.5L22.5,22.72L21.23,24L19.23,22H4C2.92,22 2,21.1 2,20V4.77L0,2.77M10,4V7.68L8,5.68V4H6.32L4.32,2H20A2,2 0 0,1 22,4V19.7L20,17.7V16H18.32L16.32,14H20V10H16V13.68L14,11.68V10H12.32L10.32,8H14V4H10M16,4V8H20V4H16M16,20H17.23L16,18.77V20M4,8H5.23L4,6.77V8M10,14H11.23L10,12.77V14M14,20V16.77L13.23,16H10V20H14M8,20V16H4V20H8M8,14V10.77L7.23,10H4V14H8Z" />' const gridOn = '<path fill="currentColor" d="M10,4V8H14V4H10M16,4V8H20V4H16M16,10V14H20V10H16M16,16V20H20V16H16M14,20V16H10V20H14M8,20V16H4V20H8M8,14V10H4V14H8M8,8V4H4V8H8M10,14H14V10H10V14M4,2H20A2,2 0 0,1 22,4V20A2,2 0 0,1 20,22H4C2.92,22 2,21.1 2,20V4A2,2 0 0,1 4,2Z" />' // Create the styles we need const s = document.createElement('style') s.innerText = ` .__gmgv-vid-container { display: grid; grid-auto-rows: 1fr; top: 50px !important; right: 2px !important; left: 2px !important; bottom: 2px !important; } .__gmgv-vid-container.__gmgv-chat-enabled { right: 325px !important; } .__gmgv-vid-container > div { position: relative !important; margin-top: 0 !important; top: 0 !important; left: 0 !important; height: 100% !important; width: 100% !important; background: 0 0 !important; } .__gmgv-vid-container > div:after { content: ""; display: block; position: absolute; top: 0; left: 0; right: 0; bottom: 0; border: 0.4em solid #64ffda; box-sizing: border-box; transition: opacity 300ms linear 500ms; opacity: 0; } .__gmgv-vid-container > div.__gmgv-speaking:after { transition: opacity 60ms linear; opacity: 1; } .__gmgv-button { overflow: visible !important; } .__gmgv-button > div { box-sizing: border-box; display: none; position: absolute; top: 40px; left: 0; width: 300px; padding: 12px; background: white; border-radius: 0 0 0 8px; text-align: left; cursor: auto; } .__gmgv-button:hover > div { display: block; } .__gmgv-button > div label { display: block; line-height: 24px; cursor: pointer; } #Attendance-div{ bottom: 88px; opacity:0.4; position:absolute; right:0; z-index:99; } #Attendance-div p{ background:#777; border-radius: 4px 0 0 0 ; color:#EEE; font-weight:bold; margin:0; padding:0 0 0 4px; } #Attendance-div p span{ cursor: pointer; margin: 0 4px; opacity: .75; } #Attendance-List{ max-height:66px; white-space: nowrap; } #Attendance-div.empty, #Attendance-div:hover, #Attendance-div p span:hover { opacity: 1; } .empty #Attendance-List, #Attendance-div:hover #Attendance-List { max-height: calc( 100% - 144px); transition: height 0.5s ease-out; } #Attendance-List:focus { max-height: calc( 100% - 144px); width: 228px; } ` document.body.append(s) //Al Caughey - create a text field where you can paste the list of invitees // if nothing is entered into the field, the list of attendees will be automatically appended (with designation `not invited`) const atd = document.createElement("div"); document.body.appendChild(atd) atd.id = "Attendance-div"; const atp = document.createElement("p"); atp.innerHTML='Class List' const atb1 = document.createElement("span"); atb1.innerHTML='[-]' atb1.title='Clear the attendance checks' const atb2 = document.createElement("span"); atb2.innerHTML='[x]' atb2.title='Clear this field' atp.appendChild(atb1) atp.appendChild(atb2) atd.appendChild(atp) const atl = document.createElement("textarea"); atl.id = "Attendance-List"; atl.cols = "14"; atl.rows = "32"; atl.placeholder="Paste your class list here..." atd.classList.add('empty') if(!!localStorage.getItem('Meet-Attendance-List')){ atl.value=localStorage.getItem('Meet-Attendance-List') atd.classList.remove('empty') } atd.appendChild(atl) // Variables let runInterval = null let container = null let toggleButtonSVG = null let pinnedIndex = -1 let showOnlyVideo = localStorage.getItem('gmgv-show-only-video') === 'true' let highlightSpeaker = localStorage.getItem('gmgv-highlight-speaker') === 'true' let includeOwnVideo = localStorage.getItem('gmgv-include-own-video') === 'true' //Al Caughey - define array to record who attended & set oldn to zero to detect number of attendees let attendees=[] let oldn=0 // This continually probes the number of participants & screen size to ensure videos are max possible size regardless of window layout const gridUpdateLoop = () => { const w = innerWidth / 16 const h = (innerHeight - 48) / 9 let n = container.children.length //Al Caughey - exit if the number of participants has not changed if (oldn===n) return oldn=n if (pinnedIndex >= 0 && pinnedIndex < n) { // Simulate having an extra quarter of videos so we can dedicate a quarter to the pinned video n = Math.ceil((4 / 3) * (n - 1)) } let size = 0 let col for (col = 1; col < 9; col++) { let s = Math.min(w / col, h / Math.ceil(n / col)) if (s < size) { col-- break } size = s } container.style.gridTemplateColumns = `repeat(${col}, 1fr)` for (let v of container.children) { // Al Caughey - add new attendees to the array if(!attendees.includes(v.innerText)) attendees.push(v.innerText) if (+v.dataset.allocationIndex === pinnedIndex) { const span = Math.ceil(col / 2) v.style.order = -1 v.style.gridArea = `span ${span} / span ${span}` } else { v.style.order = v.dataset.allocationIndex v.style.gridArea = '' } } //Al Caughey - when the number of attendees changes, iterate through the list and // update the contents of the Attendee List field // append just `joined` if the name appears in the list // add 'not invited?!?' otherwise console.log('number of attendees changed', n) var textarea = document.getElementById("Attendance-List"); let tal=textarea.value let tallc=tal.toLowerCase() for (let aa of attendees){ let lc=aa.toLowerCase() if(lc=='you' || lc.indexOf('presenting')>0 || lc.indexOf('presentation')>0) continue if(tallc.indexOf(lc)==-1){ console.log(aa + ' joined (unexpectedly)') tal+='\n? '+aa + ' (not invited?!?)' } else if(tallc.indexOf('? '+ lc)>=0){ continue // already uninvited } else if(tallc.indexOf('✔ '+ lc)>=0){ continue // already marked present } else if(tallc.indexOf('✔ '+ lc)==-1){ const pattern=new RegExp(aa, 'i') console.log(aa + ' joined (as expected)', pattern) tal=tal.replace(pattern,'✔ '+aa) } else{ console.log('WTF - ' + aa) } } //Al Caughey - update the list and trigger the change event // (so that the empty class is removed and the field is minimized) textarea.value=tal.trim().replace('✔ ✔ ','✔ ') textarea.onchange() } //Al Caughey - function to check whether the Attendance List is empty or not const listChanged = () => { var textarea = document.getElementById("Attendance-List"); let ct=textarea.value ct.trim()=='' ? atd.classList.add('empty') : atd.classList.remove('empty') localStorage.setItem('Meet-Attendance-List',ct) } //Al Caughey - function to clear those who are marked present const clearPresent = () => { console.log('clearPresent') var textarea = document.getElementById("Attendance-List"); let ct=textarea.value textarea.value=ct.replace(/[✔\?] /g,'') localStorage.setItem('Meet-Attendance-List',ct) } //Al Caughey - function to the list of attendees const clearList= () => { console.log('clearList') document.getElementById("Attendance-List").value=''; localStorage.setItem('Meet-Attendance-List','') attendees=[] } // Define run functions const disableGrid = () => { clearInterval(runInterval) runInterval = null container.classList.remove('__gmgv-vid-container') toggleButtonSVG.innerHTML = gridOff } const enableGrid = () => { if (runInterval) clearInterval(runInterval) runInterval = setInterval(gridUpdateLoop, 250) container.classList.add('__gmgv-vid-container') toggleButtonSVG.innerHTML = gridOn } const toggleGrid = () => { runInterval ? disableGrid() : enableGrid() } // Make the button to perform the toggle // This runs on a loop since you can join/leave the meeting repeatedly without changing the page setInterval(() => { // Find the UI elements we need to modify. If they don't exist we haven't entered the meeting yet and will try again later const participantVideo = document.querySelector('[data-allocation-index]') const _container = participantVideo && participantVideo.parentElement if (_container && _container !== container) { container = _container if (runInterval) enableGrid() // When someone starts a presentation `container` will change under us, so we need to restart the grid } const ownVideoPreview = document.querySelector('[data-fps-request-screencast-cap]') const buttons = ownVideoPreview && ownVideoPreview.parentElement.parentElement.parentElement if (buttons && !buttons.__grid_ran) { buttons.__grid_ran = true // Find the button container element and copy the divider buttons.prepend(buttons.children[1].cloneNode()) // Add our button to to enable/disable the grid const toggleButton = document.createElement('div') toggleButton.classList = buttons.children[1].classList toggleButton.classList.add('__gmgv-button') toggleButton.style.display = 'flex' toggleButton.onclick = toggleGrid buttons.prepend(toggleButton) toggleButtonSVG = document.createElementNS('http://www.w3.org/2000/svg', 'svg') toggleButtonSVG.style.width = '24px' toggleButtonSVG.style.height = '24px' toggleButtonSVG.setAttribute('viewBox', '0 0 24 24') toggleButtonSVG.innerHTML = gridOff toggleButton.appendChild(toggleButtonSVG) // Add checkboxes for all our additional options const additionalOptions = document.createElement('div') additionalOptions.onclick = e => e.stopPropagation() toggleButton.appendChild(additionalOptions) const showOnlyVideoL = document.createElement('label') const showOnlyVideoI = document.createElement('input') showOnlyVideoI.type = 'checkbox' showOnlyVideoI.checked = showOnlyVideo showOnlyVideoI.onchange = e => { showOnlyVideo = e.target.checked localStorage.setItem('gmgv-show-only-video', showOnlyVideo) } showOnlyVideoL.innerText = T('showOnlyVideo') showOnlyVideoL.prepend(showOnlyVideoI) additionalOptions.appendChild(showOnlyVideoL) const highlightSpeakerL = document.createElement('label') const highlightSpeakerI = document.createElement('input') highlightSpeakerI.type = 'checkbox' highlightSpeakerI.checked = highlightSpeaker highlightSpeakerI.onchange = e => { highlightSpeaker = e.target.checked localStorage.setItem('gmgv-highlight-speaker', highlightSpeaker) } highlightSpeakerL.innerText = T('highlightSpeaker') highlightSpeakerL.prepend(highlightSpeakerI) additionalOptions.appendChild(highlightSpeakerL) const includeOwnVideoL = document.createElement('label') const includeOwnVideoI = document.createElement('input') includeOwnVideoI.type = 'checkbox' includeOwnVideoI.checked = includeOwnVideo includeOwnVideoI.onchange = e => { includeOwnVideo = e.target.checked localStorage.setItem('gmgv-include-own-video', includeOwnVideo) } includeOwnVideoL.innerText = T('includeOwnVideo') includeOwnVideoL.prepend(includeOwnVideoI) additionalOptions.appendChild(includeOwnVideoL) } // Find the functions inside google meets code that we need to override for our functionality // Notably we're looking for the function that handles video layout, and the function that detects volume // This code is fairly hairy but basically just iterates through all the exposed functions until we find the // ones that roughly match the code we're looking for by running regexs on the function source code. // We can then parse that code to get variable names out and use javascript Proxys to override them. if (window.default_MeetingsUi) { let m //Al Caughey - attached the onchange function to the Attendance List field document.getElementById("Attendance-List").onchange= listChanged //Al Caughey - attached the onclicks to the buttons atb1.onclick = clearPresent atb2.onclick = clearList for (let [_k, v] of Object.entries(window.default_MeetingsUi)) { if (v && v.prototype) { for (let k of Object.keys(v.prototype)) { const p = Object.getOwnPropertyDescriptor(v.prototype, k) if (p && p.value && !v.prototype[k].__grid_ran) { // this.XX.get(_).YY(this._) m = /this\.([A-Za-z]+)\.get\([A-Za-z]+\)\.([A-Za-z]+)\(this\.[A-Za-z]+\)/.exec(p.value.toString()) if (m) { console.log('[google-meet-grid-view] Successfully hooked into rendering pipeline', v.prototype[k]) const p = new Proxy(v.prototype[k], RefreshVideoProxyHandler(m[1], m[2])) p.__grid_ran = true v.prototype[k] = p } // this.XX.getVolume() m = /this\.([A-Za-z]+)\.getVolume\(\)/.exec(p.value.toString()) if (m) { console.log('[google-meet-grid-view] Successfully hooked into volume detection', v.prototype[k]) const p = new Proxy(v.prototype[k], VolumeDetectionProxyHandler(m[1])) p.__grid_ran = true v.prototype[k] = p } } } } if (v && typeof v === 'function' && !v.__grid_ran) { m = /function\(a,b,c\){return!0===c\?/.exec(v.toString()) if (m) { console.log('[google-meet-grid-view] Successfully hooked into chat toggle', v) const p = new Proxy(v, ToggleProxyHandler()) p.__grid_ran = true window.default_MeetingsUi[_k] = p } } } } }, 250) // This overrides the function that handles laying out video. // All we do here is install another proxy on the Map that returns which layout to use function RefreshVideoProxyHandler(objKey, funcKey) { return { apply: function(target, thisArg, argumentsList) { if (!thisArg[objKey].__grid_ran) { const p = new Proxy(thisArg[objKey], LayoutVideoProxyHandler(thisArg, funcKey)) p.__grid_ran = true thisArg[objKey] = p } return target.apply(thisArg, argumentsList) }, } } // This overrides the Map that returns which layout to use, as called by the above Proxy // If grid view is enabled we always try to call our custom layout function. // If our layout function errors, or grid view is disabled, we return the actual function. function LayoutVideoProxyHandler(parent, funcKey) { return { get: function(target, name) { let ret = Reflect.get(target, name) if (typeof ret === 'function') { ret = ret.bind(target) } if (runInterval && name == 'get') { return idx => ({ [funcKey]: input => { try { return GridLayout.call(parent, input) } catch (e) { console.error(e) return ret(idx)[funcKey](input) } }, }) } return ret }, } } // This overrides the volume detection code that powers the wiggly bars next to each participant's name // We still call the underlying function, but if grid view is enabled we also add or remove a class to the // video container depending on volume level. This allows us to add visual effects like a border. function VolumeDetectionProxyHandler(objKey) { return { apply: function(target, thisArg, argumentsList) { if (!thisArg.isDisposed()) { if (!thisArg.__grid_videoElem) { for (let v of Object.values(thisArg)) { if (v instanceof HTMLElement) { thisArg.__grid_videoElem = v.parentElement.parentElement.parentElement } } } if (thisArg.__grid_videoElem.dataset.allocationIndex) { if (thisArg[objKey].getVolume() > 0 && runInterval && highlightSpeaker) { thisArg.__grid_videoElem.classList.add('__gmgv-speaking') } else { thisArg.__grid_videoElem.classList.remove('__gmgv-speaking') } } } return target.apply(thisArg, argumentsList) }, } } function ToggleProxyHandler() { return { apply: function(target, thisArg, argumentsList) { if (argumentsList.length === 3 && container) { const elems = Object.values(argumentsList[0]) .filter(v => Array.isArray(v)) .flat() .filter(v => v instanceof HTMLElement) const v = argumentsList[2] if (elems.length === 1) { const el = elems[0] if (el.parentElement === container.parentElement.parentElement && el.clientWidth === 320) { container.classList.toggle('__gmgv-chat-enabled', v) } } } return target.apply(thisArg, argumentsList) }, } } // This is a custom layout function to power grid view. // Notably it forces every participant to load (or just those with video in only-video mode) // and consistently sorts by participant name (rather than who has talked last) function GridLayout(orderingInput) { // Extract constructors from the Meets code const VideoList = orderingInput.constructor const VideoElem = Object.values(window.default_MeetingsUi) .filter(i => typeof i === 'function') .filter(i => i.toString().includes('.attribution'))[0] // Figure out what field of VideoElem is used to store the participant data const magicKey = Object.entries(new VideoElem(999)).find(e => e[1] === 999)[0] // Convert participant data to a VideoElem and add to the list // but only if it hasn't already been added. Also run a callback if provided. const addUniqueVideoElem = (a, b, c) => { if (b && !a.some(e => e[magicKey] === b)) { const d = new VideoElem(b, { attribution: true }) if (c) c(d) a.push(d) } } // Convience function const isSpacesStr = i => typeof i === 'string' && i.startsWith('spaces/') // This allows us to set values without knowing the property key // Important because the keys keep changing but the types don't. // magicSet(true) enables the "You're presenting to everyone" screen // magicSet(1 || 2) ensures multiple screens can be shown. Unsure the difference between 1 and 2 const magicSet = val => { return elem => { for (const [k, v] of Object.entries(elem)) { if (typeof v === typeof val && k !== 'attribution') { elem[k] = val } } } } // Finds the listing of map keys, and the object that contains it let videoKeys, importantObject for (let v of Object.values(this)) { if (v && typeof v === 'object') { for (let vv of Object.values(v)) { if (Array.isArray(vv) && vv.length && vv.every(isSpacesStr)) { if (videoKeys && vv != videoKeys) { console.log('Invalid videoKeys search!', videoKeys, vv) throw new Error('Failed') } else { videoKeys = vv importantObject = v } } } } } if (!importantObject) { throw new Error('No other participants, using default layout') } // Reusing the object we found earlier, find the map of participant data let videoMap for (let v of Object.values(importantObject)) { if (v instanceof Map && v.size && Array.from(v.keys()).every(isSpacesStr)) { videoMap = v } } // Find our own participant data let ownVideo = null for (let v of Object.values(importantObject)) { if (v && typeof v === 'object' && v.$goog_Thenable) { for (let vv of Object.values(v)) { if (isSpacesStr(vv)) { ownVideo = videoMap.get(vv) || null } } } } // Use the map & map keys we found earlier to add every participant let ret = [] for (const v of videoKeys) { addUniqueVideoElem(ret, videoMap.get(v), magicSet(2)) } if (includeOwnVideo) { addUniqueVideoElem(ret, ownVideo, magicSet(2)) } // If in only-video mode, remove any without video if (showOnlyVideo) { // ret[idx][magicKey].wr.Aa.Aa.Ca.Ea.Ws.Ea.state // mu (no) li (yes) const tests = [/\.call\(this\)/, /\.call\(this,.*,"a"\)/, /new Set;this\.\w+=new _/, /new Map.*new Set/, /"un".*"li"/, /new Map/, /Object/] ret = ret.filter(e => { let values = [e[magicKey]] for (let t of tests) { let newValues = [] for (let v of values) { newValues = newValues.concat(Object.values(v).filter(vv => vv && vv.constructor && t.test(vv.constructor.toString()))) } values = newValues } return values.some(v => v && v.state && v.state === 'li') }) } // If there are no participants, add ourselves if (!ret.length) { addUniqueVideoElem(ret, ownVideo) } // sort by participant name, or video id if the name is the same (when someone is presenting) ret.sort((a, b) => a[magicKey].name.localeCompare(b[magicKey].name) || a[magicKey].id.localeCompare(b[magicKey].id)) // Set Pinned Index for use in CSS loop. If there is no pin, use the presenter if available pinnedIndex = ret.findIndex(v => v[magicKey].isPinned()) if (pinnedIndex < 0) { pinnedIndex = ret.findIndex(v => !!v[magicKey].parent) } // Build a video list from the ordered output return new VideoList(ret) } })()