├── public ├── favicon.ico ├── images │ ├── icon.png │ ├── logo.png │ ├── icon_32.png │ ├── logo_header.png │ ├── logo_header@2x.png │ └── progress_spinner_googblue_20dp.gif ├── scripts │ ├── admin.js │ ├── config.js │ ├── projector.js │ └── index.js ├── third_party │ ├── jquery.voice.min.js │ ├── recorderWorker.js │ ├── recorder.js │ └── material.min.js ├── admin │ └── index.html ├── projector │ └── index.html ├── index.html ├── 404.html └── stylesheets │ └── custom.css ├── .gitignore ├── CONTRIBUTING.md ├── storage.rules ├── functions ├── spec │ ├── fake-admin.ts │ ├── fake-config.ts │ ├── fake-speech.ts │ ├── fake-translate.ts │ ├── fake-db.ts │ └── index.spec.ts ├── tsconfig.json ├── package.json └── src │ ├── db.ts │ ├── bcp47iso639.ts │ ├── index.ts │ └── saythat.ts ├── firebase.json ├── database.rules.json ├── README.md └── LICENSE /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/laurenzlong/say-that/HEAD/public/favicon.ico -------------------------------------------------------------------------------- /public/images/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/laurenzlong/say-that/HEAD/public/images/icon.png -------------------------------------------------------------------------------- /public/images/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/laurenzlong/say-that/HEAD/public/images/logo.png -------------------------------------------------------------------------------- /public/images/icon_32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/laurenzlong/say-that/HEAD/public/images/icon_32.png -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | firebase-debug.log 3 | lib 4 | node_modules 5 | .firebaserc 6 | *.mp4 7 | say-that-data.json -------------------------------------------------------------------------------- /public/images/logo_header.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/laurenzlong/say-that/HEAD/public/images/logo_header.png -------------------------------------------------------------------------------- /public/images/logo_header@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/laurenzlong/say-that/HEAD/public/images/logo_header@2x.png -------------------------------------------------------------------------------- /public/images/progress_spinner_googblue_20dp.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/laurenzlong/say-that/HEAD/public/images/progress_spinner_googblue_20dp.gif -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | ### Code reviews 2 | All submissions, including submissions by project members, require review. We 3 | use Github pull requests for this purpose. 4 | -------------------------------------------------------------------------------- /storage.rules: -------------------------------------------------------------------------------- 1 | service firebase.storage { 2 | match /b/{bucket}/o { 3 | match /{allPaths=**} { 4 | allow read; 5 | allow write: if request.auth != null; 6 | } 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /functions/spec/fake-admin.ts: -------------------------------------------------------------------------------- 1 | import * as mock from 'mock-require'; 2 | 3 | let fakeAdmin = { 4 | initializeApp: () => { }, 5 | database: () => { 6 | throw Error('Wow, this fake firebase-admin should not actually be used!'); 7 | }, 8 | }; 9 | 10 | export function init() { 11 | mock('firebase-admin', fakeAdmin); 12 | } 13 | 14 | -------------------------------------------------------------------------------- /functions/spec/fake-config.ts: -------------------------------------------------------------------------------- 1 | import * as sinon from 'sinon'; 2 | import * as functions from 'firebase-functions'; 3 | 4 | export function init() { 5 | sinon.stub(functions, 'config').returns({ 6 | firebase: { 7 | storageBucket: 'fake-bucket', 8 | databaseURL: 'https://testingfake.firebaseio.com', 9 | }, 10 | }); 11 | } 12 | -------------------------------------------------------------------------------- /firebase.json: -------------------------------------------------------------------------------- 1 | { 2 | "database": { 3 | "rules": "database.rules.json" 4 | }, 5 | "storage": { 6 | "rules": "storage.rules" 7 | }, 8 | "hosting": { 9 | "public": "public", 10 | "redirects": [ { 11 | "source" : "/source", 12 | "destination" : "https://github.com/laurenzlong/say-that", 13 | "type" : 302 14 | } ] 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /functions/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "lib": [ 4 | "es6", 5 | "es2015.promise" 6 | ], 7 | "module": "commonjs", 8 | "noImplicitAny": false, 9 | "outDir": "lib", 10 | "stripInternal": true, 11 | "target": "es5", 12 | "typeRoots": [ 13 | "node_modules/@types" 14 | ] 15 | }, 16 | "files": [ 17 | "src/index.ts", 18 | "spec/index.spec.ts" 19 | ] 20 | } 21 | -------------------------------------------------------------------------------- /functions/spec/fake-speech.ts: -------------------------------------------------------------------------------- 1 | import * as mock from 'mock-require'; 2 | 3 | let response = ['unset fake response']; 4 | let lastUrl = ''; 5 | let lastRequest = {}; 6 | export let speech = { 7 | recognize: (url, request) => { 8 | lastUrl = url; 9 | lastRequest = request; 10 | return response; 11 | }, 12 | }; 13 | 14 | export function setResponse(r: string[]) { 15 | response = r; 16 | } 17 | 18 | export function getLastUrl(): any { 19 | return lastUrl; 20 | } 21 | 22 | export function getLastRequest(): any { 23 | return lastRequest; 24 | } 25 | 26 | export function init() { 27 | mock('@google-cloud/speech', () => speech); 28 | } 29 | -------------------------------------------------------------------------------- /functions/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "functions", 3 | "description": "Cloud Functions for Firebase", 4 | "scripts": { 5 | "build": "tsc", 6 | "test": "tsc && mocha lib/spec/index.spec.js", 7 | "deploy-prod": "tsc && mocha lib/spec/index.spec.js && firebase deploy --project prod" 8 | }, 9 | "dependencies": { 10 | "@google-cloud/speech": "^0.9.1", 11 | "@google-cloud/translate": "^0.8.1", 12 | "bad-words": "^1.5.1", 13 | "firebase-admin": "^4.2.1", 14 | "firebase-functions": "^0.5", 15 | "lodash": "^4.17.4", 16 | "typescript": "^3.6.4" 17 | }, 18 | "main": "./lib/src/index.js", 19 | "private": true, 20 | "devDependencies": { 21 | "@types/chai": "^3.5.2", 22 | "@types/chai-as-promised": "0.0.30", 23 | "@types/mocha": "^2.2.41", 24 | "@types/mock-require": "^2.0.0", 25 | "@types/nock": "^8.2.1", 26 | "@types/sinon": "^2.2.1", 27 | "chai": "^3.5.0", 28 | "chai-as-promised": "^6.0.0", 29 | "mocha": "^3.3.0", 30 | "mock-require": "^2.0.2", 31 | "nock": "^9.0.13", 32 | "sinon": "^2.2.0" 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /functions/spec/fake-translate.ts: -------------------------------------------------------------------------------- 1 | import * as mock from 'mock-require'; 2 | 3 | let defaultResponse; 4 | let languageResponse; 5 | let lastInput; 6 | let lastOptions; 7 | let numTranslations; 8 | 9 | export function reset() { 10 | defaultResponse = ['unset']; 11 | languageResponse = {}; 12 | lastInput = ''; 13 | lastOptions = {}; 14 | numTranslations = 0; 15 | } 16 | 17 | // This is the actual method being faked. 18 | export let translate = { 19 | translate: (input, options) => { 20 | lastInput = input; 21 | lastOptions = options; 22 | numTranslations++; 23 | if (languageResponse.hasOwnProperty(options.to)) { 24 | return Promise.resolve(languageResponse[options.to]); 25 | } 26 | return Promise.resolve(defaultResponse); 27 | }, 28 | }; 29 | 30 | export function setDefaultResponse(r: any) { 31 | defaultResponse = r; 32 | } 33 | 34 | export function setResponse(lang: string, r: any) { 35 | languageResponse[lang] = r; 36 | } 37 | 38 | export function getLastInput(): any { 39 | return lastInput; 40 | } 41 | 42 | export function getLastOptions(): any { 43 | return lastOptions; 44 | } 45 | 46 | export function getNumTranslations(): number { 47 | return numTranslations; 48 | } 49 | 50 | export function init() { 51 | mock('@google-cloud/translate', () => translate); 52 | } 53 | -------------------------------------------------------------------------------- /public/scripts/admin.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2017 Google Inc. All Rights Reserved. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | var showNouns = function(scene) { 17 | $('#nouns-list').empty(); 18 | firebase.database().ref('admin/scenes/' + scene + '/nouns').on('child_added', function(snapshot) { 19 | var listItem = $('
  • ' + snapshot.key + '
  • '); 20 | $('#nouns-list').append(listItem); 21 | }); 22 | }; 23 | 24 | var initNewSceneBtn = function() { 25 | $('#scene-submit').click(function() { 26 | var name = $('#scene-name').val(); 27 | var url = $('#scene-url').val(); 28 | firebase.database().ref('admin/scenes/' + name + '/video').set(url).then(function() { 29 | $('#msg-box').text('Successfully added new scene.'); 30 | showNouns(name); 31 | }); 32 | }); 33 | }; 34 | 35 | $(document).ready(function() { 36 | initNewSceneBtn(); 37 | }); -------------------------------------------------------------------------------- /functions/spec/fake-db.ts: -------------------------------------------------------------------------------- 1 | import * as _ from 'lodash'; 2 | import * as mock from 'mock-require'; 3 | 4 | interface ValuesProps { 5 | [key: string]: string; 6 | } 7 | 8 | let _values: ValuesProps; 9 | let pushCount: number; 10 | reset(); 11 | 12 | export function reset() { 13 | _values = {}; 14 | pushCount = 0; 15 | } 16 | 17 | export function has(path: string): boolean { 18 | return _values.hasOwnProperty(path); 19 | } 20 | 21 | export function get(path: string): Promise { 22 | if (!_values.hasOwnProperty(path)) { 23 | return Promise.resolve(null); 24 | } 25 | return Promise.resolve(_values[path]); 26 | } 27 | 28 | export function set(path: string, value: any): Promise { 29 | _values[path] = value; 30 | return Promise.resolve(); 31 | } 32 | 33 | export function push(path: string, value: any): Promise { 34 | _values[`${path}/pushprefix-${pushCount}`] = value; 35 | pushCount++; 36 | return Promise.resolve(); 37 | } 38 | 39 | export function remove(path: string): Promise { 40 | delete _values[path]; 41 | return Promise.resolve(); 42 | } 43 | 44 | export async function transaction( 45 | path: string, 46 | callback: (get: string | null) => Promise 47 | ): Promise { 48 | return set(path, callback(await get(path))); 49 | } 50 | 51 | export function values() { 52 | return _values; 53 | } 54 | 55 | export function init(otherModule: string) { 56 | mock(otherModule, './fake-db'); 57 | } 58 | -------------------------------------------------------------------------------- /functions/src/db.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2017 Google Inc. All Rights Reserved. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | import * as functions from 'firebase-functions'; 17 | import * as admin from 'firebase-admin'; 18 | admin.initializeApp(functions.config().firebase); 19 | 20 | export function get(path: string): Promise { 21 | return admin.database().ref(path).once('value').then(snapshot => { 22 | return snapshot.val(); 23 | }); 24 | } 25 | 26 | export function set(path: string, value: any): Promise { 27 | return admin.database().ref(path).set(value) 28 | } 29 | 30 | export function push(path: string, value: any): Promise { 31 | return admin.database().ref(path).push(value); 32 | } 33 | 34 | export function remove(path: string): Promise { 35 | return admin.database().ref(path).remove(); 36 | } 37 | 38 | export function transaction(path: string, callback): Promise { 39 | return admin.database().ref(path).transaction(callback); 40 | } 41 | 42 | -------------------------------------------------------------------------------- /functions/src/bcp47iso639.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2017 Google Inc. All Rights Reserved. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | const bcp47toISO639 = { 17 | 'af-ZA': 'af', 18 | 'id-ID': 'id', 19 | 'ms-MY': 'ms', 20 | 'ca-ES': 'ca', 21 | 'cs-CZ': 'cs', 22 | 'da-DK': 'da', 23 | 'de-DE': 'de', 24 | 'en-US': 'en', 25 | 'es-MX': 'es', 26 | 'eu-ES': 'eu', 27 | 'fil-PH': 'tl', 28 | 'fr-FR': 'fr', 29 | 'gl-ES': 'gl', 30 | 'hr-HR': 'hr', 31 | 'zu-ZA': 'zu', 32 | 'is-IS': 'is', 33 | 'it-IT': 'it', 34 | 'lt-LT': 'lt', 35 | 'hu-HU': 'hu', 36 | 'nl-NL': 'nl', 37 | 'nb-NO': 'nb', 38 | 'pl-PL': 'pl', 39 | 'pt-BR': 'pt', 40 | 'ro-RO': 'ro', 41 | 'sk-SK': 'sk', 42 | 'sl-SI': 'sl', 43 | 'fi-FI': 'fi', 44 | 'sv-SE': 'sv', 45 | 'vi-VN': 'vi', 46 | 'tr-TR': 'tr', 47 | 'el-GR': 'el', 48 | 'bg-BG': 'bg', 49 | 'ru-RU': 'ru', 50 | 'sr-RS': 'sr', 51 | 'uk-UA': 'uk', 52 | 'he-IL': 'he', 53 | 'ar-IL': 'ar', 54 | 'fa-IR': 'fa', 55 | 'hi-IN': 'hi', 56 | 'th-TH': 'th', 57 | 'ko-KR': 'ko', 58 | 'ja-JP': 'ja', 59 | 'cmn-Hans-CN': 'zh-CN', 60 | 'yue-Hant-HK': 'zh-TW', 61 | }; 62 | 63 | export default bcp47toISO639; 64 | -------------------------------------------------------------------------------- /database.rules.json: -------------------------------------------------------------------------------- 1 | { 2 | "rules": { 3 | "admin": { 4 | // Admin access: only @google.com users. 5 | ".read": "auth.token.email_verified == true && auth.token.email.matches(/.*@google.com$/)", 6 | ".write": "auth.token.email_verified == true && auth.token.email.matches(/.*@google.com$/)", 7 | "current_scene": { 8 | // But everybody can see what scene we're in. 9 | ".read": "auth != null" 10 | }, 11 | "active": { 12 | // Everybody can also see whether the game is active. 13 | ".read": "auth != null" 14 | } 15 | }, 16 | "users": { 17 | "$uid": { 18 | // Users can read all information about themselves (including things like their score). 19 | ".read": "auth.uid == $uid", 20 | "scenes": { 21 | "$scene": { 22 | "nouns": { 23 | // Users can write guesses for nouns, but not their score. 24 | ".write": "auth.uid == $uid" 25 | }, 26 | "in_progress": { 27 | // Users can write to the in progress queue 28 | ".write": "auth.uid == $uid" 29 | } 30 | } 31 | }, 32 | // Users can write what their language selection is 33 | "lang": { 34 | ".write": "auth.uid == $uid" 35 | } 36 | } 37 | }, 38 | "total_scores": { 39 | // Everybody can read the combined scores. 40 | ".read": "auth != null" 41 | }, 42 | "total_langs": { 43 | // Everybody can read the combined language count. 44 | ".read": "auth != null" 45 | }, 46 | "all_guesses": { 47 | // Everybody can see all previous guesses, after they've already been evaluated 48 | ".read": "auth != null" 49 | }, 50 | "summary": { 51 | // Only admin can see summary, which contains the answer key 52 | ".read": "auth.token.email_verified == true && auth.token.email.matches(/.*@google.com$/)" 53 | } 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /public/third_party/jquery.voice.min.js: -------------------------------------------------------------------------------- 1 | /** 2 | * jQuery Voice plugin 0.3 (31st May 2015) 3 | * Copyright Subin Siby - http://subinsb.com 4 | * 5 | * ------------------ 6 | * Licensed under MIT 7 | * ------------------ 8 | * 9 | * A jQuery plugin to record, play & download microphone input sound from the user. 10 | * NEEDS recorder.js and recorderWorker.js to work - https://github.com/mattdiamond/Recorderjs 11 | * 12 | * To use MP3 conversion, NEEDS mp3Worker.js, libmp3lame.min.js and recorder.js from https://github.com/nusofthq/Recordmp3js/tree/master/js 13 | * 14 | * Full Documentation & Support - http://subinsb.com/html5-record-mic-voice 15 | */ 16 | window.Fr=window.Fr||{},function(){Fr.voice={workerPath:"third_party/recorderWorker.js",mp3WorkerPath:"third_party/mp3Worker.js",stream:!1,init_called:!1,init:function(){try{window.AudioContext=window.AudioContext||window.webkitAudioContext,navigator.getUserMedia=navigator.getUserMedia||navigator.webkitGetUserMedia||navigator.mozGetUserMedia||navigator.msGetUserMedia,window.URL=window.URL||window.webkitURL,navigator.getUserMedia===!1&&alert("getUserMedia() is not supported in your browser"),this.context=new AudioContext}catch(t){alert("Web Audio API is not supported in this browser")}},record:function(t,e){this.init_called===!1&&(this.init(),this.init_called=!0),$that=this,navigator.getUserMedia({audio:!0},function(r){var i=$that.context.createMediaStreamSource(r);t===!0&&i.connect($that.context.destination),$that.recorder=new Recorder(i,{workerPath:$that.workerPath,mp3WorkerPath:$that.mp3WorkerPath}),$that.stream=r,$that.recorder.record(),e(r)},function(){alert("No live audio input")})},stop:function(){return this.recorder.stop(),this.recorder.clear(),this.stream.getTracks().forEach(function (track) { track.stop(); }),this},"export":function(t,e){"mp3"==e?this.recorder.exportMP3(t):this.recorder.exportWAV(function(r){if(""==e||"blob"==e)t(r);else if("base64"==e){var i=new window.FileReader;i.readAsDataURL(r),i.onloadend=function(){base64data=i.result,t(base64data)}}else if("URL"==e){var o=URL.createObjectURL(r);t(o)}})}}}(jQuery); 17 | -------------------------------------------------------------------------------- /public/third_party/recorderWorker.js: -------------------------------------------------------------------------------- 1 | /* 2 | The MIT License (MIT) 3 | 4 | Copyright (c) 2014 nusofthq 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 7 | 8 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 9 | 10 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 11 | */ 12 | function init(e){sampleRate=e.sampleRate}function record(e){recBuffersL.push(e[0]),recLength+=e[0].length}function exportWAV(e){var t=mergeBuffers(recBuffersL,recLength),r=encodeWAV(t),n=new Blob([r],{type:e});this.postMessage(n)}function getBuffer(){var e=[];e.push(mergeBuffers(recBuffersL,recLength)),e.push(mergeBuffers(recBuffersR,recLength)),this.postMessage(e)}function clear(){recLength=0,recBuffersL=[],recBuffersR=[]}function mergeBuffers(e,t){for(var r=new Float32Array(t),n=0,a=0;aa;)n[a++]=e[f],n[a++]=t[f],f++;return n}function floatTo16BitPCM(e,t,r){for(var n=0;na?32768*a:32767*a,!0)}}function writeString(e,t,r){for(var n=0;n 16 | 17 | 18 | 19 | 20 | 21 | 22 | Say That 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 |
    32 | 33 |
    34 |
    35 | 36 | Say That Admin 37 |
    38 |
    39 |
    40 | 41 | 42 |
    43 | 44 |
    45 |
    46 | 47 | 48 | 49 | 50 | 51 |
    52 |
    53 |
    The Video Intelligence API found these words:
    54 |
      55 | 56 |
      57 |
      58 |
      59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | -------------------------------------------------------------------------------- /public/scripts/config.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2017 Google Inc. All Rights Reserved. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | var uiConfig = { 17 | 'callbacks': { 18 | 'signInSuccess': function(user, credential, redirectUrl) { 19 | handleSignedInUser(user); 20 | // Do not redirect. 21 | return false; 22 | } 23 | }, 24 | 'signInOptions': [ 25 | firebase.auth.GoogleAuthProvider.PROVIDER_ID, 26 | firebase.auth.GithubAuthProvider.PROVIDER_ID, 27 | firebase.auth.EmailAuthProvider.PROVIDER_ID 28 | ] 29 | }; 30 | 31 | var availableLangs = [ 32 | 'Afrikaans', 33 | 'Arabic', 34 | 'Basque', 35 | 'Bulgarian', 36 | 'Catalan', 37 | 'Chinese (Cantonese)', 38 | 'Chinese (Mandarin)', 39 | 'Croatian', 40 | 'Czech', 41 | 'Danish', 42 | 'Dutch', 43 | 'English', 44 | 'Filipino', 45 | 'Finnish', 46 | 'French', 47 | 'Galician', 48 | 'German', 49 | 'Greek', 50 | 'Hebrew', 51 | 'Hindi', 52 | 'Hungarian', 53 | 'Icelandic', 54 | 'Indonesian', 55 | 'Italian', 56 | 'Japanese', 57 | 'Korean', 58 | 'Lithuanian', 59 | 'Malay', 60 | 'Norwegian', 61 | 'Persian', 62 | 'Polish', 63 | 'Portuguese', 64 | 'Romanian', 65 | 'Russian', 66 | 'Serbian', 67 | 'Slovak', 68 | 'Slovenian', 69 | 'Spanish', 70 | 'Swedish', 71 | 'Thai', 72 | 'Turkish', 73 | 'Ukrainian', 74 | 'Vietnamese', 75 | 'Zulu' 76 | ]; 77 | 78 | var langToCode = { 79 | 'Afrikaans': 'af-ZA', 80 | 'Indonesian': 'id-ID', 81 | 'Malay': 'ms-MY', 82 | 'Catalan': 'ca-ES', 83 | 'Czech': 'cs-CZ', 84 | 'Danish': 'da-DK', 85 | 'German': 'de-DE', 86 | 'English': 'en-US', 87 | 'Spanish': 'es-MX', 88 | 'Basque': 'eu-ES', 89 | 'Filipino': 'fil-PH', 90 | 'French': 'fr-FR', 91 | 'Galician': 'gl-ES', 92 | 'Croatian': 'hr-HR', 93 | 'Zulu': 'zu-ZA', 94 | 'Icelandic': 'is-IS', 95 | 'Italian': 'it-IT', 96 | 'Lithuanian': 'lt-LT', 97 | 'Hungarian': 'hu-HU', 98 | 'Dutch': 'nl-NL', 99 | 'Norwegian': 'nb-NO', 100 | 'Polish': 'pl-PL', 101 | 'Portuguese': 'pt-BR', 102 | 'Romanian': 'ro-RO', 103 | 'Slovak': 'sk-SK', 104 | 'Slovenian': 'sl-SI', 105 | 'Finnish': 'fi-FI', 106 | 'Swedish': 'sv-SE', 107 | 'Vietnamese': 'vi-VN', 108 | 'Turkish': 'tr-TR', 109 | 'Greek': 'el-GR', 110 | 'Bulgarian': 'bg-BG', 111 | 'Russian': 'ru-RU', 112 | 'Serbian': 'sr-RS', 113 | 'Ukrainian': 'uk-UA', 114 | 'Hebrew': 'he-IL', 115 | 'Arabic': 'ar-IL', 116 | 'Persian': 'fa-IR', 117 | 'Hindi': 'hi-IN', 118 | 'Thai': 'th-TH', 119 | 'Korean': 'ko-KR', 120 | 'Cantonese': 'yue-Hant-HK', 121 | 'Japanese': 'ja-JP', 122 | 'Mandarin': 'cmn-Hans-CN', 123 | 'Chinese (Mandarin)': 'cmn-Hans-CN', 124 | 'Chinese (Cantonese)': 'yue-Hant-HK' 125 | }; -------------------------------------------------------------------------------- /public/third_party/recorder.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright Subin Siby - http://subinsb.com 3 | * 4 | * ------------------ 5 | * Licensed under MIT 6 | * ------------------ 7 | * 8 | */ 9 | (function(window){var WORKER_PATH='http://lab.subinsb.com/projects/jquery/voice/recorderWorker.js';var mp3WorkerPath='http://lab.subinsb.com/projects/jquery/voice/mp3Worker.js';var Recorder=function(source,cfg){var config=cfg||{};var bufferLen=config.bufferLen||4096;this.context=source.context;this.node=(this.context.createScriptProcessor||this.context.createJavaScriptNode).call(this.context,bufferLen,2,2);var worker=new Worker(config.workerPath||WORKER_PATH);worker.postMessage({command:'init',config:{sampleRate:this.context.sampleRate}});var recording=false,currCallback;this.node.onaudioprocess=function(e){if(!recording)return;worker.postMessage({command:'record',buffer:[e.inputBuffer.getChannelData(0),e.inputBuffer.getChannelData(1)]});} 10 | this.configure=function(cfg){for(var prop in cfg){if(cfg.hasOwnProperty(prop)){config[prop]=cfg[prop];}}} 11 | this.record=function(){recording=true;} 12 | this.stop=function(){recording=false;} 13 | this.clear=function(){worker.postMessage({command:'clear'});} 14 | this.getBuffer=function(cb){currCallback=cb||config.callback;worker.postMessage({command:'getBuffer'})} 15 | this.exportWAV=function(cb,type){currCallback=cb||config.callback;type=type||config.type||'audio/wav';if(!currCallback)throw new Error('Callback not set');worker.postMessage({command:'exportWAV',type:type});worker.onmessage=function(e){var blob=e.data;currCallback(blob);}} 16 | this.exportMP3=function(cb){this.exportWAV(function(){});currCallback=cb||config.callback;var encoderWorker=new Worker(config.mp3WorkerPath||mp3WorkerPath);worker.onmessage=function(e){var blob=e.data;var arrayBuffer;var fileReader=new FileReader();fileReader.onload=function(){arrayBuffer=this.result;var buffer=new Uint8Array(arrayBuffer),data=parseWav(buffer);encoderWorker.postMessage({cmd:'init',config:{mode:3,channels:1,samplerate:data.sampleRate,bitrate:data.bitsPerSample}});encoderWorker.postMessage({cmd:'encode',buf:Uint8ArrayToFloat32Array(data.samples)});encoderWorker.onmessage=function(e){if(e.data.cmd=='data'){var url='data:audio/mp3;base64,'+ encode64(e.data.buf);currCallback(url);console.log("Done converting to Mp3");}};};fileReader.readAsArrayBuffer(blob);}} 17 | source.connect(this.node);this.node.connect(this.context.destination);} 18 | function parseWav(wav){function readInt(i,bytes){var ret=0,shft=0;while(bytes){ret+=wav[i]<=0x8000)value|=~0x7FFF;f32Buffer[i]=value/0x8000;} 22 | return f32Buffer;} 23 | function encode64(buffer){var binary='',bytes=new Uint8Array(buffer),len=bytes.byteLength;for(var i=0;i { 21 | return saythat.setDefaults(event.data.uid); 22 | }); 23 | 24 | // When a user uploads a new audio file with the spoken guess for a noun, we 25 | // analyze that audio file (using the Cloud Speech API) and write the 26 | // transcription back into the Firebase Realtime Database as a guessed noun. 27 | exports.analyzeSpeech = functions.storage.object().onChange(async event => { 28 | if (event.data.resourceState != 'exists' || event.data.metageneration != 1) { 29 | // We're only interested in newly-created objects. 30 | return; 31 | } 32 | 33 | const url = event.data.mediaLink; 34 | const filename = event.data.name; 35 | try { 36 | return saythat.analyzeSpeech(url, filename); 37 | } catch (err) { 38 | console.error('Failed to analyze speech. Maybe a permissions issue on the GCS Bucket? ' + err); 39 | } 40 | }); 41 | 42 | // When a new guessed noun is written to the Firebase Realtime Database (either 43 | // from the 'analyzeSpeech' function or directly by the user's app when) we'll 44 | // do the actual scorekeeping in this function. 45 | exports.judgeGuessedNoun = functions.database.ref( 46 | '/users/{userId}/scenes/{scene}/nouns/{noun}').onWrite(async event => { 47 | 48 | // Only respond if the user just freshly guessed this noun. 49 | if (event.data.val() !== "maybe...") { 50 | return; 51 | } 52 | 53 | try { 54 | let noun = event.params.noun; 55 | let guessed_before = event.data.previous.exists(); 56 | await saythat.judgeGuessedNoun(event.params.userId, event.params.scene, noun, 57 | guessed_before); 58 | } catch (err) { 59 | console.error('Error while judging our noun: ' + err); 60 | } 61 | }); 62 | 63 | exports.updateCollectiveScores = functions.database.ref( 64 | '/users/{userId}/scenes/{scene}/score').onWrite(async event => { 65 | 66 | // Only respond if the score has actually changed. 67 | let before = event.data.previous.val() ? event.data.previous.val() : 0; 68 | let after = event.data.current.val() ? event.data.current.val() : 0; 69 | let diff = after - before; 70 | if (diff == 0) return; 71 | 72 | try { 73 | await saythat.updateCollectiveScores(event.params.userId, event.params.scene, diff); 74 | } catch (err) { 75 | console.error('Error while updating collective scores: ' + err); 76 | } 77 | }); 78 | 79 | // When an administrator adds a new noun to the list of nouns, we should 80 | // pre-compute what the translation of that noun is in all supported languages. 81 | exports.nounAdded = functions.database.ref( 82 | '/admin/scenes/{scene}/nouns/en-US/{noun}').onWrite(async event => { 83 | 84 | // Only respond if the entry was just added. 85 | if (!event.data.exists()) { 86 | return; 87 | } 88 | try { 89 | await saythat.nounAdded(event.params.scene, event.params.noun); 90 | } catch (err) { 91 | console.error('Translation error: ' + err); 92 | } 93 | }); 94 | 95 | -------------------------------------------------------------------------------- /public/projector/index.html: -------------------------------------------------------------------------------- 1 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | Say That 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 |
      34 | 35 |
      36 |
      37 |
      38 |
      39 |
      40 |
      41 |
      42 | 43 | 44 |
      45 | 46 |
      47 |
      48 |
      49 | 51 | 52 | 53 |
      54 |
      55 |
      56 |
      57 | 58 |
      59 |
      60 | 0 61 | 62 |
      63 |
      64 | 0 65 | 66 |
      67 |
      68 |
      69 | 70 |
      71 |
      72 |
      73 | 74 | 75 |

      Scene Summary

      76 |
      77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 |
      AnswerGuessesLanguages
      88 |
      89 |
      90 | 91 |
      92 |
      93 | 94 |
      95 |
      96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | -------------------------------------------------------------------------------- /public/scripts/projector.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2017 Google Inc. All Rights Reserved. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | var scene = null; 17 | var summaryPopulated = false; 18 | 19 | var showScore = function() { 20 | console.log(`Scene: ${scene}`); 21 | firebase.database().ref('/total_scores/' + scene).on('value', function(snapshot) { 22 | console.log('got score: ', snapshot.val()) 23 | if (snapshot.val()) { 24 | $('#total-score').text(snapshot.val()); 25 | } 26 | }); 27 | }; 28 | 29 | var showLangs = function() { 30 | firebase.database().ref('/total_langs/' + scene + '/numLanguages').on('value', function(snapshot) { 31 | if (snapshot.val()) { 32 | $('#total-langs').text(snapshot.val()); 33 | } 34 | }); 35 | }; 36 | 37 | var addStreamItem = function(noun, correctness) { 38 | var $streamItem = $([ 39 | '', 40 | noun, 41 | '' 42 | ].join('\n')); 43 | 44 | // Prepend results and only keep 30 of the newest 45 | $('#activity-stream').prepend($streamItem); 46 | if ($("#activity-stream").children().length > 30) { 47 | $("#activity-stream").children().slice(30).remove(); 48 | } 49 | } 50 | 51 | var showActivityStream = function() { 52 | firebase.database().ref('/all_guesses/' +scene).on('child_added', function(snapshot) { 53 | addStreamItem(snapshot.val().original, snapshot.val().correctness); 54 | }); 55 | }; 56 | 57 | var initSummaryDialog = function() { 58 | var dialog = document.querySelector('dialog'); 59 | var showDialogButton = $('#show-dialog'); 60 | if (! dialog.showModal) { 61 | dialogPolyfill.registerDialog(dialog); 62 | } 63 | showDialogButton.click(function() { 64 | populateSummary(); 65 | dialog.showModal(); 66 | firebase.database().ref('/admin/active').set(false); 67 | }); 68 | $('dialog .close').click(function() { 69 | $('table#summary-table tbody').empty(); 70 | summaryPopulated = false; 71 | dialog.close(); 72 | firebase.database().ref('/admin/active').set(true); 73 | }); 74 | }; 75 | 76 | var populateSummary = function() { 77 | if (summaryPopulated) { return; } 78 | summaryPopulated = true; 79 | 80 | firebase.database().ref('/summary/' + scene).once('value').then(function(snapshot) { 81 | var results = snapshot.val(); 82 | for (noun in results) { 83 | if (results.hasOwnProperty(noun)) { 84 | var nounSummary = results[noun]; 85 | var langCount = nounSummary.num_langs || 0; 86 | var nounScore = nounSummary.score || 0; 87 | $row = $('' + noun + '' + 88 | nounScore +'' + 89 | langCount + ''); 90 | $('table#summary-table tbody').append($row); 91 | } 92 | } 93 | }); 94 | } 95 | 96 | var showVideo = function() { 97 | var $video = $([ 98 | '' 101 | ].join('\n')); 102 | $('#activity-stream').empty(); 103 | $('#video-container').empty(); 104 | $('#video-container').append($video); 105 | }; 106 | 107 | var initSceneSelector = function(initialScene) { 108 | firebase.database().ref('/admin/scenes').once('value').then(function(snapshot) { 109 | $('#scene-selector').empty(); 110 | var scenes = snapshot.val(); 111 | for (key in scenes) { 112 | if (scenes.hasOwnProperty(key)) { 113 | var $choice = $(''); 114 | $('#scene-selector').append($choice); 115 | } 116 | } 117 | $('#scene-select-submit').click(function() { 118 | firebase.database().ref('/admin/current_scene').set($('#scene-selector').val()); 119 | }); 120 | }) 121 | }; 122 | 123 | $(document).ready(function() { 124 | firebase.database().ref('/admin/current_scene').on('value', function(snapshot) { 125 | scene = snapshot.val(); 126 | showVideo(); 127 | showScore(); 128 | showLangs(); 129 | showActivityStream(); 130 | initSceneSelector(scene); 131 | initSummaryDialog(); 132 | firebase.database().ref('/admin/active').set(true); 133 | }); 134 | }); 135 | -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 16 | 17 | 18 | 19 | 20 | 21 | Say That 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 |
      32 | 33 |
      34 |
      35 | 36 | 40 |
      41 |
      42 |
      43 | exit_to_app 44 |
      45 |
      46 | 47 |
      48 | 49 |
      50 | 51 |
      52 |
      53 | 58 |
      59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 |
      00
      My ScoreAudience
      69 | 70 | 71 |
      72 |
      73 |
      74 |
      75 |
      76 |
      77 | 78 | 79 |
      80 |
      81 |
      82 | 83 | 87 | 88 | 91 | 92 |
      93 | 94 | 104 | 105 | 106 |
      107 |
      108 |
      109 | 110 |
      111 | 112 | 113 | 114 |
      115 |
      116 |
      117 | 118 |
      119 |
      120 | 121 |
      122 | 123 | 124 | 125 |
      126 |
      127 | 128 | 129 | 130 | 131 | 132 | 133 | 134 | 135 | 136 | 137 | 138 | 139 | 140 | 141 | 142 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Collaborative Crowd Game # 2 | 3 | [SayThat](https://saythat.io) is a game where players watch a video together on a big screen (such as a projector), and speak or type to their phones in their language of choice what they see on the screen. Players score points when they correctly guess a word that the Cloud Video Intelligence API produced when analyzing the video. 4 | 5 | The game was first created for a talk at Google I/O 2017 titled **Supercharging Firebase Apps with Machine Learning and Cloud Function**. You can watch the video [here](https://www.youtube.com/watch?v=RdqV_N0sCpM). 6 | 7 | This game highlights how [Cloud Functions for Firebase](https://firebase.google.com/docs/functions/) can be used to easily add Machine Learning to an app. The code in this repository demonstrates uses of: 8 | 9 | * Cloud Speech API 10 | * Cloud Translate API 11 | 12 | Additionally, to analyze the video and produce the words to be guessed, we propose using the Cloud Video Intelligence API to annotate your videos. 13 | 14 | ### Gameplay Architecture 15 | A game involves the following steps: 16 | 17 | * The administrator prepares a number of videos to show, and populates a list of relevant words to guess in the Firebase Database, using the data layout described below. Specifically, they add nouns under the path `/admin/scenes/{scene}/nouns/en-US/{noun}`. 18 | * The Cloud Function `translateNoun` triggers, to translate the noun into all supported languages. 19 | * The translations are written to `/admin/scene/{scene}/nouns/{language_code}/{noun}`. 20 | * Users go to the game and log in. 21 | * The Cloud Function `setDefaults` triggers to set the user's default language. 22 | * Users speak a word. 23 | * An audio file with the recording is uploaded to Cloud Storage for Firebase. 24 | * The Cloud Function `analyzeSpeech` triggers, which uses the Cloud Speech API to transcribe the spoken word. 25 | * The guessed word (noun) is written back to the Firebase Database, as if it were typed in directly by the user. 26 | * User types a word. 27 | * The guess is written to the Firebase Realtime Database. 28 | * The Cloud Function `judgeGuessedNoun` triggers, which assigns a score (0 or 1) to the guessed noun. 29 | * The score is written back to the Firebase Realtime Database. 30 | * The score is updated. 31 | * The Cloud Function `updateCollectiveScores` triggers, which updates all audience-total scores. 32 | 33 | ### Code layout 34 | This repository follows the common Firebase pattern of storing its static public files in a `public` directory, and storing its server-side Cloud Functions for Firebase code in the `functions` directory. 35 | 36 | The game has two main screens: 37 | 38 | * The game screen is the one which users use to play the game on their phones. It is served from `public/index.html`, executing JavaScript code in `public/scripts/index.js`. 39 | * The projector screen is the one which is displayed on the large screen all users are watching. It is served from `public/projector/index.html`, executing JavaScript code in `public/scripts/projector.js`. 40 | 41 | The videos to play the game are not provided with the source code; the developers will want to source their own. The resulting videos, e.g. `dog.mp4` should be placed in the `public/videos` folder, e.g. as `public/videos/dog.mp4`. 42 | 43 | All the server-side Cloud Functions for Firebase code is written in TypeScript, and is found in the `functions/src` directory. It is unit-tested by the code in the `functions/spec` directory. To compile this code, run: 44 | 45 | ``` 46 | $ npm run build 47 | ``` 48 | 49 | To build and run unit tests, run: 50 | ``` 51 | $ npm test 52 | ``` 53 | 54 | To build, run unit tests, and deploy to the Firebase project with the `prod` alias, run: 55 | ``` 56 | $ npm run deploy-prod 57 | ``` 58 | 59 | ### Data layout 60 | SayThat uses the Firebase Realtime Database to store its data and synchronize it with clients and servers. The minimal data structure is: 61 | ``` 62 | + "admin" 63 | + "active": true // Flag to enable/disable guess-buttons on clients. Start with 'true'. 64 | + "current_scene": ... // The name of the current scene. E.g. "beach". 65 | + "scenes" 66 | + {scene_name} // E.g. "beach". 67 | + "nouns" 68 | + "en-US" // Words added to this list will automatically get translated. 69 | + {some_noun}: {some_noun} // E.g. "sand": "sand". 70 | + {some_other_noun}: {some_other_noun} // E.g. "surf": "surf". 71 | + ... 72 | + {another_scene_name} // E.g. "dog". 73 | + ... 74 | ``` 75 | The remainder of the data structure is generated automatically by the code. 76 | 77 | ### Deploying the code 78 | Begin by creating a project in the [Firebase Console](https://console.firebase.google.com/). Use the Console to pre-fill the Firebase Database with the data structure described above. 79 | 80 | Make sure you have the latest `firebase` command-line tool installed by running: 81 | ``` 82 | $ npm install -g firebase-tools 83 | ``` 84 | 85 | Next, clone this repository to a convenient directory on your machine. Within that... 86 | 87 | * Create a `public/videos` folder. 88 | * Add a few videos to that folder, using names like `beach.mp4` for a video that's associated with the scene `beach`. 89 | 90 | Next, within the project folder, run: 91 | ``` 92 | $ firebase init 93 | ``` 94 | In the wizard, choose to enable the Database, Functions and Hosting. 95 | 96 | We must allow the Cloud Speech API to read objects uploaded to Cloud Storage for Firebase. This is easiest to do from Google's larger [Cloud Console](https://console.cloud.google.com/), which serves both Firebase and the larger Google Cloud Platform. In the Cloud Console... 97 | 98 | * Make sure you select the correct project in the drop-down menu at the top. 99 | * Click the "hamburger menu" (three horizontal stripes) at the left-top. 100 | * Click "Storage" in the menu that pops out. You'll see a list of your Cloud Storage "buckets". 101 | * On the far right of the first bucket, click the three vertical dots that open its overflow menu. 102 | * Click "Edit default object permissions". 103 | * Add an entry that specifies: 104 | * Entity: "User" 105 | * Name: "allUsers" 106 | * Access: "Reader" 107 | * Click "Save". 108 | 109 | The database security rules are currently configured to only allow users with an email address that ends in "@google.com" to access administrative parts of the database. You can change this behavior by modifying **database.rules.json**, and replacing every instance of `/.*@google.com$/` with `your_email@domain.com` or `/.*@your_domain.com$/`. You can learn more about Firebase database rules [here](https://firebase.google.com/docs/database/security/). 110 | 111 | We're now ready to deploy our code, by running: 112 | ``` 113 | $ npm run build 114 | $ npm test 115 | $ firebase deploy 116 | ``` 117 | 118 | If you want to use multiple projects, such as a staging-project and a production-project, we suggest adding a `prod` alias for your production project: 119 | ``` 120 | $ firebase use --add 121 | ``` 122 | 123 | You may now shortcut the build, test and deploy step with a single command: 124 | ``` 125 | $ npm run deploy-prod 126 | ``` 127 | -------------------------------------------------------------------------------- /public/404.html: -------------------------------------------------------------------------------- 1 | 16 | 17 | 18 | 19 | Page Not Found 20 | 85 | 86 | 87 |

      Page Not Found

      88 |

      This specified file was not found on this website. Please check the URL for mistakes and try again.

      89 |

      Why am I seeing this?

      90 |

      This page was generated by the Firebase Command-Line Interface. To modify it, edit the 404.html file in your project's configured public directory.

      91 | 94 | 95 | 96 | -------------------------------------------------------------------------------- /public/stylesheets/custom.css: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2017 Google Inc. All Rights Reserved. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | body { 17 | font-family: "HelveticaNeue-Light", "Helvetica Neue Light", "Helvetica Neue", Helvetica, Arial, "Lucida Grande", sans-serif; 18 | line-height: 1.25; 19 | margin: 0px; 20 | background: #f9f9f9; 21 | } 22 | 23 | header div.mdl-layout__header-row { 24 | background:#fff; 25 | border-bottom: 1px solid #f0f0f0; 26 | color: #000; 27 | } 28 | 29 | div.logo_header { 30 | background: url(/images/logo_header.png) no-repeat; 31 | background-size: 122px 23px; 32 | width: 122px; 33 | height: 23px; 34 | } 35 | 36 | a#sign-out { 37 | color: #bbb; 38 | width: 54px; 39 | max-width: 54px; 40 | text-align: right; 41 | } 42 | a#change-lang { 43 | color: #bbb; 44 | width: 54px; 45 | max-width: 54px; 46 | display:inline-block; 47 | /* Required for long language names for header*/ 48 | text-overflow: ellipsis; 49 | overflow:hidden; 50 | white-space:nowrap; 51 | } 52 | .page-content { 53 | margin: 10px auto 0px; 54 | max-width: 1200px; 55 | } 56 | 57 | #sign-out { 58 | cursor: pointer; 59 | } 60 | 61 | div#activity-stream { 62 | margin: 0px 2px 10px 2px; 63 | max-height: 400px; 64 | } 65 | 66 | span.chip { 67 | font-size: 14px; 68 | display: inline-block; 69 | padding: 8px 16px; 70 | border-radius: 500px; 71 | margin-bottom: 8px; 72 | margin-right: 7px; 73 | background: #E9E9E9; 74 | } 75 | span.chip.true { 76 | display: inline-block; 77 | background-color: #21B2F3; 78 | color: #fff; 79 | opacity: 1.0; 80 | } 81 | 82 | span.chip.false { 83 | display: inline-block; 84 | background-color:#EB9696; 85 | color: #fff; 86 | } 87 | 88 | div#input-container { 89 | text-align: center; 90 | } 91 | div#input-container label.helper { 92 | color: #888; 93 | line-height: 1.5em; 94 | font-size: 0.8em; 95 | display: block; 96 | margin-top: 14px; 97 | text-align: center; 98 | } 99 | 100 | div.clearfix { 101 | clear: both; 102 | } 103 | 104 | div#left_pane { 105 | min-width: 320px; 106 | } 107 | 108 | div#keyboard_input { 109 | max-width: 320px; 110 | } 111 | div#keyboard_input div#noun_guess { 112 | width: 180px; 113 | margin-left: 10px; 114 | } 115 | 116 | div#keyboard_input button { 117 | width: 98px; 118 | padding-left: 4px; 119 | padding-right: 4px; 120 | } 121 | 122 | div.mdl-layout__header-row { 123 | padding-left: 20px; 124 | } 125 | 126 | div#firebaseui-container { 127 | margin-top: 50px; 128 | } 129 | 130 | div.mdl-textfield#noun_guess { 131 | margin-right: 20px; 132 | } 133 | 134 | a#change-lang { 135 | cursor: pointer; 136 | font-weight: 500; 137 | } 138 | 139 | a#change-lang i.material-icons { 140 | width: 18px; 141 | height: 18px; 142 | margin-bottom: 8px; 143 | } 144 | 145 | div.lang_wrapper { 146 | background: #fff; 147 | text-align:center; 148 | color: #222; 149 | margin-top: -10px; 150 | margin-bottom: 20px; 151 | } 152 | div.lang_wrapper div#lang-picker-container { 153 | padding: 10px 0; 154 | height:100vh; 155 | } 156 | 157 | div.lang_wrapper select { 158 | border: 1px solid #ddd; 159 | padding: 7px; 160 | margin-right: 10px; 161 | margin-top: 10px; 162 | } 163 | 164 | div#game-container { 165 | margin-top: 10px; 166 | max-width: 1200px; 167 | justify-content: center; 168 | text-align: center; 169 | } 170 | 171 | table.header-stats { 172 | width: 190px; 173 | margin: 10px auto 0px;; 174 | padding-bottom: 10px; 175 | padding-left: 10px; 176 | border-bottom: 1px solid #eee; 177 | } 178 | 179 | table.header-stats span.mdl-layout-title { 180 | font-size: 14px; 181 | } 182 | table.header-stats div.stat { 183 | display: inline-block; 184 | } 185 | table.header-stats span.title { 186 | display: block; 187 | font-size: 1.0em; 188 | text-align: center; 189 | color: #ccc; 190 | width: 92px; 191 | margin: 4px 8px 0px; 192 | } 193 | 194 | table.header-stats span.score { 195 | display: block; 196 | font-size: 1.6em; 197 | font-weight: 500; 198 | text-align: center; 199 | color: #444; 200 | width: 92px; 201 | margin: 0px 8px; 202 | } 203 | 204 | div#personal_stats { 205 | min-width: 320px; 206 | } 207 | 208 | div#personal_stats table { 209 | width: 100%; 210 | } 211 | 212 | div#personal_stats table.mdl-data-table thead td { 213 | background: #f8f8f8; 214 | height: 28px; 215 | } 216 | 217 | footer.mic { 218 | position: absolute; 219 | bottom: 2%; 220 | left: 0; 221 | width: 100%; 222 | } 223 | 224 | div#listening-indicator { 225 | margin: 0px auto 20px; 226 | max-width: 300px; 227 | text-align:center; 228 | } 229 | 230 | div#listening-indicator span { 231 | font-size: 14px; 232 | margin-bottom: 6px; 233 | display: block; 234 | color: #ccc; 235 | 236 | } 237 | 238 | i.icon-toggle { 239 | font-size: 28px; 240 | opacity: 0.2; 241 | margin-top: 0px; 242 | cursor: pointer; 243 | } 244 | 245 | .firebaseui-idp-text { 246 | color: #757575; 247 | } 248 | 249 | #scene-selector-container { 250 | padding-bottom: 10px; 251 | margin-bottom: 10px; 252 | } 253 | select#scene-selector { 254 | padding: 4px 10px; 255 | background: #fff; 256 | height: 34px; 257 | margin-right: 10px; 258 | border: 1px solid #eee; 259 | font-size: 16px; 260 | width: 200px; 261 | } 262 | select:focus { 263 | outline: none; 264 | } 265 | 266 | #scores-wrapper { 267 | text-align:center; 268 | margin: 0px auto 30px; 269 | width: 280px; 270 | } 271 | 272 | #scores-wrapper div { 273 | width: 40%; 274 | margin-left: 10px; 275 | margin-right: 10px; 276 | } 277 | div.float-left { 278 | float: left; 279 | } 280 | 281 | div.float-right { 282 | float: right; 283 | } 284 | 285 | #scores-wrapper span { 286 | text-align: center; 287 | font-size: 36px; 288 | font-weight: normal; 289 | display: block; 290 | margin-bottom: 10px; 291 | } 292 | label { 293 | display: block; 294 | font-size: 16px; 295 | color: #b0b0b0; 296 | font-weight: 100; 297 | } 298 | 299 | #summary-dialog { 300 | width: 650px; 301 | } 302 | 303 | table#summary-table.mdl-data-table { 304 | border: 0px; 305 | } 306 | table#summary-table.mdl-data-table td { 307 | border-top: 1px solid #f0f0f0; 308 | border-bottom: 0px; 309 | } 310 | #summary-table thead tr th { 311 | background: #f7f7f7; 312 | height: 44px; 313 | } 314 | #summary-table td,th { 315 | min-width: 200px; 316 | font-size: 16px; 317 | color: #555; 318 | } 319 | #summary-table td.noun { 320 | text-align: left; 321 | } 322 | @media only screen and (max-width: 900px) { 323 | @media only screen and (max-width: 840px) { 324 | div#mid-spacer { 325 | display: none; 326 | } 327 | } 328 | } 329 | 330 | @media only screen and (max-width: 480px) { 331 | .page-content { 332 | margin: 10px auto 0px; 333 | } 334 | 335 | div#personal_stats { 336 | margin: 10px auto 0px; 337 | } 338 | } 339 | 340 | @media only screen and (-moz-min-device-pixel-ratio: 1.5), 341 | only screen and (-o-min-device-pixel-ratio: 3/2), 342 | only screen and (-webkit-min-device-pixel-ratio: 1.5), 343 | only screen and (min-devicepixel-ratio: 1.5), 344 | only screen and (min-resolution: 1.5dppx) { 345 | div.logo_header { 346 | background-image: url(/images/logo_header@2x.png); 347 | } 348 | } 349 | -------------------------------------------------------------------------------- /functions/src/saythat.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2017 Google Inc. All Rights Reserved. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | import * as _ from 'lodash'; 17 | import * as speechAPI from '@google-cloud/speech'; 18 | import * as translateAPI from '@google-cloud/translate'; 19 | import * as db from './db'; 20 | import * as ProfanityFilter from 'bad-words'; 21 | 22 | const speech = speechAPI(); 23 | const translate = translateAPI(); 24 | const profanityFilter = new ProfanityFilter({ replaceRegex: /[A-Za-z0-9가-힣_]/g }); 25 | 26 | export function setDefaults(userId: string): Promise { 27 | return db.set(`/users/${userId}`, { 28 | lang: { // Default language to English 29 | name: 'English', 30 | code: 'en-US' 31 | } 32 | }); 33 | } 34 | 35 | const speechFilenameRegex = /(\w*).([a-zA-Z\-]*).(\d*).raw/; 36 | export async function analyzeSpeech(url, filename) { 37 | // Parse the filename into its components, which give us user ID, language, 38 | // and timestamp. 39 | const components = filename.match(speechFilenameRegex); 40 | if (components == null) { 41 | console.error('Failed to parse filename ' + filename); 42 | return; 43 | } 44 | const userId = components[1]; 45 | const languageCode = components[2]; 46 | const timestamp = components[3]; 47 | 48 | // Detect speech in the audio file using the Cloud Speech API. 49 | const request = { 50 | encoding: 'LINEAR16', 51 | languageCode: languageCode, 52 | profanityFilter: true, 53 | }; 54 | const results = await speech.recognize(url, request); 55 | let transcription = results[0]; 56 | const scene = await db.get('/admin/current_scene'); 57 | if (transcription == '') { 58 | console.log('Empty transcription, not written.'); 59 | await markProcessCompleted(userId, scene, timestamp); 60 | return; 61 | } 62 | let nouns = transcription.split(' '); 63 | 64 | // Persist user guesses in the Firebase Realtime Database. 65 | await writeNounsAsGuesses(nouns, userId, scene); 66 | await markProcessCompleted(userId, scene, timestamp); 67 | } 68 | 69 | function writeNounsAsGuesses(nouns, userId, scene): Promise { 70 | let operations = []; 71 | for (let index in nouns) { 72 | let noun = nouns[index].toLowerCase(); 73 | operations.push(db.set(`/users/${userId}/scenes/${scene}/nouns/${noun}`, 'maybe...')); 74 | } 75 | return Promise.all(operations); 76 | } 77 | 78 | function markProcessCompleted(userId, scene, timestamp) { 79 | return db.remove(`/users/${userId}/scenes/${scene}/in_progress/${timestamp}`); 80 | } 81 | 82 | export async function judgeGuessedNoun(userId, scene, noun, guessed_before): Promise { 83 | noun = profanityFilter.clean(noun).toLowerCase(); 84 | 85 | // Determine the user's chosen language. 86 | let lang = await getUserLanguage(userId); 87 | 88 | // Determine if the guessed noun appears in the scene, and its English 89 | // translation. 90 | let english = await getOriginalNoun(noun, scene, lang); 91 | let correct = english !== null ? 'true' : 'false'; 92 | let score_diff = correct === 'true' && !guessed_before ? 1 : 0; 93 | 94 | // Write the score to all parts of the Firebase Realtime Database that need to 95 | // know. 96 | return Promise.all([ 97 | updateAllGuesses(scene, noun, correct, lang, english), 98 | updateCorrectness(userId, scene, noun, correct), 99 | updateScore(userId, scene, score_diff), 100 | updateSummary(scene, english, lang, score_diff), 101 | ]); 102 | } 103 | 104 | function getUserLanguage(userId: string): Promise { 105 | return db.get(`/users/${userId}/lang/code`); 106 | } 107 | 108 | // Returns null if the given noun was not found for the given scene and 109 | // language. 110 | async function getOriginalNoun( 111 | noun: string, scene: string, lang: string): Promise { 112 | 113 | let nouns = await db.get(`/admin/scenes/${scene}/nouns/${lang}`); 114 | if (!_.has(nouns, noun)) { 115 | return null; 116 | } 117 | return nouns[noun]; 118 | } 119 | 120 | function updateAllGuesses( 121 | scene: string, noun: string, correct: string, lang: string, english: string) { 122 | 123 | return db.push(`/all_guesses/${scene}`, { 124 | original: noun, 125 | correctness: correct, 126 | lang: lang, 127 | translated: english, 128 | }); 129 | } 130 | 131 | function updateCorrectness(userId: string, scene: string, noun: string, correct: string) { 132 | return db.set(`/users/${userId}/scenes/${scene}/nouns/${noun}`, correct); 133 | } 134 | 135 | function updateScore(userId: string, scene: string, diff: number): Promise { 136 | if (diff === 0) return; 137 | return db.transaction(`/users/${userId}/scenes/${scene}/score`, 138 | val => val ? val + diff : diff); 139 | } 140 | 141 | function updateSummary(scene: string, english_noun: string, lang: string, score_diff: number) { 142 | if (score_diff <= 0) return; 143 | return db.transaction(`/summary/${scene}/${english_noun}`, val => { 144 | if (val === null) { 145 | val = {}; 146 | } 147 | if (val.langs === undefined || val.langs === null || val.langs === 0) { 148 | val.langs = {}; 149 | } 150 | if (!_.has(val.langs, lang)) { 151 | val.langs[lang] = score_diff; 152 | } else { 153 | val.langs[lang] += score_diff; 154 | } 155 | if (val.langs[lang] === 0) { 156 | delete val.langs[lang]; 157 | } 158 | val.num_langs = _.size(val.langs); 159 | if (val.score === undefined || val.score === null) { 160 | val.score = 0; 161 | } 162 | val.score += score_diff; 163 | return val; 164 | }); 165 | } 166 | 167 | export async function updateCollectiveScores(userId: string, scene: string, diff: number) { 168 | let userLang = await getUserLanguage(userId); 169 | 170 | let operations = []; 171 | operations.push(db.transaction(`/total_scores/${scene}`, val => val + diff)); 172 | operations.push(db.transaction(`/total_langs/${scene}`, val => { 173 | if (val === null) { 174 | val = {}; 175 | } 176 | if (!_.has(val, 'numLanguages')) { 177 | val['numLanguages'] = 0; 178 | } 179 | if (!_.has(val, userLang) || val[userLang] == 0) { 180 | val['numLanguages'] += 1; 181 | val[userLang] = 0; 182 | } 183 | val[userLang] += diff; 184 | if (val[userLang] <= 0) { 185 | val['numLanguages'] -= 1; 186 | val[userLang] = 0; 187 | } 188 | return val; 189 | })); 190 | 191 | await Promise.all(operations); 192 | } 193 | 194 | // There are two standardized ways to represent language codes: a localized 195 | // version, which distinguishes between accents and regions and such (BCP-47) 196 | // and a generic just-the-language code (ISO-639-2). The Speech API uses BCP-47, 197 | // the Translate API uses ISO-639 Alas, they don't match exactly, especially 198 | // around the various Chinese languages, and so we must map them manually. 199 | import bcp47toISO639 from './bcp47iso639'; 200 | export function nounAdded(scene: string, noun: string) { 201 | let operations = []; 202 | _.forEach(bcp47toISO639, (iso639code, bcp47code) => { 203 | if (bcp47code == 'en-US') { 204 | // This is our source language. 205 | return; 206 | } 207 | let options = { 208 | from: 'en', 209 | to: iso639code, 210 | }; 211 | operations.push(translate.translate(noun, options).then(results => { 212 | let translations = results[0]; 213 | let translation = Array.isArray(translations) ? translations[0] : translations; 214 | translation = translation.toLowerCase(); // For cases like German, which capitalizes nouns. 215 | return db.set(`/admin/scenes/${scene}/nouns/${bcp47code}/${translation}`, noun); 216 | })); 217 | operations.push(db.set(`summary/${scene}/${noun}`, { 218 | num_langs: 0, 219 | score: 0 220 | })); 221 | }); 222 | return Promise.all(operations); 223 | } 224 | 225 | -------------------------------------------------------------------------------- /public/scripts/index.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2017 Google Inc. All Rights Reserved. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | // Initialize the FirebaseUI Widget using Firebase. 17 | var ui = new firebaseui.auth.AuthUI(firebase.auth()); 18 | // Keep track of the currently signed in user. 19 | var uid = null; 20 | var lang = 'English'; 21 | var langCode = 'en-US'; 22 | var scene = null; 23 | var micCapable = true; 24 | var gameActive = true; 25 | 26 | // Database references to manage listeners 27 | var userSceneNounsChangedRef = null; 28 | var userSceneNounsAddedRef = null; 29 | var userSceneScoreValueRef = null; 30 | var audienceScoreValueRef = null; 31 | var userSceneNounsProgressAddedRef = null; 32 | var userSceneNounsProgressRemovedRef = null; 33 | 34 | function handleSignedInUser(user) { 35 | uid = user.uid; 36 | $("a#change-lang").show(); 37 | $("#user-signed-out").hide(); 38 | $("#user-signed-in").show(); 39 | $("#sign-out").show(); 40 | $(".header-stats").show(); 41 | $("span.input-toggles").show(); 42 | $("footer.mic").show(); 43 | initLanguages(); 44 | handleLangSelection(); 45 | initDisplay(); 46 | }; 47 | 48 | function handleSignedOutUser() { 49 | ui.start('#firebaseui-container', uiConfig); 50 | 51 | $(".header-stats").hide(); 52 | $("a#change-lang").hide(); 53 | $("#user-signed-out").show(); 54 | $("#user-signed-in").hide(); 55 | $("#sign-out").hide(); 56 | $("footer.mic").hide(); 57 | $("span.input-toggles").hide(); 58 | }; 59 | 60 | function handleLangSelection() { 61 | lang = $("#lang-picker :selected").text(); 62 | langCode = $('#lang-picker').val(); 63 | 64 | if (langCode) { 65 | $('#lang-display').text(lang); 66 | firebase.database().ref('users/' + uid + '/lang').set({ 67 | code: langCode, 68 | name: lang 69 | }); 70 | } 71 | 72 | $('#lang-picker-container').hide(); 73 | $('#input-container').show(); 74 | } 75 | 76 | function uploadSpeechFile(blob) { 77 | $('#start').prop('disabled', false); 78 | if (!gameActive) { 79 | alert('Round over, cannot submit more guesses.'); 80 | return; 81 | } 82 | var timestamp = Date.now(); 83 | var fileName = 'speech/' + uid + '.' + langCode + '.' + timestamp + '.raw'; 84 | var storageRef = firebase.storage().ref().child(fileName); 85 | storageRef.put(blob).then(function(snapshot) { 86 | console.log('uploaded') 87 | firebase.database().ref('/users/' + uid + '/scenes/' + scene + '/in_progress/' + timestamp) 88 | .set(fileName); 89 | }) 90 | } 91 | 92 | function initTalkBtn() { 93 | self.micCapable = navigator.getUserMedia || navigator.webkitGetUserMedia || navigator.mozGetUserMedia; 94 | 95 | if (self.micCapable) { 96 | $('#nomic').hide(); 97 | 98 | var timeoutId = 0; 99 | $('#start').on('mousedown touchstart', function() { 100 | timeoutId = setTimeout(function() { 101 | $('#listening-indicator').show(); 102 | $('#btnText').text('Listening'); 103 | }, 500); 104 | Fr.voice.record(false, function() {}); 105 | }).on('mouseup touchend', function() { 106 | $('#listening-indicator').hide(); 107 | $('#start').prop('disabled', true); 108 | $('#btnText').text('Processing'); 109 | Fr.voice.export(function(f) {uploadSpeechFile(f);}, 'blob'); 110 | Fr.voice.stop(); 111 | clearTimeout(timeoutId); 112 | }); 113 | 114 | $("#enable-mic").hide(); 115 | $("#enable-keyboard").show(); 116 | } else { 117 | toggleMicButton(false); 118 | } 119 | 120 | $("#enable-keyboard").click(function(e){ 121 | toggleMicButton(false) 122 | }); 123 | 124 | $("#enable-mic").click(function(e){ 125 | toggleMicButton(true) 126 | }); 127 | } 128 | 129 | function toggleMicButton(showMic) { 130 | if (showMic) { 131 | $("#mic-container").show(); 132 | $("#keyboard_input").hide(); 133 | $("#enable-mic").hide(); 134 | $("#enable-keyboard").show(); 135 | } else { 136 | $("#mic-container").hide(); 137 | $("#keyboard_input").show(); 138 | $("#enable-keyboard").hide(); 139 | 140 | if(self.micCapable) { 141 | $("#enable-mic").show(); 142 | } else { 143 | $("#enable-mic").hide(); 144 | } 145 | } 146 | } 147 | 148 | function detachListener(ref) { 149 | if (ref != null) { 150 | ref.off(); 151 | } 152 | } 153 | 154 | function showMetrics() { 155 | detachListener(userSceneScoreValueRef); 156 | userSceneScoreValueRef = firebase.database().ref('/users/' + uid + '/scenes/' + scene + '/score'); 157 | userSceneScoreValueRef.on('value', function(snapshot) { 158 | $("#scene_score").html(snapshot.val() != null ? snapshot.val() : 0); 159 | }); 160 | 161 | detachListener(audienceScoreValueRef); 162 | audienceScoreValueRef = firebase.database().ref('/total_scores/' + scene); 163 | audienceScoreValueRef.on('value', function(snapshot) { 164 | $("#audience_score").html(snapshot.val() != null ? snapshot.val() : 0); 165 | }); 166 | } 167 | 168 | function showGuesses() { 169 | detachListener(userSceneNounsAddedRef); 170 | userSceneNounsAddedRef = firebase.database().ref('/users/' + uid + '/scenes/' + scene + '/nouns'); 171 | userSceneNounsAddedRef.on('child_added', function(snapshot) { 172 | var noun = snapshot.key; 173 | var correctness = snapshot.val() 174 | var $pillbox = $("" + noun + ""); 175 | $pillbox.addClass("chip "+ correctness); 176 | $pillbox.attr("id", "guess-" + noun); 177 | $("#activity-stream").prepend($pillbox); 178 | }); 179 | 180 | detachListener(userSceneNounsChangedRef); 181 | userSceneNounsChangedRef = firebase.database().ref('/users/' + uid + '/scenes/' + scene + '/nouns'); 182 | userSceneNounsChangedRef.on('child_changed', function(snapshot) { 183 | var $pillbox = $("#guess-" + snapshot.key) 184 | $pillbox.removeClass(); 185 | $pillbox.addClass("chip " + snapshot.val()); 186 | }); 187 | 188 | detachListener(userSceneNounsProgressAddedRef); 189 | userSceneNounsProgressAddedRef = firebase.database().ref('/users/' + uid + '/scenes/' + scene + '/in_progress'); 190 | userSceneNounsProgressAddedRef.on('child_added', function(snapshot, prevChildKey) { 191 | console.log('child added: ', snapshot.key) 192 | var $loader = $('' + 193 | '' + 195 | ''); 196 | $loader.attr("id", "loader-" + snapshot.key); 197 | $("#activity-stream").prepend($loader); 198 | }); 199 | 200 | detachListener(userSceneNounsProgressRemovedRef); 201 | userSceneNounsProgressRemovedRef = firebase.database().ref('/users/' + uid + '/scenes/' + scene + '/in_progress'); 202 | userSceneNounsProgressRemovedRef.on('child_removed', function(snapshot) { 203 | $('#loader-' + snapshot.key).hide(); 204 | console.log('child removed: ', snapshot.key) 205 | }); 206 | } 207 | 208 | function showToast(message) { 209 | let snackbar = document.querySelector('.mdl-js-snackbar'); 210 | snackbar.MaterialSnackbar.showSnackbar({message: message}); 211 | } 212 | 213 | function initDisplay() { 214 | // Setup listener to show notification when scene changes and remove guesses 215 | firebase.database().ref('/admin/current_scene').on('value', function(snapshot) { 216 | if (scene !== snapshot.val()) { 217 | scene = snapshot.val(); 218 | console.log('Scene changed to: ' + scene); 219 | $("#activity-stream").empty(); 220 | showGuesses(); 221 | showMetrics(); 222 | showToast(`Current scene is "${scene}"`); 223 | } 224 | }); 225 | } 226 | 227 | function addTypedGuess() { 228 | if (!gameActive) { 229 | alert('Round over, cannot submit more guesses.'); 230 | return; 231 | } 232 | // Write the guessed noun (trimmed) to the list of guesses 233 | let noun = $.trim($('#noun').val().toLowerCase()); 234 | 235 | console.log(noun); 236 | $('#noun').val(""); 237 | // Starts with value "maybe...", gets updated to true or false when the guess has been validated. 238 | return firebase.database().ref('/users/' + uid + '/scenes/' + scene + '/nouns/' + noun).set("maybe..."); 239 | } 240 | 241 | function initLanguages() { 242 | $('#lang-display').text(lang); 243 | $("#lang-picker").empty(); 244 | var options = '' 245 | for (var i = 0; i < availableLangs.length; i++){ 246 | var name = availableLangs[i]; 247 | options += '