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)
}
})()